mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 18:33:22 +00:00
Merge remote-tracking branch 'origin/main' into HEAD
This commit is contained in:
+2
-1
@@ -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 (271)
|
||||
## In this repository (272)
|
||||
|
||||
| Asset | Title | Author | Licence | Source | Used for |
|
||||
|---|---|---|---|---|---|
|
||||
@@ -279,6 +279,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
|
||||
| `crates/sign/tests/data/x509-rsa-sha1.pdf` | One-page PDF with a legacy adbe.x509.rsa_sha1 signature by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl dgst -sha1 -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/pdf.rs): the legacy adbe.x509.rsa_sha1 format |
|
||||
| `assets/icons/monitor.svg` | Lucide icon "monitor" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/monitor.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `vendor/vello_common/assets/probe.png` | vello_common probe reference image (rendered from probe.rgba) | Linebender vello contributors | MIT OR Apache-2.0 | vello_common 0.0.8 (crates.io), vendored in vendor/vello_common; regenerated by its vello_sparse_tests regenerate_probe_reference binary | Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file |
|
||||
|
||||
## Compiled in through dependencies (6)
|
||||
|
||||
|
||||
+33
-22
@@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "0ef505ad4ae53d404d4474d2881f54bc3692739fe9200692fad2b3ace9036a03"
|
||||
sha256 = "393902c1a2e21346b5a328a1a8792bfc361cb0872a465901209e36d78ca45b79"
|
||||
|
||||
# ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences.
|
||||
# ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`).
|
||||
@@ -30,7 +30,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 = "73efe98c3a4190715f3f76d714390396887f45d2092926729832b79a194488c0"
|
||||
sha256 = "d90200f734b322b9987cae89a1e6e7d98c10fd7071047d9d6538d3e58e0312db"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
|
||||
@@ -41,7 +41,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 = "e0959af2ee8e317372d9076e487e41d025015460b99906482efa1a0e528802c3"
|
||||
sha256 = "3b24582f96c26d91afb592a787c20115e5adeacf5672cf2d3fe265ec25e012bf"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/cs.tsv"
|
||||
@@ -52,7 +52,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 = "0a327af10b0bb916ef1b912e6797214bd0b2c18c1e0ef27d9f7a92a3cbeac5b7"
|
||||
sha256 = "d3cd4e5358351adb254f4969a3c2d579c43a9b1c140ee2501a28bf09bd63b677"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/pt-br.tsv"
|
||||
@@ -63,7 +63,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 = "c2cf9490a9f9c828382e2f24df3eaea511b4dfab6543ca00ac61e0014029f051"
|
||||
sha256 = "74c82a97934c5423d1fe6c56a4cc23ed821bb1abacfff258c2a64414aa13fb29"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/es.tsv"
|
||||
@@ -74,7 +74,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 = "fccce321ccb64bdfcb75e1d6d09f661fb6a738b30cc0ec1133134ea91959e3ad"
|
||||
sha256 = "46e7ca20f0a6851c77fa73f3d9401ea9e9141f592d03be906328342924907310"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/fr.tsv"
|
||||
@@ -85,7 +85,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 = "64722973eaf542884ddd170dd9541649c1b1b365ad8c63d0d2864ffbecd20941"
|
||||
sha256 = "5f51fd699f88c01828ce6386ac92814a16c32a1815ad3b2fb745e231376a6db2"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/de.tsv"
|
||||
@@ -96,7 +96,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 = "0475c03f0b2190998e247d6996d5473d3fd85ea6c55e582b7159b42096c63f9e"
|
||||
sha256 = "3a840c78fcf5ea19d0e683c78874ebc4ed46025595c84eb12cea4713f7d986d3"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/te.tsv"
|
||||
@@ -107,7 +107,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 = "6423cc0904fd4e49af25121d9fe4d67d09d684f45acbb8165d4ea7a474a3275c"
|
||||
sha256 = "9e7866f38b811211b5b145052c11018c26d0e0d6b3e5edad93a9c2d7fba008a7"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/it.tsv"
|
||||
@@ -118,7 +118,7 @@ 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 = "588adcef83d5ec8e9a1e1fac093afa6afee30d94ac68e8b428ebd487cdc23c3e"
|
||||
sha256 = "0d46bc90f3fd0faef2542b5958ddb931cfae56bca5b9ce6e69e805dfee50505a"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/pl.tsv"
|
||||
@@ -129,7 +129,7 @@ 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 = "9fda2deb8db4e919199ae032b9a9a220f32896462640578cd155074809e71f0d"
|
||||
sha256 = "bfa4d801835202166874f717d9bda7c14731570bdc63b3e4c58f5dc93be02998"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ru.tsv"
|
||||
@@ -140,7 +140,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 = "b60244d234bcec0602a10a077167861d60c86ab3a364c3dfc4b4e0466dd54872"
|
||||
sha256 = "24092581310895dca1f6ed2d88f582749cc776aedb13ae7d5d4dafd67efc1bad"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/hu.tsv"
|
||||
@@ -151,7 +151,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 = "18416c5dc7b28bfb999e1f8127337d37915c67a91d8b8b3fc52e3286a11205ba"
|
||||
sha256 = "6fb3b98133d9af06d27dce35a1c0f370c6d16702b82598ffaa2909b05e9e9be4"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/bg.tsv"
|
||||
@@ -162,7 +162,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 = "e50991d9d81a9a45e78c719c790029486e4c03045c30b10b4fee349fcc6702d0"
|
||||
sha256 = "784f6dd4534dc842fd18e0c0af790af92e5caf32732d0ef85f88a094f0591a48"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/kk.tsv"
|
||||
@@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "73fb4046cd4fdccd8c1da4abfab90ecd0ca2395fc9a6302dd00dd4f810a14418"
|
||||
sha256 = "2fd1b542c391537423a5b36a0617886fc4ca8abc8aa595cc4bd700eb5600760f"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ja.tsv"
|
||||
@@ -184,7 +184,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 = "e090ca970a518bbf667959181167ff8f8058b78e4239c0c62654c58450738a99"
|
||||
sha256 = "9c3ae4e8692e9715f6a1cfb5eb0e7ef02d03d222833ca9cf11e286f222e8f024"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/uk.tsv"
|
||||
@@ -195,7 +195,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 = "a5e4cbf2fd2b1f840d31482a090a68fe58dbbd03e2187deecd1398d091c9fb8f"
|
||||
sha256 = "6a44916924fd9fed55e97e9e9aed10299bd1c2cdd3213530afc328fcc3382b8a"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ar.tsv"
|
||||
@@ -206,7 +206,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 = "036dfc2952a758352933ec9ab7f1668a699ed108c8902340566f149df4bc0b79"
|
||||
sha256 = "623bf25e7f0dc783e497d2744ac7fa17437ef3cc9c65a25b91c82321700ae037"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/tr.tsv"
|
||||
@@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "d4ac259fc9dad897c9ca8a55eb830d2027f3387eafd641744dd7b9d43a5d1cb4"
|
||||
sha256 = "73b8af9831358bc6ea9124fd7a05b725cf80c89709348ac07680646bf11378d7"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/lt.tsv"
|
||||
@@ -228,7 +228,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "b0e3dacf8ce437663e3cb9946ced5b9f3cde6a62a2bf5ff8dd3d45268b264bf5"
|
||||
sha256 = "b9edcc284143606785999408fb3c2e6ed61bd9d973225b6a37fdbbfbd226093f"
|
||||
|
||||
|
||||
[[asset]]
|
||||
@@ -240,7 +240,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "f9b0694f684bea95e20bd33bef33fedfd92e28c62f2c140193c89e0ed7a18b97"
|
||||
sha256 = "8f4d695f2a20a0a8470181c343b2a832d7cd05a7645aa07fe5e4a94299a84b82"
|
||||
|
||||
[[asset]]
|
||||
path = "assets/icons/accessibility.svg"
|
||||
@@ -3363,4 +3363,15 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "eb1f9d2990ab5e177be78d5d5e6d4c3cbdb0961c64df04bbd34e5d9ad5b42a3d"
|
||||
sha256 = "d5b66bf35341a9ce1d66204a99cfc5679c66c26f7afb0bb394d87335783d229d"
|
||||
|
||||
[[asset]]
|
||||
path = "vendor/vello_common/assets/probe.png"
|
||||
title = "vello_common probe reference image (rendered from probe.rgba)"
|
||||
author = "Linebender vello contributors"
|
||||
source = "vello_common 0.0.8 (crates.io), vendored in vendor/vello_common; regenerated by its vello_sparse_tests regenerate_probe_reference binary"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "vendor/vello_common/LICENSE-MIT"
|
||||
kind = "image"
|
||||
usage = "Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file"
|
||||
sha256 = "78c59a0cfd2c829909d77006f94e34dac1ae5d1f90e7f10f72e84de541fef697"
|
||||
|
||||
Generated
+5
-2
@@ -3884,6 +3884,7 @@ dependencies = [
|
||||
"pdfcraft-engine",
|
||||
"pdfcraft-ui-egui",
|
||||
"pollster 1.0.1",
|
||||
"raw-window-handle",
|
||||
"rustls-native-certs",
|
||||
"serde_json",
|
||||
"uds_windows",
|
||||
@@ -3959,6 +3960,7 @@ dependencies = [
|
||||
"pdfcraft-automation",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-engine",
|
||||
"pdfcraft-platform",
|
||||
"pdfcraft-render",
|
||||
"rustix 1.1.5",
|
||||
"serde_json",
|
||||
@@ -4185,6 +4187,7 @@ version = "0.8.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"futures-lite",
|
||||
"rustix 1.1.5",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
@@ -4192,6 +4195,7 @@ dependencies = [
|
||||
name = "pdfcraft-preflight"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-model",
|
||||
]
|
||||
@@ -4219,6 +4223,7 @@ dependencies = [
|
||||
"pdfcraft-forms",
|
||||
"pdfcraft-model",
|
||||
"pdfcraft-testkit",
|
||||
"pdfcraft-xfa",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
@@ -6882,8 +6887,6 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "vello_common"
|
||||
version = "0.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3361bff7f7d82c0c496b92048db83846691f0e844cc28dee92b1c824291b55ee"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"fearless_simd",
|
||||
|
||||
@@ -112,3 +112,4 @@ hayro-jbig2 = { path = "vendor/hayro-jbig2" }
|
||||
lopdf = { path = "vendor/lopdf" }
|
||||
winit = { path = "vendor/winit" }
|
||||
egui-wgpu = { path = "vendor/egui-wgpu" }
|
||||
vello_common = { path = "vendor/vello_common" }
|
||||
|
||||
@@ -199,7 +199,7 @@ Every page of a combined document renders pixel-identical to its source.
|
||||
|
||||
```sh
|
||||
pdfcraft-cli combine report.pdf appendix.pdf --out combined.pdf
|
||||
pdfcraft-cli extract report.pdf --pages 1,3,5 --out highlights.pdf
|
||||
pdfcraft-cli extract report.pdf --pages 1,3,5-8 --out highlights.pdf
|
||||
pdfcraft-cli split report.pdf --every 10 --out-dir parts/
|
||||
```
|
||||
|
||||
@@ -285,6 +285,7 @@ Every engine feature is reachable without the GUI, through one table of JSON-Sch
|
||||
pdfcraft-cli run text_find doc=1 query=invoice # key=value; values parse as JSON
|
||||
pdfcraft-cli run --script review.json # e.g. comment_add {"type": "highlight", "find": "total due"}
|
||||
pdfcraft-cli run --script steps.json --root ./work # several steps in one session
|
||||
pdfcraft-cli run --script steps.json --report # one JSON document: per step its number, tool, status, result or error
|
||||
```
|
||||
|
||||
With `--root`, every file the script's steps read or write stays in that directory, including the PNG a step saves with `"out"` (the script itself is read from wherever you name it).
|
||||
@@ -452,17 +453,40 @@ logs and crash recovery in a `PdfCraftData` folder next to `pdfcraft.exe`, so no
|
||||
`%APPDATA%`; delete that file to use the normal per-user folders.
|
||||
|
||||
The MSI installs for all users and requires administrator privileges. It adds "Edit with PdfCraft"
|
||||
to the right-click menu of PDF files in File Explorer (on Windows 11, under "Show more options"),
|
||||
without changing which app opens PDFs by default. For unattended deployment without a desktop
|
||||
shortcut, run from an elevated terminal:
|
||||
to the right-click menu of PDF files and "Create PDF with PdfCraft…" to the right-click menu of
|
||||
images (.png .jpg .jpeg .tif .tiff .gif .bmp .jp2 .j2k .jpx) in File Explorer (on Windows 11, under
|
||||
"Show more options"), without changing which app opens them by default. For unattended deployment
|
||||
without a desktop shortcut, run from an elevated terminal:
|
||||
|
||||
```powershell
|
||||
msiexec /i "pdfcraft-<ver>-windows-x64.msi" /qn /norestart INSTALLDESKTOPSHORTCUT=0
|
||||
```
|
||||
|
||||
Add `INSTALLPDFCONTEXTMENU=0` to leave out the right-click menu entry. Pass both on upgrades too,
|
||||
since each install applies its own choices. Use the MSI for your architecture. Per-user
|
||||
installation overrides are not supported.
|
||||
| Property | `=0` leaves out |
|
||||
|---|---|
|
||||
| `INSTALLDESKTOPSHORTCUT` | the all-users desktop shortcut |
|
||||
| `INSTALLPDFCONTEXTMENU` | "Edit with PdfCraft" on PDF files |
|
||||
| `INSTALLIMAGECONTEXTMENU` | "Create PDF with PdfCraft…" on images |
|
||||
|
||||
All three are on for a first install. An upgrade keeps what the installed version has: a shortcut
|
||||
or menu entry that is there stays, and one that is missing (left out at install, or a desktop
|
||||
shortcut someone deleted) stays missing, so you don't need to pass them again. A value on the
|
||||
command line always wins: `=1` puts one back and `=0` removes it. Upgrading from a release that
|
||||
didn't have a menu yet (0.6.0 and earlier for the PDF menu, 0.2.1 and earlier for the image menu)
|
||||
adds it, as a first install would. Use the MSI for your architecture. Per-user installation
|
||||
overrides are not supported.
|
||||
|
||||
**Pin the checksum, not just the version.** Release candidates of a version share its MSI version
|
||||
(MSI versions are numbers only, so 1.2.3-rc.1 and 1.2.3 are both 1.2.3), and the installer lets an
|
||||
MSI of the same version replace the installed one without asking. Two different `0.7.1` MSIs can't
|
||||
be told apart by version, so if your deployment tool checks the file, pin its SHA-256 from
|
||||
`SHA256SUMS.txt` (`Get-FileHash <file> -Algorithm SHA256`).
|
||||
|
||||
**Signing identities.** The Windows MSIs and both executables are signed through Azure Trusted
|
||||
Signing with a certificate issued to `Learning Machines Inc` (Atlanta, Georgia, US). The macOS app
|
||||
and command-line tool are signed with the Developer ID `Learning Machines LLC (DJ6XS33FX8)` and
|
||||
notarized. The MSI's manufacturer, shown as the publisher in Installed apps, is
|
||||
`Learning Machines LLC`.
|
||||
|
||||
### macOS
|
||||
|
||||
|
||||
+33
@@ -2,8 +2,13 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 #2080 candidate: explicit search jumps join per-tab history; rail and control-channel Back/Forward; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M4 #1910: Organize Pages opens on the current page; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 candidate: coordinate control clicks keep press/release together on slow frames and preserve text multi-click selection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4 #1310 #1311 #1312: undo, revert and the next save keep the last saved revision; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4: Extract and Split preserve shared Type 3 glyph resources; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M2: packed TIFF-predictor RGB rows retain their byte boundaries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
@@ -153,13 +158,39 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
## Progress log
|
||||
- **2026-10-13 (G14, Vietnamese localization):** Added Vietnamese (`vi`, Tiếng Việt) as an interface and date language with a 2,591-entry catalog (`plural_none`) covering 100% of the current localizable key union (plus `theme`/`Appearance`). Locale tags `vi` / `vi-VN` / `vi_VN.UTF-8` resolve; calendar months use numeric `Tháng 1`…`12` / `tháng 1`…`12`. Native-speaker community review recorded. The twelve-language localization mean moves from ≈59% to ≈68%; weighted readiness ≈49% → ≈50%. Status stays `partial`: engine/OS details, document contents, filenames, CLI/MCP and external help stay outside the catalog by design.
|
||||
|
||||
- **2026-10-13 (M2, page-label prefixes):** Preserve literal leading and trailing whitespace in page-label prefixes without changing generic metadata normalization. Nine original public-inspection cases cover prefix spaces, encodings and empty-prefix controls. Existing empty-prefix fallback remains a compatibility policy, not a conformance claim. Estimates unchanged.
|
||||
- **2026-10-14 (M14, Italian catalog):** Italian gains the 201 entries the other catalogs had and the code looks up: the sentences the accessibility checker, preflight, signature validation and page-range parser word, certificate and calendar labels, digital-ID dialog messages and five plurals. It now has 2,528 entries. 64 keys other catalogs carry but no code looks up (tooltip texts with a fixed shortcut, older wordings, generic labels only Lithuanian has) were left out. AI-assisted, written from the meaning of the English; native review welcome. Estimates unchanged.
|
||||
- **2026-10-15 (M3, #2166 part 1):** Save asks before writing over a version of the file the document did not come from (something else wrote it since the open or the last save), with Save as… to keep it, and the changed-on-disk notice says what Reload and Save each do; a file that is open already comes to the front instead of opening twice; a running instance's autosaves are locked, so a second instance neither offers nor discards them. Part 2 (every writer checked and atomic) is #2182.
|
||||
- **2026-10-14 (M1, #2166):** No PDF leaves the engine unchecked and no writer truncates: new files from Combine, Extract, Split and Create, signed, timestamped and LTV-embedded files and the OCR batch output reopen with their pages before anyone writes them, as saves already did; the desktop app, the automation tools (with or without `--root`) and the CLI's edit, combine, extract and split all write through the one staging-file writer in `pdfcraft-platform`, so a failed write or `--out` naming the input never leaves a half-written file. Special files such as `/dev/stdout` are still written as given.
|
||||
- **2026-10-14 (M3 #2080 candidate):** Existing per-tab page history gains explicit search jumps, rail buttons and control-channel commands. Continuous scrolling and automatic search refresh stay outside history; page-level restoration and estimates unchanged.
|
||||
|
||||
- **2026-10-13 (M2, page-unit transform precision):** Preserve small transform coefficients in page marks, added content and print output. Regression cases cover accepted page units of 4096 and 75000; existing page-unit interpretation and estimates are unchanged.
|
||||
- **2026-10-14 (M5, PR #1457 branch refresh):** Merged upstream main and reconciled roadmap entries while preserving comment submission shortcuts. Estimates unchanged.
|
||||
|
||||
- **2026-10-12 (M3, page-label navigation):** The page box displays logical labels and keeps the current page when its unchanged value is submitted, including duplicate labels. Editable-control regressions also cover numeric fallback, invalid input, active typing and focused same-document replacement with changed labels or fewer pages. Clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Estimates unchanged.
|
||||
- **2026-10-13 (community, M2/M6, form-heavy documents):** An image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN spot-colour image, a 1-4-bit gray or indexed one) is converted once per possible pixel value instead of once per pixel (vendored hayro-interpret patch 28). A fillable character sheet whose 3304 × 4105 DeviceN background ran a PostScript tint transform per pixel drew each page in 3.8 s and 430 MB, on opening and again after every field was filled; it now draws in 0.11 s and 100 MB, pixel for pixel the same. The inspector places widgets without `/P` in one pass over the pages (a form of 8,000 such fields opened in 1.17 s), and the Fields panel pairs its rows with the form through one map a frame and draws only the rows in view (8,000 fields: about 15× less CPU a frame with the panel open). Regressions fail before each fix.
|
||||
- **2026-10-14 (M9, signature appearance labels):** A visible signature's labels ("Digitally signed by", "Reason: ", …) follow the interface language when Helvetica can draw all of them, and fall back to English as a set otherwise, with a note under the preview; the preview shows the same labels and the signing time. A drawn label the font can't draw is refused naming the label and character, never written as `?`. `sign_document` takes `labels` and `appearance`. Sign, automation and kittest regressions cover German, Chinese and the refusals. Estimates unchanged.
|
||||
- **2026-10-13 (M4, #1910 Organize on the current page):** Opening Organize Pages from the toolbar, tool panel, command or `--organize` selects the page being read and scrolls its cell into view, replacing a stale grid selection that did not include it. One UI regression covers a middle page, the last page and reopening after an earlier selection. Estimates unchanged.
|
||||
- **2026-10-13 (M5, signature pad undo):** The Draw tab of Create signature/initials has an Undo button and Ctrl/Cmd+Z that take back the last stroke (#1963). Pad is UI-only, so no automation tool. Estimates unchanged.
|
||||
- **2026-10-13 (M2, raw crop and page-unit floor):** The renderer applies its existing one-point minimum in physical points for non-nearly-zero raw crop dimensions on scaled pages. Five synthetic regressions cover inspection, crop origins and rotations, reciprocal rounding and bounded raster controls. Existing UserUnit policy, nearly-zero fallback, feature statuses and estimates are unchanged.
|
||||
- **2026-10-13 (M5, text box font size, #1908):** Comment Properties has a Font size control for text boxes, typewriter text and callouts (1-400 pt); it writes `/DA` and the rich-text strings, redraws the appearance and grows the box to fit. `comment_edit` takes `font_size`. Annot, tool and UI regressions. Estimates unchanged.
|
||||
- **2026-10-13 (M3, Single Page edge paging, #1924):** In Single Page view a page taller than the window pans with the wheel, and one more notch (or a fresh trackpad swipe) at the bottom/top edge shows the next page at its top / the previous page at its bottom. A swipe that panned to the edge, momentum included, does not turn. Estimates unchanged.
|
||||
- **2026-10-13 (M3, #1980):** Ctrl+Alt+Q in the digital ID email field no longer requests application exit. Quit requires exact Ctrl+Q/Cmd+Q modifiers and uses the existing unsaved-document confirmation. Synthetic keyboard regressions cover email input and clean/dirty quit requests; estimates unchanged.
|
||||
|
||||
- **2026-10-14 (M4, page transitions read, §12.4.4):** `/Trans` and `/Dur` are read into `DocInfo::transitions` and listed by `doc_info`, the data a full-screen presentation needs to play transitions and auto-advance; playing and editing them stay planned.
|
||||
- **2026-10-14 (M4, page transitions set, §12.4.4):** `page_transition_set` (one undoable edit) writes or removes `/Trans` and `/Dur` on chosen pages with the values checked first, the counterpart of reading them into `DocInfo::transitions`; playing them and the dialog stay planned.
|
||||
- **2026-10-14 (M12, transition geometry):** `pdfcraft_render::transition` gives the picture of any of the twelve page transitions at a moment of its run (which parts of the old and new page to paint, where, how opaque), in page fractions, tested for every style and direction; the full-screen presentation will paint from it once it plays transitions.
|
||||
- **2026-10-14 (M3, Open in Full Screen mode honoured):** a document with `/PageMode /FullScreen` now asks (Full Screen / Not now) before the app enters full screen, as Acrobat does; before, the setting was written and read but never acted on when opening.
|
||||
- **2026-10-14 (M3, control-click candidate):** Coordinate clicks retain hover warmup and pair press/release in one input pass so slow frames do not turn a click into a hold. Text multi-click selection accepts the actual press event when egui has already cleared its origin. Regression cases cover slow-frame button activation, disabled controls and coalesced text selection; estimates unchanged.
|
||||
- **2026-10-14 (community, M7, #128 Hebrew in Add text):** Add text and `page_add_text` draw Hebrew with the craft-fonts Hebr face (Noto Sans Hebrew), through the same shaped path as Arabic and Thai: right-to-left display order with numbers and Latin in place, points and cantillation kept on their letter as one Type 3 glyph, ToUnicode and per-line ActualText so extraction, search and the editor get typing order. Builds without the face refuse Hebrew rather than write `?`. Regular style only. Estimates unchanged.
|
||||
- **2026-10-14 (M9, #1787):** Changes after signing are judged by the role an object held when signed for a page's resources too: a rewritten font, XObject, graphics state or resources dictionary is page content however its new version is labelled, so a `/FT` or `/Subtype /Widget` added by the attacker no longer reads as a permitted form fill. A category dictionary that only gained a name is judged as before.
|
||||
- **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged.
|
||||
- **2026-10-13 (community, M3, #531 touchpad momentum):** On Wayland a two-finger swipe keeps gliding after the fingers lift and slows to a stop, measured from the gesture's last 0.1 s and decaying with a 0.35 s time constant through the #759 1:1 path. Any wheel input, click, key, zoom or edge ends it (fingers resting on the touchpad don't yet: the vendored winit binds no Wayland hold gestures); single-page paging, macOS (which sends its own momentum), the web, and Animation effects Off never glide. Unit tests for the physics and two headless view tests. Estimates unchanged.
|
||||
- **2026-10-13 (M3, native-open admission):** Later prepared replies stay queued while a modal or native picker owns the foreground. Picker completion, cancellation and abandonment wake retained opens. Regressions cover recipient ownership, exactly-once admission, source/history preservation and one-shot repaint requests. Estimates unchanged.
|
||||
- **2026-10-13 (M6, choice indices):** Multi-select choice fields write ascending `/I` option indices while preserving the caller's `/V` order. Synthetic forms and headless engine regressions cover reverse selection and save/reopen. Estimates unchanged.
|
||||
|
||||
- **2026-10-12 (M10, Windows printing without PowerShell, #1107):** craft-print now prints on Windows in-process: printers from EnumPrinters/GetDefaultPrinter, jobs drawn as vectors by Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling), so printing works where App Control for Business or AppLocker run PowerShell in Constrained Language Mode. Portrait and landscape sheets still print as one job (turned with /Rotate); a sheet size the driver lacks is refused rather than shrunk. Pins craft-libs f03ec27 (craft-libs#28); deny.toml allows the storytold/winprint.rs fork and adds a webpki-root-certs licence exception and a RUSTSEC-2024-0370 ignore for winprint's build-dependencies. An earlier craft-print revision was checked in Windows Sandbox under enforced WDAC: printers listed, CLI and desktop jobs spooled, and no powershell.exe in the 4688 audit. The final pinned revision passed craft-libs Windows CI, including a real paused-queue job; that Sandbox run has not been repeated. Estimates unchanged.
|
||||
- **2026-10-12 (M1, #1338):** A full rewrite used to build a new trailer from `/Root`, `/Info`, `/Encrypt` and `/ID`, so accepted unknown entries such as a custom string or `/Version` disappeared, including when an incremental save was forced to rewrite. Those entries are copied, and objects reachable only from them are written and renumbered with the rest of the file. Ordinary incremental saves are unchanged. Estimates unchanged.
|
||||
- **2026-10-12 (M4, #1310, #1311, #1312):** Undo back to the opened file left it marked unsaved, so recovery and close treated unchanged work as dirty. After a save, Revert followed the restored snapshot's older base and the next save dropped the latest saved revision. The last saved file is remembered on its own: undo and redo are clean only when they land on it, Revert opens it, and a later save appends to it. Estimates unchanged.
|
||||
- **2026-10-12 (M13, CLI robustness scans):** `pdfcraft-cli check` visits a directory once even when input roots overlap or directory symlinks lead back into the tree. PDFs reached through file symlinks remain accepted. Three real CLI regressions cover overlapping roots, a directory cycle and directory/file aliases. Estimates unchanged.
|
||||
- **2026-10-12 (M4, Type 3 extraction fidelity):** Extract and Split preserve shared image/resource dictionaries reached through Type 3 glyphs, including nested forms, patterns and soft masks. A bounded object-graph walk protects unknown glyph content; unreadable pages/forms retain their glyph resources, and shared explicit fonts are visited once. Separate page dictionaries still prune unused images. Synthetic save/reopen regressions and an opt-in Poppler pixel oracle cover resource ownership, nested forms, cycles, limits and repeated fonts. Estimates unchanged.
|
||||
@@ -439,6 +470,8 @@ Newest first: the date, what moved, and the new overall estimate.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. Estimates unchanged. |
|
||||
| 2026-10-14 | minor | M7/#128: Hebrew in Add text with the craft-fonts Hebr face (display order, points, ToUnicode, ActualText); estimates unchanged. |
|
||||
| 2026-10-12 | minor | M3/#1364 candidate: absolute local GoToR links require desktop consent or headless allow_absolute; existing relative navigation and validation retained. Estimates unchanged. |
|
||||
| 2026-10-12 | minor | M8: redaction retires inherited `/Resources` so a parent `/Pages` can't keep a removed XObject reachable; only redacted pages that inherit are pinned (one shared reference), unredacted pages are untouched; estimates unchanged |
|
||||
| 2026-10-12 | minor | M7/#1200 candidate: bounded local GoToR opening, root-confined automation, and action-preserving appearance edits; named destinations and window behavior remain partial; estimates unchanged. |
|
||||
|
||||
@@ -19,6 +19,7 @@ pdfcraft-engine = { workspace = true }
|
||||
pdfcraft-audit = { workspace = true }
|
||||
pdfcraft-cos = { workspace = true }
|
||||
pdfcraft-automation = { workspace = true }
|
||||
pdfcraft-platform = { workspace = true }
|
||||
base64 = "0.22"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
|
||||
+115
-13
@@ -55,7 +55,7 @@ pdfcraft-cli edit <in.pdf> --out out.pdf [--rotate 1,3:90] [--delete 2,4] [--m
|
||||
[--insert-blank 1] [--title T] [--author A] [--full]
|
||||
(options run in order; page numbers refer to the document after the earlier options)
|
||||
pdfcraft-cli combine <a.pdf> <b.pdf> … --out combined.pdf
|
||||
pdfcraft-cli extract <in.pdf> --pages 1,3,5 --out out.pdf
|
||||
pdfcraft-cli extract <in.pdf> --pages 1,3,5-8 --out out.pdf (pages and ranges, in the order given)
|
||||
pdfcraft-cli split <in.pdf> (--every N | --before 3,7) [--out-dir DIR]
|
||||
pdfcraft-cli check <files or dirs…> [--timeout 20] [--dpi 36] [--json out.json]
|
||||
pdfcraft-cli audit <file.pdf> [--password PW] find faux redactions (fails when found)
|
||||
@@ -64,7 +64,8 @@ pdfcraft-cli batch <files or dirs...> --steps convert,ocr,optimize --out-dir DIR
|
||||
[--ppi 150] [--quality 60] [--dpi 300] [--language en] [--ocr-all-pages]
|
||||
pdfcraft-cli tools automation tools and their JSON Schemas
|
||||
pdfcraft-cli run <tool> [key=value …] [--root DIR] [--out image.png]
|
||||
pdfcraft-cli run --script steps.json [--root DIR] [{\"tool\": \"doc_open\", \"args\": {…}}, …]
|
||||
pdfcraft-cli run --script steps.json [--root DIR] [--report]
|
||||
[{\"tool\": \"doc_open\", \"args\": {…}}, …]; --report prints one JSON document for the run
|
||||
",
|
||||
mcp_usage!(),
|
||||
"\
|
||||
@@ -256,6 +257,7 @@ const RUN_OPTIONS: &[OptionSpec] = &[
|
||||
OptionSpec { name: "--root", takes_value: true },
|
||||
OptionSpec { name: "--script", takes_value: true },
|
||||
OptionSpec { name: "--out", takes_value: true },
|
||||
OptionSpec { name: "--report", takes_value: false },
|
||||
];
|
||||
const UI_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--control", takes_value: true }, OptionSpec { name: "--out", takes_value: true }];
|
||||
|
||||
@@ -310,6 +312,7 @@ fn info(args: &[String]) -> Result<(), CliError> {
|
||||
"xfa_layout": xfa_layout,
|
||||
"bookmarks": info.outline.len(), "annotations": info.annotations.len(), "fields": info.fields.len(),
|
||||
"links": info.links.len(), "layers": info.layers.len(), "attachments": info.attachments.len(),
|
||||
"transitions": info.transitions.len(),
|
||||
"page_labels": info.pages.iter().take(8).map(|p| p.label.clone()).collect::<Vec<_>>(),
|
||||
"warnings": info.warnings,
|
||||
});
|
||||
@@ -381,9 +384,30 @@ fn text(args: &[String]) -> Result<(), CliError> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 1-based page list ("1,3,5") → 0-based indices.
|
||||
/// The most pages one `A-B` range may name: a bound on what a typed range can allocate.
|
||||
const MAX_RANGE: usize = 1_000_000;
|
||||
|
||||
/// 1-based pages ("1,3,5-8": single pages and inclusive ranges, in the order given) → 0-based
|
||||
/// indices (#1923).
|
||||
fn page_list(s: &str) -> Result<Vec<usize>, String> {
|
||||
s.split(',').map(|p| p.trim().parse::<usize>().ok().filter(|n| *n > 0).map(|n| n - 1).ok_or(format!("bad page number {p:?}"))).collect()
|
||||
let number = |t: &str, item: &str| t.trim().parse::<usize>().ok().filter(|n| *n > 0).ok_or_else(|| format!("bad page number {item:?}"));
|
||||
let mut out = Vec::new();
|
||||
for item in s.split(',').map(str::trim) {
|
||||
match item.split_once('-') {
|
||||
Some((from, to)) => {
|
||||
let (from, to) = (number(from, item)?, number(to, item)?);
|
||||
if to < from {
|
||||
return Err(format!("bad page range {item:?}: it ends before it starts"));
|
||||
}
|
||||
if to - from >= MAX_RANGE {
|
||||
return Err(format!("bad page range {item:?}: more than {MAX_RANGE} pages"));
|
||||
}
|
||||
out.extend(from - 1..to);
|
||||
}
|
||||
None => out.push(number(item, item)? - 1),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The options `edit` understands, for its "unknown option" message.
|
||||
@@ -474,7 +498,16 @@ fn edit(args: &[String]) -> Result<(), CliError> {
|
||||
session.apply(id, e).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let bytes = if full { session.save_full_bytes(id) } else { session.save_bytes(id) }.map_err(|e| e.to_string())?;
|
||||
std::fs::write(out, bytes.as_slice()).map_err(|e| format!("{out}: {e}").into())
|
||||
// As the app's Save: the file must reopen with its pages before it replaces anything (#1698).
|
||||
session.check_save(id, &bytes).map_err(|e| e.to_string())?;
|
||||
write_pdf(Path::new(out), &bytes)
|
||||
}
|
||||
|
||||
/// Write a PDF a command produced, through a staging file beside the target (#2166): a write
|
||||
/// that fails, or `--out` naming the input, never leaves a truncated file where a good one was.
|
||||
fn write_pdf(out: &Path, bytes: &[u8]) -> Result<(), CliError> {
|
||||
use pdfcraft_platform::staging::{StagingName, write_atomically};
|
||||
write_atomically(out, bytes, StagingName::SuffixThenTag).map_err(|e| format!("{}: {e}", out.display()).into())
|
||||
}
|
||||
|
||||
fn file_stem(path: &str) -> String {
|
||||
@@ -490,7 +523,7 @@ fn combine(args: &[String]) -> Result<(), CliError> {
|
||||
}
|
||||
let sources = inputs.iter().map(|p| Ok((file_stem(p), read(p)?))).collect::<Result<Vec<_>, String>>()?;
|
||||
let bytes = pdfcraft_engine::Session::new().combine(&sources).map_err(|e| e.to_string())?;
|
||||
std::fs::write(out, bytes.as_slice()).map_err(|e| format!("{out}: {e}").into())
|
||||
write_pdf(Path::new(out), &bytes)
|
||||
}
|
||||
|
||||
fn extract(args: &[String]) -> Result<(), CliError> {
|
||||
@@ -501,7 +534,7 @@ fn extract(args: &[String]) -> Result<(), CliError> {
|
||||
let mut session = pdfcraft_engine::Session::new();
|
||||
let id = session.open(path, None, read(path)?, flag(args, "--password")).map_err(|e| e.to_string())?;
|
||||
let bytes = session.extract(id, &pages).map_err(|e| e.to_string())?;
|
||||
std::fs::write(out, bytes.as_slice()).map_err(|e| format!("{out}: {e}").into())
|
||||
write_pdf(Path::new(out), &bytes)
|
||||
}
|
||||
|
||||
fn split(args: &[String]) -> Result<(), CliError> {
|
||||
@@ -523,7 +556,7 @@ fn split(args: &[String]) -> Result<(), CliError> {
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("split: --out-dir {}: {e}", dir.display()))?;
|
||||
for (a, b, bytes) in parts {
|
||||
let name = dir.join(if a == b { format!("{stem}-p{a}.pdf") } else { format!("{stem}-p{a}-{b}.pdf") });
|
||||
std::fs::write(&name, bytes.as_slice()).map_err(|e| format!("{}: {e}", name.display()))?;
|
||||
write_pdf(&name, &bytes)?;
|
||||
stdout_line(format_args!("{}", name.display()))?;
|
||||
}
|
||||
Ok(())
|
||||
@@ -544,8 +577,11 @@ fn render(args: &[String]) -> Result<(), CliError> {
|
||||
Some("pam") => None,
|
||||
_ => return Err(format!("render: --out {out}: use a .png, .jpg, .tif or .pam name").into()),
|
||||
};
|
||||
// The app renders a display copy that draws comments missing their /AP streams; the CLI
|
||||
// must agree with it (and with independent viewers), while keeping the file it reads intact.
|
||||
let bytes = read(path)?;
|
||||
let mut r = PageRenderer::new(
|
||||
read(path)?,
|
||||
pdfcraft_engine::display_bytes_for(bytes.clone(), flag(args, "--password")),
|
||||
RenderConfig { password: flag(args, "--password").map(Arc::from), reject_oversize: true, ..Default::default() },
|
||||
);
|
||||
let p = r.render(RenderRequest { page: page_index, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false });
|
||||
@@ -762,22 +798,88 @@ fn print_output(auto: &pdfcraft_automation::Automation, content: Vec<pdfcraft_au
|
||||
let written = auto.write_output(path, &data).map_err(|e| e.to_string())?;
|
||||
stdout_line(format_args!("{}", serde_json::json!({ "image": written.to_string_lossy(), "width": width, "height": height })))?;
|
||||
}
|
||||
None => stdout_line(format_args!(
|
||||
"{}",
|
||||
serde_json::json!({ "image": "png", "width": width, "height": height, "bytes": data.len(), "hint": "pass --out file.png to save it" })
|
||||
))?,
|
||||
None => stdout_line(format_args!("{}", png_summary(&data, width, height)))?,
|
||||
},
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What a PNG result is reported as when it isn't written to a file.
|
||||
fn png_summary(data: &[u8], width: u32, height: u32) -> serde_json::Value {
|
||||
serde_json::json!({ "image": "png", "width": width, "height": height, "bytes": data.len(), "hint": "pass --out file.png to save it" })
|
||||
}
|
||||
|
||||
/// A tool's result as JSON values, for the `--report` of a script. Same content as
|
||||
/// [`print_output`], collected instead of printed.
|
||||
fn output_values(
|
||||
auto: &pdfcraft_automation::Automation,
|
||||
content: Vec<pdfcraft_automation::Content>,
|
||||
out: Option<&str>,
|
||||
) -> Result<Vec<serde_json::Value>, String> {
|
||||
let mut values = Vec::new();
|
||||
for c in content {
|
||||
values.push(match c {
|
||||
pdfcraft_automation::Content::Json(v) => v,
|
||||
pdfcraft_automation::Content::Png { data, width, height } => match out {
|
||||
Some(path) => {
|
||||
let written = auto.write_output(path, &data).map_err(|e| e.to_string())?;
|
||||
serde_json::json!({ "image": written.to_string_lossy(), "width": width, "height": height })
|
||||
}
|
||||
None => png_summary(&data, width, height),
|
||||
},
|
||||
});
|
||||
}
|
||||
Ok(values)
|
||||
}
|
||||
|
||||
/// Run a script and print one JSON document: per step its number, tool and `ok`/`error`/`skipped`
|
||||
/// with the result or the message. The first failing step ends the run, as without `--report`;
|
||||
/// the steps after it are listed as skipped and the exit status is the same.
|
||||
fn run_script_report(auto: &mut pdfcraft_automation::Automation, steps: &[serde_json::Value]) -> Result<(), CliError> {
|
||||
let mut report = Vec::with_capacity(steps.len());
|
||||
let mut failure: Option<String> = None;
|
||||
for (i, step) in steps.iter().enumerate() {
|
||||
let n = i + 1;
|
||||
let tool = step["tool"].as_str();
|
||||
if failure.is_some() {
|
||||
report.push(serde_json::json!({ "step": n, "tool": tool, "status": "skipped" }));
|
||||
continue;
|
||||
}
|
||||
let outcome = match tool {
|
||||
None => Err(format!("step {n}: missing \"tool\"")),
|
||||
Some(t) => auto
|
||||
.call(t, &step["args"])
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|content| output_values(auto, content, step["out"].as_str()))
|
||||
.map_err(|e| format!("step {n} ({t}): {e}")),
|
||||
};
|
||||
match outcome {
|
||||
Ok(result) => report.push(serde_json::json!({ "step": n, "tool": tool, "status": "ok", "result": result })),
|
||||
Err(message) => {
|
||||
report.push(serde_json::json!({ "step": n, "tool": tool, "status": "error", "error": message }));
|
||||
failure = Some(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
let document = serde_json::json!({ "ok": failure.is_none(), "steps": report });
|
||||
stdout_line(format_args!("{}", serde_json::to_string_pretty(&document).unwrap_or_default()))?;
|
||||
failure.map_or(Ok(()), |message| Err(CliError::Message(message)))
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> Result<(), CliError> {
|
||||
validate_options("run", args, RUN_OPTIONS)?;
|
||||
let report = args.iter().any(|a| a == "--report");
|
||||
if report && flag(args, "--script").is_none() {
|
||||
return Err("run: --report needs --script".to_string().into());
|
||||
}
|
||||
let mut auto = automation(args)?;
|
||||
if let Some(script) = flag(args, "--script") {
|
||||
let text = std::fs::read_to_string(script).map_err(|e| format!("{script}: {e}"))?;
|
||||
let steps: Vec<serde_json::Value> = serde_json::from_str(&text).map_err(|e| format!("{script}: {e}"))?;
|
||||
if report {
|
||||
return run_script_report(&mut auto, &steps);
|
||||
}
|
||||
for (i, step) in steps.iter().enumerate() {
|
||||
let tool = step["tool"].as_str().ok_or(format!("step {}: missing \"tool\"", i + 1))?;
|
||||
let content = auto.call(tool, &step["args"]).map_err(|e| format!("step {} ({tool}): {e}", i + 1))?;
|
||||
|
||||
@@ -92,6 +92,20 @@ fn the_documented_options_still_edit_and_save() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `--out` naming the input replaces it through a staging file (#2166): the result is the
|
||||
/// edited, reopenable file, and nothing is left beside it.
|
||||
#[test]
|
||||
fn editing_in_place_leaves_a_valid_file_and_no_staging_file() {
|
||||
let input = tmp("in-place", "doc.pdf");
|
||||
std::fs::write(&input, fixture()).unwrap();
|
||||
let out = Command::new(BIN).args(["edit", input.to_str().unwrap(), "--out", input.to_str().unwrap(), "--rotate", "1:90"]).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
assert_eq!(first_page_rotation(&input), 90);
|
||||
let names: Vec<String> =
|
||||
std::fs::read_dir(input.parent().unwrap()).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
||||
assert_eq!(names, ["doc.pdf"], "no staging file beside the result");
|
||||
}
|
||||
|
||||
mod one_based_arguments {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
//! `extract --pages`: single pages and ranges (#1923), and an out-of-range page named as the
|
||||
//! user typed it (1-based; it was off by one in 0.7.1).
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Output};
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
|
||||
|
||||
/// A PDF with `n` empty 200×300 pt pages.
|
||||
fn fixture(n: usize) -> Vec<u8> {
|
||||
let mut objs: Vec<Vec<u8>> = vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()];
|
||||
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 3 + i)).collect();
|
||||
objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 200 300] >>", kids.join(" ")).into_bytes());
|
||||
for _ in 0..n {
|
||||
objs.push(b"<< /Type /Page /Parent 2 0 R >>".to_vec());
|
||||
}
|
||||
let mut out = b"%PDF-1.7
|
||||
"
|
||||
.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
|
||||
",
|
||||
i + 1
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
out.extend_from_slice(o);
|
||||
out.extend_from_slice(
|
||||
b"
|
||||
endobj
|
||||
",
|
||||
);
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(
|
||||
format!(
|
||||
"xref
|
||||
0 {}
|
||||
0000000000 65535 f
|
||||
",
|
||||
objs.len() + 1
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
for o in offsets {
|
||||
out.extend_from_slice(
|
||||
format!(
|
||||
"{o:010} 00000 n
|
||||
"
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
}
|
||||
out.extend_from_slice(
|
||||
format!(
|
||||
"trailer
|
||||
<< /Size {} /Root 1 0 R >>
|
||||
startxref
|
||||
{xref}
|
||||
%%EOF
|
||||
",
|
||||
objs.len() + 1
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
/// A fresh directory holding `five.pdf` (5 pages).
|
||||
fn workdir(test: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("pdfcraft-cli-extract-{test}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
std::fs::write(d.join("five.pdf"), fixture(5)).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn extract(dir: &std::path::Path, pages: &str) -> Output {
|
||||
let out = dir.join(format!("out-{}.pdf", pages.replace(['-', ','], "_")));
|
||||
Command::new(BIN).args(["extract", dir.join("five.pdf").to_str().unwrap(), "--pages", pages, "--out", out.to_str().unwrap()]).output().unwrap()
|
||||
}
|
||||
|
||||
/// The page count `info` reports for the extracted file.
|
||||
fn pages_of(dir: &std::path::Path, pages: &str) -> usize {
|
||||
let out = dir.join(format!("out-{}.pdf", pages.replace(['-', ','], "_")));
|
||||
let info = Command::new(BIN).args(["info", out.to_str().unwrap()]).output().unwrap();
|
||||
let text = String::from_utf8_lossy(&info.stdout);
|
||||
let n = text.split("\"pages\":").nth(1).and_then(|s| s.trim().split(|c: char| !c.is_ascii_digit()).next()).unwrap_or("");
|
||||
n.parse().unwrap_or_else(|_| panic!("no page count in {text}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pages_and_ranges_are_accepted_in_the_order_given() {
|
||||
let dir = workdir("ranges");
|
||||
for (pages, count) in [("1-3", 3), ("5,1-2", 3), ("2-2", 1), ("1,3,5", 3), (" 4 - 5 , 1 ", 3)] {
|
||||
let out = extract(&dir, pages);
|
||||
assert!(out.status.success(), "{pages:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
assert_eq!(pages_of(&dir, pages), count, "{pages:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_pages_and_ranges_are_named_as_typed() {
|
||||
let dir = workdir("bad");
|
||||
for (pages, what) in [
|
||||
("3-1", "bad page range \"3-1\""),
|
||||
("0", "bad page number \"0\""),
|
||||
("1-x", "bad page number \"1-x\""),
|
||||
("2-", "bad page number \"2-\""),
|
||||
("1-9999999", "bad page range \"1-9999999\""),
|
||||
] {
|
||||
let out = extract(&dir, pages);
|
||||
assert!(!out.status.success(), "{pages:?} succeeded");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(stderr.contains(what), "{pages:?}: {stderr}");
|
||||
}
|
||||
// A page past the end is named as the user counts it (1-based), as a single page and as the
|
||||
// first page a range reaches past the end.
|
||||
for (pages, missing) in [("6", "page 6 does not exist"), ("9", "page 9 does not exist"), ("4-7", "page 6 does not exist")] {
|
||||
let out = extract(&dir, pages);
|
||||
assert!(!out.status.success(), "{pages:?} succeeded");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(stderr.contains(missing), "{pages:?}: {stderr}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! `pdfcraft-cli run --script --report`: one JSON document for the whole run (#1891).
|
||||
|
||||
use std::process::{Command, Output};
|
||||
|
||||
fn run_script(test: &str, steps: &serde_json::Value, extra: &[&str]) -> Output {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-cli-{test}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let script = dir.join("steps.json");
|
||||
std::fs::write(&script, steps.to_string()).unwrap();
|
||||
let out =
|
||||
Command::new(env!("CARGO_BIN_EXE_pdfcraft-cli")).current_dir(&dir).arg("run").arg("--script").arg(&script).args(extra).output().unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
out
|
||||
}
|
||||
|
||||
fn blank() -> serde_json::Value {
|
||||
serde_json::json!({ "tool": "doc_create", "args": { "from": "blank", "width": 100, "height": 100 } })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_successful_run_is_one_document_with_a_result_per_step() {
|
||||
let steps = serde_json::json!([blank(), { "tool": "page_render", "args": { "doc": 1, "page": 1, "dpi": 10 } }]);
|
||||
let o = run_script("report-ok", &steps, &["--report"]);
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
let report: serde_json::Value = serde_json::from_slice(&o.stdout).expect("stdout is one JSON document");
|
||||
assert_eq!(report["ok"], true);
|
||||
let steps = report["steps"].as_array().unwrap();
|
||||
assert_eq!(steps.len(), 2);
|
||||
assert_eq!((steps[0]["step"].clone(), steps[0]["tool"].clone(), steps[0]["status"].clone()), (1.into(), "doc_create".into(), "ok".into()));
|
||||
assert_eq!(steps[1]["result"][0]["image"], "png", "an image with no `out` is summarised: {report}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failing_step_is_named_and_the_rest_are_skipped() {
|
||||
let steps = serde_json::json!([blank(), { "tool": "page_rotate", "args": { "doc": 1, "pages": [999], "degrees": 90 } }, { "tool": "doc_close", "args": { "doc": 1 } }]);
|
||||
let o = run_script("report-fail", &steps, &["--report"]);
|
||||
assert_eq!(o.status.code(), Some(1), "exit status is unchanged");
|
||||
let stderr = String::from_utf8_lossy(&o.stderr);
|
||||
assert!(stderr.contains("step 2 (page_rotate)"), "{stderr}");
|
||||
let report: serde_json::Value = serde_json::from_slice(&o.stdout).expect("stdout is one JSON document");
|
||||
assert_eq!(report["ok"], false);
|
||||
let steps = report["steps"].as_array().unwrap();
|
||||
assert_eq!(steps.iter().map(|s| s["status"].as_str().unwrap()).collect::<Vec<_>>(), ["ok", "error", "skipped"]);
|
||||
assert!(steps[1]["error"].as_str().unwrap().contains("step 2 (page_rotate)"), "{report}");
|
||||
assert_eq!(steps[2]["tool"], "doc_close");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_step_without_a_tool_is_an_error_step() {
|
||||
let o = run_script("report-notool", &serde_json::json!([{ "args": {} }]), &["--report"]);
|
||||
assert_eq!(o.status.code(), Some(1));
|
||||
let report: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap();
|
||||
assert_eq!(report["steps"][0]["status"], "error");
|
||||
assert!(report["steps"][0]["tool"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_the_flag_the_output_is_unchanged() {
|
||||
let o = run_script("report-off", &serde_json::json!([blank()]), &[]);
|
||||
assert!(o.status.success());
|
||||
let stdout = String::from_utf8_lossy(&o.stdout);
|
||||
assert!(!stdout.contains("\"steps\""), "{stdout}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_flag_needs_a_script() {
|
||||
let o = Command::new(env!("CARGO_BIN_EXE_pdfcraft-cli")).args(["run", "doc_create", "--report"]).output().unwrap();
|
||||
assert_eq!(o.status.code(), Some(1));
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains("--report needs --script"));
|
||||
}
|
||||
@@ -36,6 +36,11 @@ fmv-macos-events = "=0.1.0"
|
||||
wayland-client = "0.31"
|
||||
wayland-protocols = { version = "0.32", features = ["client"] }
|
||||
|
||||
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
||||
# Whether the window is on Wayland, where it can't bring itself forward (src/single_instance.rs).
|
||||
# Already in the tree through winit and eframe.
|
||||
raw-window-handle = "0.6"
|
||||
|
||||
[dev-dependencies]
|
||||
# Waits for wgpu's adapter, device and error-scope futures in tests (already in the tree through wgpu).
|
||||
pollster = "1.0"
|
||||
|
||||
@@ -213,9 +213,10 @@ fn main() -> eframe::Result {
|
||||
Err(e) => log::warn!("no log file: {e}"),
|
||||
}
|
||||
}
|
||||
// Windows: a launch that only names files (an Outlook attachment, an Explorer double-click)
|
||||
// hands them to the PdfCraft already running and exits (#282, #317). Any option means the
|
||||
// caller wants this launch's own window.
|
||||
// Windows, Linux and the BSDs: a launch that only names files (an Outlook attachment, an
|
||||
// Explorer double-click, a file manager or `xdg-open`) hands them to the PdfCraft already
|
||||
// running and exits (#282, #317, #1726). Any option means the caller wants this launch's own
|
||||
// window.
|
||||
let may_hand_off = !new_window && !files.is_empty() && options.is_empty() && control_file.is_none() && !create_images;
|
||||
let instance = match single_instance::claim(&single_instance::absolute(&files), may_hand_off) {
|
||||
single_instance::Claim::HandedOff => return Ok(()),
|
||||
@@ -332,6 +333,18 @@ struct Launch {
|
||||
window_controls: Option<pdfcraft_ui_egui::window_controls::Layout>,
|
||||
}
|
||||
|
||||
/// Whether the window is a native Wayland one (not X11 or XWayland).
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn native_wayland(cc: &eframe::CreationContext<'_>) -> bool {
|
||||
use raw_window_handle::{HasDisplayHandle, RawDisplayHandle};
|
||||
cc.display_handle().is_ok_and(|handle| matches!(handle.as_raw(), RawDisplayHandle::Wayland(_)))
|
||||
}
|
||||
|
||||
#[cfg(not(all(unix, not(target_os = "macos"))))]
|
||||
fn native_wayland(_: &eframe::CreationContext<'_>) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn app_creator<'a>(
|
||||
launch: Launch,
|
||||
started: Rc<Cell<bool>>,
|
||||
@@ -368,7 +381,7 @@ fn app_creator<'a>(
|
||||
app.os_events = Some(apple_events.connect(&cc.egui_ctx));
|
||||
}
|
||||
if let Some(server) = instance {
|
||||
app.os_events = Some(server.connect(&cc.egui_ctx));
|
||||
app.os_events = Some(server.connect(&cc.egui_ctx, native_wayland(cc)));
|
||||
}
|
||||
if let Some(file) = &control_file {
|
||||
let client = app.attach_control(&cc.egui_ctx);
|
||||
|
||||
@@ -1,41 +1,50 @@
|
||||
//! One PdfCraft per Windows sign-in: a launch that only names files hands them to the PdfCraft
|
||||
//! that is already running, where they open as tabs, and exits (#282, #317).
|
||||
//! One PdfCraft per Windows sign-in or Linux/BSD display: a launch that only names files hands them
|
||||
//! to the PdfCraft that is already running, where they open as tabs, and exits (#282, #317, #1726).
|
||||
//!
|
||||
//! Outlook attachments, Explorer double-clicks and Open With all start `pdfcraft.exe "<file>"`, so
|
||||
//! until now every file got its own process and window. The first PdfCraft listens on a Unix
|
||||
//! domain socket (Windows 10 1803 and later) in `%LOCALAPPDATA%\PdfCraft`, or in the data folder of
|
||||
//! a portable copy. The socket is named after the Windows session (`instance-Console.sock`), so
|
||||
//! two sign-ins of the same user never open files in each other's windows, and it lives in the
|
||||
//! user's own profile, so other accounts can't create, replace or reach it: nobody else can
|
||||
//! intercept a launch or open files in this user's window. The exchange is one line each way, a
|
||||
//! JSON request naming absolute paths and `ok`, and the running app opens the paths exactly as
|
||||
//! File ▸ Open would.
|
||||
//! Outlook attachments, Explorer double-clicks and Open With all start `pdfcraft.exe "<file>"`, and
|
||||
//! a file manager or `xdg-open` starts `pdfcraft <file>` the same way, so until now every file got
|
||||
//! its own process and window. The first PdfCraft listens on a Unix domain socket. On Windows (10
|
||||
//! 1803 and later) it is in `%LOCALAPPDATA%\PdfCraft`, or in the data folder of a portable copy,
|
||||
//! and is named after the Windows session (`instance-Console.sock`), so two sign-ins of the same
|
||||
//! user never open files in each other's windows. On Linux and the BSDs it is in
|
||||
//! `$XDG_RUNTIME_DIR/pdfcraft` and is named after the display (`instance-wayland-0.sock`), so a
|
||||
//! launch on another display or seat opens its own window there. Either way it lives in a folder
|
||||
//! only the user can reach, so other accounts can't create, replace or reach it: nobody else can
|
||||
//! intercept a launch or open files in this user's window. Inside a Flatpak, `$XDG_RUNTIME_DIR` is
|
||||
//! the app's own and is shared by every run of it, so the handoff works there too. The exchange is
|
||||
//! one line each way, a JSON request naming absolute paths and `ok`, and the running app opens the
|
||||
//! paths exactly as File ▸ Open would.
|
||||
//!
|
||||
//! Every failure falls back to the old behaviour: a launch that can't hand its files over within
|
||||
//! a few seconds opens its own window. `--new-window`, any other option, or no files at all also
|
||||
//! starts a window of its own, as before. Other platforms keep their current behaviour (macOS
|
||||
//! already routes Finder opens to the running app through Apple events).
|
||||
//! starts a window of its own, as before. macOS keeps its current behaviour (it already routes
|
||||
//! Finder opens to the running app through Apple events).
|
||||
//!
|
||||
//! The socket comes from `uds_windows`, already in the lockfile through zbus: `std` has no Unix
|
||||
//! sockets on Windows, and calling Winsock directly would need `unsafe`, which the workspace
|
||||
//! forbids.
|
||||
//! On Windows the socket comes from `uds_windows`, already in the lockfile through zbus: `std` has
|
||||
//! no Unix sockets on Windows, and calling Winsock directly would need `unsafe`, which the
|
||||
//! workspace forbids.
|
||||
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
#[cfg(windows)]
|
||||
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
#[cfg(windows)]
|
||||
use uds_windows::{UnixListener, UnixStream};
|
||||
|
||||
use pdfcraft_ui_egui::{OsEvent, OsEventsFn};
|
||||
|
||||
/// Longest request read: one line of JSON, far longer than any real list of paths.
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
#[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), allow(dead_code))]
|
||||
const MAX_REQUEST: u64 = 1 << 20;
|
||||
|
||||
/// How long either side waits for the other before giving up.
|
||||
#[cfg(windows)]
|
||||
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
|
||||
const TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// What this launch should do.
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
#[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), allow(dead_code))]
|
||||
pub enum Claim {
|
||||
/// The running PdfCraft took the files: exit without a window.
|
||||
HandedOff,
|
||||
@@ -59,13 +68,13 @@ pub struct Server {
|
||||
}
|
||||
|
||||
impl Server {
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
#[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), allow(dead_code))]
|
||||
fn new() -> Self {
|
||||
Self { inbox: Arc::default() }
|
||||
}
|
||||
|
||||
/// Queue files from another launch and wake the UI.
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
#[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), allow(dead_code))]
|
||||
fn deliver(&self, paths: Vec<String>) {
|
||||
let mut inbox = self.inbox.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
inbox.paths.extend(paths);
|
||||
@@ -76,8 +85,10 @@ impl Server {
|
||||
|
||||
/// The queue the UI drains every frame (`PdfCraftApp::os_events`). Files that arrived before
|
||||
/// the window existed are in the first batch. A batch also brings the window forward, as the
|
||||
/// new window it replaces would have been.
|
||||
pub fn connect(&self, ctx: &egui::Context) -> OsEventsFn {
|
||||
/// new window it replaces would have been. A Wayland window can't bring itself forward (winit
|
||||
/// ignores `Focus` there), so on `native_wayland` it asks for the user's attention instead,
|
||||
/// which desktops show by highlighting it in the taskbar.
|
||||
pub fn connect(&self, ctx: &egui::Context, native_wayland: bool) -> OsEventsFn {
|
||||
let wake = ctx.clone();
|
||||
self.inbox.lock().unwrap_or_else(PoisonError::into_inner).wake = Some(Box::new(move || wake.request_repaint()));
|
||||
let inbox = Arc::clone(&self.inbox);
|
||||
@@ -89,13 +100,16 @@ impl Server {
|
||||
}
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
|
||||
if native_wayland {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Informational));
|
||||
}
|
||||
vec![OsEvent::Open(paths)]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The request a launch sends: `{"open":[…]}` and a newline.
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
#[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), allow(dead_code))]
|
||||
pub fn request(paths: &[String]) -> String {
|
||||
let mut line = serde_json::json!({ "open": paths }).to_string();
|
||||
line.push('\n');
|
||||
@@ -104,7 +118,7 @@ pub fn request(paths: &[String]) -> String {
|
||||
|
||||
/// The paths a request names, or `None` when it isn't a request: anything but one JSON object
|
||||
/// whose `open` is a non-empty list of non-empty strings.
|
||||
#[cfg_attr(not(windows), allow(dead_code))]
|
||||
#[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), allow(dead_code))]
|
||||
pub fn parse_request(line: &str) -> Option<Vec<String>> {
|
||||
let value: serde_json::Value = serde_json::from_str(line.trim_end()).ok()?;
|
||||
let open = value.get("open")?.as_array()?;
|
||||
@@ -121,14 +135,14 @@ pub fn absolute(paths: &[String]) -> Vec<String> {
|
||||
/// Decide what this launch does. `files` are absolute ([`absolute`]); `may_hand_off` is false
|
||||
/// when the launch asked for anything besides opening files, so it gets its own window.
|
||||
pub fn claim(files: &[String], may_hand_off: bool) -> Claim {
|
||||
#[cfg(windows)]
|
||||
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
|
||||
{
|
||||
match socket_path() {
|
||||
Some(path) => claim_at(&path, files, may_hand_off),
|
||||
None => Claim::Alone,
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
#[cfg(not(any(windows, all(unix, not(target_os = "macos")))))]
|
||||
{
|
||||
let _ = (files, may_hand_off);
|
||||
Claim::Alone
|
||||
@@ -148,6 +162,28 @@ fn socket_path() -> Option<std::path::PathBuf> {
|
||||
std::env::var_os("LOCALAPPDATA").filter(|v| !v.is_empty()).map(|d| std::path::PathBuf::from(d).join("PdfCraft").join(name))
|
||||
}
|
||||
|
||||
/// The socket in `$XDG_RUNTIME_DIR/pdfcraft`; none when `$XDG_RUNTIME_DIR` is unset. Never `/tmp`
|
||||
/// instead: other accounts can create files there, and so could listen in this user's place.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn socket_path() -> Option<std::path::PathBuf> {
|
||||
let var = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty());
|
||||
let name = display_socket_name(var("WAYLAND_DISPLAY").or_else(|| var("DISPLAY")).as_deref());
|
||||
// The XDG base directory spec makes it the user's own (mode 0700), so the folder inside it is
|
||||
// out of other accounts' reach whatever the umask.
|
||||
var("XDG_RUNTIME_DIR").map(|d| std::path::PathBuf::from(d).join("pdfcraft").join(name))
|
||||
}
|
||||
|
||||
/// The socket's file name for a display (`WAYLAND_DISPLAY`, else `DISPLAY`): a launch opens its
|
||||
/// files on the display it was started on, never in a window on another seat, a nested
|
||||
/// compositor or a remote X display. Letters, digits, `-`, `_` and `.` are kept, and of those the
|
||||
/// last 32, where an absolute `WAYLAND_DISPLAY` (`/run/user/1000/wayland-1`) differs.
|
||||
#[cfg_attr(not(all(unix, not(target_os = "macos"))), allow(dead_code))]
|
||||
fn display_socket_name(display: Option<&str>) -> String {
|
||||
let kept: Vec<char> = display.unwrap_or_default().chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')).collect();
|
||||
let display: String = kept.get(kept.len().saturating_sub(32)..).unwrap_or_default().iter().collect();
|
||||
if display.is_empty() { "instance.sock".to_string() } else { format!("instance-{display}.sock") }
|
||||
}
|
||||
|
||||
/// The socket's file name for a Windows session (`SESSIONNAME`: `Console`, `RDP-Tcp#3`…). The
|
||||
/// same user signed in twice (at the console and over Remote Desktop) gets one socket per session,
|
||||
/// so a file never opens in a window on the other screen. Only letters, digits and `-` are kept.
|
||||
@@ -161,9 +197,9 @@ fn socket_name(session: Option<&str>) -> String {
|
||||
/// files selected in Explorer and opened at once) take turns: the first one is listening before
|
||||
/// the next one looks. Without it a launch could find the socket bound but not yet listening,
|
||||
/// fail to clear it ("Access is denied") and open a window of its own. The lock is on a file
|
||||
/// beside the socket, which is never deleted; Windows releases it when the process ends, however
|
||||
/// it ends. Best effort: without the lock the launch goes on as before.
|
||||
#[cfg(windows)]
|
||||
/// beside the socket, which is never deleted; the system releases it when the process ends,
|
||||
/// however it ends. Best effort: without the lock the launch goes on as before.
|
||||
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
|
||||
fn election(socket: &std::path::Path) -> Option<std::fs::File> {
|
||||
if let Some(dir) = socket.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
@@ -183,9 +219,8 @@ fn election(socket: &std::path::Path) -> Option<std::fs::File> {
|
||||
}
|
||||
|
||||
/// [`claim`] with the socket at `path`.
|
||||
#[cfg(windows)]
|
||||
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
|
||||
fn claim_at(path: &std::path::Path, files: &[String], may_hand_off: bool) -> Claim {
|
||||
use uds_windows::{UnixListener, UnixStream};
|
||||
// Released when this function returns: by then this launch listens, or has handed over.
|
||||
let _election = election(path);
|
||||
// Two launches can start together: the one that loses the race to bind hands over to the
|
||||
@@ -241,8 +276,8 @@ fn claim_at(path: &std::path::Path, files: &[String], may_hand_off: bool) -> Cla
|
||||
}
|
||||
|
||||
/// Send `files` over `stream` and wait for the running app's `ok`.
|
||||
#[cfg(windows)]
|
||||
fn hand_off(stream: &uds_windows::UnixStream, files: &[String]) -> std::io::Result<()> {
|
||||
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
|
||||
fn hand_off(stream: &UnixStream, files: &[String]) -> std::io::Result<()> {
|
||||
use std::io::{BufRead, Read, Write};
|
||||
stream.set_read_timeout(Some(TIMEOUT))?;
|
||||
stream.set_write_timeout(Some(TIMEOUT))?;
|
||||
@@ -254,8 +289,8 @@ fn hand_off(stream: &uds_windows::UnixStream, files: &[String]) -> std::io::Resu
|
||||
}
|
||||
|
||||
/// Accept launches on a background thread for as long as the process runs.
|
||||
#[cfg(windows)]
|
||||
fn serve(listener: uds_windows::UnixListener) -> Claim {
|
||||
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
|
||||
fn serve(listener: UnixListener) -> Claim {
|
||||
let server = Server::new();
|
||||
let inbox = Server { inbox: Arc::clone(&server.inbox) };
|
||||
let started = std::thread::Builder::new().name("single-instance".into()).spawn(move || {
|
||||
@@ -278,8 +313,8 @@ fn serve(listener: uds_windows::UnixListener) -> Claim {
|
||||
}
|
||||
|
||||
/// Read one launch's request, queue its files and confirm.
|
||||
#[cfg(windows)]
|
||||
fn receive(stream: &uds_windows::UnixStream, server: &Server) -> std::io::Result<()> {
|
||||
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
|
||||
fn receive(stream: &UnixStream, server: &Server) -> std::io::Result<()> {
|
||||
use std::io::{BufRead, Read, Write};
|
||||
stream.set_read_timeout(Some(TIMEOUT))?;
|
||||
stream.set_write_timeout(Some(TIMEOUT))?;
|
||||
@@ -338,26 +373,40 @@ mod tests {
|
||||
// Before the window exists: queued, nothing to wake yet.
|
||||
server.deliver(vec!["a.pdf".into()]);
|
||||
let ctx = egui::Context::default();
|
||||
let mut poll = server.connect(&ctx);
|
||||
let mut poll = server.connect(&ctx, false);
|
||||
server.deliver(vec!["b.pdf".into()]);
|
||||
let events = poll();
|
||||
assert!(matches!(events.as_slice(), [OsEvent::Open(p)] if p == &["a.pdf", "b.pdf"]));
|
||||
assert!(poll().is_empty(), "each file is delivered once");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn each_display_gets_its_own_socket_name() {
|
||||
assert_eq!(display_socket_name(Some("wayland-0")), "instance-wayland-0.sock");
|
||||
assert_eq!(display_socket_name(Some(":0")), "instance-0.sock");
|
||||
assert_ne!(display_socket_name(Some(":1.0")), display_socket_name(Some(":10")));
|
||||
assert_ne!(display_socket_name(Some("wayland-0")), display_socket_name(Some("wayland-1")));
|
||||
// An absolute WAYLAND_DISPLAY keeps the end of its path, where nested compositors differ.
|
||||
let nested = display_socket_name(Some("/home/someone/.cache/nested-compositor/run/wayland-1"));
|
||||
assert!(nested.ends_with("wayland-1.sock") && !nested.contains('/'), "{nested}");
|
||||
assert_eq!(display_socket_name(Some("../../x/y")), "instance-....xy.sock", "never a path");
|
||||
assert_eq!(display_socket_name(None), "instance.sock");
|
||||
assert_eq!(display_socket_name(Some("")), "instance.sock");
|
||||
}
|
||||
|
||||
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
|
||||
fn socket(test: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-instance-{}-{test}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
dir.join(socket_name(None))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
|
||||
#[test]
|
||||
fn a_second_launch_hands_its_files_to_the_first() {
|
||||
let path = socket("hand-off");
|
||||
let Claim::Primary(first) = claim_at(&path, &[], true) else { panic!("the first launch listens") };
|
||||
let mut poll = first.connect(&egui::Context::default());
|
||||
let mut poll = first.connect(&egui::Context::default(), false);
|
||||
assert!(matches!(claim_at(&path, &["C:\\a.pdf".into()], true), Claim::HandedOff));
|
||||
assert!(matches!(poll().as_slice(), [OsEvent::Open(p)] if p == &["C:\\a.pdf"]));
|
||||
// A launch with options keeps its own window and doesn't take over the socket.
|
||||
@@ -365,10 +414,10 @@ mod tests {
|
||||
assert!(poll().is_empty());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
|
||||
#[test]
|
||||
fn launches_started_together_share_one_window() {
|
||||
// Several files selected in Explorer and opened with Enter: one process per file, all at once.
|
||||
// Several files selected in Explorer or a file manager and opened at once: one process per file.
|
||||
let path = socket("together");
|
||||
let barrier = Arc::new(std::sync::Barrier::new(8));
|
||||
let launches: Vec<_> = (0..8)
|
||||
@@ -386,12 +435,12 @@ mod tests {
|
||||
assert_eq!(claims.iter().filter(|c| matches!(c, Claim::HandedOff)).count(), 7, "the rest hand over");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
|
||||
#[test]
|
||||
fn a_socket_left_by_a_crashed_instance_is_replaced() {
|
||||
let path = socket("stale");
|
||||
// Bind and drop: the file stays, nobody listens.
|
||||
drop(uds_windows::UnixListener::bind({
|
||||
drop(UnixListener::bind({
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
&path
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
//! vendor/winit patch (12) (vendor/README.md).
|
||||
|
||||
#[test]
|
||||
fn winit_lets_macos_title_bar_drags_reach_the_app() {
|
||||
// On macOS the tab strip is the title bar: a transparent title bar over a full-size content
|
||||
// view. NSView answers YES to `mouseDownCanMoveWindow` for a view that isn't opaque, so AppKit
|
||||
// moved the whole window for a drag that began on a document tab, and tabs could not be
|
||||
// reordered. vendor/winit answers with the window's `movable_by_window_background` (NO unless
|
||||
// asked); the empty strip still moves the window through `ViewportCommand::StartDrag`. A
|
||||
// re-vendored copy without the patch would silently bring the bug back: re-apply the patch, or
|
||||
// drop the copy once winit has the fix.
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let view = std::fs::read_to_string(root.join("vendor/winit/src/platform_impl/macos/view.rs")).unwrap();
|
||||
let (_, method) = view.split_once("#[method(mouseDownCanMoveWindow)]").expect("a title-bar drag moves the window again");
|
||||
let body = method.split_once("\n }\n").map_or(method, |(body, _)| body);
|
||||
assert!(body.contains("movable_by_window_background"), "mouseDownCanMoveWindow no longer follows the window's setting");
|
||||
// The strip still moves the window itself, from its empty background.
|
||||
let chrome = std::fs::read_to_string(root.join("crates/ui-egui/src/chrome.rs")).unwrap();
|
||||
assert!(chrome.contains("ViewportCommand::StartDrag"), "the empty tab strip no longer moves the window");
|
||||
}
|
||||
@@ -132,3 +132,11 @@ display_name = "Jason Brashear"
|
||||
|
||||
[people.alexanderalber]
|
||||
real_name = "Alexander Alber"
|
||||
|
||||
[people.mtlcl410]
|
||||
display_name = "mtlcl410"
|
||||
sync_github_name = false
|
||||
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
sync_github_name = false
|
||||
|
||||
+20
-6
@@ -140,6 +140,22 @@ fn mcids(d: &Dict) -> Vec<i64> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The page's named property lists (`/Resources /Properties`), which a `BDC` can name instead of
|
||||
/// writing its property list inline.
|
||||
fn page_properties(doc: &Document, page: &Dict) -> Dict {
|
||||
let res = page.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
||||
res.get(b"Properties").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().cloned()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The marked-content id a `BDC` opens, whether its property list is inline or named in `props`.
|
||||
fn bdc_mcid(doc: &Document, props: &Dict, op: &pdfcraft_content::Op) -> Option<i64> {
|
||||
match op.operands.get(1)? {
|
||||
Object::Dict(d) => d.get(b"MCID").and_then(Object::as_int),
|
||||
Object::Name(n) => props.get(n).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().and_then(|d| d.get(b"MCID").and_then(Object::as_int))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn page_content(doc: &Document, page: &Dict) -> Vec<u8> {
|
||||
let Some(c) = page.get(b"Contents") else { return Vec::new() };
|
||||
match &*doc.resolve(c) {
|
||||
@@ -170,6 +186,7 @@ fn union(a: Option<[f64; 4]>, b: [f64; 4]) -> [f64; 4] {
|
||||
fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet<i64>) -> Option<[f64; 4]> {
|
||||
let res = page.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
||||
let xobjects = res.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
let props = page_properties(doc, page);
|
||||
let mut ctm = vec![Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])];
|
||||
let mut marks: Vec<bool> = Vec::new();
|
||||
let mut bbox = None;
|
||||
@@ -190,9 +207,7 @@ fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet<i64>) -> Option<[f64;
|
||||
}
|
||||
}
|
||||
b"BMC" => marks.push(false),
|
||||
b"BDC" => marks.push(
|
||||
matches!(op.operands.get(1), Some(Object::Dict(d)) if d.get(b"MCID").and_then(Object::as_int).is_some_and(|n| ids.contains(&n))),
|
||||
),
|
||||
b"BDC" => marks.push(bdc_mcid(doc, &props, &op).is_some_and(|n| ids.contains(&n))),
|
||||
b"EMC" => {
|
||||
marks.pop();
|
||||
}
|
||||
@@ -295,12 +310,11 @@ pub fn mark_decorative(doc: &mut Document, obj: ObjRef) -> Result<(), AltError>
|
||||
let pages = pdfcraft_model::pages(doc);
|
||||
let pg = d.get(b"Pg").and_then(Object::as_ref).or_else(|| elements(doc).into_iter().find(|e| e.object == obj).and_then(|e| e.page));
|
||||
if let Some(page) = pg.and_then(|p| pages.iter().find(|x| x.obj == p)).filter(|_| !ids.is_empty()) {
|
||||
let props = page_properties(doc, &page.dict);
|
||||
let mut ops = pdfcraft_content::parse(&page_content(doc, &page.dict)).ops;
|
||||
let mut changed = false;
|
||||
for op in ops.iter_mut() {
|
||||
let ours = op.op == b"BDC"
|
||||
&& matches!(op.operands.get(1), Some(Object::Dict(m)) if m.get(b"MCID").and_then(Object::as_int).is_some_and(|n| ids.contains(&n)));
|
||||
if ours {
|
||||
if op.op == b"BDC" && bdc_mcid(doc, &props, op).is_some_and(|n| ids.contains(&n)) {
|
||||
*op = pdfcraft_content::Op::new("BMC", vec![Object::name("Artifact")]);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
@@ -624,6 +624,62 @@ fn marking_decorative_survives_a_parent_tree_with_an_unpaired_key() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `figures_doc` with its property lists named in the page resources (one inline, one indirect)
|
||||
/// instead of written inline in the content.
|
||||
fn named_figures_doc() -> Document {
|
||||
pdf(
|
||||
&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /StructParents 0 \
|
||||
/Resources << /XObject << /Im 10 0 R >> /Properties << /P0 << /MCID 0 >> /P1 11 0 R /Other << /Note (kept) >> >> >> >>"
|
||||
.into(),
|
||||
stream(
|
||||
"/Figure /P0 BDC q 40 0 0 20 10 150 cm /Im Do Q EMC \
|
||||
/Photo /P1 BDC 100 100 50 30 re f EMC",
|
||||
),
|
||||
"<< /Type /StructTreeRoot /K 6 0 R /ParentTree 9 0 R /RoleMap << /Photo /Figure >> >>".into(),
|
||||
"<< /S /Document /P 5 0 R /K [7 0 R 8 0 R] >>".into(),
|
||||
"<< /S /Figure /P 6 0 R /Pg 3 0 R /K 0 >>".into(),
|
||||
"<< /S /Photo /P 6 0 R /Pg 3 0 R /Alt (A bar) /K 1 >>".into(),
|
||||
"<< /Nums [0 [7 0 R 8 0 R]] >>".into(),
|
||||
"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\n\u{0}\nendstream"
|
||||
.into(),
|
||||
"<< /MCID 1 >>".into(),
|
||||
],
|
||||
"",
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_property_lists_are_found_and_marked_decorative_like_inline_ones() {
|
||||
let (mut inline, mut named) = (figures_doc(), named_figures_doc());
|
||||
let place = |doc: &Document| figures(doc).into_iter().map(|f| (f.obj, f.page, f.alt, f.bbox)).collect::<Vec<_>>();
|
||||
assert_eq!(place(&named), place(&inline), "both figures, placed where their content is drawn");
|
||||
for doc in [&mut inline, &mut named] {
|
||||
mark_decorative(doc, pdfcraft_cos::ObjRef::new(8, 0)).unwrap();
|
||||
let left = figures(doc);
|
||||
assert_eq!((left.len(), left[0].obj.num, left[0].bbox), (1, 7, Some([10.0, 150.0, 50.0, 170.0])));
|
||||
let pages = pdfcraft_model::pages(doc);
|
||||
let c = doc.resolve(pages[0].dict.get(b"Contents").unwrap());
|
||||
let Object::Stream(c) = &*c else { panic!("one content stream") };
|
||||
let text = String::from_utf8(c.decoded().unwrap()).unwrap();
|
||||
assert!(text.contains("/Artifact BMC") && !text.contains("/P1") && !text.contains("/MCID 1"), "{text}");
|
||||
assert!(text.contains("/Im Do"), "the drawing itself stays: {text}");
|
||||
let nums = doc.get(pdfcraft_cos::ObjRef::new(9, 0)).as_dict().and_then(|d| d.get(b"Nums").cloned());
|
||||
assert_eq!(
|
||||
nums,
|
||||
Some(Object::Array(vec![Object::Int(0), Object::Array(vec![Object::Ref(pdfcraft_cos::ObjRef::new(7, 0)), Object::Null])])),
|
||||
"the parent tree no longer points MCID 1 at the removed figure"
|
||||
);
|
||||
let r = check(doc, &Options { rules: [Rule::FiguresAltText, Rule::TaggedContent].into(), pages: None });
|
||||
assert_eq!(r.result(Rule::TaggedContent).unwrap().status, Status::Passed, "{:?}", r.results);
|
||||
}
|
||||
let props = pdfcraft_model::pages(&named)[0].dict.get(b"Resources").and_then(|r| named.resolve(r).as_dict().cloned());
|
||||
let props = props.and_then(|r| r.get(b"Properties").and_then(|p| named.resolve(p).as_dict().cloned())).unwrap();
|
||||
assert!(props.contains(b"P0") && props.contains(b"P1") && props.contains(b"Other"), "resources are left as they were");
|
||||
}
|
||||
|
||||
fn ref_(n: u32) -> Option<pdfcraft_cos::ObjRef> {
|
||||
Some(pdfcraft_cos::ObjRef::new(n, 0))
|
||||
}
|
||||
|
||||
@@ -1946,6 +1946,47 @@ pub fn set_style(
|
||||
redraw_or_restore(doc, r, before)
|
||||
}
|
||||
|
||||
/// Change the font size of a text box, typewriter text or callout (`/DA` keeps its colour; the
|
||||
/// rich-text strings follow) and redraw it. The box grows to hold the wrapped text at the new
|
||||
/// size and never shrinks, so a box the user drew large stays large. Sizes are 1 to 400 pt.
|
||||
pub fn set_font_size(doc: &mut Document, page: usize, index: usize, size: f64, meta: &Meta) -> Result<(), AnnotError> {
|
||||
let (_, r) = annot_ref(doc, page, index)?;
|
||||
unlocked(doc, r)?;
|
||||
let d = annot_dict(doc, r);
|
||||
if d.name(b"Subtype") != Some(b"FreeText") {
|
||||
return Err(AnnotError::Invalid("only text boxes, typewriter text and callouts have a font size".into()));
|
||||
}
|
||||
if !size.is_finite() || !(1.0..=400.0).contains(&size) {
|
||||
return Err(AnnotError::Invalid("font size must be 1\u{2013}400 pt".into()));
|
||||
}
|
||||
refuse_foreign_text(doc, &d)?;
|
||||
let before = d;
|
||||
doc.update_dict(r, |d| {
|
||||
let (col, _) = appearance::parse_da(d);
|
||||
d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /Helv {} Tf", n(col[0]), n(col[1]), n(col[2]), n(size)).into_bytes()));
|
||||
rich_text(d);
|
||||
touch(d, meta);
|
||||
})?;
|
||||
let text = text_value(doc, &annot_dict(doc, r), b"Contents").unwrap_or_default();
|
||||
let result = match fitted_box(doc, r, &text) {
|
||||
Some(fit) => {
|
||||
let cur = annot_dict(doc, r);
|
||||
let nums: Vec<f64> =
|
||||
cur.get(b"Rect").and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect()).unwrap_or_default();
|
||||
// Keep the top and sides; only the bottom edge moves, and only downwards.
|
||||
match four(&nums).map(normalize) {
|
||||
Some(old) if !cur.contains(b"CL") => apply_text_box(doc, r, [fit[0], fit[1].min(old[1]), fit[2], fit[3]], meta, false),
|
||||
_ => apply_text_box(doc, r, fit, meta, false),
|
||||
}
|
||||
}
|
||||
None => redraw_or_restore(doc, r, before.clone()),
|
||||
};
|
||||
if result.is_err() {
|
||||
doc.set(r, Object::Dict(before));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -2180,6 +2221,8 @@ pub struct Props {
|
||||
pub fillable: bool,
|
||||
/// `/IC`, the interior colour (`None`: no fill).
|
||||
pub fill: Option<Rgb>,
|
||||
/// Text boxes, typewriter text and callouts: the `/DA` font size in points.
|
||||
pub font_size: Option<f64>,
|
||||
}
|
||||
|
||||
/// The current properties of the comment at `(page, index)`.
|
||||
@@ -2205,6 +2248,7 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
|
||||
locked: annotation_flags(doc, d) & FLAG_LOCKED != 0,
|
||||
fillable: fillable(&subtype),
|
||||
fill: fill_of(d),
|
||||
font_size: (subtype == "FreeText").then(|| appearance::parse_da(d).1),
|
||||
subtype,
|
||||
endings: line_endings_of(d),
|
||||
})
|
||||
|
||||
@@ -1273,3 +1273,38 @@ fn cached_appearances_are_reused_while_annotations_are_unchanged() {
|
||||
assert!(!a.is_empty());
|
||||
assert_eq!(a, b, "the reused appearance is byte-identical");
|
||||
}
|
||||
|
||||
/// #1908: a text box's font size changes in `/DA` (colour kept), the rich text follows, the box
|
||||
/// grows to hold the bigger text but never shrinks, and bad sizes and other subtypes are refused.
|
||||
#[test]
|
||||
fn text_box_font_size_changes_the_da_and_grows_the_box() {
|
||||
let mut doc = fixture();
|
||||
let t = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [100.0, 680.0, 220.0, 692.0], font_size: 12.0 }), &meta("t")).unwrap();
|
||||
set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap();
|
||||
set_contents(&mut doc, 0, t, "one two three four five six seven eight nine ten eleven twelve", &meta("")).unwrap();
|
||||
let height = |doc: &Document| {
|
||||
let rect: Vec<f64> = list(doc, 0)[t].get(b"Rect").unwrap().as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect();
|
||||
rect[3] - rect[1]
|
||||
};
|
||||
let before = height(&doc);
|
||||
set_font_size(&mut doc, 0, t, 24.0, &meta("")).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let d = &list(&doc, 0)[t];
|
||||
assert_eq!(text(d, b"DA"), "1 0 0 rg /Helv 24 Tf");
|
||||
assert!(text(d, b"DS").contains("24pt") && text(d, b"RC").contains("font-size:24pt"));
|
||||
assert_eq!(props(&doc, 0, t).unwrap().font_size, Some(24.0));
|
||||
assert!(height(&doc) > before, "the box grows");
|
||||
let mut doc = doc;
|
||||
let grown = height(&doc);
|
||||
set_font_size(&mut doc, 0, t, 6.0, &meta("")).unwrap();
|
||||
assert!(height(&doc) >= grown, "the box never shrinks");
|
||||
for bad in [0.0, 0.5, 401.0, f64::NAN, f64::INFINITY] {
|
||||
assert!(matches!(set_font_size(&mut doc, 0, t, bad, &meta("")), Err(AnnotError::Invalid(_))), "{bad}");
|
||||
}
|
||||
assert_eq!(props(&doc, 0, t).unwrap().font_size, Some(6.0));
|
||||
let note = add_annotation(&mut doc, &new(0, Shape::Note { at: [50.0, 50.0], icon: NoteIcon::Note }), &meta("t"));
|
||||
if let Ok(i) = note {
|
||||
assert!(set_font_size(&mut doc, 0, i, 12.0, &meta("")).is_err());
|
||||
assert_eq!(props(&doc, 0, i).unwrap().font_size, None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,3 +188,19 @@ unapplied with a warning.
|
||||
`doc_batch` runs conversion, OCR and compression over files and optionally recursive
|
||||
folders. The CLI also exposes it as `pdfcraft-cli batch`. See the
|
||||
[batch guide](../../docs/cli-batch.md) for examples, output protection, errors and limits.
|
||||
|
||||
## Viewer navigation history
|
||||
|
||||
A running PdfCraft instance launched with `--control FILE` exposes its per-tab history through
|
||||
`pdfcraft-cli ui --control FILE command id=view.previous_view` and
|
||||
`pdfcraft-cli ui --control FILE command id=view.next_view`. `commands` reports whether each action
|
||||
is enabled for the active document. These are the same actions as the rail buttons, View → Page
|
||||
navigation, and Ctrl+[ / Ctrl+] (⌘[ / ⌘] on macOS).
|
||||
|
||||
Explicit page jumps, including search-result selection and Next/Previous match, enter history
|
||||
when the page changes. Automatic search refresh, continuous scrolling, and within-page scroll
|
||||
movement do not. Existing page-step commands and single-page wheel turns retain their history
|
||||
behavior. Back/Forward
|
||||
returns to page tops, does not restore zoom, does not switch documents, and is not saved across
|
||||
restarts. Standalone engine/MCP sessions have no viewport, so they do not have a separate
|
||||
navigation-history tool; use the opt-in UI control channel to navigate the displayed document.
|
||||
|
||||
@@ -202,6 +202,10 @@ fn review_html(record: &Value) -> Result<String> {
|
||||
Ok(html)
|
||||
}
|
||||
|
||||
/// Tags in one page of `accessibility_tags`, by default and at most.
|
||||
const TAG_PAGE: usize = 200;
|
||||
const MAX_TAG_PAGE: usize = 1000;
|
||||
|
||||
impl Automation {
|
||||
pub(crate) fn accessibility_autotag_apply(&mut self, a: &Args) -> Result<Value> {
|
||||
let id = self.doc(a)?.id;
|
||||
@@ -441,11 +445,39 @@ impl Automation {
|
||||
self.accessibility_figures(a)
|
||||
}
|
||||
|
||||
/// The structure tags, one page at a time (`offset`, `limit`), optionally only some types, pages
|
||||
/// (1-based) or depths. `count` is how many match; `next` is where the following page starts.
|
||||
pub(crate) fn accessibility_tags(&self, a: &Args) -> Result<Value> {
|
||||
let tags: Vec<Value> = self
|
||||
let offset = usize::try_from(a.opt_int("offset")?.unwrap_or(0)).map_err(|_| ToolError::InvalidArgs("offset must be 0 or more".into()))?;
|
||||
let limit = a.opt_int("limit")?.unwrap_or(TAG_PAGE as i64);
|
||||
let limit = usize::try_from(limit)
|
||||
.ok()
|
||||
.filter(|l| (1..=MAX_TAG_PAGE).contains(l))
|
||||
.ok_or_else(|| ToolError::InvalidArgs(format!("limit must be from 1 to {MAX_TAG_PAGE}")))?;
|
||||
let types: Option<Vec<&str>> = a.get("types").map(|_| a.strs("types")).transpose()?;
|
||||
let pages: Option<Vec<usize>> = a
|
||||
.opt_ints("pages")?
|
||||
.map(|p| p.into_iter().map(|n| usize::try_from(n).ok().filter(|n| *n >= 1).map(|n| n - 1)).collect::<Option<Vec<_>>>())
|
||||
.map(|p| p.ok_or_else(|| ToolError::InvalidArgs("pages must be 1-based page numbers".into())))
|
||||
.transpose()?;
|
||||
let max_depth = a
|
||||
.opt_int("max_depth")?
|
||||
.map(|d| usize::try_from(d).map_err(|_| ToolError::InvalidArgs("max_depth must be 0 or more".into())))
|
||||
.transpose()?;
|
||||
let matching: Vec<_> = self
|
||||
.doc(a)?
|
||||
.tags()
|
||||
.into_iter()
|
||||
.filter(|t| types.as_ref().is_none_or(|ty| ty.contains(&t.kind.as_str())))
|
||||
.filter(|t| pages.as_ref().is_none_or(|p| t.page.is_some_and(|page| p.contains(&page))))
|
||||
.filter(|t| max_depth.is_none_or(|d| t.depth <= d))
|
||||
.collect();
|
||||
let count = matching.len();
|
||||
let next = offset.checked_add(limit).filter(|n| *n < count);
|
||||
let tags: Vec<Value> = matching
|
||||
.into_iter()
|
||||
.skip(offset)
|
||||
.take(limit)
|
||||
.map(|t| {
|
||||
json!({
|
||||
"object": t.object.num, "generation": t.object.generation, "type": t.kind,
|
||||
@@ -455,7 +487,7 @@ impl Automation {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "count": tags.len(), "tags": tags }))
|
||||
Ok(json!({ "count": count, "tags": tags, "next": next }))
|
||||
}
|
||||
|
||||
pub(crate) fn accessibility_set_tag_property(&mut self, a: &Args) -> Result<Value> {
|
||||
|
||||
@@ -619,6 +619,12 @@ impl Automation {
|
||||
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(size) = a.opt_num("font_size")? {
|
||||
if !(1.0..=400.0).contains(&size) {
|
||||
return Err(ToolError::InvalidArgs("font_size must be 1–400 pt".into()));
|
||||
}
|
||||
edits.push(Edit::FontSizeAnnotation { page, index, size });
|
||||
}
|
||||
if let Some(r) = a.nums::<4>("rect")? {
|
||||
edits.push(Edit::ResizeAnnotation { page, index, rect: rect_to_user(&info, r) });
|
||||
}
|
||||
@@ -627,7 +633,7 @@ impl Automation {
|
||||
edits.push(Edit::MoveAnnotation { page, index, dx: d[0] - o[0], dy: d[1] - o[1] });
|
||||
}
|
||||
if edits.is_empty() {
|
||||
return Err(ToolError::InvalidArgs("nothing to change: pass contents, color, opacity, width, rect or move".into()));
|
||||
return Err(ToolError::InvalidArgs("nothing to change: pass contents, color, opacity, width, fill, font_size, rect or move".into()));
|
||||
}
|
||||
let edit = if edits.len() == 1 { edits.remove(0) } else { Edit::Batch { label: "Edit comment".into(), edits } };
|
||||
self.apply(a, edit)
|
||||
|
||||
+151
-29
@@ -33,10 +33,11 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_engine::{CombineSource, DocId, Document, Edit, Session, commands};
|
||||
use pdfcraft_platform::staging::{StagingName, create_staging, staging_suffixes};
|
||||
use pdfcraft_platform::staging::{StagingName, staging_suffixes};
|
||||
use pdfcraft_render::{PageRenderer, PageText, RenderConfig, RenderRequest, RequestKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub use pdfcraft_render::Pixels;
|
||||
pub use tools::{ToolDef, tools};
|
||||
|
||||
/// One piece of a tool's result.
|
||||
@@ -140,20 +141,28 @@ impl Automation {
|
||||
self
|
||||
}
|
||||
|
||||
/// The clock and time zone of a host without its own. See [`Session::with_wall_clock`].
|
||||
pub fn with_wall_clock(mut self, clock: fn() -> i64, utc_offset: fn() -> i64) -> Self {
|
||||
self.session = std::mem::take(&mut self.session).with_wall_clock(clock, utc_offset);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn session(&self) -> &Session {
|
||||
&self.session
|
||||
}
|
||||
|
||||
/// Save `bytes` that a caller writes on the session's behalf, such as `pdfcraft-cli run`
|
||||
/// saving a rendered page. With a root, it is confined like a tool's own output (a relative
|
||||
/// path resolves inside it) and written atomically. Without one, the path is written as
|
||||
/// given, so `/dev/stdout` and the like still work. Returns where the file went.
|
||||
/// path resolves inside it). Written atomically either way (#2166), except to something
|
||||
/// that is not a regular file, such as `/dev/stdout` or a pipe, which is written as given.
|
||||
/// Returns where the file went.
|
||||
pub fn write_output(&self, path: &str, bytes: &[u8]) -> Result<PathBuf> {
|
||||
let target = self.resolve(path, true)?;
|
||||
if self.root.is_some() {
|
||||
write_atomic(&target, bytes)?;
|
||||
} else {
|
||||
let special = self.root.is_none() && std::fs::metadata(&target).is_ok_and(|m| !m.is_file() && !m.is_dir());
|
||||
if special {
|
||||
std::fs::write(&target, bytes).map_err(|e| failed(format!("{path}: {e}")))?;
|
||||
} else {
|
||||
write_atomic(&target, bytes)?;
|
||||
}
|
||||
Ok(target)
|
||||
}
|
||||
@@ -353,7 +362,9 @@ impl Automation {
|
||||
self.apply(&a, Edit::DuplicatePages { pages })?
|
||||
}
|
||||
"page_set_box" => self.page_set_box(&a)?,
|
||||
"page_transition_set" => self.page_transition_set(&a)?,
|
||||
"doc_create" => self.doc_create(&a)?,
|
||||
"xfa_to_static" => self.apply(&a, Edit::XfaToStatic)?,
|
||||
"doc_flatten" => {
|
||||
let (comments, fields) = (a.opt_bool("comments")?.unwrap_or(true), a.opt_bool("fields")?.unwrap_or(true));
|
||||
if !comments && !fields {
|
||||
@@ -467,6 +478,15 @@ impl Automation {
|
||||
ImageEdit::Move([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64])
|
||||
}
|
||||
"rotate" => ImageEdit::Rotate(a.opt_int("quarters")?.unwrap_or(1) as i32),
|
||||
"crop" => {
|
||||
let r: Vec<f64> =
|
||||
a.get("rect").and_then(Value::as_array).map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
|
||||
let r =
|
||||
<[f64; 4]>::try_from(r).map_err(|_| ToolError::InvalidArgs("crop needs rect: 4 numbers, the part to keep".into()))?;
|
||||
let info = &self.doc(&a)?.info.pages[page];
|
||||
let (u0, u1) = (info.view_to_user(r[0] as f32, r[1] as f32), info.view_to_user(r[2] as f32, r[3] as f32));
|
||||
ImageEdit::Crop([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64])
|
||||
}
|
||||
"flip_horizontal" => ImageEdit::Flip { horizontal: true },
|
||||
"flip_vertical" => ImageEdit::Flip { horizontal: false },
|
||||
"delete" => ImageEdit::Delete,
|
||||
@@ -821,7 +841,8 @@ impl Automation {
|
||||
.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 }))
|
||||
let pages = &self.doc(a)?.info.pages;
|
||||
Ok(json!({ "bookmarks": nest_rows(&page.rows, pages), "next": page.next, "truncated": page.truncated }))
|
||||
}
|
||||
|
||||
fn doc(&self, a: &Args) -> Result<&Document> {
|
||||
@@ -1205,7 +1226,7 @@ impl Automation {
|
||||
let out = pdfcraft_engine::export::extract_images(&doc.export_source(), &pages, min).map_err(failed)?;
|
||||
let mut files = Vec::new();
|
||||
for (k, img) in out.images.iter().enumerate() {
|
||||
let path = child(&folder, &pdfcraft_engine::export::image_file_name(&stem, img, k + 1));
|
||||
let path = child(&folder, &pdfcraft_engine::export::image_file_name(&stem, img, k + 1, doc.info.pages.len()));
|
||||
write_atomic(&path, &img.data)?;
|
||||
files.push(json!({ "path": path.to_string_lossy(), "page": img.page + 1, "width": img.width, "height": img.height }));
|
||||
}
|
||||
@@ -1287,6 +1308,58 @@ impl Automation {
|
||||
Ok(summary(self.session.get(id).ok_or_else(|| failed("the document vanished"))?))
|
||||
}
|
||||
|
||||
fn page_transition_set(&mut self, a: &Args) -> Result<Value> {
|
||||
use pdfcraft_engine::{Transition, TransitionSpec};
|
||||
let n = self.doc(a)?.info.pages.len();
|
||||
let pages = match a.opt_ints("pages")? {
|
||||
Some(_) => self.pages(a, "pages")?,
|
||||
None => (0..n).collect(),
|
||||
};
|
||||
let transition = match a.opt_str("style")? {
|
||||
None | Some("none") => None,
|
||||
Some(style) => {
|
||||
let mut t = Transition::new(style);
|
||||
if let Some(d) = a.opt_num("duration")? {
|
||||
t.duration = d as f32;
|
||||
}
|
||||
if let Some(dm) = a.opt_str("dimension")? {
|
||||
t.horizontal = match dm {
|
||||
"horizontal" => true,
|
||||
"vertical" => false,
|
||||
other => return Err(ToolError::InvalidArgs(format!("dimension must be horizontal or vertical, not {other:?}"))),
|
||||
};
|
||||
}
|
||||
if let Some(m) = a.opt_str("motion")? {
|
||||
t.inward = match m {
|
||||
"inward" => true,
|
||||
"outward" => false,
|
||||
other => return Err(ToolError::InvalidArgs(format!("motion must be inward or outward, not {other:?}"))),
|
||||
};
|
||||
}
|
||||
match a.get("direction") {
|
||||
None => {}
|
||||
Some(Value::String(s)) if s == "none" => t.direction = None,
|
||||
Some(v) => {
|
||||
t.direction = Some(
|
||||
v.as_u64()
|
||||
.and_then(|d| u16::try_from(d).ok())
|
||||
.ok_or_else(|| ToolError::InvalidArgs("direction must be 0, 90, 180, 270, 315 or \"none\"".into()))?,
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(s) = a.opt_num("scale")? {
|
||||
t.scale = s as f32;
|
||||
}
|
||||
if let Some(b) = a.opt_bool("opaque")? {
|
||||
t.opaque = b;
|
||||
}
|
||||
Some(t)
|
||||
}
|
||||
};
|
||||
let display = a.opt_num("display")?.map(|s| s as f32);
|
||||
self.apply(a, Edit::SetTransition { pages, spec: TransitionSpec { transition, display } })
|
||||
}
|
||||
|
||||
fn page_set_box(&mut self, a: &Args) -> Result<Value> {
|
||||
use pdfcraft_engine::{BoxSpec, PageBox};
|
||||
let doc = self.doc(a)?;
|
||||
@@ -1711,6 +1784,27 @@ impl Automation {
|
||||
Ok(Content::Png { data, width: out.width, height: out.height })
|
||||
}
|
||||
|
||||
/// A page as RGBA pixels, without encoding a PNG. `page` is 1-based and `scale` is pixels
|
||||
/// per point (1.0 = 72 dpi). For an embedder that paints the result straight onto a canvas:
|
||||
/// the `page_render` tool only returns PNG, and encoding one to decode it again is wasted
|
||||
/// work on every scroll. It uses the same renderer, and the same per-document cache, as
|
||||
/// `page_render`.
|
||||
pub fn render_pixels(&mut self, doc: u64, page: usize, scale: f32) -> Result<(u32, u32, pdfcraft_render::Pixels)> {
|
||||
if !(scale.is_finite() && scale > 0.0 && scale <= (MAX_DPI / 72.0) as f32) {
|
||||
return Err(ToolError::InvalidArgs(format!("scale must be between 0 and {}", MAX_DPI / 72.0)));
|
||||
}
|
||||
let id = DocId(doc);
|
||||
let n = self.session.get(id).ok_or_else(|| failed(format!("no open document with id {doc} (see doc_list)")))?.info.pages.len();
|
||||
if page < 1 || page > n {
|
||||
return Err(ToolError::InvalidArgs(format!("page {page} is out of range: the document has {n} pages")));
|
||||
}
|
||||
let out = self.renderer(id)?.render(RenderRequest { page: page - 1, kind: RequestKind::Pixels, tile: None, scale, tag: 0, preview: false });
|
||||
if let Some(e) = out.error {
|
||||
return Err(failed(format!("page {page} could not be rendered: {e}")));
|
||||
}
|
||||
Ok((out.width, out.height, out.rgba))
|
||||
}
|
||||
|
||||
fn page(&self, a: &Args) -> Result<usize> {
|
||||
let n = self.doc(a)?.info.pages.len();
|
||||
let p = a.int("page")?;
|
||||
@@ -2107,7 +2201,7 @@ impl Args<'_> {
|
||||
|
||||
/// 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> {
|
||||
fn nest_rows(rows: &[pdfcraft_engine::BookmarkRow], pages: &[pdfcraft_render::PageInfo]) -> 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();
|
||||
@@ -2117,7 +2211,10 @@ fn nest_rows(rows: &[pdfcraft_engine::BookmarkRow]) -> Vec<Value> {
|
||||
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 });
|
||||
let mut fields = json!({ "path": path, "title": row.title, "page": row.page.map(|p| p.saturating_add(1)), "open": row.open });
|
||||
if let Some(destination) = row.page.and_then(|p| pages.get(p)).and_then(|p| destination(&row.view, p)) {
|
||||
fields["destination"] = destination;
|
||||
}
|
||||
open.push((depth, fields, Vec::new()));
|
||||
}
|
||||
while !open.is_empty() {
|
||||
@@ -2126,6 +2223,36 @@ fn nest_rows(rows: &[pdfcraft_engine::BookmarkRow]) -> Vec<Value> {
|
||||
roots
|
||||
}
|
||||
|
||||
/// Where on its page a bookmark or link goes, in points from the top-left of the displayed page
|
||||
/// like every `rect`: the destination type (`XYZ`, `Fit`, `FitH`, `FitV`, `FitR`) and its
|
||||
/// position, `null` where the destination leaves it unspecified (`/XYZ null null null` keeps the
|
||||
/// reader's position and zoom). `None` for a destination without a type.
|
||||
pub(crate) fn destination(view: &pdfcraft_render::DestView, page: &pdfcraft_render::PageInfo) -> Option<Value> {
|
||||
use pdfcraft_render::DestView;
|
||||
let points = |left: Option<f32>, top: Option<f32>| {
|
||||
let [x, y] = page.dest_fraction(left, top, 0);
|
||||
let round = |v: f32| f64::from((v * 100.0).round() / 100.0);
|
||||
(x.map(|x| round(x * page.width)), y.map(|y| round(y * page.height)))
|
||||
};
|
||||
Some(match view {
|
||||
DestView::Top => return None,
|
||||
DestView::Xyz { left, top, zoom } => {
|
||||
let (x, y) = points(*left, *top);
|
||||
json!({ "fit": "XYZ", "left": x, "top": y, "zoom": zoom.map(f64::from) })
|
||||
}
|
||||
DestView::Fit => json!({ "fit": "Fit" }),
|
||||
DestView::FitH { top } => {
|
||||
let (x, y) = points(None, *top);
|
||||
json!({ "fit": "FitH", "left": x, "top": y })
|
||||
}
|
||||
DestView::FitV { left } => {
|
||||
let (x, y) = points(*left, None);
|
||||
json!({ "fit": "FitV", "left": x, "top": y })
|
||||
}
|
||||
DestView::FitR { rect } => json!({ "fit": "FitR", "rect": comments::rect_to_view(page, *rect) }),
|
||||
})
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
@@ -2248,6 +2375,13 @@ fn info(d: &Document) -> Value {
|
||||
},
|
||||
})).collect::<Vec<_>>(),
|
||||
"layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible })).collect::<Vec<_>>(),
|
||||
// Full-screen presentation settings a page carries (ISO 32000-2 §12.4.4): only pages with
|
||||
// a `/Trans` or `/Dur` appear; `style` is null for a display time alone.
|
||||
"transitions": i.transitions.iter().map(|t| json!({
|
||||
"page": page1(t.page), "style": t.style.map(pdfcraft_render::TransitionStyle::name), "duration": t.duration,
|
||||
"horizontal": t.horizontal, "inward": t.inward, "direction": t.direction, "scale": t.scale, "opaque": t.opaque,
|
||||
"display": t.display,
|
||||
})).collect::<Vec<_>>(),
|
||||
"attachments": i.attachments.iter().map(|a| json!({ "name": a.name, "description": a.description, "size": a.size })).collect::<Vec<_>>(),
|
||||
// `substitute` names the face standing in for a program the document does not embed, and
|
||||
// `substitution` says why. Both are null for an embedded font: nothing stands in. A null
|
||||
@@ -2370,32 +2504,20 @@ fn write_atomic_synced(
|
||||
suffixes: impl IntoIterator<Item = u64>,
|
||||
sync: impl FnOnce(&std::fs::File) -> std::io::Result<()>,
|
||||
) -> Result<()> {
|
||||
use std::io::Write;
|
||||
// A folder can't be replaced, and staging beside it could land outside the root: "." names
|
||||
// the root itself, whose parent isn't ours to write in.
|
||||
if path.is_dir() {
|
||||
return Err(failed(format!("{}: is a folder, not a file", path.display())));
|
||||
}
|
||||
let dir = path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."));
|
||||
let name = path.file_name().ok_or_else(|| failed(format!("{}: not a file path", path.display())))?;
|
||||
path.file_name().ok_or_else(|| failed(format!("{}: not a file path", path.display())))?;
|
||||
std::fs::create_dir_all(dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?;
|
||||
let (tmp, file) =
|
||||
create_staging(dir, &name.to_string_lossy(), StagingName::SuffixThenTag, suffixes).map_err(|e| failed(format!("{}: {e}", dir.display())))?;
|
||||
// Closed at the end of the block, before the rename.
|
||||
let written = {
|
||||
let mut file = file;
|
||||
// Surface delayed writeback failures before replacing the destination or marking
|
||||
// a document clean. The handle is closed before rename (required on Windows).
|
||||
file.write_all(bytes).and_then(|()| sync(&file))
|
||||
};
|
||||
if let Err(e) = written {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(failed(format!("{}: {e}", tmp.display())));
|
||||
}
|
||||
std::fs::rename(&tmp, path).map_err(|e| {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
failed(format!("{}: {e}", path.display()))
|
||||
})
|
||||
// The writer itself is the platform crate's, shared with the desktop app and the CLI
|
||||
// (#2166): the staging file is written, flushed (delayed writeback failures surface before
|
||||
// the destination is replaced or a document marked clean), closed and renamed; a failure at
|
||||
// any step removes it and leaves the destination as it was.
|
||||
pdfcraft_platform::staging::write_atomically_with(path, bytes, StagingName::SuffixThenTag, suffixes, sync)
|
||||
.map_err(|e| failed(format!("{}: {e}", path.display())))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -65,6 +65,7 @@ impl Automation {
|
||||
|
||||
pub(crate) fn link_list(&self, a: &Args) -> Result<Value> {
|
||||
let doc = self.doc(a)?;
|
||||
let targets: std::collections::HashMap<(usize, usize), &LinkTarget> = doc.info.links.iter().map(|t| ((t.page, t.index), &t.target)).collect();
|
||||
let items: Vec<Value> = doc
|
||||
.links
|
||||
.iter()
|
||||
@@ -73,10 +74,25 @@ impl Automation {
|
||||
let (v0, v1) = (p.user_to_view(l.rect[0] as f32, l.rect[1] as f32), p.user_to_view(l.rect[2] as f32, l.rect[3] as f32));
|
||||
let rect = [v0[0].min(v1[0]), v0[1].min(v1[1]), v0[0].max(v1[0]), v0[1].max(v1[1])].map(|x| (x * 100.0).round() / 100.0);
|
||||
let mut v = json!({ "page": l.page + 1, "index": l.index + 1, "rect": rect, "visible": l.style.visible });
|
||||
match &l.action {
|
||||
LinkAction::Page(p) => v["to_page"] = json!(p + 1),
|
||||
LinkAction::Uri(u) => v["url"] = json!(u),
|
||||
LinkAction::Other(o) => v["other"] = json!(o),
|
||||
// Where an in-document link goes, as the viewer resolves it: named destinations
|
||||
// included, with the position on the page.
|
||||
let target = targets.get(&(l.page, l.index)).and_then(|t| match t {
|
||||
LinkTarget::Page(page, view) => Some((*page, view)),
|
||||
_ => None,
|
||||
});
|
||||
match (&l.action, target) {
|
||||
(LinkAction::Uri(u), _) => v["url"] = json!(u),
|
||||
(action, Some((page, view))) => {
|
||||
v["to_page"] = json!(page + 1);
|
||||
if let Some(d) = doc.info.pages.get(page).and_then(|p| crate::destination(view, p)) {
|
||||
v["destination"] = d;
|
||||
}
|
||||
if matches!(action, LinkAction::Other(_)) {
|
||||
v["named"] = json!(true);
|
||||
}
|
||||
}
|
||||
(LinkAction::Page(p), None) => v["to_page"] = json!(p + 1),
|
||||
(LinkAction::Other(o), None) => v["other"] = json!(o),
|
||||
}
|
||||
v
|
||||
})
|
||||
|
||||
@@ -125,7 +125,18 @@ impl Automation {
|
||||
bad(format!("unknown paper {p:?}; choose one of {}", PAPERS.iter().map(|(name, _)| *name).collect::<Vec<_>>().join(", ")))
|
||||
})?,
|
||||
};
|
||||
let settings = print::Settings { pages, paper, orientation, layout, content };
|
||||
// Print as image (Acrobat's Advanced Print Setup): pictures at `dpi`, grey when
|
||||
// `grayscale` is also asked for.
|
||||
let raster = if a.opt_bool("as_image")?.unwrap_or(false) {
|
||||
let dpi = a.opt_num("dpi")?.unwrap_or(300.0);
|
||||
if !(72.0..=600.0).contains(&dpi) {
|
||||
return Err(bad("dpi must be between 72 and 600"));
|
||||
}
|
||||
Some(print::Raster { dpi, grayscale: a.opt_bool("grayscale")?.unwrap_or(false) })
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let settings = print::Settings { pages, paper, orientation, layout, content, raster };
|
||||
let sizes: Vec<(f64, f64)> = self.doc(a)?.info.pages.iter().map(|p| (p.width as f64, p.height as f64)).collect();
|
||||
let sheets = print::layout(&sizes, &settings).map_err(|e| bad(e.to_string()))?.len();
|
||||
let bytes = self.session.print_pdf(id, &settings).map_err(failed)?;
|
||||
|
||||
@@ -17,6 +17,62 @@ fn time(secs: i64) -> sign::Time {
|
||||
sign::Time::from_unix(secs)
|
||||
}
|
||||
|
||||
/// The longest label `sign_document` takes (characters): a label is a few words.
|
||||
const MAX_LABEL_CHARS: usize = 200;
|
||||
|
||||
/// `appearance`: which items the visible signature shows; those not given keep the default
|
||||
/// (name, date and labels on; reason, location and distinguished name off).
|
||||
fn appearance_arg(v: &Value) -> Result<sign::Appearance> {
|
||||
const KEYS: &str = "name, date, reason, location, distinguished_name, labels";
|
||||
let o = v.as_object().ok_or_else(|| bad(format!("appearance must be an object of true/false items ({KEYS})")))?;
|
||||
let mut shown = sign::Appearance::default();
|
||||
for (key, value) in o {
|
||||
let on = value.as_bool().ok_or_else(|| bad(format!("appearance.{key} must be true or false")))?;
|
||||
let item = match key.as_str() {
|
||||
"name" => &mut shown.name,
|
||||
"date" => &mut shown.date,
|
||||
"reason" => &mut shown.reason,
|
||||
"location" => &mut shown.location,
|
||||
"distinguished_name" => &mut shown.distinguished_name,
|
||||
"labels" => &mut shown.labels,
|
||||
other => return Err(bad(format!("unknown appearance item {other:?} ({KEYS})"))),
|
||||
};
|
||||
*item = on;
|
||||
}
|
||||
Ok(shown)
|
||||
}
|
||||
|
||||
/// `labels`: the words of the visible signature's labels; those not given keep the English. Each
|
||||
/// must be drawable in the signature's font. Nobody sees a preview here, so a label it can't draw
|
||||
/// is refused even when this signature doesn't show it.
|
||||
fn labels_arg(v: &Value) -> Result<sign::AppearanceLabels> {
|
||||
const KEYS: &str = "signed_by, distinguished_name, reason, location, date";
|
||||
let o = v.as_object().ok_or_else(|| bad(format!("labels must be an object of strings ({KEYS})")))?;
|
||||
let mut labels = sign::AppearanceLabels::default();
|
||||
for (key, value) in o {
|
||||
let text = value.as_str().ok_or_else(|| bad(format!("labels.{key} must be a string")))?;
|
||||
if text.chars().count() > MAX_LABEL_CHARS {
|
||||
return Err(bad(format!("labels.{key} is longer than {MAX_LABEL_CHARS} characters")));
|
||||
}
|
||||
let label = match key.as_str() {
|
||||
"signed_by" => &mut labels.signed_by,
|
||||
"distinguished_name" => &mut labels.distinguished_name,
|
||||
"reason" => &mut labels.reason,
|
||||
"location" => &mut labels.location,
|
||||
"date" => &mut labels.date,
|
||||
other => return Err(bad(format!("unknown label {other:?} ({KEYS})"))),
|
||||
};
|
||||
*label = text.to_string();
|
||||
}
|
||||
if let Some((field, c)) = labels.first_undrawable() {
|
||||
return Err(bad(format!(
|
||||
"labels.{field}: {}",
|
||||
pdfcraft_engine::undrawable_message(&format!("the {field} label of the visible signature"), c)
|
||||
)));
|
||||
}
|
||||
Ok(labels)
|
||||
}
|
||||
|
||||
pub(crate) fn cert_json(c: &Certificate) -> Value {
|
||||
json!({
|
||||
"name": c.display_name(),
|
||||
@@ -89,8 +145,8 @@ impl Automation {
|
||||
return Err(bad("name must not be empty"));
|
||||
}
|
||||
let password = a.str("password")?;
|
||||
if password.chars().count() < 6 {
|
||||
return Err(bad("the password must have at least 6 characters"));
|
||||
if password.chars().count() < sign::pkcs12::MIN_PASSWORD_CHARS {
|
||||
return Err(bad(format!("the password must have at least {} characters", sign::pkcs12::MIN_PASSWORD_CHARS)));
|
||||
}
|
||||
let path = self.resolve(a.str("path")?, true)?;
|
||||
let key = match a.opt_str("key")?.unwrap_or("rsa2048") {
|
||||
@@ -177,6 +233,17 @@ impl Automation {
|
||||
Some(g) if rect.is_none() && field.is_none() => return Err(bad(format!("graphic {g} needs a visible signature: pass rect or field"))),
|
||||
Some(g) => Some(self.signature_graphic(g)?),
|
||||
};
|
||||
let visible = rect.is_some() || field.is_some();
|
||||
let appearance = match a.get("appearance") {
|
||||
None | Some(Value::Null) => sign::Appearance::default(),
|
||||
Some(_) if !visible => return Err(bad("appearance needs a visible signature: pass rect or field")),
|
||||
Some(v) => appearance_arg(v)?,
|
||||
};
|
||||
let appearance_labels = match a.get("labels") {
|
||||
None | Some(Value::Null) => sign::AppearanceLabels::default(),
|
||||
Some(_) if !visible => return Err(bad("labels needs a visible signature: pass rect or field")),
|
||||
Some(v) => labels_arg(v)?,
|
||||
};
|
||||
let out = self.resolve(a.str("out")?, true)?;
|
||||
let opts = SignOptions {
|
||||
field,
|
||||
@@ -186,6 +253,8 @@ impl Automation {
|
||||
location: a.opt_str("location")?.map(str::to_string),
|
||||
contact: a.opt_str("contact")?.map(str::to_string),
|
||||
certify,
|
||||
appearance,
|
||||
appearance_labels,
|
||||
graphic,
|
||||
..SignOptions::default()
|
||||
};
|
||||
|
||||
@@ -133,7 +133,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
.cmd("file.open")
|
||||
.with(schema(json!({ "path": { "type": "string" }, "password": { "type": "string", "description": "User or owner password for encrypted files." } }), &["path"])),
|
||||
t("doc_list", "List open documents", "List the open documents with their ids, page counts and unsaved state.").ro().with(schema(json!({}), &[])),
|
||||
t("doc_info", "Inspect a document", "Metadata, page sizes and labels, bookmarks, annotations, form fields, links, layers, attachments, fonts, security and repair notes.")
|
||||
t("doc_info", "Inspect a document", "Metadata, page sizes and labels, bookmarks, annotations, form fields, links, layers, page transitions and display times, attachments, fonts, security and repair notes.")
|
||||
.ro()
|
||||
.cmd("file.properties")
|
||||
.with(schema(json!({ "doc": doc() }), &["doc"])),
|
||||
@@ -229,7 +229,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
json!({ "doc": doc(), "at": { "type": "integer", "minimum": 1 }, "width": { "type": "number", "description": "Points." }, "height": { "type": "number", "description": "Points." } }),
|
||||
&["doc", "at"],
|
||||
)),
|
||||
t("page_insert_file", "Insert pages from a file", "Insert pages of another PDF so the first becomes page `at`. Undoable.")
|
||||
t("page_insert_file", "Insert pages from a file", "Insert pages of another PDF so the first becomes page `at`. Its bookmarks come along, collapsed under one bookmark named after the file, placed among the document's bookmarks where the pages went. Undoable.")
|
||||
.cmd("page.insert")
|
||||
.with(schema(json!({ "doc": doc(), "path": { "type": "string" }, "at": { "type": "integer", "minimum": 1 }, "pages": pages("of the source file (default: all)") }), &["doc", "path", "at"])),
|
||||
t("page_extract", "Extract pages", "Copy pages into a new PDF (links, bookmarks, fields and layers that belong to them come along). separate: true writes each page as its own file into out_dir; delete: true removes the pages from this document afterwards (undoable).")
|
||||
@@ -372,8 +372,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"Field properties",
|
||||
"Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename), rect (position) and rotation require a single field. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.",
|
||||
)
|
||||
.with({
|
||||
let mut input = schema(
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(),
|
||||
"field": { "type": "string", "description": "The field's current name." },
|
||||
@@ -399,10 +398,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"calculate": { "description": "Calculate tab: {\"op\": \"sum\"|\"product\"|\"average\"|\"min\"|\"max\", \"fields\": [\"a\", \"b\"]}, {\"notation\": \"Price * Qty\"} (simplified field notation), or \"none\"." },
|
||||
}),
|
||||
&["doc"],
|
||||
);
|
||||
input["oneOf"] = json!([{ "required": ["field"] }, { "required": ["fields"] }]);
|
||||
input
|
||||
}),
|
||||
)),
|
||||
t("form_tab_order", "Set the tab order", "Set the tab order of pages (default all): row (top to bottom, left to right), column, structure, or annotations (unspecified). Or order tabs manually: `field` with `move` earlier|later moves that field one place on its page. Returns the resulting order of fields. Undoable.")
|
||||
.with(schema(
|
||||
json!({ "doc": doc(), "order": { "type": "string", "enum": ["row", "column", "structure", "annotations"] }, "pages": pages("to set (default: all)"), "field": { "type": "string" }, "move": { "type": "string", "enum": ["earlier", "later"] } }),
|
||||
@@ -512,6 +508,8 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"collate": { "type": "boolean" },
|
||||
"duplex": { "type": "string", "enum": ["off", "long-edge", "short-edge"] },
|
||||
"grayscale": { "type": "boolean" },
|
||||
"as_image": { "type": "boolean", "description": "Print as image (Acrobat's Advanced Print Setup): every page rendered to a picture at dpi before it is placed on the paper, for printers that skip text, shading or transparency; the print is no longer searchable text, and with grayscale the pictures are grey." },
|
||||
"dpi": { "type": "number", "minimum": 72, "maximum": 600, "description": "Resolution for as_image (default 300)." },
|
||||
"options": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Printer driver options from printer_options: key → choice value." },
|
||||
}),
|
||||
&["doc"],
|
||||
@@ -522,7 +520,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t(
|
||||
"page_add_text",
|
||||
"Add text to a page",
|
||||
"Add text as page content (not a comment). Place it with at [x, y] (top-left, points from the top-left of the displayed page) and width (wrap width, default 200), or rect. Newlines start new lines; long lines wrap. Style: font helvetica/times/courier, size, bold, italic, color (#RRGGBB or a name), align left/center/right. Text must be Western European (WinAnsi) characters; others are refused. It stays editable with content_update. Undoable.",
|
||||
"Add text as page content (not a comment). Place it with at [x, y] (top-left, points from the top-left of the displayed page) and width (wrap width, default 200), or rect. Newlines start new lines; long lines wrap. Style: font helvetica/times/courier, size, bold, italic, color (#RRGGBB or a name), align left/center/right. Text must be Western European (WinAnsi) characters, or Arabic, Hebrew or Thai when the build has their craft-fonts faces (drawn right to left where the text is); others are refused. It stays editable with content_update. Undoable.",
|
||||
)
|
||||
.cmd("edit.text")
|
||||
.with(schema(
|
||||
@@ -679,7 +677,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"country": { "type": "string", "description": "Two-letter country code." },
|
||||
"key": { "type": "string", "enum": ["rsa2048", "rsa3072", "rsa4096", "p256"] },
|
||||
"years": { "type": "integer", "minimum": 1, "maximum": 50 },
|
||||
"password": { "type": "string", "minLength": 6 },
|
||||
"password": { "type": "string", "minLength": pdfcraft_engine::sign::pkcs12::MIN_PASSWORD_CHARS },
|
||||
"path": { "type": "string", "description": "The .p12 file to write." },
|
||||
}),
|
||||
&["name", "password", "path"],
|
||||
@@ -692,7 +690,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
.ro()
|
||||
.cmd("sign.digital")
|
||||
.with(schema(json!({}), &[])),
|
||||
t("sign_document", "Sign a document", "Sign with a digital ID (a .p12/.pfx path, or on macOS a Keychain identity: \"keychain:<common name or fingerprint>\" from sign_keychain_ids, or on Windows a store identity: \"windows:<common name or fingerprint>\" from sign_windows_ids) and save the signed file to `out` (signing always saves, as in Acrobat; the document then shows the signed file). Sign an existing empty signature field (`field`), or a new one on `page` at `rect` (omit rect for an invisible signature). certify: no_changes, form_fill or comments makes a certification signature. graphic shows a picture where the signer's name would be large (Acrobat: Configure Signature Appearance): {\"image\": path} (PNG or JPEG), {\"text\": name} (typed in the Fill & Sign script font) or {\"strokes\": [[[x, y], …], …]} (y up, the drawing pad 1 wide); it needs a visible signature. PAdES B-B, SHA-256 (SHA-384 for P-384 keys).")
|
||||
t("sign_document", "Sign a document", "Sign with a digital ID (a .p12/.pfx path, or on macOS a Keychain identity: \"keychain:<common name or fingerprint>\" from sign_keychain_ids, or on Windows a store identity: \"windows:<common name or fingerprint>\" from sign_windows_ids) and save the signed file to `out` (signing always saves, as in Acrobat; the document then shows the signed file). Sign an existing empty signature field (`field`), or a new one on `page` at `rect` (omit rect for an invisible signature). certify: no_changes, form_fill or comments makes a certification signature. graphic shows a picture where the signer's name would be large (Acrobat: Configure Signature Appearance): {\"image\": path} (PNG or JPEG), {\"text\": name} (typed in the Fill & Sign script font) or {\"strokes\": [[[x, y], …], …]} (y up, the drawing pad 1 wide); it needs a visible signature. appearance chooses what a visible signature shows (default: name, date and labels; reason, location and distinguished_name are off unless turned on). labels gives the words of its labels, e.g. in another language (default English: \"Digitally signed by\", \"DN: \", \"Reason: \", \"Location: \", \"Date: \"; include the separator); each must be in Western European characters (Windows-1252), the only ones the signature's font draws, or the call is refused naming the label and character. PAdES B-B, SHA-256 (SHA-384 for P-384 keys).")
|
||||
.cmd("sign.digital")
|
||||
.with(schema(
|
||||
json!({
|
||||
@@ -717,6 +715,31 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"minProperties": 1,
|
||||
"maxProperties": 1,
|
||||
},
|
||||
"appearance": {
|
||||
"type": "object",
|
||||
"description": "What a visible signature shows; items not given keep their default.",
|
||||
"properties": {
|
||||
"name": { "type": "boolean", "description": "Default true." },
|
||||
"date": { "type": "boolean", "description": "Default true." },
|
||||
"reason": { "type": "boolean", "description": "Default false." },
|
||||
"location": { "type": "boolean", "description": "Default false." },
|
||||
"distinguished_name": { "type": "boolean", "description": "Default false." },
|
||||
"labels": { "type": "boolean", "description": "Default true." },
|
||||
},
|
||||
"additionalProperties": false,
|
||||
},
|
||||
"labels": {
|
||||
"type": "object",
|
||||
"description": "The words of a visible signature's labels (at most 200 characters each, Western European characters only); labels not given stay English.",
|
||||
"properties": {
|
||||
"signed_by": { "type": "string", "description": "Above the name. Default \"Digitally signed by\"." },
|
||||
"distinguished_name": { "type": "string", "description": "Before the certificate's subject. Default \"DN: \"." },
|
||||
"reason": { "type": "string", "description": "Default \"Reason: \"." },
|
||||
"location": { "type": "string", "description": "Default \"Location: \"." },
|
||||
"date": { "type": "string", "description": "Default \"Date: \"." },
|
||||
},
|
||||
"additionalProperties": false,
|
||||
},
|
||||
"out": { "type": "string", "description": "Where to save the signed document." },
|
||||
}),
|
||||
&["doc", "id", "out"],
|
||||
@@ -755,13 +778,14 @@ 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, 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(
|
||||
t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity, line width, font size (text box/typewriter/callout), 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." },
|
||||
"font_size": { "type": "number", "minimum": 1, "maximum": 400, "description": "Font size in points of a text box, typewriter text or callout; the box grows to hold the text." },
|
||||
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
|
||||
"move": point(),
|
||||
})),
|
||||
@@ -800,6 +824,26 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t("page_duplicate", "Duplicate pages", "Insert copies of pages after the last of them (fonts and images are shared, not copied). Undoable.")
|
||||
.cmd("page.duplicate")
|
||||
.with(schema(json!({ "doc": doc(), "pages": pages("to duplicate") }), &["doc", "pages"])),
|
||||
t(
|
||||
"page_transition_set",
|
||||
"Set page transitions",
|
||||
"Set or remove the transition and display time pages play in a full-screen presentation (ISO 32000-2 §12.4.4; Acrobat's Organize ▸ Page Transitions). style: Split, Blinds, Box, Wipe, Dissolve, Glitter, R (replace, no effect), Fly, Push, Cover, Uncover or Fade, or none to remove the transition; duration in seconds (default 1); dimension horizontal/vertical (Split, Blinds); motion inward/outward (Split, Box, Fly); direction 0, 90, 180, 270, 315 or none (Wipe, Glitter, Fly, Cover, Uncover, Push); scale and opaque for Fly. display: seconds the page stays before advancing; left out, the page waits for the presenter. doc_info lists the result under transitions.",
|
||||
)
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(),
|
||||
"pages": pages("to change (default: all)"),
|
||||
"style": { "type": "string", "enum": ["none", "Split", "Blinds", "Box", "Wipe", "Dissolve", "Glitter", "R", "Fly", "Push", "Cover", "Uncover", "Fade"] },
|
||||
"duration": { "type": "number", "minimum": 0 },
|
||||
"dimension": { "type": "string", "enum": ["horizontal", "vertical"] },
|
||||
"motion": { "type": "string", "enum": ["inward", "outward"] },
|
||||
"direction": { "description": "0, 90, 180, 270, 315 or \"none\"" },
|
||||
"scale": { "type": "number", "exclusiveMinimum": 0 },
|
||||
"opaque": { "type": "boolean" },
|
||||
"display": { "type": "number", "minimum": 0 },
|
||||
}),
|
||||
&["doc"],
|
||||
)),
|
||||
t(
|
||||
"page_set_box",
|
||||
"Set page boxes / crop",
|
||||
@@ -948,10 +992,20 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t("accessibility_set_alt", "Set alternate text", "Set a figure's alternate text (alt; empty or omitted clears it), or mark it decorative (decorative: true: its content becomes an artifact and the figure leaves the tags). figure is a number from accessibility_figures. Returns the figures. Undoable.")
|
||||
.cmd("a11y.alt_text")
|
||||
.with(schema(json!({ "doc": doc(), "figure": { "type": "integer", "minimum": 1 }, "alt": { "type": "string" }, "decorative": { "type": "boolean" } }), &["doc", "figure"])),
|
||||
t("accessibility_tags", "List structure tags", "List reachable indirect structure tags in document order with PDF object number, generation, nearest indirect parent, depth, page, type, title, actual text, alternate text and language. Inline tag dictionaries have no stable object reference and are not listed.")
|
||||
t("accessibility_tags", "List structure tags", "List reachable indirect structure tags in document order with PDF object number, generation, nearest indirect parent, depth, page, type, title, actual text, alternate text and language, one page at a time: count is how many tags match, next the offset of the following page (null at the end). types, pages and max_depth keep only some tags. Inline tag dictionaries have no stable object reference and are not listed.")
|
||||
.ro()
|
||||
.cmd("a11y.tags")
|
||||
.with(schema(json!({ "doc": doc() }), &["doc"])),
|
||||
.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": "Tags in the page (default 200)" },
|
||||
"types": { "type": "array", "items": { "type": "string" }, "description": "Only tags of these types, as listed in type (such as H1, H2, Table)" },
|
||||
"pages": { "type": "array", "items": { "type": "integer", "minimum": 1 }, "description": "Only tags on these pages" },
|
||||
"max_depth": { "type": "integer", "minimum": 0, "description": "Only tags at most this deep (0 = top level)" }
|
||||
}),
|
||||
&["doc"],
|
||||
)),
|
||||
t("accessibility_set_tag_property", "Edit tag property", "Set or clear one property of a reachable indirect structure tag. Use object and generation from accessibility_tags. Properties: type, title, actual_text, alternate_text, language. Empty or omitted value clears text properties; type requires a nonempty PDF name. Undoable.")
|
||||
.with(schema(json!({
|
||||
"doc": doc(), "object": { "type": "integer", "minimum": 1 },
|
||||
@@ -1079,7 +1133,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
.ro()
|
||||
.cmd("ocr.recognize")
|
||||
.with(schema(json!({}), &[])),
|
||||
t("doc_export_all_images", "Export all images", "Write the images that pages use into a folder (`<name>_Page_<n>_Image_<k>.jpg|png`), each once: JPEG images unchanged, others as PNG with their soft mask as alpha. min_size skips images with fewer pixels on their shorter side. Images that can't be decoded yet (JPEG 2000, JBIG2, CCITT, separations) are listed under skipped. Includes unsaved edits.")
|
||||
t("doc_export_all_images", "Export all images", "Write the images that pages use into a folder (`<name>_Page_<n>_Image_<k>.jpg|png`, n zero-padded to the page count's digits), each once: JPEG images unchanged, others as PNG with their soft mask as alpha. min_size skips images with fewer pixels on their shorter side. Images that can't be decoded yet (JPEG 2000, JBIG2, CCITT, separations) are listed under skipped. Includes unsaved edits.")
|
||||
.cmd("export.all_images")
|
||||
.with(schema(
|
||||
json!({
|
||||
@@ -1175,14 +1229,14 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
.ro()
|
||||
.cmd("edit.edit_text")
|
||||
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])),
|
||||
t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new full placement box in top-left-origin points, based on page_images.rect), rotate (quarters clockwise, default 1), flip_horizontal, flip_vertical, replace (path: an image file, drawn in the same place) or delete. Undoable. The selected image crop transforms with its pixels, isolating shared path scopes while retaining neighboring painting. Text-outline clips return a clear error. Rotation/flip use the visible centre. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.")
|
||||
t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new full placement box in top-left-origin points, based on page_images.rect), crop (rect: the part to keep, top-left-origin points; the pixels stay in place and page_images.visible_rect shows the result; a later move carries the crop along), rotate (quarters clockwise, default 1), flip_horizontal, flip_vertical, replace (path: an image file, drawn in the same place) or delete. Undoable. The selected image crop transforms with its pixels, isolating shared path scopes while retaining neighboring painting. Text-outline clips return a clear error. Rotation/flip use the visible centre. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.")
|
||||
.cmd("edit.edit_text")
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(),
|
||||
"page": { "type": "integer", "minimum": 1 },
|
||||
"image": { "type": "integer", "minimum": 1 },
|
||||
"action": { "type": "string", "enum": ["move", "rotate", "flip_horizontal", "flip_vertical", "replace", "delete"] },
|
||||
"action": { "type": "string", "enum": ["move", "crop", "rotate", "flip_horizontal", "flip_vertical", "replace", "delete"] },
|
||||
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
|
||||
"quarters": { "type": "integer" },
|
||||
"path": { "type": "string" },
|
||||
@@ -1258,9 +1312,16 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
}),
|
||||
&["doc", "path"],
|
||||
)),
|
||||
t("doc_flatten", "Flatten", "Merge comment and/or form field appearances into the page content, so they print and display everywhere but can no longer be edited. Undoable until saved.")
|
||||
t("doc_flatten", "Flatten", "Merge comment and/or form field appearances into the page content, so they print and display everywhere but can no longer be edited. Flattening the fields of an XFA form also takes the XFA out (its template, data and scripts), so Adobe's viewers show the baked pages rather than laying the form out again. Undoable until saved.")
|
||||
.destructive()
|
||||
.with(schema(json!({ "doc": doc(), "comments": { "type": "boolean", "description": "Default true." }, "fields": { "type": "boolean", "description": "Default true." } }), &["doc"])),
|
||||
t(
|
||||
"xfa_to_static",
|
||||
"Convert an XFA form to a static form",
|
||||
"Make an XFA form an ordinary PDF form: the XFA packets (template, data, scripts) and PdfCraft's private keys go; the pages as laid out and the fields with their values stay, so every viewer shows the same form and none lays it out again. Its scripts no longer run. Undoable until saved.",
|
||||
)
|
||||
.destructive()
|
||||
.with(schema(json!({ "doc": doc() }), &["doc"])),
|
||||
t("edit_undo", "Undo", "Undo the last edit of a document.").cmd("edit.undo").with(schema(json!({ "doc": doc() }), &["doc"])),
|
||||
t("edit_redo", "Redo", "Redo the last undone edit of a document.").cmd("edit.redo").with(schema(json!({ "doc": doc() }), &["doc"])),
|
||||
t("command_list", "List commands", "Every registered PdfCraft command with its menu, shortcut, whether it is enabled now, and the tool that automates it.")
|
||||
|
||||
@@ -115,6 +115,105 @@ fn auto(dir: &Path) -> Automation {
|
||||
Automation::new().with_root(dir).unwrap().with_clock(|| 1_700_000_000)
|
||||
}
|
||||
|
||||
/// Full-screen presentation settings (`/Trans`, `/Dur`) reach `doc_info`, 1-based pages, only
|
||||
/// for the pages that carry them.
|
||||
#[test]
|
||||
fn doc_info_lists_page_transitions_and_display_times() {
|
||||
let dir = workdir("transitions");
|
||||
let objects: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 300] >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /Trans << /S /Fade /D 0.5 >> /Dur 4 >>".to_vec(),
|
||||
];
|
||||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, object) in objects.iter().enumerate() {
|
||||
offsets.push(bytes.len());
|
||||
bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
bytes.extend_from_slice(object);
|
||||
bytes.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = bytes.len();
|
||||
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
std::fs::write(dir.join("slides.pdf"), bytes).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "slides.pdf" }))["doc"].as_u64().unwrap();
|
||||
let info = ok(&mut a, "doc_info", json!({ "doc": doc }));
|
||||
let t = info["transitions"].as_array().unwrap();
|
||||
assert_eq!(t.len(), 1, "{}", info["transitions"]);
|
||||
assert_eq!(
|
||||
(t[0]["page"].as_u64(), t[0]["style"].as_str(), t[0]["duration"].as_f64(), t[0]["display"].as_f64()),
|
||||
(Some(2), Some("Fade"), Some(0.5), Some(4.0)),
|
||||
"{}",
|
||||
t[0]
|
||||
);
|
||||
}
|
||||
|
||||
/// Page transitions through the tools: set on chosen pages, written to the file as `/Trans` and
|
||||
/// `/Dur`, removed again, bad values refused, and one undo step. (Read through `pdfcraft_cos`,
|
||||
/// so this does not lean on `doc_info` listing them.)
|
||||
#[test]
|
||||
fn page_transitions_are_set_and_removed_through_the_tools() {
|
||||
use pdfcraft_cos::Object;
|
||||
/// `key` of 0-based `page` in the saved file `name`, resolved.
|
||||
fn entry(dir: &Path, name: &str, page: usize, key: &[u8]) -> Option<Object> {
|
||||
let d = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join(name)).ok()?)).ok()?;
|
||||
let root = d.root()?;
|
||||
let pages = d.get(root).as_dict()?.reference(b"Pages")?;
|
||||
let kids = d.get(pages).as_dict()?.get(b"Kids")?.clone();
|
||||
let kid = d.resolve(&kids).as_array()?.get(page)?.clone();
|
||||
let o = d.resolve(&kid).as_dict()?.get(key)?.clone();
|
||||
Some((*d.resolve(&o)).clone())
|
||||
}
|
||||
let dir = workdir("transitions-set");
|
||||
std::fs::write(dir.join("a.pdf"), fixture(3)).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "page_transition_set", json!({ "doc": doc, "pages": [2, 3], "style": "Wipe", "duration": 2, "direction": 270, "display": 5 }));
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out/t1.pdf" }));
|
||||
let t = entry(&dir, "out/t1.pdf", 1, b"Trans").expect("/Trans on page 2");
|
||||
let t = t.as_dict().expect("a dictionary");
|
||||
assert_eq!(
|
||||
(t.name(b"S"), t.get(b"D").and_then(Object::as_f64), t.get(b"Di").and_then(Object::as_f64)),
|
||||
(Some(&b"Wipe"[..]), Some(2.0), Some(270.0)),
|
||||
"{t:?}"
|
||||
);
|
||||
assert_eq!(entry(&dir, "out/t1.pdf", 1, b"Dur").and_then(|o| o.as_f64()), Some(5.0));
|
||||
assert!(entry(&dir, "out/t1.pdf", 2, b"Trans").is_some(), "page 3 too");
|
||||
assert!(entry(&dir, "out/t1.pdf", 0, b"Trans").is_none() && entry(&dir, "out/t1.pdf", 0, b"Dur").is_none(), "page 1 was not asked for");
|
||||
ok(
|
||||
&mut a,
|
||||
"page_transition_set",
|
||||
json!({ "doc": doc, "pages": [3], "style": "Fly", "motion": "outward", "direction": "none", "scale": 0.5, "opaque": true }),
|
||||
);
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out/t2.pdf" }));
|
||||
let t = entry(&dir, "out/t2.pdf", 2, b"Trans").expect("/Trans on page 3");
|
||||
let t = t.as_dict().expect("a dictionary");
|
||||
assert_eq!(
|
||||
(t.name(b"S"), t.name(b"M"), t.name(b"Di"), t.get(b"SS").and_then(Object::as_f64), t.get(b"B")),
|
||||
(Some(&b"Fly"[..]), Some(&b"O"[..]), Some(&b"None"[..]), Some(0.5), Some(&Object::Bool(true))),
|
||||
"{t:?}"
|
||||
);
|
||||
assert!(entry(&dir, "out/t2.pdf", 2, b"Dur").is_none(), "display time left out removes /Dur");
|
||||
assert!(a.call("page_transition_set", &json!({ "doc": doc, "style": "Swirl" })).is_err(), "an unknown style is refused");
|
||||
assert!(a.call("page_transition_set", &json!({ "doc": doc, "style": "Wipe", "direction": 45 })).is_err());
|
||||
assert!(a.call("page_transition_set", &json!({ "doc": doc, "pages": [9], "style": "Fade" })).is_err(), "no such page");
|
||||
ok(&mut a, "page_transition_set", json!({ "doc": doc, "style": "none" }));
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out/t3.pdf" }));
|
||||
assert!((0..3).all(|p| entry(&dir, "out/t3.pdf", p, b"Trans").is_none() && entry(&dir, "out/t3.pdf", p, b"Dur").is_none()), "all removed");
|
||||
ok(&mut a, "edit_undo", json!({ "doc": doc }));
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out/t4.pdf" }));
|
||||
assert!(
|
||||
entry(&dir, "out/t4.pdf", 1, b"Trans").is_some() && entry(&dir, "out/t4.pdf", 2, b"Trans").is_some(),
|
||||
"one undo step brings both pages back"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autotag_preview_proposes_text_without_changing_the_pdf() {
|
||||
let dir = workdir("autotag-preview");
|
||||
@@ -145,6 +244,30 @@ fn autotag_preview_proposes_text_without_changing_the_pdf() {
|
||||
assert!(preview["candidates"].as_array().unwrap().iter().any(|c| c["text"] == "First paragraph" && c["type"] == "P"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_pixels_agrees_with_page_render_and_skips_the_png() {
|
||||
let dir = workdir("render-pixels");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
|
||||
let (width, height, pixels) = a.render_pixels(doc, 1, 1.0).unwrap();
|
||||
let png = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap();
|
||||
let Content::Png { width: png_width, height: png_height, .. } = png[0] else { panic!("expected an image") };
|
||||
assert_eq!((width, height), (png_width, png_height), "scale 1.0 is 72 dpi, like the tool");
|
||||
assert_eq!(pixels.as_ref().len(), width as usize * height as usize * 4, "RGBA, four bytes a pixel");
|
||||
assert!(pixels.as_ref().as_chunks::<4>().0.iter().any(|p| p[..3] != [255, 255, 255]), "the page text is drawn, not a blank sheet");
|
||||
|
||||
let (double_width, _, _) = a.render_pixels(doc, 1, 2.0).unwrap();
|
||||
assert_eq!(double_width, width * 2, "the scale is pixels per point");
|
||||
|
||||
for scale in [0.0_f32, -1.0, f32::NAN, f32::INFINITY, 1.0e6] {
|
||||
assert!(matches!(a.render_pixels(doc, 1, scale), Err(ToolError::InvalidArgs(_))), "scale {scale} must be refused");
|
||||
}
|
||||
assert!(matches!(a.render_pixels(doc, 0, 1.0), Err(ToolError::InvalidArgs(_))), "pages are 1-based");
|
||||
assert!(matches!(a.render_pixels(doc, 99, 1.0), Err(ToolError::InvalidArgs(_))), "a page past the end");
|
||||
assert!(matches!(a.render_pixels(9_999, 1, 1.0), Err(ToolError::Failed(_))), "a document that is not open");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autotag_apply_writes_reviewable_tags_and_survives_save_reopen() {
|
||||
let dir = workdir("autotag-apply");
|
||||
@@ -238,6 +361,9 @@ fn tool_table_is_well_formed() {
|
||||
assert!(props.contains_key(r.as_str().unwrap()), "{}: required {r} is not a property", t.name);
|
||||
}
|
||||
assert!(!(t.read_only && t.destructive), "{} is both read-only and destructive", t.name);
|
||||
for k in ["oneOf", "anyOf", "allOf"] {
|
||||
assert!(t.input_schema.get(k).is_none(), "{}: top-level {k} in input_schema is rejected by MCP clients such as Claude", t.name);
|
||||
}
|
||||
if let Some(c) = t.command {
|
||||
assert!(pdfcraft_engine::commands::command(c).is_some(), "{} names unregistered command {c}", t.name);
|
||||
}
|
||||
@@ -1439,6 +1565,54 @@ fn bookmark_list_pages_reach_bookmarks_past_the_hundred_thousandth() {
|
||||
assert!(matches!(a.call("bookmark_list", &json!({ "doc": doc, "offset": -1 })), Err(ToolError::InvalidArgs(_))));
|
||||
}
|
||||
|
||||
/// Bookmarks and links say where on the page they go, in the same top-left points as rect, and a
|
||||
/// link through a named destination says which page that is.
|
||||
#[test]
|
||||
fn destinations_are_listed_with_their_position() {
|
||||
let dir = workdir("destination-positions");
|
||||
let objs: Vec<&str> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /Outlines 5 0 R /Names << /Dests << /Names [(sec-b) [4 0 R /XYZ 72 500 0]] >> >> >>",
|
||||
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 612 792] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [8 0 R 9 0 R 10 0 R] >>",
|
||||
"<< /Type /Page /Parent 2 0 R >>",
|
||||
"<< /Type /Outlines /First 6 0 R /Last 7 0 R /Count 2 >>",
|
||||
"<< /Title (Section B) /Parent 5 0 R /Next 7 0 R /Dest [4 0 R /XYZ 72 500 0] >>",
|
||||
"<< /Title (Wide view) /Parent 5 0 R /Prev 6 0 R /Dest [4 0 R /FitH 400] >>",
|
||||
"<< /Type /Annot /Subtype /Link /Rect [72 700 300 716] /Border [0 0 0] /Dest [4 0 R /XYZ 72 500 2] >>",
|
||||
"<< /Type /Annot /Subtype /Link /Rect [72 670 300 686] /Border [0 0 0] /Dest (sec-b) >>",
|
||||
"<< /Type /Annot /Subtype /Link /Rect [72 640 300 656] /Border [0 0 0] /A << /S /URI /URI (https://example.com) >> >>",
|
||||
];
|
||||
let mut pdf = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(pdf.len());
|
||||
pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = pdf.len();
|
||||
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
std::fs::write(dir.join("dests.pdf"), pdf).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "dests.pdf" }))["doc"].as_u64().unwrap();
|
||||
let marks = ok(&mut a, "bookmark_list", json!({ "doc": doc }));
|
||||
// 792 − 500 = 292 points from the top; a zoom of 0 keeps the reader's zoom.
|
||||
assert_eq!(marks["bookmarks"][0]["destination"], json!({ "fit": "XYZ", "left": 72.0, "top": 292.0, "zoom": null }), "{marks}");
|
||||
assert_eq!(marks["bookmarks"][1]["destination"], json!({ "fit": "FitH", "left": null, "top": 392.0 }), "{marks}");
|
||||
let links = ok(&mut a, "link_list", json!({ "doc": doc }));
|
||||
let explicit = &links["links"][0];
|
||||
assert_eq!(
|
||||
(explicit["to_page"].clone(), explicit["destination"].clone()),
|
||||
(json!(2), json!({ "fit": "XYZ", "left": 72.0, "top": 292.0, "zoom": 2.0 }))
|
||||
);
|
||||
let named = &links["links"][1];
|
||||
assert_eq!((named["to_page"].clone(), named["named"].clone(), named.get("other")), (json!(2), json!(true), None), "{links}");
|
||||
assert_eq!(named["destination"]["top"], 292.0);
|
||||
assert_eq!((links["links"][2]["url"].clone(), links["links"][2].get("destination")), (json!("https://example.com"), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bookmarks_from_structure_through_tools() {
|
||||
let dir = workdir("bookmarks-structure");
|
||||
@@ -1656,7 +1830,7 @@ fn protecting_through_tools() {
|
||||
fn signing_an_encrypted_form_through_tools() {
|
||||
let dir = workdir("sign-encrypted");
|
||||
let mut a = auto(&dir);
|
||||
ok(&mut a, "sign_id_create", json!({ "name": "Ada Lovelace", "key": "p256", "password": "secret1", "path": "ada.p12" }));
|
||||
ok(&mut a, "sign_id_create", json!({ "name": "Ada Lovelace", "key": "p256", "password": "secret12", "path": "ada.p12" }));
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
let field = ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "signature", "rect": [20, 200, 180, 250] }))["field"]
|
||||
.as_str()
|
||||
@@ -1669,12 +1843,12 @@ fn signing_an_encrypted_form_through_tools() {
|
||||
assert_eq!(ok(&mut b, "doc_info", json!({ "doc": form }))["security"]["protected"], true);
|
||||
match b.call(
|
||||
"sign_document",
|
||||
&json!({ "doc": form, "id": "ada.p12", "password": "secret1", "page": 1, "rect": [200, 200, 380, 250], "out": "new.pdf" }),
|
||||
&json!({ "doc": form, "id": "ada.p12", "password": "secret12", "page": 1, "rect": [200, 200, 380, 250], "out": "new.pdf" }),
|
||||
) {
|
||||
Err(ToolError::Failed(m)) => assert!(m.contains("adding new signature fields"), "{m}"),
|
||||
other => panic!("a new field needs the permissions password: {other:?}"),
|
||||
}
|
||||
let r = ok(&mut b, "sign_document", json!({ "doc": form, "id": "ada.p12", "password": "secret1", "field": field, "out": "signed.pdf" }));
|
||||
let r = ok(&mut b, "sign_document", json!({ "doc": form, "id": "ada.p12", "password": "secret12", "field": field, "out": "signed.pdf" }));
|
||||
assert_eq!((r["signature"]["signer"].as_str(), r["signature"]["status"].as_str()), (Some("Ada Lovelace"), Some("unknown")));
|
||||
let original = std::fs::read(dir.join("form.pdf")).unwrap();
|
||||
let signed = std::fs::read(dir.join("signed.pdf")).unwrap();
|
||||
@@ -1682,7 +1856,7 @@ fn signing_an_encrypted_form_through_tools() {
|
||||
let mut c = auto(&dir);
|
||||
let re = ok(&mut c, "doc_open", json!({ "path": "signed.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(ok(&mut c, "doc_info", json!({ "doc": re }))["security"]["protected"], true, "still encrypted");
|
||||
ok(&mut c, "sign_trust", json!({ "paths": ["ada.p12"], "password": "secret1" }));
|
||||
ok(&mut c, "sign_trust", json!({ "paths": ["ada.p12"], "password": "secret12" }));
|
||||
let list = ok(&mut c, "sign_list", json!({ "doc": re }));
|
||||
assert_eq!((list["all_valid"].as_bool(), list["signatures"][0]["status"].as_str()), (Some(true), Some("valid")), "{list}");
|
||||
}
|
||||
@@ -2323,6 +2497,12 @@ fn editing_page_images_through_tools() {
|
||||
assert_eq!(r["undo"], "Move image");
|
||||
let moved = ok(&mut a, "page_images", json!({ "doc": made, "page": 1 }))["images"][0]["rect"].clone();
|
||||
assert_eq!(moved, json!([5.0, 5.0, 25.0, 35.0]));
|
||||
// Crop keeps part of it in place: the placement stays, visible_rect says what shows.
|
||||
let r = ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "crop", "rect": [10, 10, 25, 30] }));
|
||||
assert_eq!(r["undo"], "Crop image");
|
||||
let cropped = ok(&mut a, "page_images", json!({ "doc": made, "page": 1 }))["images"][0].clone();
|
||||
assert_eq!(cropped["rect"], json!([5.0, 5.0, 25.0, 35.0]));
|
||||
assert_eq!(cropped["visible_rect"], json!([10.0, 10.0, 25.0, 30.0]));
|
||||
ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "rotate" }));
|
||||
ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "flip_horizontal" }));
|
||||
let saved = ok(&mut a, "image_save", json!({ "doc": made, "page": 1, "image": 1, "path": "out/picture" }));
|
||||
@@ -2923,6 +3103,28 @@ fn flattening_through_tools() {
|
||||
assert!(matches!(a.call("doc_flatten", &json!({ "doc": doc, "comments": false, "fields": false })), Err(ToolError::InvalidArgs(_))));
|
||||
}
|
||||
|
||||
/// Inserting a file's pages brings its bookmarks too, under one named after the file, at the
|
||||
/// pages' place among the document's own bookmarks.
|
||||
#[test]
|
||||
fn inserting_a_file_brings_its_bookmarks() {
|
||||
let dir = workdir("insert-bookmarks");
|
||||
let mut a = auto(&dir);
|
||||
let annex = ok(&mut a, "doc_open", json!({ "path": "b.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "bookmark_add", json!({ "doc": annex, "title": "Annex intro", "page": 1 }));
|
||||
ok(&mut a, "bookmark_add", json!({ "doc": annex, "title": "Annex table", "page": 2 }));
|
||||
ok(&mut a, "doc_save", json!({ "doc": annex, "path": "annex.pdf" }));
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": "Guide start", "page": 1 }));
|
||||
ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": "Guide end", "page": 3 }));
|
||||
ok(&mut a, "page_insert_file", json!({ "doc": doc, "path": "annex.pdf", "at": 2 }));
|
||||
let list = ok(&mut a, "bookmark_list", json!({ "doc": doc }));
|
||||
let top: Vec<(&str, Value)> = list["bookmarks"].as_array().unwrap().iter().map(|b| (b["title"].as_str().unwrap(), b["page"].clone())).collect();
|
||||
assert_eq!(top, [("Guide start", json!(1)), ("annex", json!(2)), ("Guide end", json!(5))], "{list}");
|
||||
let kids: Vec<(&str, Value)> =
|
||||
list["bookmarks"][1]["children"].as_array().unwrap().iter().map(|b| (b["title"].as_str().unwrap(), b["page"].clone())).collect();
|
||||
assert_eq!(kids, [("Annex intro", json!(2)), ("Annex table", json!(3))], "{list}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_pages_through_tools() {
|
||||
let dir = workdir("replace");
|
||||
@@ -3208,6 +3410,67 @@ fn an_applied_redaction_at_the_end_of_a_line_audits_clean() {
|
||||
assert_eq!(r["count"], 0, "{r}");
|
||||
}
|
||||
|
||||
/// A long tagged document lists its tags a page at a time, and only the types, pages or depths
|
||||
/// asked for: a 20-page guide has hundreds of tags, more than one tool result should hold.
|
||||
#[test]
|
||||
fn structure_tags_are_listed_a_page_at_a_time() {
|
||||
let dir = workdir("tags-paging");
|
||||
// A Document holding, on each of 3 pages, one H1 and 149 P: 451 tags.
|
||||
let mut objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 6 0 R /MarkInfo << /Marked true >> >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 200 200] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R >>".into(),
|
||||
String::new(), // 6: the root, below
|
||||
String::new(), // 7: the Document, below
|
||||
];
|
||||
let mut kids = Vec::new();
|
||||
for page in 0..3 {
|
||||
for i in 0..150 {
|
||||
let n = objs.len() + 1;
|
||||
kids.push(format!("{n} 0 R"));
|
||||
objs.push(format!("<< /S /{} /P 7 0 R /Pg {} 0 R >>", if i == 0 { "H1" } else { "P" }, 3 + page));
|
||||
}
|
||||
}
|
||||
objs[5] = "<< /Type /StructTreeRoot /K 7 0 R >>".into();
|
||||
objs[6] = format!("<< /S /Document /P 6 0 R /K [{}] >>", kids.join(" "));
|
||||
let mut pdf = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(pdf.len());
|
||||
pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = pdf.len();
|
||||
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
std::fs::write(dir.join("long.pdf"), pdf).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "long.pdf" }))["doc"].as_u64().unwrap();
|
||||
let first = ok(&mut a, "accessibility_tags", json!({ "doc": doc }));
|
||||
assert_eq!((first["count"].as_u64(), first["tags"].as_array().map(Vec::len), first["next"].as_u64()), (Some(451), Some(200), Some(200)));
|
||||
assert_eq!(first["tags"][0]["type"], "Document");
|
||||
let last = ok(&mut a, "accessibility_tags", json!({ "doc": doc, "offset": 400, "limit": 100 }));
|
||||
assert_eq!((last["tags"].as_array().map(Vec::len), last["next"].clone()), (Some(51), Value::Null));
|
||||
let headings = ok(&mut a, "accessibility_tags", json!({ "doc": doc, "types": ["H1"] }));
|
||||
let pages: Vec<u64> = headings["tags"].as_array().unwrap().iter().map(|t| t["page"].as_u64().unwrap()).collect();
|
||||
assert_eq!((headings["count"].as_u64(), pages), (Some(3), vec![1, 2, 3]));
|
||||
let page2 = ok(&mut a, "accessibility_tags", json!({ "doc": doc, "pages": [2], "types": ["H1", "P"], "limit": 1000 }));
|
||||
assert_eq!((page2["count"].as_u64(), page2["next"].clone()), (Some(150), Value::Null));
|
||||
assert_eq!(ok(&mut a, "accessibility_tags", json!({ "doc": doc, "max_depth": 0 }))["count"], 1, "the Document alone");
|
||||
for bad in [
|
||||
json!({ "doc": doc, "limit": 0 }),
|
||||
json!({ "doc": doc, "limit": 1001 }),
|
||||
json!({ "doc": doc, "offset": -1 }),
|
||||
json!({ "doc": doc, "pages": [0] }),
|
||||
] {
|
||||
assert!(matches!(a.call("accessibility_tags", &bad), Err(ToolError::InvalidArgs(_))), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn printing_through_tools() {
|
||||
let dir = workdir("print");
|
||||
@@ -3927,6 +4190,33 @@ fn text_the_standard_fonts_cannot_draw_is_never_silent_through_tools() {
|
||||
assert!(!text.contains("?? OK"), "{text}");
|
||||
}
|
||||
|
||||
/// #1908: `comment_edit font_size` resizes the text of a text box, in one undo step, and refuses
|
||||
/// sizes outside 1-400 pt and comments without text.
|
||||
#[test]
|
||||
fn text_box_font_size_through_the_tool() {
|
||||
let dir = workdir("fontsize");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "textbox", "rect": [20, 40, 200, 80], "contents": "Hello" }));
|
||||
ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "rectangle", "rect": [20, 120, 80, 160] }));
|
||||
ok(&mut a, "comment_edit", json!({ "doc": doc, "page": 1, "index": 1, "font_size": 30 }));
|
||||
for bad in [0, 401] {
|
||||
assert!(matches!(a.call("comment_edit", &json!({ "doc": doc, "page": 1, "index": 1, "font_size": bad })), Err(ToolError::InvalidArgs(_))));
|
||||
}
|
||||
assert!(a.call("comment_edit", &json!({ "doc": doc, "page": 1, "index": 2, "font_size": 12 })).is_err());
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "sized.pdf" }));
|
||||
let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("sized.pdf")).unwrap())).unwrap();
|
||||
let sizes: Vec<String> = saved
|
||||
.object_numbers()
|
||||
.iter()
|
||||
.filter_map(|n| saved.try_get(*n).ok())
|
||||
.filter_map(|o| o.as_dict().and_then(|d| d.get(b"DA")?.as_string().map(|s| String::from_utf8_lossy(&s.bytes).into_owned())))
|
||||
.collect();
|
||||
assert_eq!(sizes.len(), 1, "{sizes:?}");
|
||||
assert!(sizes[0].ends_with("/Helv 30 Tf"), "{sizes:?}");
|
||||
assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Change comment properties");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_endings_through_the_tool() {
|
||||
let dir = workdir("endings");
|
||||
@@ -4052,12 +4342,12 @@ fn signing_with_an_image_typed_or_drawn_graphic_through_tools() {
|
||||
std::fs::write(dir.join("logo.png"), png).unwrap();
|
||||
std::fs::write(dir.join("broken.png"), b"broken").unwrap();
|
||||
let mut a = auto(&dir);
|
||||
ok(&mut a, "sign_id_create", json!({ "name": "Ada Lovelace", "key": "p256", "password": "secret1", "path": "ada.p12" }));
|
||||
ok(&mut a, "sign_id_create", json!({ "name": "Ada Lovelace", "key": "p256", "password": "secret12", "path": "ada.p12" }));
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
let sign = |a: &mut Automation, rect: [f64; 4], graphic: Value, out: &str| {
|
||||
a.call(
|
||||
"sign_document",
|
||||
&json!({ "doc": doc, "id": "ada.p12", "password": "secret1", "page": 1, "rect": rect, "graphic": graphic, "out": out }),
|
||||
&json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "page": 1, "rect": rect, "graphic": graphic, "out": out }),
|
||||
)
|
||||
};
|
||||
for (graphic, why) in [
|
||||
@@ -4077,7 +4367,7 @@ fn signing_with_an_image_typed_or_drawn_graphic_through_tools() {
|
||||
// A missing file fails as it does for every tool that reads one.
|
||||
assert!(matches!(sign(&mut a, [20.0, 200.0, 180.0, 250.0], json!({ "image": "missing.png" }), "bad.pdf"), Err(ToolError::Failed(_))));
|
||||
// An invisible signature shows nothing, so a graphic for one is a mistake.
|
||||
let r = a.call("sign_document", &json!({ "doc": doc, "id": "ada.p12", "password": "secret1", "graphic": { "text": "Ada" }, "out": "bad.pdf" }));
|
||||
let r = a.call("sign_document", &json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "graphic": { "text": "Ada" }, "out": "bad.pdf" }));
|
||||
assert!(matches!(r, Err(ToolError::InvalidArgs(_))), "{r:?}");
|
||||
assert!(!dir.join("bad.pdf").exists());
|
||||
|
||||
@@ -4113,11 +4403,13 @@ fn digital_ids_signing_and_validation_through_tools() {
|
||||
let id = ok(
|
||||
&mut a,
|
||||
"sign_id_create",
|
||||
json!({ "name": "Ada Lovelace", "organization": "Analytical Engines", "email": "ada@example.com", "country": "GB", "key": "p256", "password": "secret1", "path": "ada.p12" }),
|
||||
json!({ "name": "Ada Lovelace", "organization": "Analytical Engines", "email": "ada@example.com", "country": "GB", "key": "p256", "password": "secret12", "path": "ada.p12" }),
|
||||
);
|
||||
assert_eq!(id["certificate"]["subject"], "C=GB, O=Analytical Engines, CN=Ada Lovelace, E=ada@example.com");
|
||||
assert_eq!(id["certificate"]["self_signed"], true);
|
||||
assert!(matches!(a.call("sign_id_create", &json!({ "name": "X", "password": "short", "path": "x.p12" })), Err(ToolError::InvalidArgs(_))));
|
||||
// Eight characters at least (#1847): seven is refused.
|
||||
assert!(matches!(a.call("sign_id_create", &json!({ "name": "X", "password": "seven77", "path": "x.p12" })), Err(ToolError::InvalidArgs(_))));
|
||||
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(ok(&mut a, "sign_list", json!({ "doc": doc }))["count"], 0);
|
||||
@@ -4140,7 +4432,7 @@ fn digital_ids_signing_and_validation_through_tools() {
|
||||
let r = ok(
|
||||
&mut a,
|
||||
"sign_document",
|
||||
json!({ "doc": doc, "id": "ada.p12", "password": "secret1", "page": 2, "rect": [20, 200, 180, 250], "reason": "Approved", "location": "London", "out": "signed.pdf" }),
|
||||
json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "page": 2, "rect": [20, 200, 180, 250], "reason": "Approved", "location": "London", "out": "signed.pdf" }),
|
||||
);
|
||||
assert_eq!(r["signature"]["status"], "unknown");
|
||||
assert_eq!(r["signature"]["signer"], "Ada Lovelace");
|
||||
@@ -4150,7 +4442,7 @@ fn digital_ids_signing_and_validation_through_tools() {
|
||||
assert!(dir.join("signed.pdf").exists());
|
||||
|
||||
// Trust the ID: valid; comment afterwards: still valid, change allowed.
|
||||
let t = ok(&mut a, "sign_trust", json!({ "paths": ["ada.p12"], "password": "secret1" }));
|
||||
let t = ok(&mut a, "sign_trust", json!({ "paths": ["ada.p12"], "password": "secret12" }));
|
||||
assert_eq!(t["trusted"].as_array().unwrap().len(), 1);
|
||||
let list = ok(&mut a, "sign_list", json!({ "doc": doc }));
|
||||
assert_eq!((list["all_valid"].as_bool(), list["signatures"][0]["status"].as_str()), (Some(true), Some("valid")));
|
||||
@@ -4172,6 +4464,89 @@ fn digital_ids_signing_and_validation_through_tools() {
|
||||
assert_eq!(ok(&mut a, "sign_list", json!({ "doc": doc }))["signatures"][0]["status"], "unknown");
|
||||
}
|
||||
|
||||
/// The decoded text of every visible signature appearance (Helvetica forms) in a signed file.
|
||||
fn signature_appearances(path: &Path) -> String {
|
||||
use pdfcraft_cos::{Document, ObjRef, Object};
|
||||
let doc = Document::open(std::sync::Arc::new(std::fs::read(path).unwrap())).unwrap();
|
||||
let mut text = String::new();
|
||||
for num in doc.object_numbers() {
|
||||
if let Object::Stream(s) = &*doc.get(ObjRef { num, generation: 0 })
|
||||
&& s.dict.name(b"Subtype") == Some(b"Form")
|
||||
{
|
||||
let content = String::from_utf8_lossy(&s.decoded().unwrap()).into_owned();
|
||||
if content.contains("/Helv") {
|
||||
text.push_str(&content);
|
||||
}
|
||||
}
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// `sign_document` takes the words of a visible signature's labels (say, in German) and which
|
||||
/// items it shows, as the interface does. A label the signature's font can't draw is refused,
|
||||
/// naming the label, the character and what is allowed, before anything is written.
|
||||
#[test]
|
||||
fn signing_with_translated_labels_through_tools() {
|
||||
let dir = workdir("signing-labels");
|
||||
let mut a = auto(&dir);
|
||||
ok(&mut a, "sign_id_create", json!({ "name": "Ada Lovelace", "key": "p256", "password": "secret1", "path": "ada.p12" }));
|
||||
let open = |a: &mut Automation| ok(a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
let doc = open(&mut a);
|
||||
let args = |doc: u64, extra: Value, out: &str| {
|
||||
let mut args = json!({ "doc": doc, "id": "ada.p12", "password": "secret1", "page": 1, "rect": [10, 150, 190, 290], "reason": "Genehmigt", "location": "Berlin", "out": out });
|
||||
for (k, v) in extra.as_object().unwrap() {
|
||||
args[k] = v.clone();
|
||||
}
|
||||
args
|
||||
};
|
||||
for (extra, why, says) in [
|
||||
(
|
||||
json!({ "labels": { "signed_by": "数字签名者" } }),
|
||||
"a label Helvetica can't draw",
|
||||
"labels.signed_by: the standard fonts can't draw \"数\" (U+6570)",
|
||||
),
|
||||
(json!({ "labels": { "reason": "Причина: " } }), "even one this signature doesn't show", "labels.reason"),
|
||||
(json!({ "labels": { "signer": "Von" } }), "an unknown label", "unknown label \"signer\""),
|
||||
(json!({ "labels": { "date": 1 } }), "not a string", "labels.date must be a string"),
|
||||
(json!({ "labels": "Datum" }), "not an object", "labels must be an object"),
|
||||
(json!({ "labels": { "date": "x".repeat(201) } }), "too long", "longer than 200"),
|
||||
(json!({ "appearance": { "reason": "yes" } }), "not true or false", "appearance.reason must be true or false"),
|
||||
(json!({ "appearance": { "logo": true } }), "an unknown item", "unknown appearance item"),
|
||||
(json!({ "rect": null, "labels": { "date": "Datum: " } }), "labels for an invisible signature", "needs a visible signature"),
|
||||
(json!({ "rect": null, "appearance": { "reason": true } }), "an appearance for an invisible one", "needs a visible signature"),
|
||||
] {
|
||||
let r = a.call("sign_document", &args(doc, extra, "bad.pdf"));
|
||||
assert!(matches!(&r, Err(ToolError::InvalidArgs(m)) if m.contains(says)), "{why}: {r:?}");
|
||||
}
|
||||
let r = a.call("sign_document", &args(doc, json!({ "labels": { "signed_by": "数字签名者" } }), "bad.pdf"));
|
||||
assert!(matches!(&r, Err(ToolError::InvalidArgs(m)) if m.contains("signed_by label") && m.contains("Western European")), "{r:?}");
|
||||
assert!(!dir.join("bad.pdf").exists(), "nothing is written for a refused call");
|
||||
|
||||
// German labels, with the reason and the location shown: stamped as given.
|
||||
let german = json!({
|
||||
"labels": { "signed_by": "Digital signiert von", "reason": "Grund: ", "location": "Ort: ", "date": "Datum: " },
|
||||
"appearance": { "reason": true, "location": true },
|
||||
});
|
||||
let r = ok(&mut a, "sign_document", args(doc, german, "de.pdf"));
|
||||
assert_eq!(r["signature"]["reason"], "Genehmigt");
|
||||
let stamped = signature_appearances(&dir.join("de.pdf"));
|
||||
// Each line starts with its label (a long one wraps after it).
|
||||
for word in ["(Digital", "(Grund:", "Genehmigt", "(Ort:", "Berlin", "(Datum:"] {
|
||||
assert!(stamped.contains(word), "{word}: {stamped}");
|
||||
}
|
||||
assert!(!stamped.contains("Reason") && !stamped.contains("Date:"), "{stamped}");
|
||||
|
||||
// Labels omitted keep the English; the labels turned off show the values alone.
|
||||
let doc = open(&mut a);
|
||||
ok(&mut a, "sign_document", args(doc, json!({ "labels": { "date": "Datum: " }, "appearance": { "reason": true } }), "mixed.pdf"));
|
||||
let stamped = signature_appearances(&dir.join("mixed.pdf"));
|
||||
assert!(stamped.contains("(Reason:") && stamped.contains("(Datum:") && !stamped.contains("Berlin"), "{stamped}");
|
||||
let doc = open(&mut a);
|
||||
ok(&mut a, "sign_document", args(doc, json!({ "appearance": { "reason": true, "labels": false } }), "bare.pdf"));
|
||||
let stamped = signature_appearances(&dir.join("bare.pdf"));
|
||||
assert!(stamped.contains("(Genehmigt)") && !stamped.contains("Reason") && !stamped.contains("Date"), "{stamped}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optimizing_through_tools() {
|
||||
let dir = workdir("optimize");
|
||||
@@ -4729,6 +5104,33 @@ fn hundreds_of_xfa_calculations_open_and_recalculate_within_their_budgets() {
|
||||
eprintln!("{} calculate scripts: open {open_took:?}, recalculation after a change {change_took:?}", sections * rows + sections);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xfa_forms_flatten_and_convert_to_static_through_tools() {
|
||||
let dir = workdir("xfa-flat");
|
||||
let data = "<form><page1><country>JP</country></page1></form>";
|
||||
std::fs::write(dir.join("fields.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::fields_template(data))).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
// Convert to static: the fields stay, the XFA goes.
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "fields.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc }))["xfa"], "dynamic");
|
||||
let before = ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"].as_array().unwrap().len();
|
||||
ok(&mut a, "xfa_to_static", json!({ "doc": doc }));
|
||||
let info = ok(&mut a, "doc_info", json!({ "doc": doc }));
|
||||
assert!(info["xfa"].is_null(), "{info}");
|
||||
assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"].as_array().unwrap().len(), before);
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "static.pdf" }));
|
||||
let again = ok(&mut a, "doc_open", json!({ "path": "static.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert!(ok(&mut a, "doc_info", json!({ "doc": again }))["xfa"].is_null());
|
||||
assert!(a.call("xfa_to_static", &json!({ "doc": again })).is_err(), "not an XFA form");
|
||||
// Flatten the fields: a plain PDF with the values as page content.
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "fields.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "doc_flatten", json!({ "doc": doc, "comments": false, "fields": true }));
|
||||
assert!(ok(&mut a, "doc_info", json!({ "doc": doc }))["xfa"].is_null());
|
||||
assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"].as_array().unwrap().len(), 0);
|
||||
let text = ok(&mut a, "text_extract", json!({ "doc": doc, "pages": [1] }));
|
||||
assert!(text.to_string().contains("Japan"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xfa_data_files_export_and_import_through_tools() {
|
||||
let dir = workdir("xfa-data");
|
||||
@@ -5393,6 +5795,33 @@ fn thai_text_through_tools_saves_reopens_extracts_and_renders() {
|
||||
assert!(page_text(&mut a, reopened)[0].contains("แก้ไข น้ำ"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hebrew_text_through_tools_saves_reopens_extracts_and_renders() {
|
||||
let dir = workdir("hebrew-content");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({"path": "a.pdf"}))["doc"].as_u64().unwrap();
|
||||
let text = "שם משפחה: שרון, טל׳ 052-1234567 (נייד)";
|
||||
let args = json!({"doc": doc, "page": 1, "text": text, "rect": [10, 20, 400, 40], "size": 11, "align": "right"});
|
||||
if pdfcraft_fonts::document_hebrew_font(false).is_none() {
|
||||
assert!(a.call("page_add_text", &args).unwrap_err().to_string().contains("U+05E9"));
|
||||
assert_eq!(ok(&mut a, "content_list", json!({"doc": doc}))["count"], 0);
|
||||
return;
|
||||
}
|
||||
ok(&mut a, "page_add_text", args);
|
||||
// Extraction gives back the typing order, not the drawing order.
|
||||
assert!(page_text(&mut a, doc)[0].contains(text), "{:?}", page_text(&mut a, doc));
|
||||
ok(&mut a, "doc_save", json!({"doc": doc, "path": "hebrew.pdf"}));
|
||||
let reopened = ok(&mut a, "doc_open", json!({"path": "hebrew.pdf"}))["doc"].as_u64().unwrap();
|
||||
assert_eq!(ok(&mut a, "content_list", json!({"doc": reopened}))["items"][0]["text"], text);
|
||||
assert!(page_text(&mut a, reopened)[0].contains(text));
|
||||
let found = ok(&mut a, "text_find", json!({"doc": reopened, "query": "שרון"}));
|
||||
assert_eq!(found["matches"].as_array().map(Vec::len), Some(1), "{found}");
|
||||
let rendered = a.call("page_render", &json!({"doc": reopened, "page": 1, "dpi": 144})).unwrap();
|
||||
assert!(rendered.iter().any(|c| matches!(c, Content::Png { data, width, height } if !data.is_empty() && *width > 0 && *height > 0)));
|
||||
ok(&mut a, "content_update", json!({"doc": reopened, "page": 1, "index": 1, "text": "מיכאל שָׁלוֹם"}));
|
||||
assert!(page_text(&mut a, reopened)[0].contains("מיכאל"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_objects_move_through_tools_with_generation_and_one_undo() {
|
||||
let dir = workdir("mixed-object-move");
|
||||
@@ -5962,3 +6391,41 @@ fn a_missing_path_under_the_root_names_where_it_was_looked_for() {
|
||||
ok(&mut a, "doc_open", json!({ "path": "inside.pdf" }));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// `doc_print` prints as image: pictures at `dpi`, grey with `grayscale`; a resolution outside
|
||||
/// 72–600 is refused.
|
||||
#[test]
|
||||
fn doc_print_as_image() {
|
||||
let dir = workdir("print-image");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
let r = ok(&mut a, "doc_print", json!({ "doc": doc, "pages": "1", "as_image": true, "dpi": 150, "grayscale": true, "path": "image.pdf" }));
|
||||
assert_eq!(r["sheets"], 1);
|
||||
let bytes = std::fs::read(dir.join("image.pdf")).unwrap();
|
||||
let out = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)).unwrap();
|
||||
let pictures =
|
||||
out.scan_objects().filter(|(_, o)| matches!(&**o, pdfcraft_cos::Object::Stream(st) if st.dict.name(b"Subtype") == Some(b"Image"))).count();
|
||||
assert_eq!(pictures, 1);
|
||||
match a.call("doc_print", &json!({ "doc": doc, "as_image": true, "dpi": 20, "path": "x.pdf" })) {
|
||||
Err(ToolError::InvalidArgs(m)) => assert!(m.contains("dpi"), "{m}"),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Without a root, `write_output` still writes through a staging file (#2166): the folder is
|
||||
/// made, nothing is left beside the file, and a folder at the path is refused.
|
||||
#[test]
|
||||
fn a_root_less_write_output_is_atomic_too() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-automation-rootless-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let free = Automation::new();
|
||||
let target = dir.join("new").join("page.png");
|
||||
let written = free.write_output(&target.to_string_lossy(), &[1, 2, 3]).unwrap();
|
||||
assert_eq!(written, target);
|
||||
assert_eq!(std::fs::read(&target).unwrap(), [1, 2, 3]);
|
||||
let names: Vec<_> = std::fs::read_dir(dir.join("new")).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
||||
assert_eq!(names, ["page.png"], "no staging file left beside it");
|
||||
let e = free.write_output(&dir.join("new").to_string_lossy(), &[4]).unwrap_err();
|
||||
assert!(matches!(&e, ToolError::Failed(m) if m.contains("is a folder")), "{e:?}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -441,6 +441,13 @@ impl std::fmt::Debug for Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// How an encrypted document is opened: with a password, or with the file key of a document
|
||||
/// that has the same encryption (an earlier revision of the same file).
|
||||
enum Credentials<'a> {
|
||||
Password(Option<&'a str>),
|
||||
Handler { handler: &'a Arc<pdfcraft_crypt::SecurityHandler>, id0: Vec<u8> },
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// A new document with an empty page tree (for split / extract / combine). It has no
|
||||
/// original bytes, so saving always writes a full file.
|
||||
@@ -497,6 +504,23 @@ impl Document {
|
||||
/// document is read-only: objects in a larger object stream read as missing, so the
|
||||
/// writers refuse to save it ([`CosError::ReadOnlyLimit`]).
|
||||
pub fn open_with_stream_limit(data: Arc<Vec<u8>>, password: Option<&str>, stream_limit: usize) -> Result<Self, CosError> {
|
||||
Self::open_as(data, Credentials::Password(password), stream_limit)
|
||||
}
|
||||
|
||||
/// Open `data`, an earlier revision of this file (the bytes a signature covers) or another
|
||||
/// save of it, with this document's credentials: an encrypted one must have the same
|
||||
/// `/Encrypt` dictionary and `/ID` as this document, whose file key is then reused, so no
|
||||
/// password is needed or kept; unencrypted data opens as [`open`](Self::open) does. Data
|
||||
/// encrypted differently fails with `NeedsPassword`.
|
||||
pub fn open_with_same_credentials(&self, data: Arc<Vec<u8>>) -> Result<Self, CosError> {
|
||||
let credentials = match &self.security {
|
||||
Some(handler) => Credentials::Handler { handler, id0: self.first_id() },
|
||||
None => Credentials::Password(None),
|
||||
};
|
||||
Self::open_as(data, credentials, self.stream_limit)
|
||||
}
|
||||
|
||||
fn open_as(data: Arc<Vec<u8>>, credentials: Credentials<'_>, stream_limit: usize) -> Result<Self, CosError> {
|
||||
// Viewers accept files whose header is missing or damaged as long as the body looks
|
||||
// like PDF; so do we (a note goes to the repair log).
|
||||
let header = find(&data, b"%PDF-", 0, 1024);
|
||||
@@ -546,7 +570,7 @@ impl Document {
|
||||
// Authenticate before checking the catalog: it may sit in an encrypted object
|
||||
// stream, which reads as garbage until the key is known.
|
||||
if let Some(enc) = doc.trailer.get(b"Encrypt").cloned() {
|
||||
doc.authenticate(&enc, password)?;
|
||||
doc.authenticate(&enc, &credentials)?;
|
||||
authenticated = true;
|
||||
}
|
||||
doc.root_is_catalog()
|
||||
@@ -567,7 +591,7 @@ impl Document {
|
||||
if (!ok || !authenticated)
|
||||
&& let Some(enc) = doc.trailer.get(b"Encrypt").cloned()
|
||||
{
|
||||
doc.authenticate(&enc, password)?;
|
||||
doc.authenticate(&enc, &credentials)?;
|
||||
}
|
||||
let max = doc.entries.max_num();
|
||||
let size = doc.trailer.int(b"Size").unwrap_or(0).max(0) as u32;
|
||||
@@ -575,7 +599,7 @@ impl Document {
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
fn authenticate(&mut self, enc: &Object, password: Option<&str>) -> Result<(), CosError> {
|
||||
fn authenticate(&mut self, enc: &Object, credentials: &Credentials<'_>) -> Result<(), CosError> {
|
||||
// Authentication can change how the same input is decoded.
|
||||
self.source_identity = Arc::new(());
|
||||
let (num, dict) = match enc {
|
||||
@@ -593,12 +617,17 @@ impl Document {
|
||||
};
|
||||
let params = crate::security::encrypt_dict(self, &dict);
|
||||
let id0 = self.first_id();
|
||||
let handler = pdfcraft_crypt::SecurityHandler::open(params, &id0, password).map_err(|e| match e {
|
||||
pdfcraft_crypt::CryptError::WrongPassword if password.is_none() => CosError::NeedsPassword,
|
||||
pdfcraft_crypt::CryptError::WrongPassword => CosError::WrongPassword,
|
||||
other => CosError::Security(other.to_string()),
|
||||
})?;
|
||||
self.security = Some(Arc::new(handler));
|
||||
let handler = match credentials {
|
||||
Credentials::Password(password) => Arc::new(pdfcraft_crypt::SecurityHandler::open(params, &id0, *password).map_err(|e| match e {
|
||||
pdfcraft_crypt::CryptError::WrongPassword if password.is_none() => CosError::NeedsPassword,
|
||||
pdfcraft_crypt::CryptError::WrongPassword => CosError::WrongPassword,
|
||||
other => CosError::Security(other.to_string()),
|
||||
})?),
|
||||
// The same encryption as the document whose key this is: that key opens it.
|
||||
Credentials::Handler { handler, id0: known } if *handler.dict() == params && *known == id0 => Arc::clone(handler),
|
||||
Credentials::Handler { .. } => return Err(CosError::NeedsPassword),
|
||||
};
|
||||
self.security = Some(handler);
|
||||
self.encrypt_num = num;
|
||||
// Objects read while locating the catalog were read before decryption was possible.
|
||||
self.cache = Arc::default();
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
//! as the file's last one (table or stream) and a trailer with `/Prev`. The original bytes are
|
||||
//! never modified, which keeps digital signatures over earlier revisions valid.
|
||||
//! * `write_full` writes only objects reachable from the trailer, renumbered densely, with a
|
||||
//! cross-reference table or stream — the "Save As" / garbage-collecting path.
|
||||
//! cross-reference table or stream — the "Save As" / garbage-collecting path. Unknown trailer
|
||||
//! entries are copied, including objects reachable only from them.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::{BTreeMap, HashMap, VecDeque};
|
||||
@@ -257,6 +258,13 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>, CosErro
|
||||
queue.push_back(r);
|
||||
}
|
||||
}
|
||||
// Unknown trailer entries are document data. Objects they alone reference must be written
|
||||
// too, or a full rewrite would keep the key and drop its value.
|
||||
for (key, value) in trailer_in.iter() {
|
||||
if !trailer_key_rewritten(key) {
|
||||
collect_refs(value, &mut |r| queue.push_back(r));
|
||||
}
|
||||
}
|
||||
while let Some(r) = queue.pop_front() {
|
||||
if map.contains_key(&r) {
|
||||
continue;
|
||||
@@ -337,6 +345,11 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>, CosErro
|
||||
if let Some(id) = trailer_in.get(b"ID") {
|
||||
trailer.set(b"ID".to_vec(), id.clone());
|
||||
}
|
||||
for (key, value) in trailer_in.iter() {
|
||||
if !trailer_key_rewritten(key) {
|
||||
trailer.set(key.clone(), renumber(value, &map));
|
||||
}
|
||||
}
|
||||
if reader.document.output_security().0.is_none() {
|
||||
ensure_id(&mut trailer, opts, reader.document.bytes());
|
||||
}
|
||||
@@ -400,6 +413,15 @@ impl ObjectStreamBatch {
|
||||
}
|
||||
}
|
||||
|
||||
/// Trailer keys a full rewrite generates itself. Everything else is copied, with references
|
||||
/// renumbered, so a save does not drop metadata the reader accepted.
|
||||
fn trailer_key_rewritten(key: &[u8]) -> bool {
|
||||
matches!(
|
||||
key,
|
||||
b"Size" | b"Prev" | b"XRefStm" | b"Type" | b"W" | b"Index" | b"Filter" | b"DecodeParms" | b"Length" | b"Root" | b"Info" | b"Encrypt" | b"ID"
|
||||
)
|
||||
}
|
||||
|
||||
fn output_number(index: usize) -> Result<u32, CosError> {
|
||||
u32::try_from(index).ok().and_then(|n| n.checked_add(1)).ok_or_else(|| CosError::Syntax { offset: 0, detail: "too many objects to save".into() })
|
||||
}
|
||||
|
||||
@@ -301,3 +301,38 @@ fn a_nonpositive_crypt_filter_length_reads_as_the_default_key_length() {
|
||||
assert_eq!(content(&again), SECRET, "/Length {length}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_earlier_revision_opens_with_the_documents_own_credentials() {
|
||||
// The bytes a signature covers are an earlier revision of the file: the document that is
|
||||
// open can read them with its own key, so no password has to be kept or asked for again.
|
||||
for alg in ALL {
|
||||
let original = protect(alg, "user", "owner", -1);
|
||||
let mut doc = Document::open_with_password(Arc::new(original.clone()), Some("user")).unwrap();
|
||||
let info = doc.trailer().reference(b"Info").unwrap();
|
||||
doc.update_dict(info, |d| d.set(b"Title".to_vec(), Object::String(PdfString::text("Revised")))).unwrap();
|
||||
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
|
||||
let current = Document::open_with_password(Arc::new(saved), Some("user")).unwrap();
|
||||
assert!(matches!(Document::open(Arc::new(original.clone())), Err(CosError::NeedsPassword)), "{alg:?}: not without a password");
|
||||
let earlier = current.open_with_same_credentials(Arc::new(original.clone())).unwrap();
|
||||
assert_eq!(content(&earlier), SECRET, "{alg:?}: the earlier revision is readable");
|
||||
assert_eq!(title(&earlier).as_deref(), Some("Quarterly numbers"), "{alg:?}: and it is the earlier one");
|
||||
// Another file, encrypted with other keys: the document's key is no use, and it says so.
|
||||
let other = {
|
||||
let mut doc = Document::open(Arc::new(fixture())).unwrap();
|
||||
doc.set_encryption(&NewEncryption {
|
||||
algorithm: alg,
|
||||
user_password: "user",
|
||||
owner_password: "owner",
|
||||
permissions: -1,
|
||||
encrypt_metadata: true,
|
||||
seed: [3; 32],
|
||||
})
|
||||
.unwrap();
|
||||
write_full(&doc, &SaveOptions::default()).unwrap()
|
||||
};
|
||||
assert!(matches!(current.open_with_same_credentials(Arc::new(other)), Err(CosError::NeedsPassword)), "{alg:?}");
|
||||
// An unencrypted file opens as it always does.
|
||||
assert_eq!(content(¤t.open_with_same_credentials(Arc::new(fixture())).unwrap()), SECRET, "{alg:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,3 +327,68 @@ fn page_tree_loops_in_object_streams_do_not_crash_readers() {
|
||||
assert!(String::from_utf8_lossy(&packed).contains("/ObjStm"));
|
||||
assert_eq!(hayro_pages(&packed), 1);
|
||||
}
|
||||
|
||||
/// Catalog, empty page tree, and trailer entries a reader must not invent: a direct name, a
|
||||
/// direct string, and a string object reachable only from the trailer.
|
||||
fn trailer_fixture() -> Vec<u8> {
|
||||
let mut out = b"%PDF-1.4\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, body) in ["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "(only from the trailer)"].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(b"xref\n0 4\n0000000000 65535 f \n");
|
||||
for offset in offsets {
|
||||
out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(
|
||||
format!("trailer\n<< /Size 4 /Root 1 0 R /Version /1.7 /Custom (keep me) /Note 3 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes(),
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
fn trailer_custom(doc: &Document) -> Option<Vec<u8>> {
|
||||
doc.trailer().get(b"Custom").and_then(Object::as_string).map(|s| s.bytes.clone())
|
||||
}
|
||||
|
||||
fn trailer_note(doc: &Document) -> Option<Vec<u8>> {
|
||||
doc.trailer().get(b"Note").map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.bytes.clone()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_save_preserves_unknown_trailer_data() {
|
||||
let input = trailer_fixture();
|
||||
let doc = Document::open(Arc::new(input)).unwrap();
|
||||
assert_eq!(trailer_custom(&doc), Some(b"keep me".to_vec()));
|
||||
assert_eq!(trailer_note(&doc).as_deref(), Some(b"only from the trailer".as_slice()));
|
||||
assert_eq!(doc.trailer().get(b"Version").and_then(Object::as_name), Some(&b"1.7"[..]));
|
||||
assert!(doc.repair_log().is_empty());
|
||||
assert!(!doc.full_save_required());
|
||||
|
||||
let inc = Document::open(Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap())).unwrap();
|
||||
assert_eq!(trailer_custom(&inc), trailer_custom(&doc));
|
||||
assert_eq!(trailer_note(&inc), trailer_note(&doc));
|
||||
assert_eq!(inc.trailer().get(b"Version"), doc.trailer().get(b"Version"));
|
||||
assert!(inc.repair_log().is_empty());
|
||||
|
||||
for object_streams in [true, false] {
|
||||
let opts = SaveOptions { object_streams, ..SaveOptions::default() };
|
||||
let bytes = write_full(&doc, &opts).unwrap();
|
||||
let back = Document::open(Arc::new(bytes)).unwrap();
|
||||
assert!(back.root().is_some());
|
||||
assert!(back.repair_log().is_empty(), "{:?}", back.repair_log());
|
||||
assert_eq!(trailer_custom(&back), trailer_custom(&doc));
|
||||
assert_eq!(trailer_note(&back), trailer_note(&doc));
|
||||
assert_eq!(back.trailer().get(b"Version"), doc.trailer().get(b"Version"));
|
||||
}
|
||||
|
||||
let mut required = doc.clone();
|
||||
required.require_full_save();
|
||||
let back = Document::open(Arc::new(write_incremental(&required, &SaveOptions::default()).unwrap())).unwrap();
|
||||
assert!(back.root().is_some());
|
||||
assert!(back.repair_log().is_empty(), "{:?}", back.repair_log());
|
||||
assert_eq!(trailer_custom(&back), trailer_custom(&doc));
|
||||
assert_eq!(trailer_note(&back), trailer_note(&doc));
|
||||
assert_eq!(back.trailer().get(b"Version"), doc.trailer().get(b"Version"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
//! Property tests for what the security handler does to *content*, and to the ciphertext a
|
||||
//! stranger's file hands back.
|
||||
//!
|
||||
//! `src/tests.rs` covers this handler carefully with fixed vectors — including the `/O`, `/U` and
|
||||
//! file keys qpdf 12.4.2 produced for real files — plus one property over malformed dictionaries.
|
||||
//! What it does not vary is the *payload*. Upstream's encrypted round trips run three shapes: a
|
||||
//! 30-byte text, a 1000-byte ramp, and a 10-byte attachment in `identity_crypt_filters_leave_data_alone`.
|
||||
//! The shapes nobody walks are the ones that break CBC and readers: empty content, one block minus
|
||||
//! one, a ciphertext cut short by a damaged `/Length`, and keys of a length `create` never builds.
|
||||
//!
|
||||
//! - plaintext survives encryption at every length, for each algorithm and each crypt filter;
|
||||
//! - truncated or mutated ciphertext produces bytes rather than a panic (`AGENTS.md` §4: PdfCraft
|
||||
//! opens files from strangers, and encrypted streams in the wild do get truncated);
|
||||
//! - object keys are scoped to their object, and AES-256's documented exception still holds;
|
||||
//! - a file opens for exactly the passwords whose effective bytes match, stated as a rule over
|
||||
//! generated Unicode passwords instead of the three examples in `src/tests.rs` (the #1185 rule);
|
||||
//! - the AES-CBC and RC4 primitives invert each other for any key length, since `/Length` and
|
||||
//! crypt filters from a damaged file reach them with shapes `create` would never build.
|
||||
|
||||
use pdfcraft_crypt::{
|
||||
Algorithm, Auth, CryptError, Method, NewEncryption, PADDING, SecurityHandler, StreamKind, aes_cbc_decrypt, aes_cbc_encrypt, create,
|
||||
effective_password, pdfdoc_byte, rc4,
|
||||
};
|
||||
use proptest::prelude::*;
|
||||
|
||||
/// The first element of `/ID`, which every R2–R4 derivation hashes in (Algorithm 2).
|
||||
const ID: &[u8] = b"\x01\x23\x45\x67\x89\xab\xcd\xef\x0f\xed\xcb\xa9\x87\x65\x43\x21";
|
||||
|
||||
fn algorithms() -> impl Strategy<Value = Algorithm> {
|
||||
prop::sample::select(vec![Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128, Algorithm::Aes256])
|
||||
}
|
||||
|
||||
/// Content at the lengths where CBC padding goes wrong, plus anything in between.
|
||||
fn payloads() -> impl Strategy<Value = Vec<u8>> {
|
||||
prop_oneof![
|
||||
Just(Vec::new()),
|
||||
Just(vec![0x5cu8; 1]),
|
||||
Just(vec![0xa7u8; 15]),
|
||||
Just(vec![0x3fu8; 16]),
|
||||
Just(vec![0x9du8; 17]),
|
||||
Just(vec![0x10u8; 32]),
|
||||
prop::collection::vec(any::<u8>(), 1..96),
|
||||
]
|
||||
}
|
||||
|
||||
/// Passwords from an alphabet the two encodings disagree about: SASLprep deletes U+00AD, folds
|
||||
/// U+FF21 to "A", U+FB01 to "fi" and U+3000 to a space (revisions 5–6), while PDFDocEncoding has
|
||||
/// no byte for 'ğ' or CJK at all, which sends revisions 2–4 down their UTF-8 fallback.
|
||||
const PASSWORD_CHARS: [char; 12] = ['a', 'B', '7', ' ', '-', '\u{AD}', '\u{FF21}', '\u{FB01}', 'ı', 'ğ', '日', '\u{A0}'];
|
||||
|
||||
fn passwords() -> impl Strategy<Value = String> {
|
||||
prop::collection::vec(prop::sample::select(PASSWORD_CHARS.to_vec()), 0..12).prop_map(|chars| chars.into_iter().collect())
|
||||
}
|
||||
|
||||
/// `bytes` cut to 32 and padded with the standard padding string (Algorithm 2, step a).
|
||||
fn padded(bytes: &[u8]) -> Vec<u8> {
|
||||
let mut out = PADDING;
|
||||
let n = bytes.len().min(32);
|
||||
out[..n].copy_from_slice(&bytes[..n]);
|
||||
out.to_vec()
|
||||
}
|
||||
|
||||
/// Every byte string the handler may authenticate `pw` as. Revisions 2–4 use PDFDocEncoding, and
|
||||
/// for a password holding characters it lacks they also try the raw UTF-8 bytes (qpdf writes such
|
||||
/// passwords that way); revisions 5–6 use the SASLprep bytes. `effective_password` publishes the
|
||||
/// first of these per algorithm, and it is what callers such as Protect check before encrypting.
|
||||
fn candidates(algorithm: Algorithm, pw: &str) -> Vec<Vec<u8>> {
|
||||
let mut out = vec![effective_password(algorithm, pw)];
|
||||
if algorithm != Algorithm::Aes256 && pw.chars().any(|c| pdfdoc_byte(c).is_none()) {
|
||||
out.push(padded(&pw.as_bytes().iter().copied().take(32).collect::<Vec<u8>>()));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// What `create` keys `/O` from: the owner password, except on revisions 2–4 with no owner
|
||||
/// password given, where it falls back to the user password's bytes.
|
||||
fn owner_target(algorithm: Algorithm, user: &str, owner: &str) -> Vec<u8> {
|
||||
if algorithm != Algorithm::Aes256 && owner.is_empty() { effective_password(algorithm, user) } else { effective_password(algorithm, owner) }
|
||||
}
|
||||
|
||||
/// Which password `pw` is for this file. Owners are tried before users, so bytes that match both
|
||||
/// authenticate as owner — the order viewers use. `None` means it opens nothing.
|
||||
fn expected_auth(algorithm: Algorithm, user_target: &[u8], owner_target: &[u8], pw: &str) -> Option<Auth> {
|
||||
let tried = candidates(algorithm, pw);
|
||||
if tried.iter().any(|c| c == owner_target) {
|
||||
Some(Auth::Owner)
|
||||
} else if tried.iter().any(|c| c == user_target) {
|
||||
Some(Auth::User)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn make(algorithm: Algorithm, user: &str, owner: &str) -> SecurityHandler {
|
||||
create(&NewEncryption { algorithm, user_password: user, owner_password: owner, permissions: -1, encrypt_metadata: true, seed: [0x5e; 32] }, ID)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
proptest! {
|
||||
/// Encrypting then decrypting returns the input at every length, as a string and as each kind
|
||||
/// of stream — and is byte-for-byte reproducible, because re-running a save must not silently
|
||||
/// produce different bytes. Payloads of at least one block must come back changed: RC4 would
|
||||
/// need an all-zero keystream to leave them alone, and CBC cannot shrink a padded block.
|
||||
#[test]
|
||||
fn content_survives_the_trip_at_every_length(alg in algorithms(), data in payloads()) {
|
||||
let h = make(alg, "user", "owner");
|
||||
|
||||
// The key width each algorithm promises: 5 bytes for export-grade 40-bit RC4, 16 for
|
||||
// AES-128 and RC4-128, 32 for AES-256. `key_len` is the only place that decides this and
|
||||
// nothing in either suite reads it back, so `if d.r == 2 { 5 }` becoming `{ 4 }` today
|
||||
// produces a file key of the wrong size that every test still calls fine.
|
||||
let want_len = match alg { Algorithm::Rc4_40 => 5, Algorithm::Aes128 | Algorithm::Rc4_128 => 16, Algorithm::Aes256 => 32 };
|
||||
prop_assert_eq!(h.file_key().len(), want_len, "{:?} must use a {} byte file key", alg, want_len);
|
||||
|
||||
let h = make(alg, "user", "owner");
|
||||
for num in [0u32, 1, 4096, u32::MAX] {
|
||||
let generation = (num % 65_535) as u16;
|
||||
|
||||
let enc = h.encrypt_string(num, generation, &data);
|
||||
prop_assert_eq!(h.decrypt_string(num, generation, &enc), &data[..], "string round trip at {} bytes ({:?})", data.len(), alg);
|
||||
prop_assert_eq!(h.encrypt_string(num, generation, &data), enc, "string encryption must be reproducible ({:?})", alg);
|
||||
|
||||
for kind in [StreamKind::Normal, StreamKind::EmbeddedFile, StreamKind::Named(b"StdCF")] {
|
||||
let enc = h.encrypt_stream(num, generation, &data, kind);
|
||||
prop_assert_eq!(h.decrypt_stream(num, generation, &enc, kind), &data[..], "stream round trip at {} bytes ({:?}, {:?})", data.len(), alg, kind);
|
||||
prop_assert_eq!(h.encrypt_stream(num, generation, &data, kind), enc, "{:?} {:?} encryption must be reproducible", alg, kind);
|
||||
}
|
||||
|
||||
if data.len() >= 16 {
|
||||
prop_assert_ne!(h.encrypt_string(num, generation, &data), &data[..], "one block left in plain sight ({:?})", alg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ciphertext of any length — truncated by a damaged `/Length`, padded out with garbage, or
|
||||
/// byte-flipped inside — must decrypt to bytes and never read past the end. AES discards a
|
||||
/// trailing partial block and leaves invalid padding in place rather than failing, so its
|
||||
/// output can only be a whole number of blocks minus at most one; RC4 keeps its length
|
||||
/// whatever the bytes are. An input shorter than the IV has nothing left to decode.
|
||||
#[test]
|
||||
fn damaged_ciphertext_never_panics(alg in algorithms(), enc in prop::collection::vec(any::<u8>(), 0..64)) {
|
||||
let h = make(alg, "user", "owner");
|
||||
for cut in 0..=enc.len() {
|
||||
let broken = &enc[..cut];
|
||||
let out = h.decrypt_string(7, 3, broken);
|
||||
if alg == Algorithm::Rc4_40 || alg == Algorithm::Rc4_128 {
|
||||
prop_assert_eq!(out.len(), cut, "RC4 must be length-preserving ({:?})", alg);
|
||||
} else {
|
||||
let blocks = cut.saturating_sub(16) / 16;
|
||||
prop_assert!(out.len() <= blocks * 16, "{:?}: {} bytes of ciphertext yielded {} decrypted bytes", alg, cut, out.len());
|
||||
prop_assert!(blocks * 16 - out.len() <= 16, "{:?}: padding removed more than one block", alg);
|
||||
}
|
||||
}
|
||||
|
||||
// Flip every byte of a real ciphertext in turn: no position may crash the reader.
|
||||
let full = h.encrypt_string(7, 3, b"twenty-four bytes of PDF content");
|
||||
for i in 0..full.len() {
|
||||
let mut mutated = full.clone();
|
||||
mutated[i] ^= 0xFF;
|
||||
let _ = h.decrypt_string(7, 3, &mutated);
|
||||
}
|
||||
}
|
||||
|
||||
/// A crypt filter name decides whether content is encrypted at all: PDF 2.0 permits
|
||||
/// `/StmF /Identity` with attachments still encrypted, and a name that appears in neither `/CF`
|
||||
/// nor as `Identity` resolves to Identity rather than failing. The reported answer and the
|
||||
/// bytes must agree, at every length including empty (where AES still adds a whole block).
|
||||
/// `/EFF` is set here so embedded files and normal streams must take *different* paths: the
|
||||
/// mix-up that leaves an attachment in plain sight, or encrypts a stream its own filter says
|
||||
/// should not be. And sometimes `/CF` carries an entry *named* `Identity` that claims
|
||||
/// `/CFM /AESV2`: a producer writing nonsense. The name wins and the bytes stay plain, since
|
||||
/// encrypting what both sides agreed is plaintext would write a file other readers cannot open.
|
||||
#[test]
|
||||
fn crypt_filters_decide_what_gets_encrypted(
|
||||
data in payloads(),
|
||||
name in prop::sample::select(vec!["Identity", "StdCF", "Nope"]),
|
||||
bogus_identity_in_cf in any::<bool>(),
|
||||
) {
|
||||
let mut d = make(Algorithm::Aes256, "", "owner").dict().clone();
|
||||
d.str_f = name.as_bytes().to_vec();
|
||||
d.stm_f = b"Identity".to_vec();
|
||||
d.ef_f = b"StdCF".to_vec();
|
||||
if bogus_identity_in_cf {
|
||||
d.crypt_filters.push((b"Identity".to_vec(), Method::Aes256));
|
||||
}
|
||||
let h = SecurityHandler::open(d, ID, None).unwrap();
|
||||
|
||||
// Only `StdCF` really encrypts; `Identity` does not even when `/CF` defines it, and a name
|
||||
// in neither place resolves to Identity rather than failing.
|
||||
let strings_encrypted = name == "StdCF";
|
||||
prop_assert_eq!(h.encrypts_strings(), strings_encrypted, "/StrF /{} reported {}", name, strings_encrypted);
|
||||
|
||||
let enc = h.encrypt_string(3, 0, &data);
|
||||
if strings_encrypted {
|
||||
prop_assert_ne!(&enc, &data, "/StrF /{} left strings in plain sight", name);
|
||||
prop_assert!(enc.len() > data.len(), "AES always pads: {} bytes of ciphertext for {} plaintext", enc.len(), data.len());
|
||||
prop_assert_eq!(h.decrypt_string(3, 0, &enc), &data[..], "/StrF /{} round trip", name);
|
||||
} else {
|
||||
prop_assert_eq!(&enc, &data, "/StrF /{} must pass strings through", name);
|
||||
}
|
||||
|
||||
// A normal stream follows `/StmF /Identity` here, whatever the string filter says.
|
||||
prop_assert_eq!(&h.encrypt_stream(3, 0, &data, StreamKind::Normal), &data[..], "normal streams follow /StmF Identity");
|
||||
// An embedded file follows `/EFF /StdCF`, so it is encrypted at every length — empty
|
||||
// included, where AES still emits a padded block — and comes back intact.
|
||||
let ef = h.encrypt_stream(3, 0, &data, StreamKind::EmbeddedFile);
|
||||
prop_assert_ne!(&ef, &data[..], "/EFF /StdCF left an embedded file in plain sight at {} bytes", data.len());
|
||||
prop_assert_eq!(h.decrypt_stream(3, 0, &ef, StreamKind::EmbeddedFile), &data[..], "embedded file round trip");
|
||||
// An unknown named filter is Identity, not an error; a known one uses its real method.
|
||||
prop_assert_eq!(&h.encrypt_stream(3, 0, &data, StreamKind::Named(b"Missing")), &data[..], "an unknown crypt filter must not encrypt");
|
||||
prop_assert_ne!(&h.encrypt_stream(3, 0, &data, StreamKind::Named(b"StdCF")), &data[..], "a filter named /StdCF must encrypt");
|
||||
}
|
||||
|
||||
/// Object keys are scoped to the object (Algorithm 1, §7.6.3.3): RC4 and AES-128 fold the
|
||||
/// object number and generation into the key, so another object must not read these bytes back.
|
||||
/// AES-256 is the documented exception — revisions 5 and 6 use the file key for every object.
|
||||
/// The comparisons that decrypt under a foreign number carry the weight: wrong keystream bytes
|
||||
/// match the plaintext only by a chance of one in 256 per byte. Checking that *encryption*
|
||||
/// differs would prove less, because our initial vector already covers `/Num`.
|
||||
#[test]
|
||||
fn object_keys_are_scoped_to_their_object(alg in prop::sample::select(vec![Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128]), data in prop::collection::vec(any::<u8>(), 8..40)) {
|
||||
let h = make(alg, "user", "owner");
|
||||
|
||||
let enc = h.encrypt_string(100, 0, &data);
|
||||
prop_assert_eq!(h.decrypt_string(100, 0, &enc), &data[..], "{:?} reads its own object back", alg);
|
||||
prop_assert_ne!(h.decrypt_string(101, 0, &enc), &data[..], "{:?}: another object number must not decrypt these bytes", alg);
|
||||
prop_assert_ne!(h.decrypt_string(100, 1, &enc), &data[..], "{:?}: another generation must not decrypt these bytes", alg);
|
||||
|
||||
// Algorithm 1 folds the LOW THREE bytes of `/Num` into the key. Two directions are pinned:
|
||||
// numbers that differ inside those three bytes must separate (a fold to `[..2]` would let
|
||||
// object 0 and object 65_536 share a key, silently handing one object's plaintext to another),
|
||||
// and numbers that differ only ABOVE them must share it — the fold is three bytes by design,
|
||||
// so a widening to four would break this and nothing else in the crate notices.
|
||||
let low = h.encrypt_string(0, 0, &data);
|
||||
prop_assert_ne!(&h.decrypt_string(65_536, 0, &low)[..], &data[..], "{:?}: /Num is folded three bytes wide; 0 and 65_536 must not share a key", alg);
|
||||
prop_assert_ne!(&h.decrypt_string(1 << 23, 0, &low)[..], &data[..], "{:?}: the third byte of /Num must still reach the key", alg);
|
||||
prop_assert_eq!(&h.decrypt_string(1 << 24, 0, &low)[..], &data[..], "{:?}: /Num is folded three bytes wide, so a difference above bit 23 shares the key by design", alg);
|
||||
|
||||
let r6 = make(Algorithm::Aes256, "user", "owner");
|
||||
let enc6 = r6.encrypt_string(100, 0, &data);
|
||||
prop_assert_eq!(&r6.decrypt_string(101, 3, &enc6)[..], &data[..], "AES-256 uses the file key for every object (§7.6.3.3)");
|
||||
// ...which is exactly why the initial vector has to vary: revisions 5 and 6 reuse one key
|
||||
// for the whole file, so two objects holding the same bytes must still produce different
|
||||
// ciphertext. Identical bytes for a different `/Num` would be CBC initial-vector reuse —
|
||||
// the flaw that leaks structure in encrypted data.
|
||||
prop_assert_ne!(&r6.encrypt_string(101, 0, &data)[..], &enc6[..], "AES-256 reused the same initial vector for two objects");
|
||||
prop_assert_ne!(&r6.encrypt_string(100, 1, &data)[..], &enc6[..], "AES-256 reused the same initial vector across generations");
|
||||
}
|
||||
|
||||
/// A file created for `user` and `owner` opens for exactly the passwords whose effective bytes
|
||||
/// equal one of theirs, and reports the role that match earned — owner first, since viewers try
|
||||
/// `/O` before `/U`. Everything else is `WrongPassword`: never an opening, and no panic on odd
|
||||
/// Unicode. This is the rule #1185 became (bytes decide, so a soft hyphen alone is no password
|
||||
/// and U+FF21 is "A"), stated for all inputs rather than the three examples in `src/tests.rs`.
|
||||
/// It also pins #1762's upgrade path: a user session gains owner rights on the owner password
|
||||
/// and on nothing else.
|
||||
#[test]
|
||||
fn authentication_accepts_exactly_the_matching_passwords(
|
||||
alg in algorithms(),
|
||||
mut user in passwords(),
|
||||
owner in prop::sample::select(vec!["owner".to_string(), String::new(), "Owner\u{AD}".into(), "\u{FF21}".into()]),
|
||||
extra in passwords(),
|
||||
) {
|
||||
// `user` may have been grown past the 32-byte cut above; create with that spelling.
|
||||
// Grow the legacy password PAST the 32-byte cut BEFORE the file is built from it, so a
|
||||
// partner differing only beyond byte 32 is the same password to this file.
|
||||
if alg != Algorithm::Aes256 {
|
||||
user = format!("{user}{}", "abcdefghijklmnopqrstuvwxyz0123456789".repeat(2));
|
||||
}
|
||||
|
||||
let h = make(alg, &user, &owner);
|
||||
let d = h.dict().clone();
|
||||
let user_bytes = effective_password(alg, &user);
|
||||
let owner_bytes = owner_target(alg, &user, &owner);
|
||||
|
||||
// A collision engineered by construction, not by luck. Revisions 2-4 cut the password at
|
||||
// 32 bytes (Algorithm 2), so any spelling that agrees on the first 32 is ONE password;
|
||||
// revisions 5-6 run SASLprep, which deletes U+00AD. Growing past byte 32 guarantees the
|
||||
// partner is a different string with identical bytes at every draw — earlier drafts here
|
||||
// claimed `(` alone did it, which is false: appending "(" to a short password DISPLACES the
|
||||
// padding (pad("a") is [61,28,BF..], pad("a(") is [61,28,28,BF..]), and this alphabet draws
|
||||
// at most 11 characters, so chance produced zero legacy collisions in 1,566 measured cases.
|
||||
|
||||
let mut suspects: Vec<String> = vec![user.clone(), owner.clone(), String::new()];
|
||||
// Near misses are PREPENDED: appending to a legacy password already past the cut would be
|
||||
// another collision rather than a refusal, which would silently thin the negative testing.
|
||||
let mut near_misses: Vec<String> = vec![format!("q{user}"), extra.clone()];
|
||||
if alg == Algorithm::Aes256 && !user.is_empty() {
|
||||
near_misses.push(format!("{user}q"));
|
||||
}
|
||||
|
||||
let collision_partner = if alg == Algorithm::Aes256 { format!("{user}\u{AD}") } else { format!("{user}Z") };
|
||||
prop_assert_ne!(&collision_partner[..], &user[..], "the collision partner must be a different string");
|
||||
prop_assert_eq!(effective_password(alg, &collision_partner), effective_password(alg, &user), "{:?}: {:?} must become the same bytes as {:?}", alg, collision_partner, user);
|
||||
|
||||
// The mistake itself, pinned so it cannot come back: a short legacy password plus "(" is NOT the
|
||||
// same password, because it shifts the padding by one byte instead of filling a slot.
|
||||
if alg != Algorithm::Aes256 {
|
||||
prop_assert_ne!(effective_password(alg, "a("), effective_password(alg, "a"), "appending ( to a short password displaces the padding; that is not a collision");
|
||||
prop_assert_eq!(effective_password(alg, &"a".repeat(31)), effective_password(alg, &format!("{}(", "a".repeat(31))), "at exactly 31 characters ( does land in the padding slot, and THAT is a collision");
|
||||
}
|
||||
|
||||
suspects.extend(near_misses);
|
||||
suspects.push(collision_partner.clone());
|
||||
suspects.dedup();
|
||||
|
||||
for suspect in &suspects {
|
||||
let want = expected_auth(alg, &user_bytes, &owner_bytes, suspect);
|
||||
match (want, SecurityHandler::open(d.clone(), ID, Some(suspect))) {
|
||||
(Some(role), Ok(got)) => {
|
||||
prop_assert_eq!(got.auth(), role, "{:?}: {:?} should open as {:?}", alg, suspect, role);
|
||||
prop_assert_eq!(got.file_key(), h.file_key(), "{:?}: whichever password opens the file, the key is the same", alg);
|
||||
}
|
||||
(Some(role), Err(e)) => prop_assert!(false, "{:?}: {:?} must open as {:?} but gave {:?}", alg, suspect, role, e),
|
||||
(None, Ok(got)) => prop_assert!(false, "{:?}: {:?} is not a password for this file, yet it opened as {:?}", alg, suspect, got.auth()),
|
||||
(None, Err(CryptError::WrongPassword)) => {}
|
||||
(None, Err(e)) => prop_assert!(false, "{:?}: {:?} gave {}, expected WrongPassword", alg, suspect, e),
|
||||
}
|
||||
|
||||
// The owner password upgrades a user session; nothing else does. When the two share
|
||||
// bytes, `create` gave /O the user's own bytes (revisions 2–4) or left it empty
|
||||
// (AES-256, #1185's sharp edge), so the open password *is* an owner password here.
|
||||
if let Ok(session) = SecurityHandler::open(d.clone(), ID, Some(&user)) {
|
||||
let is_owner_password = expected_auth(alg, &user_bytes, &owner_bytes, suspect) == Some(Auth::Owner);
|
||||
let upgrade = session.authenticate_owner(ID, suspect).map(|u| u.auth());
|
||||
if is_owner_password {
|
||||
prop_assert_eq!(upgrade, Ok(Auth::Owner), "{:?}: {:?} must unlock owner rights", alg, suspect);
|
||||
} else {
|
||||
prop_assert_eq!(upgrade, Err(CryptError::WrongPassword), "{:?}: {:?} must not grant owner rights", alg, suspect);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `None` means no password was offered: the handler tries the empty string, and nothing
|
||||
// else (a file with a real open password never opens silently).
|
||||
let want_none = expected_auth(alg, &user_bytes, &owner_bytes, "");
|
||||
match (want_none, SecurityHandler::open(d.clone(), ID, None)) {
|
||||
(Some(role), Ok(got)) => prop_assert_eq!(got.auth(), role, "{:?}: no password opened as {:?}", alg, got.auth()),
|
||||
(None, Ok(got)) => prop_assert!(false, "{:?}: the empty password is not valid, yet no password opened as {:?}", alg, got.auth()),
|
||||
(Some(role), Err(e)) => prop_assert!(false, "{:?}: the empty password should open as {:?} but gave {:?}", alg, role, e),
|
||||
(None, Err(CryptError::WrongPassword)) => {}
|
||||
(None, Err(e)) => prop_assert!(false, "{:?}: no password gave {}, expected WrongPassword", alg, e),
|
||||
}
|
||||
}
|
||||
|
||||
/// The AES-CBC and RC4 primitives taken directly: a damaged file's `/Length` and crypt filters
|
||||
/// hand them keys and data of shapes `create` would never build, so they must invert each other
|
||||
/// for any key length (`Cipher::new` takes AES-256 at 32 bytes and zero-padded AES-128 below
|
||||
/// it, identically on both sides of the trip) and every payload length.
|
||||
#[test]
|
||||
fn primitives_are_invertible_for_any_key_and_length(
|
||||
key in prop::collection::vec(any::<u8>(), 1..40),
|
||||
data in payloads(),
|
||||
iv in prop::collection::vec(any::<u8>(), 16..24),
|
||||
) {
|
||||
let iv: [u8; 16] = *iv.as_slice().first_chunk::<16>().expect("the iv strategy draws at least 16 bytes; a silent zero fallback would hide a generator change");
|
||||
|
||||
let enc = aes_cbc_encrypt(&key, &iv, &data, true);
|
||||
prop_assert_eq!(aes_cbc_decrypt(&key, &iv, &enc, true), &data[..], "padded AES round trip at {} bytes", data.len());
|
||||
prop_assert!(enc.len() >= 16 && enc.len().is_multiple_of(16), "PKCS#5 makes whole blocks: {} bytes for {} plaintext", enc.len(), data.len());
|
||||
|
||||
// Without padding, a partial block is zero-padded rather than refused, and extra trailing
|
||||
// bytes decrypt to more output instead of being dropped.
|
||||
let raw = aes_cbc_encrypt(&key, &iv, &data, false);
|
||||
prop_assert_eq!(raw.len(), data.len().div_ceil(16) * 16, "unpadded AES output is whole blocks");
|
||||
let mut longer = raw.clone();
|
||||
longer.extend_from_slice(b"0123456789abcdef");
|
||||
prop_assert_eq!(aes_cbc_decrypt(&key, &iv, &longer, false).len(), raw.len() + 16, "a whole extra block decrypts to a whole extra block");
|
||||
|
||||
// RC4 undoes itself at every length; an empty key encrypts nothing, which is why the
|
||||
// handler never derives an empty object key.
|
||||
prop_assert_eq!(rc4(&key, &rc4(&key, &data)), &data[..], "RC4 round trip at {} bytes", data.len());
|
||||
prop_assert_eq!(rc4(&[], &data), &data[..], "an empty RC4 key passes bytes through");
|
||||
}
|
||||
|
||||
/// `/Perms` certifies the permission bits and `/EncryptMetadata`: decrypted with the file key it
|
||||
/// must hold `/P` little-endian, four set bytes, 'T' or 'F', and the `adb` sentinel (Algorithm 10).
|
||||
/// Nothing else in this crate reads it — `cos` writes it out and no code takes it back apart — so
|
||||
/// without this property a flipped metadata byte would tell every other viewer the wrong thing about
|
||||
/// our own file while all our tests stayed green. The older revisions we create emit no `/Perms`
|
||||
/// at all, which is recorded here as well so a change in what goes out shows up rather than
|
||||
/// shipping quietly.
|
||||
#[test]
|
||||
fn perms_value_agrees_with_the_permissions_it_certifies(permissions in any::<i32>(), encrypt_metadata in any::<bool>()) {
|
||||
let h = create(
|
||||
&NewEncryption { algorithm: Algorithm::Aes256, user_password: "user", owner_password: "owner", permissions, encrypt_metadata, seed: [0x5e; 32] },
|
||||
ID,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
prop_assert_eq!(h.dict().perms.len(), 16, "/Perms is one encrypted block");
|
||||
let open = aes_cbc_decrypt(h.file_key(), &[0u8; 16], &h.dict().perms, false);
|
||||
|
||||
// Bits 1-2 clear and the reserved bits set (§7.6.4.2 Table 22) is what `/P` must say, and the
|
||||
// encrypted certificate has to repeat it byte for byte.
|
||||
let normalised = (permissions | !0xFFF | 0xC0) & !0x3;
|
||||
prop_assert_eq!(&open[..4], &normalised.to_le_bytes(), "/Perms must carry /P");
|
||||
prop_assert_eq!(h.dict().p, normalised, "the /P written must match the permission bits drawn");
|
||||
prop_assert_eq!(&open[4..8], &[0xFF; 4], "the four bytes following /P are set");
|
||||
prop_assert_eq!(open[8], if encrypt_metadata { b'T' } else { b'F' }, "/Perms must report /EncryptMetadata");
|
||||
prop_assert_eq!(&open[9..12], b"adb", "the adb sentinel must be present");
|
||||
|
||||
// A user session's answers come from those same bits, not from the owner shortcut.
|
||||
let as_user = SecurityHandler::open(h.dict().clone(), ID, Some("user")).unwrap();
|
||||
prop_assert_eq!(as_user.permissions().print(), normalised & 0b100 != 0, "/P bit 3 decides printing");
|
||||
prop_assert_eq!(as_user.permissions().copy(), normalised & 0b1_0000 != 0, "/P bit 5 decides copying");
|
||||
prop_assert!(!as_user.permissions().owner, "the user password must not report owner rights");
|
||||
|
||||
// Bytes 13-16 of /Perms are the non-repeating filler. Nothing reads them, so pinning that
|
||||
// they move with the seed is the only way to notice a producer that freezes them at zero.
|
||||
let other = create(
|
||||
&NewEncryption { algorithm: Algorithm::Aes256, user_password: "user", owner_password: "owner", permissions, encrypt_metadata, seed: [0x11; 32] },
|
||||
ID,
|
||||
)
|
||||
.unwrap();
|
||||
let open_other = aes_cbc_decrypt(other.file_key(), &[0u8; 16], &other.dict().perms, false);
|
||||
prop_assert_ne!(&open[12..16], &open_other[12..16], "the four filler bytes of /Perms must follow the seed, not sit constant");
|
||||
|
||||
// Revisions below 5 carry no /Perms; pinned so the boundary of what we emit is explicit.
|
||||
for older in [Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128] {
|
||||
let legacy = make(older, "user", "owner");
|
||||
prop_assert!(legacy.dict().perms.is_empty(), "{:?} must emit no /Perms", older);
|
||||
}
|
||||
}
|
||||
/// Revisions 4 mix four set bytes into every file key when `/EncryptMetadata` is false (Algorithm
|
||||
/// 2, step f), so the same password over the same `/ID` must produce a *different* key than when
|
||||
/// metadata is encrypted. Every other test in this crate builds `encrypt_metadata: true` only,
|
||||
/// which is exactly why that step is reachable but never walked: switching it off entirely changed
|
||||
/// nothing any test could see. This also records the rule that revisions below 4 always encrypt
|
||||
/// metadata whatever was asked, so the boundary between the two is named rather than assumed.
|
||||
#[test]
|
||||
fn metadata_flag_changes_the_key_it_claims_to_change(data in payloads()) {
|
||||
let build = |encrypt_metadata: bool| {
|
||||
create(&NewEncryption { algorithm: Algorithm::Aes128, user_password: "user", owner_password: "owner", permissions: -1, encrypt_metadata, seed: [0x5e; 32] }, ID).unwrap()
|
||||
};
|
||||
let (yes, no) = (build(true), build(false));
|
||||
|
||||
// Algorithm 2, step f: revisions 4 hash four set bytes into every key when metadata stays
|
||||
// plain, so two files with identical passwords and the same /ID cannot share a key.
|
||||
prop_assert_ne!(yes.file_key(), no.file_key(), "R4 must fold four set bytes into the key when /EncryptMetadata is false (Algorithm 2, step f)");
|
||||
prop_assert_eq!(no.dict().encrypt_metadata, false, "the flag must reach the dictionary");
|
||||
prop_assert!(!no.encrypts_metadata(), "/EncryptMetadata false must report itself honestly");
|
||||
prop_assert!(yes.encrypts_metadata(), "and true must report true");
|
||||
|
||||
// Both still open on the same password and round trip at every length: a derivation that
|
||||
// drifted would show up as an unreadable file rather than passing quietly.
|
||||
for h in [&yes, &no] {
|
||||
let open = SecurityHandler::open(h.dict().clone(), ID, Some("user")).unwrap();
|
||||
prop_assert_eq!(open.file_key(), h.file_key(), "the user password must still derive this file's key");
|
||||
let enc = h.encrypt_string(7, 0, &data);
|
||||
prop_assert_eq!(&open.decrypt_string(7, 0, &enc)[..], &data[..], "round trip at {} bytes", data.len());
|
||||
}
|
||||
|
||||
// And the two files must not read each other's content: different keys, different plaintext.
|
||||
if data.len() >= 8 {
|
||||
prop_assert_ne!(&yes.decrypt_string(7, 0, &no.encrypt_string(7, 0, &data))[..], &data[..], "a key built for plain metadata must not decrypt a file whose keys encrypt it");
|
||||
}
|
||||
|
||||
// Revisions below 4 always encrypt metadata, whatever the caller asked for.
|
||||
let legacy = create(&NewEncryption { algorithm: Algorithm::Rc4_128, user_password: "user", owner_password: "owner", permissions: -1, encrypt_metadata: false, seed: [0x5e; 32] }, ID).unwrap();
|
||||
prop_assert!(legacy.dict().encrypt_metadata && legacy.encrypts_metadata(), "V<4 always encrypts metadata, as create() documents");
|
||||
}
|
||||
|
||||
}
|
||||
+58
-17
@@ -11,7 +11,9 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
|
||||
use pdfcraft_fonts::{GlyphError, GlyphOutline, ShapedCluster, ShapedScript, Std14, literal, shape_arabic, shape_thai, shaped_glyph, win_ansi};
|
||||
use pdfcraft_fonts::{
|
||||
GlyphError, GlyphOutline, ShapedCluster, ShapedScript, Std14, literal, shape_arabic, shape_hebrew, shape_thai, shaped_glyph, win_ansi,
|
||||
};
|
||||
use unicode_bidi::{Level, ParagraphBidiInfo};
|
||||
|
||||
use crate::{EditError, MarkKind, check, contents, matrix_number, n, page_list, place_tagged};
|
||||
@@ -239,27 +241,28 @@ fn wrapped(t: &AddedText) -> Vec<(String, bool)> {
|
||||
let width = (t.rect[2] - t.rect[0]).max(t.size);
|
||||
let mut out = Vec::new();
|
||||
for para in t.text.split('\n') {
|
||||
let arabic = para.chars().any(is_arabic);
|
||||
// Arabic or Hebrew: laid out with the bidirectional algorithm and shaped.
|
||||
let rtl_script = para.chars().any(|c| is_arabic(c) || is_hebrew(c));
|
||||
let thai = para.chars().any(is_thai);
|
||||
if thai
|
||||
&& !arabic
|
||||
&& !rtl_script
|
||||
&& let Ok(lines) = thai_lines(t, para, width)
|
||||
{
|
||||
out.extend(lines.into_iter().map(|line| (line, false)));
|
||||
continue;
|
||||
}
|
||||
let rtl = arabic && unicode_bidi::get_base_direction(para) == unicode_bidi::Direction::Rtl;
|
||||
let rtl = rtl_script && unicode_bidi::get_base_direction(para) == unicode_bidi::Direction::Rtl;
|
||||
// Arabic words are shaped one by one (joining stops at spaces), so a line's width is the
|
||||
// sum of its words' and shaping stays linear in the paragraph's length. The space is the
|
||||
// paragraph's; a space between Latin words is drawn in the item's font, a few hundredths
|
||||
// of an em apart.
|
||||
let measure = |s: &str| if arabic || thai { shaped_width(t, s, rtl) } else { t.family.width(s, t.size, t.bold, t.italic) };
|
||||
let measure = |s: &str| if rtl_script || thai { shaped_width(t, s, rtl) } else { t.family.width(s, t.size, t.bold, t.italic) };
|
||||
let space = measure(" ");
|
||||
let (mut line, mut line_w) = (String::new(), 0.0);
|
||||
for word in para.split(' ') {
|
||||
let candidate = if line.is_empty() { word.to_string() } else { format!("{line} {word}") };
|
||||
let candidate_w =
|
||||
if arabic || thai { if line.is_empty() { measure(word) } else { line_w + space + measure(word) } } else { measure(&candidate) };
|
||||
if rtl_script || thai { if line.is_empty() { measure(word) } else { line_w + space + measure(word) } } else { measure(&candidate) };
|
||||
if !line.is_empty() && candidate_w > width {
|
||||
out.push((std::mem::take(&mut line), rtl));
|
||||
line = word.to_string();
|
||||
@@ -313,9 +316,16 @@ fn is_arabic(c: char) -> bool {
|
||||
matches!(u32::from(c), 0x0600..=0x06FF | 0x0750..=0x077F | 0x0870..=0x08FF | 0xFB50..=0xFDFF | 0xFE70..=0xFEFC)
|
||||
}
|
||||
|
||||
/// Hebrew letters, points, cantillation marks and punctuation (and the presentation forms): an
|
||||
/// item with any of them is drawn with the craft-fonts Hebrew face.
|
||||
fn is_hebrew(c: char) -> bool {
|
||||
matches!(u32::from(c), 0x0591..=0x05F4 | 0xFB1D..=0xFB4F)
|
||||
}
|
||||
|
||||
/// The first character of `t` that would be drawn as `?`: outside WinAnsiEncoding and not drawn
|
||||
/// with the craft-fonts Arabic face (#125), or Arabic that the face can't draw (or that has no
|
||||
/// face to draw it). Page-content tools refuse such text; the Add-text editor keeps it open.
|
||||
/// with the craft-fonts Arabic (#125) or Hebrew (#128) face, or Arabic or Hebrew that the face can't
|
||||
/// draw (or that has no face to draw it). Page-content tools refuse such text; the Add-text editor
|
||||
/// keeps it open.
|
||||
pub fn first_undrawable(t: &AddedText) -> Option<char> {
|
||||
if t.text.chars().any(is_thai) {
|
||||
if let Some(c) = t.text.chars().find(|c| is_thai(*c) && !pdfcraft_fonts::thai_has(*c)) {
|
||||
@@ -324,11 +334,13 @@ pub fn first_undrawable(t: &AddedText) -> Option<char> {
|
||||
let rest = AddedText { text: t.text.chars().filter(|c| !is_thai(*c)).collect(), ..t.clone() };
|
||||
return first_undrawable(&rest);
|
||||
}
|
||||
// Arabic that nothing can draw: there's no face, or the face lacks it.
|
||||
if let Some(c) = t.text.chars().find(|c| shaped_arabic(*c) && !pdfcraft_fonts::arabic_has(*c)) {
|
||||
// Arabic or Hebrew that nothing can draw: there's no face, or the face lacks it.
|
||||
if let Some(c) =
|
||||
t.text.chars().find(|c| (shaped_arabic(*c) && !pdfcraft_fonts::arabic_has(*c)) || (is_hebrew(*c) && !pdfcraft_fonts::hebrew_has(*c)))
|
||||
{
|
||||
return Some(c);
|
||||
}
|
||||
if pdfcraft_fonts::document_arabic_font().is_none() || !t.text.chars().any(is_arabic) {
|
||||
if !shaped_by_a_face(t) {
|
||||
// Drawn line by line in the item's standard font (`\n` splits lines). Without the face
|
||||
// that is also how `draw` redraws an existing item, so it's checked that way.
|
||||
return t.text.split('\n').find_map(pdfcraft_fonts::first_non_win_ansi);
|
||||
@@ -343,6 +355,13 @@ pub fn first_undrawable(t: &AddedText) -> Option<char> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `t` has Arabic or Hebrew that its craft-fonts face draws (so `draw` lays it out with
|
||||
/// [`draw_shaped`]).
|
||||
fn shaped_by_a_face(t: &AddedText) -> bool {
|
||||
(pdfcraft_fonts::document_arabic_font().is_some() && t.text.chars().any(is_arabic))
|
||||
|| (pdfcraft_fonts::document_hebrew_font(false).is_some() && t.text.chars().any(is_hebrew))
|
||||
}
|
||||
|
||||
/// An Arabic character the face shapes and draws (U+061C, a direction mark, is dropped).
|
||||
fn shaped_arabic(c: char) -> bool {
|
||||
is_arabic(c) && !is_bidi_control(c)
|
||||
@@ -391,12 +410,16 @@ fn shaped_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec<Piece>, f6
|
||||
let run_rtl = levels.get(run.start).is_some_and(Level::is_rtl);
|
||||
// Runs lie on character boundaries; a missing slice is skipped, never a panic.
|
||||
let Some(text) = line.get(run) else { continue };
|
||||
// Arabic letters take the Arabic face; so do spaces and punctuation between them, which the
|
||||
// shaper then mirrors. Latin text (and what the face lacks) keeps the item's font.
|
||||
// Arabic and Hebrew letters take their face; so do spaces and punctuation between them
|
||||
// (Hebrew's when they follow Hebrew), which the shaper then mirrors. Latin text (and what
|
||||
// the faces lack) keeps the item's font.
|
||||
let mut segments: Vec<(Option<ShapedScript>, String)> = Vec::new();
|
||||
for c in text.chars().filter(|c| !is_bidi_control(*c)) {
|
||||
let after_hebrew = segments.last().is_some_and(|(kind, _)| *kind == Some(ShapedScript::Hebrew));
|
||||
let script = if is_thai(c) {
|
||||
Some(ShapedScript::Thai)
|
||||
} else if is_hebrew(c) || (run_rtl && after_hebrew && !c.is_alphanumeric() && pdfcraft_fonts::hebrew_has(c)) {
|
||||
Some(ShapedScript::Hebrew)
|
||||
} else if is_arabic(c) || (run_rtl && !c.is_alphanumeric() && pdfcraft_fonts::arabic_has(c)) {
|
||||
Some(ShapedScript::Arabic)
|
||||
} else {
|
||||
@@ -414,6 +437,7 @@ fn shaped_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec<Piece>, f6
|
||||
pieces.push(if let Some(script) = script {
|
||||
let shaped = match script {
|
||||
ShapedScript::Arabic => shape_arabic(&s, run_rtl).map_err(|e| arabic_error(e, &s))?,
|
||||
ShapedScript::Hebrew => shape_hebrew(&s, run_rtl).map_err(|e| hebrew_error(e, &s))?,
|
||||
ShapedScript::Thai => shape_thai(&s).map_err(|e| thai_error(e, &s))?,
|
||||
};
|
||||
Piece::Shaped(shaped)
|
||||
@@ -450,6 +474,16 @@ fn arabic_error(e: GlyphError, text: &str) -> EditError {
|
||||
})
|
||||
}
|
||||
|
||||
fn hebrew_error(e: GlyphError, text: &str) -> EditError {
|
||||
EditError::Invalid(match e {
|
||||
GlyphError::NoFont => "Hebrew text needs PdfCraft's Hebrew font, which this build doesn't include \
|
||||
(to build it in, set CRAFT_FONTS_DIR to a craft-fonts checkout that has a Hebr face)"
|
||||
.into(),
|
||||
GlyphError::Missing => format!("the Hebrew font can't show \"{text}\""),
|
||||
GlyphError::TooComplex => format!("the Hebrew font's glyphs for \"{text}\" are too complex"),
|
||||
})
|
||||
}
|
||||
|
||||
fn thai_error(e: GlyphError, text: &str) -> EditError {
|
||||
EditError::Invalid(match e {
|
||||
GlyphError::NoFont => {
|
||||
@@ -479,7 +513,7 @@ const GLYPHS_PER_FONT: usize = 240;
|
||||
/// its pieces in drawing order and its width at the item's size.
|
||||
type LaidLine = (String, bool, Vec<Piece>, f64);
|
||||
|
||||
/// Draw a text item that has Arabic or Thai in it. Each line is laid out in display order; Arabic glyphs
|
||||
/// Draw a text item that has Arabic, Hebrew or Thai in it. Each line is laid out in display order; their glyphs
|
||||
/// come from Type 3 fonts drawn from the craft-fonts face's outlines (no font file is embedded),
|
||||
/// with a ToUnicode map for copy and search. Other text uses the item's standard font.
|
||||
fn draw_shaped(doc: &mut Document, t: &AddedText, taken: &Dict, fonts: &mut Dict, out: &mut Vec<u8>) -> Result<(), EditError> {
|
||||
@@ -524,6 +558,7 @@ fn draw_laid_shaped(
|
||||
if let std::collections::hash_map::Entry::Vacant(entry) = outlines.entry((c.script, *id)) {
|
||||
entry.insert(outline(c.script, *id).map_err(|e| match c.script {
|
||||
ShapedScript::Arabic => arabic_error(e, &c.text),
|
||||
ShapedScript::Hebrew => hebrew_error(e, &c.text),
|
||||
ShapedScript::Thai => thai_error(e, &c.text),
|
||||
})?);
|
||||
}
|
||||
@@ -663,6 +698,7 @@ fn type3_shaped(doc: &mut Document, clusters: &[&ShapedCluster], outlines: &Hash
|
||||
Some(script) => script.font().map_or(
|
||||
match script {
|
||||
ShapedScript::Arabic => "Arabic",
|
||||
ShapedScript::Hebrew => "Hebrew",
|
||||
ShapedScript::Thai => "Thai",
|
||||
},
|
||||
|f| f.family,
|
||||
@@ -697,8 +733,9 @@ fn type3_shaped(doc: &mut Document, clusters: &[&ShapedCluster], outlines: &Hash
|
||||
}
|
||||
|
||||
/// Content and resources for an item; `view` maps display space to user space. `taken` holds the
|
||||
/// page's font names. `existing`: the item is being replaced, so a build without the Arabic face
|
||||
/// keeps drawing its Arabic as `?`, as before Arabic was supported, rather than refusing to move it.
|
||||
/// page's font names. `existing`: the item is being replaced, so a build without the Arabic or
|
||||
/// Hebrew face keeps drawing that text as `?`, as before it was supported, rather than refusing to
|
||||
/// move it.
|
||||
fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict, existing: bool) -> Result<(Vec<u8>, Dict), EditError> {
|
||||
let mut res = Dict::new();
|
||||
let mut out = format!(
|
||||
@@ -724,7 +761,11 @@ fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict, existing:
|
||||
}
|
||||
let mut fonts = Dict::new();
|
||||
fonts.set(name.clone().into_bytes(), Object::Dict(font));
|
||||
if t.text.chars().any(is_thai) || (t.text.chars().any(is_arabic) && (pdfcraft_fonts::document_arabic_font().is_some() || !existing)) {
|
||||
let face_or_new = |has: fn(char) -> bool, face: bool| t.text.chars().any(has) && (face || !existing);
|
||||
if t.text.chars().any(is_thai)
|
||||
|| face_or_new(is_arabic, pdfcraft_fonts::document_arabic_font().is_some())
|
||||
|| face_or_new(is_hebrew, pdfcraft_fonts::document_hebrew_font(false).is_some())
|
||||
{
|
||||
draw_shaped(doc, t, taken, &mut fonts, &mut out)?;
|
||||
res.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
out.extend_from_slice(b"Q\n");
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
//! the appearance's bounding box, transformed by its `/Matrix`, mapped onto `/Rect`). Hidden
|
||||
//! annotations are left alone; links are kept (they have no ink and stay clickable). Flattened
|
||||
//! comments take their pop-ups and replies with them; flattened widgets are removed from the
|
||||
//! form's field tree.
|
||||
//! form's field tree. An annotation whose appearance cannot be drawn is not flattened: it stays
|
||||
//! as it is, so no comment or field value is lost with nothing in its place.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
@@ -154,6 +155,8 @@ 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();
|
||||
// Selected, but nothing was drawn for it: it stays, pop-up and reply handling included.
|
||||
let mut undrawn: HashSet<ObjRef> = 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.
|
||||
@@ -199,9 +202,18 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result<us
|
||||
}
|
||||
// A comment whose ink was not written into the page stays. Removing it would drop
|
||||
// the annotation (a cloudy square this builder cannot draw, for example) with nothing
|
||||
// left on the page. Widgets are form fields: flattening still removes them.
|
||||
if !widget && !drew {
|
||||
continue;
|
||||
// left on the page. A widget stays when it has no appearance to draw at all (none
|
||||
// stored, or none for its selected state): removing it would lose the field's value
|
||||
// with nothing in its place (#1794). One whose appearance draws nothing (an empty
|
||||
// stream) shows nothing anywhere, so flattening still removes it.
|
||||
if !drew {
|
||||
let no_appearance = appearance(doc, d).is_none();
|
||||
if no_appearance && let Some(r) = entry.as_ref() {
|
||||
undrawn.insert(r);
|
||||
}
|
||||
if !widget || no_appearance {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
match entry.as_ref() {
|
||||
Some(r) => {
|
||||
@@ -218,9 +230,13 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result<us
|
||||
if gone.is_empty() && gone_inline.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Pop-ups and replies of what was flattened go too.
|
||||
// Pop-ups and replies of what was flattened go too, except a reply that stayed because
|
||||
// it could not be drawn: its content would go with nothing in its place.
|
||||
for entry in &list {
|
||||
let Some(r) = entry.as_ref() else { continue };
|
||||
if undrawn.contains(&r) {
|
||||
continue;
|
||||
}
|
||||
let obj = doc.get(r);
|
||||
let Some(d) = obj.as_dict() else { continue };
|
||||
let points_at = |k: &[u8]| d.reference(k).is_some_and(|t| gone.contains(&t));
|
||||
|
||||
@@ -372,6 +372,10 @@ pub fn reading_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, Edi
|
||||
pub enum ImageChange {
|
||||
/// Apply a user-space transform after its placement (move, resize, rotate, flip).
|
||||
Transform([f64; 6]),
|
||||
/// Show only this part of it (a box in user space): a clipping path around the drawing,
|
||||
/// inside its own scope so a later move or resize carries it along. The pixels stay where
|
||||
/// they are; nothing is scaled.
|
||||
Crop([f64; 4]),
|
||||
/// Draw this image XObject instead, in the same place.
|
||||
Replace(ObjRef),
|
||||
Delete,
|
||||
@@ -457,6 +461,9 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag
|
||||
ImageChange::Transform(t) => {
|
||||
return transform_image(doc, &p, &all, &img, *t);
|
||||
}
|
||||
ImageChange::Crop(keep) => {
|
||||
return crop_image(doc, &p, &all, &img, *keep);
|
||||
}
|
||||
ImageChange::Replace(r) => {
|
||||
if img.is_form {
|
||||
return Err(EditError::Invalid(
|
||||
@@ -652,6 +659,86 @@ fn transform_image(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Crop: clip the image's drawing to `keep` (user space). The clip goes just inside the scope
|
||||
/// that holds the image and its private crop (or a new `q … Q` around the `Do`), written in
|
||||
/// that scope's coordinates as the box's four corners, so a turned image crops to an upright
|
||||
/// box too and a later transform of the scope moves the crop with the pixels.
|
||||
fn crop_image(
|
||||
doc: &mut Document,
|
||||
page: &pdfcraft_model::Page,
|
||||
streams: &[(Object, Vec<u8>)],
|
||||
image: &PageImage,
|
||||
keep: [f64; 4],
|
||||
) -> Result<(), EditError> {
|
||||
if !keep.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
|
||||
return Err(EditError::Invalid("the crop box must be finite and no larger than 1e12".into()));
|
||||
}
|
||||
let [x0, y0, x1, y1] = [keep[0].min(keep[2]), keep[1].min(keep[3]), keep[0].max(keep[2]), keep[1].max(keep[3])];
|
||||
if x1 - x0 < 1e-6 || y1 - y0 < 1e-6 {
|
||||
return Err(EditError::Invalid("the crop box has no area".into()));
|
||||
}
|
||||
let mut offset = 0_usize;
|
||||
for (_, data) in streams.iter().take(image.stream) {
|
||||
offset = offset
|
||||
.checked_add(data.len())
|
||||
.and_then(|n| n.checked_add(1))
|
||||
.ok_or_else(|| EditError::Invalid("the image content is too large".into()))?;
|
||||
}
|
||||
let data = streams.get(image.stream).ok_or_else(|| EditError::Invalid("the image stream is missing".into()))?;
|
||||
let op = parse(&data.1).ops.get(image.op).cloned().ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?;
|
||||
let span = offset.checked_add(op.span.start).ok_or_else(|| EditError::Invalid("the image content is too large".into()))?;
|
||||
let pieces = pdfcraft_content::Pieces::join(&streams.iter().map(|(_, data)| data.as_slice()).collect::<Vec<_>>());
|
||||
let ops = pieces.parse().ops;
|
||||
let target = ops
|
||||
.iter()
|
||||
.position(|op| op.span.start == span && op.is("Do"))
|
||||
.ok_or_else(|| EditError::Invalid("the image operation could not be located".into()))?;
|
||||
let (start, end, placement) = image_transform_scope(&ops, target)?.unwrap_or((target, target, Matrix(image.draw_matrix)));
|
||||
let inverse = placement.invert().ok_or_else(|| EditError::Invalid("the image has no area".into()))?;
|
||||
let corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)].map(|(x, y)| inverse.apply(x, y));
|
||||
if !corners.iter().all(|(x, y)| x.is_finite() && y.is_finite() && x.abs() <= 1e12 && y.abs() <= 1e12) {
|
||||
return Err(EditError::Invalid("the crop box is too large for the image's placement".into()));
|
||||
}
|
||||
let clip: Vec<Op> = corners
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (x, y))| Op::new(if i == 0 { "m" } else { "l" }, vec![Object::Real(*x), Object::Real(*y)]))
|
||||
.chain([Op::new("h", vec![]), Op::new("W", vec![]), Op::new("n", vec![])])
|
||||
.collect();
|
||||
// The image's own earlier crop (a clipping path right after the scope's `q`) gives way to
|
||||
// the new one, so a crop can grow again as well as shrink.
|
||||
let old_clip: Option<std::ops::Range<usize>> = (start != end)
|
||||
.then(|| {
|
||||
let mut k = start + 1;
|
||||
while ops.get(k).is_some_and(|o| matches!(o.op.as_slice(), b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re")) {
|
||||
k += 1;
|
||||
}
|
||||
(k > start + 1 && ops.get(k).is_some_and(|o| matches!(o.op.as_slice(), b"W" | b"W*")) && ops.get(k + 1).is_some_and(|o| o.is("n")))
|
||||
.then_some(start + 1..k + 2)
|
||||
})
|
||||
.flatten();
|
||||
let changed = crate::text::splice(&pieces, &ops, |index| {
|
||||
if start == end && index == target {
|
||||
// No scope of its own yet: one around the drawing, with the crop inside.
|
||||
let mut out = vec![Op::new("q", vec![])];
|
||||
out.extend(clip.iter().cloned());
|
||||
out.extend(ops.get(index).cloned());
|
||||
out.push(Op::new("Q", vec![]));
|
||||
(out, false)
|
||||
} else if start != end && index == start + 1 {
|
||||
// Inside the scope's `q`, before whatever it draws (in place of its old crop).
|
||||
(clip.clone(), old_clip.is_none())
|
||||
} else if old_clip.as_ref().is_some_and(|r| r.contains(&index)) {
|
||||
(Vec::new(), false)
|
||||
} else {
|
||||
(Vec::new(), true)
|
||||
}
|
||||
});
|
||||
let contents = crate::text::rewritten_contents(doc, streams, changed);
|
||||
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), contents))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
|
||||
+37
-17
@@ -73,6 +73,17 @@ impl Default for HeaderFooter {
|
||||
}
|
||||
}
|
||||
|
||||
impl HeaderFooter {
|
||||
/// The first character [`add_header_footer`] would refuse, because the standard fonts can't
|
||||
/// draw it (#125). Tokens only add ASCII (Bates prefixes and suffixes are part of the
|
||||
/// template), so checking the templates, split as `expand`'s output is, covers every page
|
||||
/// (and a `\r` that would be drawn is refused too). The dialog asks this too, so its preview
|
||||
/// never offers text that applying refuses (#2013).
|
||||
pub fn first_undrawable(&self) -> Option<char> {
|
||||
self.text.iter().flat_map(|t| t.lines()).find_map(pdfcraft_fonts::first_non_win_ansi)
|
||||
}
|
||||
}
|
||||
|
||||
/// A picture for a background or watermark (Acrobat: Source ▸ File): an XObject already in
|
||||
/// the document, an image (drawn into a unit square) or a form (a page of a PDF).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -104,6 +115,17 @@ pub struct Watermark {
|
||||
pub offset: [f64; 2],
|
||||
}
|
||||
|
||||
impl Watermark {
|
||||
/// The first character of a text watermark [`add_watermark`] would refuse, because the
|
||||
/// standard fonts can't draw it (#125). A picture watermark draws no text.
|
||||
pub fn first_undrawable(&self) -> Option<char> {
|
||||
match self.source {
|
||||
Some(_) => None,
|
||||
None => self.text.trim().lines().find_map(pdfcraft_fonts::first_non_win_ansi),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Watermark {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -383,16 +405,6 @@ fn begin(kind: MarkKind, subtype: &str, matrix: [f64; 6]) -> String {
|
||||
|
||||
const END: &str = "EMC\nQ\n";
|
||||
|
||||
/// Refuses page text the standard-14 fonts (WinAnsiEncoding) can't draw: written anyway, each
|
||||
/// such character would become a `?` on the page (#125). `lines` must split the text the way the
|
||||
/// caller draws it, so a `\r` that would be drawn is refused too.
|
||||
pub(crate) fn drawable<'a>(lines: impl IntoIterator<Item = &'a str>) -> Result<(), EditError> {
|
||||
match lines.into_iter().find_map(pdfcraft_fonts::first_non_win_ansi) {
|
||||
Some(c) => Err(undrawable(c)),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The refusal for page text with `c`, which nothing here can draw.
|
||||
pub(crate) fn undrawable(c: char) -> EditError {
|
||||
EditError::Invalid(undrawable_message(c))
|
||||
@@ -418,9 +430,9 @@ pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter,
|
||||
if hf.text.iter().all(|t| t.trim().is_empty()) {
|
||||
return Err(EditError::Invalid("type the header or footer text first".into()));
|
||||
}
|
||||
// Tokens only add ASCII (Bates prefixes and suffixes are part of the template), so checking
|
||||
// the templates, split as `expand`'s output is, covers every page.
|
||||
hf.text.iter().try_for_each(|t| drawable(t.lines()))?;
|
||||
if let Some(c) = hf.first_undrawable() {
|
||||
return Err(undrawable(c));
|
||||
}
|
||||
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()));
|
||||
}
|
||||
@@ -438,18 +450,26 @@ pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter,
|
||||
u32::MAX
|
||||
)));
|
||||
}
|
||||
// The same goes for Bates numbers: the last selected page must still fit in a u64.
|
||||
let bates0 = hf.text.iter().find_map(|t| bates_start(t)).unwrap_or(1);
|
||||
if bates0.checked_add(pages.len().saturating_sub(1) as u64).is_none() {
|
||||
return Err(EditError::Invalid(format!(
|
||||
"the Bates start number {bates0} leaves no room for {} pages (Bates numbers stop at {})",
|
||||
pages.len(),
|
||||
u64::MAX
|
||||
)));
|
||||
}
|
||||
if replace {
|
||||
remove_marks(doc, pages, MarkKind::HeaderFooter)?;
|
||||
}
|
||||
let all = page_list(doc);
|
||||
let count = all.len();
|
||||
let bates0 = hf.text.iter().find_map(|t| bates_start(t)).unwrap_or(1);
|
||||
for (k, &i) in pages.iter().enumerate() {
|
||||
let page = &all[i];
|
||||
let (w, h) = page.display_size(doc);
|
||||
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 bates = bates0.checked_add(k as u64).ok_or_else(|| EditError::Invalid("the Bates numbers are too large".into()))?;
|
||||
let size = hf.font_size;
|
||||
let [top, bottom, left, right] = hf.margins;
|
||||
let mut body: Vec<u8> = format!("BT\n/PCHelv {} Tf\n{}\n", n(size), rgb(hf.color)).into_bytes();
|
||||
@@ -498,8 +518,8 @@ pub fn add_watermark(doc: &mut Document, pages: &[usize], wm: &Watermark, replac
|
||||
if text.is_empty() && wm.source.is_none() {
|
||||
return Err(EditError::Invalid("type the watermark text first".into()));
|
||||
}
|
||||
if wm.source.is_none() {
|
||||
drawable(text.lines())?;
|
||||
if let Some(c) = wm.first_undrawable() {
|
||||
return Err(undrawable(c));
|
||||
}
|
||||
if !(wm.opacity.is_finite()
|
||||
&& wm.rotation.is_finite()
|
||||
|
||||
@@ -115,6 +115,23 @@ fn header_start_number_that_would_wrap_is_refused_without_drawing() {
|
||||
assert_eq!(marks_present(&doc).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_bates_start_that_would_overflow_is_refused_without_drawing() {
|
||||
// A Bates start of u64::MAX has no room for a second page. The run must be refused up front
|
||||
// instead of overflowing (a panic in debug builds, a wrap to 0 in release builds).
|
||||
let mut doc = fixture();
|
||||
let hf = HeaderFooter {
|
||||
text: [String::new(), String::new(), "<<Bates Number#6#18446744073709551615#A#B>>".into(), String::new(), String::new(), String::new()],
|
||||
..HeaderFooter::default()
|
||||
};
|
||||
let err = add_header_footer(&mut doc, &[0, 1], &hf, false, &cx()).unwrap_err();
|
||||
assert!(err.to_string().contains("Bates"), "{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();
|
||||
@@ -294,6 +311,58 @@ fn flattening_fields_drops_the_ones_on_no_page() {
|
||||
assert_eq!(left, [pdfcraft_cos::ObjRef::new(7, 0)], "only the hidden field on the page stays");
|
||||
}
|
||||
|
||||
/// #1794: a form field whose widget has no usable appearance (none stored, or none for its
|
||||
/// selected state) has nothing to draw. Flattening used to remove it anyway, losing its value
|
||||
/// with no ink in its place. It stays, in the page and in the form, like a comment that can't be
|
||||
/// drawn; a field with an appearance is still baked in and removed.
|
||||
#[test]
|
||||
fn flattening_keeps_a_valued_widget_it_cannot_draw() {
|
||||
let page = stream("", " ");
|
||||
let mut doc = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R 6 0 R 7 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 6 0 R 7 0 R] >>",
|
||||
&page,
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V (Ada) /Rect [10 10 150 40] /F 4 >>",
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /V /Yes /AS /Yes /Rect [10 50 30 70] /F 4 /AP << /N << /Off 8 0 R >> >> >>",
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (drawn) /V (Bob) /Rect [10 90 150 120] /F 4 /AP << /N 8 0 R >> >>",
|
||||
"<< /Type /XObject /Subtype /Form /BBox [0 0 140 30] /Length 0 >>\nstream\n\nendstream",
|
||||
]);
|
||||
assert_eq!(flatten(&mut doc, &[0], false, true).unwrap(), 1, "only the field with an appearance is drawn");
|
||||
let doc = reopen(&doc);
|
||||
let refs = |o: &Object| -> Vec<u32> { doc.resolve(o).as_array().unwrap().iter().filter_map(|f| f.as_ref()).map(|r| r.num).collect() };
|
||||
let p = &pdfcraft_model::pages(&doc)[0];
|
||||
assert_eq!(refs(p.dict.get(b"Annots").unwrap()), [5, 6], "the undrawable widgets stay on the page");
|
||||
let root = doc.root().unwrap();
|
||||
let af = doc.resolve(doc.get(root).as_dict().unwrap().get(b"AcroForm").unwrap());
|
||||
assert_eq!(refs(af.as_dict().unwrap().get(b"Fields").unwrap()), [5, 6], "and in the form");
|
||||
let value = |n: u32| doc.get(pdfcraft_cos::ObjRef::new(n, 0)).as_dict().unwrap().get(b"V").cloned();
|
||||
assert_eq!(value(5), Some(Object::String(pdfcraft_cos::PdfString::literal(b"Ada".to_vec()))));
|
||||
assert_eq!(value(6), Some(Object::name("Yes")));
|
||||
}
|
||||
|
||||
/// #1794: a reply this builder cannot draw stays on the page even when the comment it replies to
|
||||
/// is flattened; the comment's pop-up still goes with it.
|
||||
#[test]
|
||||
fn flattening_keeps_an_undrawn_reply_to_a_flattened_comment() {
|
||||
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 600 800] /Resources << >> /Contents 4 0 R /Annots [5 0 R 6 0 R 7 0 R] >>",
|
||||
&page,
|
||||
"<< /Type /Annot /Subtype /Square /Rect [100 600 200 650] /C [1 0 0] /Contents (Synthetic comment) /AP << /N 8 0 R >> /Popup 7 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Square /Rect [100 500 200 550] /C [1 0 0] /Contents (Synthetic reply) /BE << /S /C /I 2 >> /IRT 5 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Popup /Rect [300 600 400 700] /Parent 5 0 R >>",
|
||||
"<< /Type /XObject /Subtype /Form /BBox [100 600 200 650] /Length 28 >>\nstream\n1 0 0 RG 100 600 100 50 re S\nendstream",
|
||||
]);
|
||||
assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 1, "the comment is drawn; the cloudy reply cannot be");
|
||||
let doc = reopen(&doc);
|
||||
let p = &pdfcraft_model::pages(&doc)[0];
|
||||
let left: Vec<u32> = doc.resolve(p.dict.get(b"Annots").unwrap()).as_array().unwrap().iter().filter_map(|f| f.as_ref()).map(|r| r.num).collect();
|
||||
assert_eq!(left, [6], "the undrawn reply stays; the comment and its pop-up are gone");
|
||||
}
|
||||
|
||||
/// Flattening never makes a `?` permanent: it refuses, changing nothing, a text box without an
|
||||
/// appearance whose text Helvetica can't draw (another program's), and a comment or form field
|
||||
/// whose appearance PdfCraft drew with `?` (marked `PCUndrawn`). A comment another program drew
|
||||
@@ -641,6 +710,82 @@ fn added_arabic_text_is_shaped_in_display_order_and_stays_searchable() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn added_hebrew_text_is_drawn_right_to_left_and_stays_searchable() {
|
||||
let mut doc = fixture();
|
||||
let text = AddedText {
|
||||
rect: [72.0, 600.0, 400.0, 700.0],
|
||||
text: "שלום (עולם) 2026 World".into(),
|
||||
size: 14.0,
|
||||
align: Align::Right,
|
||||
..AddedText::default()
|
||||
};
|
||||
if pdfcraft_fonts::document_hebrew_font(false).is_none() {
|
||||
eprintln!("skipping the Hebrew drawing checks: built without a craft-fonts Hebr face (set CRAFT_FONTS_DIR)");
|
||||
assert_eq!(first_undrawable(&text), Some('ש'));
|
||||
let err = add_content(&mut doc, 0, &Content::Text(text)).unwrap_err();
|
||||
assert!(matches!(&err, EditError::Invalid(m) if m.contains("U+05E9")), "{err:?}");
|
||||
assert!(!doc.is_modified(), "nothing is written when the font is missing");
|
||||
return;
|
||||
}
|
||||
assert_eq!(first_undrawable(&text), None);
|
||||
add_content(&mut doc, 0, &Content::Text(text.clone())).unwrap();
|
||||
// Points stay on their letters; a Hebrew line in an Arabic item and Arabic in a Hebrew one.
|
||||
for other in ["שָׁלוֹם", "שלום مرحبا"] {
|
||||
let other = AddedText { text: other.into(), rect: [72.0, 500.0, 400.0, 520.0], ..text.clone() };
|
||||
if other.text.chars().any(|c| ('\u{0600}'..='\u{06FF}').contains(&c)) && pdfcraft_fonts::document_arabic_font().is_none() {
|
||||
continue;
|
||||
}
|
||||
add_content(&mut doc, 0, &Content::Text(other)).unwrap();
|
||||
}
|
||||
let doc = reopen(&doc);
|
||||
let added = list_added(&doc);
|
||||
let Content::Text(t) = &added[0].content else { panic!() };
|
||||
assert_eq!(t.text, "שלום (עולם) 2026 World", "the logical text is kept for editing");
|
||||
let s = streams(&doc, 0);
|
||||
let item = s.iter().find(|s| s.contains("(World) Tj")).unwrap();
|
||||
// A right-to-left paragraph: "World", typed last, is drawn left of the Hebrew.
|
||||
let x_before = |s: &str, at: usize| -> f64 {
|
||||
let tm = s[..at].rfind("1 0 0 1 ").unwrap() + 8;
|
||||
s[tm..].split(' ').next().unwrap().parse().unwrap()
|
||||
};
|
||||
let latin_x = x_before(item, item.find("(World) Tj").unwrap());
|
||||
let hebrew_at = item.find("/PCAr").unwrap();
|
||||
let hebrew_x = x_before(item, item[hebrew_at..].find(" Tj").unwrap() + hebrew_at);
|
||||
assert!(latin_x < hebrew_x, "{item}");
|
||||
// Copy and search get the typing order back.
|
||||
let logical: String = t.text.encode_utf16().map(|u| format!("{u:04X}")).collect();
|
||||
assert!(item.contains(&format!("/ActualText <FEFF{logical}>")), "{item}");
|
||||
// The Hebrew glyphs come from a Type 3 font named after the face, whose ToUnicode map gives
|
||||
// back each letter.
|
||||
let p = &pdfcraft_model::pages(&doc)[0];
|
||||
let res = doc.resolve(p.dict.get(b"Resources").unwrap());
|
||||
let fonts = doc.resolve(res.as_dict().unwrap().get(b"Font").unwrap()).as_dict().unwrap().clone();
|
||||
let shaped: Vec<_> = fonts.iter().filter(|(k, _)| k.starts_with(b"PCAr")).map(|(_, v)| doc.resolve(v).as_dict().unwrap().clone()).collect();
|
||||
let mut mapped = String::new();
|
||||
for f in &shaped {
|
||||
assert_eq!(f.name(b"Subtype"), Some(&b"Type3"[..]));
|
||||
mapped += &String::from_utf8_lossy(&stream_bytes(&doc, f.get(b"ToUnicode").unwrap()).unwrap());
|
||||
}
|
||||
for letter in ["05E9", "05DC", "05D5", "05DD", "05E2"] {
|
||||
assert!(mapped.contains(&format!("<{letter}>")), "{letter} in {mapped}");
|
||||
}
|
||||
let font_name = |f: &Dict| doc.resolve(f.get(b"FontDescriptor").unwrap()).as_dict().unwrap().name(b"FontName").map(<[u8]>::to_vec);
|
||||
assert!(shaped.iter().any(|f| font_name(f).as_deref() == Some(&b"NotoSansHebrew"[..])));
|
||||
// A pointed letter is one glyph with its points, mapped back whole.
|
||||
assert!(mapped.contains("<05E905"), "{mapped}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odd_hebrew_text_never_panics() {
|
||||
let mut doc = fixture();
|
||||
let long = "שלום עולם ".repeat(2_000);
|
||||
for s in ["\u{202E}ש\u{05B8}\u{05B8} (]", "\u{05B8}", "א\u{200F}\u{2067}b\u{2069}", "א\n\nב\tc", "׳״ 456", long.as_str()] {
|
||||
let r = add_content(&mut doc, 0, &Content::Text(AddedText { rect: [0.0, 800.0, 300.0, 780.0], text: s.into(), ..AddedText::default() }));
|
||||
assert_eq!(r.is_ok(), pdfcraft_fonts::document_hebrew_font(false).is_some(), "{:?}: {r:?}", s.chars().take(12).collect::<String>());
|
||||
}
|
||||
}
|
||||
|
||||
/// The `PCAr` font names in page `page`'s resources.
|
||||
fn arabic_font_names(doc: &Document, page: usize) -> Vec<String> {
|
||||
let p = &pdfcraft_model::pages(doc)[page];
|
||||
@@ -1277,6 +1422,74 @@ fn a_subset_without_a_space_glyph_keeps_its_font_when_editing_words() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A Chrome (Skia) style subset: Type0/Identity-H over a TrueType program, CID = glyph id.
|
||||
/// `/W` lists most used glyphs, writes 0 for some it dropped, and leaves `a` and `y` to `/DW`;
|
||||
/// `z` has a ToUnicode entry but no glyph in the program (its CID is past the last glyph).
|
||||
fn skia_subset_page() -> Document {
|
||||
// Inter's glyph ids: P 375, o 790, l 736, a 507, n 773, d 597, G 141, e 614, r 852, m 766, y 998, space 1777.
|
||||
let mut doc = text_page(
|
||||
"BT /F3 12 Tf 72 700 Td <0177031602E001FB03050255> Tj ET \
|
||||
BT /F3 12 Tf 72 650 Td <008D0266035402FE01FB030503E606F101FB0305025506F102FE031603540266> Tj ET",
|
||||
);
|
||||
let program = doc.add(Object::Stream(Stream::flate(Dict::new(), include_bytes!("../../../assets/fonts/Inter-Regular.ttf"))));
|
||||
let unicode = doc.add(Object::Stream(Stream::flate(
|
||||
Dict::new(),
|
||||
b"1 begincodespacerange <0000> <FFFF> endcodespacerange 13 beginbfchar <0177> <0050> <0316> <006F> <02E0> <006C> \
|
||||
<01FB> <0061> <0305> <006E> <0255> <0064> <008D> <0047> <0266> <0065> <0354> <0072> <02FE> <006D> <03E6> <0079> \
|
||||
<06F1> <0020> <1000> <007A> endbfchar",
|
||||
)));
|
||||
let widths: Vec<Object> = [(0x8D, 746), (0x177, 639), (0x255, 612), (0x266, 583), (0x2E0, 242), (0x2FE, 876), (0x305, 591), (0x316, 600)]
|
||||
.into_iter()
|
||||
.chain([(0x354, 376), (0x355, 0), (0x356, 0), (0x6F1, 281)])
|
||||
.flat_map(|(cid, width)| [Object::Int(cid), Object::Array(vec![Object::Int(width)])])
|
||||
.collect();
|
||||
let descriptor = Dict::from_iter([
|
||||
(b"Type".to_vec(), Object::name("FontDescriptor")),
|
||||
(b"FontName".to_vec(), Object::name("BAAAAA+SampleSans")),
|
||||
(b"Flags".to_vec(), Object::Int(32)),
|
||||
(b"FontFile2".to_vec(), Object::Ref(program)),
|
||||
]);
|
||||
let cid_font = Dict::from_iter([
|
||||
(b"Type".to_vec(), Object::name("Font")),
|
||||
(b"Subtype".to_vec(), Object::name("CIDFontType2")),
|
||||
(b"BaseFont".to_vec(), Object::name("BAAAAA+SampleSans")),
|
||||
(b"CIDToGIDMap".to_vec(), Object::name("Identity")),
|
||||
(b"DW".to_vec(), Object::Int(562)),
|
||||
(b"W".to_vec(), Object::Array(widths)),
|
||||
(b"FontDescriptor".to_vec(), Object::Dict(descriptor)),
|
||||
]);
|
||||
let font = Dict::from_iter([
|
||||
(b"Type".to_vec(), Object::name("Font")),
|
||||
(b"Subtype".to_vec(), Object::name("Type0")),
|
||||
(b"BaseFont".to_vec(), Object::name("BAAAAA+SampleSans")),
|
||||
(b"Encoding".to_vec(), Object::name("Identity-H")),
|
||||
(b"DescendantFonts".to_vec(), Object::Array(vec![Object::Dict(cid_font)])),
|
||||
(b"ToUnicode".to_vec(), Object::Ref(unicode)),
|
||||
]);
|
||||
let p = pdfcraft_model::pages(&doc)[0].obj;
|
||||
let fonts = Dict::from_iter([(b"F3".to_vec(), Object::Dict(font))]);
|
||||
doc.update_dict(p, |d| d.set(b"Resources".to_vec(), Object::Dict(Dict::from_iter([(b"Font".to_vec(), Object::Dict(fonts))])))).unwrap();
|
||||
doc
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_subset_keeps_its_font_for_glyphs_left_to_the_default_width() {
|
||||
let doc = skia_subset_page();
|
||||
let blocks = text::text_blocks(&doc, 0).unwrap();
|
||||
assert_eq!(blocks.iter().map(|b| b.text.as_str()).collect::<Vec<_>>(), ["Poland", "Germany and more"]);
|
||||
// Deleting words only, and replacing a word with one drawn elsewhere on the page (`y` and `a`
|
||||
// have no /W entry): every glyph is in the subset, so the font stays.
|
||||
for (block, text) in [(1, "Germany"), (0, "Germany")] {
|
||||
let mut doc = doc.clone();
|
||||
assert_eq!(text::replace_block(&mut doc, 0, block, text).unwrap().substituted, None, "{text}");
|
||||
let lines = text::text_lines(&reopen(&doc), 0).unwrap();
|
||||
assert_eq!(lines.iter().find(|l| l.text == text).unwrap().font, "F3");
|
||||
}
|
||||
// `z` maps to a CID the program has no glyph for: that still needs another font.
|
||||
let mut doc = doc.clone();
|
||||
assert!(text::replace_block(&mut doc, 0, 0, "Graz").unwrap().substituted.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spaces_in_another_subset_do_not_split_a_line() {
|
||||
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello) Tj /F2 12 Tf ( ) Tj /F1 12 Tf (world) Tj ET");
|
||||
@@ -2266,6 +2479,37 @@ fn hebrew_letters_the_page_font_lacks_use_the_hebrew_face() {
|
||||
assert_eq!(text::text_blocks(&reopen(&doc), 0).unwrap()[0].text, "עברית מלאה");
|
||||
}
|
||||
|
||||
/// #2013: the dialog asks `first_undrawable` before offering OK, so it must refuse exactly what
|
||||
/// applying refuses: a Cyrillic footer was previewed (in the interface font) and then failed.
|
||||
#[test]
|
||||
fn header_and_watermark_drawability_matches_what_applying_refuses() {
|
||||
let footer = |t: &str| HeaderFooter {
|
||||
text: [String::new(), String::new(), String::new(), String::new(), String::new(), t.into()],
|
||||
..HeaderFooter::default()
|
||||
};
|
||||
for (text, refused) in [
|
||||
("Страница <<1>> из 1 / Page <<1>> of 1", Some('С')),
|
||||
("TEST-001 <<Page 1 of n>>", None),
|
||||
("Café — 5€ ™ <<Bates Number#6#1#ACME-#>>", None),
|
||||
("Left\r\nRight", None),
|
||||
("A\rB", Some('\r')),
|
||||
("line one\nΕλληνικά", Some('Ε')),
|
||||
] {
|
||||
let hf = footer(text);
|
||||
assert_eq!(hf.first_undrawable(), refused, "{text:?}");
|
||||
let mut doc = fixture();
|
||||
let applied = add_header_footer(&mut doc, &[0], &hf, false, &cx());
|
||||
match refused {
|
||||
Some(c) => assert_eq!(applied.unwrap_err().to_string(), undrawable_message(c), "{text:?}"),
|
||||
None => applied.unwrap(),
|
||||
}
|
||||
let wm = Watermark { text: text.into(), ..Watermark::default() };
|
||||
assert_eq!(wm.first_undrawable(), refused, "{text:?}");
|
||||
let applied = add_watermark(&mut fixture(), &[0], &wm, false);
|
||||
assert_eq!(applied.err().map(|e| e.to_string()), refused.map(undrawable_message), "{text:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #125: page-content tools refuse text the standard fonts can't draw instead of writing `?`,
|
||||
/// and leave the document untouched.
|
||||
#[test]
|
||||
@@ -3236,6 +3480,27 @@ fn rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen(
|
||||
assert_eq!(text::text_blocks(&original, 0).unwrap().len(), 3);
|
||||
}
|
||||
|
||||
/// Two edits applied together (heading and footer edited before leaving the editor, #2198): the
|
||||
/// runs between them are unchanged and keep their own styles instead of taking the heading's.
|
||||
#[test]
|
||||
fn unchanged_runs_between_two_edits_keep_their_styles() {
|
||||
let original = mixed_region_page();
|
||||
let look =
|
||||
|doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().map(|l| (l.text.trim().to_string(), l.base_font, l.color)).collect::<Vec<_>>();
|
||||
let before = look(&original);
|
||||
let block = text::text_blocks(&original, 0).unwrap()[1].clone();
|
||||
let mut doc = original.clone();
|
||||
let draft = block.editor_text.replace("About", "About us").replace("footer", "footnote");
|
||||
text::replace_block(&mut doc, 0, 1, &draft).unwrap();
|
||||
let after = look(&reopen(&doc));
|
||||
let style = |lines: &[(String, String, [f64; 3])], word: &str| lines.iter().find(|l| l.0 == word).map(|l| (l.1.clone(), l.2));
|
||||
for word in ["Plain", "bold", "text."] {
|
||||
assert_eq!(style(&after, word), style(&before, word), "{word:?} keeps its own style; lines after the edit: {after:?}");
|
||||
}
|
||||
assert_eq!(style(&after, "About us"), style(&before, "About"));
|
||||
assert_eq!(style(&after, "Italic footnote"), style(&before, "Italic footer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically() {
|
||||
let mut doc = mixed_region_page();
|
||||
|
||||
+135
-14
@@ -270,17 +270,17 @@ pub struct TextBlock {
|
||||
}
|
||||
|
||||
impl TextBlock {
|
||||
/// Preserve styles outside the changed range; inserted characters inherit the surrounding
|
||||
/// source run. Byte ranges are always UTF-8 boundaries. Linear time, including large edits.
|
||||
/// Preserve styles outside the changed ranges; inserted characters inherit the surrounding
|
||||
/// source run. The texts are aligned word by word, so words left untouched between two edits
|
||||
/// keep their own runs (#2198); past [`MAX_CHANGED_WORDS`] the draft counts as one change.
|
||||
/// Byte ranges are always UTF-8 boundaries. Linear time, including large edits.
|
||||
pub fn edited_spans(&self, draft: &str) -> Vec<(std::ops::Range<usize>, usize)> {
|
||||
if self.editor_text.len() > 4_000_000 || draft.len() > 4_000_000 {
|
||||
return vec![(0..draft.len(), self.lines.first().copied().unwrap_or(0))];
|
||||
}
|
||||
let old: Vec<_> = self.editor_text.chars().collect();
|
||||
let new: Vec<_> = draft.char_indices().collect();
|
||||
let prefix = old.iter().zip(&new).take_while(|(a, (_, b))| *a == b).count();
|
||||
let suffix =
|
||||
old.iter().rev().zip(new.iter().rev()).take(old.len().min(new.len()).saturating_sub(prefix)).take_while(|(a, (_, b))| *a == b).count();
|
||||
let chars: Vec<_> = new.iter().map(|(_, c)| *c).collect();
|
||||
let mut original = Vec::with_capacity(old.len());
|
||||
let mut span = 0;
|
||||
for (byte, _) in self.editor_text.char_indices() {
|
||||
@@ -289,17 +289,36 @@ impl TextBlock {
|
||||
}
|
||||
original.push(self.spans.get(span).or_else(|| self.spans.last()).map_or(0, |(_, i)| *i));
|
||||
}
|
||||
let inherited = original.get(prefix.saturating_sub(1)).or_else(|| original.get(prefix)).copied().unwrap_or(0);
|
||||
let mut sources = Vec::with_capacity(new.len());
|
||||
let slices = |text: &[char]| words(text).into_iter().map(|w| text.get(w).unwrap_or_default().to_vec()).collect::<Vec<_>>();
|
||||
let (old_words, new_words) = (slices(&old), slices(&chars));
|
||||
match edit_script(&old_words, &new_words, MAX_CHANGED_WORDS) {
|
||||
Some(script) => {
|
||||
// `(o, n)`: where the current change starts in the old and new text; `(x, y)`: where it ends.
|
||||
let ((mut o, mut n), (mut x, mut y)) = ((0, 0), (0, 0));
|
||||
let (mut a, mut b) = (old_words.iter(), new_words.iter());
|
||||
for step in script {
|
||||
match step {
|
||||
Step::Keep => {
|
||||
replaced(&original, o, old.get(o..x).unwrap_or_default(), chars.get(n..y).unwrap_or_default(), &mut sources);
|
||||
let len = a.next().map_or(0, Vec::len);
|
||||
b.next();
|
||||
sources.extend(original.get(x..x.saturating_add(len)).unwrap_or_default());
|
||||
(x, y) = (x.saturating_add(len), y.saturating_add(len));
|
||||
(o, n) = (x, y);
|
||||
}
|
||||
Step::Delete => x = x.saturating_add(a.next().map_or(0, Vec::len)),
|
||||
Step::Insert => y = y.saturating_add(b.next().map_or(0, Vec::len)),
|
||||
}
|
||||
}
|
||||
replaced(&original, o, old.get(o..x).unwrap_or_default(), chars.get(n..y).unwrap_or_default(), &mut sources);
|
||||
}
|
||||
None => replaced(&original, 0, &old, &chars, &mut sources),
|
||||
}
|
||||
let fallback = original.first().copied().unwrap_or(0);
|
||||
let mut result: Vec<(std::ops::Range<usize>, usize)> = Vec::new();
|
||||
for (i, (byte, ch)) in new.iter().enumerate() {
|
||||
let source = if i < prefix {
|
||||
original.get(i).copied()
|
||||
} else if i >= new.len().saturating_sub(suffix) {
|
||||
original.get(old.len().saturating_sub(new.len() - i)).copied()
|
||||
} else {
|
||||
Some(inherited)
|
||||
}
|
||||
.unwrap_or(inherited);
|
||||
let source = sources.get(i).copied().unwrap_or(fallback);
|
||||
let end = byte.saturating_add(ch.len_utf8());
|
||||
if let Some((range, _)) = result.last_mut().filter(|(_, prev)| *prev == source) {
|
||||
range.end = end;
|
||||
@@ -311,6 +330,108 @@ impl TextBlock {
|
||||
}
|
||||
}
|
||||
|
||||
/// Changed words [`TextBlock::edited_spans`] aligns before it treats the draft as one change.
|
||||
/// Myers' diff costs O((N + M) · D), so bounding D keeps it linear.
|
||||
const MAX_CHANGED_WORDS: usize = 64;
|
||||
|
||||
/// `text` split into words, each with the whitespace after it, as char ranges.
|
||||
fn words(text: &[char]) -> Vec<std::ops::Range<usize>> {
|
||||
let mut out = Vec::new();
|
||||
let mut start = 0;
|
||||
for (i, pair) in text.windows(2).enumerate() {
|
||||
if let [a, b] = pair
|
||||
&& a.is_whitespace()
|
||||
&& !b.is_whitespace()
|
||||
{
|
||||
out.push(start..i + 1);
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
if start < text.len() {
|
||||
out.push(start..text.len());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Step {
|
||||
Keep,
|
||||
Delete,
|
||||
Insert,
|
||||
}
|
||||
|
||||
/// Myers' shortest edit script from `a` to `b`, or `None` when it takes more than `max` steps.
|
||||
fn edit_script<T: PartialEq>(a: &[T], b: &[T], max: usize) -> Option<Vec<Step>> {
|
||||
let (n, m) = (a.len() as isize, b.len() as isize);
|
||||
let max = max.min(a.len().saturating_add(b.len())) as isize;
|
||||
// Furthest x reached on each diagonal k = x - y, stored at k + max + 1.
|
||||
let at = |v: &[isize], k: isize| usize::try_from(k + max + 1).ok().and_then(|i| v.get(i).copied()).unwrap_or(0);
|
||||
let mut v = vec![0isize; (2 * max + 3) as usize];
|
||||
let mut trace = Vec::new();
|
||||
for d in 0..=max {
|
||||
trace.push(v.clone());
|
||||
for k in (-d..=d).step_by(2) {
|
||||
let down = k == -d || (k != d && at(&v, k - 1) < at(&v, k + 1));
|
||||
let mut x = if down { at(&v, k + 1) } else { at(&v, k - 1) + 1 };
|
||||
let mut y = x - k;
|
||||
while x < n && y < m && a.get(x as usize) == b.get(y as usize) {
|
||||
x += 1;
|
||||
y += 1;
|
||||
}
|
||||
if let Some(slot) = usize::try_from(k + max + 1).ok().and_then(|i| v.get_mut(i)) {
|
||||
*slot = x;
|
||||
}
|
||||
if x >= n && y >= m {
|
||||
return Some(backtrack(&trace, n, m, max));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Walk Myers' saved frontiers back from the end to the edit script.
|
||||
fn backtrack(trace: &[Vec<isize>], n: isize, m: isize, max: isize) -> Vec<Step> {
|
||||
let at = |v: &[isize], k: isize| usize::try_from(k + max + 1).ok().and_then(|i| v.get(i).copied()).unwrap_or(0);
|
||||
let mut steps = Vec::new();
|
||||
let (mut x, mut y) = (n, m);
|
||||
for (d, v) in trace.iter().enumerate().rev() {
|
||||
let (d, k) = (d as isize, x - y);
|
||||
let prev_k = if k == -d || (k != d && at(v, k - 1) < at(v, k + 1)) { k + 1 } else { k - 1 };
|
||||
let prev_x = at(v, prev_k);
|
||||
let prev_y = prev_x - prev_k;
|
||||
while x > prev_x && y > prev_y {
|
||||
steps.push(Step::Keep);
|
||||
x -= 1;
|
||||
y -= 1;
|
||||
}
|
||||
if d > 0 {
|
||||
steps.push(if x == prev_x { Step::Insert } else { Step::Delete });
|
||||
}
|
||||
(x, y) = (prev_x, prev_y);
|
||||
}
|
||||
steps.reverse();
|
||||
steps
|
||||
}
|
||||
|
||||
/// `old`, at char `at` of the source, became `new`: characters kept at either end keep their
|
||||
/// runs, and the rest inherit the run just before the change (or the first one it replaces).
|
||||
fn replaced(original: &[usize], at: usize, old: &[char], new: &[char], out: &mut Vec<usize>) {
|
||||
let prefix = old.iter().zip(new).take_while(|(a, b)| a == b).count();
|
||||
let suffix = old.iter().rev().zip(new.iter().rev()).take(old.len().min(new.len()).saturating_sub(prefix)).take_while(|(a, b)| a == b).count();
|
||||
let start = at.saturating_add(prefix);
|
||||
let inherited = start.checked_sub(1).and_then(|i| original.get(i)).or_else(|| original.get(start)).copied().unwrap_or(0);
|
||||
for i in 0..new.len() {
|
||||
let source = if i < prefix {
|
||||
original.get(at.saturating_add(i)).copied()
|
||||
} else if i >= new.len().saturating_sub(suffix) {
|
||||
original.get(at.saturating_add(old.len()).saturating_sub(new.len() - i)).copied()
|
||||
} else {
|
||||
Some(inherited)
|
||||
};
|
||||
out.push(source.unwrap_or(inherited));
|
||||
}
|
||||
}
|
||||
|
||||
impl TextLine {
|
||||
/// The baseline's height in user space.
|
||||
pub fn origin_baseline(&self) -> f64 {
|
||||
|
||||
@@ -129,6 +129,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
|
||||
ToolSection {
|
||||
title: "Modify page",
|
||||
items: &[
|
||||
item("Edit text & images", "text-cursor-input", "edit.edit_text", Ready),
|
||||
item("Rotate pages", "rotate-cw", "page.rotate", Ready),
|
||||
item("Insert pages", "file-plus-2", "page.insert", Ready),
|
||||
item("Delete pages", "trash-2", "page.delete", Ready),
|
||||
@@ -139,7 +140,6 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
|
||||
ToolSection {
|
||||
title: "Add content",
|
||||
items: &[
|
||||
item("Edit text & images", "text-cursor-input", "edit.edit_text", Ready),
|
||||
item("Text", "type", "edit.text", Ready),
|
||||
item("Image", "image-plus", "edit.image", Ready),
|
||||
menu_item("Header and footer", "heading", &["edit.header_footer", "edit.header_footer.update", "edit.header_footer.remove"]),
|
||||
|
||||
@@ -222,6 +222,9 @@ pub const COMMANDS: &[CommandSpec] = &[
|
||||
// also run from Combine files, which has no document open.
|
||||
c("view.next_tab", "Next tab", VIEW, Some(Shortcut { command: false, shift: false, mac_ctrl: true, key: "Tab" }), Nothing, "chevron-right"),
|
||||
c("view.previous_tab", "Previous tab", VIEW, Some(Shortcut { command: false, shift: true, mac_ctrl: true, key: "Tab" }), Nothing, "chevron-left"),
|
||||
// View-local history also appears in the Page navigation section; don't duplicate its menu.
|
||||
c("view.previous_view", "Previous view", None, Some(Shortcut::cmd("[")), Document, "chevron-left"),
|
||||
c("view.next_view", "Next view", None, Some(Shortcut::cmd("]")), Document, "chevron-right"),
|
||||
c("view.focus_page_input", "Go to page…", VIEW, Some(Shortcut::cmd_shift("N")), Document, "text-cursor-input"),
|
||||
c("ui.dock.reset", "Reset panel layout", VIEW, None, Nothing, "settings"),
|
||||
c("view.theme", "Switch light / dark theme", None, None, Nothing, "moon"),
|
||||
|
||||
@@ -669,6 +669,26 @@ mod tests {
|
||||
assert_eq!(s.date_format(), "dd.mm.yyyy", "a rejected pattern keeps the previous one");
|
||||
}
|
||||
|
||||
/// 21:30 on 14 Oct 2026 in São Paulo (UTC-3) is 00:30Z on the 15th. The date Fill & Sign
|
||||
/// stamps is the wall-clock day (14th); the time the file records (`/M`, `/ModDate`) is UTC.
|
||||
/// The test shortcut (`with_clock`) takes the injected time as local and says the 15th, which
|
||||
/// is why it cannot serve a host that knows both the UTC time and the zone.
|
||||
#[test]
|
||||
fn a_wall_clock_gives_the_local_day_and_keeps_utc_for_the_file() {
|
||||
let sao_paulo = Session::new().with_wall_clock(|| 1_792_024_200, || -3 * 3600);
|
||||
assert_eq!(sao_paulo.today(), (2026, 10, 14));
|
||||
assert_eq!(sao_paulo.today_text(None, None).as_deref(), Ok("10/14/2026"));
|
||||
assert_eq!(sao_paulo.signing_date(), "D:20261014213000-03'00'");
|
||||
assert_eq!(sao_paulo.stamp_by_line("Ada"), "By Ada at 9:30 pm, Oct 14, 2026");
|
||||
assert_eq!(sao_paulo.now_secs(), 1_792_024_200, "the file's instant stays in UTC");
|
||||
|
||||
let utc = Session::new().with_wall_clock(|| 1_792_024_200, || 0);
|
||||
assert_eq!(utc.today(), (2026, 10, 15));
|
||||
|
||||
let clock_only = Session::new().with_clock(|| 1_792_024_200);
|
||||
assert_eq!(clock_only.today(), (2026, 10, 15), "on its own, an injected clock counts as local time");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_follow_the_date_language() {
|
||||
assert_eq!(in_lang("dddd, d \\de mmmm \\de yyyy", "es"), "sábado, 7 de marzo de 2026");
|
||||
|
||||
@@ -46,8 +46,11 @@ pub fn extract_images(src: &ExportSource, pages: &[usize], min_side: u32) -> Res
|
||||
}
|
||||
|
||||
/// The file name for the `index`-th (1-based) exported image: `<stem>_Page_<n>_Image_<index>.<ext>`.
|
||||
pub fn image_file_name(stem: &str, image: &pdfcraft_create::ExtractedImage, index: usize) -> String {
|
||||
format!("{stem}_Page_{}_Image_{index:04}.{}", image.page + 1, image.extension)
|
||||
/// The page number has leading zeros for as many digits as the document's `page_count` has, so the
|
||||
/// files sort in page order (#2208): page 7 of 120 is `Page_007`.
|
||||
pub fn image_file_name(stem: &str, image: &pdfcraft_create::ExtractedImage, index: usize, page_count: usize) -> String {
|
||||
let width = page_count.max(1).to_string().len();
|
||||
format!("{stem}_Page_{:0width$}_Image_{index:04}.{}", image.page + 1, image.extension)
|
||||
}
|
||||
|
||||
impl Exporter {
|
||||
@@ -212,3 +215,29 @@ pub fn encode_tiff(width: u32, height: u32, premultiplied: &[u8]) -> Result<Vec<
|
||||
image::codecs::tiff::TiffEncoder::new(&mut out).write_image(&rgb, width, height, image::ExtendedColorType::Rgb8).map_err(|e| e.to_string())?;
|
||||
Ok(out.into_inner())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod image_name_tests {
|
||||
use super::image_file_name;
|
||||
|
||||
fn image(page: usize) -> pdfcraft_create::ExtractedImage {
|
||||
pdfcraft_create::ExtractedImage {
|
||||
page,
|
||||
object: pdfcraft_cos::ObjRef { num: 1, generation: 0 },
|
||||
width: 1,
|
||||
height: 1,
|
||||
extension: "png",
|
||||
data: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_number_is_padded_to_the_page_count() {
|
||||
assert_eq!(image_file_name("doc", &image(0), 1, 1), "doc_Page_1_Image_0001.png");
|
||||
assert_eq!(image_file_name("doc", &image(0), 1, 9), "doc_Page_1_Image_0001.png");
|
||||
assert_eq!(image_file_name("doc", &image(6), 2, 20), "doc_Page_07_Image_0002.png");
|
||||
assert_eq!(image_file_name("doc", &image(6), 3, 120), "doc_Page_007_Image_0003.png");
|
||||
assert_eq!(image_file_name("doc", &image(119), 4, 120), "doc_Page_120_Image_0004.png");
|
||||
assert_eq!(image_file_name("doc", &image(0), 1, 0), "doc_Page_1_Image_0001.png");
|
||||
}
|
||||
}
|
||||
|
||||
+228
-26
@@ -28,6 +28,7 @@ pub mod js;
|
||||
pub mod links;
|
||||
pub mod ocr;
|
||||
pub mod optimizer;
|
||||
pub mod print_raster;
|
||||
pub mod printcheck;
|
||||
mod scan_colours;
|
||||
mod scan_fields;
|
||||
@@ -43,7 +44,7 @@ pub use signature_image::{ImageSignaturePreview, SignatureImage};
|
||||
pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn};
|
||||
pub use text_preview::TextEditPreview;
|
||||
|
||||
pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, split_ranges};
|
||||
pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, Transition, TransitionSpec, split_ranges};
|
||||
|
||||
/// One file produced by a split: (1-based first page, last page, PDF bytes).
|
||||
pub use pdfcraft_organize::LabelStyle;
|
||||
@@ -57,7 +58,7 @@ pub use pdfcraft_edit::{
|
||||
pub use pdfcraft_forms::{
|
||||
BorderStyle, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont, FieldKind as FormFieldKind, FieldProps, FieldValue,
|
||||
Look as FieldLook, LookPatch as FieldLookPatch, NewField, TabOrder, Trigger as FieldTrigger, Widget as FormWidget, af as form_scripts,
|
||||
flags as field_flags,
|
||||
appearance::auto_text_size_by as field_auto_text_size_by, flags as field_flags,
|
||||
};
|
||||
|
||||
pub use pdfcraft_a11y as a11y;
|
||||
@@ -77,6 +78,8 @@ pub enum ImageEdit {
|
||||
Flip {
|
||||
horizontal: bool,
|
||||
},
|
||||
/// Show only this box of it (user space), the pixels staying where they are.
|
||||
Crop([f64; 4]),
|
||||
/// Draw this image file (PNG, JPEG, TIFF, GIF, BMP, JPEG 2000) in its place.
|
||||
Replace {
|
||||
name: String,
|
||||
@@ -84,7 +87,7 @@ pub enum ImageEdit {
|
||||
},
|
||||
Delete,
|
||||
}
|
||||
pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, script_outline};
|
||||
pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, script_outline, undrawable_message};
|
||||
|
||||
/// Fill & Sign: `text` in the script font as a typed signature, its left edge at `at` (user
|
||||
/// space, vertically centred) and `height` points tall. `None` for text with no outlines.
|
||||
@@ -229,6 +232,7 @@ fn scope_of(edit: &Edit) -> Scope {
|
||||
| Edit::ResizeAnnotation { .. }
|
||||
| Edit::StyleAnnotation { .. }
|
||||
| Edit::FillAnnotation { .. }
|
||||
| Edit::FontSizeAnnotation { .. }
|
||||
| Edit::SetAnnotationInfo { .. } => Scope::Comments,
|
||||
Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } => Scope::Form,
|
||||
// Page content, resources and added content/marks only: the page tree, annotations,
|
||||
@@ -795,6 +799,23 @@ fn display_bytes(editor: Option<&mut Editor>, bytes: &Arc<Vec<u8>>) -> Arc<Vec<u
|
||||
write_incremental(&display, &SaveOptions::default()).map(Arc::new).unwrap_or_else(|_| bytes.clone())
|
||||
}
|
||||
|
||||
/// The copy PDFCraft displays when opening `bytes`: with appearances drawn, in memory only, for
|
||||
/// comments that have none. Render paths outside the app, such as the CLI, use this so their page
|
||||
/// images agree with the app and independent viewers. It never changes the source document, and an
|
||||
/// unparseable file, a write failure or a parser panic falls back to the original bytes.
|
||||
pub fn display_bytes_for(bytes: Arc<Vec<u8>>, password: Option<&str>) -> Arc<Vec<u8>> {
|
||||
guard(|| {
|
||||
pdfcraft_cos::Document::open_with_password(bytes.clone(), password)
|
||||
.ok()
|
||||
.and_then(|cos| pdfcraft_annot::with_missing_appearances(&cos))
|
||||
.and_then(|display| write_incremental(&display, &SaveOptions::default()).ok())
|
||||
.map(Arc::new)
|
||||
})
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or(bytes)
|
||||
}
|
||||
|
||||
/// Fails when the renderer still draws text under an applied redaction mark.
|
||||
///
|
||||
/// Redaction decides what lies under a mark from the positions its own interpreter computes, and
|
||||
@@ -920,6 +941,8 @@ pub struct BookmarkRow {
|
||||
pub open: bool,
|
||||
/// The 0-based page it goes to.
|
||||
pub page: Option<usize>,
|
||||
/// Where on that page it goes ([`pdfcraft_render::DestView::Top`] when it doesn't say).
|
||||
pub view: pdfcraft_render::DestView,
|
||||
}
|
||||
|
||||
/// One page of the bookmark list, in outline order.
|
||||
@@ -947,7 +970,7 @@ fn outline_rows(items: &[pdfcraft_render::OutlineItem]) -> Vec<BookmarkRow> {
|
||||
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 });
|
||||
rows.push(BookmarkRow { path, title: item.title.clone(), open: item.open, page: item.page, view: item.view });
|
||||
if !item.children.is_empty() {
|
||||
levels.push((&item.children, 0));
|
||||
}
|
||||
@@ -1151,6 +1174,12 @@ pub enum Edit {
|
||||
which: PageBox,
|
||||
spec: BoxSpec,
|
||||
},
|
||||
/// Set or remove the page transition and display time of pages (ISO 32000-2 §12.4.4),
|
||||
/// Organize ▸ Page Transitions.
|
||||
SetTransition {
|
||||
pages: Vec<usize>,
|
||||
spec: TransitionSpec,
|
||||
},
|
||||
SetInfo {
|
||||
key: String,
|
||||
value: String,
|
||||
@@ -1300,6 +1329,12 @@ pub enum Edit {
|
||||
index: usize,
|
||||
fill: Option<Rgb>,
|
||||
},
|
||||
/// Change the font size (pt) of a text box, typewriter text or callout.
|
||||
FontSizeAnnotation {
|
||||
page: usize,
|
||||
index: usize,
|
||||
size: f64,
|
||||
},
|
||||
/// Comment properties ▸ General / note icon.
|
||||
SetAnnotationInfo {
|
||||
page: usize,
|
||||
@@ -1551,6 +1586,11 @@ pub enum Edit {
|
||||
},
|
||||
/// Sanitize Document: every category, then a full rewrite on save.
|
||||
Sanitize,
|
||||
/// An XFA form made an ordinary (static) PDF form: the XFA packets, `/NeedsRendering` and
|
||||
/// PdfCraft's private keys go, the pages as laid out and the fields with their values
|
||||
/// stay, so every viewer shows the same form and none lays it out again. Its scripts no
|
||||
/// longer run.
|
||||
XfaToStatic,
|
||||
/// Flatten comments and/or form fields on every page into page content.
|
||||
Flatten {
|
||||
comments: bool,
|
||||
@@ -1647,6 +1687,7 @@ pub const EDIT_LABELS: &[&str] = &[
|
||||
"Crop page",
|
||||
"Crop pages",
|
||||
"Set page boxes",
|
||||
"Set page transitions",
|
||||
"Change Title",
|
||||
"Change Author",
|
||||
"Change Subject",
|
||||
@@ -1782,6 +1823,7 @@ impl Edit {
|
||||
Edit::ReplacePages { pages, .. } => plural("Replace page", "Replace pages", pages.len()),
|
||||
Edit::SetPageBox { pages, which: PageBox::Crop, .. } => plural("Crop page", "Crop pages", pages.len()),
|
||||
Edit::SetPageBox { .. } => Label::of("Set page boxes"),
|
||||
Edit::SetTransition { .. } => Label::of("Set page transitions"),
|
||||
// The four `/Info` keys PdfCraft itself offers get a wording of their own, so each can
|
||||
// be said in another language; any other key is the document's and goes in as it stands.
|
||||
Edit::SetInfo { key, .. } => match key.as_str() {
|
||||
@@ -1827,7 +1869,9 @@ impl Edit {
|
||||
Edit::LockAnnotation { .. } => Label::of("Unlock comment"),
|
||||
Edit::MoveAnnotation { .. } => Label::of("Move comment"),
|
||||
Edit::ResizeAnnotation { .. } => Label::of("Resize comment"),
|
||||
Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } | Edit::SetAnnotationInfo { .. } => Label::of("Change comment properties"),
|
||||
Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } | Edit::FontSizeAnnotation { .. } | Edit::SetAnnotationInfo { .. } => {
|
||||
Label::of("Change comment properties")
|
||||
}
|
||||
Edit::SetFieldValue { name, .. } => Label::with("Fill in {name}", name.as_str()),
|
||||
Edit::SetFieldImage { name, .. } => Label::with("Set the image of {name}", name.as_str()),
|
||||
Edit::ResetForm { .. } => Label::of("Clear form"),
|
||||
@@ -1857,6 +1901,7 @@ impl Edit {
|
||||
Edit::EditPageImage { change, .. } => Label::of(match change {
|
||||
ImageEdit::Move(_) => "Move image",
|
||||
ImageEdit::Rotate(_) => "Rotate image",
|
||||
ImageEdit::Crop(_) => "Crop image",
|
||||
ImageEdit::Flip { .. } => "Flip image",
|
||||
ImageEdit::Replace { .. } => "Replace image",
|
||||
ImageEdit::Delete => "Delete image",
|
||||
@@ -1885,6 +1930,7 @@ impl Edit {
|
||||
Edit::RemoveLinks { .. } => Label::of("Remove all links"),
|
||||
Edit::AddLinks { links, .. } => plural("Create link", "Create links", links.len()),
|
||||
Edit::Sanitize => Label::of("Sanitize document"),
|
||||
Edit::XfaToStatic => Label::of("Convert to a static form"),
|
||||
Edit::Flatten { comments: true, fields: false } => Label::of("Flatten comments"),
|
||||
Edit::Flatten { comments: false, fields: true } => Label::of("Flatten form fields"),
|
||||
Edit::Flatten { .. } => Label::of("Flatten"),
|
||||
@@ -1947,6 +1993,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed
|
||||
| Edit::DuplicatePages { .. }
|
||||
| Edit::ReplacePages { .. }
|
||||
| Edit::SetPageBox { .. }
|
||||
| Edit::SetTransition { .. }
|
||||
| Edit::InsertPagesFrom { .. }
|
||||
// "Assemble the document: insert, rotate or delete pages and create bookmarks" (Table 22).
|
||||
| Edit::AddBookmark { .. }
|
||||
@@ -1977,6 +2024,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed
|
||||
| Edit::ResizeAnnotation { .. }
|
||||
| Edit::StyleAnnotation { .. }
|
||||
| Edit::FillAnnotation { .. }
|
||||
| Edit::FontSizeAnnotation { .. }
|
||||
| Edit::SetAnnotationInfo { .. }
|
||||
| Edit::SetMeasurementScale { .. } => {
|
||||
if p.annotate() {
|
||||
@@ -2021,6 +2069,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed
|
||||
| Edit::RemoveLinks { .. }
|
||||
| Edit::AddLinks { .. }
|
||||
| Edit::Sanitize
|
||||
| Edit::XfaToStatic
|
||||
| Edit::SetFieldProps { .. }
|
||||
| Edit::DeleteField { .. }
|
||||
| Edit::DuplicateField { .. }
|
||||
@@ -2065,6 +2114,8 @@ struct EditCtx {
|
||||
xfa_datasets: Option<pdfcraft_cos::ObjRef>,
|
||||
/// A form script ran too long and was abandoned: the document's scripts go off.
|
||||
xfa_ran_away: bool,
|
||||
/// The edit took the XFA out of the form: what went with it, for the document's warnings.
|
||||
xfa_removed: Vec<String>,
|
||||
/// Pages in the document, for `xfa.layout.pageCount()`.
|
||||
pages: usize,
|
||||
date: Option<String>,
|
||||
@@ -2094,6 +2145,7 @@ impl EditCtx {
|
||||
xfa_out: Default::default(),
|
||||
xfa_datasets: None,
|
||||
xfa_ran_away: false,
|
||||
xfa_removed: Vec::new(),
|
||||
pages: 0,
|
||||
date: now.map(pdfcraft_cos::pdf_date),
|
||||
today: (1970, 1, 1),
|
||||
@@ -2156,13 +2208,16 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
|
||||
pdfcraft_organize::replace_pages(doc, pages, &src, src_pages)?;
|
||||
}
|
||||
Edit::SetPageBox { pages, which, spec } => pdfcraft_organize::set_page_box(doc, pages, *which, *spec)?,
|
||||
Edit::SetTransition { pages, spec } => pdfcraft_organize::set_transition(doc, pages, spec)?,
|
||||
Edit::InsertPagesFrom { name, bytes, pages, at, field_conflicts } => {
|
||||
let src = open_source(name, bytes)?;
|
||||
let pages = match pages {
|
||||
Some(p) => p.clone(),
|
||||
None => (0..pdfcraft_organize::page_count(&src)?).collect(),
|
||||
};
|
||||
let outcome = pdfcraft_organize::import_pages_with(doc, &src, &pages, *at, *field_conflicts)?;
|
||||
// The file's bookmarks come along under one named after it, as in Combine Files.
|
||||
let title = std::path::Path::new(name).file_stem().map_or_else(|| name.clone(), |s| s.to_string_lossy().into_owned());
|
||||
let outcome = pdfcraft_organize::insert_file_pages(doc, &src, &title, &pages, *at, *field_conflicts)?;
|
||||
cx.renamed_fields = outcome.renamed_fields;
|
||||
}
|
||||
Edit::AddBookmark { parent, index, title, page } => {
|
||||
@@ -2231,6 +2286,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
|
||||
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::FontSizeAnnotation { page, index, size } => pdfcraft_annot::set_font_size(doc, *page, *index, *size, &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())?;
|
||||
}
|
||||
@@ -2357,6 +2413,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
|
||||
}
|
||||
pdfcraft_edit::ImageChange::Replace(embed_image(doc, name, bytes)?.0)
|
||||
}
|
||||
ImageEdit::Crop(keep) => pdfcraft_edit::ImageChange::Crop(*keep),
|
||||
ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete,
|
||||
};
|
||||
pdfcraft_edit::change_image(doc, *page, *index, &c)?;
|
||||
@@ -2474,8 +2531,26 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
|
||||
pdfcraft_redact::sanitize::sanitize(doc)?;
|
||||
}
|
||||
Edit::Flatten { comments, fields } => {
|
||||
// An XFA form's fields baked into the pages: the XFA goes too, or Adobe's viewers
|
||||
// would lay the form out again from its template over the baked pages. A dynamic
|
||||
// form that was never laid out keeps its XFA: without it only placeholders remain.
|
||||
let shape = (*fields).then(|| pdfcraft_xfa::xfa_shape(doc)).flatten();
|
||||
let n = pdfcraft_model::pages(doc).len();
|
||||
pdfcraft_edit::flatten(doc, &(0..n).collect::<Vec<_>>(), *comments, *fields)?;
|
||||
match shape {
|
||||
Some(shape) if shape.strippable() => strip_xfa(doc, cx)?,
|
||||
Some(_) => cx.xfa_removed.push("The XFA was kept: this dynamic form was not laid out, so its fields were not baked in".into()),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
Edit::XfaToStatic => {
|
||||
let Some(shape) = pdfcraft_xfa::xfa_shape(doc) else {
|
||||
return Err(EditError::Invalid("this document is not an XFA form".into()));
|
||||
};
|
||||
if !shape.strippable() {
|
||||
return Err(EditError::Invalid("this dynamic XFA form was not laid out, so there is no static form to keep".into()));
|
||||
}
|
||||
strip_xfa(doc, cx)?;
|
||||
}
|
||||
Edit::FlattenFillSign => {
|
||||
let n = pdfcraft_model::pages(doc).len();
|
||||
@@ -2706,6 +2781,11 @@ pub struct Session {
|
||||
next_id: u64,
|
||||
/// Seconds since the Unix epoch, injected so saves are deterministic in tests.
|
||||
clock: Option<fn() -> i64>,
|
||||
/// Seconds to add to UTC for local time, read from whoever knows the zone (a browser, say).
|
||||
/// Separate from `clock` on purpose: `clock` alone is the test shortcut that takes the
|
||||
/// injected time AS local time and zeroes the zone, so injecting only it leaves either the
|
||||
/// local dates or the UTC ones wrong by the zone's offset.
|
||||
utc_offset: Option<fn() -> i64>,
|
||||
/// Certificates trusted for signing (Acrobat: Trusted Certificates).
|
||||
trust: Arc<TrustStore>,
|
||||
/// Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript, inverted (on by default).
|
||||
@@ -2861,6 +2941,62 @@ fn decode_data_file(bytes: &[u8]) -> Option<Result<String, String>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Take the XFA out of `doc` and note what went with it: the packets, data no field showed,
|
||||
/// buttons that did nothing but run scripts, and fields whose value differs from the form's
|
||||
/// data (data PdfCraft could not read into them, now lost).
|
||||
fn strip_xfa(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx) -> Result<(), EditError> {
|
||||
let fields = pdfcraft_forms::fields(doc);
|
||||
let data = xfa_field_data(doc);
|
||||
let differ = pdfcraft_xfa::read_values(doc, &data)
|
||||
.into_iter()
|
||||
.filter(|(name, value)| {
|
||||
let Some(f) = fields.iter().find(|f| f.name == *name) else { return false };
|
||||
match value {
|
||||
pdfcraft_xfa::FieldData::Text(t) => f.value.join("\n") != *t && !t.trim().is_empty(),
|
||||
pdfcraft_xfa::FieldData::Check(on) => f.value.is_empty() == *on,
|
||||
pdfcraft_xfa::FieldData::Radio(sel) => f.value.first() != sel.as_ref(),
|
||||
pdfcraft_xfa::FieldData::None => false,
|
||||
}
|
||||
})
|
||||
.count();
|
||||
let r = pdfcraft_xfa::strip(doc).map_err(|e| EditError::Write(e.to_string()))?;
|
||||
cx.xfa_removed.extend(r.notes());
|
||||
if differ > 0 {
|
||||
cx.xfa_removed.push(format!(
|
||||
"{differ} field(s) held a value other than the form's XFA data (data PdfCraft could not read into them); the data's version is gone"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Keep the document's XFA state in step with its file after an edit: a script may have laid
|
||||
/// the form out again (rows added, subforms shown), an edit may have taken the XFA out
|
||||
/// (flattened, or made static), or an undo put it back.
|
||||
fn sync_xfa_state(doc: &mut Document) {
|
||||
let Some(cos) = doc.editor.as_ref().map(|e| &e.cos) else { return };
|
||||
match pdfcraft_xfa::existing_layout(cos) {
|
||||
Some(report) => {
|
||||
if doc.xfa.is_none() {
|
||||
// Back after an undo: the XFA's own warnings apply again, the strip's don't.
|
||||
doc.xfa_warnings.retain(|w| !w.starts_with(XFA_REMOVED_PREFIX));
|
||||
if doc.xfa_template.is_none() && !doc.xfa_scripts_off {
|
||||
doc.xfa_template = xfa::template(cos);
|
||||
}
|
||||
}
|
||||
doc.xfa = Some(report);
|
||||
}
|
||||
None => {
|
||||
doc.xfa = None;
|
||||
doc.xfa_template = None;
|
||||
// Only what the strip said stays (the XFA's own warnings no longer apply).
|
||||
doc.xfa_warnings.retain(|w| w.starts_with(XFA_REMOVED_PREFIX));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How the warnings of a strip start, so they can be told from the XFA's own.
|
||||
const XFA_REMOVED_PREFIX: &str = "XFA removed: ";
|
||||
|
||||
/// Is this an XFA data file (an XDP, a datasets document, a data element or a data document),
|
||||
/// rather than XFDF, Acrobat's `<fields>` XML or a table? By its root element.
|
||||
fn is_xfa_data_file(text: &str) -> bool {
|
||||
@@ -2988,6 +3124,28 @@ impl Session {
|
||||
self
|
||||
}
|
||||
|
||||
/// A real clock for a host that has neither a clock nor a time zone of its own (a
|
||||
/// `wasm32-unknown-unknown` build: `now()` would be empty and `local_utc_offset()` is 0, so
|
||||
/// Fill & Sign stamps 1 Jan 1970 and `/ModDate` is never written). `clock` is the UTC time in
|
||||
/// seconds since the epoch; `utc_offset` is the local zone's seconds east of UTC, asked on every
|
||||
/// use because the zone changes with the date (daylight saving). Unlike
|
||||
/// [`Session::with_clock`], which is a test shortcut, nothing here is taken as local time.
|
||||
pub fn with_wall_clock(mut self, clock: fn() -> i64, utc_offset: fn() -> i64) -> Self {
|
||||
self.clock = Some(clock);
|
||||
self.utc_offset = Some(utc_offset);
|
||||
self
|
||||
}
|
||||
|
||||
/// Seconds to add to UTC for the local time of this session: the injected zone, else 0 under
|
||||
/// the test clock (which is already local), else the system's.
|
||||
fn local_offset(&self) -> i64 {
|
||||
match (self.utc_offset, self.clock) {
|
||||
(Some(zone), _) => zone(),
|
||||
(None, Some(_)) => 0,
|
||||
(None, None) => local_utc_offset(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Seconds since the Unix epoch from the session clock (0 when unknown).
|
||||
pub fn now_secs(&self) -> i64 {
|
||||
self.now().unwrap_or(0)
|
||||
@@ -3007,7 +3165,7 @@ impl Session {
|
||||
/// is taken as local time.
|
||||
/// A dynamic stamp's second line: "By Ada at 2:14 pm, Oct 02, 2026" (local time).
|
||||
pub fn stamp_by_line(&self, author: &str) -> String {
|
||||
let offset = if self.clock.is_some() { 0 } else { local_utc_offset() };
|
||||
let offset = self.local_offset();
|
||||
let d = self.now().map(|t| pdfcraft_cos::pdf_date(t + offset)).unwrap_or_default();
|
||||
let num = |a: usize, b: usize| d.get(a..b).and_then(|s| s.parse::<u32>().ok()).unwrap_or(0);
|
||||
let (y, mo, day, hh, mm) = (num(2, 6), num(6, 8), num(8, 10), num(10, 12), num(12, 14));
|
||||
@@ -3018,7 +3176,7 @@ impl Session {
|
||||
}
|
||||
|
||||
pub fn today(&self) -> (i64, u32, u32) {
|
||||
let offset = if self.clock.is_some() { 0 } else { local_utc_offset() };
|
||||
let offset = self.local_offset();
|
||||
self.now().map(|t| pdfcraft_cos::pdf_date(t + offset)).as_deref().and_then(parse_ymd).unwrap_or((1970, 1, 1))
|
||||
}
|
||||
|
||||
@@ -3490,6 +3648,12 @@ impl Session {
|
||||
doc.dirty = true;
|
||||
doc.generation += 1;
|
||||
doc.edit_notices = notices;
|
||||
if !cx.xfa_removed.is_empty() {
|
||||
let removed: Vec<String> = cx.xfa_removed.iter().map(|n| format!("{XFA_REMOVED_PREFIX}{n}")).collect();
|
||||
doc.xfa_warnings.retain(|w| w.starts_with(XFA_REMOVED_PREFIX));
|
||||
note_warnings(&mut doc.xfa_warnings, &removed);
|
||||
note_warnings(&mut doc.info.warnings, &removed);
|
||||
}
|
||||
note_warnings(&mut doc.xfa_warnings, &xfa_notes);
|
||||
let notes = doc.xfa_warnings.clone();
|
||||
note_warnings(&mut doc.info.warnings, ¬es);
|
||||
@@ -3658,13 +3822,8 @@ impl Session {
|
||||
/// appearances are kept: content edits leave every annotation dictionary alone.
|
||||
fn refresh_content(doc: &mut Document, edit: Option<&Edit>) -> Result<(), EditError> {
|
||||
Self::release_working(doc);
|
||||
sync_xfa_state(doc);
|
||||
let Some(editor) = doc.editor.as_mut() else { return Ok(()) };
|
||||
// A script may have laid an XFA form out again (rows added, subforms shown).
|
||||
if doc.xfa.is_some()
|
||||
&& let Some(report) = pdfcraft_xfa::existing_layout(&editor.cos)
|
||||
{
|
||||
doc.xfa = Some(report);
|
||||
}
|
||||
let bytes = if editor.cos.is_modified() {
|
||||
Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?)
|
||||
} else {
|
||||
@@ -3720,13 +3879,8 @@ impl Session {
|
||||
editor.appearances.clear();
|
||||
}
|
||||
Self::release_working(doc);
|
||||
sync_xfa_state(doc);
|
||||
let Some(editor) = doc.editor.as_mut() else { return Ok(()) };
|
||||
// A script may have laid an XFA form out again (rows added, subforms shown).
|
||||
if doc.xfa.is_some()
|
||||
&& let Some(report) = pdfcraft_xfa::existing_layout(&editor.cos)
|
||||
{
|
||||
doc.xfa = Some(report);
|
||||
}
|
||||
let bytes = if editor.cos.is_modified() {
|
||||
Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?)
|
||||
} else {
|
||||
@@ -4022,6 +4176,26 @@ impl Session {
|
||||
if !doc.allows_printing() {
|
||||
return Err(EditError::NotPermitted("printing"));
|
||||
}
|
||||
// Print as image: the pages used become pictures first, then the picture document is
|
||||
// imposed in their place (its page k is the k-th distinct source page used).
|
||||
if let Some(raster) = &settings.raster {
|
||||
let mut distinct: Vec<usize> = Vec::new();
|
||||
for &p in &settings.pages {
|
||||
if !distinct.contains(&p) {
|
||||
distinct.push(p);
|
||||
}
|
||||
}
|
||||
let hide_comments = matches!(settings.content, print::Content::Document | print::Content::FormFieldsOnly);
|
||||
let bytes = print_raster::rasterize(doc, &distinct, raster, hide_comments).map_err(EditError::Print)?;
|
||||
let pictures = pdfcraft_cos::Document::open(Arc::new(bytes)).map_err(|e| EditError::Print(e.to_string()))?;
|
||||
let remapped = print::Settings {
|
||||
pages: settings.pages.iter().map(|p| distinct.iter().position(|d| d == p).unwrap_or(0)).collect(),
|
||||
content: print::Content::Document,
|
||||
raster: None,
|
||||
..settings.clone()
|
||||
};
|
||||
return pdfcraft_print::impose(&pictures, &remapped).map_err(|e| EditError::Print(e.to_string()));
|
||||
}
|
||||
let cos = match doc.editor.as_ref() {
|
||||
Some(e) => e.cos.clone(),
|
||||
None => {
|
||||
@@ -4224,7 +4398,7 @@ impl Session {
|
||||
let mut cuts = Vec::new();
|
||||
let mut used = 0usize;
|
||||
for p in 0..n {
|
||||
let size = self.write_new(&pdfcraft_organize::extract_pages(src, &[p])?)?.len();
|
||||
let size = self.write_unchecked(&pdfcraft_organize::extract_pages(src, &[p])?)?.len();
|
||||
if used > 0 && used + size > max_bytes {
|
||||
cuts.push(p);
|
||||
used = 0;
|
||||
@@ -4284,7 +4458,17 @@ impl Session {
|
||||
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();
|
||||
let rows = page
|
||||
.bookmarks
|
||||
.into_iter()
|
||||
.map(|b| {
|
||||
let view = b.view.map_or_else(Default::default, |(kind, numbers)| {
|
||||
let numbers: Vec<Option<f32>> = numbers.iter().map(|n| n.map(|n| n as f32)).collect();
|
||||
pdfcraft_render::DestView::from_operands(Some(&kind), &numbers)
|
||||
});
|
||||
BookmarkRow { path: b.path, title: b.title, open: b.open, page: b.page, view }
|
||||
})
|
||||
.collect();
|
||||
return Some(BookmarkPage { rows, next: page.next, truncated: false });
|
||||
}
|
||||
let all = outline_rows(&doc.info.outline);
|
||||
@@ -4322,7 +4506,18 @@ impl Session {
|
||||
doc.editor.as_ref().map(|e| &e.cos).ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))
|
||||
}
|
||||
|
||||
/// A new file (Combine, Extract, Split, Create) as bytes, checked like a save before anyone
|
||||
/// writes it (#1698, #2166): it reopens, and shows the pages it was built from.
|
||||
fn write_new(&self, doc: &pdfcraft_cos::Document) -> Result<Arc<Vec<u8>>, EditError> {
|
||||
let bytes = self.write_unchecked(doc)?;
|
||||
let expected = pdfcraft_organize::page_count(doc)?;
|
||||
pdfcraft_cos::Document::open(bytes.clone()).map_err(|e| EditError::Reopen(e.to_string()))?;
|
||||
pdfcraft_render::check_pages(bytes.clone(), None, expected).map_err(|e| EditError::Reopen(e.to_string()))?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// [`Self::write_new`] without the check, for bytes nobody writes: measuring a part's size.
|
||||
fn write_unchecked(&self, doc: &pdfcraft_cos::Document) -> Result<Arc<Vec<u8>>, EditError> {
|
||||
let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() };
|
||||
write_full(doc, &opts).map(Arc::new).map_err(|e| EditError::Write(e.to_string()))
|
||||
}
|
||||
@@ -4441,7 +4636,7 @@ impl Session {
|
||||
|
||||
/// The signing time as a PDF date in local time with its offset (`D:…+02'00'`).
|
||||
pub fn signing_date(&self) -> String {
|
||||
let offset = if self.clock.is_some() { 0 } else { local_utc_offset() };
|
||||
let offset = self.local_offset();
|
||||
let local = pdfcraft_cos::pdf_date(self.now().unwrap_or(0) + offset);
|
||||
let stamp = local.trim_end_matches('Z');
|
||||
if offset == 0 {
|
||||
@@ -4478,7 +4673,10 @@ impl Session {
|
||||
if opts.date.is_empty() {
|
||||
opts.date = self.signing_date();
|
||||
}
|
||||
Ok(Arc::new(pdfcraft_sign::sign_with_timestamp(&editor.cos, id, &opts, tsa)?))
|
||||
let signed = Arc::new(pdfcraft_sign::sign_with_timestamp(&editor.cos, id, &opts, tsa)?);
|
||||
// Checked like a save before anyone writes it (#2166).
|
||||
self.check_save(doc, &signed)?;
|
||||
Ok(signed)
|
||||
}
|
||||
|
||||
/// Append a standalone document timestamp (RFC 3161, `/ETSI.RFC3161`) covering the file's
|
||||
@@ -4487,7 +4685,9 @@ impl Session {
|
||||
let d = self.get(doc).ok_or(EditError::NoDocument)?;
|
||||
let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?;
|
||||
let date = if date.is_empty() { self.signing_date() } else { date };
|
||||
Ok(Arc::new(pdfcraft_sign::timestamp_document(&editor.cos, tsa, &date)?))
|
||||
let stamped = Arc::new(pdfcraft_sign::timestamp_document(&editor.cos, tsa, &date)?);
|
||||
self.check_save(doc, &stamped)?;
|
||||
Ok(stamped)
|
||||
}
|
||||
|
||||
/// Embed revocation evidence into the catalog's `/DSS` with `/VRI` entries per signature
|
||||
@@ -4495,7 +4695,9 @@ impl Session {
|
||||
pub fn embed_ltv(&self, doc: DocId, evidence: &pdfcraft_sign::dss::Evidence) -> Result<Arc<Vec<u8>>, EditError> {
|
||||
let d = self.get(doc).ok_or(EditError::NoDocument)?;
|
||||
let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?;
|
||||
Ok(Arc::new(pdfcraft_sign::dss::embed(&editor.cos, evidence)?))
|
||||
let embedded = Arc::new(pdfcraft_sign::dss::embed(&editor.cos, evidence)?);
|
||||
self.check_save(doc, &embedded)?;
|
||||
Ok(embedded)
|
||||
}
|
||||
|
||||
/// Record that the signed file `bytes` was saved (to `path`): like [`Session::mark_saved`],
|
||||
|
||||
@@ -30,6 +30,10 @@ pub struct OcrSettings {
|
||||
/// text read again, not a word in a picture.
|
||||
const ALREADY_ON_PAGE: f32 = 0.5;
|
||||
|
||||
/// The long side of an A4 sheet in points (a letter page's, 792, is shorter): "Downsample to"
|
||||
/// is a resolution on paper this size or smaller.
|
||||
const SHEET: f32 = 842.0;
|
||||
|
||||
/// How much of `word` the `text` boxes cover, from 0 to 1. Boxes are `[x0, y0, x1, y1]` in page
|
||||
/// view space. Overlaps between the text boxes are counted twice (glyphs rarely overlap), so the
|
||||
/// sum is capped at the whole word.
|
||||
@@ -121,11 +125,7 @@ impl OcrJob {
|
||||
out.push(skip("the page already contains text"));
|
||||
continue;
|
||||
}
|
||||
// A scan is read at its own resolution (enlarging it only blurs the letters), at most
|
||||
// the chosen one.
|
||||
let wanted = self.settings.dpi.clamp(72.0, 600.0) / 72.0;
|
||||
let scale = self.native.get(page).copied().flatten().map_or(wanted, |n| n.clamp(1.0, wanted));
|
||||
let shot = r.render(RenderRequest { page, scale, ..Default::default() });
|
||||
let shot = r.render(RenderRequest { page, scale: self.scale(page, info), ..Default::default() });
|
||||
if let Some(e) = shot.error {
|
||||
out.push(skip(&e));
|
||||
continue;
|
||||
@@ -151,6 +151,18 @@ impl OcrJob {
|
||||
progress(total, total);
|
||||
out
|
||||
}
|
||||
|
||||
/// The render scale (pixels per point) `page` is read at. A scan is read at its own
|
||||
/// resolution (enlarging it only blurs the letters), at most the chosen one.
|
||||
fn scale(&self, page: usize, info: &PageInfo) -> f32 {
|
||||
let wanted = self.settings.dpi.clamp(72.0, 600.0) / 72.0;
|
||||
let Some(native) = self.native.get(page).copied().flatten() else { return wanted };
|
||||
// A scan whose page is larger than a sheet (often a box given in pixels, one point per
|
||||
// pixel, #1848) is measured as if shrunk to one: otherwise 1 px/pt is already its full
|
||||
// image, and no "Downsample to" choice could ask for less.
|
||||
let fit = (SHEET / info.width.max(info.height).max(1e-3)).min(1.0);
|
||||
native.clamp(fit, wanted * fit)
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
@@ -236,12 +248,16 @@ pub fn recognize_file(
|
||||
if s.apply_ocr(id, &pages).map_err(|e| e.to_string())? == 0 {
|
||||
return Ok(FileResult { bytes, pages });
|
||||
}
|
||||
Ok(FileResult { bytes: s.save_bytes(id).map_err(|e| e.to_string())?, pages })
|
||||
// Checked like a save before the caller writes it (#2166).
|
||||
let bytes = s.save_bytes(id).map_err(|e| e.to_string())?;
|
||||
s.check_save(id, &bytes).map_err(|e| e.to_string())?;
|
||||
Ok(FileResult { bytes, pages })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::export;
|
||||
|
||||
/// #1028: a recognised word over the page's own text is that text again; one in an empty
|
||||
/// part of the page (a picture) is new.
|
||||
@@ -260,6 +276,28 @@ mod tests {
|
||||
assert_eq!(covered([100.5, 52.0, 134.0, 61.0], &[]), 0.0);
|
||||
}
|
||||
|
||||
/// #1848: an A4 scan at 300 dpi whose page box is its pixel size (2480 x 3508 pt, one pixel
|
||||
/// per point) gets a smaller bitmap for each lower "Downsample to" choice, and a low-resolution
|
||||
/// letter page is still never shrunk below one pixel per point.
|
||||
#[test]
|
||||
fn downsample_shrinks_a_page_box_given_in_pixels() {
|
||||
let mut s = Session::new();
|
||||
let page = |w: u32, h: u32| export::encode_png(w, h, &vec![255; (w * h * 4) as usize]).unwrap();
|
||||
let bytes = s.create_from_images(&[("a4.png".into(), page(2480, 3508)), ("letter.png".into(), page(612, 792))]).unwrap();
|
||||
let id = s.open("scan.pdf", None, bytes, None).unwrap();
|
||||
let mut r = PageRenderer::new(s.get(id).unwrap().bytes.clone(), RenderConfig::default());
|
||||
for (dpi, a4) in [(72.0, [595, 842]), (150.0, [1240, 1754]), (300.0, [2480, 3508]), (600.0, [2480, 3508])] {
|
||||
let job = s.ocr_job(id, &[], OcrSettings { dpi, ..Default::default() }).unwrap();
|
||||
for (page, want) in [(0, a4), (1, [612, 792])] {
|
||||
let scale = job.scale(page, &job.infos[page]);
|
||||
let shot = r.render(RenderRequest { page, scale, ..Default::default() });
|
||||
assert!(shot.error.is_none(), "{:?}", shot.error);
|
||||
let near = |got: u32, want: u32| got.abs_diff(want) <= 1;
|
||||
assert!(near(shot.width, want[0]) && near(shot.height, want[1]), "{dpi} dpi, page {page}: {}x{}", shot.width, shot.height);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coverage_of_overlapping_or_broken_boxes_stays_between_zero_and_one() {
|
||||
// A glyph drawn twice (fake bold) does not cover more than the whole word.
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! Print as image (Acrobat's Advanced Print Setup ▸ Print As Image): every page
|
||||
//! sent to the printer as a picture at a chosen resolution, for printers that skip text,
|
||||
//! shading or transparency. The pages are rendered the way the viewer shows them (reading
|
||||
//! colours off, the print's own colours), made into an image-only document, and that document
|
||||
//! is imposed on the paper like any other. The print is no longer searchable text.
|
||||
|
||||
use pdfcraft_create::ImageResolution;
|
||||
use pdfcraft_print::Raster;
|
||||
use pdfcraft_render::{PRINT_TAG, PageRenderer, RenderRequest};
|
||||
|
||||
use crate::Document;
|
||||
use crate::export::encode_png;
|
||||
|
||||
/// `doc`'s pages `pages` (0-based, each once, in the order given) as an image-only PDF, one
|
||||
/// page per entry, the size of the source page as displayed. Pages too large for the renderer
|
||||
/// at `raster.dpi` render at the largest resolution it allows.
|
||||
pub fn rasterize(doc: &Document, pages: &[usize], raster: &Raster, hide_comments: bool) -> Result<Vec<u8>, String> {
|
||||
let dpi = raster.dpi.clamp(72.0, 600.0);
|
||||
let src = doc.export_source();
|
||||
let mut config = src.config.clone();
|
||||
config.hide_comments = hide_comments;
|
||||
let mut renderer = PageRenderer::new(src.bytes, config);
|
||||
let mut images: Vec<(String, Vec<u8>)> = Vec::with_capacity(pages.len());
|
||||
let mut used: Option<f64> = None;
|
||||
for &page in pages {
|
||||
let (w, h) = src.sizes.get(page).copied().ok_or_else(|| format!("page {} does not exist", page + 1))?;
|
||||
let scale = pdfcraft_render::effective_scale(w, h, (dpi / 72.0) as f32);
|
||||
let page_dpi = f64::from(scale) * 72.0;
|
||||
// Every page of the image document shares one resolution, so its pages keep the
|
||||
// source's sizes; the lowest a page needed is used for all.
|
||||
used = Some(used.map_or(page_dpi, |u: f64| u.min(page_dpi)));
|
||||
let r = renderer.render(RenderRequest { page, scale, tag: PRINT_TAG, ..Default::default() });
|
||||
if let Some(e) = r.error {
|
||||
return Err(format!("page {}: {e}", page + 1));
|
||||
}
|
||||
let mut rgba = r.rgba.to_vec();
|
||||
over_white(&mut rgba, raster.grayscale);
|
||||
images.push((format!("page-{}.png", page + 1), encode_png(r.width, r.height, &rgba)?));
|
||||
}
|
||||
let dpi = used.unwrap_or(dpi);
|
||||
let doc = pdfcraft_create::from_images_with_resolution(&images, ImageResolution::Dpi(dpi)).map_err(|e| e.to_string())?;
|
||||
pdfcraft_cos::write_full(&doc, &pdfcraft_cos::SaveOptions::default()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Premultiplied RGBA composed over white paper (opaque), in grey when asked (Rec. 601 luma).
|
||||
fn over_white(rgba: &mut [u8], grayscale: bool) {
|
||||
for px in rgba.as_chunks_mut::<4>().0 {
|
||||
let gap = 255 - u32::from(px[3]);
|
||||
let (r, g, b) = ((u32::from(px[0]) + gap).min(255), (u32::from(px[1]) + gap).min(255), (u32::from(px[2]) + gap).min(255));
|
||||
let (r, g, b) = if grayscale {
|
||||
let y = ((299 * r + 587 * g + 114 * b) / 1000).min(255);
|
||||
(y, y, y)
|
||||
} else {
|
||||
(r, g, b)
|
||||
};
|
||||
px[0] = r as u8;
|
||||
px[1] = g as u8;
|
||||
px[2] = b as u8;
|
||||
px[3] = 255;
|
||||
}
|
||||
}
|
||||
@@ -1811,6 +1811,34 @@ fn form_refresh_keeps_first_value_when_field_names_repeat() {
|
||||
assert_eq!(dup.as_deref(), Some("first"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiselect_choice_indices_stay_sorted_through_engine_save_and_reopen() {
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(
|
||||
id,
|
||||
Edit::AddField {
|
||||
page: 0,
|
||||
rect: [20.0, 180.0, 180.0, 240.0],
|
||||
kind: NewField::List { options: vec!["Cheese".into(), "Ham".into(), "Olives".into()], multi: true },
|
||||
name: Some("toppings".into()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let chosen = vec!["Olives".to_string(), "Ham".to_string()];
|
||||
s.apply(id, Edit::SetFieldValue { name: "toppings".into(), value: FieldValue::Choice(chosen.clone()) }).unwrap();
|
||||
assert_eq!(s.get(id).unwrap().form[0].value, chosen, "the live engine keeps selection order");
|
||||
let saved = s.save_bytes(id).unwrap();
|
||||
let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap();
|
||||
let all = pdfcraft_forms::fields(&cos);
|
||||
let list = all.iter().find(|field| field.name == "toppings").unwrap();
|
||||
let object = cos.get(list.obj);
|
||||
let indices = object.as_dict().unwrap().get(b"I").unwrap().as_array().unwrap();
|
||||
assert_eq!(indices.iter().map(|index| index.as_int().unwrap()).collect::<Vec<_>>(), [1, 2]);
|
||||
assert_eq!(list.value, chosen, "sorted /I does not reorder /V");
|
||||
let reopened = s.open("reopened.pdf", None, saved, None).unwrap();
|
||||
assert_eq!(s.get(reopened).unwrap().form[0].value, chosen, "reopening keeps selection order");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preparing_a_form_adds_renames_and_deletes_fields() {
|
||||
let (mut s, id) = session_with(1);
|
||||
@@ -3918,6 +3946,113 @@ fn a_value_a_script_sets_reaches_every_field_bound_to_the_same_data_node() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flattening_an_xfa_form_bakes_its_pages_and_takes_the_xfa_out() {
|
||||
let data = "<form><page1><country>JP</country><langs>English\nSpanish</langs></page1></form>";
|
||||
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::fields_template(data)));
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("fields.pdf", None, bytes, None).expect("opens");
|
||||
assert_eq!(s.get(id).unwrap().info.xfa, Some(pdfcraft_render::Xfa::Dynamic));
|
||||
s.apply(id, Edit::SetFieldValue { name: "other".into(), value: FieldValue::Text("typed here".into()) }).unwrap();
|
||||
s.apply(id, Edit::Flatten { comments: false, fields: true }).unwrap();
|
||||
let d = s.get(id).unwrap();
|
||||
assert!(d.form.is_empty(), "no fields remain");
|
||||
assert_eq!(d.info.xfa, None, "no longer an XFA form: Adobe's viewers show the baked pages");
|
||||
assert!(d.xfa.is_none());
|
||||
let texts = page_texts(&s, id).join("\n");
|
||||
for want in ["Japan", "typed here", "Country: JP", "12345"] {
|
||||
assert!(texts.contains(want), "{want} is page content now: {texts}");
|
||||
}
|
||||
// Saved and reopened: a plain PDF, the same page content, nothing of the XFA left.
|
||||
let saved = s.save_bytes(id).unwrap();
|
||||
let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap();
|
||||
assert!(pdfcraft_xfa::read_packets(&cos).unwrap().is_none());
|
||||
let id2 = s.open("flat.pdf", None, saved, None).unwrap();
|
||||
let d2 = s.get(id2).unwrap();
|
||||
assert_eq!((d2.info.xfa, d2.form.len()), (None, 0));
|
||||
assert!(page_texts(&s, id2).join("\n").contains("typed here"));
|
||||
// Undone, the form is a laid-out XFA form again.
|
||||
s.undo(id).unwrap();
|
||||
let d = s.get(id).unwrap();
|
||||
assert_eq!(d.info.xfa, Some(pdfcraft_render::Xfa::Dynamic));
|
||||
assert_eq!(d.form.iter().find(|f| f.name == "other").unwrap().value, vec!["typed here".to_string()]);
|
||||
// Flattening comments alone leaves the XFA form as it is.
|
||||
s.apply(id, Edit::Flatten { comments: true, fields: false }).unwrap();
|
||||
assert_eq!(s.get(id).unwrap().info.xfa, Some(pdfcraft_render::Xfa::Dynamic));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dynamic_xfa_form_that_was_never_laid_out_keeps_its_xfa_and_the_user_is_told_what_went() {
|
||||
// A template that can't be read: the form keeps its placeholder page and its XFA.
|
||||
let broken = "<xdp:xdp xmlns:xdp=\"http://ns.adobe.com/xdp/\"><template xmlns=\"http://www.xfa.org/schema/xfa-template/3.3/\"><subform name=\"form\"</template></xdp:xdp>";
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("broken.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(broken)), None).expect("opens");
|
||||
assert_eq!(s.get(id).unwrap().info.xfa, Some(pdfcraft_render::Xfa::Dynamic));
|
||||
assert!(s.get(id).unwrap().xfa.is_none(), "not laid out");
|
||||
assert!(s.apply(id, Edit::XfaToStatic).is_err(), "nothing to keep");
|
||||
s.apply(id, Edit::Flatten { comments: false, fields: true }).unwrap();
|
||||
let d = s.get(id).unwrap();
|
||||
assert_eq!(d.info.xfa, Some(pdfcraft_render::Xfa::Dynamic), "the XFA stays: Adobe can still render the form");
|
||||
assert!(d.info.warnings.iter().any(|w| w.contains("The XFA was kept")), "{:?}", d.info.warnings);
|
||||
// A laid-out form with data no field holds: converting says what went with the XFA, and
|
||||
// the note stays on the document, while the XFA's own warnings go; undo brings those back.
|
||||
let data = "<form><page1><country>JP</country><nobody>holds this</nobody></page1></form>";
|
||||
let id = s.open("fields.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::fields_template(data))), None).unwrap();
|
||||
let before: Vec<String> = s.get(id).unwrap().info.warnings.clone();
|
||||
assert!(before.iter().any(|w| w.contains("scripts changed it on opening")), "an XFA-only warning: {before:?}");
|
||||
s.apply(id, Edit::XfaToStatic).unwrap();
|
||||
let w = s.get(id).unwrap().info.warnings.clone();
|
||||
assert!(w.iter().any(|n| n.starts_with("XFA removed: The XFA was taken out") && n.contains("template, datasets")), "{w:?}");
|
||||
assert!(w.iter().any(|n| n.contains("1 value(s) the form's XFA data held that no field showed")), "{w:?}");
|
||||
assert!(w.iter().any(|n| n.contains("1 image field(s) do nothing now")), "{w:?}");
|
||||
assert!(!w.iter().any(|n| n.contains("scripts changed it on opening")), "the XFA's own warning is gone: {w:?}");
|
||||
let js = s.take_js_output(id);
|
||||
assert!(js.errors.is_empty(), "{js:?}");
|
||||
s.undo(id).unwrap();
|
||||
let w = s.get(id).unwrap().info.warnings.clone();
|
||||
assert!(!w.iter().any(|n| n.starts_with("XFA removed")), "{w:?}");
|
||||
assert_eq!(s.get(id).unwrap().info.xfa, Some(pdfcraft_render::Xfa::Dynamic));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_xfa_form_converts_to_a_static_form_that_keeps_its_fields() {
|
||||
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template()));
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("scripted.pdf", None, bytes, None).expect("opens");
|
||||
let value = |s: &Session, id: DocId, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).map(|f| f.value.clone()).unwrap_or_default();
|
||||
assert_eq!(value(&s, id, "total"), vec!["10".to_string()], "the scripts ran on open");
|
||||
let fields_before = s.get(id).unwrap().form.len();
|
||||
s.apply(id, Edit::XfaToStatic).unwrap();
|
||||
let d = s.get(id).unwrap();
|
||||
assert_eq!(d.info.xfa, None);
|
||||
assert_eq!(d.form.len(), fields_before, "the fields stay");
|
||||
assert_eq!(value(&s, id, "qty"), vec!["2".to_string()]);
|
||||
assert_eq!(value(&s, id, "total"), vec!["10".to_string()]);
|
||||
assert_eq!(s.get(id).unwrap().can_undo(), Some("Convert to a static form"));
|
||||
// The form is an ordinary one now: a change no longer runs the XFA calculation.
|
||||
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("7".into()) }).unwrap();
|
||||
assert_eq!(value(&s, id, "total"), vec!["10".to_string()]);
|
||||
assert!(s.take_js_output(id).is_empty());
|
||||
// Saved: no XFA, no private keys on the fields, the pages as laid out.
|
||||
let saved = s.save_bytes(id).unwrap();
|
||||
let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap();
|
||||
assert!(pdfcraft_xfa::read_packets(&cos).unwrap().is_none());
|
||||
assert!(pdfcraft_xfa::fields_by_som(&cos).is_empty(), "no /PCSom left");
|
||||
let id2 = s.open("static.pdf", None, saved, None).unwrap();
|
||||
let d2 = s.get(id2).unwrap();
|
||||
assert_eq!(d2.info.xfa, None);
|
||||
assert_eq!(value(&s, id2, "qty"), vec!["7".to_string()]);
|
||||
assert_eq!(d2.info.pages.len(), 1);
|
||||
// Not an XFA form: refused.
|
||||
assert!(s.apply(id2, Edit::XfaToStatic).is_err());
|
||||
// Undone, the XFA form is back and its scripts run again.
|
||||
s.undo(id).unwrap();
|
||||
s.undo(id).unwrap();
|
||||
assert_eq!(s.get(id).unwrap().info.xfa, Some(pdfcraft_render::Xfa::Dynamic));
|
||||
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("3".into()) }).unwrap();
|
||||
assert_eq!(value(&s, id, "total"), vec!["15".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_xfa_forms_run_formcalc_scripts() {
|
||||
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::formcalc_template()));
|
||||
@@ -4210,6 +4345,51 @@ fn comments_without_appearances_are_drawn_but_not_saved() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A page with a FreeText comment that has no `/AP` stream: the smallest case behind #2371.
|
||||
fn free_text_without_appearance() -> Arc<Vec<u8>> {
|
||||
let objs: Vec<&[u8]> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>",
|
||||
b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
|
||||
b"<< /Type /Annot /Subtype /FreeText /Rect [20 200 180 260] /Contents (Box) /DA (/Helv 10 Tf 0 0 1 rg) /Border [0 0 1] /C [.85 .47 .02] >>",
|
||||
];
|
||||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(bytes.len());
|
||||
bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
bytes.extend_from_slice(o);
|
||||
bytes.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = bytes.len();
|
||||
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
Arc::new(bytes)
|
||||
}
|
||||
|
||||
/// #2371: a comment without an appearance stream is displayed by the app, so the CLI's page image
|
||||
/// must show it too. The helper's copy is for rendering only; documents that need no copy, and
|
||||
/// files it can't display, keep exactly the original source bytes.
|
||||
#[test]
|
||||
fn cli_display_adds_missing_appearances_without_changing_the_source() {
|
||||
let original = free_text_without_appearance();
|
||||
let display = display_bytes_for(original.clone(), None);
|
||||
assert_ne!(display.as_slice(), original.as_slice(), "the display copy draws the missing comment appearance");
|
||||
|
||||
let doc = pdfcraft_cos::Document::open(display).unwrap();
|
||||
let object = doc.get(pdfcraft_cos::ObjRef::new(4, 0));
|
||||
let annot = object.as_dict().unwrap();
|
||||
assert!(annot.contains(b"AP"), "the display copy has an appearance stream");
|
||||
|
||||
let plain = Arc::new(fixture(1));
|
||||
assert!(Arc::ptr_eq(&display_bytes_for(plain.clone(), None), &plain), "a complete document needs no copy");
|
||||
let damaged = Arc::new(b"not a PDF".to_vec());
|
||||
assert!(Arc::ptr_eq(&display_bytes_for(damaged.clone(), None), &damaged), "an unreadable file falls back to its source");
|
||||
}
|
||||
|
||||
/// Titles a browser writes for a blank page or a pop-up name no document; real titles stay.
|
||||
#[test]
|
||||
fn placeholder_titles_are_recognised() {
|
||||
@@ -4868,3 +5048,167 @@ fn copy_image_finds_a_picture_inside_form_artwork_under_text() {
|
||||
assert_eq!(d.page_image_copy_file(0, 0).unwrap(), ("jpg", jpeg));
|
||||
assert!(d.page_image_copy_file(0, 1).is_err());
|
||||
}
|
||||
|
||||
/// Print as image (Acrobat's Advanced Print Setup): the print-ready PDF carries each page as a
|
||||
/// picture, the size of the page, with no text left to search; in grayscale the pictures are
|
||||
/// grey. A page used on several sheets is rendered once.
|
||||
#[test]
|
||||
fn printing_as_image_sends_pictures_instead_of_pages() {
|
||||
let (s, id) = session_with(3);
|
||||
let page_size = (s.get(id).unwrap().info.pages[1].width as f64, s.get(id).unwrap().info.pages[1].height as f64);
|
||||
let settings = print::Settings {
|
||||
pages: vec![1, 2, 1],
|
||||
layout: print::Layout::Size(print::SizeMode::Actual),
|
||||
raster: Some(print::Raster { dpi: 100.0, grayscale: false }),
|
||||
..Default::default()
|
||||
};
|
||||
let bytes = s.print_pdf(id, &settings).unwrap();
|
||||
let mut s2 = Session::new();
|
||||
let id2 = s2.open("print.pdf", None, Arc::new(bytes.clone()), None).unwrap();
|
||||
assert_eq!(s2.get(id2).unwrap().info.pages.len(), 3, "three sheets, one page each");
|
||||
assert!(page_texts(&s2, id2).iter().all(|t| t.trim().is_empty()), "pictures carry no text");
|
||||
let doc = pdfcraft_cos::Document::open(Arc::new(bytes)).unwrap();
|
||||
let images: Vec<pdfcraft_cos::Dict> = doc
|
||||
.scan_objects()
|
||||
.filter_map(|(_, o)| match &*o {
|
||||
pdfcraft_cos::Object::Stream(st) if st.dict.name(b"Subtype") == Some(b"Image") => Some(st.dict.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(images.len(), 2, "two distinct pages, each rendered once: {images:?}");
|
||||
// 100 dpi: the picture's pixels match the page size.
|
||||
let w = images.iter().map(|d| d.int(b"Width").unwrap_or(0)).max().unwrap();
|
||||
assert!((w as f64 - page_size.0 * 100.0 / 72.0).abs() <= 1.0, "{w} px for {} pt", page_size.0);
|
||||
assert!(images.iter().all(|d| d.name(b"ColorSpace") == Some(b"DeviceRGB")), "{images:?}");
|
||||
// Grayscale renders the pictures grey (the printer is not merely asked for monochrome).
|
||||
let grey = s.print_pdf(id, &print::Settings { raster: Some(print::Raster { dpi: 72.0, grayscale: true }), ..settings.clone() }).unwrap();
|
||||
let mut s3 = Session::new();
|
||||
let id3 = s3.open("grey.pdf", None, Arc::new(grey), None).unwrap();
|
||||
let mut ex = crate::export::Exporter::new(s3.get(id3).unwrap());
|
||||
let png = ex.png(0, 36.0).unwrap();
|
||||
let img = image::load_from_memory(&png).unwrap().to_rgba8();
|
||||
assert!(img.pixels().all(|p| p[0] == p[1] && p[1] == p[2]), "every pixel grey");
|
||||
}
|
||||
|
||||
/// A one-page PDF: a JPEG logo (DCTDecode) at the top and a big text title below it.
|
||||
fn jpeg_cover_pdf(logo_y: i32) -> Vec<u8> {
|
||||
let mut jpeg = Vec::new();
|
||||
let img = image::RgbImage::from_fn(40, 20, |x, y| image::Rgb([(x * 6) as u8, (y * 12) as u8, 90]));
|
||||
image::codecs::jpeg::JpegEncoder::new(&mut jpeg).encode_image(&img).unwrap();
|
||||
let content = format!(
|
||||
"q 200 0 0 100 72 {logo_y} cm /Im1 Do Q BT /F1 30 Tf 72 580 Td (Annual Report) Tj ET \
|
||||
BT /F1 11 Tf 72 500 Td (Body text that is long enough to count as the body size of this page.) Tj ET"
|
||||
);
|
||||
let mut pdf = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
let mut add = |pdf: &mut Vec<u8>, n: usize, body: &[u8]| {
|
||||
offsets.push(pdf.len());
|
||||
pdf.extend_from_slice(format!("{n} 0 obj\n").as_bytes());
|
||||
pdf.extend_from_slice(body);
|
||||
pdf.extend_from_slice(b"\nendobj\n");
|
||||
};
|
||||
add(&mut pdf, 1, b"<< /Type /Catalog /Pages 2 0 R >>");
|
||||
add(&mut pdf, 2, b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
|
||||
add(
|
||||
&mut pdf,
|
||||
3,
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im1 6 0 R >> >> >>",
|
||||
);
|
||||
add(&mut pdf, 4, format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()).as_bytes());
|
||||
add(&mut pdf, 5, b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>");
|
||||
let mut obj = format!("<< /Type /XObject /Subtype /Image /Width 40 /Height 20 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length {} >>\nstream\n", jpeg.len()).into_bytes();
|
||||
obj.extend_from_slice(&jpeg);
|
||||
obj.extend_from_slice(b"\nendstream");
|
||||
add(&mut pdf, 6, &obj);
|
||||
let xref = pdf.len();
|
||||
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", offsets.len() + 1).as_bytes());
|
||||
for o in &offsets {
|
||||
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", offsets.len() + 1).as_bytes());
|
||||
pdf
|
||||
}
|
||||
|
||||
/// #1936: a JPEG logo on a cover page reaches the Word file as the unchanged JPEG, both where it
|
||||
/// sits clear of the title (inline) and where it overlaps it (a positioned page).
|
||||
#[test]
|
||||
fn exporting_a_jpeg_logo_to_word_keeps_the_image() {
|
||||
for (logo_y, overlaps) in [(650, false), (560, true)] {
|
||||
let pdf = jpeg_cover_pdf(logo_y);
|
||||
let mut s = Session::new();
|
||||
let id = s.open("c.pdf", None, Arc::new(pdf), None).unwrap();
|
||||
let d = s.get(id).unwrap();
|
||||
let pages = d.export_pages();
|
||||
assert_eq!((pages[0].images.len(), pages[0].images[0].ext), (1, "jpg"), "overlap {overlaps}");
|
||||
let jpeg = pages[0].images[0].bytes.clone();
|
||||
assert!(jpeg.starts_with(&[0xFF, 0xD8]));
|
||||
let docx = d.export_office(compare::OfficeFormat::Docx);
|
||||
assert!(docx.windows(21).any(|w| w == b"word/media/image1.jpg"), "overlap {overlaps}");
|
||||
assert!(docx.windows(jpeg.len()).any(|w| w == jpeg.as_slice()), "the JPEG is stored unchanged (overlap {overlaps})");
|
||||
}
|
||||
}
|
||||
|
||||
/// Cropping an existing page image (Acrobat's crop handles on a picture) shows only the chosen
|
||||
/// box, the pixels staying where they are: `visible_rect` says what shows, the full placement is
|
||||
/// unchanged, and a later move carries the crop along.
|
||||
#[test]
|
||||
fn cropping_an_existing_image_clips_it_and_moves_with_it() {
|
||||
let mut s = Session::new();
|
||||
let mut px = image::RgbaImage::new(40, 40);
|
||||
for (x, y, p) in px.enumerate_pixels_mut() {
|
||||
*p = if x < 20 {
|
||||
image::Rgba([255, 0, 0, 255])
|
||||
} else if y < 20 {
|
||||
image::Rgba([0, 255, 0, 255])
|
||||
} else {
|
||||
image::Rgba([0, 0, 255, 255])
|
||||
};
|
||||
}
|
||||
let mut png = std::io::Cursor::new(Vec::new());
|
||||
image::DynamicImage::ImageRgba8(px).write_to(&mut png, image::ImageFormat::Png).unwrap();
|
||||
let pic = s.create_from_images(&[("p.png".into(), png.into_inner())]).unwrap();
|
||||
let id = s.open("p.pdf", None, pic, None).unwrap();
|
||||
let full = s.get(id).unwrap().page_images(0)[0].rect;
|
||||
let (w, h) = (full[2] - full[0], full[3] - full[1]);
|
||||
// Keep the right half, upper part.
|
||||
let keep = [full[0] + w / 2.0, full[1] + h / 2.0, full[2], full[3]];
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Crop(keep) }).unwrap();
|
||||
let d = s.get(id).unwrap();
|
||||
assert_eq!(d.can_undo(), Some("Crop image"));
|
||||
let im = &d.page_images(0)[0];
|
||||
assert_eq!(im.rect, full, "the placement is not scaled");
|
||||
let v = im.visible_rect.expect("clipped");
|
||||
assert!(v.iter().zip(keep.iter()).all(|(a, b)| (a - b).abs() < 0.01), "{v:?} vs {keep:?}");
|
||||
// What shows: the kept quarter is green, the cropped-away quarters are paper.
|
||||
let (pw, ph, rgba) = {
|
||||
let mut ex = crate::export::Exporter::new(d);
|
||||
let png = ex.png(0, 72.0).unwrap();
|
||||
let img = image::load_from_memory(&png).unwrap().to_rgba8();
|
||||
(img.width(), img.height(), img)
|
||||
};
|
||||
let at = |fx: f64, fy: f64| rgba.get_pixel((pw as f64 * fx) as u32, (ph as f64 * fy) as u32).0;
|
||||
let page_h = d.info.pages[0].height as f64;
|
||||
let page_w = d.info.pages[0].width as f64;
|
||||
// Fractions of the page from the top-left: the kept box is the top-right quarter of the image.
|
||||
let fx = |x: f64| x / page_w;
|
||||
let fy = |y: f64| 1.0 - y / page_h;
|
||||
assert_eq!(at(fx(full[0] + 0.75 * w), fy(full[1] + 0.75 * h))[1], 255, "kept green");
|
||||
assert_eq!(at(fx(full[0] + 0.25 * w), fy(full[1] + 0.25 * h)), [255, 255, 255, 255], "cropped-away red is gone");
|
||||
// A move takes the crop along (down and left, so the kept part stays on the page).
|
||||
let to = [full[0] - 10.0, full[1] - 10.0, full[2] - 10.0, full[3] - 10.0];
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move(to) }).unwrap();
|
||||
let im = &s.get(id).unwrap().page_images(0)[0];
|
||||
let v = im.visible_rect.expect("still clipped");
|
||||
let moved_keep = [keep[0] - 10.0, keep[1] - 10.0, keep[2] - 10.0, keep[3] - 10.0];
|
||||
assert!(v.iter().zip(moved_keep.iter()).all(|(a, b)| (a - b).abs() < 0.01), "{v:?} vs {moved_keep:?}");
|
||||
// Cropping again replaces the earlier crop, so the picture can grow back (Remove crop).
|
||||
let full_now = s.get(id).unwrap().page_images(0)[0].rect;
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Crop(full_now) }).unwrap();
|
||||
let im = &s.get(id).unwrap().page_images(0)[0];
|
||||
let v = im.visible_rect.expect("visible");
|
||||
let page = [0.0, 0.0, page_w, page_h];
|
||||
let shown = [full_now[0].max(page[0]), full_now[1].max(page[1]), full_now[2].min(page[2]), full_now[3].min(page[3])];
|
||||
assert!(v.iter().zip(shown.iter()).all(|(a, b)| (a - b).abs() < 0.01), "{v:?} vs {shown:?}");
|
||||
// No area, or a box off the image, is refused.
|
||||
assert!(s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Crop([10.0, 10.0, 10.0, 50.0]) }).is_err());
|
||||
}
|
||||
|
||||
+103
-19
@@ -520,6 +520,36 @@ fn layout_number(n: f64) -> f64 {
|
||||
if n.is_finite() { n.clamp(-1584.0, 1584.0) } else { 0.0 }
|
||||
}
|
||||
|
||||
/// Most images a Word export embeds, and most image bytes (the zip writer has 32-bit sizes and
|
||||
/// a 16-bit entry count; a hostile PDF must not be able to overflow them).
|
||||
const MAX_MEDIA: usize = 1000;
|
||||
const MAX_MEDIA_BYTES: usize = 512 << 20;
|
||||
|
||||
/// Whether `im` can still be embedded: it has data, and the package stays within its limits.
|
||||
fn room_for(media: &[(String, &Image)], im: &Image) -> bool {
|
||||
!im.bytes.is_empty()
|
||||
&& media.len() < MAX_MEDIA
|
||||
&& media.iter().map(|(_, m)| m.bytes.len()).fold(im.bytes.len(), usize::saturating_add) <= MAX_MEDIA_BYTES
|
||||
}
|
||||
|
||||
/// Points as EMU (12700 per point); non-finite values are 0.
|
||||
fn emu(pt: f64) -> i64 {
|
||||
if pt.is_finite() { (pt * 12700.0).round() as i64 } else { 0 }
|
||||
}
|
||||
|
||||
/// The `a:graphic` of one picture (DrawingML), rotated by `rotation` degrees when given.
|
||||
fn picture_xml(n: usize, name: &str, (cx, cy): (i64, i64), rotation: Option<f64>) -> String {
|
||||
let rot = rotation.map_or(String::new(), |r| format!(" rot=\"{}\"", (r * 60000.0).round() as i64));
|
||||
format!(
|
||||
"<wp:docPr id=\"{n}\" name=\"Picture {n}\"/><wp:cNvGraphicFramePr/>\
|
||||
<a:graphic xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\"><a:graphicData uri=\"http://schemas.openxmlformats.org/drawingml/2006/picture\">\
|
||||
<pic:pic xmlns:pic=\"http://schemas.openxmlformats.org/drawingml/2006/picture\"><pic:nvPicPr><pic:cNvPr id=\"{n}\" name=\"{name}\"/><pic:cNvPicPr/></pic:nvPicPr>\
|
||||
<pic:blipFill><a:blip r:embed=\"rIdImg{n}\"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill>\
|
||||
<pic:spPr><a:xfrm{rot}><a:off x=\"0\" y=\"0\"/><a:ext cx=\"{cx}\" cy=\"{cy}\"/></a:xfrm><a:prstGeom prst=\"rect\"><a:avLst/></a:prstGeom></pic:spPr></pic:pic>\
|
||||
</a:graphicData></a:graphic>"
|
||||
)
|
||||
}
|
||||
|
||||
fn positioned_page<'a>(page: &'a Page, media: &mut Vec<(String, &'a Image)>, shape_id: &mut usize) -> String {
|
||||
// One 1 pt paragraph anchors the whole page. Shapes never reserve body space, so even a
|
||||
// page-sized watermark cannot push the invoice footer onto another page.
|
||||
@@ -552,13 +582,31 @@ fn positioned_page<'a>(page: &'a Page, media: &mut Vec<(String, &'a Image)>, sha
|
||||
));
|
||||
}
|
||||
for im in &page.images {
|
||||
if !room_for(media, im) {
|
||||
continue;
|
||||
}
|
||||
*shape_id = shape_id.saturating_add(1);
|
||||
let n = media.len().saturating_add(1);
|
||||
let (w, h) = (layout_number(im.size[0]).max(1.0), layout_number(im.size[1]).max(1.0));
|
||||
let (x, y) = (layout_number((im.rect[0] + im.rect[2] - w) / 2.0), layout_number(page.height - (im.rect[1] + im.rect[3] + h) / 2.0));
|
||||
let rotation = layout_number(-im.rotation).rem_euclid(360.0);
|
||||
s.push_str(&format!("<w:r><w:pict><v:rect id=\"Image{shape_id}\" style=\"position:absolute;margin-left:{x:.3}pt;margin-top:{y:.3}pt;width:{w:.3}pt;height:{h:.3}pt;rotation:{rotation:.3};z-index:-1;mso-position-horizontal-relative:page;mso-position-vertical-relative:page\" stroked=\"f\"><v:imagedata r:id=\"rIdImg{n}\"/><w10:wrap type=\"none\"/></v:rect></w:pict></w:r>"));
|
||||
media.push((format!("image{n}.{}", im.ext), im));
|
||||
let name = format!("image{n}.{}", im.ext);
|
||||
let pic = picture_xml(n, &name, (emu(w), emu(h)), Some(rotation));
|
||||
// A DrawingML anchor, the way Word itself stores a picture placed behind text: a VML
|
||||
// rectangle with an image does not show the picture.
|
||||
s.push_str(&format!(
|
||||
"<w:r><w:drawing><wp:anchor distT=\"0\" distB=\"0\" distL=\"0\" distR=\"0\" simplePos=\"0\" relativeHeight=\"{}\" behindDoc=\"1\" locked=\"0\" layoutInCell=\"1\" allowOverlap=\"1\">\
|
||||
<wp:simplePos x=\"0\" y=\"0\"/><wp:positionH relativeFrom=\"page\"><wp:posOffset>{}</wp:posOffset></wp:positionH><wp:positionV relativeFrom=\"page\"><wp:posOffset>{}</wp:posOffset></wp:positionV>\
|
||||
<wp:extent cx=\"{}\" cy=\"{}\"/><wp:effectExtent l=\"0\" t=\"0\" r=\"0\" b=\"0\"/><wp:wrapNone/>",
|
||||
n.saturating_add(1),
|
||||
emu(x),
|
||||
emu(y),
|
||||
emu(w),
|
||||
emu(h),
|
||||
));
|
||||
s.push_str(&pic);
|
||||
s.push_str("</wp:anchor></w:drawing></w:r>");
|
||||
media.push((name, im));
|
||||
}
|
||||
s.push_str("</w:p>");
|
||||
s
|
||||
@@ -612,21 +660,18 @@ pub fn docx(pages: &[Page], title: &str) -> Vec<u8> {
|
||||
body.push_str(&docx_table(t));
|
||||
}
|
||||
Item::Img(im) => {
|
||||
let n = media.len() + 1;
|
||||
let name = format!("image{n}.{}", im.ext);
|
||||
// Size on the page, in EMU (12700 per point), at most the text width.
|
||||
let (w, h) = ((im.rect[2] - im.rect[0]).max(1.0), (im.rect[3] - im.rect[1]).max(1.0));
|
||||
let k = (text_w / w).min(1.0);
|
||||
let (cx, cy) = ((w * k * 12700.0) as i64, (h * k * 12700.0) as i64);
|
||||
body.push_str(&format!(
|
||||
"<w:p><w:r><w:drawing><wp:inline><wp:extent cx=\"{cx}\" cy=\"{cy}\"/><wp:docPr id=\"{n}\" name=\"Picture {n}\"/>\
|
||||
<a:graphic xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\"><a:graphicData uri=\"http://schemas.openxmlformats.org/drawingml/2006/picture\">\
|
||||
<pic:pic xmlns:pic=\"http://schemas.openxmlformats.org/drawingml/2006/picture\"><pic:nvPicPr><pic:cNvPr id=\"{n}\" name=\"{name}\"/><pic:cNvPicPr/></pic:nvPicPr>\
|
||||
<pic:blipFill><a:blip r:embed=\"rIdImg{n}\"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill>\
|
||||
<pic:spPr><a:xfrm><a:off x=\"0\" y=\"0\"/><a:ext cx=\"{cx}\" cy=\"{cy}\"/></a:xfrm><a:prstGeom prst=\"rect\"><a:avLst/></a:prstGeom></pic:spPr></pic:pic>\
|
||||
</a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>"
|
||||
));
|
||||
media.push((name, im));
|
||||
if room_for(&media, im) {
|
||||
let n = media.len() + 1;
|
||||
let name = format!("image{n}.{}", im.ext);
|
||||
// Size on the page, at most the text width.
|
||||
let (w, h) = ((im.rect[2] - im.rect[0]).max(1.0), (im.rect[3] - im.rect[1]).max(1.0));
|
||||
let k = (text_w / w).min(1.0);
|
||||
let (cx, cy) = (emu(w * k), emu(h * k));
|
||||
body.push_str(&format!("<w:p><w:r><w:drawing><wp:inline><wp:extent cx=\"{cx}\" cy=\"{cy}\"/>"));
|
||||
body.push_str(&picture_xml(n, &name, (cx, cy), None));
|
||||
body.push_str("</wp:inline></w:drawing></w:r></w:p>");
|
||||
media.push((name, im));
|
||||
}
|
||||
}
|
||||
Item::PageBreak => body.push_str("<w:p><w:r><w:br w:type=\"page\"/></w:r></w:p>"),
|
||||
}
|
||||
@@ -907,8 +952,9 @@ mod tests {
|
||||
assert!(xml.contains("margin-left:72.000pt;margin-top:160.000pt"), "Meter field keeps its source position: {xml}");
|
||||
let watermark = xml.split(">DRAFT</w:t>").next().unwrap().rsplit("<v:rect ").next().unwrap();
|
||||
assert!(watermark.contains("z-index:-2"), "watermark is behind foreground graphics");
|
||||
let image = xml.split("<v:imagedata ").next().unwrap().rsplit("<v:rect ").next().unwrap();
|
||||
assert!(image.contains("z-index:-1"), "foreground graphics cover the watermark");
|
||||
// The picture is a DrawingML anchor behind the text (a VML rectangle shows no picture in Word).
|
||||
assert!(!xml.contains("<v:imagedata"), "{xml}");
|
||||
assert!(xml.contains("<wp:anchor ") && xml.contains("behindDoc=\"1\"") && xml.contains("<a:blip r:embed=\"rIdImg1\"/>"), "{xml}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1165,4 +1211,42 @@ mod tests {
|
||||
let h = html(&[p], "Paper");
|
||||
assert!(!h.contains("<table>"), "tall two-column text must stay paragraphs: {h}");
|
||||
}
|
||||
|
||||
/// #1936: every picture Word is told to draw (`r:embed`) names a relationship whose target is a
|
||||
/// file in the package, with a content type; JPEG pictures too, inline or placed.
|
||||
#[test]
|
||||
fn word_pictures_resolve_to_media_in_the_package() {
|
||||
let jpeg = Image { ext: "jpg", bytes: vec![0xFF, 0xD8, 0xFF, 0xD9], ..page().images[0].clone() };
|
||||
for placed in [false, true] {
|
||||
let mut p = page();
|
||||
p.images = vec![jpeg.clone()];
|
||||
if placed {
|
||||
p.images[0].rect = [72.0, 640.0, 272.0, 790.0];
|
||||
p.blocks[0].rect = [72.0, 700.0, 300.0, 760.0];
|
||||
}
|
||||
let zip = docx(&[p], "T");
|
||||
let xml = part(&zip, "word/document.xml");
|
||||
assert_eq!(xml.contains("<wp:anchor "), placed, "{xml}");
|
||||
let rels = part(&zip, "word/_rels/document.xml.rels");
|
||||
let embed = xml.split("r:embed=\"").nth(1).and_then(|r| r.split('"').next()).unwrap();
|
||||
let rel = rels.split(&format!("Id=\"{embed}\"")).nth(1).unwrap();
|
||||
assert!(rel.contains("relationships/image\""), "{rels}");
|
||||
let target = rel.split("Target=\"").nth(1).and_then(|t| t.split('"').next()).unwrap();
|
||||
assert_eq!(target, "media/image1.jpg");
|
||||
assert!(zip.windows(21).any(|w| w == b"word/media/image1.jpg"));
|
||||
assert!(part(&zip, "[Content_Types].xml").contains("Extension=\"jpg\" ContentType=\"image/jpeg\""));
|
||||
assert!(xml.matches("<wp:docPr ").count() == 1 && !xml.contains("<v:imagedata"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_export_bounds_the_number_of_pictures_and_skips_empty_ones() {
|
||||
let mut p = page();
|
||||
let one = p.images[0].clone();
|
||||
p.images = vec![Image { bytes: Vec::new(), ..one.clone() }];
|
||||
p.images.extend(std::iter::repeat_n(one, MAX_MEDIA + 10));
|
||||
let zip = docx(&[p], "T");
|
||||
let rels = part(&zip, "word/_rels/document.xml.rels");
|
||||
assert_eq!(rels.matches("relationships/image\"").count(), MAX_MEDIA);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,9 @@ set). Without that variable `CRAFT_FONTS` is empty and everything below copes:
|
||||
Hebrew interface text, and the face whose outlines become the Type 3 fallback for Hebrew text
|
||||
written by Edit text (Bold when asked for and present, else Regular). `None` without a
|
||||
craft-fonts input that has one; the editor then reports a clear error.
|
||||
- `shape_hebrew` / `hebrew_has`: the Regular `Hebr` face shaped with harfrust for Hebrew Add
|
||||
Text content, one cluster per letter with its points and cantillation marks (positioned by the
|
||||
face), drawn by the same Type 3 writer as Arabic and Thai. `GlyphError::NoFont` without the face.
|
||||
- `document_japanese_font` / `japanese_glyph`: the face (Shippori Mincho, then BIZ UDMincho) whose
|
||||
outlines become the Type 3 fallback font for Japanese text written into PDFs. Without it,
|
||||
`japanese_glyph` returns `GlyphError::NoFont` and the editor reports a clear error.
|
||||
|
||||
@@ -6,7 +6,11 @@ pub(crate) static WIN_ANSI: [u32; 256] = [
|
||||
80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112,
|
||||
113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 0, 8364, 0, 8218, 402, 8222, 8230, 8224, 8225, 710, 8240, 352, 8249, 338,
|
||||
0, 381, 0, 0, 8216, 8217, 8220, 8221, 8226, 8211, 8212, 732, 8482, 353, 8250, 339, 0, 382, 376, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169,
|
||||
170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198,
|
||||
// 0xAD is `hyphen`, not U+00AD: WinAnsiEncoding's table (Annex D.2) gives codes 0x2D and 0xAD
|
||||
// the same glyph name, so the byte a Windows codepage calls a soft hyphen draws, and extracts
|
||||
// as, an ordinary hyphen. Emitting U+00AD instead hides the character from search and from
|
||||
// redaction's pattern matching, because a soft hyphen is invisible and matches nothing.
|
||||
170, 171, 172, 45, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198,
|
||||
199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227,
|
||||
228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255,
|
||||
];
|
||||
|
||||
+32
-2
@@ -25,7 +25,9 @@ pub use craft::{
|
||||
pub use script::{
|
||||
GlyphError, GlyphOutline, INTER_REGULAR, MAX_SIGNATURE_CHARS, ScriptOutline, japanese_glyph, japanese_glyph_from, script_missing, script_outline,
|
||||
};
|
||||
pub use shaping::{ShapedCluster, ShapedScript, arabic_glyph, arabic_has, shape_arabic, shape_thai, shaped_glyph, thai_has};
|
||||
pub use shaping::{
|
||||
ShapedCluster, ShapedScript, arabic_glyph, arabic_has, hebrew_has, shape_arabic, shape_hebrew, shape_thai, shaped_glyph, thai_has,
|
||||
};
|
||||
pub use std14::Std14;
|
||||
|
||||
/// Advance of `s` in Helvetica at `size` points, using the exact standard-14 metrics.
|
||||
@@ -303,11 +305,39 @@ mod tests {
|
||||
assert_eq!(literal(b"a(b)\\c"), b"(a\\(b\\)\\\\c)");
|
||||
}
|
||||
|
||||
/// WinAnsiEncoding's 0xAD is the `hyphen` glyph, not U+00AD.
|
||||
///
|
||||
/// Annex D.2 gives codes 0x2D and 0xAD the same glyph name, so the byte a Windows codepage
|
||||
/// calls a soft hyphen draws an ordinary hyphen and must extract as one. Reading it as U+00AD
|
||||
/// hid the character: a soft hyphen is invisible, so a search for "well-known" missed text
|
||||
/// that drew exactly that, and `redact`'s pattern matching missed it too. One corpus fixture
|
||||
/// alone extracted 104 of them.
|
||||
#[test]
|
||||
fn win_ansi_soft_hyphen_byte_reads_as_an_ordinary_hyphen() {
|
||||
assert_eq!(encodings::WIN_ANSI.get(0xAD), Some(&u32::from('-')));
|
||||
assert_eq!(encodings::WIN_ANSI.get(0x2D), Some(&u32::from('-')));
|
||||
// Writing is unaffected: '-' takes the canonical low code, and a caller holding a literal
|
||||
// U+00AD still writes the 0xAD byte, which is the one that draws a hyphen.
|
||||
assert_eq!(win_ansi_byte('-'), Some(0x2D));
|
||||
assert_eq!(win_ansi_byte('\u{00ad}'), Some(0xAD));
|
||||
assert_eq!(win_ansi("a-b"), b"a-b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn win_ansi_covers_the_0x80_to_0x9f_glyphs() {
|
||||
// Every character in the WinAnsiEncoding table encodes to its code and back (#347).
|
||||
// Every character in the WinAnsiEncoding table encodes to its code and back (#347), except
|
||||
// where two codes share one glyph: 0xAD is `hyphen`, the same glyph as 0x2D, so it decodes
|
||||
// to '-' and '-' encodes back to the canonical 0x2D. Round-tripping the *code* is what
|
||||
// cannot hold there; round-tripping the character still does.
|
||||
const ALIASES: &[u8] = &[0xAD];
|
||||
for (code, &u) in encodings::WIN_ANSI.iter().enumerate() {
|
||||
let Some(c) = char::from_u32(u).filter(|_| u != 0) else { continue };
|
||||
if ALIASES.contains(&(code as u8)) {
|
||||
let canonical = win_ansi_byte(c).expect("an aliased glyph still encodes");
|
||||
assert!(canonical < code as u8, "{c:?}: the canonical code is the lower one");
|
||||
assert_eq!(encodings::WIN_ANSI.get(usize::from(canonical)), Some(&u), "both codes mean the same character");
|
||||
continue;
|
||||
}
|
||||
assert_eq!(win_ansi_byte(c), Some(code as u8), "{c:?} (U+{u:04X})");
|
||||
assert_eq!(win_ansi(&c.to_string()), [code as u8]);
|
||||
}
|
||||
|
||||
+45
-1
@@ -61,6 +61,9 @@ pub struct Metrics {
|
||||
code_len: usize,
|
||||
/// Valid single-character explicit mappings, preindexed once for large full CJK fonts.
|
||||
explicit_reverse: HashMap<char, u32>,
|
||||
/// Subset CIDFontType2 only: one bit per CID (below 65 536) whose glyph has an outline in the
|
||||
/// embedded program. See [`cids_with_outlines`].
|
||||
outlined_cids: Vec<u64>,
|
||||
}
|
||||
|
||||
fn nums(doc: &Document, o: Option<&Object>) -> Vec<f64> {
|
||||
@@ -129,6 +132,38 @@ pub fn embedded_sfnt(doc: &Document, font: &Dict) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The CIDs (below 65 536) of a Type 0 font over an embedded TrueType program whose glyph has
|
||||
/// outline data, through `/CIDToGIDMap`; empty when the program or the map can't be read.
|
||||
/// Subsetters keep glyph ids and empty the glyphs they drop, so an empty glyph is a missing one.
|
||||
fn cids_with_outlines(doc: &Document, font: &Dict) -> Vec<u64> {
|
||||
use skrifa::raw::TableProvider as _;
|
||||
let Some(descendant) = dict(doc, font.get(b"DescendantFonts").and_then(|o| doc.resolve(o).as_array().and_then(|a| a.first()).cloned()).as_ref())
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(program) = dict(doc, descendant.get(b"FontDescriptor")).and_then(|d| d.get(b"FontFile2").cloned()) else { return Vec::new() };
|
||||
let program = doc.resolve(&program);
|
||||
let Object::Stream(stream) = program.as_ref() else { return Vec::new() };
|
||||
let Ok(bytes) = stream.decoded_within(16 << 20) else { return Vec::new() };
|
||||
let Some(loca) = skrifa::FontRef::from_index(&bytes, 0).ok().and_then(|face| face.loca(None).ok()) else { return Vec::new() };
|
||||
let outlined = |gid: u32| loca.get_raw(gid as usize).zip(loca.get_raw(gid as usize + 1)).is_some_and(|(start, end)| end > start);
|
||||
match descendant.get(b"CIDToGIDMap").map(|o| doc.resolve(o)).as_deref() {
|
||||
None | Some(Object::Name(_)) => bit_set_below_64k((0..loca.len().min(0x1_0000) as u32).filter(|&gid| outlined(gid))),
|
||||
Some(Object::Stream(map)) => {
|
||||
let Ok(map) = map.decoded_within(1 << 17) else { return Vec::new() };
|
||||
bit_set_below_64k(
|
||||
map.as_chunks::<2>()
|
||||
.0
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, gid)| outlined(u32::from(u16::from_be_bytes(**gid))))
|
||||
.map(|(cid, _)| cid as u32),
|
||||
)
|
||||
}
|
||||
Some(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace an existing cmap directory entry with a bounded Unicode format-12 table.
|
||||
/// Checked ranges prevent damaged sfnt offsets from indexing outside the source program.
|
||||
fn append_editor_cmap(bytes: &mut Vec<u8>, mappings: &std::collections::BTreeMap<u32, u32>) -> Option<()> {
|
||||
@@ -538,6 +573,7 @@ impl Metrics {
|
||||
visual_order: false,
|
||||
code_len: 1,
|
||||
explicit_reverse: (32..127u8).map(|c| (char::from(c), u32::from(c))).collect(),
|
||||
outlined_cids: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -558,6 +594,9 @@ impl Metrics {
|
||||
m.bold |= bold;
|
||||
m.italic |= italic;
|
||||
m.base_font = base;
|
||||
if m.composite && m.subset {
|
||||
m.outlined_cids = cids_with_outlines(doc, font);
|
||||
}
|
||||
m.code_len = match &m.codes {
|
||||
Codes::One => 1,
|
||||
Codes::Two => 2,
|
||||
@@ -752,7 +791,12 @@ impl Metrics {
|
||||
fn has_glyph(&self, code: u32) -> bool {
|
||||
if self.composite {
|
||||
let cid = self.cid(code);
|
||||
return self.cid_widths.contains_key(&cid) || self.cid_ranges.iter().any(|(a, b, _)| cid >= *a && cid <= *b) || !self.subset;
|
||||
// A CID /W leaves out takes /DW, and Skia (Chrome) leaves out every CID of that width: for
|
||||
// those, only the subset's program says whether the glyph is there.
|
||||
return self.cid_widths.contains_key(&cid)
|
||||
|| self.cid_ranges.iter().any(|(a, b, _)| cid >= *a && cid <= *b)
|
||||
|| !self.subset
|
||||
|| self.outlined_cids.get(cid as usize / 64).is_some_and(|w| (*w >> (cid % 64)) & 1 == 1);
|
||||
}
|
||||
match code.checked_sub(self.first).and_then(|i| self.widths.get(i as usize)) {
|
||||
Some(w) => *w > 0.0 || code == 32,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Arabic and Thai text shaping with the matching craft-fonts face and bounded glyph outlines.
|
||||
//! Arabic, Hebrew and Thai text shaping with the matching craft-fonts face and bounded glyph outlines.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
@@ -26,6 +26,7 @@ pub struct ShapedCluster {
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum ShapedScript {
|
||||
Arabic,
|
||||
Hebrew,
|
||||
Thai,
|
||||
}
|
||||
|
||||
@@ -33,6 +34,7 @@ impl ShapedScript {
|
||||
pub fn font(self) -> Option<&'static crate::CraftFont> {
|
||||
match self {
|
||||
Self::Arabic => crate::document_arabic_font(),
|
||||
Self::Hebrew => crate::document_hebrew_font(false),
|
||||
Self::Thai => crate::document_thai_font(),
|
||||
}
|
||||
}
|
||||
@@ -40,9 +42,11 @@ impl ShapedScript {
|
||||
|
||||
fn face(script: ShapedScript) -> Option<&'static (FontRef<'static>, ShaperData)> {
|
||||
static FACE: OnceLock<Option<(FontRef<'static>, ShaperData)>> = OnceLock::new();
|
||||
static HEBREW: OnceLock<Option<(FontRef<'static>, ShaperData)>> = OnceLock::new();
|
||||
static THAI: OnceLock<Option<(FontRef<'static>, ShaperData)>> = OnceLock::new();
|
||||
let cache = match script {
|
||||
ShapedScript::Arabic => &FACE,
|
||||
ShapedScript::Hebrew => &HEBREW,
|
||||
ShapedScript::Thai => &THAI,
|
||||
};
|
||||
cache
|
||||
@@ -66,6 +70,17 @@ pub fn shape_arabic(text: &str, rtl: bool) -> Result<Vec<ShapedCluster>, GlyphEr
|
||||
shape_script(text, rtl, ShapedScript::Arabic)
|
||||
}
|
||||
|
||||
/// Whether the Hebrew document face has a glyph for `c` (false without the face).
|
||||
pub fn hebrew_has(c: char) -> bool {
|
||||
face(ShapedScript::Hebrew).is_some_and(|(font, _)| font.charmap().map(c).is_some())
|
||||
}
|
||||
|
||||
/// Shape one directional run of `text` with the Hebrew document face, clusters in drawing order.
|
||||
/// Points (niqqud) and cantillation marks stay in their letter's cluster, positioned by the face.
|
||||
pub fn shape_hebrew(text: &str, rtl: bool) -> Result<Vec<ShapedCluster>, GlyphError> {
|
||||
shape_script(text, rtl, ShapedScript::Hebrew)
|
||||
}
|
||||
|
||||
pub fn thai_has(c: char) -> bool {
|
||||
face(ShapedScript::Thai).is_some_and(|(font, _)| font.charmap().map(c).is_some())
|
||||
}
|
||||
@@ -85,12 +100,13 @@ fn shape_script(text: &str, rtl: bool, kind: ShapedScript) -> Result<Vec<ShapedC
|
||||
buffer.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight });
|
||||
buffer.set_script(match kind {
|
||||
ShapedScript::Arabic => script::ARABIC,
|
||||
ShapedScript::Hebrew => script::HEBREW,
|
||||
ShapedScript::Thai => script::THAI,
|
||||
});
|
||||
// Preserve the historical Arabic per-character mapping; Thai marks stay with their base.
|
||||
// Preserve the historical Arabic per-character mapping; Hebrew and Thai marks stay with their base.
|
||||
buffer.set_cluster_level(match kind {
|
||||
ShapedScript::Arabic => BufferClusterLevel::Characters,
|
||||
ShapedScript::Thai => BufferClusterLevel::MonotoneGraphemes,
|
||||
ShapedScript::Hebrew | ShapedScript::Thai => BufferClusterLevel::MonotoneGraphemes,
|
||||
});
|
||||
let shaped = shaper.shape(buffer, ShapeOptions::new());
|
||||
// Clusters are byte offsets into `text`; a cluster shows the characters up to the next one.
|
||||
@@ -250,3 +266,36 @@ mod thai_tests {
|
||||
assert_eq!(shaped_glyph(ShapedScript::Thai, u32::MAX).map(|_| ()), Err(GlyphError::Missing));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod hebrew_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hebrew_runs_are_drawn_from_their_last_letter_with_points_on_their_letter() {
|
||||
if crate::document_hebrew_font(false).is_none() {
|
||||
eprintln!("skipping the Hebrew shaping checks: built without a craft-fonts Hebr face (set CRAFT_FONTS_DIR)");
|
||||
assert_eq!(shape_hebrew("ש", true), Err(GlyphError::NoFont));
|
||||
assert!(!hebrew_has('ש'));
|
||||
return;
|
||||
}
|
||||
let g = shape_hebrew("שלום", true).unwrap();
|
||||
assert!(g.iter().all(|c| c.script == ShapedScript::Hebrew && c.advance > 0.0 && !c.glyphs.is_empty()), "{g:?}");
|
||||
let drawn: String = g.iter().map(|c| c.text.as_str()).collect();
|
||||
assert_eq!(drawn.chars().rev().collect::<String>(), "שלום");
|
||||
// A point (qamats) is one cluster with its letter, so copy and search get it once.
|
||||
let pointed = shape_hebrew("שָׁ", true).unwrap();
|
||||
assert_eq!(pointed.len(), 1, "{pointed:?}");
|
||||
assert_eq!(pointed[0].text, "שָׁ");
|
||||
assert!(pointed[0].glyphs.len() >= 2);
|
||||
for c in shape_hebrew("שָׁלוֹם 123", true).unwrap() {
|
||||
for (id, pos) in c.glyphs {
|
||||
assert!(pos.iter().all(|v| v.is_finite()));
|
||||
shaped_glyph(c.script, id).unwrap();
|
||||
}
|
||||
}
|
||||
assert!(hebrew_has('א') && hebrew_has('\u{05B8}') && !hebrew_has('日'));
|
||||
assert_eq!(shape_hebrew("日", true), Err(GlyphError::Missing));
|
||||
assert!(shape_hebrew("", true).unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
+44
-1
@@ -446,7 +446,9 @@ pub fn calculate_js(c: &Calculate) -> Option<String> {
|
||||
js_str(op.code()),
|
||||
fields.iter().map(|f| js_str(f)).collect::<Vec<_>>().join(", ")
|
||||
)),
|
||||
Calculate::Notation(expr) => Some(format!("/** BVCALC {expr} EVCALC **/ event.value = AFMakeNumber(0);")),
|
||||
// Acrobat's own translation follows the comment, so a viewer that runs the script instead
|
||||
// of reading the comment computes the same value (a placeholder here showed 0).
|
||||
Calculate::Notation(expr) => Some(format!("/** BVCALC {expr} EVCALC **/ event.value = {};", notation_js(expr))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -864,6 +866,41 @@ pub fn validate(v: &Validate, value: &str) -> Result<(), String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Simplified field notation as the JavaScript Acrobat writes after its `BVCALC` comment:
|
||||
/// each field name becomes `AFMakeNumber(this.getField("name").value)`, the rest stays.
|
||||
pub fn notation_js(expr: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let cs: Vec<char> = expr.chars().collect();
|
||||
let mut i = 0;
|
||||
while let Some(&c) = cs.get(i) {
|
||||
if c.is_whitespace() || "+-*/()".contains(c) {
|
||||
out.push(c);
|
||||
i += 1;
|
||||
} else if c.is_ascii_digit() || c == '.' {
|
||||
while let Some(&d) = cs.get(i).filter(|d| d.is_ascii_digit() || **d == '.') {
|
||||
out.push(d);
|
||||
i += 1;
|
||||
}
|
||||
} else {
|
||||
let mut name = String::new();
|
||||
while let Some(&n) = cs.get(i).filter(|n| !(n.is_whitespace() || "+-*/()".contains(**n))) {
|
||||
// `\` escapes a space or an operator inside a name, as Acrobat writes them.
|
||||
if n == '\\'
|
||||
&& let Some(&next) = cs.get(i + 1)
|
||||
{
|
||||
name.push(next);
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
name.push(n);
|
||||
i += 1;
|
||||
}
|
||||
out.push_str(&format!("AFMakeNumber(this.getField({}).value)", js_str(&name)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Evaluate simplified field notation: + - * / and parentheses over numbers and field names
|
||||
/// (`\` escapes spaces and operators in names, as Acrobat writes them).
|
||||
fn eval_notation(src: &str, value_of: &dyn Fn(&str) -> f64) -> Option<f64> {
|
||||
@@ -1024,6 +1061,12 @@ mod tests {
|
||||
assert_eq!(parse_calculate(&calculate_js(&c).unwrap()), Some(c));
|
||||
let c = Calculate::Notation("a + b".into());
|
||||
assert_eq!(parse_calculate(&calculate_js(&c).unwrap()), Some(c));
|
||||
// The JavaScript after the comment is Acrobat's translation, so viewers that run it
|
||||
// instead of reading the comment compute the same value (not 0).
|
||||
assert_eq!(
|
||||
calculate_js(&Calculate::Notation("(Unit\\ Price * Qty) / 2".into())).unwrap(),
|
||||
"/** BVCALC (Unit\\ Price * Qty) / 2 EVCALC **/ event.value = (AFMakeNumber(this.getField(\"Unit Price\").value) * AFMakeNumber(this.getField(\"Qty\").value)) / 2;"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -38,6 +38,39 @@ pub fn fmt(v: f64) -> String {
|
||||
n(v)
|
||||
}
|
||||
|
||||
/// The font size an auto-sized (`0 Tf`) text field uses for `text` in a `width` × `height` box
|
||||
/// (inside the padding), measured by `width_of(text, size)`: at most 12 pt and at least 4 pt.
|
||||
/// Single-line text shrinks until it fits the width; multiline text takes the largest size whose
|
||||
/// wrapped lines fit the height. The page appearance and the on-screen editor share this.
|
||||
pub fn auto_text_size(text: &str, width: f64, height: f64, multiline: bool, width_of: impl Fn(&str, f64) -> f64) -> f64 {
|
||||
let wrap_width = if width.is_finite() { width.max(1.0) } else { 1.0 };
|
||||
auto_text_size_by(
|
||||
width,
|
||||
height,
|
||||
multiline,
|
||||
|size| width_of(text, size),
|
||||
|size| wrap_with(text, size, wrap_width, &width_of).len() as f64 * size * 1.15,
|
||||
)
|
||||
}
|
||||
|
||||
/// [`auto_text_size`] with the text measured by the caller: `line_width(size)` is its width on
|
||||
/// one line, `wrapped_height(size)` its height wrapped at `width`. The screen measures in its
|
||||
/// own font, whose widths and line breaks differ from the page's.
|
||||
pub fn auto_text_size_by(width: f64, height: f64, multiline: bool, line_width: impl Fn(f64) -> f64, wrapped_height: impl Fn(f64) -> f64) -> f64 {
|
||||
let (width, height) = (if width.is_finite() { width.max(1.0) } else { 1.0 }, if height.is_finite() { height } else { 0.0 });
|
||||
if multiline {
|
||||
let mut size = 12.0;
|
||||
// A NaN height from the measurer compares false, so it keeps the size it has.
|
||||
while size > 4.0 && wrapped_height(size) > height {
|
||||
size -= 0.5;
|
||||
}
|
||||
return size;
|
||||
}
|
||||
let size = (height / 1.15).clamp(4.0, 12.0);
|
||||
let tw = line_width(size);
|
||||
if tw.is_finite() && tw > width { (size * width / tw).max(4.0) } else { size }
|
||||
}
|
||||
|
||||
/// `/MK /R` as a quadrant, or 0 when it is missing or not a multiple of 90.
|
||||
pub(crate) fn mk_rotation(doc: &Document, wd: &Dict) -> i64 {
|
||||
let Some(raw) = wd.get(b"MK") else { return 0 };
|
||||
@@ -389,11 +422,7 @@ pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[S
|
||||
let comb = f.has(flags::COMB) && !f.has(flags::MULTILINE) && !f.has(flags::PASSWORD) && f.max_len.is_some_and(|m| m > 0);
|
||||
if f.kind == FieldKind::Text && f.has(flags::MULTILINE) {
|
||||
if size == 0.0 {
|
||||
// Auto size: the largest size (≤ 12) whose wrapped lines fit the height.
|
||||
size = 12.0;
|
||||
while size > 4.0 && wrap_with(&text, size, inner_w, width_of).len() as f64 * size * 1.15 > height - 2.0 * pad {
|
||||
size -= 0.5;
|
||||
}
|
||||
size = auto_text_size(&text, inner_w, height - 2.0 * pad, true, width_of);
|
||||
}
|
||||
let mut y = height - pad - size * 0.85;
|
||||
for line in wrap_with(&text, size, inner_w, width_of) {
|
||||
@@ -405,11 +434,12 @@ pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[S
|
||||
}
|
||||
} else {
|
||||
if size == 0.0 {
|
||||
size = ((height - 2.0 * pad) / 1.15).clamp(4.0, 12.0);
|
||||
let tw = width_of(&text, size);
|
||||
if tw > inner_w && !comb {
|
||||
size = (size * inner_w / tw).max(4.0);
|
||||
}
|
||||
// Comb cells hold one character each, so only the height limits them.
|
||||
size = if comb {
|
||||
((height - 2.0 * pad) / 1.15).clamp(4.0, 12.0)
|
||||
} else {
|
||||
auto_text_size(&text, inner_w, height - 2.0 * pad, false, width_of)
|
||||
};
|
||||
}
|
||||
// Centre Helvetica's ascent (0.718) and descent (0.207) vertically.
|
||||
let y = (height - 0.925 * size) / 2.0 + 0.207 * size;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! Prepare a form: create, change and delete fields (execution plan M6.6).
|
||||
//!
|
||||
//! New fields follow Acrobat's conventions: names "Text1", "Check Box1", "Group1" (radio groups),
|
||||
//! "Dropdown1", "List Box1", "Button1", "Signature1", "Date1"; 12 pt Helvetica (auto-size for
|
||||
//! check boxes), a thin grey border and a white background, and appearance streams generated
|
||||
//! "Dropdown1", "List Box1", "Button1", "Signature1", "Date1"; Helvetica at Acrobat's Auto size
|
||||
//! for text and date fields (12 pt in a box that has room, smaller in a short one), 12 pt in
|
||||
//! choice fields, a thin grey border and a white background, and appearance streams generated
|
||||
//! immediately so every viewer shows the empty field.
|
||||
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
|
||||
@@ -545,14 +546,15 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel
|
||||
match kind {
|
||||
NewField::Text { multiline } => {
|
||||
w.set(b"FT".to_vec(), Object::name("Tx"));
|
||||
w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 12 Tf 0 g".to_vec()));
|
||||
// Auto size (0): a fixed 12 pt is clipped in a box shorter than that (#1888).
|
||||
w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec()));
|
||||
if *multiline {
|
||||
w.set(b"Ff".to_vec(), Object::Int(flags::MULTILINE as i64));
|
||||
}
|
||||
}
|
||||
NewField::Date => {
|
||||
w.set(b"FT".to_vec(), Object::name("Tx"));
|
||||
w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 12 Tf 0 g".to_vec()));
|
||||
w.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec()));
|
||||
// Acrobat's format and keystroke actions for mm/dd/yyyy (run by its JavaScript engine).
|
||||
let js = |s: &str| {
|
||||
let mut a = Dict::new();
|
||||
|
||||
+45
-1
@@ -854,10 +854,51 @@ pub fn set_value_with(doc: &mut Document, name: &str, value: &FieldValue, script
|
||||
return Err(FormError::ReadOnly(name.into()));
|
||||
}
|
||||
write_value(doc, f, value, scripts)?;
|
||||
// Fields with the same fully qualified name are one field (§12.7.3.1). Writers that repeat a
|
||||
// name as separate field dictionaries, instead of widgets under one parent, get the value
|
||||
// the first one took after its scripts, without running them again.
|
||||
let twins: Vec<&Field> = all.iter().filter(|t| t.name == name && t.obj != f.obj && t.kind == f.kind).collect();
|
||||
if !twins.is_empty() {
|
||||
let taken = fields(doc).into_iter().find(|x| x.obj == f.obj).map(|x| x.value).unwrap_or_default();
|
||||
for t in twins {
|
||||
share_value(doc, f.obj, t, &taken, scripts)?;
|
||||
}
|
||||
}
|
||||
recalculate_with(doc, scripts)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Give `twin` the value `source` holds: its `/V` (and a choice's `/I`), drawn in the twin's own
|
||||
/// widgets.
|
||||
fn share_value(doc: &mut Document, source: ObjRef, twin: &Field, value: &[String], scripts: &mut dyn Scripts) -> Result<(), FormError> {
|
||||
match twin.kind {
|
||||
FieldKind::CheckBox => {
|
||||
let state = twin.widgets.iter().find_map(|w| w.on_state.clone()).unwrap_or_else(|| "Yes".into());
|
||||
set_states(doc, twin, (!value.is_empty()).then_some(state.as_str()))
|
||||
}
|
||||
FieldKind::Radio => {
|
||||
let choice = value.first().filter(|c| twin.widgets.iter().any(|w| w.on_state.as_deref() == Some(c.as_str())));
|
||||
set_states(doc, twin, choice.map(String::as_str))
|
||||
}
|
||||
FieldKind::Text | FieldKind::Combo | FieldKind::List => {
|
||||
let src = doc.get(source).as_dict().cloned().unwrap_or_default();
|
||||
doc.update_dict(twin.obj, |d| {
|
||||
for key in [&b"V"[..], b"I"] {
|
||||
match src.get(key) {
|
||||
Some(v) => d.set(key.to_vec(), v.clone()),
|
||||
None => {
|
||||
d.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
d.remove(b"RV");
|
||||
})?;
|
||||
redraw(doc, twin, value, scripts)
|
||||
}
|
||||
FieldKind::PushButton | FieldKind::Signature => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run every field's Calculate script in the form's calculation order (`/CO`, then the other
|
||||
/// calculated fields), as Acrobat does after any value changes. Returns how many changed.
|
||||
pub fn recalculate(doc: &mut Document) -> Result<usize, FormError> {
|
||||
@@ -1007,8 +1048,11 @@ fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scripts: &mut
|
||||
}
|
||||
}
|
||||
}
|
||||
let indices: Vec<Object> =
|
||||
let mut indices: Vec<Object> =
|
||||
exports.iter().filter_map(|e| f.options.iter().position(|(x, _)| x == e)).map(|i| Object::Int(i as i64)).collect();
|
||||
// /I lists option indices in ascending order; /V keeps the caller's value order.
|
||||
// Every entry above is an integer, so this sorts by its numeric option index.
|
||||
indices.sort_unstable_by_key(Object::as_int);
|
||||
doc.update_dict(f.obj, |d| {
|
||||
match exports.as_slice() {
|
||||
[] => {
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
//! calculation order, Format when the appearance is drawn — and applies what the script asks
|
||||
//! for: the event's value, `rc`, and changes to other fields.
|
||||
|
||||
use pdfcraft_cos::{Document, Object, PdfString};
|
||||
use std::collections::HashSet;
|
||||
|
||||
use pdfcraft_cos::{Document, ObjRef, Object, PdfString};
|
||||
|
||||
use crate::{Field, FieldKind, FieldValue, FormError, fields, flags};
|
||||
|
||||
@@ -172,18 +174,27 @@ pub fn document_scripts_named(doc: &Document) -> Vec<(String, String)> {
|
||||
let Some(root) = doc.root() else { return Vec::new() };
|
||||
let cat = doc.get(root);
|
||||
let Some(names) = cat.as_dict().and_then(|c| c.get(b"Names")).map(|n| doc.resolve(n)) else { return Vec::new() };
|
||||
let Some(tree) = names.as_dict().and_then(|n| n.get(b"JavaScript")).map(|t| doc.resolve(t)) else { return Vec::new() };
|
||||
let Some(tree) = names.as_dict().and_then(|n| n.get(b"JavaScript")).cloned() else { return Vec::new() };
|
||||
let mut out: Vec<(String, String)> = Vec::new();
|
||||
let mut stack = vec![(*tree).clone()];
|
||||
let mut seen = 0;
|
||||
while let Some(node) = stack.pop() {
|
||||
seen += 1;
|
||||
if seen > 10_000 {
|
||||
// Kids stay unresolved on the stack, and each node is read once: a node listed many times,
|
||||
// or among its own kids, must not multiply the work.
|
||||
let mut stack = vec![tree];
|
||||
let mut seen: HashSet<ObjRef> = HashSet::new();
|
||||
let mut visited = 0;
|
||||
while let Some(raw) = stack.pop() {
|
||||
if let Object::Ref(r) = raw
|
||||
&& !seen.insert(r)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
visited += 1;
|
||||
if visited > 10_000 {
|
||||
break;
|
||||
}
|
||||
let node = doc.resolve(&raw);
|
||||
let Some(d) = node.as_dict() else { continue };
|
||||
if let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) {
|
||||
stack.extend(kids.iter().map(|k| (*doc.resolve(k)).clone()));
|
||||
stack.extend(kids);
|
||||
}
|
||||
if let Some(pairs) = d.get(b"Names").map(|n| doc.resolve(n)).and_then(|n| n.as_array().cloned()) {
|
||||
for pair in pairs.chunks(2) {
|
||||
|
||||
@@ -607,6 +607,30 @@ fn choices_accept_exports_or_display_text() {
|
||||
assert!(matches!(set_value(&mut doc, "country", &FieldValue::Choice(vec!["ca".into(), "fr".into()])), Err(FormError::Invalid(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiselect_choice_indices_are_sorted_without_reordering_values() {
|
||||
let mut forward_appearance = None;
|
||||
for values in [["Ham", "Olives"], ["Olives", "Ham"]] {
|
||||
let mut doc = fixture();
|
||||
let chosen: Vec<String> = values.iter().map(|value| (*value).into()).collect();
|
||||
set_value(&mut doc, "toppings", &FieldValue::Choice(chosen.clone())).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let all = fields(&doc);
|
||||
let list = field(&all, "toppings");
|
||||
assert_eq!(list.value, chosen, "saving preserves the caller's /V order");
|
||||
let object = doc.get(list.obj);
|
||||
let indices = object.as_dict().unwrap().get(b"I").unwrap().as_array().unwrap();
|
||||
assert_eq!(indices.iter().map(|index| index.as_int().unwrap()).collect::<Vec<_>>(), [1, 2], "saved option indices are ascending");
|
||||
let appearance = ap(&doc, &list.widgets[0]);
|
||||
assert_eq!(appearance.matches("0.6 0.75 0.86 rg").count(), 2, "both selected rows stay highlighted");
|
||||
if let Some(forward) = &forward_appearance {
|
||||
assert_eq!(&appearance, forward, "the same selected rows have the same appearance in either selection order");
|
||||
} else {
|
||||
forward_appearance = Some(appearance);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_are_specific_and_change_nothing() {
|
||||
let mut doc = fixture();
|
||||
@@ -895,6 +919,34 @@ fn formats_validation_and_calculations_run_like_acrobat() {
|
||||
assert_eq!(field(&fields(&doc), "Price").actions.format, Format::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_text_fields_fit_their_box() {
|
||||
// #1888: a fixed 12 pt was clipped in a short box. New text and date fields take Acrobat's
|
||||
// Auto size: 12 pt where there is room, smaller where there isn't.
|
||||
let mut doc = one_page();
|
||||
let tf_size = |ap: &str| {
|
||||
ap.split("/Helv ")
|
||||
.nth(1)
|
||||
.and_then(|s| s.split(' ').next())
|
||||
.and_then(|n| n.parse::<f64>().ok())
|
||||
.unwrap_or_else(|| panic!("no /Helv size in {ap}"))
|
||||
};
|
||||
for (kind, name, value) in [(NewField::Text { multiline: false }, "short", "Hello world"), (NewField::Date, "date", "10/13/2026")] {
|
||||
add_field(&mut doc, 0, [20.0, 20.0, 200.0, 28.0], &kind, Some(name)).unwrap();
|
||||
set_value(&mut doc, name, &FieldValue::Text(value.into())).unwrap();
|
||||
let f = field(&fields(&doc), name).clone();
|
||||
assert_eq!(f.da, "/Helv 0 Tf 0 g", "{name}");
|
||||
let size = tf_size(&ap(&doc, &f.widgets[0]));
|
||||
assert!(size > 0.0 && size <= 8.0, "{name}: {size} pt in an 8 pt box");
|
||||
}
|
||||
add_field(&mut doc, 0, [20.0, 50.0, 200.0, 72.0], &NewField::Text { multiline: false }, Some("tall")).unwrap();
|
||||
set_value(&mut doc, "tall", &FieldValue::Text("Hello world".into())).unwrap();
|
||||
assert_eq!(tf_size(&ap(&doc, &field(&fields(&doc), "tall").widgets[0])), 12.0, "a box with room keeps the 12 pt look");
|
||||
// An explicit size still wins.
|
||||
set_props(&mut doc, "short", &FieldProps { font_size: Some(12.0), ..FieldProps::default() }).unwrap();
|
||||
assert_eq!(tf_size(&ap(&doc, &field(&fields(&doc), "short").widgets[0])), 12.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appearance_properties_restyle_every_widget() {
|
||||
let mut doc = one_page();
|
||||
@@ -1121,6 +1173,49 @@ fn duplicating_a_field_across_pages_shares_its_value() {
|
||||
assert!(duplicate_field(&mut doc.clone(), &name, &[7]).is_err());
|
||||
}
|
||||
|
||||
/// Fields with the same fully qualified name are one field (§12.7.3.1): a parent whose widgets are
|
||||
/// its kids, as Duplicate Across Pages writes it, and separate field dictionaries that repeat the
|
||||
/// same `/T`, as other writers do. Filling the name fills every one of them.
|
||||
#[test]
|
||||
fn fields_sharing_a_name_take_the_same_value() {
|
||||
let objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 5 0 R >>".into(), // 1
|
||||
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 600 800] >>".into(), // 2
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [7 0 R 11 0 R] >>".into(), // 3
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [8 0 R 9 0 R 12 0 R] >>".into(), // 4
|
||||
"<< /Fields [6 0 R 9 0 R 11 0 R 12 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 10 0 R >> >> >>".into(), // 5
|
||||
"<< /FT /Tx /T (ClientName) /Kids [7 0 R 8 0 R] >>".into(), // 6 parent
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 6 0 R /Rect [50 700 250 720] /P 3 0 R >>".into(), // 7 its widget, page 1
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 6 0 R /Rect [50 700 250 720] /P 4 0 R >>".into(), // 8 its widget, page 2
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (ClientName) /Rect [50 600 250 620] /P 4 0 R >>".into(), // 9 same name, own field
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), // 10
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (Agree) /V /Off /AS /Off /Rect [300 700 315 715] /P 3 0 R /AP << /N << /Yes 13 0 R /Off 14 0 R >> >> >>".into(), // 11
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (Agree) /V /Off /AS /Off /Rect [300 700 315 715] /P 4 0 R /AP << /N << /Yes 13 0 R /Off 14 0 R >> >> >>".into(), // 12
|
||||
"<< /Length 0 >>\nstream\n\nendstream".into(), // 13
|
||||
"<< /Length 0 >>\nstream\n\nendstream".into(), // 14
|
||||
];
|
||||
let mut doc = document(&objs);
|
||||
set_value(&mut doc, "ClientName", &FieldValue::Text("Ada Lovelace".into())).unwrap();
|
||||
set_value(&mut doc, "Agree", &FieldValue::Check(true)).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let v = |n: u32| doc.get(ObjRef { num: n, generation: 0 }).as_dict().unwrap().get(b"V").cloned();
|
||||
let typed = Some(Object::String(PdfString::text("Ada Lovelace")));
|
||||
assert_eq!((v(6), v(9)), (typed.clone(), typed), "the value sits on the parent and on the field that repeats its name");
|
||||
assert_eq!((v(7), v(8)), (None, None), "the parent's widgets carry no value of their own");
|
||||
let all = fields(&doc);
|
||||
let named: Vec<&Field> = all.iter().filter(|f| f.name == "ClientName").collect();
|
||||
let widgets: Vec<&Widget> = named.iter().flat_map(|f| &f.widgets).collect();
|
||||
assert_eq!(widgets.iter().map(|w| w.obj.num).collect::<Vec<_>>(), [7, 8, 9]);
|
||||
for w in widgets {
|
||||
assert!(ap(&doc, w).contains("Ada Lovelace"), "widget {} shows the value", w.obj.num);
|
||||
}
|
||||
let agree: Vec<&Field> = all.iter().filter(|f| f.name == "Agree").collect();
|
||||
assert_eq!(agree.len(), 2);
|
||||
for f in agree {
|
||||
assert_eq!((f.value.as_slice(), f.widgets[0].state.as_deref()), (&["Yes".to_string()][..], Some("Yes")), "check box {}", f.obj.num);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_fields_only_take_unlocking() {
|
||||
let mut doc = one_page();
|
||||
@@ -1943,3 +2038,42 @@ fn page_shapes_closes_only_last_stroked_subpath_but_all_filled_subpaths() {
|
||||
assert_eq!(filled.boxes, [[50.0, 100.0, 170.0, 140.0], [200.0, 100.0, 320.0, 140.0]]);
|
||||
assert!(filled.rules.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_text_size_fits_single_and_multiline_text() {
|
||||
use crate::appearance::auto_text_size;
|
||||
use pdfcraft_fonts::{helvetica_width, wrap_with};
|
||||
let long = "Weekly progress and diligence: CoBank refi, IL and Redwood Falls";
|
||||
// Single line: the height's size for short text, shrunk to the width for long text.
|
||||
assert_eq!(auto_text_size("Hi", 94.0, 8.0, false, helvetica_width), 8.0 / 1.15);
|
||||
let s = auto_text_size(long, 194.0, 8.0, false, helvetica_width);
|
||||
assert!(s > 4.0 && s < 8.0 / 1.15 && helvetica_width(long, s) <= 194.0 + 1e-9, "{s}");
|
||||
// Never past 12 pt or under 4 pt.
|
||||
assert_eq!(auto_text_size("Hi", 500.0, 500.0, false, helvetica_width), 12.0);
|
||||
assert_eq!(auto_text_size(&long.repeat(20), 20.0, 8.0, false, helvetica_width), 4.0);
|
||||
// Multiline: the largest size whose wrapped lines fit the height.
|
||||
let m = auto_text_size(long, 114.0, 44.0, true, helvetica_width);
|
||||
assert!(wrap_with(long, m, 114.0, helvetica_width).len() as f64 * m * 1.15 <= 44.0, "{m}");
|
||||
assert!(wrap_with(long, m + 0.5, 114.0, helvetica_width).len() as f64 * (m + 0.5) * 1.15 > 44.0, "{m} is the largest that fits");
|
||||
// Hostile boxes and measurements don't panic or return nonsense.
|
||||
for (w, h) in [(f64::NAN, 10.0), (10.0, f64::INFINITY), (-5.0, -5.0), (0.0, 0.0)] {
|
||||
for multiline in [false, true] {
|
||||
let s = auto_text_size(long, w, h, multiline, helvetica_width);
|
||||
assert!((4.0..=12.0).contains(&s), "{w}×{h}: {s}");
|
||||
}
|
||||
}
|
||||
assert_eq!(auto_text_size(long, 50.0, 10.0, false, |_, _| f64::NAN), 10.0 / 1.15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_scripts_read_each_name_tree_node_once() {
|
||||
// The root lists itself and the leaf twice each.
|
||||
let doc = document(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /Names << /JavaScript 3 0 R >> >>".into(),
|
||||
"<< /Type /Pages /Kids [] /Count 0 >>".into(),
|
||||
"<< /Kids [3 0 R 4 0 R 3 0 R 4 0 R] >>".into(),
|
||||
"<< /Names [(init) 5 0 R] >>".into(),
|
||||
"<< /S /JavaScript /JS (var ready = true;) >>".into(),
|
||||
]);
|
||||
assert_eq!(document_scripts_named(&doc), [("init".to_string(), "var ready = true;".to_string())]);
|
||||
}
|
||||
|
||||
@@ -1913,7 +1913,17 @@ fn parse_date(text: &str, picture: &str) -> Option<(i64, u32, u32)> {
|
||||
}
|
||||
}
|
||||
let (y, m, d) = (y?, m.unwrap_or(1), d.unwrap_or(1));
|
||||
((-200_000..=200_000).contains(&y) && (1..=12).contains(&m) && (1..=31).contains(&d)).then_some((y, m, d))
|
||||
((-200_000..=200_000).contains(&y) && (1..=12).contains(&m) && (1..=days_in(y, m)).contains(&d)).then_some((y, m, d))
|
||||
}
|
||||
|
||||
/// Number of days in month `m` (1-12) of year `y`, leap years included.
|
||||
fn days_in(y: i64, m: u32) -> u32 {
|
||||
match m {
|
||||
2 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29,
|
||||
2 => 28,
|
||||
4 | 6 | 9 | 11 => 30,
|
||||
_ => 31,
|
||||
}
|
||||
}
|
||||
|
||||
fn format_date(y: i64, m: u32, d: u32, picture: &str) -> String {
|
||||
|
||||
@@ -783,8 +783,10 @@ fn printd(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue>
|
||||
out.push_str(if run >= 2 { ampm } else { &m[..1] });
|
||||
}
|
||||
b'\\' if i + 1 < b.len() => {
|
||||
out.push(b[i + 1] as char);
|
||||
i += 2;
|
||||
// The escaped character may be multi-byte; copy it whole.
|
||||
let ch = fmt[i + 1..].chars().next().unwrap_or(' ');
|
||||
out.push(ch);
|
||||
i += 1 + ch.len_utf8();
|
||||
continue;
|
||||
}
|
||||
_ => {
|
||||
|
||||
@@ -94,6 +94,17 @@ 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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn printd_escapes_multibyte_chars() {
|
||||
let o = go(
|
||||
"event.value = [util.printd('yyyy \\\\é', new Date(2024, 0, 5)), util.printd('\\\\€ yyyy', new Date(2024, 0, 5)), \
|
||||
util.printd('\\\\y yyyy', new Date(2024, 0, 5))].join('|');",
|
||||
&Event::field("Calculate", "total", ""),
|
||||
);
|
||||
assert_eq!(o.error, None);
|
||||
assert_eq!(o.value, "2024 é|€ 2024|y 2024");
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -681,6 +692,20 @@ mod formcalc {
|
||||
assert!(go("Nope(1)", "form1[0].page1[0].total[0]", "calculate").error.unwrap().contains("unknown function"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date2num_rejects_days_past_the_end_of_the_month() {
|
||||
assert_eq!(calc(r#"Date2Num("2023-02-30")"#), "0");
|
||||
assert_eq!(calc(r#"Date2Num("31/04/2024", "DD/MM/YYYY")"#), "0");
|
||||
assert_eq!(calc(r#"Date2Num("2023-02-29")"#), "0");
|
||||
assert_eq!(calc(r#"Date2Num("1900-02-29")"#), "0");
|
||||
assert_eq!(calc(r#"Parse("YYYY-MM-DD", "2023-02-30") == null"#), "1");
|
||||
// Valid month ends still work.
|
||||
assert_eq!(calc(r#"Date2Num("2024-02-29")"#), "45350");
|
||||
assert_eq!(calc(r#"Date2Num("2000-02-29") <> 0"#), "1");
|
||||
assert_eq!(calc(r#"Date2Num("2024-04-30") <> 0"#), "1");
|
||||
assert_eq!(calc(r#"Date2Num("2024-12-31") <> 0"#), "1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_flow_variables_and_functions_work() {
|
||||
assert_eq!(calc("var x = 3\nif (x > 2) then 10 elseif (x > 1) then 20 else 30 endif"), "10");
|
||||
|
||||
@@ -14,5 +14,9 @@ L3/L4 crates need:
|
||||
`view_matrix_for(rotation, rect)` is the same matrix for any rectangle, e.g. to draw a
|
||||
comment's picture upright inside its user-space `/Rect` on a turned page.
|
||||
|
||||
The page-tree walk keeps one cursor per ancestor and shares inherited attribute owners.
|
||||
Repeated child references are skipped before copying attributes, with first depth-first
|
||||
occurrence order preserved. Returned page dictionaries still own their effective attributes.
|
||||
|
||||
`organize`, `annot` and `forms` still carry their own small walkers; they move here as the model
|
||||
grows (ADR-0004).
|
||||
|
||||
+151
-36
@@ -84,44 +84,97 @@ pub fn view_matrix_for(rotation: i64, crop: [f64; 4]) -> [f64; 6] {
|
||||
/// Leaf pages in document order, with inherited attributes resolved. Cycles and absurdly deep
|
||||
/// trees are cut off rather than followed.
|
||||
pub fn pages(doc: &Document) -> Vec<Page> {
|
||||
let Some(root) = doc.root() else { return Vec::new() };
|
||||
let Some(top) = doc.get(root).as_dict().and_then(|d| d.reference(b"Pages")) else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut stack: Vec<(ObjRef, Dict)> = vec![(top, Dict::new())];
|
||||
while let Some((node, inherited)) = stack.pop() {
|
||||
if !seen.insert(node) || seen.len() > 1_000_000 {
|
||||
continue;
|
||||
}
|
||||
let obj = doc.get(node);
|
||||
let Some(d) = obj.as_dict() else { continue };
|
||||
let mut attrs = inherited.clone();
|
||||
for k in INHERITABLE {
|
||||
if let Some(v) = d.get(k) {
|
||||
attrs.set(k.to_vec(), v.clone());
|
||||
}
|
||||
}
|
||||
let is_pages = d.name(b"Type") == Some(b"Pages") || (d.contains(b"Kids") && d.name(b"Type") != Some(b"Page"));
|
||||
if is_pages {
|
||||
let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default();
|
||||
for k in kids.iter().rev() {
|
||||
if let Some(r) = k.as_ref() {
|
||||
stack.push((r, attrs.clone()));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let mut dict = d.clone();
|
||||
for k in INHERITABLE {
|
||||
if !dict.contains(k)
|
||||
&& let Some(v) = attrs.get(k)
|
||||
{
|
||||
dict.set(k.to_vec(), v.clone());
|
||||
}
|
||||
}
|
||||
out.push(Page { obj: node, dict });
|
||||
PageTree::new(doc).collect()
|
||||
}
|
||||
|
||||
// Retain the owner of each inherited value rather than deep-cloning direct Resources on
|
||||
// every edge. Only the final Page dictionaries need owned copies of those values.
|
||||
type Inherited = [Option<std::sync::Arc<Object>>; 4];
|
||||
|
||||
struct Children {
|
||||
owner: std::sync::Arc<Object>,
|
||||
next: usize,
|
||||
inherited: Inherited,
|
||||
}
|
||||
|
||||
impl Children {
|
||||
fn kids(&self) -> Option<&Vec<Object>> {
|
||||
match &*self.owner {
|
||||
Object::Dict(d) => d.get(b"Kids")?.as_array(),
|
||||
other => other.as_array(),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
struct PageTree<'a> {
|
||||
doc: &'a Document,
|
||||
seen: std::collections::HashSet<ObjRef>,
|
||||
// One cursor per ancestor, never one entry per child. This also preserves the first
|
||||
// depth-first occurrence's attributes when malformed trees share a child.
|
||||
pending: Vec<Children>,
|
||||
}
|
||||
|
||||
impl<'a> PageTree<'a> {
|
||||
fn new(doc: &'a Document) -> Self {
|
||||
let top = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages")));
|
||||
let pending = top
|
||||
.into_iter()
|
||||
.map(|r| Children { owner: std::sync::Arc::new(Object::Array(vec![Object::Ref(r)])), next: 0, inherited: Default::default() })
|
||||
.collect();
|
||||
Self { doc, seen: Default::default(), pending }
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for PageTree<'_> {
|
||||
type Item = Page;
|
||||
|
||||
fn next(&mut self) -> Option<Page> {
|
||||
while let Some(parent) = self.pending.last_mut() {
|
||||
let Some(child) = parent.kids().and_then(|kids| kids.get(parent.next)) else {
|
||||
self.pending.pop();
|
||||
continue;
|
||||
};
|
||||
let node = child.as_ref();
|
||||
// A successful get means next is strictly below the allocated array length.
|
||||
parent.next += 1;
|
||||
let Some(node) = node else { continue };
|
||||
if self.seen.len() >= 1_000_000 {
|
||||
self.pending.clear();
|
||||
return None;
|
||||
}
|
||||
if !self.seen.insert(node) {
|
||||
continue;
|
||||
}
|
||||
let obj = self.doc.get(node);
|
||||
let Some(d) = obj.as_dict() else { continue };
|
||||
let mut inherited = parent.inherited.clone();
|
||||
for (key, owner) in INHERITABLE.iter().zip(&mut inherited) {
|
||||
if d.contains(key) {
|
||||
*owner = Some(obj.clone());
|
||||
}
|
||||
}
|
||||
let is_pages = d.name(b"Type") == Some(b"Pages") || (d.contains(b"Kids") && d.name(b"Type") != Some(b"Page"));
|
||||
if is_pages {
|
||||
let owner = match d.get(b"Kids") {
|
||||
Some(Object::Ref(r)) => self.doc.get(*r),
|
||||
Some(Object::Array(_)) => obj.clone(),
|
||||
_ => continue,
|
||||
};
|
||||
self.pending.push(Children { owner, next: 0, inherited });
|
||||
} else {
|
||||
let mut dict = d.clone();
|
||||
for (key, owner) in INHERITABLE.iter().zip(&inherited) {
|
||||
if !dict.contains(key)
|
||||
&& let Some(value) = owner.as_ref().and_then(|o| o.as_dict()).and_then(|d| d.get(key))
|
||||
{
|
||||
dict.set(key.to_vec(), value.clone());
|
||||
}
|
||||
}
|
||||
return Some(Page { obj: node, dict });
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -156,6 +209,68 @@ mod tests {
|
||||
Document::open(Arc::new(out)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_children_do_not_queue_resource_copies() {
|
||||
let mut d = doc(0);
|
||||
let root = ObjRef { num: 2, generation: 0 };
|
||||
let leaf = ObjRef { num: 3, generation: 0 };
|
||||
d.update_dict(root, |dict| {
|
||||
// Modest fixture: the assertion measures retained work, not an OOM or timing.
|
||||
dict.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(leaf); 128]));
|
||||
let mut resources = Dict::new();
|
||||
resources.set(b"Payload".to_vec(), Object::Array(vec![Object::Int(42); 1024]));
|
||||
dict.set(b"Resources".to_vec(), Object::Dict(resources));
|
||||
})
|
||||
.unwrap();
|
||||
let mut walk = PageTree::new(&d);
|
||||
let p = walk.next().unwrap();
|
||||
assert_eq!(p.obj, leaf);
|
||||
assert_eq!(p.dict.get(b"Resources").unwrap().as_dict().unwrap().get(b"Payload").unwrap().as_array().unwrap().len(), 1024);
|
||||
assert!(walk.pending.len() <= 2, "duplicate edges retained {} pending entries", walk.pending.len());
|
||||
assert!(walk.next().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_children_keep_depth_first_order_and_nearest_attributes() {
|
||||
let mut d = doc(0);
|
||||
let root = ObjRef { num: 2, generation: 0 };
|
||||
let leaf = ObjRef { num: 3, generation: 0 };
|
||||
let mut second = d.get(leaf).as_dict().unwrap().clone();
|
||||
second.set(b"Rotate".to_vec(), Object::Int(180));
|
||||
let second = d.add(second);
|
||||
let mut branch = Dict::new();
|
||||
branch.set(b"Type".to_vec(), Object::name("Pages"));
|
||||
branch.set(b"Rotate".to_vec(), Object::Int(90));
|
||||
// A cycle, a shared child, an invalid entry, and an indirect Kids array.
|
||||
let kids = d.add(Object::Array(vec![Object::Ref(root), Object::Null, Object::Ref(leaf), Object::Ref(second)]));
|
||||
branch.set(b"Kids".to_vec(), Object::Ref(kids));
|
||||
let branch = d.add(branch);
|
||||
d.update_dict(root, |dict| {
|
||||
dict.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(branch), Object::Ref(leaf), Object::Ref(second)]));
|
||||
})
|
||||
.unwrap();
|
||||
let pages = pages(&d);
|
||||
assert_eq!(pages.iter().map(|p| p.obj).collect::<Vec<_>>(), vec![leaf, second]);
|
||||
assert_eq!(pages[0].rotation(&d), 90, "first depth-first occurrence supplies inheritance");
|
||||
assert_eq!(pages[1].rotation(&d), 180, "a leaf's own attribute wins");
|
||||
assert!(pages.iter().all(|p| p.dict.contains(b"Resources")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_page_tree_retains_only_ancestor_cursors() {
|
||||
let mut d = doc(0);
|
||||
let root = ObjRef { num: 2, generation: 0 };
|
||||
let leaf = d.get(ObjRef { num: 3, generation: 0 }).as_dict().unwrap().clone();
|
||||
let kids: Vec<Object> = (0..64).map(|_| Object::Ref(d.add(leaf.clone()))).collect();
|
||||
d.update_dict(root, |dict| dict.set(b"Kids".to_vec(), Object::Array(kids.clone()))).unwrap();
|
||||
let mut walk = PageTree::new(&d);
|
||||
for kid in kids {
|
||||
assert_eq!(walk.next().unwrap().obj, kid.as_ref().unwrap());
|
||||
assert!(walk.pending.len() <= 2);
|
||||
}
|
||||
assert!(walk.next().is_none());
|
||||
}
|
||||
|
||||
fn apply(m: [f64; 6], x: f64, y: f64) -> [f64; 2] {
|
||||
[m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]]
|
||||
}
|
||||
|
||||
@@ -20,7 +20,8 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going
|
||||
printed this way went from 4.3 MB to 231 KB (Standard) and 161 KB (Mobile). Not merged yet:
|
||||
side-by-side tiles, rotated strips, masks.
|
||||
- **Images:** the effective resolution of each image is measured where pages draw it (the CTM
|
||||
through form XObjects; the smallest over all uses). Colour and grayscale images above a
|
||||
through form XObjects, up to 12 deep; the smallest over all uses). If a use can't be
|
||||
measured (forms nested deeper, or drawn too many times), no image is resampled. Colour and grayscale images above a
|
||||
threshold are resampled with a bicubic (Catmull-Rom) filter to a target resolution and
|
||||
stored as JPEG (Acrobat's quality steps) or ZIP; soft masks are resampled with their image.
|
||||
An image is replaced only if the result is smaller. Left alone: CMYK and other colour spaces,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
//! PDF Optimizer ▸ Audit space usage: how many bytes each kind of content takes.
|
||||
//!
|
||||
//! Every object is sized as it would be written (dictionary plus stream data) and put in the
|
||||
//! first category that reaches it; what remains (the catalog, page tree, info, metadata,
|
||||
//! cross-reference data and anything unreachable) is document overhead.
|
||||
//! Every object is sized as it would be written (dictionary plus stream data). Resources go by
|
||||
//! kind first, wherever they are used: a font or image a form field or comment draws with is
|
||||
//! still a font or image. Every other object goes to the first category that reaches it; what
|
||||
//! remains (the catalog, page tree, info, metadata, cross-reference data and anything
|
||||
//! unreachable) is document overhead.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
@@ -80,6 +82,11 @@ pub struct SpaceUse {
|
||||
pub percent: f64,
|
||||
}
|
||||
|
||||
/// How deep form XObjects nested in each other's resources are sorted by kind. Deeper forms
|
||||
/// (and what they use) are still counted, as X Object Forms; the limit keeps a crafted chain
|
||||
/// of nested forms from overflowing the stack.
|
||||
const MAX_FORM_DEPTH: usize = 64;
|
||||
|
||||
struct Audit<'a> {
|
||||
doc: &'a Document,
|
||||
owner: HashMap<u32, SpaceCategory>,
|
||||
@@ -116,24 +123,22 @@ impl Audit<'_> {
|
||||
o.map(|x| self.doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A page's resources, by kind.
|
||||
fn resources(&mut self, res: &Dict) {
|
||||
/// A resource dictionary, by kind; `depth` counts the form XObjects it is nested in.
|
||||
fn resources(&mut self, res: &Dict, depth: usize) {
|
||||
let xobjects = self.resolve_dict(res.get(b"XObject"));
|
||||
for (_, x) in xobjects.iter() {
|
||||
let Some(r) = x.as_ref() else { continue };
|
||||
let is_image = matches!(&*self.doc.get(r), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image"));
|
||||
if is_image {
|
||||
self.claim(x, SpaceCategory::Images);
|
||||
} else {
|
||||
// A form: its own resources first, by kind, then the form itself.
|
||||
if let Object::Stream(s) = &*self.doc.get(r) {
|
||||
match &*self.doc.get(r) {
|
||||
Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => self.claim(x, SpaceCategory::Images),
|
||||
Object::Stream(s) if depth < MAX_FORM_DEPTH && !self.owner.contains_key(&r.num) => {
|
||||
// A form: its own resources first, by kind, then the rest of it. Owning it
|
||||
// first stops a form that draws itself.
|
||||
self.owner.insert(r.num, SpaceCategory::XObjectForms);
|
||||
let inner = self.resolve_dict(s.dict.get(b"Resources"));
|
||||
if let std::collections::hash_map::Entry::Vacant(e) = self.owner.entry(r.num) {
|
||||
e.insert(SpaceCategory::XObjectForms);
|
||||
self.resources(&inner);
|
||||
}
|
||||
self.resources(&inner, depth + 1);
|
||||
self.claim(&Object::Dict(s.dict.clone()), SpaceCategory::XObjectForms);
|
||||
}
|
||||
self.claim(x, SpaceCategory::XObjectForms);
|
||||
_ => self.claim(x, SpaceCategory::XObjectForms),
|
||||
}
|
||||
}
|
||||
for (key, cat) in [
|
||||
@@ -148,6 +153,40 @@ impl Audit<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The resources an annotation's appearances draw with, by kind. Each of /N, /R and /D is
|
||||
/// one appearance stream or a dictionary of them, one per state.
|
||||
fn appearance_resources(&mut self, annot: &Object) {
|
||||
let ap = self.resolve_dict(self.resolve_dict(Some(annot)).get(b"AP"));
|
||||
for (_, v) in ap.iter() {
|
||||
let streams = match &*self.doc.resolve(v) {
|
||||
Object::Stream(s) => vec![s.dict.clone()],
|
||||
Object::Dict(states) => states
|
||||
.iter()
|
||||
.filter_map(|(_, s)| match &*self.doc.resolve(s) {
|
||||
Object::Stream(s) => Some(s.dict.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
for dict in streams {
|
||||
let res = self.resolve_dict(dict.get(b"Resources"));
|
||||
self.resources(&res, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A page's annotations.
|
||||
fn annotations(doc: &Document, page: &Dict) -> Vec<Object> {
|
||||
page.get(b"Annots").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Form fields' widgets belong to the form; every other annotation is a comment.
|
||||
fn annotation_category(doc: &Document, annot: &Object) -> SpaceCategory {
|
||||
let widget = annot.as_ref().is_some_and(|r| doc.get(r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget")));
|
||||
if widget { SpaceCategory::Forms } else { SpaceCategory::Comments }
|
||||
}
|
||||
|
||||
fn size(doc: &Document, num: u32) -> u64 {
|
||||
@@ -164,16 +203,27 @@ pub fn audit_space(doc: &Document, file_len: u64) -> Vec<SpaceUse> {
|
||||
let mut a = Audit { doc, owner: HashMap::new() };
|
||||
let cat = doc.root().and_then(|r| doc.get(r).as_dict().cloned()).unwrap_or_default();
|
||||
let pages = pdfcraft_model::pages(doc);
|
||||
// Order matters: the most specific owners first.
|
||||
// Order matters: the most specific owners first. Resources by kind before anything that
|
||||
// uses them: a content stream's dictionary may point at what it draws (PdfCraft's /PCAdded
|
||||
// records an added image), and form fields and comments draw with fonts and images too.
|
||||
for p in &pages {
|
||||
let res = a.resolve_dict(p.dict.get(b"Resources"));
|
||||
a.resources(&res, 0);
|
||||
}
|
||||
let form = a.resolve_dict(cat.get(b"AcroForm"));
|
||||
let form_resources = a.resolve_dict(form.get(b"DR"));
|
||||
a.resources(&form_resources, 0);
|
||||
for p in &pages {
|
||||
for x in annotations(doc, &p.dict) {
|
||||
a.appearance_resources(&x);
|
||||
}
|
||||
}
|
||||
if let Some(s) = cat.get(b"StructTreeRoot") {
|
||||
// Structure elements point at pages and annotations; those stay out (claim skips pages,
|
||||
// and annotations are claimed first below).
|
||||
// and annotations are claimed first).
|
||||
for p in &pages {
|
||||
if let Some(annots) = p.dict.get(b"Annots") {
|
||||
for x in doc.resolve(annots).as_array().cloned().unwrap_or_default() {
|
||||
let widget = x.as_ref().and_then(|r| doc.get(r).as_dict().map(|d| d.name(b"Subtype") == Some(b"Widget"))).unwrap_or(false);
|
||||
a.claim(&x, if widget { SpaceCategory::Forms } else { SpaceCategory::Comments });
|
||||
}
|
||||
for x in annotations(doc, &p.dict) {
|
||||
a.claim(&x, annotation_category(doc, &x));
|
||||
}
|
||||
}
|
||||
a.claim(s, SpaceCategory::StructureInfo);
|
||||
@@ -182,16 +232,9 @@ pub fn audit_space(doc: &Document, file_len: u64) -> Vec<SpaceUse> {
|
||||
a.claim(f, SpaceCategory::Forms);
|
||||
}
|
||||
for p in &pages {
|
||||
if let Some(annots) = p.dict.get(b"Annots") {
|
||||
for x in doc.resolve(annots).as_array().cloned().unwrap_or_default() {
|
||||
let widget = x.as_ref().and_then(|r| doc.get(r).as_dict().map(|d| d.name(b"Subtype") == Some(b"Widget"))).unwrap_or(false);
|
||||
a.claim(&x, if widget { SpaceCategory::Forms } else { SpaceCategory::Comments });
|
||||
}
|
||||
for x in annotations(doc, &p.dict) {
|
||||
a.claim(&x, annotation_category(doc, &x));
|
||||
}
|
||||
// Resources before the content: a content stream's dictionary may point at what it
|
||||
// draws (PdfCraft's /PCAdded records an added image), which stays an image or font.
|
||||
let res = a.resolve_dict(p.dict.get(b"Resources"));
|
||||
a.resources(&res);
|
||||
if let Some(c) = p.dict.get(b"Contents") {
|
||||
a.claim(c, SpaceCategory::ContentStreams);
|
||||
}
|
||||
|
||||
+102
-52
@@ -41,73 +41,123 @@ pub(crate) fn content_bytes(doc: &Document, contents: &Object) -> Vec<u8> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk one content stream, recording each image's displayed resolution (pixels per inch, the
|
||||
/// smaller of the two axes) and descending into form XObjects.
|
||||
fn walk(doc: &Document, data: &[u8], resources: &Dict, ctm: Matrix, depth: usize, seen: &mut HashSet<ObjRef>, out: &mut HashMap<ObjRef, f64>) {
|
||||
if depth > 12 {
|
||||
return;
|
||||
}
|
||||
let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
let mut stack = vec![ctm];
|
||||
for op in pdfcraft_content::parse(data).ops {
|
||||
let top = stack.last().copied().unwrap_or(ctm);
|
||||
match op.op.as_slice() {
|
||||
b"q" => stack.push(top),
|
||||
b"Q" if stack.len() > 1 => {
|
||||
stack.pop();
|
||||
}
|
||||
b"cm" => {
|
||||
if let Some(m) = Matrix::from_operands(&op.operands)
|
||||
&& let Some(t) = stack.last_mut()
|
||||
{
|
||||
*t = m.then(&top);
|
||||
/// Forms nested deeper than this aren't followed.
|
||||
const MAX_FORM_DEPTH: usize = 12;
|
||||
/// Walks of a form already walked once (other scale or caller) before the measurement gives up.
|
||||
/// Each form's first walk is free: their number is bounded by the file's object count.
|
||||
const MAX_REWALKS: usize = 100_000;
|
||||
|
||||
/// State of one [`effective_resolutions`] pass.
|
||||
struct Walk<'a> {
|
||||
doc: &'a Document,
|
||||
/// The forms being walked right now, outermost first: a form drawing one of these is a cycle.
|
||||
path: Vec<ObjRef>,
|
||||
/// Forms with their own resources already walked, by the linear part of their matrix. Drawing
|
||||
/// one again at the same scale (only moved) can't change any image's resolution.
|
||||
walked: HashSet<(ObjRef, [u64; 4])>,
|
||||
/// Forms walked at least once.
|
||||
seen: HashSet<ObjRef>,
|
||||
rewalks: usize,
|
||||
/// False once a use may have been missed (depth or work limit).
|
||||
complete: bool,
|
||||
out: HashMap<ObjRef, f64>,
|
||||
}
|
||||
|
||||
impl Walk<'_> {
|
||||
/// Walk one content stream, recording each image's displayed resolution (pixels per inch, the
|
||||
/// smaller of the two axes) and descending into form XObjects.
|
||||
fn content(&mut self, data: &[u8], resources: &Dict, ctm: Matrix) {
|
||||
let doc = self.doc;
|
||||
let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
let mut stack = vec![ctm];
|
||||
for op in pdfcraft_content::parse(data).ops {
|
||||
let top = stack.last().copied().unwrap_or(ctm);
|
||||
match op.op.as_slice() {
|
||||
b"q" => stack.push(top),
|
||||
b"Q" if stack.len() > 1 => {
|
||||
stack.pop();
|
||||
}
|
||||
}
|
||||
b"Do" => {
|
||||
let Some(name) = op.name(0) else { continue };
|
||||
let Some(r) = xobjects.get(name).and_then(Object::as_ref) else { continue };
|
||||
let obj = doc.get(r);
|
||||
let Object::Stream(s) = &*obj else { continue };
|
||||
match s.dict.name(b"Subtype") {
|
||||
Some(b"Image") => {
|
||||
let (w, h) = (s.dict.int(b"Width").unwrap_or(0) as f64, s.dict.int(b"Height").unwrap_or(0) as f64);
|
||||
let [a, b, c, d, ..] = top.0;
|
||||
let (sx, sy) = ((a * a + b * b).sqrt(), (c * c + d * d).sqrt());
|
||||
if w > 0.0 && h > 0.0 && sx > 1e-6 && sy > 1e-6 {
|
||||
let ppi = (w / (sx / 72.0)).min(h / (sy / 72.0));
|
||||
let e = out.entry(r).or_insert(f64::INFINITY);
|
||||
*e = e.min(ppi);
|
||||
}
|
||||
b"cm" => {
|
||||
if let Some(m) = Matrix::from_operands(&op.operands)
|
||||
&& let Some(t) = stack.last_mut()
|
||||
{
|
||||
*t = m.then(&top);
|
||||
}
|
||||
Some(b"Form") => {
|
||||
if !seen.insert(r) && depth > 4 {
|
||||
continue;
|
||||
}
|
||||
let m = s.dict.get(b"Matrix").map(|m| doc.resolve(m)).and_then(|m| m.as_array().map(|a| Matrix::from_operands(a))).flatten();
|
||||
let res =
|
||||
s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_else(|| resources.clone());
|
||||
let inner = m.unwrap_or(Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])).then(&top);
|
||||
walk(doc, &s.decoded().unwrap_or_default(), &res, inner, depth + 1, seen, out);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
b"Do" => {
|
||||
let Some(name) = op.name(0) else { continue };
|
||||
let Some(r) = xobjects.get(name).and_then(Object::as_ref) else { continue };
|
||||
let obj = doc.get(r);
|
||||
let Object::Stream(s) = &*obj else { continue };
|
||||
match s.dict.name(b"Subtype") {
|
||||
Some(b"Image") => {
|
||||
let (w, h) = (s.dict.int(b"Width").unwrap_or(0) as f64, s.dict.int(b"Height").unwrap_or(0) as f64);
|
||||
let [a, b, c, d, ..] = top.0;
|
||||
let (sx, sy) = ((a * a + b * b).sqrt(), (c * c + d * d).sqrt());
|
||||
if w > 0.0 && h > 0.0 && sx > 1e-6 && sy > 1e-6 {
|
||||
let ppi = (w / (sx / 72.0)).min(h / (sy / 72.0));
|
||||
let e = self.out.entry(r).or_insert(f64::INFINITY);
|
||||
*e = e.min(ppi);
|
||||
}
|
||||
}
|
||||
Some(b"Form") => self.form(r, s, resources, top),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Descend into form `r` drawn with `ctm` from a stream whose resources are `resources`.
|
||||
fn form(&mut self, r: ObjRef, s: &Stream, resources: &Dict, ctm: Matrix) {
|
||||
if self.path.contains(&r) {
|
||||
return;
|
||||
}
|
||||
if self.path.len() >= MAX_FORM_DEPTH {
|
||||
self.complete = false;
|
||||
return;
|
||||
}
|
||||
let doc = self.doc;
|
||||
let m = s.dict.get(b"Matrix").map(|m| doc.resolve(m)).and_then(|m| m.as_array().map(|a| Matrix::from_operands(a))).flatten();
|
||||
let inner = m.unwrap_or(Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])).then(&ctm);
|
||||
let own = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned());
|
||||
// A form without resources uses its caller's, so the same form can draw other images.
|
||||
if own.is_some() {
|
||||
let [a, b, c, d, ..] = inner.0;
|
||||
if !self.walked.insert((r, [a.to_bits(), b.to_bits(), c.to_bits(), d.to_bits()])) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if !self.seen.insert(r) {
|
||||
if self.rewalks >= MAX_REWALKS {
|
||||
self.complete = false;
|
||||
return;
|
||||
}
|
||||
self.rewalks += 1;
|
||||
}
|
||||
self.path.push(r);
|
||||
self.content(&s.decoded().unwrap_or_default(), own.as_ref().unwrap_or(resources), inner);
|
||||
self.path.pop();
|
||||
}
|
||||
}
|
||||
|
||||
/// The effective resolution (ppi) of every image drawn by `pages`: the smallest over its uses.
|
||||
///
|
||||
/// If forms nest too deeply or are drawn too many times to measure every use, every image is
|
||||
/// given `INFINITY` (never resampled), since its largest use may be among those missed.
|
||||
pub fn effective_resolutions(doc: &Document, pages: &[ObjRef]) -> HashMap<ObjRef, f64> {
|
||||
let mut out = HashMap::new();
|
||||
let mut seen = HashSet::new();
|
||||
let mut walk = Walk { doc, path: Vec::new(), walked: HashSet::new(), seen: HashSet::new(), rewalks: 0, complete: true, out: HashMap::new() };
|
||||
for p in pages {
|
||||
let Some(d) = doc.get(*p).as_dict().cloned() else { continue };
|
||||
let Some(contents) = d.get(b"Contents") else { continue };
|
||||
let res = page_resources(doc, *p).unwrap_or_default();
|
||||
walk(doc, &content_bytes(doc, contents), &res, Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]), 0, &mut seen, &mut out);
|
||||
walk.content(&content_bytes(doc, contents), &res, Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]));
|
||||
}
|
||||
out
|
||||
if !walk.complete {
|
||||
walk.out.values_mut().for_each(|v| *v = f64::INFINITY);
|
||||
}
|
||||
walk.out
|
||||
}
|
||||
|
||||
/// Colour components of a colour space PdfCraft resamples: gray (1) or RGB (3).
|
||||
|
||||
@@ -245,6 +245,100 @@ fn space_audit_counts_images_a_content_stream_dictionary_points_at_as_images() {
|
||||
assert!(images.percent > 80.0, "{audit:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_audit_counts_what_fields_and_comments_draw_with_by_kind() {
|
||||
// A font or image a form field or comment draws with is still a font or image. The form
|
||||
// was claimed first, so its fonts made a fillable form look like mostly "Forms".
|
||||
let mut doc = Document::new_empty();
|
||||
let p = page(&mut doc, &[], "BT ET");
|
||||
let font = |doc: &mut Document| {
|
||||
let file = doc.add(Object::Stream(Stream::from_raw(Dict::new(), vec![7; 40_000])));
|
||||
let mut desc = Dict::new();
|
||||
desc.set(b"Type".to_vec(), Object::name("FontDescriptor"));
|
||||
desc.set(b"FontFile2".to_vec(), Object::Ref(file));
|
||||
let desc = doc.add(Object::Dict(desc));
|
||||
let mut f = Dict::new();
|
||||
f.set(b"Type".to_vec(), Object::name("Font"));
|
||||
f.set(b"Subtype".to_vec(), Object::name("TrueType"));
|
||||
f.set(b"FontDescriptor".to_vec(), Object::Ref(desc));
|
||||
doc.add(Object::Dict(f))
|
||||
};
|
||||
let (field_font, comment_font) = (font(&mut doc), font(&mut doc));
|
||||
let icon = image(&mut doc, 200, 200, 3, false, None);
|
||||
let appearance = |doc: &mut Document, kind: &str, name: &str, r: ObjRef| {
|
||||
let mut entries = Dict::new();
|
||||
entries.set(name.as_bytes().to_vec(), Object::Ref(r));
|
||||
let mut res = Dict::new();
|
||||
res.set(kind.as_bytes().to_vec(), Object::Dict(entries));
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Subtype".to_vec(), Object::name("Form"));
|
||||
d.set(b"Resources".to_vec(), Object::Dict(res));
|
||||
Object::Ref(doc.add(Object::Stream(Stream::from_raw(d, b"q Q".to_vec()))))
|
||||
};
|
||||
let annot = |doc: &mut Document, subtype: &str, ap: Object| {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("Annot"));
|
||||
d.set(b"Subtype".to_vec(), Object::name(subtype));
|
||||
d.set(b"Rect".to_vec(), Object::Array(vec![0.into(), 0.into(), 10.into(), 10.into()]));
|
||||
d.set(b"P".to_vec(), Object::Ref(p));
|
||||
let mut aps = Dict::new();
|
||||
aps.set(b"N".to_vec(), ap);
|
||||
d.set(b"AP".to_vec(), Object::Dict(aps));
|
||||
Object::Ref(doc.add(Object::Dict(d)))
|
||||
};
|
||||
// A text field's appearance uses the form's default font; a check box's "on" state (one of
|
||||
// a dictionary of states) draws an image; a free-text comment has a font of its own.
|
||||
let text_ap = appearance(&mut doc, "Font", "F1", field_font);
|
||||
let text = annot(&mut doc, "Widget", text_ap);
|
||||
let mut states = Dict::new();
|
||||
states.set(b"On".to_vec(), appearance(&mut doc, "XObject", "Im0", icon));
|
||||
let check = annot(&mut doc, "Widget", Object::Dict(states));
|
||||
let comment_ap = appearance(&mut doc, "Font", "F2", comment_font);
|
||||
let comment = annot(&mut doc, "FreeText", comment_ap);
|
||||
doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![text.clone(), check.clone(), comment]))).unwrap();
|
||||
let mut fonts = Dict::new();
|
||||
fonts.set(b"F1".to_vec(), Object::Ref(field_font));
|
||||
let mut dr = Dict::new();
|
||||
dr.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
let mut form = Dict::new();
|
||||
form.set(b"Fields".to_vec(), Object::Array(vec![text, check]));
|
||||
form.set(b"DR".to_vec(), Object::Dict(dr));
|
||||
let root = doc.root().unwrap();
|
||||
doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(form))).unwrap();
|
||||
let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
|
||||
let doc = Document::open(Arc::new(bytes.clone())).unwrap();
|
||||
let audit = audit_space(&doc, bytes.len() as u64);
|
||||
let get = |c: SpaceCategory| audit.iter().find(|u| u.category == c).unwrap().bytes;
|
||||
assert!(get(SpaceCategory::Fonts) > 80_000, "both fonts are fonts: {audit:?}");
|
||||
assert!(get(SpaceCategory::Images) > 2_000, "the check box's image is an image: {audit:?}");
|
||||
assert!(get(SpaceCategory::Forms) < 2_000 && get(SpaceCategory::Comments) < 1_000, "{audit:?}");
|
||||
assert!(get(SpaceCategory::Forms) > 0 && get(SpaceCategory::Comments) > 0, "the fields and the comment are still counted: {audit:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_audit_survives_deeply_nested_forms() {
|
||||
// Each form draws the next; sorting their resources by kind recursed once per form, so a
|
||||
// long enough chain overflowed the stack.
|
||||
let mut doc = Document::new_empty();
|
||||
let mut next: Option<ObjRef> = None;
|
||||
for _ in 0..20_000 {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Subtype".to_vec(), Object::name("Form"));
|
||||
if let Some(n) = next {
|
||||
let mut x = Dict::new();
|
||||
x.set(b"X".to_vec(), Object::Ref(n));
|
||||
let mut res = Dict::new();
|
||||
res.set(b"XObject".to_vec(), Object::Dict(x));
|
||||
d.set(b"Resources".to_vec(), Object::Dict(res));
|
||||
}
|
||||
next = Some(doc.add(Object::Stream(Stream::from_raw(d, b"/X Do".to_vec()))));
|
||||
}
|
||||
page(&mut doc, &[("X", next.unwrap())], "/X Do");
|
||||
let audit = audit_space(&doc, 0);
|
||||
let forms = audit.iter().find(|u| u.category == SpaceCategory::XObjectForms).unwrap();
|
||||
assert!(forms.bytes > 20_000 * 40, "every form is counted, however deep: {audit:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_links_and_unreferenced_destinations_go() {
|
||||
let mut doc = Document::new_empty();
|
||||
@@ -821,3 +915,110 @@ fn images_that_do_not_tile_a_picture_are_left_alone() {
|
||||
let (mut off, _) = printed_page(40, |_| false, |_, s| s);
|
||||
unmerged(&mut off, &Settings { merge_fragments: false, ..Settings::default() });
|
||||
}
|
||||
|
||||
/// `levels` nested forms with their own resources, the innermost drawing `img` over the unit square.
|
||||
fn nested_forms(doc: &mut Document, img: ObjRef, levels: usize) -> ObjRef {
|
||||
let mut inner = form(doc, Some(Object::Dict(resources(xobjects(&[("Im", img)])))), "/Im Do");
|
||||
for _ in 1..levels {
|
||||
inner = form(doc, Some(Object::Dict(resources(xobjects(&[("F", inner)])))), "/F Do");
|
||||
}
|
||||
inner
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deeply_nested_form_is_measured_at_its_largest_use() {
|
||||
// 1200 px drawn 1 in wide (1200 ppi) and then 8 in wide (150 ppi): 150 ppi, so it isn't resampled.
|
||||
let small = "q 72 0 0 72 36 36 cm /F Do Q";
|
||||
let large = "q 576 0 0 576 18 100 cm /F Do Q";
|
||||
for levels in [1, 6, 12] {
|
||||
for (pages, label) in [(vec![format!("{small} {large}")], "one page"), (vec![small.into(), large.into()], "two pages")] {
|
||||
let mut doc = Document::new_empty();
|
||||
let img = image(&mut doc, 1200, 1200, 3, false, None);
|
||||
let outer = nested_forms(&mut doc, img, levels);
|
||||
for c in &pages {
|
||||
page(&mut doc, &[("F", outer)], c);
|
||||
}
|
||||
let refs = pdfcraft_annot::page_refs(&doc).unwrap();
|
||||
assert_eq!(effective_resolutions(&doc, &refs)[&img].round(), 150.0, "{levels} levels, {label}");
|
||||
optimize(&mut doc, &Settings::default()).unwrap();
|
||||
assert_eq!(stream(&doc, img).dict.int(b"Width"), Some(1200), "{levels} levels, {label}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_form_drawing_itself_is_walked_once_per_use() {
|
||||
let mut doc = Document::new_empty();
|
||||
let img = image(&mut doc, 1200, 1200, 3, false, None);
|
||||
let f = form(&mut doc, None, "/Im Do q 2 0 0 2 0 0 cm /F Do Q");
|
||||
let mut x = xobjects(&[("Im", img)]);
|
||||
x.set(b"F".to_vec(), Object::Ref(f));
|
||||
let mut d = match &*doc.get(f) {
|
||||
Object::Stream(s) => s.clone(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
d.dict.set(b"Resources".to_vec(), Object::Dict(resources(x)));
|
||||
doc.set(f, Object::Stream(d));
|
||||
page(&mut doc, &[("F", f)], "q 72 0 0 72 36 36 cm /F Do Q");
|
||||
let refs = pdfcraft_annot::page_refs(&doc).unwrap();
|
||||
// Only the outer use: the cycle isn't followed, and doesn't count as a missed use.
|
||||
assert_eq!(effective_resolutions(&doc, &refs)[&img].round(), 1200.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shared_form_without_resources_draws_each_callers_image() {
|
||||
// G has no /Resources, so its /Im is whichever image the form drawing it names.
|
||||
let mut doc = Document::new_empty();
|
||||
let a = image(&mut doc, 1200, 1200, 3, false, None);
|
||||
let b = image(&mut doc, 1200, 1200, 3, false, None);
|
||||
let g = form(&mut doc, None, "/Im Do");
|
||||
let mut outers = Vec::new();
|
||||
for img in [a, b] {
|
||||
let mut inner = form(&mut doc, Some(Object::Dict(resources(xobjects(&[("Im", img), ("G", g)])))), "/G Do");
|
||||
for _ in 1..6 {
|
||||
inner = form(&mut doc, Some(Object::Dict(resources(xobjects(&[("F", inner)])))), "/F Do");
|
||||
}
|
||||
outers.push(inner);
|
||||
}
|
||||
page(&mut doc, &[("A", outers[0]), ("B", outers[1])], "q 72 0 0 72 36 36 cm /A Do Q q 576 0 0 576 18 100 cm /B Do Q");
|
||||
let refs = pdfcraft_annot::page_refs(&doc).unwrap();
|
||||
let ppi = effective_resolutions(&doc, &refs);
|
||||
assert_eq!(ppi[&a].round(), 1200.0);
|
||||
assert_eq!(ppi.get(&b).map(|p| p.round()), Some(150.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_are_not_resampled_when_some_uses_could_not_be_measured() {
|
||||
// Too deep: the 13th form isn't followed, so its use of the image is unknown.
|
||||
let mut doc = Document::new_empty();
|
||||
let img = image(&mut doc, 1200, 1200, 3, false, None);
|
||||
let other = image(&mut doc, 1200, 1200, 3, false, None);
|
||||
let deep = nested_forms(&mut doc, img, 13);
|
||||
page(
|
||||
&mut doc,
|
||||
&[("F", deep), ("Im", img), ("O", other)],
|
||||
"q 72 0 0 72 36 36 cm /Im Do Q q 72 0 0 72 200 36 cm /O Do Q q 576 0 0 576 18 100 cm /F Do Q",
|
||||
);
|
||||
let refs = pdfcraft_annot::page_refs(&doc).unwrap();
|
||||
let ppi = effective_resolutions(&doc, &refs);
|
||||
assert_eq!((ppi[&img], ppi[&other]), (f64::INFINITY, f64::INFINITY));
|
||||
optimize(&mut doc, &Settings::default()).unwrap();
|
||||
assert_eq!(stream(&doc, img).dict.int(b"Width"), Some(1200));
|
||||
|
||||
// Too much work: forms without resources (so nothing can be skipped), each drawing the next
|
||||
// three times, twelve deep, would be (3^12 - 1) / 2 walks.
|
||||
let mut doc = Document::new_empty();
|
||||
let img = image(&mut doc, 1200, 1200, 3, false, None);
|
||||
let mut names = vec![("Im", img)];
|
||||
let mut next = "Im";
|
||||
let labels = ["F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12"];
|
||||
for (i, label) in labels.iter().enumerate().rev() {
|
||||
let s = 1.0 + i as f64 / 100.0;
|
||||
let f = form(&mut doc, None, &format!("q {s} 0 0 {s} 0 0 cm /{next} Do Q /{next} Do q 1 0 0 1 1 0 cm /{next} Do Q"));
|
||||
names.push((label, f));
|
||||
next = label;
|
||||
}
|
||||
page(&mut doc, &names, "q 72 0 0 72 36 36 cm /F1 Do Q");
|
||||
let refs = pdfcraft_annot::page_refs(&doc).unwrap();
|
||||
assert_eq!(effective_resolutions(&doc, &refs)[&img], f64::INFINITY);
|
||||
}
|
||||
|
||||
+130
-56
@@ -28,8 +28,16 @@ use crate::{INHERITABLE, OrganizeError, pages_root, rebuild, walk};
|
||||
const SKIP_ON_PAGE: &[&[u8]] = &[b"Parent", b"B", b"StructParents", b"Thumb", b"PieceInfo"];
|
||||
const SKIP_ON_ANNOT: &[&[u8]] = &[b"StructParent", b"P"];
|
||||
|
||||
/// How deep a copy follows references and nested arrays and dictionaries, counted together.
|
||||
/// Real documents stay well under this (a page's resources, fonts and descriptors, nested
|
||||
/// form XObjects and field trees are a few levels each); a chain of thousands of indirect
|
||||
/// objects, each shallow enough for the parser, would otherwise exhaust the stack (#1800).
|
||||
const MAX_COPY_DEPTH: usize = 256;
|
||||
|
||||
struct Copier<'a> {
|
||||
src: &'a Document,
|
||||
/// Current recursion depth, bounded by [`MAX_COPY_DEPTH`].
|
||||
depth: usize,
|
||||
map: HashMap<ObjRef, ObjRef>,
|
||||
/// Source page → destination page, for rewriting `/P` and destinations.
|
||||
pages: HashMap<ObjRef, ObjRef>,
|
||||
@@ -41,11 +49,15 @@ struct Copier<'a> {
|
||||
ocgs: Vec<(ObjRef, ObjRef)>,
|
||||
}
|
||||
|
||||
impl Copier<'_> {
|
||||
impl<'a> Copier<'a> {
|
||||
fn new(src: &'a Document, map: HashMap<ObjRef, ObjRef>, pages: HashMap<ObjRef, ObjRef>) -> Self {
|
||||
Copier { src, depth: 0, map, pages, annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new() }
|
||||
}
|
||||
|
||||
/// Copy an indirect object (once), returning its destination reference.
|
||||
fn copy_ref(&mut self, dst: &mut Document, r: ObjRef) -> ObjRef {
|
||||
fn copy_ref(&mut self, dst: &mut Document, r: ObjRef) -> Result<ObjRef, OrganizeError> {
|
||||
if let Some(d) = self.map.get(&r) {
|
||||
return *d;
|
||||
return Ok(*d);
|
||||
}
|
||||
// Reserve the number first so cycles (field ↔ widget) terminate.
|
||||
let new = dst.add(Object::Null);
|
||||
@@ -54,26 +66,41 @@ impl Copier<'_> {
|
||||
if obj.as_dict().is_some_and(|d| d.name(b"Type") == Some(b"OCG")) {
|
||||
self.ocgs.push((r, new));
|
||||
}
|
||||
let copied = self.copy_value(dst, &obj, Some(new));
|
||||
let copied = self.copy_value(dst, &obj, Some(new))?;
|
||||
dst.set(new, copied);
|
||||
new
|
||||
Ok(new)
|
||||
}
|
||||
|
||||
fn copy_value(&mut self, dst: &mut Document, o: &Object, this: Option<ObjRef>) -> Object {
|
||||
match o {
|
||||
Object::Ref(r) => Object::Ref(self.copy_ref(dst, *r)),
|
||||
Object::Array(a) => Object::Array(a.iter().map(|x| self.copy_value(dst, x, None)).collect()),
|
||||
Object::Dict(d) => Object::Dict(self.copy_dict(dst, d, this)),
|
||||
Object::Stream(s) => {
|
||||
let mut s = s.clone();
|
||||
s.dict = self.copy_dict(dst, &s.dict, None);
|
||||
Object::Stream(s)
|
||||
}
|
||||
other => other.clone(),
|
||||
/// One level deeper into the object graph, or the refusal when there are too many.
|
||||
fn descend(&mut self) -> Result<(), OrganizeError> {
|
||||
if self.depth >= MAX_COPY_DEPTH {
|
||||
return Err(OrganizeError::Invalid(format!("the pages refer to objects nested more than {MAX_COPY_DEPTH} levels deep")));
|
||||
}
|
||||
self.depth += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_dict(&mut self, dst: &mut Document, d: &Dict, this: Option<ObjRef>) -> Dict {
|
||||
fn copy_value(&mut self, dst: &mut Document, o: &Object, this: Option<ObjRef>) -> Result<Object, OrganizeError> {
|
||||
if !matches!(o, Object::Ref(_) | Object::Array(_) | Object::Dict(_) | Object::Stream(_)) {
|
||||
return Ok(o.clone());
|
||||
}
|
||||
self.descend()?;
|
||||
let copied = match o {
|
||||
Object::Ref(r) => self.copy_ref(dst, *r).map(Object::Ref),
|
||||
Object::Array(a) => a.iter().map(|x| self.copy_value(dst, x, None)).collect::<Result<Vec<_>, _>>().map(Object::Array),
|
||||
Object::Dict(d) => self.copy_dict(dst, d, this).map(Object::Dict),
|
||||
Object::Stream(s) => self.copy_dict(dst, &s.dict, None).map(|dict| {
|
||||
let mut s = s.clone();
|
||||
s.dict = dict;
|
||||
Object::Stream(s)
|
||||
}),
|
||||
other => Ok(other.clone()),
|
||||
};
|
||||
self.depth = self.depth.saturating_sub(1);
|
||||
copied
|
||||
}
|
||||
|
||||
fn copy_dict(&mut self, dst: &mut Document, d: &Dict, this: Option<ObjRef>) -> Result<Dict, OrganizeError> {
|
||||
let is_annot = d.name(b"Type") == Some(b"Annot") || (d.contains(b"Subtype") && d.contains(b"Rect") && !d.contains(b"Type"));
|
||||
let is_field = d.contains(b"FT") || (d.contains(b"T") && d.contains(b"Kids"));
|
||||
let mut out = Dict::new();
|
||||
@@ -86,7 +113,7 @@ impl Copier<'_> {
|
||||
out.set(k.clone(), v.clone());
|
||||
continue;
|
||||
}
|
||||
out.set(k.clone(), self.copy_value(dst, v, None));
|
||||
out.set(k.clone(), self.copy_value(dst, v, None)?);
|
||||
}
|
||||
if let Some(this) = this {
|
||||
if is_annot {
|
||||
@@ -97,7 +124,7 @@ impl Copier<'_> {
|
||||
self.fields.push(this);
|
||||
}
|
||||
}
|
||||
out
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Map a source destination (explicit array or reference to one) to the destination
|
||||
@@ -162,15 +189,8 @@ impl Copier<'_> {
|
||||
}
|
||||
} else {
|
||||
// Other actions (URI, Launch, JavaScript…) carry no page references.
|
||||
let mut c = Copier {
|
||||
src: self.src,
|
||||
map: self.map.clone(),
|
||||
pages: HashMap::new(),
|
||||
annots: Vec::new(),
|
||||
fields: Vec::new(),
|
||||
ocgs: Vec::new(),
|
||||
};
|
||||
let copied = c.copy_value(dst, &Object::Dict(ad.clone()), None);
|
||||
let mut c = Copier::new(self.src, self.map.clone(), HashMap::new());
|
||||
let copied = c.copy_value(dst, &Object::Dict(ad.clone()), None)?;
|
||||
d.set(b"A".to_vec(), copied);
|
||||
}
|
||||
}
|
||||
@@ -183,9 +203,8 @@ impl Copier<'_> {
|
||||
|
||||
/// Copy `o` from `src` into `dst`, reusing the copies listed in `map` (source → destination), so
|
||||
/// an object the copied pages already brought along (an ICC profile) is not stored twice.
|
||||
pub(crate) fn copy_object(dst: &mut Document, src: &Document, o: &Object, map: HashMap<ObjRef, ObjRef>) -> Object {
|
||||
let mut c = Copier { src, map, pages: HashMap::new(), annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new() };
|
||||
c.copy_value(dst, o, None)
|
||||
pub(crate) fn copy_object(dst: &mut Document, src: &Document, o: &Object, map: HashMap<ObjRef, ObjRef>) -> Result<Object, OrganizeError> {
|
||||
Copier::new(src, map, HashMap::new()).copy_value(dst, o, None)
|
||||
}
|
||||
|
||||
/// How colliding fully-qualified field names are resolved when pages are imported.
|
||||
@@ -222,12 +241,70 @@ pub fn import_pages_with(
|
||||
conflicts: FieldConflicts,
|
||||
) -> Result<ImportOutcome, OrganizeError> {
|
||||
let first_new = dst.object_numbers().last().map_or(1, |n| n + 1);
|
||||
let imported = import_pages_mapped(dst, src, src_pages, at, conflicts)?;
|
||||
let imported = match import_pages_mapped(dst, src, src_pages, at, conflicts) {
|
||||
Ok(imported) => imported,
|
||||
Err(e) => {
|
||||
// A refusal while copying (a source too deep to follow) has only added objects:
|
||||
// take them back, so the destination is as it was.
|
||||
for n in dst.object_numbers().into_iter().filter(|n| *n >= first_new) {
|
||||
dst.free(ObjRef::new(n, dst.generation(n)));
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let created: Vec<ObjRef> = dst.object_numbers().into_iter().filter(|n| *n >= first_new).map(|n| ObjRef::new(n, dst.generation(n))).collect();
|
||||
crate::dedupe::dedupe_resources(dst, &created, true);
|
||||
Ok(ImportOutcome { pages: imported.pages, renamed_fields: imported.renamed_fields })
|
||||
}
|
||||
|
||||
/// Insert Pages from File: [`import_pages_with`], and the inserted file's bookmarks come along
|
||||
/// under one new bookmark titled `title` (the file's name) at its first inserted page, as Combine
|
||||
/// Files does for each file. That bookmark goes among the top-level bookmarks where the pages went:
|
||||
/// before the first one that points after them. Bookmarks to pages left out lose their destination;
|
||||
/// a file without bookmarks adds none.
|
||||
pub fn insert_file_pages(
|
||||
dst: &mut Document,
|
||||
src: &Document,
|
||||
title: &str,
|
||||
src_pages: &[usize],
|
||||
at: usize,
|
||||
conflicts: FieldConflicts,
|
||||
) -> Result<ImportOutcome, OrganizeError> {
|
||||
let first_new = dst.object_numbers().last().map_or(1, |n| n + 1);
|
||||
let imported = import_pages_mapped(dst, src, src_pages, at, conflicts)?;
|
||||
let created: Vec<ObjRef> = dst.object_numbers().into_iter().filter(|n| *n >= first_new).map(|n| ObjRef::new(n, dst.generation(n))).collect();
|
||||
crate::dedupe::dedupe_resources(dst, &created, true);
|
||||
let outline = source_outline(src, imported.page_map);
|
||||
if let Some(first) = imported.pages.first()
|
||||
&& !outline.is_empty()
|
||||
{
|
||||
let after = at.min(crate::page_count(dst)?.saturating_sub(imported.pages.len())).saturating_add(imported.pages.len());
|
||||
insert_file_bookmark(dst, &Mark { title: title.to_string(), page: *first, outline }, after)?;
|
||||
}
|
||||
Ok(ImportOutcome { pages: imported.pages, renamed_fields: imported.renamed_fields })
|
||||
}
|
||||
|
||||
/// Add `mark` (a file's bookmark holding its own, collapsed) to the top level of `doc`'s outline,
|
||||
/// before the first top-level bookmark that points at page `after` (0-based) or later.
|
||||
fn insert_file_bookmark(doc: &mut Document, mark: &Mark, after: usize) -> Result<(), OrganizeError> {
|
||||
let index = crate::outline::top_level_bookmarks(doc).iter().position(|b| b.page.is_some_and(|p| p >= after));
|
||||
let root = crate::outline::container(doc, &[])?;
|
||||
let item = doc.add(Object::Null);
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Title".to_vec(), Object::String(pdfcraft_cos::PdfString::text(&mark.title)));
|
||||
d.set(b"Dest".to_vec(), Object::Array(vec![Object::Ref(mark.page), Object::name("Fit")]));
|
||||
if let Some((f, l, n)) = write_outline_level(doc, item, &mark.outline)? {
|
||||
d.set(b"First".to_vec(), Object::Ref(f));
|
||||
d.set(b"Last".to_vec(), Object::Ref(l));
|
||||
d.set(b"Count".to_vec(), Object::Int(-(n as i64))); // collapsed
|
||||
}
|
||||
doc.set(item, Object::Dict(d));
|
||||
let mut top = crate::outline::children(doc, root);
|
||||
top.insert(index.unwrap_or(top.len()).min(top.len()), item);
|
||||
crate::outline::relink_and_recount(doc, root, &top)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copy pages `src_pages` (0-based, in the given order) of `src` into `dst`, inserting them at
|
||||
/// position `at`. Returns the new page references in order.
|
||||
///
|
||||
@@ -268,7 +345,7 @@ fn import_pages_mapped(
|
||||
}
|
||||
let mut existing = walk(dst)?;
|
||||
let root = pages_root(dst)?;
|
||||
let mut copier = Copier { src, map: HashMap::new(), pages: HashMap::new(), annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new() };
|
||||
let mut copier = Copier::new(src, HashMap::new(), HashMap::new());
|
||||
// Allocate every destination page first, so destinations between copied pages resolve.
|
||||
let targets: Vec<(ObjRef, ObjRef, Dict)> = src_pages
|
||||
.iter()
|
||||
@@ -291,14 +368,7 @@ fn import_pages_mapped(
|
||||
// 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 mut own = repeat.then(|| Copier::new(src, HashMap::new(), copier.pages.clone()));
|
||||
let src_dict = src.get(page).as_dict().cloned().unwrap_or_default();
|
||||
let resources = resources_with_ancestors(src, &src_dict);
|
||||
let mut d = Dict::new();
|
||||
@@ -321,7 +391,7 @@ fn import_pages_mapped(
|
||||
&& let Some(copied) = copier.map.get(&parent).copied()
|
||||
{
|
||||
let r = dst.add(Object::Null);
|
||||
let c = copier.copy_value(dst, &resolved, Some(r));
|
||||
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.
|
||||
@@ -348,10 +418,10 @@ fn import_pages_mapped(
|
||||
// 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) => annots.copy_ref(dst, r),
|
||||
Object::Ref(r) => annots.copy_ref(dst, r)?,
|
||||
Object::Dict(_) => {
|
||||
let r = dst.add(Object::Null);
|
||||
let c = annots.copy_value(dst, &a, Some(r));
|
||||
let c = annots.copy_value(dst, &a, Some(r))?;
|
||||
dst.set(r, c);
|
||||
r
|
||||
}
|
||||
@@ -363,17 +433,17 @@ fn import_pages_mapped(
|
||||
d.set(b"Annots".to_vec(), Object::Array(out));
|
||||
continue;
|
||||
}
|
||||
d.set(k.clone(), copier.copy_value(dst, v, None));
|
||||
d.set(k.clone(), copier.copy_value(dst, v, None)?);
|
||||
}
|
||||
if let Some(r) = resources {
|
||||
let r = copier.copy_value(dst, &r, None);
|
||||
let r = copier.copy_value(dst, &r, None)?;
|
||||
d.set(b"Resources".to_vec(), r);
|
||||
}
|
||||
for k in INHERITABLE {
|
||||
if !d.contains(k)
|
||||
&& let Some(v) = inherited.get(k)
|
||||
{
|
||||
let v = copier.copy_value(dst, v, None);
|
||||
let v = copier.copy_value(dst, v, None)?;
|
||||
d.set(k.to_vec(), v);
|
||||
}
|
||||
}
|
||||
@@ -936,7 +1006,7 @@ impl Combiner {
|
||||
let outline = source_outline(src, imported.page_map);
|
||||
self.marks.push(Mark { title: title.to_string(), page: *first, outline });
|
||||
}
|
||||
collect_attachments(&mut self.out, src, &mut self.attachments);
|
||||
collect_attachments(&mut self.out, src, &mut self.attachments)?;
|
||||
let declared = crate::pdfx::PrintStandard::of(src);
|
||||
match self.standard.as_ref().map(|first| first.declared.same_as(&declared)) {
|
||||
Some(same) => self.agreed &= same,
|
||||
@@ -1030,7 +1100,7 @@ pub fn combine_grouped_with(runs: &[Run<'_>], conflicts: FieldConflicts) -> Resu
|
||||
if let Some(first) = imported.pages.first() {
|
||||
marks.push(Mark { title: file.title.to_string(), page: *first, outline: source_outline(file.src, imported.page_map) });
|
||||
}
|
||||
collect_attachments(&mut out, file.src, &mut attachments);
|
||||
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)),
|
||||
@@ -1056,22 +1126,23 @@ pub fn combine_grouped_with(runs: &[Run<'_>], conflicts: FieldConflicts) -> Resu
|
||||
}
|
||||
|
||||
/// 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)>) {
|
||||
fn collect_attachments(dst: &mut Document, src: &Document, out: &mut Vec<(Vec<u8>, Object)>) -> Result<(), OrganizeError> {
|
||||
let Some(tree) = src
|
||||
.root()
|
||||
.and_then(|r| src.get(r).as_dict().cloned())
|
||||
.and_then(|c| c.get(b"Names").map(|n| src.resolve(n)))
|
||||
.and_then(|n| n.as_dict().and_then(|d| d.get(b"EmbeddedFiles").cloned()))
|
||||
else {
|
||||
return;
|
||||
return Ok(());
|
||||
};
|
||||
let mut entries = Vec::new();
|
||||
name_tree_entries(src, &tree, 0, &mut entries);
|
||||
let mut copier = Copier { src, map: HashMap::new(), pages: HashMap::new(), annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new() };
|
||||
let mut copier = Copier::new(src, HashMap::new(), HashMap::new());
|
||||
for (k, v) in entries {
|
||||
let v = copier.copy_value(dst, &v, None);
|
||||
let v = copier.copy_value(dst, &v, None)?;
|
||||
out.push((k, v));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write attachments as a flat, sorted `/EmbeddedFiles` name tree; duplicate names get " (2)"…
|
||||
@@ -1137,7 +1208,7 @@ fn source_outline(src: &Document, pages: HashMap<ObjRef, ObjRef>) -> Vec<Outline
|
||||
.and_then(|r| src.get(r).as_dict().cloned())
|
||||
.and_then(|c| c.get(b"Outlines").map(|o| src.resolve(o)))
|
||||
.and_then(|o| o.as_dict().and_then(|d| d.reference(b"First")));
|
||||
let mapper = Copier { src, map: HashMap::new(), pages, annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new() };
|
||||
let mapper = Copier::new(src, HashMap::new(), pages);
|
||||
let mut budget = 10_000usize;
|
||||
outline_level(&mapper, first, 0, &mut budget)
|
||||
}
|
||||
@@ -1288,8 +1359,11 @@ pub fn page_as_form(dst: &mut Document, src: &Document, page: usize) -> Result<(
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut copier = Copier { src, map: HashMap::new(), pages: HashMap::new(), annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new() };
|
||||
let resources = d.get(b"Resources").map(|r| copier.copy_value(dst, r, None)).unwrap_or(Object::Dict(Dict::new()));
|
||||
let mut copier = Copier::new(src, HashMap::new(), HashMap::new());
|
||||
let resources = match d.get(b"Resources") {
|
||||
Some(r) => copier.copy_value(dst, r, None)?,
|
||||
None => Object::Dict(Dict::new()),
|
||||
};
|
||||
register_layers(dst, src, &copier.ocgs)?;
|
||||
let mut fd = Dict::new();
|
||||
fd.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
|
||||
@@ -385,7 +385,9 @@ pub fn set_page_label_ranges(doc: &mut Document, ranges: &[LabelRange]) -> Resul
|
||||
spec.set(b"St".to_vec(), Object::Int(i64::from(r.first)));
|
||||
}
|
||||
nums.push(Object::Int(r.start as i64));
|
||||
nums.push(Object::Dict(spec));
|
||||
// Indirect, as Acrobat writes them: a direct dictionary is valid too, but some readers
|
||||
// (pdfannots2json, through unipdf) skip it and show physical page numbers (#1763).
|
||||
nums.push(Object::Ref(doc.add(Object::Dict(spec))));
|
||||
}
|
||||
let mut tree = Dict::new();
|
||||
tree.set(b"Nums".to_vec(), Object::Array(nums));
|
||||
@@ -394,6 +396,31 @@ pub fn set_page_label_ranges(doc: &mut Document, ranges: &[LabelRange]) -> Resul
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// After `deleted` pages (0-based, as they were numbered before the delete) are gone, move the label
|
||||
/// ranges with the pages they label: a range starts as many pages earlier as were deleted before
|
||||
/// it, and keeps its first number, so numbering continues over the gap as in Acrobat. A range whose
|
||||
/// first page went starts on the next page left; one left with no pages gives way to the next.
|
||||
/// A tree that can't be read is left as it is: the delete still goes ahead.
|
||||
pub(crate) fn drop_deleted_pages(doc: &mut Document, deleted: &[usize]) -> Result<(), OrganizeError> {
|
||||
let Ok(old) = checked_ranges(doc) else { return Ok(()) };
|
||||
let mut deleted = deleted.to_vec();
|
||||
deleted.sort_unstable();
|
||||
deleted.dedup();
|
||||
let mut ranges: Vec<LabelRange> = Vec::with_capacity(old.len());
|
||||
for r in &old {
|
||||
let start = r.start.saturating_sub(deleted.partition_point(|d| *d < r.start));
|
||||
match ranges.last_mut() {
|
||||
// The later range is the one on the first page left; ranges stay sorted by start.
|
||||
Some(last) if last.start == start => *last = LabelRange { start, ..r.clone() },
|
||||
_ => ranges.push(LabelRange { start, ..r.clone() }),
|
||||
}
|
||||
}
|
||||
if ranges == old {
|
||||
return Ok(());
|
||||
}
|
||||
set_page_label_ranges(doc, &ranges)
|
||||
}
|
||||
|
||||
/// Acrobat's "Number pages": label pages `from..=to` (0-based) with `style`, `prefix` and
|
||||
/// starting number `first`; pages after `to` keep the labels they had.
|
||||
pub fn number_pages(doc: &mut Document, from: usize, to: usize, style: LabelStyle, prefix: &str, first: u32) -> Result<(), OrganizeError> {
|
||||
|
||||
@@ -20,6 +20,7 @@ mod labels;
|
||||
mod outline;
|
||||
mod pdfx;
|
||||
mod prune;
|
||||
mod transition;
|
||||
pub mod view;
|
||||
mod xmp;
|
||||
|
||||
@@ -27,14 +28,15 @@ pub use boxes::{BoxSpec, PageBox, page_boxes, set_page_box};
|
||||
pub use dedupe::dedupe_resources;
|
||||
pub use import::{
|
||||
CombineOutcome, Combiner, FieldConflicts, ImportOutcome, Run, SplitBy, combine, combine_field_conflicts, combine_grouped, combine_grouped_with,
|
||||
combine_selected, combine_selected_with, combine_with, extract_pages, field_name_conflicts, import_pages, import_pages_with, page_as_form, split,
|
||||
split_ranges,
|
||||
combine_selected, combine_selected_with, combine_with, extract_pages, field_name_conflicts, import_pages, import_pages_with, insert_file_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, 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 transition::{TRANSITION_DIRECTIONS, TRANSITION_STYLES, Transition, TransitionSpec, set_transition};
|
||||
pub use view::{InitialView, displays_doc_title, initial_view, set_initial_view};
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq)]
|
||||
@@ -179,6 +181,7 @@ pub fn delete_pages(doc: &mut Document, indices: &[usize]) -> Result<(), Organiz
|
||||
return Err(OrganizeError::WouldRemoveAllPages);
|
||||
}
|
||||
rebuild(doc, &keep)?;
|
||||
labels::drop_deleted_pages(doc, indices)?;
|
||||
// The deleted pages go for good: bookmarks, links and widgets that still point at one would
|
||||
// otherwise keep it, with its content, in the saved file. Their form widgets leave the form,
|
||||
// and bookmarks and links that went to them lose that destination.
|
||||
|
||||
@@ -38,6 +38,9 @@ pub struct Bookmark {
|
||||
pub open: bool,
|
||||
/// The 0-based page it goes to, when that page is in this document.
|
||||
pub page: Option<usize>,
|
||||
/// Where on that page it goes: the destination's type (`XYZ`, `Fit`, `FitH`…) and the numbers
|
||||
/// after it, in PDF user space (`None` for `null` or anything that isn't a number).
|
||||
pub view: Option<(Vec<u8>, Vec<Option<f64>>)>,
|
||||
}
|
||||
|
||||
/// One page of the outline listing (see [`bookmark_page`]).
|
||||
@@ -229,43 +232,57 @@ fn dests_dict(doc: &Document, key: &[u8]) -> Option<Object> {
|
||||
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> {
|
||||
/// The explicit destination array (`[page /XYZ …]`) a destination stands for: itself, a dictionary
|
||||
/// whose `/D` is one, or a named destination that resolves to one.
|
||||
fn explicit_dest(doc: &Document, targets: &mut Targets, dest: &Object, depth: u8) -> Option<Vec<Object>> {
|
||||
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::Array(a) => Some(a.clone()),
|
||||
Object::Dict(d) => explicit_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)
|
||||
explicit_dest(doc, targets, &inner, depth + 1)
|
||||
}
|
||||
Object::Name(n) => {
|
||||
let inner = targets.named(doc, n)?;
|
||||
page_of_dest(doc, targets, &inner, depth + 1)
|
||||
explicit_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)
|
||||
/// The page index an explicit destination array names.
|
||||
fn page_of_explicit(targets: &Targets, dest: &[Object]) -> Option<usize> {
|
||||
match dest.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,
|
||||
}
|
||||
}
|
||||
|
||||
/// The view an explicit destination array asks for: its type and the numbers after it.
|
||||
fn view_of_explicit(doc: &Document, dest: &[Object]) -> Option<(Vec<u8>, Vec<Option<f64>>)> {
|
||||
let kind = dest.get(1).map(|k| doc.resolve(k)).and_then(|k| k.as_name().map(<[u8]>::to_vec))?;
|
||||
// At most the four numbers of /FitR.
|
||||
let numbers = dest.iter().skip(2).take(4).map(|n| doc.resolve(n).as_f64().filter(|v| v.is_finite())).collect();
|
||||
Some((kind, numbers))
|
||||
}
|
||||
|
||||
/// The explicit destination an item goes to: its `/Dest` when that names a page here, else the
|
||||
/// destination of its GoTo action `/A`.
|
||||
fn item_dest(doc: &Document, targets: &mut Targets, d: &Dict) -> Option<Vec<Object>> {
|
||||
if let Some(dest) = d.get(b"Dest").and_then(|dest| explicit_dest(doc, targets, dest, 0))
|
||||
&& page_of_explicit(targets, &dest).is_some()
|
||||
{
|
||||
return Some(page);
|
||||
return Some(dest);
|
||||
}
|
||||
let action = doc.resolve(d.get(b"A")?);
|
||||
let dest = action.as_dict()?.get(b"D")?;
|
||||
page_of_dest(doc, targets, dest, 0)
|
||||
explicit_dest(doc, targets, dest, 0)
|
||||
}
|
||||
|
||||
/// A bookmark's title as viewers show it: the decoded text, without NULs or surrounding space.
|
||||
@@ -281,11 +298,13 @@ fn title_of(doc: &Document, d: &Dict) -> 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)),
|
||||
let (title, open, dest) = match item.as_dict() {
|
||||
Some(d) => (title_of(doc, d), d.int(b"Count").is_some_and(|c| c > 0), item_dest(doc, targets, d)),
|
||||
None => (String::new(), false, None),
|
||||
};
|
||||
Bookmark { obj, path, title, open, page }
|
||||
let page = dest.as_deref().and_then(|d| page_of_explicit(targets, d));
|
||||
let view = dest.as_deref().filter(|_| page.is_some()).and_then(|d| view_of_explicit(doc, d));
|
||||
Bookmark { obj, path, title, open, page, view }
|
||||
}
|
||||
|
||||
/// Lists up to `limit` bookmarks from position `offset`, entering no level deeper than `max_depth`.
|
||||
@@ -345,7 +364,7 @@ fn resolve_path(doc: &Document, path: &[usize]) -> Result<(ObjRef, ObjRef)> {
|
||||
}
|
||||
|
||||
/// The object that holds children at `parent_path` (`[]` = the outline root, created if needed).
|
||||
fn container(doc: &mut Document, parent_path: &[usize]) -> Result<ObjRef> {
|
||||
pub(crate) fn container(doc: &mut Document, parent_path: &[usize]) -> Result<ObjRef> {
|
||||
if parent_path.is_empty() {
|
||||
if let Some(r) = outline_root(doc) {
|
||||
return Ok(r);
|
||||
@@ -375,6 +394,27 @@ fn put(doc: &mut Document, r: ObjRef, key: &[u8], value: Option<Object>) -> Resu
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The children of `parent` (an item or the outline root), in order. Cycle-safe.
|
||||
pub(crate) fn children(doc: &Document, parent: ObjRef) -> Vec<ObjRef> {
|
||||
children_of(doc, parent, &mut HashSet::from([parent]))
|
||||
}
|
||||
|
||||
/// [`relink`] `kids` under `parent`, then recompute every `/Count`.
|
||||
pub(crate) fn relink_and_recount(doc: &mut Document, parent: ObjRef, kids: &[ObjRef]) -> Result<()> {
|
||||
relink(doc, parent, kids)?;
|
||||
recount(doc)
|
||||
}
|
||||
|
||||
impl From<OutlineError> for OrganizeError {
|
||||
fn from(e: OutlineError) -> Self {
|
||||
match e {
|
||||
OutlineError::Organize(e) => e,
|
||||
OutlineError::Cos(e) => OrganizeError::Cos(e),
|
||||
other => OrganizeError::Invalid(other.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Make `kids` the children of `parent`, in order: rewires `/Parent`, `/Prev`, `/Next`,
|
||||
/// `/First` and `/Last`.
|
||||
fn relink(doc: &mut Document, parent: ObjRef, kids: &[ObjRef]) -> Result<()> {
|
||||
|
||||
@@ -93,7 +93,7 @@ pub(crate) fn carry(dst: &mut Document, src: Option<&Document>, standard: &Print
|
||||
let root = dst.root().ok_or(OrganizeError::NoPageTree)?;
|
||||
if let Some(intents) = &standard.intents {
|
||||
let src = src.ok_or_else(|| OrganizeError::Invalid("the output intents' source document was not kept".into()))?;
|
||||
let copied = crate::import::copy_object(dst, src, intents, map);
|
||||
let copied = crate::import::copy_object(dst, src, intents, map)?;
|
||||
dst.update_dict(root, |c| c.set(b"OutputIntents".to_vec(), copied))?;
|
||||
}
|
||||
let Some(version) = &standard.version else { return Ok(()) };
|
||||
|
||||
@@ -267,6 +267,56 @@ fn document_info_edits_round_trip_with_unicode() {
|
||||
assert_eq!(info(&doc, "Producer"), None);
|
||||
}
|
||||
|
||||
/// Page transitions (§12.4.4): written as `/Trans` and `/Dur` with only the entries the style
|
||||
/// reads and that differ from the defaults, removed again, checked before any page changes, and
|
||||
/// kept through a save.
|
||||
#[test]
|
||||
fn page_transitions_are_set_removed_and_checked() {
|
||||
let mut doc = open(fixture());
|
||||
let wipe = Transition { duration: 2.5, direction: Some(270), ..Transition::new("Wipe") };
|
||||
set_transition(&mut doc, &[0, 2], &TransitionSpec { transition: Some(wipe), display: Some(5.0) }).unwrap();
|
||||
let d = page_dict(&doc, 0);
|
||||
let t = d.get(b"Trans").and_then(|o| o.as_dict()).expect("/Trans").clone();
|
||||
assert_eq!(t.name(b"Type"), Some(&b"Trans"[..]));
|
||||
assert_eq!(t.name(b"S"), Some(&b"Wipe"[..]));
|
||||
assert_eq!(t.get(b"D"), Some(&Object::Real(2.5)));
|
||||
assert_eq!(t.get(b"Di"), Some(&Object::Int(270)));
|
||||
assert!(
|
||||
t.get(b"Dm").is_none() && t.get(b"M").is_none() && t.get(b"SS").is_none() && t.get(b"B").is_none(),
|
||||
"entries Wipe does not read stay out: {t:?}"
|
||||
);
|
||||
assert_eq!(d.get(b"Dur"), Some(&Object::Real(5.0)));
|
||||
assert!(page_dict(&doc, 1).get(b"Trans").is_none(), "page 2 was not asked for");
|
||||
// Fly with everything non-default; the defaults themselves are not written.
|
||||
let fly = Transition { style: "Fly".into(), duration: 1.0, horizontal: false, inward: false, direction: None, scale: 0.5, opaque: true };
|
||||
set_transition(&mut doc, &[1], &TransitionSpec { transition: Some(fly), display: None }).unwrap();
|
||||
let t = page_dict(&doc, 1).get(b"Trans").and_then(|o| o.as_dict()).expect("/Trans").clone();
|
||||
assert_eq!((t.name(b"S"), t.name(b"M"), t.name(b"Di")), (Some(&b"Fly"[..]), Some(&b"O"[..]), Some(&b"None"[..])));
|
||||
assert_eq!((t.get(b"SS"), t.get(b"B")), (Some(&Object::Real(0.5)), Some(&Object::Bool(true))));
|
||||
assert!(t.get(b"D").is_none() && t.get(b"Dm").is_none(), "{t:?}");
|
||||
// Removed again.
|
||||
set_transition(&mut doc, &[0], &TransitionSpec { transition: None, display: None }).unwrap();
|
||||
let d = page_dict(&doc, 0);
|
||||
assert!(d.get(b"Trans").is_none() && d.get(b"Dur").is_none());
|
||||
// Checked before anything changes.
|
||||
for (what, spec) in [
|
||||
("style", TransitionSpec { transition: Some(Transition::new("Swirl")), display: None }),
|
||||
("direction", TransitionSpec { transition: Some(Transition { direction: Some(45), ..Transition::new("Wipe") }), display: None }),
|
||||
("duration", TransitionSpec { transition: Some(Transition { duration: -1.0, ..Transition::new("Fade") }), display: None }),
|
||||
("scale", TransitionSpec { transition: Some(Transition { scale: 0.0, ..Transition::new("Fly") }), display: None }),
|
||||
("display", TransitionSpec { transition: None, display: Some(f32::NAN) }),
|
||||
] {
|
||||
assert!(set_transition(&mut doc, &[2], &spec).is_err(), "{what} must be refused");
|
||||
}
|
||||
assert!(set_transition(&mut doc, &[7], &TransitionSpec { transition: None, display: None }).is_err(), "no such page");
|
||||
let t = page_dict(&doc, 2).get(b"Trans").and_then(|o| o.as_dict()).expect("page 3 unchanged").clone();
|
||||
assert_eq!(t.name(b"S"), Some(&b"Wipe"[..]));
|
||||
// Kept through a save.
|
||||
let doc = save_and_reopen(&doc);
|
||||
// (A whole number of seconds may come back as an integer: the writer's choice.)
|
||||
assert_eq!(page_dict(&doc, 2).get(b"Dur").and_then(Object::as_f64), Some(5.0));
|
||||
}
|
||||
|
||||
/// A one-page document whose catalog has `xmp` as its (Flate-compressed) XMP packet, and the
|
||||
/// same title and author in `/Info`, as LibreOffice writes them.
|
||||
fn doc_with_xmp(xmp: &[u8]) -> Document {
|
||||
@@ -1052,6 +1102,65 @@ fn combine_nests_source_bookmarks_and_keeps_attachments() {
|
||||
assert_eq!(keys, ["notes.txt", "notes.txt (2)"]);
|
||||
}
|
||||
|
||||
/// Insert Pages from File brings the inserted file's bookmarks along (they used to be dropped),
|
||||
/// collapsed under one bookmark named after the file at its first inserted page, placed among the
|
||||
/// host's bookmarks where the pages went. Destinations follow the copies; named ones are resolved.
|
||||
#[test]
|
||||
fn inserting_a_file_brings_its_bookmarks_under_one_named_after_it() {
|
||||
let mut host = open(fixture());
|
||||
add_bookmark(&mut host, &[], 0, "Start", 0).unwrap();
|
||||
add_bookmark(&mut host, &[], 1, "End", 2).unwrap();
|
||||
insert_file_pages(&mut host, &doc_c(), "annex", &[0, 1], 1, FieldConflicts::Rename).unwrap();
|
||||
let host = save_and_reopen(&host);
|
||||
assert_eq!(labels(&host), ["Page 1", "C1", "C2", "Page 2", "Page 3"]);
|
||||
let marks = bookmarks(&host);
|
||||
assert_eq!(titles(&marks), ["Start", "annex[Chapter one[Section]]", "End"]);
|
||||
let page_of = |title: &str| marks.iter().find(|b| b.title == title).and_then(|b| b.page);
|
||||
assert_eq!((page_of("annex"), page_of("Chapter one"), page_of("Section"), page_of("End")), (Some(1), Some(1), Some(2), Some(4)));
|
||||
assert!(!marks.iter().find(|b| b.title == "annex").unwrap().open, "the file's bookmarks start collapsed");
|
||||
// Inserted last, after every bookmark; only the pages chosen keep a destination.
|
||||
let mut host = open(fixture());
|
||||
add_bookmark(&mut host, &[], 0, "Start", 0).unwrap();
|
||||
insert_file_pages(&mut host, &doc_c(), "annex", &[0], 9, FieldConflicts::Rename).unwrap();
|
||||
let marks = bookmarks(&host);
|
||||
assert_eq!(titles(&marks), ["Start", "annex[Chapter one[Section]]"]);
|
||||
assert_eq!(marks.iter().find(|b| b.title == "Section").unwrap().page, None, "its page was left out");
|
||||
// A file without bookmarks adds none, and a host without an outline gets one only when needed.
|
||||
let mut host = open(fixture());
|
||||
insert_file_pages(&mut host, &doc_b(), "plain", &[0], 0, FieldConflicts::Rename).unwrap();
|
||||
assert!(bookmarks(&host).is_empty());
|
||||
assert!(!catalog(&host).contains(b"Outlines"));
|
||||
}
|
||||
|
||||
/// Bookmarks say where on their page they go, through every kind of destination: explicit,
|
||||
/// named (resolved), and a GoTo action's.
|
||||
#[test]
|
||||
fn bookmarks_report_the_view_their_destination_asks_for() {
|
||||
let mut objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /Outlines 5 0 R /Names << /Dests << /Names [(sec) [4 0 R /FitH 300]] >> >> >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 400] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R >>".into(),
|
||||
"<< /Type /Outlines /First 6 0 R /Last 9 0 R /Count 4 >>".into(),
|
||||
];
|
||||
objs.push("<< /Title (Explicit) /Parent 5 0 R /Next 7 0 R /Dest [4 0 R /XYZ 20 250 null] >>".into()); // 6
|
||||
objs.push("<< /Title (Named) /Parent 5 0 R /Prev 6 0 R /Next 8 0 R /Dest (sec) >>".into()); // 7
|
||||
objs.push("<< /Title (Action) /Parent 5 0 R /Prev 7 0 R /Next 9 0 R /A << /S /GoTo /D [3 0 R /FitR 10 20 110 (x) 999] >> >>".into()); // 8
|
||||
objs.push("<< /Title (Nowhere) /Parent 5 0 R /Prev 8 0 R /Dest (missing) >>".into()); // 9
|
||||
let doc = open(build(&objs, "/Root 1 0 R"));
|
||||
type View = Option<(Vec<u8>, Vec<Option<f64>>)>;
|
||||
let views: Vec<(String, Option<usize>, View)> = bookmarks(&doc).into_iter().map(|b| (b.title, b.page, b.view)).collect();
|
||||
assert_eq!(
|
||||
views,
|
||||
[
|
||||
("Explicit".into(), Some(1), Some((b"XYZ".to_vec(), vec![Some(20.0), Some(250.0), None]))),
|
||||
("Named".into(), Some(1), Some((b"FitH".to_vec(), vec![Some(300.0)]))),
|
||||
("Action".into(), Some(0), Some((b"FitR".to_vec(), vec![Some(10.0), Some(20.0), Some(110.0), None]))),
|
||||
("Nowhere".into(), None, None),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combine_and_insert_share_identical_resources() {
|
||||
let a = doc_a();
|
||||
@@ -1392,6 +1501,38 @@ fn number_pages_like_acrobat() {
|
||||
assert!(d.get(d.root().unwrap()).as_dict().unwrap().get(b"PageLabels").is_none());
|
||||
}
|
||||
|
||||
/// Deleting pages moves the label ranges with the pages they label (#2091): later ranges start that
|
||||
/// many pages earlier and keep numbering on, a range whose first page went starts on the next page
|
||||
/// left, and a range left with no pages gives way to the one after it.
|
||||
#[test]
|
||||
fn deleting_pages_keeps_the_label_ranges_on_their_pages() {
|
||||
use crate::{LabelRange, LabelStyle::*};
|
||||
let ten = || {
|
||||
let mut d = doc_a();
|
||||
for at in 3..10 {
|
||||
crate::insert_blank_page(&mut d, at, 300.0, 400.0).unwrap();
|
||||
}
|
||||
let ranges = [
|
||||
LabelRange { start: 0, style: LowerRoman, prefix: String::new(), first: 1 },
|
||||
LabelRange { start: 3, style: Decimal, prefix: String::new(), first: 1 },
|
||||
LabelRange { start: 7, style: Decimal, prefix: "A-".into(), first: 1 },
|
||||
];
|
||||
crate::set_page_label_ranges(&mut d, &ranges).unwrap();
|
||||
assert_eq!(crate::page_labels(&d).unwrap(), ["i", "ii", "iii", "1", "2", "3", "4", "A-1", "A-2", "A-3"]);
|
||||
d
|
||||
};
|
||||
for (deleted, expected) in [
|
||||
(&[4, 5][..], &["i", "ii", "iii", "1", "2", "A-1", "A-2", "A-3"][..]),
|
||||
(&[3], &["i", "ii", "iii", "1", "2", "3", "A-1", "A-2", "A-3"]),
|
||||
(&[3, 4, 5, 6], &["i", "ii", "iii", "A-1", "A-2", "A-3"]),
|
||||
] {
|
||||
let mut d = ten();
|
||||
crate::delete_pages(&mut d, deleted).unwrap();
|
||||
assert_eq!(crate::page_labels(&d).unwrap(), expected, "after deleting {deleted:?}");
|
||||
assert_eq!(crate::page_labels(&full_roundtrip(&d)).unwrap(), expected, "reopened after deleting {deleted:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_label_limits_reject_unsupported_edits_before_mutating() {
|
||||
use crate::LabelStyle::*;
|
||||
@@ -1520,6 +1661,38 @@ fn page_label_limits_validate_the_same_ranges_that_are_written() {
|
||||
assert_eq!(nums[2].as_int(), Some(1));
|
||||
}
|
||||
|
||||
/// Each label dictionary is its own object, referenced from `/Nums`, as Acrobat writes them. A
|
||||
/// direct dictionary is just as valid, but some readers (pdfannots2json, through unipdf) skip it
|
||||
/// and fall back to physical page numbers (#1763).
|
||||
#[test]
|
||||
fn page_labels_are_written_as_indirect_dictionaries() {
|
||||
use crate::{LabelRange, LabelStyle};
|
||||
let mut d = doc_a();
|
||||
let ranges = [
|
||||
LabelRange { start: 0, style: LabelStyle::LowerRoman, prefix: String::new(), first: 1 },
|
||||
LabelRange { start: 1, style: LabelStyle::Decimal, prefix: "A-".into(), first: 324 },
|
||||
LabelRange { start: 2, style: LabelStyle::None, prefix: "Cover".into(), first: 1 },
|
||||
];
|
||||
crate::set_page_label_ranges(&mut d, &ranges).unwrap();
|
||||
let back = full_roundtrip(&d);
|
||||
let catalog = back.get(back.root().unwrap());
|
||||
let tree = back.resolve(catalog.as_dict().unwrap().get(b"PageLabels").unwrap());
|
||||
let nums = tree.as_dict().unwrap().get(b"Nums").unwrap().as_array().unwrap();
|
||||
assert_eq!(nums.len(), 6);
|
||||
let expected = [(0, Some(b"r".as_slice()), None, None), (1, Some(b"D".as_slice()), Some("A-"), Some(324)), (2, None, Some("Cover"), None)];
|
||||
for (pair, (start, style, prefix, first)) in nums.chunks(2).zip(expected) {
|
||||
assert_eq!(pair[0].as_int(), Some(start));
|
||||
let Object::Ref(r) = &pair[1] else { panic!("the label of page {start} is not an indirect object: {:?}", pair[1]) };
|
||||
let spec = back.get(*r);
|
||||
let spec = spec.as_dict().unwrap();
|
||||
assert_eq!(spec.name(b"S"), style);
|
||||
assert_eq!(spec.get(b"P").and_then(|p| p.as_string()).map(|p| p.to_text()).as_deref(), prefix);
|
||||
assert_eq!(spec.int(b"St"), first);
|
||||
}
|
||||
assert_eq!(crate::page_label_ranges(&back), ranges);
|
||||
assert_eq!(crate::page_labels(&back).unwrap(), ["i", "A-324", "Cover"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_label_duplicate_starts_keep_the_later_range() {
|
||||
let mut d = doc_a();
|
||||
@@ -2444,3 +2617,43 @@ fn combine_field_conflicts_previews_each_file_in_order() {
|
||||
assert_eq!(preview[1], [("Text1".to_string(), "Text1-1".to_string())]);
|
||||
assert_eq!(preview[2], [("Text1".to_string(), "Text1-2".to_string())], "the cascade continues past the earlier rename");
|
||||
}
|
||||
|
||||
/// #1800: thousands of shallow objects, each referring to the next, used to recurse once per
|
||||
/// object and exhaust the stack. The copy is refused instead, and the destination is as it was;
|
||||
/// a chain within the budget and a short cycle copy as before.
|
||||
#[test]
|
||||
fn a_chain_of_objects_too_deep_to_copy_is_refused_and_leaves_the_destination_alone() {
|
||||
fn chained(tail: &[String]) -> Document {
|
||||
let mut b: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Foo 6 0 R >>".into(),
|
||||
body("C1"),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
|
||||
];
|
||||
b.extend_from_slice(tail);
|
||||
open(build(&b, "/Root 1 0 R"))
|
||||
}
|
||||
fn chain(n: usize) -> Document {
|
||||
let mut tail: Vec<String> = (0..n).map(|i| format!("<< /Next {} 0 R >>", 7 + i)).collect();
|
||||
tail.push("<< /End true >>".into());
|
||||
chained(&tail)
|
||||
}
|
||||
// On a thread with less stack than any platform's main thread, so the budget is proven.
|
||||
let run = std::thread::Builder::new()
|
||||
.stack_size(512 << 10)
|
||||
.spawn(|| {
|
||||
let mut dst = open(fixture());
|
||||
let before = (labels(&dst), dst.object_numbers());
|
||||
let err = import_pages(&mut dst, &chain(20_000), &[0], 0).unwrap_err();
|
||||
assert!(matches!(&err, OrganizeError::Invalid(m) if m.contains("levels deep")), "{err:?}");
|
||||
assert_eq!((labels(&dst), dst.object_numbers()), before, "the destination is as it was");
|
||||
import_pages(&mut dst, &chain(100), &[0], 0).unwrap();
|
||||
let cycle = chained(&["<< /Next 7 0 R >>".into(), "<< /Next 6 0 R >>".into()]);
|
||||
import_pages(&mut dst, &cycle, &[0], 0).unwrap();
|
||||
assert_eq!(labels(&dst)[..3], ["C1", "C1", "Page 1"]);
|
||||
})
|
||||
.unwrap()
|
||||
.join();
|
||||
assert!(run.is_ok(), "the copy must not overflow the stack");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Page transitions and display times (ISO 32000-2 §12.4.4): the `/Trans` dictionary and `/Dur`
|
||||
//! entry a full-screen presentation plays, as Acrobat's Organize ▸ Page Transitions sets them.
|
||||
//! Reading them back is the renderer's `DocInfo::transitions`.
|
||||
|
||||
use pdfcraft_cos::{Dict, Document, Object};
|
||||
|
||||
use crate::{OrganizeError, check, walk};
|
||||
|
||||
/// The transition styles of ISO 32000-2 table 164, as written in `/S` (`R` is "replace": no
|
||||
/// effect).
|
||||
pub const TRANSITION_STYLES: [&str; 12] = ["Split", "Blinds", "Box", "Wipe", "Dissolve", "Glitter", "R", "Fly", "Push", "Cover", "Uncover", "Fade"];
|
||||
|
||||
/// The directions `/Di` allows, in degrees.
|
||||
pub const TRANSITION_DIRECTIONS: [u16; 5] = [0, 90, 180, 270, 315];
|
||||
|
||||
/// A page transition to write: the `/Trans` dictionary's entries, with the spec's defaults.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Transition {
|
||||
/// One of [`TRANSITION_STYLES`].
|
||||
pub style: String,
|
||||
/// `/D`: seconds the transition takes (default 1).
|
||||
pub duration: f32,
|
||||
/// `/Dm`: horizontal (`/H`, the default) or vertical (`/V`); Split and Blinds.
|
||||
pub horizontal: bool,
|
||||
/// `/M`: inward (`/I`, the default) or outward (`/O`); Split, Box and Fly.
|
||||
pub inward: bool,
|
||||
/// `/Di`: one of [`TRANSITION_DIRECTIONS`] (default 0), or `None` for `/None` (Fly only);
|
||||
/// Wipe, Glitter, Fly, Cover, Uncover and Push.
|
||||
pub direction: Option<u16>,
|
||||
/// `/SS`: the Fly scale (default 1).
|
||||
pub scale: f32,
|
||||
/// `/B`: Fly with an opaque backdrop.
|
||||
pub opaque: bool,
|
||||
}
|
||||
|
||||
impl Transition {
|
||||
/// A transition of `style` with every other entry at its default.
|
||||
pub fn new(style: &str) -> Self {
|
||||
Self { style: style.to_string(), duration: 1.0, horizontal: true, inward: true, direction: Some(0), scale: 1.0, opaque: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// What a page gets: its transition (`None` removes `/Trans`) and the seconds it stays before the
|
||||
/// presentation advances (`None` removes `/Dur`: the page waits for the presenter).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TransitionSpec {
|
||||
pub transition: Option<Transition>,
|
||||
pub display: Option<f32>,
|
||||
}
|
||||
|
||||
/// Set (or remove) the transition and display time of `pages` (0-based). Every value is checked
|
||||
/// before any page changes: an unknown style, a direction that is not one of the five, a negative
|
||||
/// or non-finite duration, scale or display time are refused.
|
||||
pub fn set_transition(doc: &mut Document, pages: &[usize], spec: &TransitionSpec) -> Result<(), OrganizeError> {
|
||||
let bad = |m: String| OrganizeError::Invalid(m);
|
||||
if let Some(t) = &spec.transition {
|
||||
if !TRANSITION_STYLES.contains(&t.style.as_str()) {
|
||||
return Err(bad(format!("unknown transition style {:?}; one of {}", t.style, TRANSITION_STYLES.join(", "))));
|
||||
}
|
||||
if !(t.duration.is_finite() && t.duration >= 0.0) {
|
||||
return Err(bad("the transition duration must be zero or more seconds".into()));
|
||||
}
|
||||
if let Some(d) = t.direction
|
||||
&& !TRANSITION_DIRECTIONS.contains(&d)
|
||||
{
|
||||
return Err(bad(format!("the direction must be one of {:?} degrees", TRANSITION_DIRECTIONS)));
|
||||
}
|
||||
if !(t.scale.is_finite() && t.scale > 0.0) {
|
||||
return Err(bad("the Fly scale must be more than zero".into()));
|
||||
}
|
||||
}
|
||||
if let Some(s) = spec.display
|
||||
&& !(s.is_finite() && s >= 0.0)
|
||||
{
|
||||
return Err(bad("the display time must be zero or more seconds".into()));
|
||||
}
|
||||
let all = walk(doc)?;
|
||||
check(pages, all.len())?;
|
||||
let trans = spec.transition.as_ref().map(trans_dict);
|
||||
for &i in pages {
|
||||
let Some((page, _)) = all.get(i) else { continue };
|
||||
let trans = trans.clone();
|
||||
doc.update_dict(*page, |d| {
|
||||
match trans {
|
||||
Some(t) => d.set(b"Trans".to_vec(), Object::Dict(t)),
|
||||
None => {
|
||||
d.remove(b"Trans");
|
||||
}
|
||||
}
|
||||
match spec.display {
|
||||
Some(s) => d.set(b"Dur".to_vec(), Object::Real(f64::from(s))),
|
||||
None => {
|
||||
d.remove(b"Dur");
|
||||
}
|
||||
}
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The `/Trans` dictionary for `t`: only the entries that differ from the defaults, and only the
|
||||
/// ones the style reads (table 164), so the file says no more than it means.
|
||||
fn trans_dict(t: &Transition) -> Dict {
|
||||
let style = t.style.as_str();
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("Trans"));
|
||||
d.set(b"S".to_vec(), Object::name(style));
|
||||
if t.duration != 1.0 {
|
||||
d.set(b"D".to_vec(), Object::Real(f64::from(t.duration)));
|
||||
}
|
||||
if matches!(style, "Split" | "Blinds") && !t.horizontal {
|
||||
d.set(b"Dm".to_vec(), Object::name("V"));
|
||||
}
|
||||
if matches!(style, "Split" | "Box" | "Fly") && !t.inward {
|
||||
d.set(b"M".to_vec(), Object::name("O"));
|
||||
}
|
||||
if matches!(style, "Wipe" | "Glitter" | "Fly" | "Cover" | "Uncover" | "Push") {
|
||||
match t.direction {
|
||||
None if style == "Fly" => d.set(b"Di".to_vec(), Object::name("None")),
|
||||
Some(deg) if deg != 0 => d.set(b"Di".to_vec(), Object::Int(i64::from(deg))),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if style == "Fly" {
|
||||
if t.scale != 1.0 {
|
||||
d.set(b"SS".to_vec(), Object::Real(f64::from(t.scale)));
|
||||
}
|
||||
if t.opaque {
|
||||
d.set(b"B".to_vec(), Object::Bool(true));
|
||||
}
|
||||
}
|
||||
d
|
||||
}
|
||||
@@ -10,6 +10,10 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# `write_atomically`: a volume that refuses F_FULLFSYNC gets a plain fsync.
|
||||
rustix = { version = "1", features = ["fs"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Already in the dependency graph through the accessibility integration. The portal watcher
|
||||
# uses the async API so dropping it can cancel an idle subscription without polling.
|
||||
|
||||
@@ -64,6 +64,62 @@ pub fn staging_suffixes() -> impl Iterator<Item = u64> {
|
||||
(0u64..).map(move |i| state.hash_one(i))
|
||||
}
|
||||
|
||||
/// Write `bytes` to `path` through a staging file beside it (see [`create_staging`]; `form`
|
||||
/// names it as the caller always has), flushed to the disk and then renamed over the target: a
|
||||
/// crash, a full disk or a refused flush never leaves a half-written file where a good one was,
|
||||
/// and the target is untouched until the new file is complete. The target's folder must exist;
|
||||
/// a folder at the target is refused by the rename. Flushing is the staging file's own
|
||||
/// (`sync_all`); parent-directory durability after a power loss is not promised.
|
||||
pub fn write_atomically(path: &Path, bytes: &[u8], form: StagingName) -> std::io::Result<()> {
|
||||
write_atomically_with(path, bytes, form, staging_suffixes(), std::fs::File::sync_all)
|
||||
}
|
||||
|
||||
/// [`write_atomically`] with the staging-name suffixes and the flush given, so tests can plant
|
||||
/// names and refuse flushes. A failure at any step removes the staging file and leaves the
|
||||
/// target as it was.
|
||||
pub fn write_atomically_with(
|
||||
path: &Path,
|
||||
bytes: &[u8],
|
||||
form: StagingName,
|
||||
suffixes: impl IntoIterator<Item = u64>,
|
||||
sync_all: impl FnOnce(&std::fs::File) -> std::io::Result<()>,
|
||||
) -> std::io::Result<()> {
|
||||
use std::io::Write as _;
|
||||
let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new("."));
|
||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("save");
|
||||
let (tmp, file) = create_staging(dir, name, form, suffixes)?;
|
||||
let result = (|| {
|
||||
// Closed at the end of the block, before the rename (required on Windows).
|
||||
{
|
||||
let mut file = file;
|
||||
file.write_all(bytes)?;
|
||||
sync_staged(&file, sync_all)?;
|
||||
}
|
||||
std::fs::rename(&tmp, path)
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Flush the staging file to the disk before it replaces the target. On macOS `sync_all` asks
|
||||
/// for a full flush (`F_FULLFSYNC`), which SMB and FUSE volumes, such as a mounted Cryptomator
|
||||
/// vault, refuse as unsupported: there a plain `fsync` is the strongest flush the volume offers.
|
||||
/// Any other failure still fails the write.
|
||||
fn sync_staged(f: &std::fs::File, sync_all: impl FnOnce(&std::fs::File) -> std::io::Result<()>) -> std::io::Result<()> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
use rustix::io::Errno;
|
||||
match sync_all(f) {
|
||||
Err(e) if matches!(Errno::from_io_error(&e), Some(Errno::NOTSUP | Errno::NOTTY)) => Ok(rustix::fs::fsync(f)?),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
sync_all(f)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -126,4 +182,37 @@ mod tests {
|
||||
assert!(!name(STAGING_ATTEMPTS as u64 + 1).exists(), "a 17th name was tried");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `write_atomically`: the target holds the new bytes only once they are complete; a refused
|
||||
/// flush leaves the old file and removes the staging file; a missing folder writes nothing.
|
||||
#[test]
|
||||
fn an_atomic_write_replaces_the_target_only_when_complete() {
|
||||
let dir = test_dir("write");
|
||||
let target = dir.join("out.pdf");
|
||||
std::fs::write(&target, b"OLD").unwrap();
|
||||
let names = |dir: &Path| {
|
||||
let mut v: Vec<String> = std::fs::read_dir(dir).unwrap().map(|e| file_name(&e.unwrap().path())).collect();
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
// The staging file is written and flushed before the target changes.
|
||||
let mut seen_old = false;
|
||||
write_atomically_with(&target, b"NEW", StagingName::SuffixThenTag, [7], |f| {
|
||||
seen_old = std::fs::read(&target).unwrap() == b"OLD" && f.metadata().unwrap().len() == 3;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(seen_old, "flushed before publication, with the target still the old file");
|
||||
assert_eq!(std::fs::read(&target).unwrap(), b"NEW");
|
||||
assert_eq!(names(&dir), ["out.pdf"], "no staging file is left");
|
||||
// A refused flush keeps the target and cleans up.
|
||||
let e = write_atomically_with(&target, b"NEWER", StagingName::TagThenSuffix, [8], |_| Err(std::io::Error::other("refused"))).unwrap_err();
|
||||
assert!(e.to_string().contains("refused"));
|
||||
assert_eq!(std::fs::read(&target).unwrap(), b"NEW");
|
||||
assert_eq!(names(&dir), ["out.pdf"]);
|
||||
// A folder that does not exist: nothing is written anywhere.
|
||||
assert!(write_atomically(&dir.join("missing").join("out.pdf"), b"X", StagingName::SuffixThenTag).is_err());
|
||||
assert_eq!(names(&dir), ["out.pdf"]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,5 +11,6 @@ repository.workspace = true
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
pdfcraft-content = { workspace = true }
|
||||
pdfcraft-cos = { workspace = true }
|
||||
pdfcraft-model = { workspace = true }
|
||||
|
||||
+120
-14
@@ -24,7 +24,7 @@
|
||||
pub mod icc;
|
||||
mod xmp;
|
||||
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream, is_whitespace};
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
|
||||
|
||||
/// The PDF/A part and conformance level.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -255,12 +255,124 @@ impl DeviceColour {
|
||||
fn any(self) -> bool {
|
||||
self.gray || self.rgb || self.cmyk
|
||||
}
|
||||
|
||||
/// Note the device colour space `name`, if it is one. Inline images may also use the
|
||||
/// abbreviations (`G`, `RGB`, `CMYK`); anywhere else those are resource names.
|
||||
fn mark(&mut self, name: &[u8], inline: bool) {
|
||||
match name {
|
||||
b"DeviceGray" => self.gray = true,
|
||||
b"DeviceRGB" => self.rgb = true,
|
||||
b"DeviceCMYK" => self.cmyk = true,
|
||||
b"G" if inline => self.gray = true,
|
||||
b"RGB" if inline => self.rgb = true,
|
||||
b"CMYK" if inline => self.cmyk = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How deep [`type3_procs`] looks into an object's direct dictionaries and arrays.
|
||||
const MAX_DIRECT_DEPTH: u8 = 8;
|
||||
|
||||
/// The glyph procedures of every Type 3 font in `d`: `d` itself, or a font held directly inside
|
||||
/// it (a font can sit inline in a resource dictionary).
|
||||
fn type3_procs(doc: &Document, d: &Dict, depth: u8, out: &mut std::collections::HashSet<ObjRef>) {
|
||||
if d.name(b"Subtype") == Some(b"Type3")
|
||||
&& let Some(procs) = d.get(b"CharProcs").map(|c| doc.resolve(c))
|
||||
&& let Some(procs) = procs.as_dict()
|
||||
{
|
||||
out.extend(procs.iter().filter_map(|(_, p)| p.as_ref()));
|
||||
}
|
||||
for (_, v) in d.iter() {
|
||||
type3_procs_within(doc, v, depth.saturating_add(1), out);
|
||||
}
|
||||
}
|
||||
|
||||
fn type3_procs_within(doc: &Document, o: &Object, depth: u8, out: &mut std::collections::HashSet<ObjRef>) {
|
||||
if depth > MAX_DIRECT_DEPTH {
|
||||
return;
|
||||
}
|
||||
match o {
|
||||
Object::Dict(d) => type3_procs(doc, d, depth, out),
|
||||
Object::Array(a) => {
|
||||
for v in a {
|
||||
type3_procs_within(doc, v, depth.saturating_add(1), out);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The content streams that only a reference identifies, their dictionaries saying nothing: the
|
||||
/// pages' `/Contents` and Type 3 glyph procedures.
|
||||
fn referenced_content(doc: &Document, objs: &[(ObjRef, std::sync::Arc<Object>)]) -> std::collections::HashSet<ObjRef> {
|
||||
let mut out = std::collections::HashSet::new();
|
||||
for p in pdfcraft_model::pages(doc) {
|
||||
let contents = match p.dict.get(b"Contents") {
|
||||
Some(Object::Ref(r)) => match &*doc.get(*r) {
|
||||
Object::Array(a) => a.clone(),
|
||||
_ => vec![Object::Ref(*r)],
|
||||
},
|
||||
Some(Object::Array(a)) => a.clone(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
out.extend(contents.iter().filter_map(Object::as_ref));
|
||||
}
|
||||
for (_, o) in objs {
|
||||
if let Some(d) = dict_of(o) {
|
||||
type3_procs(doc, d, 0, &mut out);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether a stream's own dictionary says it holds content: a form XObject, or a stream with a
|
||||
/// `/BBox` and no `/Subtype` (a tiling pattern, or an appearance stream that leaves out
|
||||
/// `/Subtype /Form`). Mesh shadings may carry a `/BBox` too, but their data isn't content.
|
||||
fn is_content(d: &Dict) -> bool {
|
||||
match d.name(b"Subtype") {
|
||||
Some(t) => t == b"Form",
|
||||
None => d.get(b"BBox").is_some() && d.get(b"ShadingType").is_none(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The device colour a content stream sets. It is read operator by operator, so the bytes of a
|
||||
/// string, a comment or an inline image are never taken for an operator, and an operator right
|
||||
/// before a delimiter (`1 k/GS0 gs`) is still found.
|
||||
fn content_colour(data: &[u8], used: &mut DeviceColour) {
|
||||
for op in pdfcraft_content::parse(data).ops {
|
||||
match op.op.as_slice() {
|
||||
b"g" | b"G" => used.gray = true,
|
||||
b"rg" | b"RG" => used.rgb = true,
|
||||
b"k" | b"K" => used.cmyk = true,
|
||||
b"cs" | b"CS" => {
|
||||
if let Some(name) = op.name(0) {
|
||||
used.mark(name, false);
|
||||
}
|
||||
}
|
||||
b"BI" => {
|
||||
let Some((image, _)) = &op.inline else { continue };
|
||||
match image.get(b"CS").or_else(|| image.get(b"ColorSpace")) {
|
||||
Some(Object::Name(n)) => used.mark(n, true),
|
||||
// An indexed space draws in its base: `[/I /DeviceRGB 1 <…>]`.
|
||||
Some(Object::Array(a)) if a.first().and_then(Object::as_name).is_some_and(|n| n == b"I" || n == b"Indexed") => {
|
||||
if let Some(base) = a.get(1).and_then(Object::as_name) {
|
||||
used.mark(base, true);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any content or object uses a device colour space.
|
||||
fn uses_device_colour(doc: &Document, objs: &[(ObjRef, std::sync::Arc<Object>)]) -> DeviceColour {
|
||||
let mut used = DeviceColour::default();
|
||||
for (_, o) in objs {
|
||||
let referenced = referenced_content(doc, objs);
|
||||
for (r, o) in objs {
|
||||
let Some(d) = dict_of(o) else { continue };
|
||||
// A resource dictionary's /ColorSpace, whether it is this object or the page's inline
|
||||
// /Resources.
|
||||
@@ -281,20 +393,14 @@ fn uses_device_colour(doc: &Document, objs: &[(ObjRef, std::sync::Arc<Object>)])
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Object::Stream(s) = &**o
|
||||
&& d.get(b"Type").is_none()
|
||||
&& d.get(b"Subtype").is_none_or(|t| t.as_name() == Some(b"Form"))
|
||||
// Content streams only: a font program or an ICC profile is data, whatever bytes it holds.
|
||||
// Parsing is the expensive part, so once content has used all three spaces no more is read.
|
||||
if !(used.gray && used.rgb && used.cmyk)
|
||||
&& let Object::Stream(s) = &**o
|
||||
&& (referenced.contains(r) || is_content(&s.dict))
|
||||
&& let Ok(data) = s.decoded()
|
||||
{
|
||||
// Content streams: rg/RG/g/G/k/K operators.
|
||||
for token in data.split(|b| is_whitespace(*b)) {
|
||||
match token {
|
||||
b"g" | b"G" => used.gray = true,
|
||||
b"rg" | b"RG" => used.rgb = true,
|
||||
b"k" | b"K" => used.cmyk = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
content_colour(&data, &mut used);
|
||||
}
|
||||
}
|
||||
used
|
||||
|
||||
@@ -250,3 +250,124 @@ fn device_colour_is_found_after_any_whitespace() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A page that draws `content` with `resources`, followed by `extra` objects (from object 5 on).
|
||||
fn drawing(content: &str, resources: &str, extra: &[&str]) -> Document {
|
||||
let stream = format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len());
|
||||
let page = format!("<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << {resources} >> >>");
|
||||
let mut objs = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>",
|
||||
page.as_str(),
|
||||
stream.as_str(),
|
||||
];
|
||||
objs.extend_from_slice(extra);
|
||||
build(&objs, "")
|
||||
}
|
||||
|
||||
fn uses_cmyk(doc: &Document) -> bool {
|
||||
messages(&verify(doc, Level::A2b)).contains(&"DeviceCMYK is used: it needs a CMYK output intent (not added automatically)")
|
||||
}
|
||||
|
||||
/// Issue #2259: a `k` is DeviceCMYK only when it is an operator. Inside a string, a comment, an inline image's
|
||||
/// data, a font program, an ICC profile or a mesh shading's data it is just a byte, and the
|
||||
/// document still gets its sRGB output intent.
|
||||
#[test]
|
||||
fn device_colour_is_read_from_operators_not_bytes() {
|
||||
let rgb = "1 0 0 rg 0 0 10 10 re f";
|
||||
let cases: &[(&str, String, &str, Vec<&str>)] = &[
|
||||
(
|
||||
"a string",
|
||||
format!("{rgb} BT /F1 12 Tf 10 10 Td (press k now) Tj ET"),
|
||||
"/Font << /F1 5 0 R >>",
|
||||
vec![
|
||||
"<< /Type /Font /Subtype /TrueType /BaseFont /Sans /FontDescriptor 6 0 R >>",
|
||||
"<< /Type /FontDescriptor /FontName /Sans /FontFile2 7 0 R >>",
|
||||
// A font program whose bytes read " k ", as Chromium and krilla write them.
|
||||
"<< /Length1 3 /Length 3 >>\nstream\n k \nendstream",
|
||||
],
|
||||
),
|
||||
("a comment", format!("{rgb} % 0 0 0 1 k"), "", vec![]),
|
||||
// A 3×1 gray image whose three bytes read " k ".
|
||||
("inline image data", format!("{rgb} q 10 0 0 10 0 0 cm BI /W 3 /H 1 /CS /G /BPC 8 ID k EI Q"), "", vec![]),
|
||||
("an ICC profile", rgb.to_owned(), "/ColorSpace << /CS0 [/ICCBased 5 0 R] >>", vec!["<< /N 3 /Length 3 >>\nstream\n k \nendstream"]),
|
||||
(
|
||||
"a mesh shading",
|
||||
format!("{rgb} /Sh0 sh"),
|
||||
"/Shading << /Sh0 5 0 R >>",
|
||||
vec![
|
||||
"<< /ShadingType 4 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [0 1 0 1 0 1 0 1 0 1] /BBox [0 0 10 10] /Length 3 >>\nstream\n k \nendstream",
|
||||
],
|
||||
),
|
||||
];
|
||||
for (what, content, resources, extra) in cases {
|
||||
let mut doc = drawing(content, resources, extra);
|
||||
assert!(!uses_cmyk(&doc), "a k in {what} was read as DeviceCMYK");
|
||||
let r = convert(&mut doc, Level::A2b).unwrap();
|
||||
assert_eq!(declared(&doc).output_intents, ["sRGB IEC61966-2.1"], "{what}: {:?}", r.fixed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #2259: DeviceCMYK is found however the content sets it: `k`/`K` right before a delimiter, `cs`/`CS`
|
||||
/// with the space's name, or an inline image in `/CMYK` or indexed over it.
|
||||
#[test]
|
||||
fn device_colour_is_found_however_content_sets_it() {
|
||||
for content in [
|
||||
"0 0 0 1 k/GS0 gs 10 10 50 50 re f",
|
||||
"0 0 0 1 K[]0 d 10 10 m 50 50 l S",
|
||||
"/DeviceCMYK cs 0 0 0 1 sc 10 10 50 50 re f",
|
||||
"/DeviceCMYK CS 0 0 0 1 SC 10 10 m 50 50 l S",
|
||||
"q 10 0 0 10 0 0 cm BI /W 1 /H 1 /CS /CMYK /BPC 8 ID \u{0}\u{0}\u{0}\u{0} EI Q",
|
||||
"q 10 0 0 10 0 0 cm BI /W 1 /H 1 /ColorSpace /DeviceCMYK /BitsPerComponent 8 ID \u{0}\u{0}\u{0}\u{0} EI Q",
|
||||
"q 10 0 0 10 0 0 cm BI /W 1 /H 1 /CS [/I /DeviceCMYK 0 <00000000>] /BPC 8 ID \u{0} EI Q",
|
||||
] {
|
||||
assert!(uses_cmyk(&drawing(content, "", &[])), "{content:?}");
|
||||
}
|
||||
// A resource named like a device space's abbreviation is a resource, not that space.
|
||||
let named =
|
||||
drawing("/CMYK cs 1 sc 10 10 50 50 re f", "/ColorSpace << /CMYK [/ICCBased 5 0 R] >>", &["<< /N 1 /Length 0 >>\nstream\n\nendstream"]);
|
||||
assert!(!uses_cmyk(&named));
|
||||
}
|
||||
|
||||
/// Issue #2259: content is not only the page's own stream: form XObjects (with `/Type /XObject`, as almost
|
||||
/// every writer gives them), appearance streams, tiling patterns and Type 3 glyphs draw too. A
|
||||
/// CMYK logo placed as a form must not get the document an sRGB output intent.
|
||||
#[test]
|
||||
fn device_colour_in_forms_patterns_and_glyphs_is_found() {
|
||||
let cmyk = "0.75 0 1 0 k 0 0 10 10 re f";
|
||||
let stream = |dict: &str| format!("<< {dict} /Length {} >>\nstream\n{cmyk}\nendstream", cmyk.len());
|
||||
let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 10 10]");
|
||||
let appearance = stream("/BBox [0 0 10 10]");
|
||||
let pattern = stream("/Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >>");
|
||||
let glyph = format!("<< /Length {} >>\nstream\n1 0 d0 {cmyk}\nendstream", cmyk.len() + 7);
|
||||
let cases: [(&str, Document); 4] = [
|
||||
("a form XObject", drawing("/Fm0 Do", "/XObject << /Fm0 5 0 R >>", &[&form])),
|
||||
(
|
||||
"an appearance stream without /Subtype",
|
||||
build(
|
||||
&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Square /Rect [0 0 10 10] /F 4 /P 3 0 R /AP << /N 5 0 R >> >>",
|
||||
&appearance,
|
||||
],
|
||||
"",
|
||||
),
|
||||
),
|
||||
("a tiling pattern", drawing("/Pattern cs /P0 scn 0 0 100 100 re f", "/Pattern << /P0 5 0 R >>", &[&pattern])),
|
||||
(
|
||||
"a Type 3 glyph in an inline font",
|
||||
drawing(
|
||||
"BT /T3 1 Tf (a) Tj ET",
|
||||
"/Font << /T3 << /Type /Font /Subtype /Type3 /FontBBox [0 0 10 10] /FontMatrix [1 0 0 1 0 0] /CharProcs << /a 5 0 R >> /Encoding << /Differences [97 /a] >> /FirstChar 97 /LastChar 97 /Widths [10] >> >>",
|
||||
&[&glyph],
|
||||
),
|
||||
),
|
||||
];
|
||||
for (what, mut doc) in cases {
|
||||
assert!(uses_cmyk(&doc), "DeviceCMYK in {what} wasn't found");
|
||||
let r = convert(&mut doc, Level::A2b).unwrap();
|
||||
assert!(declared(&doc).output_intents.is_empty(), "{what}: an sRGB output intent was added to a CMYK document: {:?}", r.fixed);
|
||||
}
|
||||
}
|
||||
|
||||
+27
-1
@@ -228,11 +228,37 @@ pub struct Settings {
|
||||
pub orientation: Orientation,
|
||||
pub layout: Layout,
|
||||
pub content: Content,
|
||||
/// Print as image (Acrobat's Advanced Print Setup): every page rendered to a picture at this
|
||||
/// resolution before it is placed on the paper. The engine does the rendering; `impose` is
|
||||
/// then given the image-only document.
|
||||
pub raster: Option<Raster>,
|
||||
}
|
||||
|
||||
/// How pages are rendered when printing as image.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Raster {
|
||||
/// Dots per inch, 72–600 (Acrobat offers 150, 300 and 600).
|
||||
pub dpi: f64,
|
||||
/// Render the pictures in grey, so the print-ready PDF itself is grayscale.
|
||||
pub grayscale: bool,
|
||||
}
|
||||
|
||||
impl Default for Raster {
|
||||
fn default() -> Self {
|
||||
Raster { dpi: 300.0, grayscale: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Settings { pages: Vec::new(), paper: PAPERS[0].1, orientation: Orientation::Auto, layout: Layout::default(), content: Content::default() }
|
||||
Settings {
|
||||
pages: Vec::new(),
|
||||
paper: PAPERS[0].1,
|
||||
orientation: Orientation::Auto,
|
||||
layout: Layout::default(),
|
||||
content: Content::default(),
|
||||
raster: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ pdfcraft-model = { workspace = true }
|
||||
pdfcraft-forms = { workspace = true }
|
||||
pdfcraft-edit = { workspace = true }
|
||||
pdfcraft-annot = { workspace = true }
|
||||
pdfcraft-xfa = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -23,9 +23,9 @@ fn components(doc: &Document, s: &Stream) -> Option<usize> {
|
||||
b"DeviceRGB" | b"RGB" | b"CalRGB" | b"Lab" => 3,
|
||||
b"DeviceCMYK" | b"CMYK" => 4,
|
||||
b"ICCBased" => {
|
||||
// /N lives in the ICC profile stream.
|
||||
// /N lives in the ICC profile stream, and shall be 1, 3 or 4 (ISO 32000-2 §8.6.5.5).
|
||||
match &*doc.resolve(cs.as_array()?.get(1)?) {
|
||||
Object::Stream(icc) => icc.dict.int(b"N")? as usize,
|
||||
Object::Stream(icc) => usize::try_from(icc.dict.int(b"N")?).ok().filter(|n| matches!(n, 1 | 3 | 4))?,
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
@@ -70,12 +70,18 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]
|
||||
if !matches!(bpc, 1 | 2 | 4 | 8 | 16) {
|
||||
return None;
|
||||
}
|
||||
// Every size comes from the file: multiply checked, so an overflowing product refuses the image
|
||||
// instead of sizing the writes below.
|
||||
let bits_per_pixel = ncomp.checked_mul(bpc)?;
|
||||
let row = w.checked_mul(bits_per_pixel)?.div_ceil(8);
|
||||
let bytes = row.checked_mul(h)?;
|
||||
let row_bits = row.checked_mul(8)?;
|
||||
bytes.checked_mul(8)?;
|
||||
let mut data = s.decoded().ok()?;
|
||||
let row = (w * ncomp * bpc).div_ceil(8);
|
||||
if data.len() < row * h {
|
||||
if data.len() < bytes {
|
||||
return None;
|
||||
}
|
||||
data.truncate(row * h);
|
||||
data.truncate(bytes);
|
||||
// Image masks: a sample of 0 paints unless /Decode is [1 0]; cleared samples must not paint.
|
||||
let clear_bit = if mask {
|
||||
let inverted = s.dict.get(b"Decode").and_then(Object::as_array).and_then(|d| d.first()).and_then(Object::as_f64) == Some(1.0);
|
||||
@@ -83,7 +89,6 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let bits_per_pixel = ncomp * bpc;
|
||||
let mut cleared = 0usize;
|
||||
for j in 0..h {
|
||||
// Image space: (0,0) is the bottom-left of the unit square; row 0 is the top.
|
||||
@@ -101,13 +106,15 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]
|
||||
continue;
|
||||
}
|
||||
cleared += 1;
|
||||
let start = j * row * 8 + i * bits_per_pixel;
|
||||
// `bytes * 8` fits, and every bit index is below it, so this arithmetic can't overflow.
|
||||
let start = j * row_bits + i * bits_per_pixel;
|
||||
for bit in start..start + bits_per_pixel {
|
||||
let (byte, shift) = (bit / 8, 7 - bit % 8);
|
||||
let b = data.get_mut(byte)?;
|
||||
if clear_bit {
|
||||
data[byte] |= 1 << shift;
|
||||
*b |= 1 << shift;
|
||||
} else {
|
||||
data[byte] &= !(1 << shift);
|
||||
*b &= !(1 << shift);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -470,32 +470,12 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Reda
|
||||
// 3b. An XFA form mirrors its field values in the /XFA datasets packet, and a relayout
|
||||
// rebuilds fields and pages from that packet, so deleting the AcroForm field alone leaves the
|
||||
// redacted value in the file and lets a later relayout put it back. Once redactions are
|
||||
// applied the form stops being XFA: drop /AcroForm /XFA (template and datasets), the catalog's
|
||||
// /NeedsRendering, and PdfCraft's own layout mark (pdfcraft_xfa::LAYOUT_KEY), leaving a plain
|
||||
// already-laid-out AcroForm that neither the saved file nor a relayout can restore from.
|
||||
if let Some(root) = doc.root() {
|
||||
let strip_xfa = |d: &mut Dict| {
|
||||
d.remove(b"XFA");
|
||||
d.remove(b"PCXfaLayout");
|
||||
};
|
||||
match doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) {
|
||||
Some(Object::Ref(af)) if doc.get(af).as_dict().is_some_and(|d| d.contains(b"XFA")) => {
|
||||
doc.update_dict(af, strip_xfa)?;
|
||||
doc.update_dict(root, |d| {
|
||||
d.remove(b"NeedsRendering");
|
||||
})?;
|
||||
report.xfa_removed = true;
|
||||
}
|
||||
Some(Object::Dict(mut af)) if af.contains(b"XFA") => {
|
||||
strip_xfa(&mut af);
|
||||
doc.update_dict(root, |d| {
|
||||
d.set(b"AcroForm".to_vec(), Object::Dict(af));
|
||||
d.remove(b"NeedsRendering");
|
||||
})?;
|
||||
report.xfa_removed = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// applied the form stops being XFA: the packets, /NeedsRendering, the layout mark and the
|
||||
// rest go (pdfcraft_xfa::strip), leaving a plain already-laid-out AcroForm that neither the
|
||||
// saved file nor a relayout can restore from.
|
||||
if pdfcraft_xfa::xfa_shape(doc).is_some() {
|
||||
let r = pdfcraft_xfa::strip(doc).map_err(|e| pdfcraft_edit::EditError::Invalid(e.to_string()))?;
|
||||
report.xfa_removed = !r.packets.is_empty();
|
||||
}
|
||||
|
||||
// 4. The boxes.
|
||||
|
||||
@@ -68,18 +68,41 @@ fn digit_run(s: &[char], start: usize, seps: &[char], max_len: usize) -> (usize,
|
||||
(i, n)
|
||||
}
|
||||
|
||||
/// Whether the separated digit run goes on past `k` in the direction `step` (1 or −1): the next
|
||||
/// digit after `k` is reached through separators only.
|
||||
fn run_continues(s: &[char], k: usize, step: isize, seps: &[char]) -> bool {
|
||||
let mut j = k;
|
||||
loop {
|
||||
let Some(c) = j.checked_add_signed(step).and_then(|j| s.get(j)) else { return false };
|
||||
if c.is_ascii_digit() {
|
||||
return true;
|
||||
}
|
||||
if !seps.contains(c) {
|
||||
return false;
|
||||
}
|
||||
j = j.wrapping_add_signed(step);
|
||||
}
|
||||
}
|
||||
|
||||
fn phone(s: &[char], i: usize) -> Option<usize> {
|
||||
const SEPS: [char; 5] = [' ', '-', '.', '(', ')'];
|
||||
let start_ok = s[i] == '+' && digit(s.get(i + 1)) || s[i] == '(' && digit(s.get(i + 1)) || s[i].is_ascii_digit();
|
||||
if !start_ok || word(i.checked_sub(1).and_then(|p| s.get(p))) || s.get(i.wrapping_sub(1)) == Some(&'+') {
|
||||
return None;
|
||||
}
|
||||
// A phone number is a whole run of digit groups. The tail of a longer one (an IBAN, a
|
||||
// company or social security number) is not a phone number, however it is grouped (#1884).
|
||||
if run_continues(s, i, -1, &SEPS) {
|
||||
return None;
|
||||
}
|
||||
let plus = s[i] == '+';
|
||||
let first = if plus { i + 1 } else { i };
|
||||
let (end, n) = digit_run(s, first, &[' ', '-', '.', '(', ')'], 28);
|
||||
let (end, n) = digit_run(s, first, &SEPS, 28);
|
||||
let run = s.get(first..end)?;
|
||||
let seps = run.iter().filter(|c| !c.is_ascii_digit()).count();
|
||||
// Always grouped by at least one separator, so plain numbers never match.
|
||||
if seps == 0 || digit(s.get(end)) {
|
||||
// Always grouped by at least one separator, so plain numbers never match; nor does the
|
||||
// head of a run too long to be a phone number.
|
||||
if seps == 0 || digit(s.get(end)) || run_continues(s, end - 1, 1, &SEPS) {
|
||||
return None;
|
||||
}
|
||||
let ok = if plus {
|
||||
@@ -358,6 +381,18 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tail_of_a_longer_number_is_not_a_phone_number() {
|
||||
// #1884: the last groups of a French social security number, an IBAN and a SIRET used
|
||||
// to be marked as phone numbers (and the start left visible). Example numbers.
|
||||
for s in ["NIR 1 85 05 78 006 084 36 end", "IBAN FR76 3000 6000 0112 3456 7890 189", "SIRET 123 456 782 00010"] {
|
||||
assert_eq!(found(Pattern::Phone, s), Vec::<String>::new(), "{s}");
|
||||
}
|
||||
assert_eq!(found(Pattern::Phone, "tel 01 23 45 67 89 / +33 1 23 45 67 89"), ["01 23 45 67 89", "+33 1 23 45 67 89"]);
|
||||
// Nor the head of a run too long to be one: 28 characters in, this one still goes on.
|
||||
assert_eq!(found(Pattern::Phone, "01 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7"), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emails() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -1056,6 +1056,21 @@ fn redacting_every_inheriting_page_retires_the_parent_resources() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hostile_icc_component_count_is_refused_not_multiplied_into_a_crash() {
|
||||
// /N comes from the file. 2^60 × 8 bpc × width 2 overflows the row size; a negative /N wrapped
|
||||
// to a huge usize. Either way the half-covered image can't be cleared, so it is removed whole.
|
||||
for n in ["1152921504606846976", "-1", "2"] {
|
||||
let img = stream("/Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace [/ICCBased 8 0 R] /BitsPerComponent 8", [255, 255]);
|
||||
let icc = stream(&format!("/N {n}"), b"");
|
||||
let mut doc = one_page(b"q 20 0 0 10 100 100 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![img, icc]);
|
||||
mark(&mut doc, 0, &[[95.0, 95.0, 110.0, 115.0]], "");
|
||||
let r = apply(&mut doc, None).unwrap();
|
||||
assert_eq!((r.images_removed, r.images_cleared), (1, 0), "/N {n}: {r:?}");
|
||||
assert!(!content(&doc, 0).contains("/Im Do"), "/N {n}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_alternatives_and_masks_cannot_keep_redacted_pixels() {
|
||||
for extra in ["/Alternates [<< /Image 8 0 R >>]", "/SMask 8 0 R", "/Mask 8 0 R", "/Mask [0 0]"] {
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Times and hashes the renders the desktop canvas asks for on page 1 of a PDF, for before and after
|
||||
//! comparisons (storytold/pdfcraft#623, item 8).
|
||||
//!
|
||||
//! Each round queues one scenario's requests on a render pool with the engine's worker count and
|
||||
//! prints the time to the first answer, the wall time, the slowest request, the text glyph count,
|
||||
//! the warning count, the error count, and an FNV-1a hash of every answer's size, pixels and text
|
||||
//! layer in request order. Equal hashes mean equal output. Peak memory is not measured here; on
|
||||
//! macOS wrap the command in `/usr/bin/time -l`, and subtract the `load` scenario to get the
|
||||
//! render's share.
|
||||
//!
|
||||
//! Usage: `page_timing FILE SCENARIO [ROUNDS] [THREADS]`. Scenarios:
|
||||
//! - `load`: open the file and start the pool with no request.
|
||||
//! - `fit`: page 1 at 2800 device pixels wide.
|
||||
//! - `backdrop`: page 1 at 2048 pixels on its longest side, the canvas's low-resolution backdrop.
|
||||
//! - `thumb`: a 240 × 320 thumbnail at density 2.
|
||||
//! - `tiles100`, `tiles400`: four 1024 px tiles at 100% and 400% on a 2× display.
|
||||
//! - `tiny`: one 16 px tile, which shows the fixed cost of a request.
|
||||
//! - `text`: the text layer of page 1.
|
||||
//! - `sweep`: whole pages and tiles at six scales, the broadest identity check.
|
||||
|
||||
use std::error::Error;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use pdfcraft_render::{PageText, RenderConfig, RenderPool, RenderRequest, RenderedPage, RequestKind, Tile};
|
||||
|
||||
/// The canvas's tile side in device pixels.
|
||||
const TILE: u32 = 1024;
|
||||
const BACKDROP_SIDE: f32 = 2048.0;
|
||||
const FIT_WIDTH: f32 = 2800.0;
|
||||
const THUMB_W: f32 = 240.0;
|
||||
const THUMB_H: f32 = 320.0;
|
||||
const THUMB_DENSITY: f32 = 2.0;
|
||||
/// The tag bit that marks a thumbnail request, as the canvas sets it.
|
||||
const THUMB_TAG: u64 = 1 << 63;
|
||||
const TEXT_TAG: u64 = 1 << 62;
|
||||
/// Device pixels per point at 100% on a 2× display.
|
||||
const SCREEN_SCALE: f32 = 96.0 / 72.0 * 2.0;
|
||||
const TIMEOUT: Duration = Duration::from_secs(600);
|
||||
const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
const FNV_PRIME: u64 = 0x0100_0000_01b3;
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let (Some(path), Some(scenario)) = (args.get(1), args.get(2)) else {
|
||||
return Err("usage: page_timing FILE SCENARIO [ROUNDS] [THREADS]".into());
|
||||
};
|
||||
let rounds: usize = match args.get(3) {
|
||||
Some(s) => s.parse()?,
|
||||
None => 1,
|
||||
};
|
||||
let threads: usize = match args.get(4) {
|
||||
Some(s) => s.parse::<usize>()?.max(1),
|
||||
None => std::thread::available_parallelism().map_or(4, |n| n.get()).clamp(2, 8) - 1,
|
||||
};
|
||||
|
||||
let bytes = Arc::new(std::fs::read(path)?);
|
||||
let (width, height) = {
|
||||
let pdf = hayro_syntax::Pdf::new_with_password(bytes.clone(), "").map_err(|e| format!("{e:?}"))?;
|
||||
let pages = pdf.pages();
|
||||
pages.first().ok_or("the document has no pages")?.render_dimensions()
|
||||
};
|
||||
let requests = scenario_requests(scenario, width, height)?;
|
||||
|
||||
let pool = RenderPool::new(bytes, threads, RenderConfig::default());
|
||||
for round in 0..rounds {
|
||||
let (mut results, first, wall) = run(&pool, &requests)?;
|
||||
results.sort_by_key(|r| (r.request.page, r.request.scale.to_bits(), r.request.tile.map(|t| (t.x, t.y, t.w, t.h))));
|
||||
let slowest = results.iter().map(|r| r.millis).max().unwrap_or(0);
|
||||
let glyphs: usize = results.iter().filter_map(|r| r.text.as_ref()).map(|t| t.glyphs.len()).sum();
|
||||
let warnings: usize = results.iter().map(|r| r.warnings.len()).sum();
|
||||
let errors: Vec<&str> = results.iter().filter_map(|r| r.error.as_deref()).collect();
|
||||
let mut hash = FNV_OFFSET;
|
||||
for r in &results {
|
||||
hash = fnv1a(hash, &r.width.to_le_bytes());
|
||||
hash = fnv1a(hash, &r.height.to_le_bytes());
|
||||
hash = fnv1a(hash, &r.rgba[..]);
|
||||
if let Some(text) = &r.text {
|
||||
hash = hash_text(hash, text);
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"round={round} scenario={scenario} page_pt={width}x{height} threads={threads} requests={} first_ms={} wall_ms={} slowest_ms={slowest} glyphs={glyphs} warnings={warnings} errors={} hash={hash:016x}",
|
||||
requests.len(),
|
||||
first.as_millis(),
|
||||
wall.as_millis(),
|
||||
errors.len()
|
||||
);
|
||||
if let Some(first_error) = errors.first() {
|
||||
println!("first_error={first_error}");
|
||||
}
|
||||
if let Some(first_warning) = results.iter().flat_map(|r| r.warnings.iter()).next() {
|
||||
println!("first_warning={first_warning:?}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The requests of one scenario for a page of `width` × `height` points.
|
||||
fn scenario_requests(scenario: &str, width: f32, height: f32) -> Result<Vec<RenderRequest>, String> {
|
||||
let page = |scale: f32, tile: Option<Tile>, preview: bool| RenderRequest {
|
||||
page: 0,
|
||||
kind: RequestKind::Pixels,
|
||||
tile,
|
||||
scale,
|
||||
tag: scale_tag(scale),
|
||||
preview,
|
||||
};
|
||||
let tiles = |scale: f32| -> Vec<RenderRequest> {
|
||||
[(0, 0), (TILE, 0), (0, TILE), (TILE, TILE)].into_iter().map(|(x, y)| page(scale, Some(Tile { x, y, w: TILE, h: TILE }), false)).collect()
|
||||
};
|
||||
let requests = match scenario {
|
||||
"load" => Vec::new(),
|
||||
"fit" => vec![page(FIT_WIDTH / width, None, false)],
|
||||
"backdrop" => vec![page(BACKDROP_SIDE / width.max(height), None, true)],
|
||||
"thumb" => {
|
||||
let scale = (THUMB_W / width).min(THUMB_H / height) * THUMB_DENSITY;
|
||||
vec![RenderRequest { tag: THUMB_TAG | u64::from(scale.to_bits()), ..page(scale, None, true) }]
|
||||
}
|
||||
"tiles100" => tiles(SCREEN_SCALE),
|
||||
"tiles400" => tiles(4.0 * SCREEN_SCALE),
|
||||
"tiny" => vec![page(SCREEN_SCALE, Some(Tile { x: 0, y: 0, w: 16, h: 16 }), false)],
|
||||
"text" => vec![RenderRequest { kind: RequestKind::Text, tag: TEXT_TAG, ..page(1.0, None, false) }],
|
||||
"sweep" => {
|
||||
let mut out = Vec::new();
|
||||
for scale in [0.37_f32, 1.0, 1.7, 2.666_667, 5.5, 10.666_667] {
|
||||
out.push(page(scale, None, false));
|
||||
for side in [333_u32, 1024, 2048] {
|
||||
for (x, y) in [(0_u32, 0_u32), (811, 507), (1500, 900)] {
|
||||
out.push(page(scale, Some(Tile { x, y, w: side, h: side }), false));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
other => return Err(format!("unknown scenario `{other}`")),
|
||||
};
|
||||
Ok(requests)
|
||||
}
|
||||
|
||||
/// Queues `requests` as the canvas does and waits for every answer. Returns the answers, the time
|
||||
/// to the first one, and the wall time.
|
||||
fn run(pool: &RenderPool, requests: &[RenderRequest]) -> Result<(Vec<RenderedPage>, Duration, Duration), String> {
|
||||
let start = Instant::now();
|
||||
pool.set_queue(requests.to_vec());
|
||||
let mut results = Vec::with_capacity(requests.len());
|
||||
let mut first = None;
|
||||
while results.len() < requests.len() {
|
||||
match pool.try_recv() {
|
||||
Some(page) => {
|
||||
first.get_or_insert_with(|| start.elapsed());
|
||||
results.push(page);
|
||||
}
|
||||
None if start.elapsed() > TIMEOUT => {
|
||||
return Err(format!("{} of {} answers after {TIMEOUT:?}", results.len(), requests.len()));
|
||||
}
|
||||
None => std::thread::sleep(Duration::from_millis(1)),
|
||||
}
|
||||
}
|
||||
Ok((results, first.unwrap_or_default(), start.elapsed()))
|
||||
}
|
||||
|
||||
/// The canvas's tag for a scale: equal tags mean the same scale to 1/65536.
|
||||
fn scale_tag(scale: f32) -> u64 {
|
||||
(f64::from(scale) * 65536.0).round() as u64
|
||||
}
|
||||
|
||||
/// Folds `bytes` into an FNV-1a hash.
|
||||
fn fnv1a(mut hash: u64, bytes: &[u8]) -> u64 {
|
||||
for &byte in bytes {
|
||||
hash ^= u64::from(byte);
|
||||
hash = hash.wrapping_mul(FNV_PRIME);
|
||||
}
|
||||
hash
|
||||
}
|
||||
|
||||
/// Folds a text layer into `hash`: each glyph's text and boxes, then the line and space structure.
|
||||
fn hash_text(mut hash: u64, text: &PageText) -> u64 {
|
||||
for glyph in &text.glyphs {
|
||||
hash = fnv1a(hash, glyph.text.as_bytes());
|
||||
for value in glyph.rect.iter().chain(glyph.quad.iter().flatten()).chain(glyph.direction.iter()) {
|
||||
hash = fnv1a(hash, &value.to_bits().to_le_bytes());
|
||||
}
|
||||
}
|
||||
for line in &text.line_of {
|
||||
hash = fnv1a(hash, &line.to_le_bytes());
|
||||
}
|
||||
for &space in &text.space_before {
|
||||
hash = fnv1a(hash, &[u8::from(space)]);
|
||||
}
|
||||
hash
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
//! Writes a synthetic A1 engineering sheet, for timing large vector pages without the proprietary
|
||||
//! sample from storytold/pdfcraft#623 (item 8). It follows the sample's content layout: 1684 × 2384
|
||||
//! pt with `/Rotate 90`, one Flate stream of unit-length polyline strokes, each in its own `q … Q`
|
||||
//! block with `0.12 0 0 0.12 0 0 cm` and integer coordinates. Almost every stroke starts where the
|
||||
//! previous one ended, and most run vertically, as hatching does. Strokes are 2 units wide. A colour
|
||||
//! change comes before about 29% of strokes, a twelve-rectangle clip with one fill every 50,000
|
||||
//! strokes, and 1,143 labels come last, so a cut in the content loses the labels first. The colour
|
||||
//! space is named DeviceRGB, because the sample's ICC space renders at about the same cost, while
|
||||
//! CalRGB adds about 0.6 s to every request. The labels use non-embedded Helvetica, so the file
|
||||
//! carries no profile or font data.
|
||||
//!
|
||||
//! Usage: `synth_sheet OUT.pdf [STROKES]`. The default is 4,400,000 strokes (about 277 MB decoded).
|
||||
|
||||
use std::error::Error;
|
||||
use std::io::Write as _;
|
||||
|
||||
use flate2::Compression;
|
||||
use flate2::write::ZlibEncoder;
|
||||
|
||||
const DEFAULT_STROKES: u64 = 4_400_000;
|
||||
/// The sheet spans this many content units (0.12 pt each).
|
||||
const SPAN: u64 = 14_000;
|
||||
const CLIP_EVERY: u64 = 50_000;
|
||||
const LABELS: u64 = 1_143;
|
||||
const SEED: u64 = 0x9E37_79B9_7F4A_7C15;
|
||||
|
||||
/// xorshift64, so every run writes the same sheet.
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn below(&mut self, n: u64) -> u64 {
|
||||
let mut x = self.0;
|
||||
x ^= x << 13;
|
||||
x ^= x >> 7;
|
||||
x ^= x << 17;
|
||||
self.0 = x;
|
||||
x % n
|
||||
}
|
||||
}
|
||||
|
||||
/// The content stream, compressed as it is written.
|
||||
struct Content {
|
||||
encoder: ZlibEncoder<Vec<u8>>,
|
||||
decoded: u64,
|
||||
}
|
||||
|
||||
impl Content {
|
||||
fn push(&mut self, text: &str) -> std::io::Result<()> {
|
||||
self.encoder.write_all(text.as_bytes())?;
|
||||
self.decoded += text.len() as u64;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A colour component in millionths, written with six decimals.
|
||||
fn micro(v: i64) -> String {
|
||||
format!("{}.{:06}", v / 1_000_000, v % 1_000_000)
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let out = args.get(1).ok_or("usage: synth_sheet OUT.pdf [STROKES]")?;
|
||||
let strokes: u64 = match args.get(2) {
|
||||
Some(s) => s.parse()?,
|
||||
None => DEFAULT_STROKES,
|
||||
};
|
||||
|
||||
let mut content = Content { encoder: ZlibEncoder::new(Vec::new(), Compression::default()), decoded: 0 };
|
||||
content.push("q\n0 1 -1 0 1684 0 cm\n1 J\n2 w\n")?;
|
||||
let mut rng = Rng(SEED);
|
||||
let mut colour = [49_509_i64, 49_175, 47_727];
|
||||
let (mut x, mut y) = (rng.below(SPAN), rng.below(SPAN));
|
||||
let (mut clips, mut colour_changes) = (0_u64, 0_u64);
|
||||
for i in 0..strokes {
|
||||
if i % CLIP_EVERY == 0 {
|
||||
// A clip of twelve small rectangles with one fill, as the sample's clips are. The clip
|
||||
// wraps no strokes.
|
||||
content.push("q\n")?;
|
||||
for _ in 0..12 {
|
||||
let (rx, ry) = (rng.below(SPAN), rng.below(SPAN));
|
||||
content.push(&format!("{} {} m\n{} {} l\n{} {} l\n{} {} l\nh\n", rx, ry, rx + 14, ry, rx + 14, ry + 30, rx, ry + 30))?;
|
||||
}
|
||||
content.push("W* n\n/Cs1 cs 0.930000 0.940000 0.960000 sc\n")?;
|
||||
let (rx, ry) = (rng.below(SPAN), rng.below(SPAN));
|
||||
content.push(&format!("{rx} {ry} 14 30 re f\nQ\n"))?;
|
||||
clips += 1;
|
||||
}
|
||||
if rng.below(1000) < 293 {
|
||||
for c in &mut colour {
|
||||
*c = (*c + rng.below(4_001) as i64 - 2_000).clamp(0, 1_000_000);
|
||||
}
|
||||
content.push(&format!("/Cs1 CS {} {} {} SC\n", micro(colour[0]), micro(colour[1]), micro(colour[2])))?;
|
||||
colour_changes += 1;
|
||||
}
|
||||
if rng.below(1000) < 11 {
|
||||
x = rng.below(SPAN);
|
||||
y = rng.below(SPAN);
|
||||
}
|
||||
let (dx, dy) = match rng.below(1000) {
|
||||
0..=693 => (0, 1),
|
||||
694..=845 => (1, 1),
|
||||
846..=992 => (1, 0),
|
||||
_ => (0, 0),
|
||||
};
|
||||
let (nx, ny) = (x + dx, y + dy);
|
||||
content.push(&format!("q\n0.12 0 0 0.12 0 0 cm\n{x} {y} m\n{nx} {ny} l\nS\nQ\n"))?;
|
||||
(x, y) = (nx, ny);
|
||||
}
|
||||
for k in 0..LABELS {
|
||||
let text = if k + 1 == LABELS { "END OF SHEET".to_string() } else { format!("CBL -{}", 100 + k) };
|
||||
let (tx, ty) = (1210 + (k % 30) * 40, 497 + (k / 30) * 12);
|
||||
content.push(&format!("q\n1 0 0 1 0 0 cm\n0 g\nBT\n/F3 1 Tf\n4 0 0 4 {tx} {ty} Tm\n({text}) Tj\nET\nQ\n"))?;
|
||||
}
|
||||
content.push("Q\n")?;
|
||||
|
||||
let Content { encoder, decoded } = content;
|
||||
let stream = encoder.finish()?;
|
||||
let mut stream_object = format!("<< /Length {} /Filter /FlateDecode >>\nstream\n", stream.len()).into_bytes();
|
||||
stream_object.extend_from_slice(&stream);
|
||||
stream_object.extend_from_slice(b"\nendstream");
|
||||
|
||||
let objects: 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 1684 2384] /Rotate 90 /Contents 4 0 R /Resources << /ColorSpace << /Cs1 /DeviceRGB >> /Font << /F3 5 0 R >> >> >>"
|
||||
.to_vec(),
|
||||
stream_object,
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"
|
||||
.to_vec(),
|
||||
];
|
||||
let mut pdf = b"%PDF-1.4\n".to_vec();
|
||||
let mut offsets = Vec::with_capacity(objects.len());
|
||||
for (n, body) in objects.iter().enumerate() {
|
||||
offsets.push(pdf.len());
|
||||
pdf.extend_from_slice(format!("{} 0 obj\n", n + 1).as_bytes());
|
||||
pdf.extend_from_slice(body);
|
||||
pdf.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref_at = pdf.len();
|
||||
let size = objects.len() + 1;
|
||||
pdf.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f \n").as_bytes());
|
||||
for offset in &offsets {
|
||||
pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
pdf.extend_from_slice(format!("trailer\n<< /Size {size} /Root 1 0 R >>\nstartxref\n{xref_at}\n%%EOF\n").as_bytes());
|
||||
std::fs::write(out, &pdf)?;
|
||||
|
||||
println!(
|
||||
"wrote {out}: {strokes} strokes, {colour_changes} colour changes, {clips} clip groups, {LABELS} labels, {decoded} decoded bytes, {} stream bytes ({:.2}:1)",
|
||||
stream.len(),
|
||||
decoded as f64 / stream.len() as f64
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -76,6 +76,8 @@ pub struct DocInfo {
|
||||
pub layer_groups: Vec<Vec<(u32, u16)>>,
|
||||
pub fonts: Vec<FontInfo>,
|
||||
pub attachments: Vec<Attachment>,
|
||||
/// Pages with a presentation transition (`/Trans`) or display time (`/Dur`), in page order.
|
||||
pub transitions: Vec<PageTransition>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -90,6 +92,89 @@ pub enum Xfa {
|
||||
Dynamic,
|
||||
}
|
||||
|
||||
/// A page's presentation transition and display time (ISO 32000-2 §12.4.4): how the page comes
|
||||
/// into view in a full-screen presentation, and how long it stays before advancing. Only pages
|
||||
/// carrying `/Trans` or `/Dur` are listed. Values outside the spec's ranges fall back to the
|
||||
/// defaults rather than being refused.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PageTransition {
|
||||
pub page: usize,
|
||||
/// `/Trans /S`; `None` when the page has only `/Dur`.
|
||||
pub style: Option<TransitionStyle>,
|
||||
/// `/D`: how long the transition takes, in seconds (default 1).
|
||||
pub duration: f32,
|
||||
/// `/Dm /H` (true) or `/V` (Split, Blinds).
|
||||
pub horizontal: bool,
|
||||
/// `/M /I` (true) or `/O` (Split, Box, Fly).
|
||||
pub inward: bool,
|
||||
/// `/Di`: 0, 90, 180, 270 or 315 degrees (Wipe, Glitter, Fly, Cover, Uncover, Push); `None`
|
||||
/// for `/None` (Fly only).
|
||||
pub direction: Option<u16>,
|
||||
/// `/SS`: the Fly scale (default 1).
|
||||
pub scale: f32,
|
||||
/// `/B`: Fly with an opaque backdrop.
|
||||
pub opaque: bool,
|
||||
/// `/Dur`: seconds the page shows before the presentation advances; `None` waits for the
|
||||
/// presenter.
|
||||
pub display: Option<f32>,
|
||||
}
|
||||
|
||||
/// The transition styles of ISO 32000-2 table 164 (`/Trans /S`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TransitionStyle {
|
||||
Split,
|
||||
Blinds,
|
||||
Box,
|
||||
Wipe,
|
||||
Dissolve,
|
||||
Glitter,
|
||||
/// `/R`: the next page replaces the old one with no effect (the default).
|
||||
Replace,
|
||||
Fly,
|
||||
Push,
|
||||
Cover,
|
||||
Uncover,
|
||||
Fade,
|
||||
}
|
||||
|
||||
impl TransitionStyle {
|
||||
/// The style a `/S` name means; an unknown name is `Replace`, the spec's default.
|
||||
pub fn from_name(name: &str) -> Self {
|
||||
match name {
|
||||
"Split" => Self::Split,
|
||||
"Blinds" => Self::Blinds,
|
||||
"Box" => Self::Box,
|
||||
"Wipe" => Self::Wipe,
|
||||
"Dissolve" => Self::Dissolve,
|
||||
"Glitter" => Self::Glitter,
|
||||
"Fly" => Self::Fly,
|
||||
"Push" => Self::Push,
|
||||
"Cover" => Self::Cover,
|
||||
"Uncover" => Self::Uncover,
|
||||
"Fade" => Self::Fade,
|
||||
_ => Self::Replace,
|
||||
}
|
||||
}
|
||||
|
||||
/// The name as written in a PDF (`/S`).
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Split => "Split",
|
||||
Self::Blinds => "Blinds",
|
||||
Self::Box => "Box",
|
||||
Self::Wipe => "Wipe",
|
||||
Self::Dissolve => "Dissolve",
|
||||
Self::Glitter => "Glitter",
|
||||
Self::Replace => "R",
|
||||
Self::Fly => "Fly",
|
||||
Self::Push => "Push",
|
||||
Self::Cover => "Cover",
|
||||
Self::Uncover => "Uncover",
|
||||
Self::Fade => "Fade",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PageInfo {
|
||||
/// Displayed size in points, after `/Rotate` and `/UserUnit`.
|
||||
@@ -548,6 +633,7 @@ impl<'a> Inspector<'a> {
|
||||
(info.outline, info.outline_more) = self.outline_window(first);
|
||||
}
|
||||
self.annotations(info);
|
||||
self.transitions(info);
|
||||
let mut field_seen = HashSet::new();
|
||||
if let Some(form) = catalog.get(b"AcroForm").ok().and_then(|o| self.dict(o))
|
||||
&& let Ok(fields) = form.get(b"Fields").and_then(|o| self.resolve(o).as_array())
|
||||
@@ -596,6 +682,53 @@ impl<'a> Inspector<'a> {
|
||||
|
||||
// ── object helpers ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// `/Trans` and `/Dur` of every page (ISO 32000-2 §12.4.4), in page order. Lenient: a value
|
||||
/// outside the spec's choices takes that entry's default, and a page with neither entry is
|
||||
/// not listed. Bounded by the page count.
|
||||
fn transitions(&self, info: &mut DocInfo) {
|
||||
for (page, &pid) in self.page_ids.iter().enumerate() {
|
||||
let Ok(d) = self.doc.get_dictionary(pid) else { continue };
|
||||
let display = d.get(b"Dur").ok().and_then(|o| self.resolve(o).as_float().ok()).filter(|s| s.is_finite() && *s >= 0.0);
|
||||
let trans = d.get(b"Trans").ok().and_then(|o| self.dict(o));
|
||||
if trans.is_none() && display.is_none() {
|
||||
continue;
|
||||
}
|
||||
let mut t = PageTransition {
|
||||
page,
|
||||
style: None,
|
||||
duration: 1.0,
|
||||
horizontal: true,
|
||||
inward: true,
|
||||
direction: Some(0),
|
||||
scale: 1.0,
|
||||
opaque: false,
|
||||
display,
|
||||
};
|
||||
if let Some(tr) = trans {
|
||||
t.style = Some(self.name(tr, b"S").map_or(TransitionStyle::Replace, |n| TransitionStyle::from_name(&n)));
|
||||
let num = |key: &[u8]| tr.get(key).ok().and_then(|o| self.resolve(o).as_float().ok()).filter(|v| v.is_finite());
|
||||
t.duration = num(b"D").filter(|d| *d >= 0.0).unwrap_or(1.0);
|
||||
t.horizontal = self.name(tr, b"Dm").as_deref() != Some("V");
|
||||
t.inward = self.name(tr, b"M").as_deref() != Some("O");
|
||||
t.direction = match tr.get(b"Di").map(|o| self.resolve(o)) {
|
||||
Ok(Object::Name(n)) if n.as_slice() == b"None" => None,
|
||||
Ok(o) => match o.as_i64().ok() {
|
||||
Some(0) => Some(0),
|
||||
Some(90) => Some(90),
|
||||
Some(180) => Some(180),
|
||||
Some(270) => Some(270),
|
||||
Some(315) => Some(315),
|
||||
_ => Some(0),
|
||||
},
|
||||
Err(_) => Some(0),
|
||||
};
|
||||
t.scale = num(b"SS").filter(|s| *s > 0.0).unwrap_or(1.0);
|
||||
t.opaque = matches!(tr.get(b"B").map(|o| self.resolve(o)), Ok(Object::Boolean(true)));
|
||||
}
|
||||
info.transitions.push(t);
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve(&self, o: &'a Object) -> &'a Object {
|
||||
self.doc.dereference(o).map(|(_, o)| o).unwrap_or(o)
|
||||
}
|
||||
@@ -851,11 +984,19 @@ impl<'a> Inspector<'a> {
|
||||
for (start, d) in ranges {
|
||||
let first = d.get(b"St").ok().and_then(|o| self.resolve(o).as_i64().ok()).unwrap_or(1).max(1) as u64;
|
||||
let prefix = match d.get(b"P").ok().map(|o| self.resolve(o)) {
|
||||
Some(Object::String(raw, _) | Object::Name(raw)) => {
|
||||
Some(Object::String(raw, _)) => {
|
||||
if raw.len() > MAX_PREFIX_BYTES {
|
||||
return Err("a raw prefix exceeds 2050 bytes");
|
||||
}
|
||||
self.text(d, b"P").unwrap_or_default()
|
||||
// A label prefix is literal text, not trimmed document metadata.
|
||||
text_string(raw).trim_matches('\0').to_string()
|
||||
}
|
||||
Some(Object::Name(raw)) => {
|
||||
if raw.len() > MAX_PREFIX_BYTES {
|
||||
return Err("a raw prefix exceeds 2050 bytes");
|
||||
}
|
||||
// Retain the existing name compatibility and boundary-NUL handling.
|
||||
String::from_utf8_lossy(raw).trim_matches('\0').to_string()
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
@@ -1807,6 +1948,78 @@ mod tests {
|
||||
assert!(info.warnings[0].contains("physical page numbers"), "{:?}", info.warnings);
|
||||
}
|
||||
|
||||
// Contributor-original, blank three-page PDFs. The public importer reads both labels and metadata.
|
||||
fn page_prefix_space_pdf(prefix: Vec<u8>, decimal: bool, indirect: bool) -> Vec<u8> {
|
||||
use lopdf::dictionary;
|
||||
let mut doc = Document::with_version("1.7");
|
||||
let pages_id = doc.new_object_id();
|
||||
let kids: Vec<Object> = (0..3)
|
||||
.map(|_| {
|
||||
doc.add_object(dictionary! {
|
||||
"Type" => "Page",
|
||||
"Parent" => pages_id,
|
||||
"MediaBox" => vec![0.into(), 0.into(), 200.into(), 300.into()],
|
||||
"Resources" => Dictionary::new(),
|
||||
})
|
||||
.into()
|
||||
})
|
||||
.collect();
|
||||
doc.objects.insert(pages_id, dictionary! { "Type" => "Pages", "Kids" => kids, "Count" => 3 }.into());
|
||||
let prefix = Object::String(prefix, lopdf::StringFormat::Hexadecimal);
|
||||
let prefix = if indirect { Object::Reference(doc.add_object(prefix)) } else { prefix };
|
||||
let mut label = Dictionary::new();
|
||||
label.set("P", prefix);
|
||||
if decimal {
|
||||
label.set("S", Object::Name(b"D".to_vec()));
|
||||
}
|
||||
let labels = dictionary! { "Nums" => vec![Object::Integer(0), Object::Dictionary(label)] };
|
||||
let catalog_id = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id, "PageLabels" => labels });
|
||||
let info_id = doc.add_object(dictionary! { "Title" => Object::string_literal(" Metadata title ") });
|
||||
doc.trailer.set("Root", catalog_id);
|
||||
doc.trailer.set("Info", info_id);
|
||||
let mut bytes = Vec::new();
|
||||
doc.save_to(&mut bytes).expect("write original page-prefix fixture");
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_label_prefix_spaces_survive_public_inspection() {
|
||||
let mut utf16 = vec![0xfe, 0xff];
|
||||
utf16.extend(" Ω ".encode_utf16().flat_map(u16::to_be_bytes));
|
||||
for (raw, prefix, decimal, indirect) in [
|
||||
(b" leading".to_vec(), " leading", true, false),
|
||||
(b"trailing ".to_vec(), "trailing ", true, false),
|
||||
(b" both ".to_vec(), " both ", false, false),
|
||||
(b" ".to_vec(), " ", false, false),
|
||||
(b" Caf\xe9 ".to_vec(), " Café ", true, false),
|
||||
(utf16.clone(), " Ω ", true, false),
|
||||
(utf16, " Ω ", false, true),
|
||||
(Vec::new(), "", true, false),
|
||||
(Vec::new(), "", false, false),
|
||||
] {
|
||||
let bytes = page_prefix_space_pdf(raw, decimal, indirect);
|
||||
let info = inspect(Arc::new(bytes), None).expect("public import of original page-prefix fixture");
|
||||
let expected: Vec<String> = (1..=3)
|
||||
.map(|n| {
|
||||
if decimal {
|
||||
format!("{prefix}{n}")
|
||||
} else if prefix.is_empty() {
|
||||
n.to_string()
|
||||
} else {
|
||||
prefix.to_owned()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
info.pages.iter().map(|p| p.label.clone()).collect::<Vec<_>>(),
|
||||
expected,
|
||||
"{prefix:?}, decimal={decimal}, indirect={indirect}"
|
||||
);
|
||||
assert!(info.warnings.is_empty(), "{:?}", info.warnings);
|
||||
assert_eq!(info.title.as_deref(), Some("Metadata title"), "page-prefix decoding must not change metadata normalization");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_label_limits_keep_normal_pdf_numbering() {
|
||||
for (style, first, expected) in [
|
||||
@@ -2049,6 +2262,46 @@ trailer << /Root 1 0 R >>
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
|
||||
/// `/Trans` and `/Dur` (ISO 32000-2 §12.4.4): read with the spec's defaults for what a page
|
||||
/// leaves out, lenient about what is out of range, and only for pages that have either.
|
||||
const TRANSITIONS: &[u8] = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R 5 0 R 6 0 R 7 0 R] /Count 5 /MediaBox [0 0 200 200] >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /Trans << /Type /Trans /S /Wipe /D 2.5 /Di 270 >> /Dur 5 >> endobj
|
||||
4 0 obj << /Type /Page /Parent 2 0 R /Dur 3 >> endobj
|
||||
5 0 obj << /Type /Page /Parent 2 0 R /Trans 8 0 R >> endobj
|
||||
6 0 obj << /Type /Page /Parent 2 0 R /Trans << /S /Bogus /D (slow) /Di 45 /Dm /Q >> /Dur -1 >> endobj
|
||||
7 0 obj << /Type /Page /Parent 2 0 R >> endobj
|
||||
8 0 obj << /S /Fly /M /O /SS 0.5 /B true /Di /None /Dm /V >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
|
||||
#[test]
|
||||
fn page_transitions_and_display_times_are_read() {
|
||||
use super::TransitionStyle as S;
|
||||
let info = inspect(Arc::new(TRANSITIONS.to_vec()), None).expect("opens");
|
||||
let t = &info.transitions;
|
||||
assert_eq!(t.iter().map(|t| t.page).collect::<Vec<_>>(), [0, 1, 2, 3], "pages with /Trans or /Dur, in order; page 5 has neither");
|
||||
assert_eq!((t[0].style, t[0].duration, t[0].direction, t[0].display), (Some(S::Wipe), 2.5, Some(270), Some(5.0)));
|
||||
assert!((t[0].horizontal, t[0].inward, t[0].scale, t[0].opaque) == (true, true, 1.0, false), "defaults: {:?}", t[0]);
|
||||
assert_eq!((t[1].style, t[1].duration, t[1].display), (None, 1.0, Some(3.0)), "/Dur without /Trans");
|
||||
assert_eq!(
|
||||
(t[2].style, t[2].inward, t[2].scale, t[2].opaque, t[2].direction, t[2].horizontal),
|
||||
(Some(S::Fly), false, 0.5, true, None, false),
|
||||
"an indirect /Trans: {:?}",
|
||||
t[2]
|
||||
);
|
||||
// Out of range: an unknown style is the spec's default R, a non-numeric /D the default
|
||||
// second, a direction that is not one of the five falls back to 0, a bad /Dm to
|
||||
// horizontal, and a negative /Dur is no display time.
|
||||
assert_eq!(
|
||||
(t[3].style, t[3].duration, t[3].direction, t[3].horizontal, t[3].display),
|
||||
(Some(S::Replace), 1.0, Some(0), true, None),
|
||||
"{:?}",
|
||||
t[3]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radio_button_layer_groups_are_read() {
|
||||
let info = inspect(Arc::new(LAYERS.to_vec()), None).expect("opens");
|
||||
|
||||
@@ -14,10 +14,11 @@ mod raster;
|
||||
mod reading;
|
||||
mod structure;
|
||||
pub mod text;
|
||||
pub mod transition;
|
||||
|
||||
pub use inspect::{
|
||||
Annotation, Attachment, AttachmentSource, DestView, DocInfo, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, OutlineItem, PageInfo,
|
||||
RemoteDestination, Xfa, attachment_data, check_pages, inspect, pretty_date,
|
||||
PageTransition, RemoteDestination, TransitionStyle, Xfa, attachment_data, check_pages, inspect, pretty_date,
|
||||
};
|
||||
pub use pixels::Pixels;
|
||||
pub use raster::{
|
||||
|
||||
+346
-4
@@ -1118,6 +1118,8 @@ fn worker(
|
||||
if lock(&shared.busy).get_mut(id).is_none_or(|slot| slot.take().is_none()) {
|
||||
return;
|
||||
}
|
||||
// Release the active key, retrying if demand returned during parsing.
|
||||
shared.queue.retry(req);
|
||||
continue;
|
||||
}
|
||||
let _ = parsed.set(init);
|
||||
@@ -2155,6 +2157,62 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn check_cancelled_parse_request_requeue(renew_before_abort: bool) {
|
||||
use super::*;
|
||||
use std::{sync::mpsc, time::Duration};
|
||||
|
||||
let pool = RenderPool::new(Arc::new(ONE_PAGE_TWICE.to_vec()), 1, RenderConfig::default());
|
||||
let a = RenderRequest { page: 0, kind: RequestKind::Text, scale: 1.0, tag: 501, ..Default::default() };
|
||||
let b = RenderRequest { page: 1, scale: 1.0, tag: 502, ..Default::default() };
|
||||
let parse = parse_gate();
|
||||
*lock(&pool.shared.parse_gate) = Some(parse.clone());
|
||||
let (entered_tx, entered_rx) = mpsc::channel();
|
||||
let (release_tx, release_rx) = mpsc::channel();
|
||||
*lock(&pool.shared.busy_publish_gate) = Some(BusyPublishGate { request: b, entered: entered_tx, release: Arc::new(Mutex::new(release_rx)) });
|
||||
|
||||
pool.set_queue(vec![a]);
|
||||
parse.entered.wait();
|
||||
pool.set_queue(vec![b]);
|
||||
if renew_before_abort {
|
||||
// B stays first so the worker reaches its gate even when aborting A requeues it.
|
||||
pool.set_queue(vec![b, a]);
|
||||
}
|
||||
parse.release.wait();
|
||||
entered_rx.recv_timeout(Duration::from_secs(2)).expect("the worker finished aborting A and reached B");
|
||||
// The parse hook's lock can span its barrier waits; clear it only after parsing returned.
|
||||
*lock(&pool.shared.parse_gate) = None;
|
||||
if !renew_before_abort {
|
||||
pool.set_queue(vec![a]);
|
||||
}
|
||||
// B holds the sole worker, so this observes queue admission without racing a worker pop.
|
||||
let pending = pool.shared.queue.pop();
|
||||
if let Some(request) = pending {
|
||||
pool.shared.queue.retry(request);
|
||||
}
|
||||
pool.set_queue(vec![a]);
|
||||
// Release the worker before the regression assertion, including on the unfixed baseline.
|
||||
release_tx.send(()).unwrap();
|
||||
assert_eq!(pending, Some(a), "aborting a parse must release its active request identity");
|
||||
|
||||
let page = receive_before_deadline(&pool);
|
||||
assert_eq!(page.request, a);
|
||||
assert!(page.error.is_none(), "renewed text request failed: {:?}", page.error);
|
||||
assert!(page.text.is_some(), "the exact cancelled text request completed");
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn cancelled_parse_request_can_be_requested_again() {
|
||||
check_cancelled_parse_request_requeue(false);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn cancelled_parse_request_renewed_before_abort_is_requeued() {
|
||||
check_cancelled_parse_request_requeue(true);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn request_removed_before_busy_publication_skips_parser_acquisition() {
|
||||
@@ -2976,6 +3034,67 @@ trailer << /Root 1 0 R >>
|
||||
}
|
||||
}
|
||||
|
||||
/// #1968: the highlight appearance other editors write, a transparency group whose content is
|
||||
/// filled with `/BM /Multiply`, drew as an opaque block over the text. Every `/Group` form
|
||||
/// got a layer of its own, so the Multiply blended with that layer's transparent backdrop
|
||||
/// and the layer then covered the page. Vendored hayro-interpret patch: a group that is not
|
||||
/// isolated (ISO 32000-2 §11.4.7: its elements composite with what is already on the page)
|
||||
/// and is entered at full opacity, with no soft mask and Normal blending, has no backdrop of
|
||||
/// its own to offer, so its content is drawn straight onto the page. An isolated group
|
||||
/// (`/I true`) keeps its blank backdrop, and a group entered at partial opacity still gets
|
||||
/// its layer so that opacity applies to the group as a whole.
|
||||
#[test]
|
||||
fn a_multiply_highlight_inside_a_transparency_group_keeps_the_text() {
|
||||
// `annots`: the form drawn as the highlight's appearance; `page`: drawn from the page
|
||||
// content instead, with the extra graphics state in front of the `Do`.
|
||||
let render = |group: &str, annots: &str, page: &str| {
|
||||
let content = format!("0 g 100 40 10 20 re f {page}");
|
||||
let form = "/GS0 gs 1 1 0 rg 15 35 105 30 re f";
|
||||
let pdf = format!(
|
||||
"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R {annots} /Resources << /XObject << /Fx 6 0 R >> /ExtGState << /Half << /ca 0.5 >> >> >> >> endobj
|
||||
4 0 obj << /Length {} >> stream
|
||||
{content}
|
||||
endstream endobj
|
||||
5 0 obj << /Type /Annot /Subtype /Highlight /Rect [15 35 120 65] /QuadPoints [15 65 120 65 15 35 120 35] /C [1 1 0] /AP << /N 6 0 R >> >> endobj
|
||||
6 0 obj << /Type /XObject /Subtype /Form /BBox [15 35 120 65] {group} /Resources << /ExtGState << /GS0 << /BM /Multiply /CA 1 /ca 1 >> >> >> /Length {} >> stream
|
||||
{form}
|
||||
endstream endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF",
|
||||
content.len(),
|
||||
form.len()
|
||||
);
|
||||
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
|
||||
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
|
||||
assert!(p.error.is_none(), "{group}: {:?}", p.error);
|
||||
p
|
||||
};
|
||||
let px = |p: &RenderedPage, x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec();
|
||||
let (white, black, yellow) = (vec![255, 255, 255, 255], vec![0, 0, 0, 255], vec![255, 255, 0, 255]);
|
||||
let annots = "/Annots [5 0 R]";
|
||||
let plain = "/Group << /S /Transparency /CS /DeviceRGB >>";
|
||||
let explicit = "/Group << /S /Transparency /CS /DeviceRGB /I false /K false >>";
|
||||
for group in [plain, explicit, ""] {
|
||||
for (annots, page) in [(annots, ""), ("", "/Fx Do")] {
|
||||
let p = render(group, annots, page);
|
||||
assert_eq!(px(&p, 30, 50), yellow, "{group} {page}: filled with the highlight colour");
|
||||
assert_eq!(px(&p, 105, 50), black, "{group} {page}: Multiply keeps the text underneath");
|
||||
assert_eq!(px(&p, 150, 50), white, "{group} {page}: outside the appearance");
|
||||
}
|
||||
}
|
||||
// Isolated: the group's own transparent backdrop is all the Multiply sees (§11.4.7).
|
||||
let p = render("/Group << /S /Transparency /CS /DeviceRGB /I true >>", annots, "");
|
||||
assert_eq!(px(&p, 105, 50), yellow, "an isolated group starts from a blank backdrop");
|
||||
// Entered at half opacity, the group is still composited as one: half yellow over white.
|
||||
let p = render(plain, "", "/Half gs /Fx Do");
|
||||
let half = px(&p, 30, 50);
|
||||
assert!(half[0] == 255 && half[1] == 255 && (120..=136).contains(&half[2]), "{half:?}");
|
||||
assert_eq!(px(&p, 150, 50), white);
|
||||
}
|
||||
|
||||
/// A Highlight annotation without an appearance stream (common in older and generated files)
|
||||
/// was not drawn at all. Vendored hayro-interpret patch: its /QuadPoints are filled with /C at
|
||||
/// /CA, blended with Multiply, as PdfCraft draws its own highlights (pdfcraft-annot); MuPDF,
|
||||
@@ -4343,8 +4462,8 @@ trailer << /Root 1 0 R >>
|
||||
/// Found while triaging `cargo xtask fuzz` on Windows, by hand rather than by the fuzzer
|
||||
/// (random mutations almost never make a high-ratio stream): a 2 MB page whose content
|
||||
/// stream, or whose 8 × 8 image, inflated to 2 GB took the renderer past 3 GB in under a
|
||||
/// second. Flate (through flate2 and through the fallback inflater), LZW and RunLength expand
|
||||
/// without limit. Vendored hayro-syntax patch: each stops at `MAX_DECODED_STREAM`, and an image
|
||||
/// second. Flate (through flate2 and through the fallback inflater), LZW, RunLength and ASCII85
|
||||
/// (#1796: each `z` is four bytes, here after Flate) expand without limit. Vendored hayro-syntax patch: each stops at `MAX_DECODED_STREAM`, and an image
|
||||
/// of known size at its own rows.
|
||||
#[test]
|
||||
fn decompression_bombs_stop_at_the_stream_limit() {
|
||||
@@ -4354,11 +4473,14 @@ trailer << /Root 1 0 R >>
|
||||
let big = limit + (32 << 20);
|
||||
let mut run_length = [0x81u8, 0].repeat(big / 128 + 1); // runs of 128 zeros
|
||||
run_length.push(128);
|
||||
let streams: [(&str, Vec<u8>); 4] = [
|
||||
let mut a85 = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::best());
|
||||
std::io::Write::write_all(&mut a85, &b"z".repeat(big / 4 + 1)).unwrap();
|
||||
let streams: [(&str, Vec<u8>); 5] = [
|
||||
("/FlateDecode", zlib_zeros(big)),
|
||||
("/FlateDecode", deflate_for_the_fallback(big)),
|
||||
("/LZWDecode", lzw_zeros(big / 7_000_000 + 1)),
|
||||
("/RunLengthDecode", run_length),
|
||||
("[/FlateDecode /ASCII85Decode]", a85.finish().unwrap()),
|
||||
];
|
||||
let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] >> endobj\n".to_vec();
|
||||
for (i, (filter, data)) in streams.iter().enumerate() {
|
||||
@@ -4369,7 +4491,7 @@ trailer << /Root 1 0 R >>
|
||||
pdf.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF\n");
|
||||
let doc = Pdf::new(pdf).expect("parses");
|
||||
let found: Vec<Stream<'_>> = doc.objects().into_iter().filter_map(|o| if let Object::Stream(s) = o { Some(s) } else { None }).collect();
|
||||
assert_eq!(found.len(), 4, "all four bomb streams are found");
|
||||
assert_eq!(found.len(), 5, "all five bomb streams are found");
|
||||
for stream in &found {
|
||||
let what = format!("{:?}", stream.filters());
|
||||
let decoded = stream.decoded().expect("a bomb still decodes, up to the limit");
|
||||
@@ -5175,6 +5297,194 @@ trailer << /Root 1 0 R >>
|
||||
}
|
||||
}
|
||||
|
||||
// Contributor-original classic-xref vector fixtures; no embedded or external assets.
|
||||
fn raw_crop_unit_pdf(unit: f64, rotation: i32, crop: [f64; 4], marker: Option<[f64; 4]>) -> Vec<u8> {
|
||||
let bounds = crop.map(|n| n.to_string()).join(" ");
|
||||
let body = marker.map(|rect| format!("0 0 0 rg {} re f\n", rect.map(|n| n.to_string()).join(" "))).unwrap_or_default();
|
||||
let objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_owned(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_owned(),
|
||||
format!(
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [{bounds}] /CropBox [{bounds}] /UserUnit {unit} /Rotate {rotation} /Resources << >> /Contents 4 0 R >>"
|
||||
),
|
||||
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()),
|
||||
];
|
||||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, object) in objects.iter().enumerate() {
|
||||
offsets.push(bytes.len());
|
||||
bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = bytes.len();
|
||||
bytes.extend_from_slice(b"xref\n0 5\n0000000000 65535 f \n");
|
||||
for offset in offsets {
|
||||
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(format!("trailer << /Size 5 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_crop_user_unit_inspection_keeps_observed_dimensions() {
|
||||
let bytes = raw_crop_unit_pdf(4096.0, 0, [0.0, 0.0, 0.1, 0.1], None);
|
||||
let info = crate::inspect(Arc::new(bytes), None).expect("original valid raw-0.1 PDF");
|
||||
assert_eq!(info.pages.len(), 1);
|
||||
assert!(info.warnings.is_empty(), "{:?}", info.warnings);
|
||||
let page = &info.pages[0];
|
||||
assert!(
|
||||
(page.width - 409.6).abs() < 0.0001 && (page.height - 409.6).abs() < 0.0001,
|
||||
"raw 0.1 at UserUnit4096 is 409.6 points, got {} by {}",
|
||||
page.width,
|
||||
page.height
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_crop_unit_floor_corners_match_nonzero_origin_and_rotations() {
|
||||
use hayro::hayro_interpret::TransformExt;
|
||||
|
||||
for (unit, width, height, dx, dy) in
|
||||
[(1.0, 1.0, 1.0, 0.125, 0.25), (4096.0, 512.0, 1024.0, 512.0, 1024.0), (75000.0, 9375.0, 18750.0, 9375.0, 18750.0)]
|
||||
{
|
||||
for rotation in [0, 90, 180, 270] {
|
||||
let bytes = raw_crop_unit_pdf(unit, rotation, [10.0, 20.0, 10.125, 20.25], None);
|
||||
let info = crate::inspect(Arc::new(bytes.clone()), None).expect("original valid offset crop");
|
||||
assert!(info.warnings.is_empty(), "{:?}", info.warnings);
|
||||
let dimensions = if rotation % 180 == 0 { (width as f32, height as f32) } else { (height as f32, width as f32) };
|
||||
assert_eq!((info.pages[0].width, info.pages[0].height), dimensions, "unit={unit}, rotation={rotation}");
|
||||
let pdf = Pdf::new(Arc::new(bytes)).expect("same PDF parser");
|
||||
let page = pdf.pages().first().expect("one page");
|
||||
assert_eq!(page.base_dimensions(), ((width / unit) as f32, (height / unit) as f32));
|
||||
let transform = page.initial_transform(true).to_kurbo();
|
||||
let (origin, opposite) = match rotation {
|
||||
0 => ([0.0, height], [dx, height - dy]),
|
||||
90 => ([0.0, 0.0], [dy, dx]),
|
||||
180 => ([width, 0.0], [width - dx, dy]),
|
||||
_ => ([height, width], [height - dy, width - dx]),
|
||||
};
|
||||
for (point, expected) in [(kurbo::Point::new(10.0, 20.0), origin), (kurbo::Point::new(10.125, 20.25), opposite)] {
|
||||
let actual = transform * point;
|
||||
assert!(
|
||||
(actual.x - expected[0]).abs() < 1e-9 && (actual.y - expected[1]).abs() < 1e-9,
|
||||
"unit={unit}, rotation={rotation}, point={point:?}: {actual:?} != {expected:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_crop_unit_floor_reciprocal_does_not_trigger_letter_fallback() {
|
||||
for unit in [1.0, 41.0, 47.0] {
|
||||
let bytes = raw_crop_unit_pdf(unit, 0, [0.0, 0.0, 0.01, 0.01], None);
|
||||
let pdf = Pdf::new(Arc::new(bytes.clone())).expect("ordinary small crop");
|
||||
let page = pdf.pages().first().expect("one page");
|
||||
let (width, height) = page.base_dimensions();
|
||||
// In f32, the nearest reciprocal of 41 or 47 multiplies back to 0.99999994.
|
||||
assert!(
|
||||
width * page.user_unit() >= 1.0 && height * page.user_unit() >= 1.0,
|
||||
"unit={unit}: floor must not round below one physical point"
|
||||
);
|
||||
let info = crate::inspect(Arc::new(bytes), None).expect("inspect the same small crop");
|
||||
let page = &info.pages[0];
|
||||
assert!(
|
||||
(1.0..=1.000001).contains(&page.width) && (1.0..=1.000001).contains(&page.height),
|
||||
"unit={unit}: one-point floor must not become a Letter placeholder: {} by {}",
|
||||
page.width,
|
||||
page.height
|
||||
);
|
||||
assert!(info.warnings.is_empty(), "{:?}", info.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_crop_unit_floor_preserves_nearly_zero_fallback() {
|
||||
for unit in [1.0, 4096.0, 75000.0] {
|
||||
for side in [0.0, 0.0001] {
|
||||
let bytes = raw_crop_unit_pdf(unit, 0, [0.0, 0.0, side, side], None);
|
||||
let pdf = Pdf::new(Arc::new(bytes)).expect("existing degenerate-page fallback");
|
||||
let page = pdf.pages().first().expect("one page");
|
||||
// Existing A4 raw fallback remains raw A4, then scales by the page unit.
|
||||
let expected = (595.2755905511812_f64 as f32, 841.8897637795277_f64 as f32);
|
||||
assert_eq!(page.base_dimensions(), expected, "unit={unit}, raw side={side}");
|
||||
assert_eq!(page.render_dimensions(), (expected.0 * unit as f32, expected.1 * unit as f32));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_crop_ink(page: &RenderedPage) -> ([u32; 4], usize) {
|
||||
let (mut left, mut top, mut right, mut bottom, mut count) = (page.width, page.height, 0, 0, 0);
|
||||
for (i, pixel) in page.rgba.as_chunks::<4>().0.iter().enumerate() {
|
||||
if pixel[0] < 128 && pixel[1] < 128 && pixel[2] < 128 {
|
||||
let (x, y) = (i as u32 % page.width, i as u32 / page.width);
|
||||
left = left.min(x);
|
||||
top = top.min(y);
|
||||
right = right.max(x + 1);
|
||||
bottom = bottom.max(y + 1);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
assert!(count > 0, "original vector marker must draw");
|
||||
([left, top, right, bottom], count)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_crop_user_unit_vector_rasters_match_physical_controls() {
|
||||
for (unit, scale, width, height, physical_width, physical_height, rects) in [
|
||||
(
|
||||
4096.0,
|
||||
0.5,
|
||||
256,
|
||||
512,
|
||||
512.0,
|
||||
1024.0,
|
||||
[[32.0, 320.0, 96.0, 448.0], [64.0, 32.0, 192.0, 96.0], [160.0, 64.0, 224.0, 192.0], [320.0, 160.0, 448.0, 224.0]],
|
||||
),
|
||||
(
|
||||
75000.0,
|
||||
0.04,
|
||||
375,
|
||||
750,
|
||||
9375.0,
|
||||
18750.0,
|
||||
[
|
||||
[46.875, 468.75, 140.625, 656.25],
|
||||
[93.75, 46.875, 281.25, 140.625],
|
||||
[234.375, 93.75, 328.125, 281.25],
|
||||
[468.75, 234.375, 656.25, 328.125],
|
||||
],
|
||||
),
|
||||
] {
|
||||
for (index, rotation) in [0, 90, 180, 270].into_iter().enumerate() {
|
||||
let raw_crop = [10.0, 20.0, 10.125, 20.25];
|
||||
let raw_marker = [10.015625, 20.03125, 0.03125, 0.0625];
|
||||
let control_crop = raw_crop.map(|n| n * unit);
|
||||
let control_marker = raw_marker.map(|n| n * unit);
|
||||
let physical = if rotation % 180 == 0 { (physical_width, physical_height) } else { (physical_height, physical_width) };
|
||||
let pixels = if rotation % 180 == 0 { (width, height) } else { (height, width) };
|
||||
let render = |bytes: Vec<u8>| {
|
||||
let info = crate::inspect(Arc::new(bytes.clone()), None).expect("bounded original vector PDF");
|
||||
assert_eq!((info.pages[0].width, info.pages[0].height), physical, "check dimensions before any raster allocation");
|
||||
let mut renderer = PageRenderer::new(Arc::new(bytes), RenderConfig::default());
|
||||
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale, ..Default::default() });
|
||||
assert!(page.error.is_none(), "{:?}", page.error);
|
||||
assert_eq!((page.width, page.height), pixels);
|
||||
page
|
||||
};
|
||||
let high = render(raw_crop_unit_pdf(unit, rotation, raw_crop, Some(raw_marker)));
|
||||
let control = render(raw_crop_unit_pdf(1.0, rotation, control_crop, Some(control_marker)));
|
||||
let (bbox, count) = raw_crop_ink(&high);
|
||||
for (actual, expected) in bbox.into_iter().zip(rects[index]) {
|
||||
assert!((f64::from(actual) - expected).abs() <= 1.0, "unit={unit}, rotation={rotation}: {bbox:?} != {:?}", rects[index]);
|
||||
}
|
||||
let (control_bbox, control_count) = raw_crop_ink(&control);
|
||||
assert_eq!(bbox, control_bbox, "unit={unit}, rotation={rotation}: marker placement");
|
||||
assert_eq!(count, control_count, "unit={unit}, rotation={rotation}: visible marker ink");
|
||||
assert!(high.rgba == control.rgba, "unit={unit}, rotation={rotation}: physically equivalent crops render identically");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A page's `/UserUnit` (the size of its unit in points) scales the page and everything on
|
||||
/// it, after the crop box and before `/Rotate`, as Acrobat shows such pages: with
|
||||
/// `/UserUnit 2`, scale 1 renders pixel for pixel what scale 2 renders without it. As in
|
||||
@@ -6126,6 +6436,38 @@ trailer << /Root 1 0 R >>
|
||||
assert_eq!(px(75, 50), vec![255, 255, 255, 255], "outside the mask");
|
||||
}
|
||||
|
||||
/// Issue #2128: PowerPoint fades a gradient fill (an axial shading pattern) through a soft
|
||||
/// mask. vello_cpu 0.0.8 copies an opaque gradient straight over fully covered tiles,
|
||||
/// skipping the mask, so the fade came out solid. Images get around that by declaring
|
||||
/// transparency while a mask or blend mode is set; native gradients can't, so they are
|
||||
/// sampled as a texture then.
|
||||
#[test]
|
||||
fn soft_mask_applies_to_a_shading_pattern_fill() {
|
||||
let content = "/Pattern cs /P0 scn /M gs 0 0 100 100 re f";
|
||||
let pdf = format!(
|
||||
"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << /ExtGState << /M 5 0 R >> /Pattern << /P0 7 0 R >> >> >> endobj
|
||||
4 0 obj << /Length {} >> stream
|
||||
{content}
|
||||
endstream endobj
|
||||
5 0 obj << /Type /ExtGState /SMask << /Type /Mask /S /Alpha /G 6 0 R >> >> endobj
|
||||
6 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /CS /DeviceRGB >> /Length 20 >> stream
|
||||
0 g 0 0 50 100 re f
|
||||
endstream endobj
|
||||
7 0 obj << /PatternType 2 /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Extend [true true] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [1 0 0] /N 1 >> >> >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF",
|
||||
content.len()
|
||||
);
|
||||
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
|
||||
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
|
||||
let px = |x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec();
|
||||
assert_eq!(px(25, 50), vec![255, 0, 0, 255], "inside the mask");
|
||||
assert_eq!(px(75, 50), vec![255, 255, 255, 255], "outside the mask");
|
||||
}
|
||||
|
||||
/// ISO 32000-2 §8.5.4: `W` / `W*` clip with the current path once whichever path-painting
|
||||
/// operator ends it has painted it, `S`, `f` or `B` as much as `n`. The vendored interpreter
|
||||
/// applied the clip only on `n`: after `re W* S` everything later painted outside the clip,
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
//! The geometry of a page transition (ISO 32000-2 §12.4.4, table 164) part-way through its run:
|
||||
//! which parts of the outgoing and the incoming page a presentation paints, where, and how
|
||||
//! opaque. Pure functions in page fractions (0 to 1 across and down the page box on screen), so
|
||||
//! the painter only scales them; nothing here touches pixels or time.
|
||||
//!
|
||||
//! `Effect` is the transition as a presentation plays it (the `/Trans` entries that matter for
|
||||
//! each style, with the spec's defaults), `frame` the picture at progress `t`. Every frame starts
|
||||
//! with the outgoing page alone (`t = 0`) and ends with the incoming page alone (`t = 1`); the
|
||||
//! pieces of a frame never leave the page box.
|
||||
|
||||
/// A page transition as it is played: the style with the entries that style reads. Directions
|
||||
/// are degrees as `/Di` gives them: 0 left to right, 90 bottom to top, 180 right to left, 270 top
|
||||
/// to bottom, 315 top-left to bottom-right (Glitter; Wipe treats it the same way).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Effect {
|
||||
/// Two halves split along a horizontal (`horizontal`) or vertical line, revealing the new page
|
||||
/// from the edges inward (`inward`) or from the centre outward.
|
||||
Split { horizontal: bool, inward: bool },
|
||||
/// Strips across (`horizontal`) or down the page open together, like Venetian blinds.
|
||||
Blinds { horizontal: bool },
|
||||
/// A rectangle grows from the centre (`inward` false) or the edges close in on the centre.
|
||||
Box { inward: bool },
|
||||
/// A line sweeps across the page in `direction`, revealing the new page behind it.
|
||||
Wipe { direction: u16 },
|
||||
/// Random squares of the new page appear until it is whole.
|
||||
Dissolve,
|
||||
/// Like Dissolve, but the squares appear in a band that sweeps in `direction`.
|
||||
Glitter { direction: u16 },
|
||||
/// No effect: the new page replaces the old one.
|
||||
Replace,
|
||||
/// The new page flies in from off-screen in `direction` (or from the centre, `None`), growing
|
||||
/// from `scale`; `outward` flies the old page off instead. With `opaque` the moving page hides
|
||||
/// what is under it (it always does here: the pieces overlap in paint order).
|
||||
Fly { direction: Option<u16>, inward: bool, scale: f32 },
|
||||
/// The new page pushes the old one off the page in `direction`.
|
||||
Push { direction: u16 },
|
||||
/// The new page slides in over the old one in `direction`.
|
||||
Cover { direction: u16 },
|
||||
/// The old page slides off in `direction`, uncovering the new one beneath.
|
||||
Uncover { direction: u16 },
|
||||
/// The new page fades in over the old one.
|
||||
Fade,
|
||||
}
|
||||
|
||||
impl Effect {
|
||||
/// The effect for a `/Trans` dictionary read as its parts: the `/S` name, `/Dm` as
|
||||
/// `horizontal`, `/M` as `inward`, `/Di` as `direction` (`None` for `/None`), `/SS` and `/B`.
|
||||
/// An unknown style is `Replace`, as the spec's default; an entry a style does not read is
|
||||
/// ignored.
|
||||
pub fn from_parts(style: &str, horizontal: bool, inward: bool, direction: Option<u16>, scale: f32) -> Self {
|
||||
let dir = direction.filter(|d| matches!(d, 0 | 90 | 180 | 270 | 315)).unwrap_or(0);
|
||||
match style {
|
||||
"Split" => Self::Split { horizontal, inward },
|
||||
"Blinds" => Self::Blinds { horizontal },
|
||||
"Box" => Self::Box { inward },
|
||||
"Wipe" => Self::Wipe { direction: dir },
|
||||
"Dissolve" => Self::Dissolve,
|
||||
"Glitter" => Self::Glitter { direction: dir },
|
||||
"Fly" => Self::Fly {
|
||||
direction: direction.filter(|d| matches!(d, 0 | 90 | 180 | 270)),
|
||||
inward,
|
||||
scale: if scale.is_finite() && scale > 0.0 { scale.min(1.0) } else { 1.0 },
|
||||
},
|
||||
"Push" => Self::Push { direction: dir },
|
||||
"Cover" => Self::Cover { direction: dir },
|
||||
"Uncover" => Self::Uncover { direction: dir },
|
||||
"Fade" => Self::Fade,
|
||||
_ => Self::Replace,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A part of a page to paint: `src` is the part of that page, `dst` where it goes, both as
|
||||
/// fractions `[x0, y0, x1, y1]` of the page box (y down), `alpha` its opacity.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Piece {
|
||||
pub src: [f32; 4],
|
||||
pub dst: [f32; 4],
|
||||
pub alpha: f32,
|
||||
}
|
||||
|
||||
/// One moment of a transition: the pieces of the outgoing page, then of the incoming page, in
|
||||
/// paint order (later pieces over earlier ones).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Frame {
|
||||
pub outgoing: Vec<Piece>,
|
||||
pub incoming: Vec<Piece>,
|
||||
}
|
||||
|
||||
/// Strips in Blinds, and cells per side in Dissolve and Glitter.
|
||||
const BLINDS: usize = 8;
|
||||
const GRID: usize = 8;
|
||||
/// Strips that approximate a diagonal sweep.
|
||||
const DIAGONAL_STRIPS: usize = 16;
|
||||
const FULL: [f32; 4] = [0.0, 0.0, 1.0, 1.0];
|
||||
|
||||
fn whole(alpha: f32) -> Piece {
|
||||
Piece { src: FULL, dst: FULL, alpha }
|
||||
}
|
||||
|
||||
/// A piece of the page at `rect` (fractions), shown in place.
|
||||
fn at(rect: [f32; 4]) -> Piece {
|
||||
Piece { src: rect, dst: rect, alpha: 1.0 }
|
||||
}
|
||||
|
||||
/// The whole page shifted by `dx, dy` (fractions), clipped to the page box, or `None` when it is
|
||||
/// entirely off the page.
|
||||
fn shifted(dx: f32, dy: f32) -> Option<Piece> {
|
||||
let dst = [dx.max(0.0), dy.max(0.0), (1.0 + dx).min(1.0), (1.0 + dy).min(1.0)];
|
||||
if dst[2] - dst[0] <= 0.0 || dst[3] - dst[1] <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let src = [dst[0] - dx, dst[1] - dy, dst[2] - dx, dst[3] - dy];
|
||||
Some(Piece { src, dst, alpha: 1.0 })
|
||||
}
|
||||
|
||||
/// Where a page is at `k` of the way along `direction` (0 at the start, 1 at the end of a move
|
||||
/// across the whole page), as an offset in fractions.
|
||||
fn offset(direction: u16, k: f32) -> (f32, f32) {
|
||||
match direction {
|
||||
90 => (0.0, -k),
|
||||
180 => (-k, 0.0),
|
||||
270 => (0.0, k),
|
||||
_ => (k, 0.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// A small deterministic hash, so Dissolve and Glitter look random but repeat exactly.
|
||||
fn hash(i: u32) -> u32 {
|
||||
let mut x = i.wrapping_mul(0x9E37_79B9).wrapping_add(0x7F4A_7C15);
|
||||
x ^= x >> 15;
|
||||
x = x.wrapping_mul(0x2C1B_3C6D);
|
||||
x ^= x >> 12;
|
||||
x
|
||||
}
|
||||
|
||||
/// The cells of the dissolve grid in the order they appear: by `key`, ties by position.
|
||||
fn cells_in_order(key: impl Fn(usize, usize) -> f32) -> Vec<[f32; 4]> {
|
||||
let mut cells: Vec<(f32, usize, usize)> = (0..GRID * GRID).map(|i| (key(i % GRID, i / GRID), i / GRID, i % GRID)).collect();
|
||||
cells.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)).then(a.2.cmp(&b.2)));
|
||||
let side = 1.0 / GRID as f32;
|
||||
cells.into_iter().map(|(_, row, col)| [col as f32 * side, row as f32 * side, (col + 1) as f32 * side, (row + 1) as f32 * side]).collect()
|
||||
}
|
||||
|
||||
/// The pieces of the incoming page at `t` for a diagonal sweep: a point is revealed once its
|
||||
/// distance along the diagonal, `(x + y) / 2`, is within `t`, approximated by vertical strips.
|
||||
fn diagonal(t: f32) -> Vec<Piece> {
|
||||
let w = 1.0 / DIAGONAL_STRIPS as f32;
|
||||
(0..DIAGONAL_STRIPS)
|
||||
.filter_map(|k| {
|
||||
let x0 = k as f32 * w;
|
||||
let h = (2.0 * t - (x0 + w / 2.0)).clamp(0.0, 1.0);
|
||||
(h > 0.0).then(|| at([x0, 0.0, x0 + w, h]))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The incoming page revealed by a straight sweep in `direction` at `t`.
|
||||
fn swept(direction: u16, t: f32) -> Vec<Piece> {
|
||||
match direction {
|
||||
90 => vec![at([0.0, 1.0 - t, 1.0, 1.0])],
|
||||
180 => vec![at([1.0 - t, 0.0, 1.0, 1.0])],
|
||||
270 => vec![at([0.0, 0.0, 1.0, t])],
|
||||
315 => diagonal(t),
|
||||
_ => vec![at([0.0, 0.0, t, 1.0])],
|
||||
}
|
||||
}
|
||||
|
||||
/// The picture of `effect` at progress `t` (0: the outgoing page alone; 1: the incoming page
|
||||
/// alone; anything else clamped). Pieces are in paint order and never leave the page box.
|
||||
pub fn frame(effect: &Effect, t: f32) -> Frame {
|
||||
let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 1.0 };
|
||||
if t <= 0.0 {
|
||||
return Frame { outgoing: vec![whole(1.0)], incoming: Vec::new() };
|
||||
}
|
||||
if t >= 1.0 {
|
||||
return Frame { outgoing: Vec::new(), incoming: vec![whole(1.0)] };
|
||||
}
|
||||
let over_old = |incoming: Vec<Piece>| Frame { outgoing: vec![whole(1.0)], incoming };
|
||||
match *effect {
|
||||
Effect::Replace => Frame { outgoing: Vec::new(), incoming: vec![whole(1.0)] },
|
||||
Effect::Fade => Frame { outgoing: vec![whole(1.0)], incoming: vec![whole(t)] },
|
||||
Effect::Wipe { direction } => over_old(swept(direction, t)),
|
||||
Effect::Split { horizontal, inward } => {
|
||||
let h = t / 2.0;
|
||||
let bands = match (horizontal, inward) {
|
||||
(true, true) => vec![at([0.0, 0.0, 1.0, h]), at([0.0, 1.0 - h, 1.0, 1.0])],
|
||||
(true, false) => vec![at([0.0, 0.5 - h, 1.0, 0.5 + h])],
|
||||
(false, true) => vec![at([0.0, 0.0, h, 1.0]), at([1.0 - h, 0.0, 1.0, 1.0])],
|
||||
(false, false) => vec![at([0.5 - h, 0.0, 0.5 + h, 1.0])],
|
||||
};
|
||||
over_old(bands)
|
||||
}
|
||||
Effect::Blinds { horizontal } => {
|
||||
let w = 1.0 / BLINDS as f32;
|
||||
let strips = (0..BLINDS)
|
||||
.map(|k| {
|
||||
let a = k as f32 * w;
|
||||
if horizontal { at([0.0, a, 1.0, a + t * w]) } else { at([a, 0.0, a + t * w, 1.0]) }
|
||||
})
|
||||
.collect();
|
||||
over_old(strips)
|
||||
}
|
||||
Effect::Box { inward } => {
|
||||
let h = t / 2.0;
|
||||
let pieces = if inward {
|
||||
vec![at([0.0, 0.0, 1.0, h]), at([0.0, 1.0 - h, 1.0, 1.0]), at([0.0, h, h, 1.0 - h]), at([1.0 - h, h, 1.0, 1.0 - h])]
|
||||
} else {
|
||||
vec![at([0.5 - h, 0.5 - h, 0.5 + h, 0.5 + h])]
|
||||
};
|
||||
over_old(pieces)
|
||||
}
|
||||
Effect::Dissolve => {
|
||||
let cells = cells_in_order(|col, row| hash((row * GRID + col) as u32) as f32);
|
||||
let shown = ((t * cells.len() as f32).round() as usize).min(cells.len());
|
||||
over_old(cells.into_iter().take(shown).map(at).collect())
|
||||
}
|
||||
Effect::Glitter { direction } => {
|
||||
// Ordered by how far along the sweep a cell lies, jittered so the band sparkles.
|
||||
let along = |col: usize, row: usize| -> f32 {
|
||||
let (x, y) = ((col as f32 + 0.5) / GRID as f32, (row as f32 + 0.5) / GRID as f32);
|
||||
match direction {
|
||||
90 => 1.0 - y,
|
||||
180 => 1.0 - x,
|
||||
270 => y,
|
||||
315 => (x + y) / 2.0,
|
||||
_ => x,
|
||||
}
|
||||
};
|
||||
let cells = cells_in_order(|col, row| along(col, row) + (hash((row * GRID + col) as u32 + 977) % 1000) as f32 / 4000.0);
|
||||
let shown = ((t * cells.len() as f32).round() as usize).min(cells.len());
|
||||
over_old(cells.into_iter().take(shown).map(at).collect())
|
||||
}
|
||||
Effect::Push { direction } => {
|
||||
let (dx, dy) = offset(direction, t);
|
||||
let (ux, uy) = offset(direction, 1.0);
|
||||
Frame { outgoing: shifted(dx, dy).into_iter().collect(), incoming: shifted(dx - ux, dy - uy).into_iter().collect() }
|
||||
}
|
||||
Effect::Cover { direction } => {
|
||||
let (dx, dy) = offset(direction, t);
|
||||
let (ux, uy) = offset(direction, 1.0);
|
||||
over_old(shifted(dx - ux, dy - uy).into_iter().collect())
|
||||
}
|
||||
Effect::Uncover { direction } => {
|
||||
let (dx, dy) = offset(direction, t);
|
||||
Frame { outgoing: shifted(dx, dy).into_iter().collect(), incoming: vec![whole(1.0)] }
|
||||
}
|
||||
Effect::Fly { direction, inward, scale } => {
|
||||
// The moving page: at `k` of its flight (0 off-screen or small, 1 whole and in place).
|
||||
// `leaving`: the page moves on in `direction` (flying out); else it comes in from the
|
||||
// side it would have left from (flying in).
|
||||
let flight = |k: f32, leaving: bool| -> Option<Piece> {
|
||||
let s = scale + (1.0 - scale) * k;
|
||||
let (dx, dy) = direction.map_or((0.0, 0.0), |d| {
|
||||
let (ox, oy) = offset(d, 1.0 - k);
|
||||
if leaving { (ox, oy) } else { (-ox, -oy) }
|
||||
});
|
||||
// The page at scale `s`, centred, shifted by (dx, dy), clipped to the page box.
|
||||
let half = s / 2.0;
|
||||
let dst = [0.5 - half + dx, 0.5 - half + dy, 0.5 + half + dx, 0.5 + half + dy];
|
||||
let clip = [dst[0].max(0.0), dst[1].max(0.0), dst[2].min(1.0), dst[3].min(1.0)];
|
||||
if clip[2] - clip[0] <= 0.0 || clip[3] - clip[1] <= 0.0 || s <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let src = [(clip[0] - dst[0]) / s, (clip[1] - dst[1]) / s, (clip[2] - dst[0]) / s, (clip[3] - dst[1]) / s];
|
||||
Some(Piece { src, dst: clip, alpha: 1.0 })
|
||||
};
|
||||
if inward {
|
||||
Frame { outgoing: vec![whole(1.0)], incoming: flight(t, false).into_iter().collect() }
|
||||
} else {
|
||||
Frame { outgoing: flight(1.0 - t, true).into_iter().collect(), incoming: vec![whole(1.0)] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn area(p: &Piece) -> f32 {
|
||||
(p.dst[2] - p.dst[0]).max(0.0) * (p.dst[3] - p.dst[1]).max(0.0)
|
||||
}
|
||||
|
||||
fn inside(p: &Piece) -> bool {
|
||||
p.dst.iter().chain(p.src.iter()).all(|v| (-1e-4..=1.0 + 1e-4).contains(v)) && p.dst[0] <= p.dst[2] && p.dst[1] <= p.dst[3]
|
||||
}
|
||||
|
||||
fn all_effects() -> Vec<Effect> {
|
||||
let mut v = vec![
|
||||
Effect::Replace,
|
||||
Effect::Fade,
|
||||
Effect::Dissolve,
|
||||
Effect::Split { horizontal: true, inward: true },
|
||||
Effect::Split { horizontal: false, inward: false },
|
||||
Effect::Blinds { horizontal: true },
|
||||
Effect::Blinds { horizontal: false },
|
||||
Effect::Box { inward: true },
|
||||
Effect::Box { inward: false },
|
||||
];
|
||||
for d in [0, 90, 180, 270, 315] {
|
||||
v.push(Effect::Wipe { direction: d });
|
||||
v.push(Effect::Glitter { direction: d });
|
||||
}
|
||||
for d in [0, 90, 180, 270] {
|
||||
v.push(Effect::Push { direction: d });
|
||||
v.push(Effect::Cover { direction: d });
|
||||
v.push(Effect::Uncover { direction: d });
|
||||
v.push(Effect::Fly { direction: Some(d), inward: true, scale: 0.5 });
|
||||
v.push(Effect::Fly { direction: Some(d), inward: false, scale: 1.0 });
|
||||
}
|
||||
v.push(Effect::Fly { direction: None, inward: true, scale: 0.25 });
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_effect_starts_with_the_old_page_ends_with_the_new_and_stays_on_the_page() {
|
||||
for e in all_effects() {
|
||||
let start = frame(&e, 0.0);
|
||||
assert_eq!((start.outgoing.len(), start.incoming.len()), (1, 0), "{e:?} at 0");
|
||||
assert_eq!(start.outgoing[0], whole(1.0));
|
||||
let end = frame(&e, 1.0);
|
||||
assert_eq!((end.outgoing.len(), end.incoming.len()), (0, 1), "{e:?} at 1");
|
||||
assert_eq!(end.incoming[0], whole(1.0));
|
||||
assert_eq!(frame(&e, f32::NAN), end, "{e:?}: a non-number is the end");
|
||||
assert_eq!(frame(&e, -3.0), start);
|
||||
for k in 1..20 {
|
||||
let f = frame(&e, k as f32 / 20.0);
|
||||
for p in f.outgoing.iter().chain(f.incoming.iter()) {
|
||||
assert!(inside(p), "{e:?} at {k}/20: {p:?} leaves the page");
|
||||
assert!((0.0..=1.0).contains(&p.alpha));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reveals_grow_with_time() {
|
||||
// For every effect that reveals the new page in place, the revealed area never shrinks.
|
||||
for e in all_effects().into_iter().filter(|e| {
|
||||
!matches!(e, Effect::Replace | Effect::Fade | Effect::Push { .. } | Effect::Cover { .. } | Effect::Uncover { .. } | Effect::Fly { .. })
|
||||
}) {
|
||||
let mut last = 0.0;
|
||||
for k in 0..=20 {
|
||||
let f = frame(&e, k as f32 / 20.0);
|
||||
let a: f32 = f.incoming.iter().map(area).sum();
|
||||
assert!(a + 1e-4 >= last, "{e:?}: revealed area fell from {last} to {a} at {k}/20");
|
||||
last = a;
|
||||
}
|
||||
assert!((last - 1.0).abs() < 1e-4, "{e:?} ends whole: {last}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wipes_sweep_in_their_direction() {
|
||||
let f = frame(&Effect::Wipe { direction: 0 }, 0.25);
|
||||
assert_eq!(f.incoming, vec![at([0.0, 0.0, 0.25, 1.0])], "left to right");
|
||||
assert_eq!(frame(&Effect::Wipe { direction: 90 }, 0.25).incoming, vec![at([0.0, 0.75, 1.0, 1.0])], "bottom to top");
|
||||
assert_eq!(frame(&Effect::Wipe { direction: 180 }, 0.25).incoming, vec![at([0.75, 0.0, 1.0, 1.0])], "right to left");
|
||||
assert_eq!(frame(&Effect::Wipe { direction: 270 }, 0.25).incoming, vec![at([0.0, 0.0, 1.0, 0.25])], "top to bottom");
|
||||
// Diagonal: the top-left corner first, the bottom-right last.
|
||||
let d = frame(&Effect::Wipe { direction: 315 }, 0.25);
|
||||
assert!(!d.incoming.is_empty() && d.incoming[0].dst[0] == 0.0 && d.incoming[0].dst[3] > 0.0, "{:?}", d.incoming);
|
||||
assert!(d.incoming.iter().all(|p| p.dst[0] < 0.5), "nothing past the middle yet: {:?}", d.incoming);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_box_and_blinds_open_where_they_say() {
|
||||
let h = frame(&Effect::Split { horizontal: true, inward: true }, 0.5).incoming;
|
||||
assert_eq!(h, vec![at([0.0, 0.0, 1.0, 0.25]), at([0.0, 0.75, 1.0, 1.0])], "from the top and bottom edges");
|
||||
let v = frame(&Effect::Split { horizontal: false, inward: false }, 0.5).incoming;
|
||||
assert_eq!(v, vec![at([0.25, 0.0, 0.75, 1.0])], "a vertical band from the centre");
|
||||
let b = frame(&Effect::Box { inward: false }, 0.5).incoming;
|
||||
assert_eq!(b, vec![at([0.25, 0.25, 0.75, 0.75])]);
|
||||
let bi = frame(&Effect::Box { inward: true }, 0.5).incoming;
|
||||
assert_eq!(bi.len(), 4);
|
||||
assert!((bi.iter().map(area).sum::<f32>() - 0.75).abs() < 1e-5, "a 0.5 hole is left in the middle");
|
||||
let blinds = frame(&Effect::Blinds { horizontal: true }, 0.5).incoming;
|
||||
assert_eq!(blinds.len(), BLINDS);
|
||||
assert_eq!(blinds[1], at([0.0, 0.125, 1.0, 0.1875]), "each strip opens from its top");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dissolve_and_glitter_show_a_share_of_cells_in_a_fixed_order() {
|
||||
let half = frame(&Effect::Dissolve, 0.5).incoming;
|
||||
assert_eq!(half.len(), GRID * GRID / 2);
|
||||
assert_eq!(frame(&Effect::Dissolve, 0.5).incoming, half, "the same every time");
|
||||
let quarter = frame(&Effect::Dissolve, 0.25).incoming;
|
||||
assert_eq!(&half[..quarter.len()], &quarter[..], "cells only ever join");
|
||||
assert!(half.windows(2).any(|w| w[0].dst[1] != w[1].dst[1] || w[0].dst[0] + 0.125 != w[1].dst[0]), "not in reading order");
|
||||
// Glitter sweeps: at a third, every cell shown is in the left half for direction 0.
|
||||
let g = frame(&Effect::Glitter { direction: 0 }, 0.3).incoming;
|
||||
assert!(!g.is_empty() && g.iter().all(|p| p.dst[0] < 0.5), "{g:?}");
|
||||
let g = frame(&Effect::Glitter { direction: 270 }, 0.3).incoming;
|
||||
assert!(g.iter().all(|p| p.dst[1] < 0.5), "top first: {g:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_cover_uncover_and_fly_move_whole_pages() {
|
||||
let f = frame(&Effect::Push { direction: 0 }, 0.25);
|
||||
assert_eq!(f.outgoing, vec![Piece { src: [0.0, 0.0, 0.75, 1.0], dst: [0.25, 0.0, 1.0, 1.0], alpha: 1.0 }], "the old page moves right");
|
||||
assert_eq!(
|
||||
f.incoming,
|
||||
vec![Piece { src: [0.75, 0.0, 1.0, 1.0], dst: [0.0, 0.0, 0.25, 1.0], alpha: 1.0 }],
|
||||
"the new page comes in from the left"
|
||||
);
|
||||
let f = frame(&Effect::Push { direction: 270 }, 0.5);
|
||||
assert_eq!(f.incoming[0].dst, [0.0, 0.0, 1.0, 0.5], "top to bottom");
|
||||
let c = frame(&Effect::Cover { direction: 180 }, 0.25);
|
||||
assert_eq!(
|
||||
(c.outgoing, c.incoming[0].dst),
|
||||
(vec![whole(1.0)], [0.75, 0.0, 1.0, 1.0]),
|
||||
"the old page stays, the new slides in from the right"
|
||||
);
|
||||
let u = frame(&Effect::Uncover { direction: 90 }, 0.25);
|
||||
assert_eq!((u.incoming, u.outgoing[0].dst), (vec![whole(1.0)], [0.0, 0.0, 1.0, 0.75]), "the old page slides up off the new one");
|
||||
// Fly in from the centre: a quarter-size page in the middle at the start, growing.
|
||||
let f = frame(&Effect::Fly { direction: None, inward: true, scale: 0.25 }, 0.5);
|
||||
let p = f.incoming[0];
|
||||
assert_eq!(p.src, FULL, "the whole page, small");
|
||||
assert!((p.dst[2] - p.dst[0] - 0.625).abs() < 1e-5 && (p.dst[0] - 0.1875).abs() < 1e-5, "{p:?}");
|
||||
// Fly in from the left: off-page at the start, so nothing to paint yet.
|
||||
assert!(frame(&Effect::Fly { direction: Some(0), inward: true, scale: 1.0 }, 1e-3).incoming.first().is_some_and(|p| p.dst[2] < 0.01));
|
||||
// Fly out: the old page leaves to the right over the new page.
|
||||
let f = frame(&Effect::Fly { direction: Some(0), inward: false, scale: 1.0 }, 0.5);
|
||||
assert_eq!((f.incoming, f.outgoing[0].dst), (vec![whole(1.0)], [0.5, 0.0, 1.0, 1.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effects_come_from_trans_parts_with_the_defaults() {
|
||||
assert_eq!(Effect::from_parts("Wipe", true, true, Some(270), 1.0), Effect::Wipe { direction: 270 });
|
||||
assert_eq!(
|
||||
Effect::from_parts("Wipe", true, true, Some(45), 1.0),
|
||||
Effect::Wipe { direction: 0 },
|
||||
"a direction outside the five is the default"
|
||||
);
|
||||
assert_eq!(Effect::from_parts("Split", false, false, Some(90), 1.0), Effect::Split { horizontal: false, inward: false });
|
||||
assert_eq!(Effect::from_parts("Fly", true, true, None, 0.5), Effect::Fly { direction: None, inward: true, scale: 0.5 });
|
||||
assert_eq!(
|
||||
Effect::from_parts("Fly", true, false, Some(315), f32::NAN),
|
||||
Effect::Fly { direction: None, inward: false, scale: 1.0 },
|
||||
"Fly has no diagonal; a bad scale is 1"
|
||||
);
|
||||
assert_eq!(Effect::from_parts("Swirl", true, true, Some(0), 1.0), Effect::Replace);
|
||||
assert_eq!(Effect::from_parts("R", true, true, Some(0), 1.0), Effect::Replace);
|
||||
}
|
||||
}
|
||||
+143
-11
@@ -156,13 +156,15 @@ impl DigestCache {
|
||||
candidates
|
||||
}
|
||||
|
||||
/// The signed revision `bytes[..end]`, parsed (and kept).
|
||||
fn revision(&self, bytes: &[u8], end: usize) -> Option<Document> {
|
||||
/// The signed revision `bytes[..end]`, parsed (and kept). An encrypted one is opened with
|
||||
/// `doc`'s own key (the same `/Encrypt` and `/ID`), so a user password the document was
|
||||
/// opened with is neither needed again nor kept here (#1808).
|
||||
fn revision(&self, doc: &Document, bytes: &[u8], end: usize) -> Option<Document> {
|
||||
let key = (end, fingerprint(&bytes[..end]));
|
||||
if let Some(d) = self.revisions.lock().ok().and_then(|m| m.get(&key).cloned()) {
|
||||
return Some(d);
|
||||
}
|
||||
let d = Document::open(Arc::new(bytes[..end].to_vec())).ok()?;
|
||||
let d = doc.open_with_same_credentials(Arc::new(bytes[..end].to_vec())).ok()?;
|
||||
if let Ok(mut m) = self.revisions.lock() {
|
||||
if m.len() > 8 {
|
||||
m.clear();
|
||||
@@ -726,7 +728,7 @@ fn validate_into(doc: &Document, bytes: &[u8], trust: &TrustStore, v: &Dict, inf
|
||||
let covered = o1 + l1;
|
||||
info.signed_len = covered;
|
||||
// The signed revision's number: its cross-reference sections (1 for a reconstructed file).
|
||||
info.revision = cache.revision(bytes, covered).map_or(1, |d| d.revisions().len().max(1));
|
||||
info.revision = cache.revision(doc, bytes, covered).map_or(1, |d| d.revisions().len().max(1));
|
||||
// What can't be checked is unknown, not invalid: only data that is wrong or fails its check
|
||||
// is "invalid".
|
||||
let unsupported = |info: &mut SignatureInfo, why: &str| {
|
||||
@@ -853,7 +855,7 @@ fn finish_validation(
|
||||
info.modification = if covered == bytes.len() || bytes[covered..].iter().all(|b| b.is_ascii_whitespace() || *b == 0) {
|
||||
Modification::None
|
||||
} else {
|
||||
classify_changes(doc, cache.revision(bytes, covered), info.certify)
|
||||
classify_changes(doc, cache.revision(doc, bytes, covered), info.certify)
|
||||
};
|
||||
let mut problems = false;
|
||||
// The changes are handed over as the words they are, not stitched into the sentence, so an
|
||||
@@ -1015,7 +1017,7 @@ fn validate_doc_timestamp(doc: &Document, bytes: &[u8], v: &Dict, info: &mut Sig
|
||||
};
|
||||
let covered = o1 + l1;
|
||||
info.signed_len = covered;
|
||||
info.revision = cache.revision(bytes, covered).map_or(1, |d| d.revisions().len().max(1));
|
||||
info.revision = cache.revision(doc, bytes, covered).map_or(1, |d| d.revisions().len().max(1));
|
||||
let token = match crate::timestamp::parse_token(&contents) {
|
||||
Ok(t) => t,
|
||||
Err(e) => return invalid(info, Detail::of("The timestamp token could not be read ({}).", Fill::Reason(e.to_string()))),
|
||||
@@ -1038,7 +1040,7 @@ fn validate_doc_timestamp(doc: &Document, bytes: &[u8], v: &Dict, info: &mut Sig
|
||||
info.modification = if covered == bytes.len() || bytes[covered..].iter().all(|b| b.is_ascii_whitespace() || *b == 0) {
|
||||
Modification::None
|
||||
} else {
|
||||
classify_changes(doc, cache.revision(bytes, covered), None)
|
||||
classify_changes(doc, cache.revision(doc, bytes, covered), None)
|
||||
};
|
||||
match &info.modification {
|
||||
Modification::None => info.details.push(Detail::plain("This document has not been modified since this timestamp was applied.")),
|
||||
@@ -1203,6 +1205,8 @@ fn classify_changes(doc: &Document, old: Option<Document>, p: Option<u8>) -> Mod
|
||||
// attacker-written version carries (relabelling a signed page /Type /Sig, or dropping its
|
||||
// /Type and adding /ModDate, must not reclassify it away from page content).
|
||||
let signed_pages: HashSet<ObjRef> = pdfcraft_annot::page_refs(&old).unwrap_or_default().into_iter().collect();
|
||||
// What each signed page draws from, by the role it held when signed (#1787).
|
||||
let signed_resources = signed_page_resources(&old, &signed_pages);
|
||||
let old_info = old.trailer().get(b"Info").and_then(Object::as_ref);
|
||||
let (mut allowed, mut disallowed): (Vec<&'static str>, Vec<&'static str>) = (Vec::new(), Vec::new());
|
||||
let dss = dss_parts(doc, &old);
|
||||
@@ -1248,10 +1252,23 @@ fn classify_changes(doc: &Document, old: Option<Document>, p: Option<u8>) -> Mod
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let what = match &*new {
|
||||
// A resource of a signed page is page content whatever keys its new version carries
|
||||
// (#1787): a /FT or /Subtype /Widget written by whoever rewrote a font, an XObject or a
|
||||
// graphics state does not make that a form fill. A container (the resources dictionary,
|
||||
// a category dictionary) that kept every entry it had and only gained names changes
|
||||
// nothing the signed content draws, and is judged as before.
|
||||
let kept_entries = |d: &Dict| before.as_deref().and_then(Object::as_dict).is_some_and(|b| b.iter().all(|(k, v)| d.get(k) == Some(v)));
|
||||
let resource_kind = match (signed_resources.get(&r), &*new) {
|
||||
(Some(false), _) => Some(kind::PAGE_CONTENT),
|
||||
(Some(true), Object::Dict(d)) if kept_entries(d) => None,
|
||||
(Some(true), _) => Some(kind::PAGE_CONTENT),
|
||||
(None, _) => None,
|
||||
};
|
||||
let what = match (resource_kind, &*new) {
|
||||
(Some(k), _) => k,
|
||||
// The Document Security Store's own arrays and dictionaries, when they only grew.
|
||||
_ if dss.contains(&num) => kind::SECURITY_STORE,
|
||||
Object::Dict(d) => {
|
||||
(_, _) if dss.contains(&num) => kind::SECURITY_STORE,
|
||||
(_, Object::Dict(d)) => {
|
||||
let was = before.as_deref().and_then(Object::as_dict);
|
||||
if signed_pages.contains(&r) {
|
||||
// A signed page is always judged by what the page shows, whatever it is now
|
||||
@@ -1264,7 +1281,7 @@ fn classify_changes(doc: &Document, old: Option<Document>, p: Option<u8>) -> Mod
|
||||
change_kind(doc, &old, d, was)
|
||||
}
|
||||
}
|
||||
Object::Stream(s) => {
|
||||
(_, Object::Stream(s)) => {
|
||||
// File plumbing is what the stream was when signed: the /Type it has now is
|
||||
// written by whoever changed it, and the renderer ignores /Type (a page's Form
|
||||
// XObject relabelled /ObjStm still draws its new content).
|
||||
@@ -1485,6 +1502,88 @@ fn is_signature_dict(d: &Dict) -> bool {
|
||||
matches!(d.name(b"Type"), Some(b"Sig" | b"DocTimeStamp" | b"SigRef" | b"TransformParams")) || d.name(b"FT") == Some(b"Sig")
|
||||
}
|
||||
|
||||
/// The objects the signed pages draw from, by reference, with the role each held when signed
|
||||
/// (#1787): `true` for a container (a page's resources dictionary, or one of its category
|
||||
/// dictionaries), where a name added later can't be reached by unchanged content; `false` for
|
||||
/// everything reached from a container's entries (fonts and their files, XObjects and their own
|
||||
/// resources, graphics states and soft masks, colour spaces, patterns, shadings, optional-content
|
||||
/// properties), where any change changes what the page shows. Resources are the page's own or
|
||||
/// inherited. Bounded by depth and by the number of objects visited; past the bound the rest is
|
||||
/// judged as before.
|
||||
fn signed_page_resources(old: &Document, pages: &HashSet<ObjRef>) -> HashMap<ObjRef, bool> {
|
||||
const CATEGORIES: [&[u8]; 7] = [b"Font", b"XObject", b"ExtGState", b"ColorSpace", b"Pattern", b"Shading", b"Properties"];
|
||||
const MAX_VISITED: usize = 50_000;
|
||||
const MAX_DEPTH: u8 = 32;
|
||||
const MAX_INHERITANCE: usize = 64;
|
||||
|
||||
/// Everything reachable from `o` is content the page draws (a leaf role, overriding a
|
||||
/// container one reached by another path). A signed page or the catalog reached this way
|
||||
/// (a `/P` or `/Parent` back-reference) keeps the role of its own: the walk stops there.
|
||||
fn reach(old: &Document, o: &Object, depth: u8, stop: &HashSet<ObjRef>, roles: &mut HashMap<ObjRef, bool>) {
|
||||
if roles.len() >= MAX_VISITED || depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
let resolved;
|
||||
let o = match o {
|
||||
Object::Ref(r) => {
|
||||
if stop.contains(r) || roles.insert(*r, false).is_some() {
|
||||
return;
|
||||
}
|
||||
resolved = old.get(*r);
|
||||
&*resolved
|
||||
}
|
||||
other => other,
|
||||
};
|
||||
match o {
|
||||
Object::Dict(d) => d.iter().for_each(|(_, v)| reach(old, v, depth + 1, stop, roles)),
|
||||
Object::Stream(s) => s.dict.iter().for_each(|(_, v)| reach(old, v, depth + 1, stop, roles)),
|
||||
Object::Array(a) => a.iter().for_each(|v| reach(old, v, depth + 1, stop, roles)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// A page's `/Resources`, its own or the nearest ancestor's.
|
||||
fn resources(old: &Document, page: ObjRef) -> Option<Object> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut cur = page;
|
||||
while seen.insert(cur) && seen.len() <= MAX_INHERITANCE {
|
||||
let d = old.get(cur);
|
||||
let d = d.as_dict()?;
|
||||
if let Some(r) = d.get(b"Resources") {
|
||||
return Some(r.clone());
|
||||
}
|
||||
cur = d.get(b"Parent").and_then(Object::as_ref)?;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
let mut roles = HashMap::new();
|
||||
let mut stop = pages.clone();
|
||||
stop.extend(old.root());
|
||||
for page in pages {
|
||||
let Some(res) = resources(old, *page) else { continue };
|
||||
let container = |o: &Object, roles: &mut HashMap<ObjRef, bool>| -> Arc<Object> {
|
||||
match o {
|
||||
Object::Ref(r) => {
|
||||
roles.entry(*r).or_insert(true);
|
||||
old.get(*r)
|
||||
}
|
||||
other => Arc::new(other.clone()),
|
||||
}
|
||||
};
|
||||
let res = container(&res, &mut roles);
|
||||
let Some(d) = res.as_dict() else { continue };
|
||||
for category in CATEGORIES {
|
||||
let Some(v) = d.get(category) else { continue };
|
||||
let category = container(v, &mut roles);
|
||||
if let Some(entries) = category.as_dict() {
|
||||
entries.iter().for_each(|(_, entry)| reach(old, entry, 0, &stop, &mut roles));
|
||||
}
|
||||
}
|
||||
}
|
||||
roles
|
||||
}
|
||||
|
||||
/// A changed object that was a page in the signed revision, judged by what the page shows rather
|
||||
/// than by any `/Type` its new version carries. Only added annotations are permitted; any other
|
||||
/// difference (content, resources, a relabelled or dropped `/Type`) is a page content change.
|
||||
@@ -1605,6 +1704,27 @@ impl Default for AppearanceLabels {
|
||||
}
|
||||
}
|
||||
|
||||
impl AppearanceLabels {
|
||||
/// Each label with its field name (`signed_by`, `distinguished_name`, `reason`, `location`,
|
||||
/// `date`), in the order the visible signature shows them.
|
||||
pub fn fields(&self) -> [(&'static str, &str); 5] {
|
||||
[
|
||||
("signed_by", self.signed_by.as_str()),
|
||||
("distinguished_name", self.distinguished_name.as_str()),
|
||||
("reason", self.reason.as_str()),
|
||||
("location", self.location.as_str()),
|
||||
("date", self.date.as_str()),
|
||||
]
|
||||
}
|
||||
|
||||
/// The first label, with its field name, that has a character the visible signature's font
|
||||
/// can't draw, and that character. Signing refuses such a label when it is shown, so an
|
||||
/// interface checks its translated labels here and falls back to [`Default`] if one fails.
|
||||
pub fn first_undrawable(&self) -> Option<(&'static str, char)> {
|
||||
self.fields().into_iter().find_map(|(field, text)| pdfcraft_fonts::first_undrawable(text).map(|c| (field, c)))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct SignOptions {
|
||||
/// Sign this existing, unsigned signature field.
|
||||
@@ -1948,6 +2068,7 @@ pub fn display_date(pdf: &str) -> String {
|
||||
/// Helvetica (the signer's name, the certificate's subject, the reason or the location) has a
|
||||
/// character Helvetica can't draw: signing invisibly, or leaving that part out, still works. A
|
||||
/// graphic doesn't change this: the name it replaces on the left is still among the details.
|
||||
/// A label that is drawn ([`AppearanceLabels`], the caller's words) is refused the same way.
|
||||
fn appearance(doc: &mut Document, rect: [f64; 4], name: &str, cert: &Certificate, opts: &SignOptions) -> Result<Stream, SignError> {
|
||||
use pdfcraft_fonts::{first_undrawable, helvetica_width, literal, undrawable_message, win_ansi, wrap};
|
||||
let (w, h) = ((rect[2] - rect[0]).max(0.0), (rect[3] - rect[1]).max(0.0));
|
||||
@@ -1968,6 +2089,17 @@ fn appearance(doc: &mut Document, rect: [f64; 4], name: &str, cert: &Certificate
|
||||
)));
|
||||
}
|
||||
}
|
||||
// The labels are the caller's words (a translation), checked as the details are: only
|
||||
// those that are drawn, as each is drawn only beside its detail.
|
||||
let has = |v: Option<&str>| v.is_some_and(|v| !v.is_empty());
|
||||
let drawn = [a.name, a.distinguished_name, a.reason && has(opts.reason.as_deref()), a.location && has(opts.location.as_deref()), a.date];
|
||||
let mut labels = opts.appearance_labels.fields().into_iter().zip(drawn).filter(|&(_, on)| a.labels && on);
|
||||
if let Some((field, c)) = labels.find_map(|((field, text), _)| first_undrawable(text).map(|c| (field, c))) {
|
||||
return Err(SignError::Unsupported(format!(
|
||||
"{}; pass that label in Western European characters, or leave the labels out of the visible signature",
|
||||
undrawable_message(&format!("the {field} label of the visible signature"), c)
|
||||
)));
|
||||
}
|
||||
}
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
let label = |l: &str, v: &str| if a.labels { format!("{l}{v}") } else { v.to_string() };
|
||||
|
||||
+80
-15
@@ -6,7 +6,8 @@
|
||||
//! Files that aren't clean DER still open: trailing whitespace, PEM armour or a bare
|
||||
//! base64 body (#159).
|
||||
//! Writing uses PBES2 AES-256-CBC with PBKDF2-HMAC-SHA-256 and an HMAC-SHA-256 MAC, OpenSSL 3's
|
||||
//! defaults.
|
||||
//! algorithms, at [`WRITE_ROUNDS`] rather than OpenSSL's 2048. Opening stops at a fixed amount
|
||||
//! of key-derivation work, whatever the file asks for.
|
||||
//!
|
||||
//! The envelope is read as BER, not DER: Windows writes `.p12` files with indefinite lengths and
|
||||
//! the AuthenticatedSafe split into OCTET STRING segments, and nothing in the envelope is signed
|
||||
@@ -48,6 +49,42 @@ const PBE_SHA_2DES: &str = "1.2.840.113549.1.12.1.4";
|
||||
const PBE_SHA_RC2_128: &str = "1.2.840.113549.1.12.1.5";
|
||||
const PBE_SHA_RC2_40: &str = "1.2.840.113549.1.12.1.6";
|
||||
|
||||
/// Key-derivation rounds for new files: OWASP's figure for PBKDF2-HMAC-SHA-256. The MAC uses the
|
||||
/// same count, because checking it alone tells whether a guessed password is right (#1847).
|
||||
pub const WRITE_ROUNDS: u32 = 600_000;
|
||||
|
||||
/// The shortest password a new digital ID may have (NIST SP 800-63B). Opening an existing file
|
||||
/// takes any password, so nobody is locked out of an ID made before this minimum.
|
||||
pub const MIN_PASSWORD_CHARS: usize = 8;
|
||||
|
||||
/// The most key-derivation work one [`open`] does, in hash blocks summed over every derivation
|
||||
/// in the file. The file names its own iteration counts, so without a budget a crafted file
|
||||
/// hangs the caller on the password check (#1847). A file written by [`write`] needs about
|
||||
/// 2 million; real files from other tools need far less.
|
||||
const OPEN_BUDGET: u64 = 10_000_000;
|
||||
|
||||
/// What is left of [`OPEN_BUDGET`] while one file is opened.
|
||||
struct Budget(u64);
|
||||
|
||||
impl Budget {
|
||||
/// Spend `rounds` iterations to derive `len` bytes with `alg`, and return them as the `u32`
|
||||
/// the derivation takes, or refuse the file if the budget runs out.
|
||||
fn spend(&mut self, alg: DigestAlg, rounds: u64, len: usize) -> Result<u32, SignError> {
|
||||
// Every derivation hashes at least once, and repeats the rounds for each output block.
|
||||
let blocks = len.div_ceil(alg.digest(&[]).len().max(1)).max(1) as u64;
|
||||
let cost = rounds.max(1).saturating_mul(blocks);
|
||||
match (self.0.checked_sub(cost), u32::try_from(rounds)) {
|
||||
(Some(left), Ok(rounds)) => {
|
||||
self.0 = left;
|
||||
Ok(rounds)
|
||||
}
|
||||
_ => {
|
||||
Err(SignError::Unsupported(format!("PKCS #12 files that need more than {OPEN_BUDGET} rounds of key derivation ({rounds} asked for)")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a digital ID file holds: the signing key, its certificate and any others (issuers).
|
||||
#[derive(Debug)]
|
||||
pub struct DigitalId {
|
||||
@@ -182,7 +219,7 @@ fn pbkdf2(prf: DigestAlg, password: &[u8], salt: &[u8], rounds: u32, len: usize)
|
||||
}
|
||||
|
||||
/// Decrypt `ct` with the password-based scheme `alg` (an AlgorithmIdentifier).
|
||||
fn pbe_decrypt(alg: &Tlv<'_>, password: &str, ct: &[u8]) -> Result<Vec<u8>, SignError> {
|
||||
fn pbe_decrypt(alg: &Tlv<'_>, password: &str, ct: &[u8], budget: &mut Budget) -> Result<Vec<u8>, SignError> {
|
||||
let parts = alg.children()?;
|
||||
let o = parts.first().ok_or_else(|| bad("algorithm"))?.oid()?;
|
||||
let params = parts.get(1).ok_or_else(|| bad("algorithm parameters"))?;
|
||||
@@ -196,7 +233,7 @@ fn pbe_decrypt(alg: &Tlv<'_>, password: &str, ct: &[u8]) -> Result<Vec<u8>, Sign
|
||||
}
|
||||
let kp = kdf.get(1).ok_or_else(|| bad("PBKDF2 parameters"))?.children()?;
|
||||
let salt = kp.first().ok_or_else(|| bad("salt"))?.octets("salt")?;
|
||||
let rounds = kp.get(1).ok_or_else(|| bad("iterations"))?.u64()? as u32;
|
||||
let rounds = kp.get(1).ok_or_else(|| bad("iterations"))?.u64()?;
|
||||
let prf = match kp
|
||||
.iter()
|
||||
.skip(2)
|
||||
@@ -222,13 +259,14 @@ fn pbe_decrypt(alg: &Tlv<'_>, password: &str, ct: &[u8]) -> Result<Vec<u8>, Sign
|
||||
other => return Err(SignError::Unsupported(format!("PBES2 cipher {}", other.unwrap_or("?")))),
|
||||
};
|
||||
let iv = sc.get(1).ok_or_else(|| bad("IV"))?.octets("IV")?;
|
||||
let rounds = budget.spend(prf, rounds, cipher.key_len())?;
|
||||
let key = pbkdf2(prf, password.as_bytes(), &salt, rounds, cipher.key_len())?;
|
||||
cipher.decrypt(&key, &iv, ct)
|
||||
}
|
||||
PBE_SHA_3DES | PBE_SHA_2DES | PBE_SHA_RC2_128 | PBE_SHA_RC2_40 => {
|
||||
let p = params.children()?;
|
||||
let salt = p.first().ok_or_else(|| bad("salt"))?.octets("salt")?;
|
||||
let rounds = p.get(1).ok_or_else(|| bad("iterations"))?.u64()? as u32;
|
||||
let rounds = p.get(1).ok_or_else(|| bad("iterations"))?.u64()?;
|
||||
let (cipher, key_len, iv_len) = match o.as_str() {
|
||||
PBE_SHA_3DES => (Cipher::TripleDes, 24, 8),
|
||||
PBE_SHA_2DES => (Cipher::TripleDes, 16, 8),
|
||||
@@ -236,6 +274,9 @@ fn pbe_decrypt(alg: &Tlv<'_>, password: &str, ct: &[u8]) -> Result<Vec<u8>, Sign
|
||||
_ => (Cipher::Rc2(40), 5, 8),
|
||||
};
|
||||
let pw = bmp(password);
|
||||
// Two derivations from the same count: the key, then the IV.
|
||||
let rounds = budget.spend(DigestAlg::Sha1, rounds, key_len)?;
|
||||
budget.spend(DigestAlg::Sha1, u64::from(rounds), iv_len)?;
|
||||
let mut key = pkcs12_kdf(DigestAlg::Sha1, &pw, &salt, 1, rounds, key_len);
|
||||
if key.len() == 16 && matches!(cipher, Cipher::TripleDes) {
|
||||
// Two-key 3DES: K1 K2 K1.
|
||||
@@ -260,7 +301,7 @@ enum BagKind {
|
||||
Cert(Vec<u8>),
|
||||
}
|
||||
|
||||
fn bags(safe_contents: &[u8], password: &str, out: &mut Vec<Bag>) -> Result<(), SignError> {
|
||||
fn bags(safe_contents: &[u8], password: &str, out: &mut Vec<Bag>, budget: &mut Budget) -> Result<(), SignError> {
|
||||
for bag in Tlv::parse_all_ber(safe_contents)?.children()? {
|
||||
let f = bag.children()?;
|
||||
let Some(id) = f.first().and_then(|o| o.oid().ok()) else { continue };
|
||||
@@ -282,7 +323,7 @@ fn bags(safe_contents: &[u8], password: &str, out: &mut Vec<Bag>) -> Result<(),
|
||||
SHROUDED_KEY_BAG => {
|
||||
let p = value.children()?;
|
||||
let [alg, data] = p.as_slice() else { return Err(bad("EncryptedPrivateKeyInfo")) };
|
||||
BagKind::Key(pbe_decrypt(alg, password, &data.octets("encrypted private key")?)?)
|
||||
BagKind::Key(pbe_decrypt(alg, password, &data.octets("encrypted private key")?, budget)?)
|
||||
}
|
||||
CERT_BAG => {
|
||||
let p = value.children()?;
|
||||
@@ -355,6 +396,7 @@ pub fn open(bytes: &[u8], password: &str) -> Result<DigitalId, SignError> {
|
||||
return Err(SignError::Unsupported("public-key protected PKCS #12 files".into()));
|
||||
}
|
||||
let content = auth_safe.get(1).ok_or_else(|| bad("authSafe content"))?.inner()?.octets("authSafe")?;
|
||||
let mut budget = Budget(OPEN_BUDGET);
|
||||
// The MAC proves the password before anything is decrypted. It covers the contents of that
|
||||
// OCTET STRING — the segments joined, if the file is segmented, which is what wrote them.
|
||||
if let Some(mac) = pfx.get(2) {
|
||||
@@ -367,8 +409,9 @@ pub fn open(bytes: &[u8], password: &str) -> Result<DigitalId, SignError> {
|
||||
.ok_or_else(|| SignError::Unsupported(format!("MAC digest {alg_oid}")))?;
|
||||
let expected = digest_info.get(1).ok_or_else(|| bad("MAC"))?.octets("MAC")?;
|
||||
let salt = m.get(1).ok_or_else(|| bad("MAC salt"))?.octets("MAC salt")?;
|
||||
let iterations = m.get(2).map(|i| i.u64()).transpose()?.unwrap_or(1) as u32;
|
||||
let iterations = m.get(2).map(|i| i.u64()).transpose()?.unwrap_or(1);
|
||||
let key_len = alg.digest(&[]).len();
|
||||
let iterations = budget.spend(alg, iterations, key_len)?;
|
||||
let key = pkcs12_kdf(alg, &bmp(password), &salt, 3, iterations, key_len);
|
||||
if hmac(alg, &key, &content)? != *expected {
|
||||
return Err(SignError::WrongPassword);
|
||||
@@ -380,7 +423,7 @@ pub fn open(bytes: &[u8], password: &str) -> Result<DigitalId, SignError> {
|
||||
let kind = p.first().ok_or_else(|| bad("ContentInfo"))?.oid()?;
|
||||
let body = p.get(1).ok_or_else(|| bad("ContentInfo content"))?.inner()?;
|
||||
match kind.as_str() {
|
||||
DATA => bags(&body.octets("SafeContents")?, password, &mut all)?,
|
||||
DATA => bags(&body.octets("SafeContents")?, password, &mut all, &mut budget)?,
|
||||
ENCRYPTED_DATA => {
|
||||
let ed = body.children()?;
|
||||
let eci = ed.get(1).ok_or_else(|| bad("EncryptedContentInfo"))?.children()?;
|
||||
@@ -397,8 +440,8 @@ pub fn open(bytes: &[u8], password: &str) -> Result<DigitalId, SignError> {
|
||||
}
|
||||
_ => return Err(bad("encryptedContent")),
|
||||
};
|
||||
let plain = pbe_decrypt(alg, password, &ct)?;
|
||||
bags(&plain, password, &mut all)?;
|
||||
let plain = pbe_decrypt(alg, password, &ct, &mut budget)?;
|
||||
bags(&plain, password, &mut all, &mut budget)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -441,16 +484,15 @@ fn random(n: usize) -> Result<Vec<u8>, SignError> {
|
||||
|
||||
/// Write a digital ID as a `.p12` protected by `password`.
|
||||
pub fn write(id: &DigitalId, password: &str) -> Result<Vec<u8>, SignError> {
|
||||
const ITER: u32 = 2048;
|
||||
let encrypt = |plain: &[u8]| -> Result<Vec<u8>, SignError> {
|
||||
let (salt, iv) = (random(16)?, random(16)?);
|
||||
let key = pbkdf2(DigestAlg::Sha256, password.as_bytes(), &salt, ITER, 32)?;
|
||||
let key = pbkdf2(DigestAlg::Sha256, password.as_bytes(), &salt, WRITE_ROUNDS, 32)?;
|
||||
let ct = cbc::Encryptor::<aes::Aes256>::new_from_slices(&key, &iv)
|
||||
.map_err(|_| SignError::Crypto("AES".into()))?
|
||||
.encrypt_padded_vec::<Pkcs7>(plain);
|
||||
let kdf = der::seq(&[
|
||||
&der::oid(PBKDF2),
|
||||
&der::seq(&[&der::octets(&salt), &der::int(ITER as u64), &der::seq(&[&der::oid(HMAC_SHA256), &der::null()])]),
|
||||
&der::seq(&[&der::octets(&salt), &der::int(WRITE_ROUNDS as u64), &der::seq(&[&der::oid(HMAC_SHA256), &der::null()])]),
|
||||
]);
|
||||
let alg = der::seq(&[&der::oid(PBES2), &der::seq(&[&kdf, &der::seq(&[&der::oid(AES256_CBC), &der::octets(&iv)])])]);
|
||||
Ok(der::seq(&[&alg, &der::octets(&ct)]))
|
||||
@@ -480,8 +522,31 @@ pub fn write(id: &DigitalId, password: &str) -> Result<Vec<u8>, SignError> {
|
||||
let ci_key = der::seq(&[&der::oid(DATA), &der::explicit(0, &der::octets(&der::seq(&[&key_bag])))]);
|
||||
let auth_safe = der::seq(&[&ci_certs, &ci_key]);
|
||||
let salt = random(16)?;
|
||||
let mac_key = pkcs12_kdf(DigestAlg::Sha256, &bmp(password), &salt, 3, ITER, 32);
|
||||
let mac_key = pkcs12_kdf(DigestAlg::Sha256, &bmp(password), &salt, 3, WRITE_ROUNDS, 32);
|
||||
let mac = hmac(DigestAlg::Sha256, &mac_key, &auth_safe)?;
|
||||
let mac_data = der::seq(&[&der::seq(&[&DigestAlg::Sha256.algorithm(), &der::octets(&mac)]), &der::octets(&salt), &der::int(ITER as u64)]);
|
||||
let mac_data = der::seq(&[&der::seq(&[&DigestAlg::Sha256.algorithm(), &der::octets(&mac)]), &der::octets(&salt), &der::int(WRITE_ROUNDS as u64)]);
|
||||
Ok(der::seq(&[&der::int(3), &der::seq(&[&der::oid(DATA), &der::explicit(0, &der::octets(&auth_safe))]), &mac_data]))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn one_budget_covers_every_derivation_in_a_file() {
|
||||
let mut budget = Budget(OPEN_BUDGET);
|
||||
// What a file from `write` needs: the MAC and two PBKDF2 derivations.
|
||||
for _ in 0..3 {
|
||||
assert_eq!(budget.spend(DigestAlg::Sha256, WRITE_ROUNDS.into(), 32).ok(), Some(WRITE_ROUNDS));
|
||||
}
|
||||
// Six million rounds fit once, but not twice.
|
||||
assert!(budget.spend(DigestAlg::Sha256, 6_000_000, 32).is_ok());
|
||||
assert!(budget.spend(DigestAlg::Sha256, 6_000_000, 32).is_err());
|
||||
// Each output block repeats the rounds: 24 bytes of SHA-1 are two blocks.
|
||||
let mut budget = Budget(OPEN_BUDGET);
|
||||
assert!(budget.spend(DigestAlg::Sha1, 6_000_000, 24).is_err());
|
||||
// Zero rounds still hashes once.
|
||||
assert_eq!(budget.spend(DigestAlg::Sha1, 0, 20).ok(), Some(0));
|
||||
assert_eq!(budget.0, OPEN_BUDGET - 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,6 +185,48 @@ fn new_digital_ids_are_self_signed_and_survive_a_p12_round_trip() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_digital_ids_use_the_stronger_round_count_for_the_key_and_the_mac() {
|
||||
let name = Name::build("Grace Hopper", "", "", "", "");
|
||||
let now = Time { year: 2026, month: 10, day: 14, hour: 12, minute: 0, second: 0 };
|
||||
let key = pdfcraft_sign::PrivateKey::generate_p256().unwrap();
|
||||
let certificate = Certificate::self_signed(&name, &key, now, 5, &[0x42]).unwrap();
|
||||
let id = pkcs12::DigitalId { key, certificate, chain: Vec::new(), friendly_name: None };
|
||||
let p12 = pkcs12::write(&id, "s3cret12").unwrap();
|
||||
// OWASP's figure for PBKDF2-HMAC-SHA-256, spelled out so lowering the constant fails here.
|
||||
const ROUNDS: u64 = 600_000;
|
||||
// MacData's iteration count, the last field of the file.
|
||||
let pfx = Tlv::parse_all(&p12).unwrap().children().unwrap();
|
||||
let mac = pfx[2].children().unwrap();
|
||||
assert_eq!(mac[2].u64().unwrap(), ROUNDS);
|
||||
// The same INTEGER three times: the MAC and the PBKDF2 parameters of both encrypted parts.
|
||||
let rounds = pdfcraft_sign::der::int(ROUNDS);
|
||||
assert_eq!(p12.windows(rounds.len()).filter(|w| *w == rounds.as_slice()).count(), 3);
|
||||
assert_eq!(pkcs12::open(&p12, "s3cret12").unwrap().certificate, id.certificate);
|
||||
}
|
||||
|
||||
/// A `.p12` whose MAC asks for `rounds` iterations. Its content is empty: opening it must stop
|
||||
/// at the MAC, before the content is read.
|
||||
fn p12_with_mac_rounds(rounds: u64) -> Vec<u8> {
|
||||
use pdfcraft_sign::der;
|
||||
let auth_safe = der::seq(&[]);
|
||||
let digest = der::seq(&[&DigestAlg::Sha256.algorithm(), &der::octets(&[0; 32])]);
|
||||
let mac_data = der::seq(&[&digest, &der::octets(&[1; 16]), &der::int(rounds)]);
|
||||
der::seq(&[&der::int(3), &der::seq(&[&der::oid("1.2.840.113549.1.7.1"), &der::explicit(0, &der::octets(&auth_safe))]), &mac_data])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_p12_asking_for_too_many_rounds_is_refused_before_hashing() {
|
||||
// 2^32 + 1 used to wrap to one round; 20 million took seconds and 4 billion about an hour,
|
||||
// all spent on the password check (#1847).
|
||||
for rounds in [(1u64 << 32) + 1, 20_000_000, 4_000_000_000] {
|
||||
let err = pkcs12::open(&p12_with_mac_rounds(rounds), "password").expect_err("refused");
|
||||
assert!(matches!(err, SignError::Unsupported(_)), "{rounds}: {err}");
|
||||
}
|
||||
// An ordinary count still gets as far as the password.
|
||||
assert!(matches!(pkcs12::open(&p12_with_mac_rounds(2048), "password"), Err(SignError::WrongPassword)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certificates_load_from_pem_and_der_and_export_as_pem() {
|
||||
let pem_text = data("rsa.crt.pem");
|
||||
|
||||
+231
-10
@@ -942,7 +942,7 @@ fn a_dss_entry_does_not_make_the_object_it_names_part_of_the_store() {
|
||||
#[test]
|
||||
fn an_existing_dictionary_is_not_a_store_part_because_the_store_names_it() {
|
||||
// The font dictionary (object 5), rewritten and then listed as the store, its /VRI, a VRI
|
||||
// entry, or simply labelled /Type /DSS.
|
||||
// entry, or simply labelled /Type /DSS. The page draws it, so each is page content (#1787).
|
||||
let rewrite_font = |doc: &mut Document, extra: Option<(&str, Object)>| {
|
||||
let mut d = dict_of(vec![("Type", Object::name("Font")), ("Subtype", Object::name("Type1")), ("BaseFont", Object::name("Courier"))]);
|
||||
if let Some((k, v)) = extra {
|
||||
@@ -950,19 +950,19 @@ fn an_existing_dictionary_is_not_a_store_part_because_the_store_names_it() {
|
||||
}
|
||||
doc.set(pdfcraft_cos::ObjRef { num: 5, generation: 0 }, Object::Dict(d));
|
||||
};
|
||||
assert_refused_at_every_level(&fixture(), "other changes", |doc| {
|
||||
assert_refused_at_every_level(&fixture(), "page content", |doc| {
|
||||
rewrite_font(doc, Some(("Type", Object::name("DSS"))));
|
||||
});
|
||||
assert_refused_at_every_level(&fixture(), "other changes", |doc| {
|
||||
assert_refused_at_every_level(&fixture(), "page content", |doc| {
|
||||
set_dss(doc, oref(5));
|
||||
rewrite_font(doc, None);
|
||||
});
|
||||
assert_refused_at_every_level(&fixture(), "other changes", |doc| {
|
||||
assert_refused_at_every_level(&fixture(), "page content", |doc| {
|
||||
set_dss(doc, Object::Dict(dict_of(vec![("VRI", oref(5))])));
|
||||
rewrite_font(doc, None);
|
||||
});
|
||||
// An existing dictionary with only DSS-looking keys that the signed file never had as a store.
|
||||
assert_refused_at_every_level(&fixture(), "other changes", |doc| {
|
||||
assert_refused_at_every_level(&fixture(), "page content", |doc| {
|
||||
doc.set(
|
||||
pdfcraft_cos::ObjRef { num: 5, generation: 0 },
|
||||
Object::Dict(dict_of(vec![("Type", Object::name("Font")), ("Cert", Object::Array(vec![]))])),
|
||||
@@ -985,7 +985,8 @@ fn an_existing_array_stays_what_it_is_when_the_store_points_at_it() {
|
||||
#[test]
|
||||
fn a_dictionary_labelled_metadata_is_not_a_metadata_change() {
|
||||
// Only the catalog's XMP stream is metadata (a stream, not a dictionary a label was put on).
|
||||
assert_refused_at_every_level(&fixture(), "other changes", |doc| {
|
||||
// The dictionary is the page's font, so the rewrite is page content (#1787).
|
||||
assert_refused_at_every_level(&fixture(), "page content", |doc| {
|
||||
doc.set(
|
||||
pdfcraft_cos::ObjRef { num: 5, generation: 0 },
|
||||
Object::Dict(dict_of(vec![
|
||||
@@ -1042,9 +1043,10 @@ fn fixture_with_stream_7(catalog_extra: &str, drawn: bool, dict: &str, body: &[u
|
||||
#[test]
|
||||
fn a_stream_already_labelled_metadata_when_signed_is_not_metadata_unless_the_catalog_names_it() {
|
||||
// A Form XObject the page draws, labelled /Type /Metadata before signing (the renderer ignores
|
||||
// /Type). Rewriting it later changes what the page shows, so it is not a metadata change.
|
||||
// /Type). Rewriting it later changes what the page shows, so it is not a metadata change
|
||||
// but page content (#1787).
|
||||
let base = fixture_with_stream_7("", true, "/Type /Metadata /Subtype /Form /BBox [0 0 300 300]", b"0 0 1 rg 10 10 100 100 re f");
|
||||
assert_refused_at_every_level(&base, "other changes", |doc| {
|
||||
assert_refused_at_every_level(&base, "page content", |doc| {
|
||||
let d = dict_of(vec![
|
||||
("Type", Object::name("Metadata")),
|
||||
("Subtype", Object::name("Form")),
|
||||
@@ -1193,7 +1195,8 @@ fn a_form_xobject_relabelled_as_file_plumbing_is_still_judged() {
|
||||
});
|
||||
let s = signatures(&open(&edited), &edited, &trust).into_iter().find(|s| s.signed).unwrap();
|
||||
assert_eq!(s.status, Status::Invalid, "{certify:?} /{label}: {:?}", s.details);
|
||||
assert!(matches!(&s.modification, Modification::Disallowed(k) if k.contains(&"other changes")), "{:?}", s.modification);
|
||||
// The page draws the XObject: its rewrite is page content (#1787).
|
||||
assert!(matches!(&s.modification, Modification::Disallowed(k) if k.contains(&"page content")), "{:?}", s.modification);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1638,10 +1641,15 @@ fn validates_the_legacy_adbe_x509_rsa_sha1_format() {
|
||||
/// `fixture()` protected with an empty user password (opens without asking, like most
|
||||
/// "secured" forms) and an owner password, then saved.
|
||||
fn protected(alg: pdfcraft_cos::Algorithm, permissions: i32) -> Vec<u8> {
|
||||
protected_with(alg, "", permissions)
|
||||
}
|
||||
|
||||
/// `fixture()` protected with the user password `user` and an owner password, then saved.
|
||||
fn protected_with(alg: pdfcraft_cos::Algorithm, user: &str, permissions: i32) -> Vec<u8> {
|
||||
let mut doc = open(&fixture());
|
||||
doc.set_encryption(&pdfcraft_cos::NewEncryption {
|
||||
algorithm: alg,
|
||||
user_password: "",
|
||||
user_password: user,
|
||||
owner_password: "owner",
|
||||
permissions,
|
||||
encrypt_metadata: true,
|
||||
@@ -1651,6 +1659,38 @@ fn protected(alg: pdfcraft_cos::Algorithm, permissions: i32) -> Vec<u8> {
|
||||
pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap()
|
||||
}
|
||||
|
||||
/// #1808: a document that asks for its user password, signed and then counter-signed. The
|
||||
/// first signature no longer covers the whole file, so its revision is reopened to classify the
|
||||
/// change; that revision is encrypted too, and used to be unreadable without the password,
|
||||
/// which made the first signature Invalid. It opens with the document's own key.
|
||||
#[test]
|
||||
fn a_user_password_document_keeps_its_earlier_signature_valid_when_counter_signed() {
|
||||
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
|
||||
let id2 = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
|
||||
let trust = TrustStore { certs: vec![id.certificate.clone(), id2.certificate.clone()] };
|
||||
for alg in ALL_ALGORITHMS {
|
||||
let base = protected_with(alg, "secret", -1);
|
||||
assert!(Document::open_with_password(Arc::new(base.clone()), None).is_err(), "{alg:?}: the file asks for its password");
|
||||
let signed = pdfcraft_sign::sign(&open_pw(&base, Some("secret")), &id, &opts()).unwrap();
|
||||
let both = pdfcraft_sign::sign(&open_pw(&signed, Some("secret")), &id2, &SignOptions { field: Some("Approval".into()), ..opts() }).unwrap();
|
||||
let sigs = signatures(&open_pw(&both, Some("secret")), &both, &trust);
|
||||
let first = sigs.iter().find(|s| s.field == "Signature1").unwrap();
|
||||
assert_eq!(first.modification, Modification::Allowed(vec![kind::SIGNATURE]), "{alg:?}: {:?}", first.details);
|
||||
assert_eq!(first.status, Status::Valid, "{alg:?}: {:?}", first.details);
|
||||
let second = sigs.iter().find(|s| s.field == "Approval").unwrap();
|
||||
assert_eq!((second.status, second.revision), (Status::Valid, 3), "{alg:?}: {:?}", second.details);
|
||||
// Opened as owner, the same.
|
||||
let sigs = signatures(&open_pw(&both, Some("owner")), &both, &trust);
|
||||
assert!(sigs.iter().filter(|s| s.signed).all(|s| s.status == Status::Valid), "{alg:?}");
|
||||
// Tampering inside the first signature's range still breaks both.
|
||||
let mut tampered = both.clone();
|
||||
let at = base.len() / 2;
|
||||
tampered[at] ^= 1;
|
||||
let tampered_sigs = signatures(&open_pw(&tampered, Some("secret")), &tampered, &trust);
|
||||
assert!(tampered_sigs.iter().filter(|s| s.signed).all(|s| s.status == Status::Invalid), "{alg:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn open_pw(b: &[u8], pw: Option<&str>) -> Document {
|
||||
Document::open_with_password(Arc::new(b.to_vec()), pw).unwrap()
|
||||
}
|
||||
@@ -1815,6 +1855,58 @@ fn visible_appearance_text_helvetica_cannot_draw_is_refused() {
|
||||
assert_eq!(s.reason.as_deref(), Some("同意此文件"));
|
||||
}
|
||||
|
||||
/// The labels are the caller's words (a translation), so they are checked as the details are: a
|
||||
/// label Helvetica can't draw is refused with the label and the character, never drawn as `?`.
|
||||
/// Only labels that are drawn count: one whose detail is hidden, or empty, or with the labels
|
||||
/// turned off or the signature invisible, signs. Labels it can draw are what the stamp says.
|
||||
#[test]
|
||||
fn appearance_labels_helvetica_cannot_draw_are_refused() {
|
||||
use pdfcraft_sign::{Appearance, AppearanceLabels};
|
||||
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
|
||||
assert_eq!(AppearanceLabels::default().first_undrawable(), None, "the English labels are drawable");
|
||||
let chinese = AppearanceLabels { signed_by: "数字签名者".into(), reason: "原因:".into(), ..AppearanceLabels::default() };
|
||||
assert_eq!(chinese.first_undrawable(), Some(("signed_by", '数')));
|
||||
let sign = |labels: AppearanceLabels, appearance: Appearance, o: SignOptions| {
|
||||
pdfcraft_sign::sign(&open(&fixture()), &id, &SignOptions { appearance_labels: labels, appearance, ..o })
|
||||
};
|
||||
|
||||
// The name is shown with its label (the default appearance): refused, saying which and what.
|
||||
let err = sign(chinese.clone(), Appearance::default(), opts()).unwrap_err();
|
||||
let m = err.to_string();
|
||||
assert!(matches!(err, SignError::Unsupported(_)), "{err:?}");
|
||||
assert!(m.contains("\"数\" (U+6570)") && m.contains("the signed_by label") && m.contains("leave the labels out"), "{m}");
|
||||
let russian_date = AppearanceLabels { date: "Дата: ".into(), ..AppearanceLabels::default() };
|
||||
let m = sign(russian_date.clone(), Appearance::default(), opts()).unwrap_err().to_string();
|
||||
assert!(m.contains("the date label") && m.contains("U+0414"), "{m}");
|
||||
let reason_only = AppearanceLabels { reason: "原因:".into(), ..AppearanceLabels::default() };
|
||||
let shown = Appearance { reason: true, ..Appearance::default() };
|
||||
assert!(sign(reason_only.clone(), shown.clone(), opts()).unwrap_err().to_string().contains("the reason label"));
|
||||
|
||||
// Not drawn: the reason hidden, or empty; the labels off; the signature invisible.
|
||||
sign(reason_only.clone(), Appearance::default(), opts()).unwrap();
|
||||
sign(reason_only.clone(), shown.clone(), SignOptions { reason: None, ..opts() }).unwrap();
|
||||
sign(reason_only, shown, SignOptions { reason: Some(String::new()), ..opts() }).unwrap();
|
||||
sign(chinese.clone(), Appearance { labels: false, ..Appearance::default() }, opts()).unwrap();
|
||||
sign(russian_date, Appearance { date: false, ..Appearance::default() }, opts()).unwrap();
|
||||
sign(chinese, Appearance::default(), SignOptions { rect: None, ..opts() }).unwrap();
|
||||
|
||||
// Labels Helvetica can draw are stamped as given, in place of the English ones.
|
||||
let german = AppearanceLabels {
|
||||
signed_by: "Digital signiert von".into(),
|
||||
distinguished_name: "DN: ".into(),
|
||||
reason: "Grund: ".into(),
|
||||
location: "Ort: ".into(),
|
||||
date: "Datum: ".into(),
|
||||
};
|
||||
let all = Appearance { reason: true, location: true, ..Appearance::default() };
|
||||
let signed = sign(german, all, opts()).unwrap();
|
||||
let (content, _) = appearance_of(&signed, "Signature1");
|
||||
for word in ["(Digital", "(Grund:", "(Ort:", "(Datum:"] {
|
||||
assert!(content.contains(word), "{word}: {content}");
|
||||
}
|
||||
assert!(!content.contains("Reason") && !content.contains("Date:") && !content.contains('?'), "{content}");
|
||||
}
|
||||
|
||||
/// Every sentence this crate says about a signature is listed in `WORDINGS`, and every sentence on
|
||||
/// that list is one it still says: the details are written as `Detail::plain`/`Detail::of`, the
|
||||
/// sentences the modules that read a signature word for themselves are pushed as notes and quirks,
|
||||
@@ -1978,3 +2070,132 @@ fn a_cross_reference_entry_that_misses_its_object_is_not_an_unchanged_object() {
|
||||
let s = signatures(&open(&honest), &honest, &trust).into_iter().find(|s| s.signed).unwrap();
|
||||
assert_eq!(s.status, Status::Valid, "{:?}", s.details);
|
||||
}
|
||||
|
||||
/// `fixture()` with the page's resources indirect: object 7 is the resources dictionary, 8 its
|
||||
/// `/Font` dictionary, 9 an `/ExtGState` the content uses, and 5 the font as before.
|
||||
fn fixture_with_indirect_resources() -> Vec<u8> {
|
||||
let objs: Vec<&[u8]> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >>",
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 7 0 R /Annots [6 0 R] >>",
|
||||
b"<< /Length 52 >>\nstream\n/GS1 gs BT /F1 14 Tf 20 250 Td (Contract text) Tj ET\nendstream",
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
b"<< /Type /Annot /Subtype /Widget /FT /Sig /T (Approval) /Rect [150 20 280 70] /P 3 0 R /F 4 >>",
|
||||
b"<< /Font 8 0 R /ExtGState << /GS1 9 0 R >> >>",
|
||||
b"<< /F1 5 0 R >>",
|
||||
b"<< /Type /ExtGState /ca 1 >>",
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
out.extend_from_slice(o);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// A signed page's resources are judged by the role they held when signed, not by the keys a
|
||||
/// later revision writes into them (#1787): rewriting the page's resources dictionary, a
|
||||
/// category dictionary, a font or a graphics state changes what the page shows, however the
|
||||
/// new version is labelled (/FT, /DR, /T + /Kids, /Subtype /Widget, /Type /Annot + /Rect, which
|
||||
/// otherwise read as form filling or a comment, permitted under P=2 and P=3). A category
|
||||
/// dictionary that only gained a name changes nothing drawn and is judged as before.
|
||||
#[test]
|
||||
fn rewritten_page_resources_are_page_content_whatever_keys_they_carry() {
|
||||
/// Replace object `num` with a dictionary; each attack runs once per certification level,
|
||||
/// so the entries are cloned into each.
|
||||
fn set(num: u32, entries: Vec<(&'static str, Object)>) -> impl Fn(&mut Document) {
|
||||
move |doc| doc.set(pdfcraft_cos::ObjRef { num, generation: 0 }, Object::Dict(dict_of(entries.clone())))
|
||||
}
|
||||
/// A second font, so a name can be made to resolve elsewhere.
|
||||
fn courier(doc: &mut Document) -> pdfcraft_cos::ObjRef {
|
||||
doc.add(Object::Dict(dict_of(vec![
|
||||
("Type", Object::name("Font")),
|
||||
("Subtype", Object::name("Type1")),
|
||||
("BaseFont", Object::name("Courier")),
|
||||
])))
|
||||
}
|
||||
// The resources dictionary: another font under the same name, plus /FT.
|
||||
assert_refused_at_every_level(&fixture_with_indirect_resources(), "page content", |doc| {
|
||||
let f = courier(doc);
|
||||
set(
|
||||
7,
|
||||
vec![
|
||||
("Font", Object::Dict(dict_of(vec![("F1", Object::Ref(f))]))),
|
||||
("ExtGState", Object::Dict(dict_of(vec![("GS1", oref(9))]))),
|
||||
("FT", Object::name("Tx")),
|
||||
],
|
||||
)(doc);
|
||||
});
|
||||
// The /Font dictionary: the name now resolves elsewhere, plus /DR.
|
||||
assert_refused_at_every_level(&fixture_with_indirect_resources(), "page content", |doc| {
|
||||
let f = courier(doc);
|
||||
set(8, vec![("F1", Object::Ref(f)), ("DR", Object::Dict(dict_of(vec![])))])(doc);
|
||||
});
|
||||
// The graphics state: everything drawn through it becomes invisible, plus /T and /Kids.
|
||||
assert_refused_at_every_level(
|
||||
&fixture_with_indirect_resources(),
|
||||
"page content",
|
||||
set(
|
||||
9,
|
||||
vec![
|
||||
("Type", Object::name("ExtGState")),
|
||||
("ca", Object::Real(0.0)),
|
||||
("T", Object::String(PdfString::literal("x"))),
|
||||
("Kids", Object::Array(vec![])),
|
||||
],
|
||||
),
|
||||
);
|
||||
// The font itself, relabelled a widget.
|
||||
assert_refused_at_every_level(
|
||||
&fixture_with_indirect_resources(),
|
||||
"page content",
|
||||
set(5, vec![("Type", Object::name("Font")), ("BaseFont", Object::name("Courier")), ("Subtype", Object::name("Widget"))]),
|
||||
);
|
||||
// The resources dictionary relabelled a comment (permitted under P=3 and by an approval signature).
|
||||
assert_refused_at_every_level(&fixture_with_indirect_resources(), "page content", |doc| {
|
||||
let f = courier(doc);
|
||||
set(
|
||||
7,
|
||||
vec![
|
||||
("Type", Object::name("Annot")),
|
||||
("Subtype", Object::name("Square")),
|
||||
("Rect", Object::Array(vec![Object::Int(0), Object::Int(0), Object::Int(10), Object::Int(10)])),
|
||||
("Font", Object::Dict(dict_of(vec![("F1", Object::Ref(f))]))),
|
||||
],
|
||||
)(doc);
|
||||
});
|
||||
// The same page resources inline in the page keep being page content through the page itself.
|
||||
assert_refused_at_every_level(&fixture(), "page content", |doc| {
|
||||
let f = courier(doc);
|
||||
doc.update_dict(pdfcraft_cos::ObjRef { num: 3, generation: 0 }, |page| {
|
||||
page.set(b"Resources".to_vec(), Object::Dict(dict_of(vec![("Font", Object::Dict(dict_of(vec![("F1", Object::Ref(f))])))])));
|
||||
})
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
// Positive control: a /Font dictionary that only gained a name changes nothing the signed
|
||||
// content draws, so it is not page content (it is still an unclassified change, refused as
|
||||
// "other", as before).
|
||||
let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
|
||||
let trust = TrustStore { certs: vec![id.certificate.clone()] };
|
||||
let signed = pdfcraft_sign::sign(&open(&fixture_with_indirect_resources()), &id, &SignOptions { certify: Some(3), ..opts() }).unwrap();
|
||||
let grown = edit_after(&signed, |doc| {
|
||||
let f = courier(doc);
|
||||
set(8, vec![("F1", oref(5)), ("F2", Object::Ref(f))])(doc);
|
||||
});
|
||||
let s = signatures(&open(&grown), &grown, &trust).into_iter().find(|s| s.signed).unwrap();
|
||||
assert!(
|
||||
matches!(&s.modification, Modification::Disallowed(k) if !k.contains(&"page content") && k.contains(&"other changes")),
|
||||
"{:?}",
|
||||
s.modification
|
||||
);
|
||||
}
|
||||
|
||||
+505
-109
File diff suppressed because it is too large
Load Diff
@@ -170,6 +170,11 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
if response.clicked() {
|
||||
app.active = Some(i);
|
||||
}
|
||||
// A middle click closes the tab, as in Acrobat and browsers (#1853);
|
||||
// unsaved changes ask first, as the close button does.
|
||||
if response.middle_clicked() {
|
||||
close = Some(i);
|
||||
}
|
||||
}
|
||||
// Dragging a tab moves it (#1537): a line marks the gap it will land
|
||||
// in, and releasing the button drops it there.
|
||||
@@ -186,6 +191,10 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
if let Some(marker) = drop_marker(&slots, from, to, ui.spacing().item_spacing.x) {
|
||||
let span = slots.first().map_or(ui.max_rect().y_range(), |(_, r)| r.y_range());
|
||||
// In front of the first tab the gap lies outside the scroll area's
|
||||
// clip rect (#2225): keep the whole line inside it.
|
||||
let clip = ui.clip_rect();
|
||||
let marker = marker.clamp(clip.left() + 1.0, (clip.right() - 1.0).max(clip.left() + 1.0));
|
||||
ui.painter().vline(marker, span.shrink(4.0), Stroke::new(2.0, t.accent));
|
||||
}
|
||||
}
|
||||
@@ -222,7 +231,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
if response.clicked() {
|
||||
app.open_combine_tab();
|
||||
}
|
||||
if close.is_some() {
|
||||
if close.is_some() || response.middle_clicked() {
|
||||
app.close_combine_tab();
|
||||
}
|
||||
}
|
||||
@@ -695,6 +704,16 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
if icons::button(ui, "chevron-up", 30.0, false, tl!("Previous page")).clicked() {
|
||||
view.step_page(false);
|
||||
}
|
||||
// History is distinct from stepping to the adjacent page, and belongs to this tab.
|
||||
for (forward, icon, label, shortcut, enabled) in [
|
||||
(true, "chevron-right", tl!("Next view"), crate::commands::NEXT_VIEW, !view.forward.is_empty()),
|
||||
(false, "chevron-left", tl!("Previous view"), crate::commands::PREV_VIEW, !view.back.is_empty()),
|
||||
] {
|
||||
let tip = format!("{label} ({})", crate::commands::shortcut_label(ui.ctx(), shortcut));
|
||||
if ui.add_enabled_ui(enabled, |ui| icons::button(ui, icon, 30.0, false, &tip)).inner.clicked() {
|
||||
view.view_history(forward);
|
||||
}
|
||||
}
|
||||
ui.label(egui::RichText::new(page_count.to_string()).font(theme::regular(11.0)).color(t.text_muted));
|
||||
let page_input_id = view.page_input_id();
|
||||
// Consume replacement selection once, including when the editor has lost focus.
|
||||
|
||||
@@ -86,6 +86,11 @@ impl PdfCraftApp {
|
||||
}
|
||||
commands::is_enabled(spec, &self.session, self.active_ids().map(|(_, id)| id))
|
||||
&& (spec.needs != commands::Needs::TwoPageView || self.active.and_then(|i| self.views.get(i)).is_some_and(crate::DocView::cover_applies))
|
||||
&& match spec.id {
|
||||
"view.previous_view" => self.active.and_then(|i| self.views.get(i)).is_some_and(|v| !v.back.is_empty()),
|
||||
"view.next_view" => self.active.and_then(|i| self.views.get(i)).is_some_and(|v| !v.forward.is_empty()),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a registered command by id. Returns `false` when the id is unknown or the command
|
||||
@@ -267,6 +272,11 @@ impl PdfCraftApp {
|
||||
}
|
||||
}
|
||||
"view.read_mode" => self.switch_mode(if self.mode == Mode::Read { Mode::AllTools } else { Mode::Read }, false),
|
||||
"view.previous_view" | "view.next_view" => {
|
||||
if let Some(v) = active.and_then(|i| self.views.get_mut(i)) {
|
||||
return v.view_history(id == "view.next_view");
|
||||
}
|
||||
}
|
||||
"view.next_tab" => self.step_tab(true),
|
||||
"view.previous_tab" => self.step_tab(false),
|
||||
"view.theme" => {
|
||||
@@ -317,8 +327,12 @@ impl PdfCraftApp {
|
||||
self.apply_edit(Edit::ResetForm { names: None });
|
||||
}
|
||||
"page.organize" => {
|
||||
if let Some(i) = active {
|
||||
self.views[i].organize = !self.views[i].organize;
|
||||
if let Some(v) = active.and_then(|i| self.views.get_mut(i)) {
|
||||
if v.organize {
|
||||
v.organize = false;
|
||||
} else {
|
||||
v.open_organize();
|
||||
}
|
||||
}
|
||||
}
|
||||
"page.rotate" => {
|
||||
@@ -450,7 +464,10 @@ impl PdfCraftApp {
|
||||
self.redact_search.found = None;
|
||||
self.dialog = Some(Dialog::RedactSearch);
|
||||
}
|
||||
"redact.properties" => self.dialog = Some(Dialog::RedactProps),
|
||||
"redact.properties" => {
|
||||
self.redact_before = Some(self.redact_prefs.clone());
|
||||
self.dialog = Some(Dialog::RedactProps);
|
||||
}
|
||||
"redact.apply" => {
|
||||
let marks = active.and_then(|i| self.session.get(self.views[i].id)).map_or(0, |d| d.redaction_marks());
|
||||
if marks == 0 {
|
||||
@@ -674,6 +691,10 @@ impl PdfCraftApp {
|
||||
specs.sort_by_key(|c| std::cmp::Reverse(c.shortcut.map(|s| s.modifier_count()).unwrap_or(0)));
|
||||
for spec in specs {
|
||||
let Some(s) = spec.shortcut else { continue };
|
||||
// Keep the canvas's silent no-op when there is no previous/next view to visit.
|
||||
if matches!(spec.id, "view.previous_view" | "view.next_view") && !self.command_enabled(spec) {
|
||||
continue;
|
||||
}
|
||||
if typing && !spec.in_text {
|
||||
continue;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user