mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 18:33:22 +00:00
M1: merge current upstream into Flate fallback fix
This commit is contained in:
@@ -48,13 +48,21 @@ jobs:
|
||||
echo "CRAFT_FONTS_DIR=$GITHUB_WORKSPACE/craft-fonts" >> "$GITHUB_ENV"
|
||||
echo "CRAFT_FONTS_REQUIRED=1" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install Linux system dependencies (egui/winit/rfd)
|
||||
- name: Install Linux system dependencies (egui/winit/rfd) and the oracles
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \
|
||||
libgl1-mesa-dev libgtk-3-dev
|
||||
libgl1-mesa-dev libgtk-3-dev \
|
||||
qpdf poppler-utils
|
||||
|
||||
# The independent oracles (crates/oracle) are only worth having when they run. With this
|
||||
# set, a missing one fails the job instead of printing a skip notice that reads as a pass.
|
||||
# Only the tools the job actually uses are installed above.
|
||||
- name: Require the oracles (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: echo "PDFCRAFT_ORACLES_REQUIRED=1" >> "$GITHUB_ENV"
|
||||
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
|
||||
+4
-2
@@ -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 (270)
|
||||
## In this repository (272)
|
||||
|
||||
| Asset | Title | Author | Licence | Source | Used for |
|
||||
|---|---|---|---|---|---|
|
||||
@@ -27,7 +27,8 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
|
||||
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; reviewed by a native Lithuanian-speaking contributor | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/align-center.svg` | Lucide icon "align-center" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
@@ -278,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)
|
||||
|
||||
|
||||
+45
-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 = "389982dcbb7f1ac29fb150c9af43f9529db6e59a091b9971f0401684847d8b53"
|
||||
sha256 = "cefa955aa92790d6773cb1d1e15e19019b3165a00528d57d6617362e1d1988ab"
|
||||
|
||||
# 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 = "d8ea23644d81476e3fd35c09780ab9b3ac8c9e28bed4e14e1cbc6493bb896d91"
|
||||
sha256 = "1de29a6cc089be31cb6206bf41e0715f8ddcf74ef887b45ccee235d743bed223"
|
||||
|
||||
[[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 = "26dd0b0d697fd40db996fc102f3c7f2320bdeb6d6af38dbdde353bd5d940baad"
|
||||
sha256 = "0b0dafa3806a6b3508684f64e1f07b435113f8efd0e59201d12250c63c1d9547"
|
||||
|
||||
[[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 = "322b8cda21a2b8d157716182846619bd92bfb31263d1f8d13da7eaad365fbfd7"
|
||||
sha256 = "8a8aed18bd8fceabd1389580361ba75b0dccf141d845650a353d8dfe19c95536"
|
||||
|
||||
[[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 = "21bf9c73b482eb987ddaf839b4a4aa8f9938d5aa10428e6d39beea254a95a0c0"
|
||||
sha256 = "1c197ac045810950aceb733152a296f2224f38857b1169032fdc7092ab4aa3f7"
|
||||
|
||||
[[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 = "fef35ae1771edf2e044e72aebb958bb4827ded57234207f96b6cb7a250fedb22"
|
||||
sha256 = "a9cf0e062b006b40f549ce2316b88d4b0196d70bd9cecd66c12d980d699bf679"
|
||||
|
||||
[[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 = "d723622298b10400e9a1ba697cbebd831f5e0f7e5bbe4545b97f61e64db7535f"
|
||||
sha256 = "ef5c84bd13ef8318f7deab7f42136cfe3c14cbef6fd5231014b94167baaecd2a"
|
||||
|
||||
[[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 = "686ab9658b7ef9dd7f3af05ce73f99f1ab2a75f8357bad6f7e987d40a8730df9"
|
||||
sha256 = "be689af8bb22537879269ad9ab9bdcc4b730665cf0b27c6bf4bad560cacf3b69"
|
||||
|
||||
[[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 = "90b94038699aa74aa48ffbda3e90a2255b65d0dbf081e8ecf20c5804940fc129"
|
||||
sha256 = "989d2e5f57cf22c21a85951835ca9fbd05e314da67eb4c17332df1f03679fa00"
|
||||
|
||||
[[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 = "5aa6475df91a3e82010aee198c2e29bb80328505bbd0240f3828e36a5d6dfdf3"
|
||||
sha256 = "425c42acb8d98ac31b216b13c05a285e0676318dd379dcfb9d57cc7329e9d7a5"
|
||||
|
||||
[[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 = "e6d306d9bf289fac2fe8fcfa6f9d0a51b650728e45669289d391be2031076692"
|
||||
sha256 = "988bcf2e0322a46c0464ff7b91ab5d90193b0ea7f454812d12595eb843de6fc7"
|
||||
|
||||
[[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 = "277f4995a87297a2fcc473df1feebdbdf3378fa460a7f85eb1a0a151cee40a2b"
|
||||
sha256 = "ebe7aa9152baff38ae7e36aeb4f6a7ed2198166ab93425a612a66d53e43cb363"
|
||||
|
||||
[[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 = "01b32370cedce935b74925858a79ee691dc2a5560ba5eb758de2de1c243c528e"
|
||||
sha256 = "69d2da21949f02c3c40f71159e377eef2fdd1a87f749d6c504e1d151ef33f643"
|
||||
|
||||
[[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 = "f8ae97901c61dfdc0a844a23356173211bb052b3ff54128d7edc8097c535eee9"
|
||||
sha256 = "98359685aa555f391846f2259ac302ce1234c7a180fb4abe07c36e0c1c62211e"
|
||||
|
||||
[[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 = "7107ba77577794395451dd85355a86ef3e033a7f240b72414b877b9b43adb19e"
|
||||
sha256 = "9f6296d9c79c848167fc80bb4cfaacc9e60f72edb70cc1d70f7b6088ada83578"
|
||||
|
||||
[[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 = "114b3d50531eb1682535c7a646e62914b0c9afaa5d5d1b9f66b94fd472e4e7b3"
|
||||
sha256 = "3528e85afd4117de2c22b9f37d32a380a073a49834fa5a3063d21b803606ffbf"
|
||||
|
||||
[[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 = "f56ec5f4cb7d1992041df0783f1cfd76c27d5d180c3f73c60b104e3fd02f600d"
|
||||
sha256 = "4db7e23074d44305f4584e2cd8b9a0fc35b433cb187dad493c8f7d78a8cd4ad2"
|
||||
|
||||
[[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 = "113fdf57e28fcff90d212fb468011bedebfb98ac100218d29b584f4de4c87912"
|
||||
sha256 = "2af01de9509db4b4ae61193f41ff0f7f3b70ca5f91cd2afda667e90d34980b25"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/tr.tsv"
|
||||
@@ -217,18 +217,30 @@ 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 = "2b5edfe026578ed475f07f94f1d1ed4a3dac93a745c19209589936e2d67f6ec5"
|
||||
sha256 = "de988b6959572931c4878a0588109c236bf8c5d46ef57c7a406b6ab42de51339"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/lt.tsv"
|
||||
title = "Lithuanian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; reviewed by a native Lithuanian-speaking contributor"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed"
|
||||
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 = "fe58835b0538f470b463f078e1b4cdf78a8e2632056bc61c6946071478f39a64"
|
||||
sha256 = "62151d79be5089d964876b6569d490ee3fdacef4388684b574be9627b1354e92"
|
||||
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/vi.tsv"
|
||||
title = "Vietnamese interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed"
|
||||
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 = "f9002d56e44e49429e0cdeabb10525f79f32831b94233f3c0a8a0ecb202850d5"
|
||||
|
||||
[[asset]]
|
||||
path = "assets/icons/accessibility.svg"
|
||||
@@ -3351,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 = "9bbbd0d516f284b571ad6e2ed811f6bbdee283cea8483686f4aa939db38ea288"
|
||||
sha256 = "5881cc7fbfe9da5f5f611309dacf267f95db3a143b916a113b649839019b292c"
|
||||
|
||||
[[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
+86
-5
@@ -1145,7 +1145,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=f03ec27ab2c405969428512178f746183cd55ca0#f03ec27ab2c405969428512178f746183cd55ca0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=666c358065261a2816b376fa3bb10538dbf53040#666c358065261a2816b376fa3bb10538dbf53040"
|
||||
dependencies = [
|
||||
"miniz_oxide 0.8.9",
|
||||
"roxmltree",
|
||||
@@ -1154,6 +1154,18 @@ dependencies = [
|
||||
"winsafe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-quit"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=f58bc1a3df490992d633393d0898af0588ce882c#f58bc1a3df490992d633393d0898af0588ce882c"
|
||||
dependencies = [
|
||||
"dispatch2",
|
||||
"egui",
|
||||
"muda",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
@@ -1184,6 +1196,15 @@ dependencies = [
|
||||
"x509-cert",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.8"
|
||||
@@ -2067,6 +2088,15 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fontconfig-parser"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
|
||||
dependencies = [
|
||||
"roxmltree",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.5.0"
|
||||
@@ -3005,6 +3035,16 @@ dependencies = [
|
||||
"cpufeatures 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyboard-types"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "khronos-egl"
|
||||
version = "6.0.0"
|
||||
@@ -3248,6 +3288,24 @@ dependencies = [
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "muda"
|
||||
version = "0.21.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"dpi",
|
||||
"keyboard-types",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation 0.3.2",
|
||||
"png 0.18.1",
|
||||
"thiserror 2.0.21",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multiversion_no_op"
|
||||
version = "1.0.0"
|
||||
@@ -3877,6 +3935,7 @@ dependencies = [
|
||||
name = "pdfcraft"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"craft-quit",
|
||||
"eframe",
|
||||
"egui",
|
||||
"fmv-macos-events",
|
||||
@@ -3884,6 +3943,7 @@ dependencies = [
|
||||
"pdfcraft-engine",
|
||||
"pdfcraft-ui-egui",
|
||||
"pollster 1.0.1",
|
||||
"raw-window-handle",
|
||||
"rustls-native-certs",
|
||||
"serde_json",
|
||||
"uds_windows",
|
||||
@@ -3891,6 +3951,7 @@ dependencies = [
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"windows",
|
||||
"winit",
|
||||
"winresource",
|
||||
"winsafe",
|
||||
]
|
||||
@@ -3959,6 +4020,7 @@ dependencies = [
|
||||
"pdfcraft-automation",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-engine",
|
||||
"pdfcraft-platform",
|
||||
"pdfcraft-render",
|
||||
"rustix 1.1.5",
|
||||
"serde_json",
|
||||
@@ -3983,6 +4045,8 @@ dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-crypt",
|
||||
"pdfcraft-filters",
|
||||
"pdfcraft-oracle",
|
||||
"pdfcraft-testkit",
|
||||
"proptest",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
@@ -4065,6 +4129,7 @@ dependencies = [
|
||||
"pdfcraft-xfdf",
|
||||
"png 0.18.1",
|
||||
"thiserror 2.0.21",
|
||||
"unicode-segmentation",
|
||||
"url",
|
||||
]
|
||||
|
||||
@@ -4126,6 +4191,7 @@ dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-model",
|
||||
"pdfcraft-testkit",
|
||||
"serde",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
@@ -4160,6 +4226,10 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-oracle"
|
||||
version = "0.8.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-organize"
|
||||
version = "0.8.0"
|
||||
@@ -4167,6 +4237,7 @@ dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-oracle",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
@@ -4176,6 +4247,7 @@ version = "0.8.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"futures-lite",
|
||||
"rustix 1.1.5",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
@@ -4183,6 +4255,7 @@ dependencies = [
|
||||
name = "pdfcraft-preflight"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-model",
|
||||
]
|
||||
@@ -4209,6 +4282,8 @@ dependencies = [
|
||||
"pdfcraft-fonts",
|
||||
"pdfcraft-forms",
|
||||
"pdfcraft-model",
|
||||
"pdfcraft-testkit",
|
||||
"pdfcraft-xfa",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
@@ -4265,6 +4340,10 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-testkit"
|
||||
version = "0.8.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-ui-egui"
|
||||
version = "0.8.0"
|
||||
@@ -4272,11 +4351,13 @@ dependencies = [
|
||||
"arboard",
|
||||
"base64 0.22.1",
|
||||
"bytemuck",
|
||||
"craft-quit",
|
||||
"craft-ui",
|
||||
"eframe",
|
||||
"egui",
|
||||
"egui_extras",
|
||||
"egui_kittest",
|
||||
"fontconfig-parser",
|
||||
"getrandom 0.4.3",
|
||||
"image",
|
||||
"js-sys",
|
||||
@@ -5398,9 +5479,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.45"
|
||||
version = "0.23.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
@@ -6868,8 +6949,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",
|
||||
@@ -7946,6 +8025,8 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lopdf",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-oracle",
|
||||
"png 0.18.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
+6
-1
@@ -14,8 +14,10 @@ repository = "https://github.com/storytold/pdfcraft"
|
||||
[workspace.dependencies]
|
||||
# Shared frontend components; engines remain independent of egui.
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
# Quit the macOS way (#1136): ⌘Q asks about unsaved changes, and closing quits through `terminate:`.
|
||||
craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "f58bc1a3df490992d633393d0898af0588ce882c" }
|
||||
# Shared system spooler; document composition remains in pdfcraft-print.
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "f03ec27ab2c405969428512178f746183cd55ca0" }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "666c358065261a2816b376fa3bb10538dbf53040" }
|
||||
# internal
|
||||
pdfcraft-geom = { path = "crates/geom", version = "0.8.0" }
|
||||
pdfcraft-filters = { path = "crates/filters", version = "0.8.0" }
|
||||
@@ -34,6 +36,8 @@ pdfcraft-xfdf = { path = "crates/xfdf", version = "0.8.0" }
|
||||
pdfcraft-sign = { path = "crates/sign", version = "0.8.0" }
|
||||
pdfcraft-optimize = { path = "crates/optimize", version = "0.8.0" }
|
||||
pdfcraft-a11y = { path = "crates/a11y", version = "0.8.0" }
|
||||
pdfcraft-oracle = { path = "crates/oracle", version = "0.8.0" }
|
||||
pdfcraft-testkit = { path = "crates/testkit", version = "0.8.0" }
|
||||
pdfcraft-ocr = { path = "crates/ocr", version = "0.8.0" }
|
||||
pdfcraft-js = { path = "crates/js", version = "0.8.0" }
|
||||
pdfcraft-compare = { path = "crates/compare", version = "0.8.0" }
|
||||
@@ -110,3 +114,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" }
|
||||
|
||||
@@ -172,7 +172,7 @@ Open **Organize pages** to see every page at once:
|
||||
**Saves you can trust:**
|
||||
- *Incremental:* the original bytes stay byte-for-byte intact.
|
||||
- *Atomic:* the file is written to a temporary copy, then swapped in.
|
||||
- *Verified:* independently checked with qpdf.
|
||||
- *Verified:* independently checked with qpdf, on every CI run.
|
||||
|
||||
When open documents exceed the window width, scroll over the tab strip with the mouse wheel or trackpad, or use its horizontal scrollbar. Opening or switching to a document brings its tab into view.
|
||||
|
||||
@@ -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).
|
||||
@@ -365,7 +366,8 @@ On top of those, two corpus sweeps run over real-world files:
|
||||
- **Opening and rendering:** of the 983 pdf.js test files, 963 open and render cleanly, with 0 crashes.
|
||||
- **Open, edit and save round trips:** 958 succeed.
|
||||
|
||||
The output is verified with independent tools: hayro, qpdf and poppler.
|
||||
The output is verified against independent implementations: hayro and qpdf on every CI run, and
|
||||
poppler over the corpus on demand (`cargo xtask text-oracle`).
|
||||
|
||||
PdfCraft is a clean-room implementation. Its behaviour comes from the ISO 32000 specification and black-box observation, never from anyone else's code. Every icon, font and image is openly licensed and listed in [ATTRIBUTION.md](ATTRIBUTION.md).
|
||||
|
||||
@@ -451,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
|
||||
|
||||
|
||||
+79
-10
@@ -1,9 +1,15 @@
|
||||
# PdfCraft roadmap
|
||||
|
||||
**Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away
|
||||
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M1: raw-deflate fallback preserves decoding-limit errors; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-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)
|
||||
@@ -11,6 +17,7 @@
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M5 #1398: stamps read upright on rotated pages; 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 (gap 6: cloudy comments stay when flatten cannot draw them, a refused text-box edit keeps its properties, arrow defaults stay off lines, a custom match stops at a separator, and the vertical-text redaction error no longer claims an overlap; #1317, #1327, #1302, #1315, #1340; 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 (M7 #1316: replacing a line keeps a neighbouring line whose glyph box overlaps; 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 (M5: comment submission shortcuts; 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 (M1 #1339: an unchanged incremental save of a classic cross-reference table writes a zero-length subsection; 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 (M14 Windows low-DPI font-hinting candidate measured on macOS; no Windows runtime claim; 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 (M3: Windows' Animation effects setting is followed, and Preferences has Animation effects: system, on or off); earlier: exclusive image-crop resize follow-up; M2 short same-line redraws split before placement; estimates unchanged · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
@@ -48,7 +55,7 @@ and add a line to the [progress log](#progress-log); when a gap closes, remove i
|
||||
|---|---|---|
|
||||
| **Feature breadth** | **≈ 67%** tier-weighted (56.9% unweighted: 423 shipped + 74 partial of 808; P0 94.2%) | measured, `parity/acrobat-features.toml` |
|
||||
| Feature breadth, mainstream practitioner | ≈ 70% tier-weighted (63.2% unweighted; P0 95.3%) | measured ([split](docs/target-app-parity.md#mainstream-practitioner-vs-full-acrobat-pro)) |
|
||||
| **Ready for real work**, full Acrobat Pro | **≈ 49%** (45–53%) | estimated, weighted sum over [dimensions](#by-dimension) |
|
||||
| **Ready for real work**, full Acrobat Pro | **≈ 50%** (45–53%) | estimated, weighted sum over [dimensions](#by-dimension) |
|
||||
| **Ready for real work**, mainstream practitioner | **≈ 55%** (50–60%) | estimated ([split](docs/target-app-parity.md#mainstream-practitioner-vs-full-acrobat-pro)) |
|
||||
| **Ready for real work**, essentials user | **≈ 66%** (60–70%) | estimated ([method](docs/target-app-parity.md#essentials-user)) |
|
||||
| Remaining to **beta** | ≈ 400–700 Opus 5.5 agent-hours | estimated |
|
||||
@@ -65,7 +72,7 @@ and 245 open issues keep it far from beta.
|
||||
|
||||
| Audience | Ready | Opus 5.5 agent-hours to ≈ 95% | Work that dominates |
|
||||
|---|---|---|---|
|
||||
| Full Acrobat Pro (ready for real work) | ≈ 49% | ≈ 780–1,400 h (≈ 300–550 h wall clock with 3–5 agents; 60–70% parallelizes) | Own renderer and fidelity harness, editing existing content, OCR and Office conversion, Preflight and PDF/A/X/UA, accessibility tagging, signatures for institutions |
|
||||
| Full Acrobat Pro (ready for real work) | ≈ 50% | ≈ 780–1,400 h (≈ 300–550 h wall clock with 3–5 agents; 60–70% parallelizes) | Own renderer and fidelity harness, editing existing content, OCR and Office conversion, Preflight and PDF/A/X/UA, accessibility tagging, signatures for institutions |
|
||||
| Mainstream practitioner | ≈ 55% | ≈ 500–900 h (≈ 200–400 h wall clock; ≈ 60% parallelizes) | Rendering fidelity, editing existing text (CJK, RTL, subsets), conversion (Word layout, Excel, PowerPoint, Office→PDF), OCR languages, the bug backlog |
|
||||
| Essentials user | ≈ 66% | ≈ 180–330 h (≈ 90–180 h wall clock; ≈ 50% parallelizes) | Rendering fidelity on everyday files, launch and GPU stability, simple text fixes, Word export layout, Chinese UI glyphs |
|
||||
|
||||
@@ -80,13 +87,13 @@ Hours are calibrated as in [Effort and calibration](docs/target-app-parity.md#ef
|
||||
| File formats and conversion | ≈ 40% | 12% | 120–220 (within features) | [file-format-parity.md](docs/file-format-parity.md) |
|
||||
| PDF specification and standards | ≈ 60% | 8% | 150–260 (within features) | [pdf-spec-parity.md](docs/pdf-spec-parity.md) |
|
||||
| Hardware | ≈ 35% | 4% | 25–50 | [hardware-parity.md](docs/hardware-parity.md) |
|
||||
| Localization | ≈ 59% | 5% | 35–60 | [localization-parity.md](docs/localization-parity.md) |
|
||||
| Localization | ≈ 68% | 5% | 35–60 | [localization-parity.md](docs/localization-parity.md) |
|
||||
| Performance | ≈ 40% | 6% | 25–45 | [gaps.md](docs/gaps.md) gap 13 |
|
||||
| Stability | ≈ 50% | 8% | 45–80 | [gaps.md](docs/gaps.md) gap 6 |
|
||||
| Platforms | ≈ 70% | 5% | 15–30 | [hardware-parity.md](docs/hardware-parity.md#platforms) |
|
||||
| Ecosystem and plug-ins | ≈ 30% | 1% | 10–20 (within features) | [gaps.md](docs/gaps.md) |
|
||||
| AI features | ≈ 0% (in scope: local providers) | 1% | 20–40 (within features) | [gaps.md](docs/gaps.md) gap 23 |
|
||||
| **Total (weighted sum)** | **≈ 49%** | 100% | **≈ 780–1,400** | |
|
||||
| **Total (weighted sum)** | **≈ 50%** | 100% | **≈ 780–1,400** | |
|
||||
|
||||
## Features
|
||||
|
||||
@@ -111,8 +118,8 @@ What's strong and missing in each: [target-app-parity.md](docs/target-app-parity
|
||||
|
||||
## Languages
|
||||
|
||||
Measured from `crates/ui-egui/src/i18n/*.tsv` (2,143 keys). Detail and script notes:
|
||||
[localization-parity.md](docs/localization-parity.md).
|
||||
Twelve key languages (historical % snapshot vs 2,143 keys, except Vietnamese vs the current
|
||||
union). Detail and script notes: [localization-parity.md](docs/localization-parity.md).
|
||||
|
||||
| Language | Code | Strings | Status |
|
||||
|---|---|---|---|
|
||||
@@ -127,10 +134,11 @@ Measured from `crates/ui-egui/src/i18n/*.tsv` (2,143 keys). Detail and script no
|
||||
| Japanese | `ja` | 99.1% | partial |
|
||||
| German | `de` | 99.3% | partial |
|
||||
| Korean | `ko` | 0% | none |
|
||||
| Vietnamese | `vi` | 0% | none |
|
||||
| Vietnamese | `vi` | 100% of current union | partial (dialogs / tooltips; no help) |
|
||||
|
||||
Also shipped: Traditional Chinese, Russian, Bulgarian, Telugu, Hungarian, Ukrainian, Italian
|
||||
(94–100%) and Czech (menus only): **8 more languages**.
|
||||
Also shipped: Traditional Chinese, Russian, Bulgarian, Telugu, Hungarian, Ukrainian, Italian,
|
||||
Kazakh, Polish, Slovenian, Greek, Turkish, Lithuanian and Czech (menus only): **14 more catalogs**
|
||||
(23 interface languages including English).
|
||||
|
||||
## Upcoming
|
||||
|
||||
@@ -149,11 +157,61 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
| 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 |
|
||||
|
||||
## Progress log
|
||||
- **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged.
|
||||
- **2026-10-12 (M6):** XFDF field import and data-file extraction join all text nodes in a value, preserving text after XML comments and processing instructions. Synthetic regressions cover Unicode, CDATA, empty values and PDF save/reopen. Estimates unchanged.
|
||||
- **2026-10-12 (M7, picture right-click menu):** The Edit text & images menu of a picture gains Cut Image, Redact Image (a redaction mark over the picture, applied from Redact a PDF), Create Link… and Recognize Text… (for that page), in all 21 catalogs. The menu was drawn only while the pointer was over the picture, so its items outside the picture (most of a logo's menu, and Delete below a page) could not be clicked; an open menu now stays drawn. Still missing from Acrobat's menu: Arrange (bring to front, send backward), Remove crop, Draw freehand and Edit image using… (opening an external editor).
|
||||
- **2026-10-12 (M2, inline-image boundaries):** Unfiltered inline images with known sample layouts keep pixel bytes that look like the `EI` terminator when content is rewritten. Regression tests cover row padding, colour-space and parameter aliases, masks and malformed sizes; classic and packed saved PDFs retain the pixels in independent readers and renderers. Filtered and resource-defined layouts retain the existing tolerant heuristic. Estimates unchanged.
|
||||
- **2026-10-12 (M3, drawing markups):** While a comment is being drawn, the pointer in a band along any edge of the view (or just past it) scrolls the view up, down, left or right, so points beyond what is shown can be reached. A polygon, cloud or run of connected lines takes back its last point with Backspace, Delete or Undo (the first point's removal cancels it). Edge scrolling has unit tests of its maths only; it has not been exercised end to end in the app by an automated test.
|
||||
- **2026-10-12 (gap 6, #1319, #1305, #1324):** Editing an XFA form keeps a radio item's stored text when it contains a space, so a later layout still selects it. Replace Pages on a split selection replaces those pages and leaves the gap; editing the dialog range still replaces the span. Marking a figure decorative rewrites only that figure's content stream, so a header, added text and unknown stream entries stay. Estimates unchanged.
|
||||
- **2026-10-15 (M14, gap 24):** `misc.localization` and `misc.installers` move from planned to partial. Localization: 22 catalogs ship, but Chinese shows tofu in releases, Telugu has no release font and Arabic is not mirrored. Installers: macOS and Windows releases are signed, Linux packages are not. The Acrobat menu diff itself remains open. Estimates unchanged.
|
||||
- **2026-10-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-14 (M1, Flate fallback):** Raw deflate whose first bytes resemble a zlib header no longer loses a decoding-limit error during fallback. Strict and tolerant filter regressions and a COS stream regression cover refusal at the caller limit and successful decoding within it. No parity status or estimate changes.
|
||||
|
||||
- **2026-10-12 (M6, XML field names):** AcroForm XML data export constrains generated element names to XML 1.0 NCNames, so Latin-1 names such as `Area²` and `Aµ` produce readable data files. Original PDF field names travel through the existing `xfdf:original` attribute; previously valid tags, including Greek and CJK, keep their spelling. Saved-PDF regressions cover invalid prefixes, combining letters and normalized-name collisions. Estimates unchanged.
|
||||
- **2026-10-12 (M6, CSV carriage returns):** Form-data CSV export quotes carriage returns in field names and values, so a trailing carriage return cannot turn into a record separator and disappear on reimport. Saved-PDF round-trip regressions cover CR, LF, CRLF, commas and quotes; estimates unchanged.
|
||||
- **2026-10-13 (community, M14, #1553 review):** Relative Fontconfig includes resolve beside each including config, including nested configuration selected through `FONTCONFIG_PATH`. Directory includes accept only digit-prefixed `*.conf` in lexical order. Isolated regressions reproduce both review findings; configuration size/count/depth limits, cycles, symlinked config and valid reset ordering are covered. Estimates unchanged.
|
||||
- **2026-10-12 (community, M14, NixOS UI fonts):** Linux/Unix interface fallback fonts are also discovered in Fontconfig's configured directories, including Nix store and user font paths. Existing candidate names, glyph checks, size limits and the screenshot opt-out are retained. Synthetic regressions cover included configuration, symlinks, cycles, depth limits and candidate ordering. Estimates unchanged.
|
||||
- **2026-10-12 (M7, local text-box candidate):** Add Text opens source text for editing and both text tools retype added items on one click. Draft boxes grow toward the page edge and downwards, with the grown width saved. Single visual rows grow across style boundaries, while multi-row paragraph widths stay fixed. Added-text previews remove only the selected item on a clone, with UI and read-only tool regressions. Arabic shaping, ordering and alignment are outside this candidate. Estimates unchanged.
|
||||
- **2026-10-12 (M14, #1395):** XDG desktops use the configuration base for app.ron, safely copying existing preferences without overwriting a destination. Original preferences, logs, recovery and digital IDs are retained; failed migration falls back to the original location. Portable and non-XDG locations remain unchanged; estimates unchanged.
|
||||
|
||||
- **2026-10-12 (M10, Windows print progress, follow-up to #944):** While a job spools, the progress card counts its sheets ("Sending sheet 2 of 3 to Office…") as craft-print reports them (`submit_with_progress`, storytold/craft-libs#27), and steps aside for another long job's card. Pins craft-libs 666c358. Estimates unchanged.
|
||||
- 2026-10-12 · M3: selected page text can be copied from Edit and the canvas context menu (#1225). Five focused regressions, full CI and native Unicode clipboard checks pass; floating Copy shares the document permission gate. Estimates unchanged.
|
||||
|
||||
- **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.
|
||||
- **2026-10-12 (M2, TIFF predictor rows):** Packed RGB images using TIFF horizontal differencing keep every row when samples do not fill the last byte. The renderer aligns each decoded row before moving to the next. Synthetic decoding tests cover 1/2/4-bit samples, horizontal differencing and an 8-bit control; a red/green page-render regression verifies visible output. Estimates unchanged.
|
||||
@@ -165,6 +223,9 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-12 (gap 6, #1317, #1327, #1302, #1315, #1340):** Flattening keeps a visible comment it cannot draw, such as a cloudy square with no stored appearance, and still draws a solid square, a hidden comment and a cloudy square that already has an appearance. A text box another program drew is left unchanged when a restyle or resize is refused. Make Current Properties Default on an arrow updates the Arrow tool and leaves plain lines alone. A custom text match that ends on a reconstructed word or line separator no longer includes the next glyph. The vertical-writing redaction refusal still fails closed, and its message no longer says the text was under the mark. Estimates unchanged.
|
||||
- **2026-10-12 (M12, text-only auto-tag):** Automatically tag PDF now writes marked text objects, paragraph tags, a Document element and a parent tree for untagged text-only PDFs. The result is undoable and opens the Tags panel for review. Mixed paint, forms, annotations, existing tags and malformed streams are refused before mutation. Synthetic two-page engine and tool tests check the tree, checker, unchanged rendering and save/reopen. Richer content and role classification remain open. Estimates unchanged.
|
||||
- **2026-10-12 (M7, #1316):** Replacing a line or paragraph no longer removes a distinct neighbour whose glyph box overlaps the edited line. A second drawing is removed only when it also sits on the same baseline, so fake-bold and invisible copies still go. Synthetic regressions cover 6 pt leading, the same word on the next line, ordinary spacing, a separate paragraph, same-position copies, and incremental and full save/reopen. Estimates unchanged.
|
||||
- **2026-10-12 (M5, comment shortcuts):** Ctrl+Enter posts highlight comment text and replies; Shift+Enter adds a newline. Replies keep Enter-to-post, and floating comment composers only submit from their focused editor. Estimates unchanged.
|
||||
|
||||
- **2026-10-12 (M7, #1443):** Adding a word to a multi-line paragraph no longer reflows or merges its lines. Two causes: in Chrome- and Skia-made subset fonts, `/W` leaves out glyphs of the `/DW` width, which were treated as missing, so unchanged rows measured too wide and rewrapped (and new letters fell back to Helvetica); and lines drawn turned on the page (a landscape page drawn a quarter turn round) were compared along the page's x axis, so all of them became one line. Lines and rows are now found along each text's own baseline, a turned paragraph is rewritten in its own text space, and that rewrite keeps explicit line breaks. Formatting part of turned text is refused rather than misplaced. Synthetic regressions cover three turns and a default-width subset. Estimates unchanged.
|
||||
- **2026-10-12 (M1, #1339):** An incremental save of an unchanged classic-table file used to append `xref` and then `trailer` with no subsection, which qpdf reports as a damaged file. The section now has a zero-length subsection, so no object entry changes and a trailer-only edit is still written. Estimates unchanged.
|
||||
- **2026-10-12 (M12, auto-tag preview):** The engine and `accessibility_autotag_preview` propose paragraph and large-heading roles for bounded text blocks on one page, with locations and reasons. Every proposal needs review; images remain unclassified. No PDF tags are written yet. Synthetic tool tests cover normal text, a larger heading, bounds and unchanged source bytes. Estimates unchanged.
|
||||
- **2026-10-12 (M10, editing OCR text on scans):** Editing a recognised word on a scanned page now changes what the page shows. Before, the rewrite stayed invisible OCR text, so the scan still showed the old word; on a form, the whole OCR layer also grouped into one paragraph that rewrapped when one word changed. OCR words now group into phrases (a table cell or a tab stop splits them, and lines stay apart); an edit draws the phrase visibly in the scan's sampled ink colour over a cover in the sampled paper colour, keeping each word's width and growing rightwards. The editor covers the scanned word while typing. OCR words are now sized from their letters (a capitals-only word's box spans the cap height, not the full ascender-descender span), so recognised and rewritten text is as tall as the scanned word and sits on its baseline. Five engine tests on synthetic scans and a kittest UI test; `ocr.editable-text-output` is partial.
|
||||
@@ -178,6 +239,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-12 (M12, XFA scripts turned off):** When a runaway script turns an XFA form's scripts off, the app now says so: a toast as it happens (at open or after a click), and the notice bar from then on, ahead of its other notices, with a JavaScript Console button. Before, the error was only in the console, so calculations and buttons just stopped. A notice bar the user had closed comes back for it; closed again, it stays closed. The engine reports the state (`Document::xfa_scripts_off`, and `xfa_scripts_off` in `doc_info`) instead of the UI reading error text. Two kittest UI tests (a runaway at open; a runaway click after the bar was closed), engine assertions and a `doc_info` tool test. Overall unchanged.
|
||||
- **2026-10-12 (M14, Windows low-DPI font-hinting candidate):** The Windows policy disables symmetric rendering for embedded JetBrains Mono only. On the locked-font macOS probe, horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150% scaling, and is down 0.79% at 200%; Inter is pixel-identical. Logical layout hashes and 8 MiB atlas size match, repeated fractional offsets add no atlas dimensions, and baseline/candidate time and RSS ranges overlap. Windows runtime and hardware remain unverified; estimates unchanged.
|
||||
- **2026-10-12 (M7, #1200 candidate):** Bounded local GoToR file specifications open in desktop tabs; explicit numeric page views apply, while named destinations open at the default view with a warning. The automation tool resolves relative to the source and uses existing `--root` confinement. Appearance-only Link Properties edits preserve the existing action. URL/network paths remain refused; `/NewWindow` is a tab hint. Estimates unchanged.
|
||||
- **2026-10-12 (M1, #1337):** COS opening repairs exhausted cross-reference entries and records the skipped row; allocation safely falls back to unused numbers for exhausted /Size hints and high edits. Classic-table and stream regressions reproduce the former panic; ordinary, zero-offset and repaired-range controls preserve opening and allocation. Estimates unchanged.
|
||||
- **2026-10-12 (M7, private image crops):** Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged.
|
||||
|
||||
- **2026-10-12 (M12, accessibility finding locations):** Checker findings for figures, annotations and form widgets carry verified page bounds. Selecting one flashes its location; the headless check returns the same PDF-space rectangle. Malformed annotation rectangles remain page-only findings. Guided review and tagging estimates unchanged.
|
||||
@@ -203,6 +265,7 @@ Newest first: the date, what moved, and the new overall estimate.
|
||||
|
||||
- **2026-10-12 (M3, GNOME title bar, #553):** On GNOME's Wayland session, whose compositor draws no window frames (it offers no `zxdg_decoration_manager_v1`), PdfCraft no longer shows winit's fallback title bar: it turns the frame off and the tab strip becomes the header bar, as in GNOME's own apps. Minimize, maximize/restore and close are drawn in Adwaita's style, in the order and on the side GNOME's `button-layout` names (Fedora's default shows only close); the window's edges and corners resize it, and a hairline outlines it while it isn't maximized. Preferences ▸ Use system title bar (shown only there) gives the window back winit's frame at once, without a restart (part of #1001), and `PDFCRAFT_TITLEBAR=app|system` overrides the choice. KDE, Sway, Hyprland, COSMIC and X11 keep the frame their desktop draws. Not done: right-clicking the header bar doesn't open GNOME's window menu (egui can't ask for it), the window has no shadow or rounded corners, the resize edges lie just inside the window rather than outside it, and Windows still uses its system frame (the rest of #1001). Estimates unchanged.
|
||||
- **2026-10-12 (M0, build label):** A local or patched build can carry a label, set at compile time with `PDFCRAFT_BUILD_LABEL`: `pdfcraft --version`, `pdfcraft-cli --version`, About and the log show `0.7.1 (titlebar)`, and `packaging/linux/package.sh` names the files `pdfcraft-0.7.1-titlebar-…` and makes the .rpm/.deb release `1.titlebar`, which upgrades over the release of the same version (and the next release over it). The version itself is unchanged, so the update check still compares it with published releases. Unlabelled builds are as before. Estimates unchanged.
|
||||
- **2026-10-11 (M6, #1208):** Dropdown and list-box Field Properties expose the existing Left/Center/Right alignment picker. Existing alignment stays intact until changed; cancel, undo/redo and save/reopen are covered through the real UI without changing options, values or flags. Estimates unchanged.
|
||||
- 2026-10-11: Organize Pages shows an editable, synchronized physical page-range selection (#1092), with bounded validation and UI/operation regression coverage; overall estimate unchanged.
|
||||
|
||||
- **2026-10-11 (M5.7, scanned signatures, #1110):** Fill & Sign ▸ Add signature has a Scan tab: a phone photo of a signature on paper is framed automatically (all words; table, sheet edge and dust ignored; a sideways signature turned upright), the frame can be moved, resized or redrawn, the photo turned, and an ink-sensitivity slider picks up faint ink. A morphological paper estimate removes shadows and uneven light, and the trimmed ink is saved and reused like an image signature. Photos and images can also be dropped on the Image and Scan tabs. Photos keep the image-signature limits (#1124: a 4 MiB file, up to 32 megapixels decoded, scaled down to 4 megapixels), so a scan never processes more than 4 megapixels. `fill_sign_add` takes `scan` and `sensitivity`. Synthetic engine, UI and automation tests; checked by hand on real phone photos. Milestone and effort estimates unchanged.
|
||||
@@ -431,6 +494,10 @@ Newest first: the date, what moved, and the new overall estimate.
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | minor | M1: raw-deflate fallback preserves decoding-limit errors in strict and tolerant decoding; synthetic filter and COS stream regressions. Estimates unchanged. |
|
||||
| 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. |
|
||||
| 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. |
|
||||
| 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. |
|
||||
@@ -447,5 +514,7 @@ Newest first: the date, what moved, and the new overall estimate.
|
||||
| 2026-10-10 | major | Restructured to the progress-docs standard (stage, two numbers, dimensions, features, languages, upcoming, log); full re-measure; assessment, gaps and milestones moved to `docs/`; log sorted newest first and a stray merged entry dated |
|
||||
| 2026-10-07 | minor | 806 tracked features, 51.0% shipped, effort-weighted ≈ 30–35%, 600–1,100 h |
|
||||
| 2026-10-05 | major | Honest assessment and "Where we're lacking" added |
|
||||
| 2026-10-12 | minor | M8: image redaction removes alternate images and unsupported masks only on affected images. Estimates unchanged. |
|
||||
|
||||
- **2026-10-12 (M8):** Redaction cleans indirect and form structure references, preserving surviving form tags.
|
||||
- **2026-10-13 (M7):** Edit a PDF ▸ Add content: Header and footer, Watermark and Background open an Add / Update / Remove menu (Update and Remove enabled only when the document has that mark), as Acrobat's Watermark control does. The catalogue gains `ToolItem::actions`, so any tool can list more commands (#1192).
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Parse `--automation-read-root` / `--automation-write-root` before the server starts. A malformed
|
||||
//! flag must not silently remove a root and leave the server unconfined.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct AutomationArgs {
|
||||
pub read_root: Option<PathBuf>,
|
||||
pub write_root: Option<PathBuf>,
|
||||
read_given: bool,
|
||||
write_given: bool,
|
||||
}
|
||||
|
||||
impl AutomationArgs {
|
||||
pub fn new(read_root: Option<PathBuf>, write_root: Option<PathBuf>) -> Self {
|
||||
Self { read_root, write_root, read_given: false, write_given: false }
|
||||
}
|
||||
|
||||
/// Returns whether this argument is a root flag. A second occurrence of a flag is an error,
|
||||
/// including when the two spellings (`--flag DIR`, `--flag=DIR`) are mixed.
|
||||
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
|
||||
let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value)));
|
||||
let (root, given) = match flag {
|
||||
"--automation-read-root" => (&mut self.read_root, &mut self.read_given),
|
||||
"--automation-write-root" => (&mut self.write_root, &mut self.write_given),
|
||||
_ => return Ok(false),
|
||||
};
|
||||
if *given {
|
||||
return Err(format!("{flag} given twice"));
|
||||
}
|
||||
let value = match inline {
|
||||
Some(value) => value.to_owned(),
|
||||
None => {
|
||||
let value = args.next().ok_or_else(|| format!("{flag} needs a folder"))?;
|
||||
if value.starts_with('-') {
|
||||
return Err(format!("{flag} needs a folder; use {flag}=<dir> for a folder starting with '-'"));
|
||||
}
|
||||
value
|
||||
}
|
||||
};
|
||||
if value.is_empty() {
|
||||
return Err(format!("{flag} needs a non-empty folder"));
|
||||
}
|
||||
*root = Some(value.into());
|
||||
*given = true;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roots_are_taken_once_and_other_arguments_are_left() {
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
let mut args = ["a folder".to_owned(), "report.pdf".to_owned()].into_iter();
|
||||
assert!(roots.consume("--automation-read-root", &mut args).unwrap());
|
||||
assert!(roots.consume("--automation-write-root=out", &mut args).unwrap());
|
||||
assert!(!roots.consume("--compact", &mut args).unwrap());
|
||||
assert_eq!(roots.read_root, Some("a folder".into()));
|
||||
assert_eq!(roots.write_root, Some("out".into()));
|
||||
assert_eq!(args.next().as_deref(), Some("report.pdf"));
|
||||
assert!(roots.consume("--automation-read-root=other", &mut args).is_err());
|
||||
assert_eq!(roots.read_root, Some("a folder".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_operands_leave_roots_unchanged() {
|
||||
for flag in ["--automation-read-root", "--automation-write-root"] {
|
||||
for operand in [None, Some(""), Some("--version"), Some("--root"), Some("-folder")] {
|
||||
let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into()));
|
||||
let mut args = operand.map(str::to_owned).into_iter();
|
||||
assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}");
|
||||
assert_eq!(roots.read_root, Some("read".into()));
|
||||
assert_eq!(roots.write_root, Some("write".into()));
|
||||
}
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_dash_folder_and_relative_dash_folder_are_valid() {
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap());
|
||||
assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap());
|
||||
assert_eq!(roots.read_root, Some("-folder".into()));
|
||||
assert_eq!(roots.write_root, Some("./-folder".into()));
|
||||
}
|
||||
}
|
||||
+163
-17
@@ -17,6 +17,8 @@
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(feature = "mcp")]
|
||||
mod automation_args;
|
||||
mod batch;
|
||||
|
||||
use std::io::Write;
|
||||
@@ -33,8 +35,14 @@ use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind, in
|
||||
macro_rules! mcp_usage {
|
||||
() => {
|
||||
"\
|
||||
pdfcraft-cli mcp [--root DIR] [--compact] MCP server on stdin/stdout (opt-in)
|
||||
pdfcraft-cli mcp [--root DIR] [--compact] [--automation-read-root DIR] [--automation-write-root DIR]
|
||||
MCP server on stdin/stdout (opt-in)
|
||||
--compact lists a core set of tools plus tool_search and tool_call
|
||||
--automation-read-root / --automation-write-root confine the files the
|
||||
agent's tools read (open, combine, import, fonts, certificates…) and
|
||||
write (save, export, extract, split, reports…) to DIR, links followed;
|
||||
a root left out grants none of its access. --root, when also given,
|
||||
applies as well
|
||||
"
|
||||
};
|
||||
}
|
||||
@@ -55,7 +63,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 +72,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!(),
|
||||
"\
|
||||
@@ -161,15 +170,20 @@ fn audit(args: &[String]) -> Result<(), CliError> {
|
||||
validate_options("audit", args, AUDIT_OPTIONS)?;
|
||||
let path = *positional(args).first().ok_or("audit: missing file")?;
|
||||
let bytes = read(path)?;
|
||||
let doc = pdfcraft_cos::Document::open_with_password(bytes, flag(args, "--password")).map_err(|e| format!("{path}: {e}"))?;
|
||||
let password = flag(args, "--password");
|
||||
let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map_err(|e| format!("{path}: {e}"))?;
|
||||
let findings = pdfcraft_audit::audit_redactions(&doc).map_err(|e| format!("{path}: {e}"))?;
|
||||
// Rectangles in points from the top-left of the displayed page, as `doc_audit_redactions`
|
||||
// reports them; the page geometry is read only when there is something to place.
|
||||
let info = if findings.is_empty() { None } else { Some(inspect(bytes, password).map_err(|e| format!("{path}: {e}"))?) };
|
||||
let items: Vec<serde_json::Value> = findings
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
let rect = f.rect.map(|v| v as f32);
|
||||
serde_json::json!({
|
||||
"page": f.page + 1,
|
||||
"kind": f.kind.id(),
|
||||
"rect": f.rect,
|
||||
"rect": info.as_ref().map_or(rect, |i| pdfcraft_automation::view_rect(i, f.page, rect)),
|
||||
"covered_text": f.covered_text,
|
||||
})
|
||||
})
|
||||
@@ -251,6 +265,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 }];
|
||||
|
||||
@@ -305,6 +320,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,
|
||||
});
|
||||
@@ -376,9 +392,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.
|
||||
@@ -469,7 +506,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 {
|
||||
@@ -485,7 +531,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> {
|
||||
@@ -496,7 +542,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> {
|
||||
@@ -518,7 +564,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(())
|
||||
@@ -539,8 +585,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 });
|
||||
@@ -757,22 +806,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))?;
|
||||
@@ -794,11 +909,42 @@ fn run(args: &[String]) -> Result<(), CliError> {
|
||||
print_output(&auto, content, flag(args, "--out"))
|
||||
}
|
||||
|
||||
/// Take `--automation-read-root` / `--automation-write-root` (each `--flag DIR` or `--flag=DIR`,
|
||||
/// once) out of `args`, and open the roots, which must be existing folders. Returns the arguments
|
||||
/// that are left. A malformed root flag is an error, never a root silently left out.
|
||||
#[cfg(feature = "mcp")]
|
||||
fn automation_roots(command: &str, args: &[String]) -> Result<(Option<Arc<pdfcraft_automation::file_access::AutomationRoots>>, Vec<String>), String> {
|
||||
let mut found = automation_args::AutomationArgs::new(None, None);
|
||||
let mut left = Vec::new();
|
||||
let mut it = args.iter().cloned();
|
||||
while let Some(arg) = it.next() {
|
||||
if !found.consume(&arg, &mut it).map_err(|e| format!("{command}: {e}"))? {
|
||||
left.push(arg);
|
||||
}
|
||||
}
|
||||
let roots = pdfcraft_automation::file_access::AutomationRoots::new(found.read_root.as_deref(), found.write_root.as_deref())
|
||||
.map_err(|e| format!("{command}: {e}"))?;
|
||||
Ok((roots, left))
|
||||
}
|
||||
|
||||
#[cfg(feature = "mcp")]
|
||||
fn mcp(args: &[String]) -> Result<(), CliError> {
|
||||
let (roots, args) = automation_roots("mcp", args)?;
|
||||
let args = args.as_slice();
|
||||
validate_options("mcp", args, MCP_OPTIONS)?;
|
||||
let compact = args.iter().any(|a| a == "--compact");
|
||||
let mut server = pdfcraft_automation::mcp::McpServer::new(automation(args)?).with_compact(compact);
|
||||
let mut auto = automation(args)?;
|
||||
if let Some(roots) = roots {
|
||||
let show = |root: Option<&Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string());
|
||||
let _ = writeln!(
|
||||
std::io::stderr().lock(),
|
||||
"pdfcraft-cli: files confined: read root {}, write root {}",
|
||||
show(roots.read_root()),
|
||||
show(roots.write_root())
|
||||
);
|
||||
auto = auto.with_automation_roots(Some(roots));
|
||||
}
|
||||
let mut server = pdfcraft_automation::mcp::McpServer::new(auto).with_compact(compact);
|
||||
let _ = writeln!(
|
||||
std::io::stderr().lock(),
|
||||
"pdfcraft-cli: MCP server on stdio (protocol {}{}); close stdin to stop",
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//! `audit` reports the rectangles of faux redactions where `doc_audit_redactions` does: in
|
||||
//! points from the top-left of the page as displayed, after `/Rotate`.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
|
||||
|
||||
/// Two US Letter pages with "TOP SECRET" under an opaque `90 688 220 32 re`; the second page is
|
||||
/// turned a quarter (`/Rotate 90`).
|
||||
fn fixture() -> Vec<u8> {
|
||||
let body = "BT /F1 24 Tf 100 700 Td (TOP SECRET) Tj ET\n0 g\n90 688 220 32 re f";
|
||||
let objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [4 0 R 5 0 R] /Count 2 /MediaBox [0 0 612 792] >>".into(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources << /Font << /F1 3 0 R >> >> >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Rotate 90 /Contents 6 0 R /Resources << /Font << /F1 3 0 R >> >> >>".into(),
|
||||
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// The `rect` of each finding, in page order.
|
||||
fn rects(report: &serde_json::Value) -> Vec<serde_json::Value> {
|
||||
report["findings"].as_array().expect("findings").iter().map(|f| f["rect"].clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audit_rectangles_match_the_tool_in_top_left_points() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-cli-audit-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("faux.pdf"), fixture()).unwrap();
|
||||
|
||||
let out = Command::new(BIN).arg("audit").arg(dir.join("faux.pdf")).output().unwrap();
|
||||
assert!(!out.status.success(), "findings must fail the audit");
|
||||
let cli: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
assert_eq!(cli["count"], 2, "{cli}");
|
||||
// Page 1 upright: y measured down from the top of the 792 pt page. Page 2 turned a quarter
|
||||
// clockwise: 792 pt wide, and the cover's user-space y becomes its x.
|
||||
let expected = [serde_json::json!([90.0, 72.0, 310.0, 104.0]), serde_json::json!([688.0, 90.0, 720.0, 310.0])];
|
||||
assert_eq!(rects(&cli), expected, "{cli}");
|
||||
|
||||
// The tool reports the same rectangles.
|
||||
let steps = serde_json::json!([
|
||||
{ "tool": "doc_open", "args": { "path": "faux.pdf" } },
|
||||
{ "tool": "doc_audit_redactions", "args": { "doc": 1 } },
|
||||
]);
|
||||
std::fs::write(dir.join("steps.json"), steps.to_string()).unwrap();
|
||||
let out = Command::new(BIN).arg("run").arg("--script").arg(dir.join("steps.json")).arg("--root").arg(&dir).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let tool = serde_json::Deserializer::from_str(&stdout).into_iter::<serde_json::Value>().last().unwrap().unwrap();
|
||||
assert_eq!(rects(&tool), rects(&cli), "{tool}");
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//! `pdfcraft-cli mcp --automation-read-root DIR --automation-write-root DIR`, on the real binary:
|
||||
//! both spellings of the flags confine the server, and a malformed flag stops it instead of
|
||||
//! leaving it unconfined.
|
||||
#![cfg(feature = "mcp")]
|
||||
|
||||
use std::io::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output, Stdio};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
|
||||
|
||||
/// `in/a.pdf` and `outside/secret.pdf`, blank one-page PDFs made by the binary itself.
|
||||
fn folders(tag: &str) -> PathBuf {
|
||||
let base = std::env::temp_dir().join(format!("pdfcraft-cli-roots-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
for d in ["in", "out", "outside"] {
|
||||
std::fs::create_dir_all(base.join(d)).unwrap();
|
||||
}
|
||||
for f in ["in/a.pdf", "outside/secret.pdf"] {
|
||||
let script = json!([
|
||||
{"tool": "doc_create", "args": {"from": "blank", "pages": 1, "width": 100, "height": 100}},
|
||||
{"tool": "doc_save", "args": {"doc": 1, "path": f}},
|
||||
]);
|
||||
std::fs::write(base.join("steps.json"), script.to_string()).unwrap();
|
||||
let out = Command::new(BIN).args(["run", "--script"]).arg(base.join("steps.json")).arg("--root").arg(&base).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
base
|
||||
}
|
||||
|
||||
/// Run `mcp <flags>`, send `calls` as `tools/call` requests and return the replies.
|
||||
fn session(flags: &[String], calls: &[(&str, Value)]) -> (Vec<Value>, String) {
|
||||
let mut child = Command::new(BIN).arg("mcp").args(flags).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
let mut input = String::from("{\"jsonrpc\":\"2.0\",\"id\":0,\"method\":\"initialize\",\"params\":{}}\n");
|
||||
for (i, (name, args)) in calls.iter().enumerate() {
|
||||
let r = json!({"jsonrpc": "2.0", "id": i + 1, "method": "tools/call", "params": {"name": name, "arguments": args}});
|
||||
input.push_str(&format!("{r}\n"));
|
||||
}
|
||||
child.stdin.take().unwrap().write_all(input.as_bytes()).unwrap();
|
||||
let out = child.wait_with_output().unwrap();
|
||||
assert!(out.status.success());
|
||||
let replies = String::from_utf8(out.stdout).unwrap().lines().skip(1).map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
(replies, String::from_utf8(out.stderr).unwrap())
|
||||
}
|
||||
|
||||
fn s(p: &Path) -> String {
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn flags(spelling: &str, base: &Path) -> Vec<String> {
|
||||
let (r, w) = (s(&base.join("in")), s(&base.join("out")));
|
||||
if spelling == "equals" {
|
||||
vec![format!("--automation-read-root={r}"), format!("--automation-write-root={w}")]
|
||||
} else {
|
||||
vec!["--automation-read-root".into(), r, "--automation-write-root".into(), w]
|
||||
}
|
||||
}
|
||||
|
||||
fn text(reply: &Value) -> String {
|
||||
reply["result"]["content"][0]["text"].as_str().unwrap_or_default().to_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_spellings_confine_the_server() {
|
||||
for spelling in ["equals", "separate"] {
|
||||
let base = folders(spelling);
|
||||
let calls = [
|
||||
("doc_open", json!({"path": s(&base.join("in/a.pdf"))})),
|
||||
("doc_open", json!({"path": s(&base.join("outside/secret.pdf"))})),
|
||||
("doc_save", json!({"doc": 1, "path": s(&base.join("out/copy.pdf"))})),
|
||||
("doc_save", json!({"doc": 1, "path": s(&base.join("outside/copy.pdf"))})),
|
||||
];
|
||||
let (replies, stderr) = session(&flags(spelling, &base), &calls);
|
||||
assert_eq!(replies[0]["result"]["isError"], false, "{spelling}: {replies:?}");
|
||||
assert!(text(&replies[1]).contains("automation path rejected: outside the read root"), "{spelling}: {replies:?}");
|
||||
assert_eq!(replies[2]["result"]["isError"], false, "{spelling}: {replies:?}");
|
||||
assert!(text(&replies[3]).contains("automation path rejected: outside the write root"), "{spelling}: {replies:?}");
|
||||
assert!(base.join("out/copy.pdf").is_file() && !base.join("outside/copy.pdf").exists());
|
||||
assert!(stderr.contains("files confined: read root") && stderr.contains("write root"), "{stderr}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_root_left_out_grants_none_of_its_access() {
|
||||
let base = folders("one");
|
||||
let read_only = vec!["--automation-read-root".to_string(), s(&base.join("in"))];
|
||||
let calls = [("doc_open", json!({"path": s(&base.join("in/a.pdf"))})), ("doc_save", json!({"doc": 1, "path": s(&base.join("out/x.pdf"))}))];
|
||||
let (replies, _) = session(&read_only, &calls);
|
||||
assert_eq!(replies[0]["result"]["isError"], false);
|
||||
assert!(text(&replies[1]).contains("write authority is absent"), "{replies:?}");
|
||||
assert!(!base.join("out/x.pdf").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_flags_means_no_confinement() {
|
||||
let base = folders("none");
|
||||
let (replies, stderr) = session(&[], &[("doc_open", json!({"path": s(&base.join("outside/secret.pdf"))}))]);
|
||||
assert_eq!(replies[0]["result"]["isError"], false);
|
||||
assert!(!stderr.contains("confined"), "{stderr}");
|
||||
}
|
||||
|
||||
fn fails(args: &[&str]) -> String {
|
||||
let out: Output = Command::new(BIN).arg("mcp").args(args).stdin(Stdio::null()).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(1), "{args:?} should fail");
|
||||
assert!(out.stdout.is_empty(), "{args:?} must not start a server");
|
||||
String::from_utf8(out.stderr).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_flags_are_errors_not_a_missing_root() {
|
||||
let dir = s(&std::env::temp_dir());
|
||||
assert!(fails(&["--automation-read-root"]).contains("--automation-read-root needs a folder"));
|
||||
assert!(fails(&["--automation-write-root", "--compact"]).contains("--automation-write-root needs a folder; use --automation-write-root=<dir>"));
|
||||
assert!(fails(&["--automation-read-root="]).contains("--automation-read-root needs a non-empty folder"));
|
||||
assert!(fails(&["--automation-read-root", &dir, "--automation-read-root", &dir]).contains("--automation-read-root given twice"));
|
||||
assert!(fails(&["--automation-write-root", &dir, &format!("--automation-write-root={dir}")]).contains("--automation-write-root given twice"));
|
||||
let missing = fails(&["--automation-read-root", "/definitely/not/a/folder"]);
|
||||
assert!(missing.contains("mcp: cannot open the automation read root"), "{missing}");
|
||||
let file = std::env::current_exe().unwrap();
|
||||
assert!(fails(&["--automation-write-root", &s(&file)]).contains("cannot open the automation write root"));
|
||||
}
|
||||
@@ -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};
|
||||
@@ -225,6 +239,102 @@ fn the_append_destination_and_counts_grown_by_earlier_edits_are_accepted() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Two pages whose catalog points at a page tree object the file does not have (#1656). The
|
||||
/// reader finds the pages by scanning, in file order, so `edit` must work on the same pages.
|
||||
/// Object 3 is written before object 2, so file order and object numbers disagree.
|
||||
fn fixture_without_page_tree() -> Vec<u8> {
|
||||
let stream = |content: &str| format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()).into_bytes();
|
||||
let objs: Vec<(usize, Vec<u8>)> = vec![
|
||||
(1, b"<< /Type /Catalog /Pages 6 0 R >>".to_vec()),
|
||||
(3, b"<< /Type /Page /Parent 6 0 R /MediaBox [0 0 200 300] /Contents 4 0 R >>".to_vec()),
|
||||
(2, b"<< /Type /Page /Parent 6 0 R /MediaBox [0 0 300 200] /Contents 5 0 R >>".to_vec()),
|
||||
(4, stream("0 0 m 10 10 l S")),
|
||||
(5, stream("0 0 m 20 20 l S")),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = vec![0; objs.len()];
|
||||
for (num, o) in &objs {
|
||||
offsets[num - 1] = out.len();
|
||||
out.extend_from_slice(format!("{num} 0 obj\n").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
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_whose_page_tree_is_missing_edits_the_pages_the_reader_shows() {
|
||||
let input = tmp("no-page-tree", "in.pdf");
|
||||
std::fs::write(&input, fixture_without_page_tree()).unwrap();
|
||||
let before = pdfcraft_render::inspect(std::sync::Arc::new(fixture_without_page_tree()), None).unwrap();
|
||||
assert_eq!(before.pages.iter().map(|p| (p.width, p.height)).collect::<Vec<_>>(), [(200.0, 300.0), (300.0, 200.0)]);
|
||||
for (name, args, expected) in [
|
||||
// Width and height are as displayed: the turned page reads 200 x 300.
|
||||
("rotate.pdf", ["--rotate", "2:90"], &[(200.0, 300.0, 0), (200.0, 300.0, 90)][..]),
|
||||
("delete.pdf", ["--delete", "1"], &[(300.0, 200.0, 0)][..]),
|
||||
] {
|
||||
let output = tmp("no-page-tree", name);
|
||||
let mut all = vec!["edit", input.to_str().unwrap(), "--out", output.to_str().unwrap()];
|
||||
all.extend(args);
|
||||
let out = Command::new(BIN).args(&all).output().unwrap();
|
||||
assert!(out.status.success(), "{name}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let info = pdfcraft_render::inspect(std::sync::Arc::new(std::fs::read(&output).unwrap()), None).unwrap();
|
||||
let pages: Vec<_> = info.pages.iter().map(|p| (p.width, p.height, p.rotation)).collect();
|
||||
assert_eq!(pages, expected, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Like [`fixture_without_page_tree`], but the scanning reader shows different pages from the
|
||||
/// page objects: it skips the blank 200 x 200 page (no `/Contents`) and shows the Text
|
||||
/// annotation (which has `/Contents`) as a page. No page tree may be rebuilt from such a file.
|
||||
fn fixture_whose_scan_disagrees() -> Vec<u8> {
|
||||
let stream = |content: &str| format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()).into_bytes();
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 9 0 R >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 9 0 R /MediaBox [0 0 100 100] /Contents 5 0 R >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 9 0 R /MediaBox [0 0 200 200] >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 9 0 R /MediaBox [0 0 300 300] /Contents 6 0 R /Annots [7 0 R] >>".to_vec(),
|
||||
stream("0 0 m 10 10 l S"),
|
||||
stream("0 0 m 20 20 l S"),
|
||||
b"<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /Contents (hello) >>".to_vec(),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
out.extend_from_slice(o);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_tree_is_not_rebuilt_when_the_reader_shows_other_pages() {
|
||||
let input = tmp("scan-disagrees", "in.pdf");
|
||||
std::fs::write(&input, fixture_whose_scan_disagrees()).unwrap();
|
||||
let shown = pdfcraft_render::inspect(std::sync::Arc::new(fixture_whose_scan_disagrees()), None).unwrap();
|
||||
assert_eq!(shown.pages.len(), 3);
|
||||
assert!(shown.pages.iter().all(|p| p.width != 200.0), "the reader must not show the blank page");
|
||||
let output = tmp("scan-disagrees", "out.pdf");
|
||||
let out = Command::new(BIN).args(["edit", input.to_str().unwrap(), "--out", output.to_str().unwrap(), "--delete", "2"]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
assert_eq!(String::from_utf8_lossy(&out.stderr), "pdfcraft-cli: the document's page tree lists no pages: the file is damaged\n");
|
||||
assert!(!output.exists(), "no page may be deleted");
|
||||
}
|
||||
|
||||
/// A page past the end names its own option and the page count left by the earlier options (#1550).
|
||||
#[test]
|
||||
fn a_page_past_the_end_after_earlier_edits_names_that_option() {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -29,6 +29,9 @@ log = { workspace = true }
|
||||
# crate registers the handler (Objective-C interop needs unsafe, which our workspace forbids)
|
||||
# behind a safe API. Same version as PhotoCraft. Pinned exactly: review the diff before bumping.
|
||||
fmv-macos-events = "=0.1.0"
|
||||
# ⌘Q asks about unsaved changes like the close button (#1136): an app menu whose Quit asks the
|
||||
# window to close instead of winit's, which ends the process without asking.
|
||||
craft-quit = { workspace = true, features = ["menu"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback), so the window is
|
||||
@@ -36,10 +39,19 @@ 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"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dev-dependencies]
|
||||
# Exercise the vendored Wayland scroll policy with the actual winit event type.
|
||||
winit = { version = "0.30", default-features = false, features = ["wayland"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
# Which GPU drives which display (EnumDisplayDevices), so the window is drawn on the GPU that
|
||||
# shows it (issue #378). Safe wrappers over Win32; only the `user` feature (which brings `kernel` with it).
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! Without a logger every `log::warn!`/`log::error!` in the workspace (a failed autosave, a page
|
||||
//! that would not render, a render worker that could not start, the panic hook's report) vanished.
|
||||
//! A launch from a desktop menu has no terminal, so the file is what a bug report can attach:
|
||||
//! `<settings dir>/logs/pdfcraft.log`, next to `app.ron` (see `settings_dir` in `main.rs`). Each
|
||||
//! `<data dir>/logs/pdfcraft.log` (the PdfCraft eframe storage directory, or portable data). Each
|
||||
//! start moves the previous log to `pdfcraft.1.log` (and that one to `.2`), so the log of a run
|
||||
//! that crashed survives the next launch.
|
||||
//!
|
||||
|
||||
+44
-13
@@ -37,6 +37,7 @@ mod logging;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod motion;
|
||||
mod reopen;
|
||||
mod settings;
|
||||
mod single_instance;
|
||||
mod text_scale;
|
||||
mod updates;
|
||||
@@ -57,14 +58,24 @@ const APP_ICON_PNG: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256
|
||||
/// The app was called PrintCraft before; settings saved then are under this key.
|
||||
const LEGACY_STORAGE_KEY: &str = "printcraft";
|
||||
|
||||
/// The settings folder: `app.ron` and the `logs` folder (docs/development.md). In portable mode
|
||||
/// it is `PdfCraftData` beside the executable (#157). eframe would otherwise derive it from the
|
||||
/// app id; keep it under "PdfCraft".
|
||||
/// The preferences folder (`app.ron`, docs/development.md). In portable mode
|
||||
/// it is `PdfCraftData` beside the executable (#157). XDG desktops use the configuration base;
|
||||
/// other platforms retain eframe's PdfCraft folder. Logs and recovery stay in the data folder.
|
||||
fn settings_dir() -> Option<std::path::PathBuf> {
|
||||
if let Some(dir) = pdfcraft_ui_egui::portable::data_dir() {
|
||||
return Some(dir.to_path_buf());
|
||||
let legacy = eframe::storage_dir("PdfCraft");
|
||||
match settings::directory(
|
||||
cfg!(all(unix, not(target_os = "macos"))),
|
||||
pdfcraft_ui_egui::portable::data_dir(),
|
||||
legacy.clone(),
|
||||
std::env::var_os("XDG_CONFIG_HOME").map(std::path::PathBuf::from),
|
||||
std::env::home_dir(),
|
||||
) {
|
||||
Ok(dir) => dir,
|
||||
Err(e) => {
|
||||
log::warn!("could not migrate preferences to the XDG configuration folder; using the original location: {e}");
|
||||
legacy
|
||||
}
|
||||
}
|
||||
eframe::storage_dir("PdfCraft")
|
||||
}
|
||||
|
||||
/// Move the settings and crash-recovery folders of the app's former name, PrintCraft, to the new
|
||||
@@ -75,7 +86,7 @@ fn migrate_legacy_folders() {
|
||||
if pdfcraft_ui_egui::portable::data_dir().is_some() {
|
||||
return;
|
||||
}
|
||||
let mut moves = vec![(eframe::storage_dir("PrintCraft"), settings_dir())];
|
||||
let mut moves = vec![(eframe::storage_dir("PrintCraft"), eframe::storage_dir("PdfCraft"))];
|
||||
// Recovery lives in the settings folder except on Windows, where it is under %LOCALAPPDATA%.
|
||||
if cfg!(windows) {
|
||||
let local = std::env::var_os("LOCALAPPDATA").filter(|v| !v.is_empty()).map(std::path::PathBuf::from);
|
||||
@@ -203,19 +214,22 @@ fn main() -> eframe::Result {
|
||||
// GNOME on Wayland leaves the window frame to the app (src/decorations.rs).
|
||||
let window_controls = if cfg!(any(target_os = "macos", target_os = "windows")) { None } else { decorations::app_drawn() };
|
||||
migrate_legacy_folders();
|
||||
// The log file lives in the settings folder; opened after the arguments (so `--version` leaves
|
||||
// The log file stays in the data folder; opened after the arguments (so `--version` leaves
|
||||
// no file behind) and after the PrintCraft migration (which a fresh folder would block).
|
||||
// Records logged until now are written to it first.
|
||||
if let (Some(logger), Some(dir)) = (logger, settings_dir()) {
|
||||
if let (Some(logger), Some(dir)) =
|
||||
(logger, pdfcraft_ui_egui::portable::data_dir().map(std::path::Path::to_path_buf).or_else(|| eframe::storage_dir("PdfCraft")))
|
||||
{
|
||||
match logger.attach_dir(&dir.join("logs")) {
|
||||
Ok(path) => log::info!("PdfCraft {}, log file {}", pdfcraft_engine::build_info::version(), path.display()),
|
||||
// Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics.
|
||||
Err(e) => log::warn!("no log file: {e}"),
|
||||
}
|
||||
}
|
||||
// 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 +346,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>>,
|
||||
@@ -366,9 +392,14 @@ fn app_creator<'a>(
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
app.os_events = Some(apple_events.connect(&cc.egui_ctx));
|
||||
// winit's Quit (⌘Q) ends the process without asking about unsaved changes (#1136). This
|
||||
// menu's Quit closes the window instead, so `guard_quit` asks, as for the close button.
|
||||
if let Err(e) = craft_quit::install_app_menu(&cc.egui_ctx, "PdfCraft") {
|
||||
log::warn!("couldn't install the app menu, so ⌘Q quits without asking to save: {e}");
|
||||
}
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
//! Platform preference location, separate from logs and crash recovery.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static STAGING_ID: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
pub fn directory(
|
||||
xdg: bool,
|
||||
portable: Option<&Path>,
|
||||
legacy: Option<PathBuf>,
|
||||
config_home: Option<PathBuf>,
|
||||
home: Option<PathBuf>,
|
||||
) -> std::io::Result<Option<PathBuf>> {
|
||||
if let Some(portable) = portable {
|
||||
return Ok(Some(portable.to_path_buf()));
|
||||
}
|
||||
if !xdg {
|
||||
return Ok(legacy);
|
||||
}
|
||||
// XDG requires absolute environment paths; empty and relative overrides are ignored.
|
||||
let preferred = config_home.filter(|p| p.is_absolute()).or_else(|| home.filter(|p| p.is_absolute()).map(|p| p.join(".config")));
|
||||
let Some(preferred) = preferred.map(|p| p.join("pdfcraft")) else { return Ok(legacy) };
|
||||
let target = preferred.join("app.ron");
|
||||
if std::fs::symlink_metadata(&target).is_ok() {
|
||||
return Ok(Some(preferred));
|
||||
}
|
||||
create_private_directory(&preferred)?;
|
||||
if let Some(source) = legacy.map(|p| p.join("app.ron")) {
|
||||
match std::fs::File::open(&source) {
|
||||
Ok(mut source) => migrate(&mut source, &preferred, &target)?,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Ok(Some(preferred))
|
||||
}
|
||||
|
||||
/// Create private configuration directories without changing existing directory permissions.
|
||||
fn create_private_directory(directory: &Path) -> std::io::Result<()> {
|
||||
let mut builder = std::fs::DirBuilder::new();
|
||||
builder.recursive(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::DirBuilderExt;
|
||||
builder.mode(0o700);
|
||||
}
|
||||
builder.create(directory)
|
||||
}
|
||||
|
||||
/// Publish a complete copy without replacing a concurrent launch's preferences. Keep the
|
||||
/// original file and all other data (especially recovery and digital IDs) where they were.
|
||||
fn migrate(source: &mut std::fs::File, directory: &Path, target: &Path) -> std::io::Result<()> {
|
||||
create_private_directory(directory)?;
|
||||
for _ in 0..32 {
|
||||
let id = STAGING_ID.fetch_add(1, Ordering::Relaxed);
|
||||
let staging = directory.join(format!(".app.ron-migrate-{}-{id}", std::process::id()));
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
let mut file = match options.open(&staging) {
|
||||
Ok(file) => file,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let result = (|| {
|
||||
std::io::copy(source, &mut file)?;
|
||||
file.sync_all()?;
|
||||
// Both files are in the same directory/filesystem. Unlike rename, hard_link
|
||||
// atomically refuses an existing destination on Unix as well as Windows.
|
||||
match std::fs::hard_link(&staging, target) {
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
|
||||
result => result,
|
||||
}
|
||||
})();
|
||||
drop(file);
|
||||
let _ = std::fs::remove_file(&staging);
|
||||
return result;
|
||||
}
|
||||
Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "no free settings migration staging name"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn xdg_preferences_use_config_instead_of_data() {
|
||||
let fixture = Fixture::new();
|
||||
let home = fixture.0.join("home");
|
||||
let data = home.join(".local/share/pdfcraft");
|
||||
assert_eq!(directory(true, None, Some(data), None, Some(home.clone())).unwrap(), Some(home.join(".config/pdfcraft")));
|
||||
}
|
||||
|
||||
struct Fixture(PathBuf);
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let id = STAGING_ID.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-settings-{}-{id}", std::process::id()));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
Self(dir)
|
||||
}
|
||||
}
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xdg_override_and_invalid_overrides() {
|
||||
let f = Fixture::new();
|
||||
let home = f.0.join("home");
|
||||
let config = f.0.join("custom-config");
|
||||
assert_eq!(directory(true, None, None, Some(config.clone()), Some(home.clone())).unwrap(), Some(config.join("pdfcraft")));
|
||||
for invalid in [PathBuf::new(), PathBuf::from("relative")] {
|
||||
assert_eq!(directory(true, None, None, Some(invalid), Some(home.clone())).unwrap(), Some(home.join(".config/pdfcraft")));
|
||||
}
|
||||
assert_eq!(directory(true, None, Some(f.0.clone()), None, None).unwrap(), Some(f.0.clone()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_preserves_preferences_and_other_data() {
|
||||
let f = Fixture::new();
|
||||
let data = f.0.join("data/pdfcraft");
|
||||
let config = f.0.join("config");
|
||||
std::fs::create_dir_all(data.join("recovery")).unwrap();
|
||||
std::fs::write(data.join("app.ron"), b"(theme:Dark,language:German)").unwrap();
|
||||
std::fs::write(data.join("recovery/unsaved.pdf"), b"UNSAVED").unwrap();
|
||||
std::fs::write(data.join("digital-id.p12"), b"KEY").unwrap();
|
||||
let selected = directory(true, None, Some(data.clone()), Some(config.clone()), None).unwrap().unwrap();
|
||||
assert_eq!(selected, config.join("pdfcraft"));
|
||||
assert_eq!(std::fs::read(selected.join("app.ron")).unwrap(), std::fs::read(data.join("app.ron")).unwrap());
|
||||
assert_eq!(std::fs::read(data.join("recovery/unsaved.pdf")).unwrap(), b"UNSAVED");
|
||||
assert_eq!(std::fs::read(data.join("digital-id.p12")).unwrap(), b"KEY");
|
||||
// The migrated file is a copy, not a link to the original settings.
|
||||
std::fs::write(selected.join("app.ron"), b"NEW").unwrap();
|
||||
assert_eq!(std::fs::read(data.join("app.ron")).unwrap(), b"(theme:Dark,language:German)");
|
||||
assert_eq!(directory(true, None, Some(data), Some(config), None).unwrap(), Some(selected.clone()));
|
||||
assert_eq!(std::fs::read(selected.join("app.ron")).unwrap(), b"NEW");
|
||||
assert_eq!(std::fs::read_dir(selected).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_or_concurrently_created_preferences_are_never_replaced() {
|
||||
let f = Fixture::new();
|
||||
let source = f.0.join("old.ron");
|
||||
let config = f.0.join("config/pdfcraft");
|
||||
std::fs::write(&source, b"OLD").unwrap();
|
||||
std::fs::create_dir_all(&config).unwrap();
|
||||
let target = config.join("app.ron");
|
||||
std::fs::write(&target, b"CURRENT").unwrap();
|
||||
migrate(&mut std::fs::File::open(source).unwrap(), &config, &target).unwrap();
|
||||
assert_eq!(std::fs::read(&target).unwrap(), b"CURRENT");
|
||||
assert_eq!(std::fs::read_dir(config).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_migration_keeps_original_available() {
|
||||
let f = Fixture::new();
|
||||
std::fs::write(f.0.join("app.ron"), b"OLD").unwrap();
|
||||
let blocked = f.0.join("blocked");
|
||||
std::fs::write(&blocked, b"NOT A DIRECTORY").unwrap();
|
||||
assert!(directory(true, None, Some(f.0.clone()), Some(blocked), None).is_err());
|
||||
assert_eq!(std::fs::read(f.0.join("app.ron")).unwrap(), b"OLD");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_and_non_xdg_platforms_keep_their_locations() {
|
||||
let f = Fixture::new();
|
||||
let portable = f.0.join("PdfCraftData");
|
||||
for xdg in [true, false] {
|
||||
assert_eq!(directory(xdg, Some(&portable), Some(f.0.clone()), Some(f.0.clone()), None).unwrap(), Some(portable.clone()));
|
||||
}
|
||||
assert_eq!(directory(false, None, Some(f.0.clone()), Some(portable), None).unwrap(), Some(f.0.clone()));
|
||||
assert_eq!(std::fs::read_dir(&f.0).unwrap().count(), 0, "no migration outside XDG mode");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn new_config_directory_is_private_and_existing_permissions_are_preserved() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let f = Fixture::new();
|
||||
let selected = directory(true, None, None, Some(f.0.join("config")), None).unwrap().unwrap();
|
||||
assert_eq!(std::fs::metadata(&selected).unwrap().permissions().mode() & 0o777, 0o700);
|
||||
std::fs::set_permissions(&selected, std::fs::Permissions::from_mode(0o750)).unwrap();
|
||||
create_private_directory(&selected).unwrap();
|
||||
assert_eq!(std::fs::metadata(selected).unwrap().permissions().mode() & 0o777, 0o750);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
#![cfg(target_os = "linux")]
|
||||
|
||||
use egui::{Event, Modifiers, MouseWheelUnit, RawInput, TouchPhase, vec2};
|
||||
use wayland_client::protocol::wl_pointer::AxisSource;
|
||||
|
||||
// Compile the production policy against winit's real event type, without a compositor.
|
||||
mod event {
|
||||
pub use winit::event::TouchPhase;
|
||||
}
|
||||
#[path = "../../../vendor/winit/src/platform_impl/linux/wayland/seat/pointer/scroll.rs"]
|
||||
mod scroll;
|
||||
|
||||
fn egui_phase(phase: event::TouchPhase) -> TouchPhase {
|
||||
match phase {
|
||||
event::TouchPhase::Started => TouchPhase::Start,
|
||||
event::TouchPhase::Moved => TouchPhase::Move,
|
||||
event::TouchPhase::Ended => TouchPhase::End,
|
||||
event::TouchPhase::Cancelled => TouchPhase::Cancel,
|
||||
}
|
||||
}
|
||||
|
||||
fn wheel_trace(source: AxisSource, discrete: bool) -> Vec<(f32, egui::Vec2)> {
|
||||
let ctx = egui::Context::default();
|
||||
let mut previous = event::TouchPhase::Ended;
|
||||
let mut seen = Vec::new();
|
||||
let ctrl = Modifiers { ctrl: true, command: true, ..Modifiers::NONE };
|
||||
for (frame, modifiers) in [ctrl, ctrl, Modifiers::NONE].into_iter().enumerate() {
|
||||
let phase = scroll::phase(previous, Some(source), discrete, false);
|
||||
previous = phase;
|
||||
let (unit, dy) = if discrete { (MouseWheelUnit::Line, 1.0) } else { (MouseWheelUnit::Point, 24.0) };
|
||||
let input = RawInput {
|
||||
time: Some(frame as f64 / 60.0),
|
||||
events: vec![Event::MouseWheel { unit, delta: vec2(0.0, dy), phase: egui_phase(phase), modifiers }],
|
||||
..Default::default()
|
||||
};
|
||||
ctx.run_ui(input, |ui| seen.push(ui.input(|i| (i.zoom_delta(), i.smooth_scroll_delta)))).textures_delta.clear();
|
||||
}
|
||||
seen
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn releasing_ctrl_after_pixel_wheel_zoom_returns_to_scrolling() {
|
||||
for source in [AxisSource::Wheel, AxisSource::WheelTilt, AxisSource::Continuous] {
|
||||
let seen = wheel_trace(source, false);
|
||||
assert!(seen[1].0 > 1.0, "Ctrl must zoom: {source:?}");
|
||||
assert_eq!(seen[2].0, 1.0, "releasing Ctrl must stop zooming: {source:?}");
|
||||
assert!(seen[2].1.y > 0.0, "the next wheel event must scroll: {source:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discrete_wheels_still_follow_current_modifiers() {
|
||||
let seen = wheel_trace(AxisSource::Wheel, true);
|
||||
assert!(seen[1].0 > 1.0);
|
||||
assert_eq!(seen[2].0, 1.0);
|
||||
assert!(seen[2].1.y > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finger_gestures_keep_their_modifier_until_the_gesture_ends() {
|
||||
// A finger scroll's momentum may arrive after Ctrl is released.
|
||||
let seen = wheel_trace(AxisSource::Finger, false);
|
||||
assert!(seen[1].0 > 1.0);
|
||||
assert!(seen[2].0 > 1.0);
|
||||
assert_eq!(seen[2].1, egui::Vec2::ZERO);
|
||||
assert_eq!(scroll::phase(event::TouchPhase::Moved, Some(AxisSource::Finger), false, true), event::TouchPhase::Ended);
|
||||
assert_eq!(scroll::phase(event::TouchPhase::Ended, Some(AxisSource::Finger), false, false), event::TouchPhase::Started);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_sources_keep_the_existing_phase_policy() {
|
||||
assert_eq!(scroll::phase(event::TouchPhase::Ended, None, false, false), event::TouchPhase::Started);
|
||||
assert_eq!(scroll::phase(event::TouchPhase::Started, None, false, false), event::TouchPhase::Moved);
|
||||
assert_eq!(scroll::phase(event::TouchPhase::Ended, None, true, false), event::TouchPhase::Moved);
|
||||
assert_eq!(scroll::phase(event::TouchPhase::Moved, None, false, true), event::TouchPhase::Ended);
|
||||
}
|
||||
@@ -25,6 +25,11 @@ real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
sync_github_name = false
|
||||
|
||||
[people.edurbs]
|
||||
real_name = "Eduardo Soares"
|
||||
display_name = "Eduardo Soares"
|
||||
sync_github_name = false
|
||||
|
||||
[people.jcl-bit]
|
||||
real_name = "James Locke"
|
||||
display_name = "James Locke"
|
||||
@@ -129,3 +134,14 @@ sync_github_name = false
|
||||
|
||||
[people.webdevtodayjason]
|
||||
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
|
||||
|
||||
+199
-27
@@ -27,6 +27,31 @@ pub enum AltError {
|
||||
Cos(#[from] pdfcraft_cos::CosError),
|
||||
}
|
||||
|
||||
/// Why a structure tag cannot safely become an artifact.
|
||||
#[derive(Debug, thiserror::Error, PartialEq)]
|
||||
pub enum ArtifactError {
|
||||
#[error("the document has no tags")]
|
||||
Untagged,
|
||||
#[error("object {0} is not a reachable indirect structure tag")]
|
||||
NotATag(u32),
|
||||
#[error("the tag has child structure elements; mark a leaf tag instead")]
|
||||
HasChildren,
|
||||
#[error("the tag has no direct marked content")]
|
||||
NoMarkedContent,
|
||||
#[error("the tag has no page")]
|
||||
NoPage,
|
||||
#[error("the tag's marked content was not found on its page")]
|
||||
ContentNotFound,
|
||||
#[error("the tag has no removable structure-tree parent")]
|
||||
NoParent,
|
||||
#[error("the parent must list the tag exactly once")]
|
||||
AmbiguousParent,
|
||||
#[error("couldn't update the parent tree: {0}")]
|
||||
ParentTree(String),
|
||||
#[error(transparent)]
|
||||
Cos(#[from] pdfcraft_cos::CosError),
|
||||
}
|
||||
|
||||
pub(crate) fn tree_root(doc: &Document) -> Option<Dict> {
|
||||
let cat = doc.root().and_then(|r| doc.get(r).as_dict().cloned())?;
|
||||
cat.get(b"StructTreeRoot").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned())
|
||||
@@ -115,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) {
|
||||
@@ -145,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;
|
||||
@@ -165,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();
|
||||
}
|
||||
@@ -262,6 +302,72 @@ pub fn set_alt(doc: &mut Document, obj: ObjRef, alt: Option<&str>) -> Result<(),
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The page's content entries: one stream, or the elements of a contents array.
|
||||
fn content_entries(doc: &Document, page: &Dict) -> Option<Vec<Object>> {
|
||||
let c = page.get(b"Contents")?;
|
||||
Some(match &*doc.resolve(c) {
|
||||
Object::Array(a) => a.clone(),
|
||||
Object::Stream(_) => vec![c.clone()],
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn marked(doc: &Document, props: &Dict, op: &pdfcraft_content::Op, ids: &HashSet<i64>) -> bool {
|
||||
op.op == b"BDC" && bdc_mcid(doc, props, op).is_some_and(|n| ids.contains(&n))
|
||||
}
|
||||
|
||||
/// Turn the figure's marked-content blocks into artifacts, rewriting only the streams that
|
||||
/// contain them. Other content streams, and unknown entries on a rewritten stream, stay.
|
||||
fn artifact_marked_content(doc: &mut Document, page: &pdfcraft_model::Page, ids: &HashSet<i64>) -> Result<bool, AltError> {
|
||||
let Some(entries) = content_entries(doc, &page.dict) else { return Ok(false) };
|
||||
let props = page_properties(doc, &page.dict);
|
||||
let mut next = Vec::with_capacity(entries.len());
|
||||
let mut changed = false;
|
||||
for entry in entries {
|
||||
let Some(stream) = (match &*doc.resolve(&entry) {
|
||||
Object::Stream(s) => Some(s.clone()),
|
||||
_ => None,
|
||||
}) else {
|
||||
next.push(entry);
|
||||
continue;
|
||||
};
|
||||
let mut ops = pdfcraft_content::parse(&stream.decoded().unwrap_or_default()).ops;
|
||||
let mut ours = false;
|
||||
for op in &mut ops {
|
||||
if marked(doc, &props, op, ids) {
|
||||
*op = pdfcraft_content::Op::new("BMC", vec![Object::name("Artifact")]);
|
||||
ours = true;
|
||||
}
|
||||
}
|
||||
if !ours {
|
||||
next.push(entry);
|
||||
continue;
|
||||
}
|
||||
changed = true;
|
||||
let mut dict = stream.dict;
|
||||
dict.remove(b"Length");
|
||||
dict.remove(b"Filter");
|
||||
dict.remove(b"DecodeParms");
|
||||
let stream = Object::Stream(Stream::flate(dict, &pdfcraft_content::serialize_ops(&ops)));
|
||||
let stored = match entry {
|
||||
Object::Ref(r) => {
|
||||
doc.set(r, stream);
|
||||
Object::Ref(r)
|
||||
}
|
||||
_ => Object::Ref(doc.add(stream)),
|
||||
};
|
||||
next.push(stored);
|
||||
}
|
||||
if changed {
|
||||
let contents = match next.as_slice() {
|
||||
[one] => one.clone(),
|
||||
_ => Object::Array(next),
|
||||
};
|
||||
doc.update_dict(page.obj, |p| p.set(b"Contents".to_vec(), contents))?;
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// Decorative figure: its marked content on the page becomes an artifact, and the element
|
||||
/// leaves the structure tree (and the parent tree).
|
||||
pub fn mark_decorative(doc: &mut Document, obj: ObjRef) -> Result<(), AltError> {
|
||||
@@ -269,24 +375,12 @@ pub fn mark_decorative(doc: &mut Document, obj: ObjRef) -> Result<(), AltError>
|
||||
let ids: HashSet<i64> = mcids(&d).into_iter().collect();
|
||||
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 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 {
|
||||
*op = pdfcraft_content::Op::new("BMC", vec![Object::name("Artifact")]);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
let s = doc.add(Object::Stream(Stream::flate(Dict::new(), &pdfcraft_content::serialize_ops(&ops))));
|
||||
doc.update_dict(page.obj, |p| p.set(b"Contents".to_vec(), Object::Ref(s)))?;
|
||||
// The parent tree no longer points those ids at the figure.
|
||||
if let Some(key) = page.dict.get(b"StructParents").and_then(Object::as_int) {
|
||||
clear_parent_tree(doc, key, obj)?;
|
||||
}
|
||||
if let Some(page) = pg.and_then(|p| pages.iter().find(|x| x.obj == p)).filter(|_| !ids.is_empty())
|
||||
&& artifact_marked_content(doc, page, &ids)?
|
||||
{
|
||||
// The parent tree no longer points those ids at the figure.
|
||||
if let Some(key) = page.dict.get(b"StructParents").and_then(Object::as_int) {
|
||||
clear_parent_tree(doc, key, obj)?;
|
||||
}
|
||||
}
|
||||
// Out of its parent's kids.
|
||||
@@ -305,17 +399,95 @@ pub fn mark_decorative(doc: &mut Document, obj: ObjRef) -> Result<(), AltError>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark a leaf structure tag's directly owned marked content as an artifact.
|
||||
///
|
||||
/// This deliberately refuses a tag with child structure elements. Converting a parent would
|
||||
/// otherwise silently discard semantic descendants. The visible page content is preserved: each
|
||||
/// matching marked-content sequence changes from `BDC` with an MCID to `/Artifact BMC`.
|
||||
pub fn mark_tag_artifact(doc: &mut Document, obj: ObjRef) -> Result<(), ArtifactError> {
|
||||
tree_root(doc).ok_or(ArtifactError::Untagged)?;
|
||||
let elements = elements(doc);
|
||||
let Some(element) = elements.iter().find(|e| e.object == obj) else { return Err(ArtifactError::NotATag(obj.num)) };
|
||||
if has_child_structure(doc, &element.dict) {
|
||||
return Err(ArtifactError::HasChildren);
|
||||
}
|
||||
let ids: HashSet<i64> = mcids(&element.dict).into_iter().collect();
|
||||
if ids.is_empty() {
|
||||
return Err(ArtifactError::NoMarkedContent);
|
||||
}
|
||||
let pages = pdfcraft_model::pages(doc);
|
||||
let page_ref = element.page.ok_or(ArtifactError::NoPage)?;
|
||||
let page = pages.iter().find(|page| page.obj == page_ref).ok_or(ArtifactError::NoPage)?;
|
||||
let mut ops = pdfcraft_content::parse(&page_content(doc, &page.dict)).ops;
|
||||
let mut found = HashSet::new();
|
||||
for op in &mut ops {
|
||||
let id = (op.op == b"BDC")
|
||||
.then(|| match op.operands.get(1) {
|
||||
Some(Object::Dict(mark)) => mark.get(b"MCID").and_then(Object::as_int),
|
||||
_ => None,
|
||||
})
|
||||
.flatten()
|
||||
.filter(|id| ids.contains(id));
|
||||
if let Some(id) = id {
|
||||
*op = pdfcraft_content::Op::new("BMC", vec![Object::name("Artifact")]);
|
||||
found.insert(id);
|
||||
}
|
||||
}
|
||||
if found != ids {
|
||||
return Err(ArtifactError::ContentNotFound);
|
||||
}
|
||||
let stream = doc.add(Object::Stream(Stream::flate(Dict::new(), &pdfcraft_content::serialize_ops(&ops))));
|
||||
doc.update_dict(page.obj, |p| p.set(b"Contents".to_vec(), Object::Ref(stream)))?;
|
||||
if let Some(key) = page.dict.get(b"StructParents").and_then(Object::as_int) {
|
||||
clear_parent_tree(doc, key, obj).map_err(|e| ArtifactError::ParentTree(e.to_string()))?;
|
||||
}
|
||||
let parent = element.dict.get(b"P").and_then(Object::as_ref).ok_or(ArtifactError::NoParent)?;
|
||||
remove_from_parent(doc, parent, obj)?;
|
||||
// Keep the retired object valid if another object holds an out-of-tree reference to it.
|
||||
doc.update_dict(obj, |d| {
|
||||
d.remove(b"P");
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn has_child_structure(doc: &Document, tag: &Dict) -> bool {
|
||||
fn child(doc: &Document, item: &Object) -> bool {
|
||||
match item {
|
||||
Object::Array(items) => items.iter().any(|item| child(doc, item)),
|
||||
Object::Ref(r) => doc.get(*r).as_dict().is_some_and(|d| d.contains(b"S")),
|
||||
Object::Dict(d) => d.contains(b"S"),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
tag.get(b"K").is_some_and(|items| child(doc, items))
|
||||
}
|
||||
|
||||
fn remove_from_parent(doc: &mut Document, parent: ObjRef, obj: ObjRef) -> Result<(), ArtifactError> {
|
||||
let kids = doc.get(parent).as_dict().and_then(|p| p.get(b"K").cloned()).ok_or(ArtifactError::NoParent)?;
|
||||
let replacement = match kids {
|
||||
Object::Array(items) => {
|
||||
if items.iter().filter(|item| item.as_ref() == Some(obj)).count() != 1 {
|
||||
return Err(ArtifactError::AmbiguousParent);
|
||||
}
|
||||
Object::Array(items.into_iter().filter(|item| item.as_ref() != Some(obj)).collect())
|
||||
}
|
||||
Object::Ref(r) if r == obj => Object::Array(Vec::new()),
|
||||
_ => return Err(ArtifactError::NoParent),
|
||||
};
|
||||
doc.update_dict(parent, |p| p.set(b"K".to_vec(), replacement))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replace `obj` by null in the page's parent-tree array (`key`), in a flat /Nums tree.
|
||||
fn clear_parent_tree(doc: &mut Document, key: i64, obj: ObjRef) -> Result<(), AltError> {
|
||||
let Some(pt) = tree_root(doc).and_then(|r| r.get(b"ParentTree").and_then(Object::as_ref)) else { return Ok(()) };
|
||||
let Some(nums) = doc.get(pt).as_dict().and_then(|d| d.get(b"Nums").and_then(|n| n.as_array().cloned())) else { return Ok(()) };
|
||||
let mut nums = nums;
|
||||
for pair in nums.chunks_mut(2) {
|
||||
if pair[0].as_int() == Some(key) {
|
||||
match &pair[1] {
|
||||
Object::Array(a) => {
|
||||
pair[1] = Object::Array(a.iter().map(|x| if x.as_ref() == Some(obj) { Object::Null } else { x.clone() }).collect())
|
||||
}
|
||||
// Whole pairs only: a trailing key without a value maps nothing, so it is left as it is.
|
||||
for [k, v] in nums.as_chunks_mut::<2>().0 {
|
||||
if k.as_int() == Some(key) {
|
||||
match &*v {
|
||||
Object::Array(a) => *v = Object::Array(a.iter().map(|x| if x.as_ref() == Some(obj) { Object::Null } else { x.clone() }).collect()),
|
||||
Object::Ref(r) => {
|
||||
if let Some(a) = doc.get(*r).as_array().cloned() {
|
||||
let a: Vec<Object> = a.iter().map(|x| if x.as_ref() == Some(obj) { Object::Null } else { x.clone() }).collect();
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
//! Conservative tagging for AcroForm widgets.
|
||||
//!
|
||||
//! It creates `/Form` structure elements and `/OBJR` children so assistive technology can find
|
||||
//! widgets. An existing direct `/Document` root and direct `/ParentTree /Nums` are extended;
|
||||
//! unusual tree layouts are refused before any edit is made.
|
||||
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
|
||||
|
||||
fn reference(object: Option<&Object>, what: &str) -> Result<ObjRef, String> {
|
||||
object.and_then(Object::as_ref).ok_or_else(|| format!("the {what} must be an indirect reference"))
|
||||
}
|
||||
|
||||
fn nums(doc: &Document, parent_tree: ObjRef) -> Result<Vec<Object>, String> {
|
||||
let tree = doc.get(parent_tree);
|
||||
let tree = tree.as_dict().ok_or("the structure parent tree is invalid")?;
|
||||
tree.get(b"Nums")
|
||||
.map(|value| doc.resolve(value))
|
||||
.as_deref()
|
||||
.and_then(Object::as_array)
|
||||
.cloned()
|
||||
.ok_or_else(|| "the structure parent tree must use a direct Nums array".to_string())
|
||||
}
|
||||
|
||||
/// Add `/Form` tags for every page widget that does not already have a structure parent.
|
||||
///
|
||||
/// A new structure tree is written for an untagged form. A conventional existing tree is
|
||||
/// extended by adding the form tags to its direct `/Document` element and fresh entries to its
|
||||
/// direct `/ParentTree /Nums` array. Text, figures, links, and non-widget annotations remain
|
||||
/// outside this operation and need separate review.
|
||||
pub fn tag_form_fields(doc: &mut Document) -> Result<usize, String> {
|
||||
let root = doc.root().ok_or("the PDF has no catalog")?;
|
||||
let catalog_object = doc.get(root);
|
||||
let catalog = catalog_object.as_dict().ok_or("the PDF catalog is invalid")?;
|
||||
if !catalog.contains(b"AcroForm") {
|
||||
return Err("the PDF has no AcroForm fields".into());
|
||||
}
|
||||
let existing_root = catalog.get(b"StructTreeRoot").cloned();
|
||||
let mark_info = catalog.get(b"MarkInfo").map(|v| doc.resolve(v)).as_deref().and_then(Object::as_dict).cloned().unwrap_or_default();
|
||||
let pages = pdfcraft_model::pages(doc);
|
||||
let mut widgets = Vec::new();
|
||||
// A widget listed in more than one page's /Annots is tagged once (one key, one /Form).
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for page in &pages {
|
||||
let annots = page.dict.get(b"Annots").map(|a| doc.resolve(a)).as_deref().and_then(Object::as_array).cloned().unwrap_or_default();
|
||||
for annot in annots {
|
||||
let Some(reference) = annot.as_ref() else { continue };
|
||||
let resolved = doc.get(reference);
|
||||
let Some(dict) = resolved.as_dict() else { continue };
|
||||
if dict.name(b"Subtype") == Some(b"Widget") && !dict.contains(b"StructParent") && seen.insert(reference) {
|
||||
widgets.push((page.obj, reference));
|
||||
}
|
||||
}
|
||||
}
|
||||
if widgets.is_empty() {
|
||||
return Err("the AcroForm has no untagged page widgets".into());
|
||||
}
|
||||
|
||||
let (struct_root, document_ref, parent_tree, mut parent_nums, first_key, new_tree) = if let Some(existing_root) = existing_root {
|
||||
let struct_root = reference(Some(&existing_root), "structure tree root")?;
|
||||
let structure_object = doc.get(struct_root);
|
||||
let structure = structure_object.as_dict().ok_or("the structure tree root is invalid")?;
|
||||
let document_ref = reference(structure.get(b"K"), "structure tree root K")?;
|
||||
let document = doc.get(document_ref);
|
||||
if document.as_dict().is_none_or(|d| d.name(b"S") != Some(b"Document")) {
|
||||
return Err("the structure tree root must point directly to a Document element".into());
|
||||
}
|
||||
let parent_tree = reference(structure.get(b"ParentTree"), "structure parent tree")?;
|
||||
let parent_nums = nums(doc, parent_tree)?;
|
||||
if parent_nums.len() % 2 != 0 {
|
||||
return Err("the structure parent tree has an odd number of entries".into());
|
||||
}
|
||||
let mut greatest = -1_i64;
|
||||
for pair in parent_nums.as_chunks::<2>().0 {
|
||||
let Some(key) = pair[0].as_int() else { return Err("the structure parent tree has a non-integer key".into()) };
|
||||
if key < 0 {
|
||||
return Err("the structure parent tree has a negative key".into());
|
||||
}
|
||||
greatest = greatest.max(key);
|
||||
}
|
||||
let first_key = greatest.checked_add(1).ok_or("the structure parent tree has no available key")?;
|
||||
(struct_root, document_ref, parent_tree, parent_nums, first_key, false)
|
||||
} else {
|
||||
let mut root_dict = Dict::new();
|
||||
root_dict.set(b"Type".to_vec(), Object::Name(b"StructTreeRoot".to_vec()));
|
||||
let struct_root = doc.add(Object::Dict(root_dict));
|
||||
let mut document = Dict::new();
|
||||
document.set(b"S".to_vec(), Object::Name(b"Document".to_vec()));
|
||||
document.set(b"P".to_vec(), Object::Ref(struct_root));
|
||||
let document_ref = doc.add(Object::Dict(document));
|
||||
let parent_tree = doc.add(Object::Dict(Dict::new()));
|
||||
(struct_root, document_ref, parent_tree, Vec::new(), 0, true)
|
||||
};
|
||||
let mut kids = Vec::with_capacity(widgets.len());
|
||||
for (offset, (page, widget)) in widgets.iter().copied().enumerate() {
|
||||
let key = first_key.checked_add(i64::try_from(offset).map_err(|_| "too many form widgets")?).ok_or("too many form widgets")?;
|
||||
let mut objr = Dict::new();
|
||||
objr.set(b"Type".to_vec(), Object::Name(b"OBJR".to_vec()));
|
||||
objr.set(b"Obj".to_vec(), Object::Ref(widget));
|
||||
objr.set(b"Pg".to_vec(), Object::Ref(page));
|
||||
let mut form = Dict::new();
|
||||
form.set(b"S".to_vec(), Object::Name(b"Form".to_vec()));
|
||||
form.set(b"P".to_vec(), Object::Ref(document_ref));
|
||||
form.set(b"Pg".to_vec(), Object::Ref(page));
|
||||
form.set(b"K".to_vec(), Object::Dict(objr));
|
||||
let form_ref = doc.add(Object::Dict(form));
|
||||
doc.update_dict(widget, |d| d.set(b"StructParent".to_vec(), Object::Int(key))).map_err(|e| e.to_string())?;
|
||||
parent_nums.extend([Object::Int(key), Object::Ref(form_ref)]);
|
||||
kids.push(Object::Ref(form_ref));
|
||||
}
|
||||
doc.update_dict(document_ref, |d| {
|
||||
let old = d.remove(b"K").unwrap_or(Object::Array(Vec::new()));
|
||||
let mut all = match old {
|
||||
Object::Array(items) => items,
|
||||
item => vec![item],
|
||||
};
|
||||
all.extend(kids);
|
||||
d.set(b"K".to_vec(), Object::Array(all));
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
doc.update_dict(parent_tree, |d| d.set(b"Nums".to_vec(), Object::Array(parent_nums))).map_err(|e| e.to_string())?;
|
||||
if new_tree {
|
||||
doc.update_dict(struct_root, |d| {
|
||||
d.set(b"K".to_vec(), Object::Ref(document_ref));
|
||||
d.set(b"ParentTree".to_vec(), Object::Ref(parent_tree));
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
let mut mark_info = mark_info;
|
||||
mark_info.set(b"Marked".to_vec(), Object::Bool(true));
|
||||
doc.update_dict(root, |d| {
|
||||
d.set(b"MarkInfo".to_vec(), Object::Dict(mark_info));
|
||||
if new_tree {
|
||||
d.set(b"StructTreeRoot".to_vec(), Object::Ref(struct_root));
|
||||
}
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(widgets.len())
|
||||
}
|
||||
+130
-25
@@ -3,6 +3,7 @@
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use pdfcraft_content::Matrix;
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
|
||||
use pdfcraft_fonts::pdf::Metrics;
|
||||
use pdfcraft_model::Page;
|
||||
@@ -171,13 +172,24 @@ fn sub(doc: &Document, d: &Dict, key: &[u8]) -> Dict {
|
||||
}
|
||||
|
||||
/// The text each marked-content id shows on a page (text drawn directly in the page content).
|
||||
fn mcid_texts(doc: &Document, page: &Dict) -> HashMap<i64, String> {
|
||||
///
|
||||
/// Word breaks come from where the glyphs are, as on the page: a glyph that starts on another
|
||||
/// line, more than 0.2 em past the end of the previous glyph of the same id, or back over more
|
||||
/// than an em from it, starts a new word (the em is the font size times |`Tz`|; see [`new_word`]).
|
||||
/// Text-positioning operators alone don't break words: producers that place every glyph with its
|
||||
/// own `Td` (Chromium) would otherwise read as "C h a p t e r".
|
||||
pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap<i64, String> {
|
||||
let res = sub(doc, page, b"Resources");
|
||||
let (fonts, props) = (sub(doc, &res, b"Font"), sub(doc, &res, b"Properties"));
|
||||
let mut metrics: HashMap<Vec<u8>, Metrics> = HashMap::new();
|
||||
let mut font: Option<Vec<u8>> = None;
|
||||
let mut stack: Vec<Option<i64>> = Vec::new();
|
||||
let mut out: HashMap<i64, String> = HashMap::new();
|
||||
// Where each id's last glyph ended, in user space.
|
||||
let mut ends: HashMap<i64, (f64, f64)> = HashMap::new();
|
||||
let mut gs = TextState::default();
|
||||
let mut saved: Vec<TextState> = Vec::new();
|
||||
let mut unsaved = 0usize;
|
||||
let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY);
|
||||
for op in pdfcraft_content::parse(&page_content(doc, page)).ops {
|
||||
let id = stack.iter().rev().find_map(|m| *m);
|
||||
match op.op.as_slice() {
|
||||
@@ -192,8 +204,22 @@ fn mcid_texts(doc: &Document, page: &Dict) -> HashMap<i64, String> {
|
||||
b"EMC" => {
|
||||
stack.pop();
|
||||
}
|
||||
b"q" if saved.len() < MAX_SAVED => saved.push(gs.clone()),
|
||||
b"q" => unsaved += 1,
|
||||
b"Q" if unsaved > 0 => unsaved -= 1,
|
||||
b"Q" => {
|
||||
if let Some(saved) = saved.pop() {
|
||||
gs = saved;
|
||||
}
|
||||
}
|
||||
b"cm" => {
|
||||
if let Some(m) = op.nums::<6>() {
|
||||
gs.ctm = Matrix(m).then(&gs.ctm);
|
||||
}
|
||||
}
|
||||
b"Tf" => {
|
||||
font = op.name(0).map(<[u8]>::to_vec);
|
||||
gs.size = op.num(1).unwrap_or(gs.size);
|
||||
gs.font = op.name(0).map(<[u8]>::to_vec);
|
||||
if let Some(name) = op.name(0)
|
||||
&& !metrics.contains_key(name)
|
||||
&& let Some(f) = fonts.get(name).map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned())
|
||||
@@ -201,34 +227,113 @@ fn mcid_texts(doc: &Document, page: &Dict) -> HashMap<i64, String> {
|
||||
metrics.insert(name.to_vec(), Metrics::from_dict(doc, &f));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let (Some(id), Some(m)) = (id, font.as_ref().and_then(|f| metrics.get(f))) else { continue };
|
||||
let text = out.entry(id).or_default();
|
||||
if text.len() >= MAX_TITLE * 4 {
|
||||
continue;
|
||||
}
|
||||
match op.op.as_slice() {
|
||||
b"Tj" | b"'" | b"\"" => {
|
||||
if op.op != b"Tj" {
|
||||
text.push(' ');
|
||||
}
|
||||
if let Some(s) = op.operands.iter().rev().find_map(Object::as_string) {
|
||||
text.push_str(&m.decode(&s.bytes));
|
||||
b"Tc" => gs.char_spacing = op.num(0).unwrap_or(0.0),
|
||||
b"Tw" => gs.word_spacing = op.num(0).unwrap_or(0.0),
|
||||
b"Tz" => gs.scale = op.num(0).unwrap_or(100.0) / 100.0,
|
||||
b"TL" => gs.leading = op.num(0).unwrap_or(0.0),
|
||||
b"BT" => (tm, tlm) = (Matrix::IDENTITY, Matrix::IDENTITY),
|
||||
b"Td" | b"TD" => {
|
||||
if let Some([x, y]) = op.nums::<2>() {
|
||||
if op.op == b"TD" {
|
||||
gs.leading = -y;
|
||||
}
|
||||
tlm = Matrix::translate(x, y).then(&tlm);
|
||||
tm = tlm;
|
||||
}
|
||||
}
|
||||
b"TJ" => {
|
||||
for item in op.operands.first().and_then(Object::as_array).into_iter().flatten() {
|
||||
match item {
|
||||
Object::String(s) => text.push_str(&m.decode(&s.bytes)),
|
||||
n if n.as_f64().is_some_and(|n| n < -200.0) => text.push(' '),
|
||||
_ => {}
|
||||
b"Tm" => {
|
||||
if let Some(m) = op.nums::<6>() {
|
||||
(tm, tlm) = (Matrix(m), Matrix(m));
|
||||
}
|
||||
}
|
||||
b"T*" => {
|
||||
tlm = Matrix::translate(0.0, -gs.leading).then(&tlm);
|
||||
tm = tlm;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let shows = matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"");
|
||||
let Some(m) = gs.font.as_ref().and_then(|f| metrics.get(f)).filter(|_| shows) else { continue };
|
||||
if op.op != b"Tj" && op.op != b"TJ" {
|
||||
if op.op == b"\"" {
|
||||
gs.word_spacing = op.num(0).unwrap_or(gs.word_spacing);
|
||||
gs.char_spacing = op.num(1).unwrap_or(gs.char_spacing);
|
||||
}
|
||||
tlm = Matrix::translate(0.0, -gs.leading).then(&tlm);
|
||||
tm = tlm;
|
||||
}
|
||||
let pieces: Vec<&Object> = match op.op.as_slice() {
|
||||
b"TJ" => op.operands.first().and_then(Object::as_array).map(|a| a.iter().collect()).unwrap_or_default(),
|
||||
_ => op.operands.last().into_iter().collect(),
|
||||
};
|
||||
for piece in pieces {
|
||||
let s = match piece {
|
||||
Object::String(s) => s,
|
||||
other => {
|
||||
// A TJ adjustment moves the next glyph; whether it breaks a word is decided there.
|
||||
let dx = -other.as_f64().unwrap_or(0.0) / 1000.0 * gs.size * gs.scale;
|
||||
tm = Matrix::translate(dx, 0.0).then(&tm);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
for (code, len) in m.codes(&s.bytes) {
|
||||
let trm = tm.then(&gs.ctm);
|
||||
if let Some(id) = id
|
||||
&& let Some(glyph) = m.text_of(code)
|
||||
{
|
||||
let text = out.entry(id).or_default();
|
||||
if text.len() < MAX_TITLE * 4 {
|
||||
let breaks = ends.get(&id).is_some_and(|end| new_word(&trm, *end, gs.size, gs.scale));
|
||||
if breaks && !text.is_empty() && !text.ends_with(' ') && !glyph.starts_with(' ') {
|
||||
text.push(' ');
|
||||
}
|
||||
text.push_str(&glyph);
|
||||
}
|
||||
}
|
||||
let advance = m.width(code) * gs.size + gs.char_spacing + if m.is_space(code, len) { gs.word_spacing } else { 0.0 };
|
||||
tm = Matrix::translate(advance * gs.scale, 0.0).then(&tm);
|
||||
if let Some(id) = id {
|
||||
ends.insert(id, tm.then(&gs.ctm).apply(0.0, 0.0));
|
||||
}
|
||||
}
|
||||
b"Td" | b"TD" | b"T*" | b"Tm" => text.push(' '),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// How deep `q` nests before the text state stops being saved (a hostile stream can nest millions).
|
||||
const MAX_SAVED: usize = 256;
|
||||
|
||||
/// The graphics state that places glyphs: the CTM, the font and the text state parameters.
|
||||
#[derive(Clone)]
|
||||
struct TextState {
|
||||
ctm: Matrix,
|
||||
font: Option<Vec<u8>>,
|
||||
size: f64,
|
||||
char_spacing: f64,
|
||||
word_spacing: f64,
|
||||
scale: f64,
|
||||
leading: f64,
|
||||
}
|
||||
|
||||
impl Default for TextState {
|
||||
fn default() -> Self {
|
||||
TextState { ctm: Matrix::IDENTITY, font: None, size: 0.0, char_spacing: 0.0, word_spacing: 0.0, scale: 1.0, leading: 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Does a glyph drawn with text rendering matrix `trm` (text space → user space) start a new word
|
||||
/// after a glyph that ended at `end` (user space)? Yes on another line (more than half the absolute
|
||||
/// font size up or down), more than 0.2 horizontal em ahead, or back over more than a horizontal em.
|
||||
/// The horizontal em is the absolute value of font size times horizontal scale (`Tz`); "ahead" follows
|
||||
/// the glyph advance, backwards when the size or `Tz` is negative. `Tz` never scales the vertical threshold.
|
||||
fn new_word(trm: &Matrix, end: (f64, f64), font_size: f64, horizontal_scale: f64) -> bool {
|
||||
let advance = font_size * horizontal_scale;
|
||||
let em = advance.abs();
|
||||
let start = trm.apply(0.0, 0.0);
|
||||
let [a, b, c, d, _, _] = trm.0;
|
||||
let Some(back) = Matrix([a, b, c, d, 0.0, 0.0]).invert() else { return true };
|
||||
let (dx, dy) = back.apply(start.0 - end.0, start.1 - end.1);
|
||||
let ahead = if advance < 0.0 { -dx } else { dx };
|
||||
!(ahead.is_finite() && dy.is_finite()) || dy.abs() > font_size.abs() * 0.5 || ahead > em * 0.2 || ahead < -em
|
||||
}
|
||||
|
||||
@@ -14,17 +14,19 @@ use pdfcraft_cos::Document;
|
||||
|
||||
mod alt;
|
||||
mod autotag;
|
||||
mod autotag_fields;
|
||||
mod content;
|
||||
mod headings;
|
||||
mod report;
|
||||
mod structure;
|
||||
mod tags;
|
||||
|
||||
pub use alt::{AltError, Figure, figures, mark_decorative, set_alt};
|
||||
pub use alt::{AltError, ArtifactError, Figure, figures, mark_decorative, mark_tag_artifact, set_alt};
|
||||
pub use autotag::{AutoTagError, AutoTagResult, autotag_text};
|
||||
pub use autotag_fields::tag_form_fields;
|
||||
pub use headings::{Heading, headings};
|
||||
pub use report::{REPORT_WORDS, report_html};
|
||||
pub use tags::{TagError, TagInfo, TagPlacement, TagProperty, move_tag, set_tag_property, tags};
|
||||
pub use tags::{TagContentItem, TagError, TagInfo, TagPlacement, TagProperty, move_tag, set_tag_property, tag_content, tags};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum Category {
|
||||
|
||||
@@ -21,6 +21,14 @@ pub struct TagInfo {
|
||||
pub page: Option<usize>,
|
||||
}
|
||||
|
||||
/// A marked-content reference directly owned by a structure tag.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct TagContentItem {
|
||||
pub page: Option<usize>,
|
||||
pub mcid: i64,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TagProperty {
|
||||
Type,
|
||||
@@ -140,6 +148,58 @@ pub fn tags(doc: &Document) -> Vec<TagInfo> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// List marked-content references directly owned by an indirect tag.
|
||||
pub fn tag_content(doc: &Document, object: ObjRef) -> Result<Vec<TagContentItem>, TagError> {
|
||||
let Some(element) = alt::elements(doc).into_iter().find(|e| e.object == object) else { return Err(TagError::NotATag(object)) };
|
||||
let pages = pdfcraft_model::pages(doc);
|
||||
let page_index: std::collections::HashMap<ObjRef, usize> = pages.iter().enumerate().map(|(i, p)| (p.obj, i)).collect();
|
||||
let mut marks = Vec::new();
|
||||
if let Some(kids) = element.dict.get(b"K") {
|
||||
direct_marks(doc, kids, element.page, &mut marks);
|
||||
}
|
||||
// Each page's content is parsed once, however many of its MCIDs the tag owns.
|
||||
let mut texts: std::collections::HashMap<usize, std::collections::HashMap<i64, String>> = std::collections::HashMap::new();
|
||||
Ok(marks
|
||||
.into_iter()
|
||||
.map(|(page, mcid)| {
|
||||
let page = page.and_then(|p| page_index.get(&p).copied());
|
||||
let text = page
|
||||
.and_then(|i| {
|
||||
let page_texts = match texts.entry(i) {
|
||||
std::collections::hash_map::Entry::Occupied(e) => e.into_mut(),
|
||||
std::collections::hash_map::Entry::Vacant(e) => {
|
||||
e.insert(pages.get(i).map(|p| crate::headings::mcid_texts(doc, &p.dict)).unwrap_or_default())
|
||||
}
|
||||
};
|
||||
page_texts.get(&mcid).cloned()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
TagContentItem { page, mcid, text }
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn direct_marks(doc: &Document, item: &Object, page: Option<ObjRef>, out: &mut Vec<(Option<ObjRef>, i64)>) {
|
||||
match item {
|
||||
Object::Int(mcid) => out.push((page, *mcid)),
|
||||
Object::Array(items) => items.iter().for_each(|item| direct_marks(doc, item, page, out)),
|
||||
Object::Dict(dict) if dict.name(b"Type") == Some(b"MCR") => {
|
||||
if let Some(mcid) = dict.get(b"MCID").and_then(Object::as_int) {
|
||||
out.push((dict.get(b"Pg").and_then(Object::as_ref).or(page), mcid));
|
||||
}
|
||||
}
|
||||
Object::Ref(reference) => {
|
||||
if let Some(dict) = doc.get(*reference).as_dict()
|
||||
&& dict.name(b"Type") == Some(b"MCR")
|
||||
&& let Some(mcid) = dict.get(b"MCID").and_then(Object::as_int)
|
||||
{
|
||||
out.push((dict.get(b"Pg").and_then(Object::as_ref).or(page), mcid));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Change one property of a reachable tag; `None` removes a text property.
|
||||
pub fn set_tag_property(doc: &mut Document, object: ObjRef, property: TagProperty, value: Option<&str>) -> Result<(), TagError> {
|
||||
if !alt::elements(doc).iter().any(|e| e.object == object) {
|
||||
|
||||
@@ -96,6 +96,78 @@ fn autotag_text_refuses_paint_and_does_not_modify_the_document() {
|
||||
assert_eq!(autotag_text(&mut pdf(&good(), "")), Err(AutoTagError::AlreadyTagged));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autotag_form_fields_writes_widget_structure_and_parent_tree() {
|
||||
let mut doc = pdf(
|
||||
&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [5 0 R 6 0 R] >>".into(),
|
||||
"<< /Length 0 >>\nstream\n\nendstream".into(),
|
||||
"<< /FT /Tx /T (name) /Kids [6 0 R] >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [20 60 120 80] >>".into(),
|
||||
],
|
||||
"",
|
||||
);
|
||||
assert_eq!(tag_form_fields(&mut doc).unwrap(), 1);
|
||||
let report = check(&doc, &Options { rules: [Rule::TaggedPdf, Rule::TaggedFormFields].into(), pages: None });
|
||||
assert_eq!(status(&report, Rule::TaggedPdf), Status::Passed);
|
||||
assert_eq!(status(&report, Rule::TaggedFormFields), Status::Passed);
|
||||
let tree = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"StructTreeRoot").unwrap();
|
||||
assert!(doc.get(tree).as_dict().unwrap().contains(b"ParentTree"));
|
||||
let widget = pdfcraft_model::pages(&doc)[0].dict.get(b"Annots").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap()[1].as_ref().unwrap();
|
||||
assert_eq!(doc.get(widget).as_dict().unwrap().get(b"StructParent").and_then(Object::as_int), Some(0));
|
||||
let written = pdfcraft_cos::write_full(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap();
|
||||
let reopened = Document::open(Arc::new(written)).unwrap();
|
||||
assert_eq!(status(&check(&reopened, &Options { rules: [Rule::TaggedFormFields].into(), pages: None }), Rule::TaggedFormFields), Status::Passed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autotag_form_fields_tags_a_widget_listed_on_two_pages_once() {
|
||||
let mut doc = pdf(
|
||||
&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R 6 0 R] /Count 2 >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [5 0 R] >>".into(),
|
||||
"<< /Length 0 >>\nstream\n\nendstream".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [20 60 120 80] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [5 0 R] >>".into(),
|
||||
],
|
||||
"",
|
||||
);
|
||||
assert_eq!(tag_form_fields(&mut doc).unwrap(), 1);
|
||||
let tree = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"StructTreeRoot").unwrap();
|
||||
let parent_tree = doc.get(tree).as_dict().unwrap().reference(b"ParentTree").unwrap();
|
||||
let nums = doc.get(parent_tree).as_dict().unwrap().get(b"Nums").and_then(|n| n.as_array().cloned()).unwrap();
|
||||
assert_eq!(nums.len(), 2, "one key and one /Form element for the shared widget");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autotag_form_fields_extends_a_direct_existing_structure_tree() {
|
||||
let mut doc = pdf(
|
||||
&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> /MarkInfo << /Marked true >> /StructTreeRoot 6 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [5 0 R] /StructParents 0 >>".into(),
|
||||
"<< /Length 0 >>\nstream\n\nendstream".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [20 60 120 80] >>".into(),
|
||||
"<< /Type /StructTreeRoot /K 7 0 R /ParentTree 8 0 R >>".into(),
|
||||
"<< /S /Document /P 6 0 R /K 9 0 R >>".into(),
|
||||
"<< /Nums [0 [9 0 R]] >>".into(),
|
||||
"<< /S /P /P 7 0 R /Pg 3 0 R /K 0 >>".into(),
|
||||
],
|
||||
"",
|
||||
);
|
||||
assert_eq!(tag_form_fields(&mut doc).unwrap(), 1);
|
||||
let widget = pdfcraft_model::pages(&doc)[0].dict.get(b"Annots").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap()[0].as_ref().unwrap();
|
||||
assert_eq!(doc.get(widget).as_dict().unwrap().get(b"StructParent").and_then(Object::as_int), Some(1));
|
||||
let report = check(&doc, &Options { rules: [Rule::TaggedFormFields].into(), pages: None });
|
||||
assert_eq!(status(&report, Rule::TaggedFormFields), Status::Passed);
|
||||
let document_object = doc.get(pdfcraft_cos::ObjRef::new(7, 0));
|
||||
let document = document_object.as_dict().unwrap();
|
||||
assert_eq!(document.get(b"K").and_then(Object::as_array).unwrap().len(), 2, "the existing paragraph and new form remain siblings");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untagged_document_fails_the_document_rules() {
|
||||
let doc = pdf(
|
||||
@@ -526,6 +598,16 @@ fn figures_are_listed_with_their_alt_text_and_place() {
|
||||
assert_eq!((finding.object, finding.page, finding.bbox), (Some(f[0].obj), Some(0), f[0].bbox));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_content_lists_direct_marked_content() {
|
||||
let doc = figures_doc();
|
||||
let tags = tags(&doc);
|
||||
let first = tag_content(&doc, tags[1].object).unwrap();
|
||||
let second = tag_content(&doc, tags[2].object).unwrap();
|
||||
assert_eq!((first[0].page, first[0].mcid, first[0].text.as_str()), (Some(0), 0, ""));
|
||||
assert_eq!((second[0].page, second[0].mcid, second[0].text.as_str()), (Some(0), 1, ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sibling_tags_move_in_reading_order_without_changing_content() {
|
||||
let mut doc = figures_doc();
|
||||
@@ -591,6 +673,95 @@ fn setting_alt_text_and_marking_decorative() {
|
||||
assert!(text.contains("/Artifact BMC") && !text.contains("/MCID 0"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marking_a_leaf_tag_as_an_artifact_preserves_appearance_and_structure_integrity() {
|
||||
let mut doc = figures_doc();
|
||||
let page = pdfcraft_model::pages(&doc)[0].obj;
|
||||
mark_tag_artifact(&mut doc, pdfcraft_cos::ObjRef::new(7, 0)).unwrap();
|
||||
assert_eq!(tags(&doc).into_iter().map(|tag| tag.object.num).collect::<Vec<_>>(), [6, 8]);
|
||||
let c = doc.resolve(doc.get(page).as_dict().unwrap().get(b"Contents").unwrap());
|
||||
let pdfcraft_cos::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("/MCID 0"), "{text}");
|
||||
let report = check(&doc, &Options { rules: [Rule::TaggedContent, Rule::FiguresAltText].into(), pages: None });
|
||||
assert_eq!(failed(&report), Vec::<Rule>::new(), "{:?}", report.results);
|
||||
assert_eq!(mark_tag_artifact(&mut doc, pdfcraft_cos::ObjRef::new(6, 0)), Err(ArtifactError::HasChildren));
|
||||
assert_eq!(mark_tag_artifact(&mut doc, pdfcraft_cos::ObjRef::new(7, 0)), Err(ArtifactError::NotATag(7)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marking_decorative_survives_a_parent_tree_with_an_unpaired_key() {
|
||||
// A trailing key without a value, matching the page's StructParents (#1809), and the same
|
||||
// with no valid pair at all.
|
||||
let tree = |nums: &str| pdf(&[format!("<< /Nums {nums} >>")], "").get(pdfcraft_cos::ObjRef::new(1, 0)).as_ref().clone();
|
||||
for (nums, cleared) in [("[0 [7 0 R 8 0 R] 0]", "[0 [null 8 0 R] 0]"), ("[0]", "[0]")] {
|
||||
let mut doc = figures_doc();
|
||||
let pt = pdfcraft_cos::ObjRef::new(9, 0);
|
||||
doc.set(pt, tree(nums));
|
||||
let f = figures(&doc);
|
||||
mark_decorative(&mut doc, f[0].obj).unwrap();
|
||||
assert_eq!(figures(&doc).len(), 1, "{nums}");
|
||||
// The pair is cleared and the unpaired key is kept as it was.
|
||||
assert_eq!(*doc.get(pt), tree(cleared), "{nums}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `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))
|
||||
}
|
||||
@@ -640,6 +811,114 @@ fn headings_are_found_with_their_levels_titles_and_pages() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_content_reads_the_text_of_each_marked_content_reference() {
|
||||
let doc = pdf(&headings_doc(), "");
|
||||
let items = |n| tag_content(&doc, ref_(n).unwrap()).unwrap().into_iter().map(|i| (i.page, i.mcid, i.text)).collect::<Vec<_>>();
|
||||
assert_eq!(items(12), [(Some(0), 0, "Chapter One".to_string())]);
|
||||
assert_eq!(items(13), [(Some(0), 2, "Sec A".to_string())]);
|
||||
assert_eq!(items(15), [(Some(1), 1, "AB".to_string())]);
|
||||
}
|
||||
|
||||
/// Helvetica advances (standard 14 metrics, per 1000 em) of the characters the tests below draw.
|
||||
fn helvetica_width(c: char) -> f64 {
|
||||
match c {
|
||||
'C' | 'w' => 722.0,
|
||||
'P' => 667.0,
|
||||
'T' | 'F' => 611.0,
|
||||
'v' => 500.0,
|
||||
'r' => 333.0,
|
||||
't' | ' ' => 278.0,
|
||||
'i' => 222.0,
|
||||
_ => 556.0, // a e h o p
|
||||
}
|
||||
}
|
||||
|
||||
/// `text` drawn one glyph at a time from (72, y), each glyph moved into place with its own `Td`,
|
||||
/// as Chromium writes text. With `spaces`, the space is drawn too; without, the next glyph is
|
||||
/// just moved past it.
|
||||
fn glyph_by_glyph(text: &str, size: f64, y: f64, spaces: bool) -> String {
|
||||
let mut ops = format!("BT /F1 {size} Tf 72 {y} Td");
|
||||
let mut step = 0.0;
|
||||
for c in text.chars() {
|
||||
if c == ' ' && !spaces {
|
||||
step += helvetica_width(c) / 1000.0 * size;
|
||||
continue;
|
||||
}
|
||||
ops.push_str(&format!(" {step:.3} 0 Td ({c}) Tj"));
|
||||
step = helvetica_width(c) / 1000.0 * size;
|
||||
}
|
||||
ops + " ET"
|
||||
}
|
||||
|
||||
/// `text` drawn one glyph at a time with `/F1 1 Tf` and a `Tm` per glyph scaled to `size`; the
|
||||
/// space is skipped over, not drawn.
|
||||
fn glyph_by_tm(text: &str, size: f64, y: f64) -> String {
|
||||
let mut ops = "BT /F1 1 Tf".to_string();
|
||||
let mut x = 72.0;
|
||||
for c in text.chars() {
|
||||
if c != ' ' {
|
||||
ops.push_str(&format!(" {size} 0 0 {size} {x:.3} {y} Tm ({c}) Tj"));
|
||||
}
|
||||
x += helvetica_width(c) / 1000.0 * size;
|
||||
}
|
||||
ops + " ET"
|
||||
}
|
||||
|
||||
/// One tagged page whose H1 headings show `contents` (one marked-content sequence each).
|
||||
fn heading_page(contents: &[String]) -> Document {
|
||||
let marked: Vec<String> = contents.iter().enumerate().map(|(i, c)| format!("/H1 << /MCID {i} >> BDC {c} EMC")).collect();
|
||||
let kids: Vec<String> = (0..contents.len()).map(|i| format!("{} 0 R", 7 + i)).collect();
|
||||
let mut objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 6 0 R >> >> >>".into(),
|
||||
stream(&marked.join("\n")),
|
||||
format!("<< /Type /StructTreeRoot /K [{}] >>", kids.join(" ")),
|
||||
FONT.into(),
|
||||
];
|
||||
objs.extend((0..contents.len()).map(|i| format!("<< /S /H1 /Pg 3 0 R /K {i} >>")));
|
||||
pdf(&objs, "")
|
||||
}
|
||||
|
||||
/// Chromium (Skia) places each glyph with its own `Td`: a heading must read as its words, not as
|
||||
/// "C h a p t e r T w o", whether the space is drawn or only skipped over. A heading set with a
|
||||
/// negative font size or `Tz` keeps its words too.
|
||||
#[test]
|
||||
fn headings_drawn_glyph_by_glyph_keep_their_words() {
|
||||
let doc = heading_page(&[
|
||||
"BT /F1 20 Tf 72 700 Td (Chapter One) Tj ET".into(),
|
||||
glyph_by_glyph("Chapter Two", 20.0, 650.0, true),
|
||||
glyph_by_glyph("Chapter Three", 20.0, 600.0, false),
|
||||
// Each glyph placed with its own Tm, at font size 1 scaled by the matrix.
|
||||
glyph_by_tm("Part Five", 18.0, 550.0),
|
||||
// Kerning inside a word, then a word space as a TJ adjustment, then a wrapped line.
|
||||
"BT /F1 16 Tf 72 450 Td [(Ch) 40 (apter) -350 (Four)] TJ 0 -20 Td (continued) Tj ET".into(),
|
||||
// A negative font size runs the glyphs backwards in text space: alone, in a mirrored
|
||||
// text matrix (upright on the page), with a word space as a TJ adjustment, and the same
|
||||
// with a negative horizontal scale instead (reset after, as Tz outlives the text object).
|
||||
"BT /F1 -20 Tf 72 380 Td (Chapter Six) Tj ET".into(),
|
||||
"BT /F1 -20 Tf -1 0 0 -1 300 330 Tm (Chapter Seven) Tj ET".into(),
|
||||
"BT /F1 -20 Tf 72 280 Td [(Chapter) -300 (Eight)] TJ ET".into(),
|
||||
"BT /F1 20 Tf -100 Tz 300 230 Td [(Chapter) -300 (Nine)] TJ ET 100 Tz".into(),
|
||||
]);
|
||||
let titles: Vec<String> = headings(&doc).into_iter().map(|h| h.title).collect();
|
||||
assert_eq!(
|
||||
titles,
|
||||
[
|
||||
"Chapter One",
|
||||
"Chapter Two",
|
||||
"Chapter Three",
|
||||
"Part Five",
|
||||
"Chapter Four continued",
|
||||
"Chapter Six",
|
||||
"Chapter Seven",
|
||||
"Chapter Eight",
|
||||
"Chapter Nine"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untagged_document_has_no_headings() {
|
||||
let mut objs = headings_doc();
|
||||
@@ -736,3 +1015,11 @@ fn a_nested_finding_says_itself() {
|
||||
// Both wordings are translated, each on its own, and the document's words are left alone.
|
||||
assert_eq!(f.said(&|w: &str| format!("«{w}»")), "«Table element on page 3: «rows have between 1 and 2 columns»»");
|
||||
}
|
||||
|
||||
/// Horizontal stretching must not raise the vertical line-break threshold.
|
||||
#[test]
|
||||
fn horizontally_scaled_wrapped_heading_keeps_a_space_between_lines() {
|
||||
let doc = heading_page(&["BT /F1 20 Tf 400 Tz 72 700 Td (I) Tj 0 -30 Td (Overview) Tj ET".into()]);
|
||||
let titles: Vec<String> = headings(&doc).into_iter().map(|h| h.title).collect();
|
||||
assert_eq!(titles, ["I Overview"]);
|
||||
}
|
||||
|
||||
@@ -269,6 +269,12 @@ fn stamp_turn(d: &Dict) -> i64 {
|
||||
d.get(b"PCStampRotate").and_then(Object::as_int).unwrap_or(0).rem_euclid(360) / 90 * 90
|
||||
}
|
||||
|
||||
/// The page `/Rotate` a text box's or Fill & Sign text's lines are turned back by
|
||||
/// (`/PCTextRotate`, written when it is added to a turned page).
|
||||
fn text_turn(d: &Dict) -> i64 {
|
||||
d.get(b"PCTextRotate").and_then(Object::as_int).unwrap_or(0).rem_euclid(360) / 90 * 90
|
||||
}
|
||||
|
||||
/// The first text [`build`] would draw in the standard fonts (Helvetica, WinAnsiEncoding) that
|
||||
/// they can't, which would come out as `?`: what it is (for the message) and the character. The
|
||||
/// text of a text box, callout or Fill & Sign text, a measurement's label, a dynamic stamp's
|
||||
@@ -666,6 +672,19 @@ pub fn build(d: &Dict) -> Option<Stream> {
|
||||
let bw = if d.contains(b"BS") || d.contains(b"Border") { border_width(d) } else { 0.0 };
|
||||
// A callout: the leader line (arrowhead at its first point), and the text box inside
|
||||
// `/Rect` by `/RD`.
|
||||
// Text added to a turned page is drawn upright in its displayed box, which the form
|
||||
// `/Matrix` then maps into `/Rect` (as for stamps). Callouts keep user-space axes.
|
||||
let turn = if d.contains(b"CL") { 0 } else { text_turn(d) };
|
||||
let (rect, matrix) = if turn == 0 {
|
||||
(rect, None)
|
||||
} else {
|
||||
let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]);
|
||||
let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) };
|
||||
if dw < 1.0 || dh < 1.0 {
|
||||
return None;
|
||||
}
|
||||
([0.0, 0.0, dw, dh], Some(pdfcraft_model::view_matrix_for(turn, rect)))
|
||||
};
|
||||
let full = rect;
|
||||
let mut rect = rect;
|
||||
if d.contains(b"CL") {
|
||||
@@ -751,7 +770,11 @@ pub fn build(d: &Dict) -> Option<Stream> {
|
||||
let mut fonts = Dict::new();
|
||||
fonts.set(b"Helv".to_vec(), Object::Dict(font));
|
||||
res.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
return Some(form(full, &out, res));
|
||||
let mut stream = form(full, &out, res);
|
||||
if let Some(m) = matrix {
|
||||
stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect()));
|
||||
}
|
||||
return Some(stream);
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
|
||||
@@ -1206,6 +1206,13 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
|
||||
}
|
||||
border(&mut d);
|
||||
rich_text(&mut d);
|
||||
// The text is laid out for the page as displayed, like a stamp's label.
|
||||
if !matches!(new.shape, Shape::Callout { .. }) {
|
||||
let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc));
|
||||
if matches!(turn, 90 | 180 | 270) {
|
||||
d.set(b"PCTextRotate".to_vec(), Object::Int(turn));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -1939,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 {
|
||||
@@ -2173,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)`.
|
||||
@@ -2198,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);
|
||||
}
|
||||
}
|
||||
|
||||
+11
-2
@@ -16,6 +16,11 @@ It reports two kinds of finding:
|
||||
A properly applied redaction removes the text, so it never flags. Page backgrounds, table
|
||||
shading and highlights don't either: they're painted before the text, or light against it.
|
||||
|
||||
A rectangle covers a text line when it hides at least 40% of the area of the words the line
|
||||
draws, or at least 40% of the line's box together with a positive-area intersection with one
|
||||
of its words. The gap an applied redaction leaves (a `TJ` adjustment where the text was) is in the line's box but
|
||||
holds no word, so a redaction box over it alone does not count.
|
||||
|
||||
Limits, by design and documented rather than hidden:
|
||||
|
||||
- only filled rectangles count as covers, drawn with `re` or as a single `m`/`l` subpath whose
|
||||
@@ -23,9 +28,13 @@ Limits, by design and documented rather than hidden:
|
||||
translucent one (`/ca` below 1) is not flagged;
|
||||
- covers inside form XObjects are found, but only text drawn directly in the page content is
|
||||
checked under them;
|
||||
- annotation rectangles are compared in unrotated user space, so `/Rotate` pages may misalign.
|
||||
- annotation rectangles are compared in unrotated user space, so `/Rotate` pages may misalign;
|
||||
- a cover that hides less than 40% of a line's box and less than 40% of its words (a short
|
||||
value in the middle of a long line) is not flagged.
|
||||
|
||||
It fails closed: an undecodable content stream, malformed `/Annots`, form XObjects nested deeper
|
||||
than 32 or in a cycle, or a walk past the operator bound is an error, never "no findings".
|
||||
|
||||
Surfaces: the `redact_audit` automation tool and `pdfcraft-cli`.
|
||||
Surfaces: the `doc_audit_redactions` automation tool and `pdfcraft-cli audit`. Both report each
|
||||
rectangle in points from the top-left of the page as displayed (y down, after `/Rotate`), like
|
||||
the other tools; `AuditFinding::rect` itself stays in PDF user space.
|
||||
|
||||
@@ -219,10 +219,13 @@ impl<'a> Walker<'a> {
|
||||
b"h" => {
|
||||
path = match path {
|
||||
Path::Lines(pts, _) => Path::Lines(pts, true),
|
||||
// `re` already closed its subpath, so a further `h` changes nothing.
|
||||
Path::Rect(r) => Path::Rect(r),
|
||||
_ => Path::Other,
|
||||
};
|
||||
}
|
||||
b"f" | b"F" | b"f*" | b"B" | b"B*" => {
|
||||
// `b`/`b*` close the path before filling it, which the fill does anyway.
|
||||
b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" => {
|
||||
if let Some(r) = std::mem::replace(&mut path, Path::Empty).rect()
|
||||
&& gs.alpha >= 1.0
|
||||
{
|
||||
|
||||
+46
-25
@@ -9,12 +9,13 @@
|
||||
//! unreadable (WCAG contrast below 3.0). Page backgrounds, table shading and highlight
|
||||
//! marks are therefore not flagged.
|
||||
//!
|
||||
//! Detection limits (documented, not hidden): only `re` rectangles closed by a fill operator
|
||||
//! are considered — arbitrary filled paths, shadings and patterns are not; a rectangle drawn
|
||||
//! with a translucent ExtGState (`/ca < 1`) is not flagged; annotation rectangles are compared
|
||||
//! in unrotated user space, so pages with `/Rotate` may misalign; covers inside form XObjects
|
||||
//! are found, but only text drawn directly in the page content is checked under them, so text
|
||||
//! inside a form XObject is not audited.
|
||||
//! Detection limits (documented, not hidden): `re` rectangles and single closed `m`/`l`
|
||||
//! subpaths whose four corners form an axis-aligned rectangle in user space (#1510) are
|
||||
//! considered when closed by a fill operator — other filled paths, shadings and patterns are
|
||||
//! not; a rectangle drawn with a translucent ExtGState (`/ca < 1`) is not flagged; annotation
|
||||
//! rectangles are compared in unrotated user space, so pages with `/Rotate` may misalign;
|
||||
//! covers inside form XObjects are found, but only text drawn directly in the page content
|
||||
//! is checked under them, so text inside a form XObject is not audited.
|
||||
//!
|
||||
//! The audit fails closed: any content that cannot be read (an undecodable content stream,
|
||||
//! malformed annotations, form XObjects nested deeper than 32 or in a cycle) is an error, never
|
||||
@@ -40,6 +41,11 @@ pub enum FindingKind {
|
||||
}
|
||||
|
||||
impl FindingKind {
|
||||
/// The stable machine-readable ID for this finding class.
|
||||
///
|
||||
/// These strings are the crate's public vocabulary: they appear in CLI output and are
|
||||
/// the IDs a future machine-readable format would serialize. Do not rename them;
|
||||
/// add a new variant instead.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
FindingKind::CoveredText => "covered-text",
|
||||
@@ -54,9 +60,11 @@ pub struct AuditFinding {
|
||||
/// 0-based page index.
|
||||
pub page: usize,
|
||||
pub kind: FindingKind,
|
||||
/// The covering rectangle (or the mark's rectangle), in page user space.
|
||||
/// The covering rectangle (or the mark's rectangle), in page user space, normalized so
|
||||
/// `rect[0] <= rect[2]` and `rect[1] <= rect[3]`.
|
||||
pub rect: [f64; 4],
|
||||
/// The extractable text the cover hides (possibly empty when nothing decodable remains).
|
||||
/// The extractable text the cover hides, lines joined with single spaces and trimmed.
|
||||
/// Possibly empty when nothing decodable remains under the cover.
|
||||
pub covered_text: String,
|
||||
}
|
||||
|
||||
@@ -76,7 +84,8 @@ pub enum AuditError {
|
||||
Edit(#[from] pdfcraft_edit::EditError),
|
||||
}
|
||||
|
||||
/// Fraction of a text line's area that must lie under a cover to count as hidden.
|
||||
/// Fraction of a text line's ink (the area of its words), or of its box, that must lie under a
|
||||
/// cover to count as hidden (see [`covers`]).
|
||||
const COVER_FRACTION: f64 = 0.4;
|
||||
/// WCAG contrast ratio below which the text is unreadable against the cover.
|
||||
const COVER_CONTRAST: f64 = 3.0;
|
||||
@@ -106,7 +115,7 @@ pub fn audit_redactions(doc: &Document) -> Result<Vec<AuditFinding>, AuditError>
|
||||
for annot in annots(doc, &page.dict)? {
|
||||
let mut covered = String::new();
|
||||
for line in &lines {
|
||||
if covers(&annot, &line.rect) {
|
||||
if covers(&annot, line) {
|
||||
if !covered.is_empty() {
|
||||
covered.push(' ');
|
||||
}
|
||||
@@ -126,23 +135,35 @@ pub fn audit_redactions(doc: &Document) -> Result<Vec<AuditFinding>, AuditError>
|
||||
/// make the text unreadable. Light covers (table shading, highlights) over dark text fail the
|
||||
/// contrast check and are not flagged.
|
||||
fn is_cover(rect: &cover::FilledRect, line: &TextLine) -> bool {
|
||||
rect.pos > line.paint_pos() && covers(&rect.rect, &line.rect) && contrast(line.color, rect.rgb) < COVER_CONTRAST
|
||||
rect.pos > line.paint_pos() && covers(&rect.rect, line) && contrast(line.color, rect.rgb) < COVER_CONTRAST
|
||||
}
|
||||
|
||||
/// Does `cover` hide a substantial part of `line`?
|
||||
fn covers(cover: &[f64; 4], line: &[f64; 4]) -> bool {
|
||||
let ix0 = cover[0].max(line[0]);
|
||||
let iy0 = cover[1].max(line[1]);
|
||||
let ix1 = cover[2].min(line[2]);
|
||||
let iy1 = cover[3].min(line[3]);
|
||||
if ix1 <= ix0 || iy1 <= iy0 {
|
||||
return false;
|
||||
}
|
||||
let line_area = (line[2] - line[0]).max(0.0) * (line[3] - line[1]).max(0.0);
|
||||
if line_area <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
(ix1 - ix0) * (iy1 - iy0) / line_area >= COVER_FRACTION
|
||||
/// Does `cover` hide a substantial part of what `line` draws? Two ways, either is enough:
|
||||
///
|
||||
/// - 40% of the area of the line's words (its `ink`) lies under the cover. Measured on the words,
|
||||
/// not the box: an applied redaction leaves a gap that draws nothing (a `TJ` adjustment where
|
||||
/// the text was), and the box still spans it, so a redaction box at the end of a line used to
|
||||
/// cover most of the box and flag the words left beside it.
|
||||
/// - 40% of the line's box lies under the cover (the measure used before `ink`) and intersects the ink
|
||||
/// of a word still drawn. This keeps a cover over a short value after a long label
|
||||
/// flagged, while a cover over the gap alone, with no word under it, stops counting.
|
||||
///
|
||||
/// A line without word boxes is measured on its box alone.
|
||||
fn covers(cover: &[f64; 4], line: &TextLine) -> bool {
|
||||
let hidden = |b: &[f64; 4]| area(&[cover[0].max(b[0]), cover[1].max(b[1]), cover[2].min(b[2]), cover[3].min(b[3])]);
|
||||
let fraction = |boxes: &[[f64; 4]]| {
|
||||
let total: f64 = boxes.iter().map(area).sum();
|
||||
if total > 0.0 { boxes.iter().map(hidden).sum::<f64>() / total } else { 0.0 }
|
||||
};
|
||||
let inked = fraction(&line.ink) >= COVER_FRACTION;
|
||||
let boxed = fraction(std::slice::from_ref(&line.rect)) >= COVER_FRACTION;
|
||||
let word_intersects = || line.ink.iter().any(|w| hidden(w) > 0.0);
|
||||
inked || (boxed && (line.ink.is_empty() || word_intersects()))
|
||||
}
|
||||
|
||||
/// The area of a rectangle, 0 when it is empty or inverted.
|
||||
fn area(r: &[f64; 4]) -> f64 {
|
||||
(r[2] - r[0]).max(0.0) * (r[3] - r[1]).max(0.0)
|
||||
}
|
||||
|
||||
/// WCAG 2.x relative luminance of an sRGB triple.
|
||||
|
||||
+188
-3
@@ -5,7 +5,7 @@ use std::sync::Arc;
|
||||
|
||||
use pdfcraft_cos::Document;
|
||||
|
||||
use crate::{FindingKind, audit_redactions};
|
||||
use crate::{AuditFinding, FindingKind, audit_redactions};
|
||||
|
||||
/// Minimal PDF assembler: objects are numbered 1..=n in order.
|
||||
struct Builder {
|
||||
@@ -228,12 +228,105 @@ fn self_drawing_form_fails_the_audit() {
|
||||
assert!(matches!(result, Err(crate::AuditError::TooDeep)), "{result:?}");
|
||||
}
|
||||
|
||||
/// An applied redaction leaves the words around it and a `TJ` adjustment over the gap where the
|
||||
/// text was, then the redaction box. At the end of a line, the box covers most of the line's box
|
||||
/// (which spans the gap) but none of its words: nothing is hidden, so nothing is flagged.
|
||||
#[test]
|
||||
fn applied_redaction_at_the_end_of_a_line_is_not_flagged() {
|
||||
let content = b"BT /F1 11 Tf 72 742 Td [(Mail the team:) -11354] TJ ET\n0 g\n144.138 739.8 121.814 11 re f\n";
|
||||
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
||||
assert!(findings.is_empty(), "{findings:?}");
|
||||
// The same gap between two words, wider than the words beside it.
|
||||
let content = b"BT /F1 11 Tf 72 742 Td [(Card:) -14000 (ok)] TJ ET\n0 g\n101 739.8 150 11 re f\n";
|
||||
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
||||
assert!(findings.is_empty(), "{findings:?}");
|
||||
}
|
||||
|
||||
/// The control: the same box over text still drawn under it is a faux redaction, at the end of a
|
||||
/// line as in the middle of a short one.
|
||||
#[test]
|
||||
fn faux_redaction_at_the_end_of_a_line_is_found() {
|
||||
let content = b"BT /F1 11 Tf 72 742 Td (Mail the team: someone@example.com) Tj ET\n0 g\n144.138 739.8 121.814 11 re f\n";
|
||||
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{findings:?}");
|
||||
assert_eq!(findings[0].covered_text, "Mail the team: someone@example.com");
|
||||
let content = b"BT /F1 11 Tf 72 742 Td [(Card:) -300 (4111111111111111) -300 (ok)] TJ ET\n0 g\n101 739.8 100 11 re f\n";
|
||||
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{findings:?}");
|
||||
}
|
||||
|
||||
/// A cover over a short value after a long label hides less than 40% of the line's words, but the
|
||||
/// whole of the words it covers, and most of the line's box: still a faux redaction. The value
|
||||
/// follows the label in the same `TJ` after a wide gap, or in a second run moved by `Td`.
|
||||
#[test]
|
||||
fn faux_redaction_over_a_short_value_after_a_long_label_is_found() {
|
||||
let content = b"BT /F1 11 Tf 72 742 Td [(Social insurance number:) -8000 (123 456 789)] TJ ET\n0 g\n200 739.8 150 11 re f\n";
|
||||
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{findings:?}");
|
||||
assert_eq!(findings[0].covered_text, "Social insurance number: 123 456 789");
|
||||
let content = b"BT /F1 11 Tf 72 742 Td (Account:) Tj 70 0 Td (4111) Tj ET\n0 g\n115 739.8 55 11 re f\n";
|
||||
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{findings:?}");
|
||||
assert!(findings[0].covered_text.contains("4111"), "{findings:?}");
|
||||
}
|
||||
|
||||
/// Text set with a negative font size in a mirrored text matrix runs its glyphs backwards: a
|
||||
/// cover over the address it draws is still found.
|
||||
#[test]
|
||||
fn faux_redaction_over_mirrored_text_is_found() {
|
||||
let content = b"BT /F1 -11 Tf -1 0 0 1 300 742 Tm (Mail the team: someone@example.com) Tj ET\n0 g\n365 733 130 14 re f\n";
|
||||
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{findings:?}");
|
||||
assert_eq!(findings[0].covered_text, "Mail the team: someone@example.com");
|
||||
}
|
||||
|
||||
/// A cover over less than half a value must retain the historical line-box detection.
|
||||
#[test]
|
||||
fn faux_redaction_over_a_partial_value_after_a_long_label_is_found() {
|
||||
partial_value_is_found(false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn faux_redaction_over_a_partial_value_after_a_long_label_on_a_rotated_page_is_found() {
|
||||
partial_value_is_found(true);
|
||||
}
|
||||
|
||||
fn partial_value_is_found(rotated: bool) {
|
||||
let bytes = one_page(
|
||||
b"BT /F1 11 Tf 72 742 Td [(Social insurance number:) -8000 (123456789)] TJ ET\n0 g\n193 738 111 16 re f\n",
|
||||
if rotated { "/Rotate 90" } else { "" },
|
||||
);
|
||||
let findings = audit_redactions(&open(bytes)).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "partial value, rotated={rotated}");
|
||||
}
|
||||
|
||||
/// Keep the historic detections with several covers, lines and text-spacing states.
|
||||
#[test]
|
||||
fn faux_redactions_with_multiple_covers_lines_and_spacing_are_found() {
|
||||
let cases: [&[u8]; 4] = [
|
||||
b"BT /F1 11 Tf 72 742 Td (Account: 123456789) Tj ET\n0 g\n72 738 65 16 re f\n140 738 40 16 re f\n",
|
||||
b"BT /F1 11 Tf 72 742 Td (Account: 123456789) Tj 0 -15 Td (Account: 987654321) Tj ET\n0 g\n115 723 100 30 re f\n",
|
||||
b"BT /F1 11 Tf 8 Tc 72 742 Td (Account: 1234) Tj ET\n0 g\n145 738 90 16 re f\n",
|
||||
b"BT /F1 11 Tf 250 Tz 72 742 Td (Account: 1234) Tj ET\n0 g\n100 738 160 16 re f\n",
|
||||
];
|
||||
for content in cases {
|
||||
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
||||
assert_eq!(findings.len(), 1, "{}: {findings:?}", String::from_utf8_lossy(content));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rectangle_drawn_as_a_path_covers_text_like_re() {
|
||||
// The same cover as `re`, built from `m`/`l` (closed with `h`, a repeated start point, or
|
||||
// only by the fill itself), or in a scaled user space: all are faux redactions (#1510).
|
||||
let covers: [&[u8]; 5] = [
|
||||
// So are `re` with a redundant `h`, and either path painted with `b`/`b*` (#1817).
|
||||
let covers: [&[u8]; 10] = [
|
||||
b"0 g\n100 688 220 32 re f\n",
|
||||
b"0 g\n100 688 220 32 re h f\n",
|
||||
b"0 g\n100 688 220 32 re b\n",
|
||||
b"0 g\n100 688 220 32 re h b*\n",
|
||||
b"0 g\n100 688 m 320 688 l 320 720 l 100 720 l b\n",
|
||||
b"0 g\n100 688 m 320 688 l 320 720 l 100 720 l h b*\n",
|
||||
b"0 g\n100 688 m 320 688 l 320 720 l 100 720 l h f\n",
|
||||
b"0 g\n100 688 m 320 688 l 320 720 l 100 720 l 100 688 l h f\n",
|
||||
b"0 g\n100 688 m 100 720 l 320 720 l 320 688 l f\n",
|
||||
@@ -253,9 +346,12 @@ fn rectangle_drawn_as_a_path_covers_text_like_re() {
|
||||
#[test]
|
||||
fn non_rectangular_path_is_not_a_cover() {
|
||||
// Each path's bounding box covers the text, but the shape itself does not hide it all.
|
||||
let covers: [&[u8]; 5] = [
|
||||
let covers: [&[u8]; 7] = [
|
||||
// A triangle.
|
||||
b"0 g\n100 688 m 320 688 l 320 720 l h f\n",
|
||||
// A triangle painted with `b`, and a trapezoid with `b*`.
|
||||
b"0 g\n100 688 m 320 688 l 320 720 l b\n",
|
||||
b"0 g\n100 688 m 320 688 l 320 720 l 140 720 l b*\n",
|
||||
// A triangle, then a line from its start point after `h`: not a fourth corner.
|
||||
b"0 g\n100 688 m 320 688 l 320 720 l h 100 720 l f\n",
|
||||
// A trapezoid: four corners, one slanted side.
|
||||
@@ -272,3 +368,92 @@ fn non_rectangular_path_is_not_a_cover() {
|
||||
assert!(findings.is_empty(), "{:?}: {findings:?}", String::from_utf8_lossy(cover));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finding_kind_ids_are_stable() {
|
||||
// The string IDs are the crate's public vocabulary (CLI output, future machine-readable
|
||||
// formats). They must not change; add a variant instead of renaming.
|
||||
assert_eq!(FindingKind::CoveredText.id(), "covered-text");
|
||||
assert_eq!(FindingKind::UnappliedMark.id(), "unapplied-mark");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn covered_text_finding_is_fully_determined() {
|
||||
// Golden test: the whole finding, not just its presence, is pinned.
|
||||
let content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n0 g\n100 688 220 32 re f\n";
|
||||
let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run");
|
||||
assert_eq!(
|
||||
findings,
|
||||
vec![AuditFinding {
|
||||
page: 0,
|
||||
kind: FindingKind::CoveredText,
|
||||
rect: [100.0, 688.0, 320.0, 720.0],
|
||||
covered_text: "SECRET DATA 123".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unapplied_mark_finding_is_fully_determined() {
|
||||
// Golden test for the second finding class, with its exact mark rectangle.
|
||||
let content = b"BT /F1 24 Tf 100 700 Td (DO NOT SHARE) Tj ET\n";
|
||||
let page_extra = "/Annots [6 0 R]";
|
||||
let mut b = Builder::new();
|
||||
b.add("<< /Type /Catalog /Pages 2 0 R >>");
|
||||
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
|
||||
b.add(&format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> {page_extra} >>"));
|
||||
b.add_stream(content);
|
||||
b.add(FONT);
|
||||
b.add("<< /Type /Annot /Subtype /Redact /Rect [95 685 305 725] /C [1 0 0] >>");
|
||||
let findings = audit_redactions(&open(b.build())).expect("audit must run");
|
||||
assert_eq!(
|
||||
findings,
|
||||
vec![AuditFinding { page: 0, kind: FindingKind::UnappliedMark, rect: [95.0, 685.0, 305.0, 725.0], covered_text: "DO NOT SHARE".to_string() }]
|
||||
);
|
||||
}
|
||||
|
||||
/// A page whose content draws a chain of `n` nested form XObjects. The innermost form draws
|
||||
/// nothing, so a successful audit reports no findings.
|
||||
fn nested_forms(n: usize) -> Vec<u8> {
|
||||
let mut b = Builder::new();
|
||||
b.add("<< /Type /Catalog /Pages 2 0 R >>");
|
||||
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
|
||||
// Object 3: page; object 4: content; objects 5..5+n: the form chain.
|
||||
b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /XObject << /F1 5 0 R >> >> >>");
|
||||
b.add_stream(b"/F1 Do");
|
||||
for i in 1..=n {
|
||||
let obj = 4 + i;
|
||||
if i < n {
|
||||
let next = obj + 1;
|
||||
let name = format!("/F{}", i + 1);
|
||||
b.add_stream_with(
|
||||
format!("/{name} Do", name = &name[1..]).as_bytes(),
|
||||
&format!(
|
||||
"/Type /XObject /Subtype /Form /BBox [0 0 612 792] /Resources << /XObject << /F{i} {next} 0 R >> >>",
|
||||
i = i + 1,
|
||||
next = next
|
||||
),
|
||||
);
|
||||
} else {
|
||||
b.add_stream_with(b"q Q", "/Type /XObject /Subtype /Form /BBox [0 0 612 792]");
|
||||
}
|
||||
debug_assert_eq!(b.objects.len(), obj);
|
||||
}
|
||||
b.build()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forms_nested_at_depth_limit_audit_clean() {
|
||||
// 32 nested forms is the deepest walk the audit follows: it must succeed and report
|
||||
// the page clean rather than fail.
|
||||
let findings = audit_redactions(&open(nested_forms(32))).expect("audit must run");
|
||||
assert!(findings.is_empty(), "{findings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forms_nested_past_depth_limit_fail_closed() {
|
||||
// The 33rd nesting level cannot be followed: the audit fails rather than report the
|
||||
// page clean with a possible cover left unseen.
|
||||
let result = audit_redactions(&open(nested_forms(33)));
|
||||
assert!(matches!(result, Err(crate::AuditError::TooDeep)), "{result:?}");
|
||||
}
|
||||
|
||||
@@ -27,9 +27,14 @@ mcp::McpServer::new(a).serve(stdin, stdout)? // newline-delimited JS
|
||||
- For proportional image signature resizing, use the embedded image layer's width/height ratio rather than a previously stretched annotation rectangle. The GUI preserves this ratio at corners with the opposite corner anchored; edge midpoint handles change only their own axis. `comment_edit` accepts the resulting rectangle.
|
||||
- `doc_close` refuses to drop unsaved changes unless `discard_changes: true`. `doc_save` writes atomically, incrementally in place, and in full for a new path. Atomic output writes call `sync_all` on the staging file before replacing the destination. A write or sync error leaves the destination intact, removes staging and leaves the document dirty for retry. A filesystem that does not support syncing returns an error; there is no unsynced fallback. This flushes file contents, but does not guarantee persistence of the parent-directory rename across power loss.
|
||||
- With a root set, every read and write path must resolve inside it (symlinks and `..` included). Relative paths resolve inside the root, and `..` is resolved by name before the check. Every path outside the root gets the same refusal (`<path> is outside the allowed directory <root>`), whether or not it exists, so a confined agent can't probe the rest of the disk; on Windows another network share or device path (`\\host\share`, `\\?\UNC\…`, `\\.\…`) is refused without being contacted. "Not found" and other filesystem errors are reported only for paths inside the root. Tools that write several files into a folder name them after the document or input file, with separators, colons and control characters replaced by `_`, so a name can't lead them out of the folder. The root itself must be a folder.
|
||||
- `Automation::with_automation_roots` holds a read folder and a write folder (`pdfcraft-cli mcp --automation-read-root DIR --automation-write-root DIR`, see [docs/mcp.md](../../docs/mcp.md)). Reads must lie inside the read root and writes inside the write root, links followed. A root left out grants none of its access. Refusals read `automation path rejected: outside the read root <root>: <path>`. It applies on top of `with_root`.
|
||||
|
||||
## Text search and cache limits
|
||||
|
||||
`text_word_count` counts the current snapshot without filling the text cache. It returns totals
|
||||
and per-page Unicode word/character counts, with optional tagged-table, tagged-code and
|
||||
image/OCR exclusions. See [word count](../../docs/word-count.md) for counting rules and limits.
|
||||
|
||||
Extracted page text is reused in a session-wide LRU cache: at most **64 MiB** of estimated owned
|
||||
text storage and **4,096 pages**, shared by all open documents. Accounting includes glyph-vector
|
||||
capacity, each glyph string's capacity, line indexes and word-break flags. Cache hits share the
|
||||
@@ -152,6 +157,10 @@ For inline editor previews, `page_render` accepts `hide_text_block` (a 1-based i
|
||||
`text_paragraphs`). It renders the page without that paragraph’s source glyphs and leaves the
|
||||
working document unchanged.
|
||||
|
||||
`page_render.hide_added_text` does the same for an added text item's 1-based index from
|
||||
`content_list` or `object_list`. It removes only that item in a read-only preview and is
|
||||
mutually exclusive with `hide_text_block`.
|
||||
|
||||
|
||||
## Existing text and document fonts
|
||||
|
||||
@@ -184,3 +193,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;
|
||||
@@ -213,6 +217,19 @@ impl Automation {
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn accessibility_autotag_form_fields(&mut self, a: &Args) -> Result<Value> {
|
||||
let id = self.doc(a)?.id;
|
||||
self.session.apply(id, pdfcraft_engine::Edit::AutoTagFormFields).map_err(failed)?;
|
||||
let report = self
|
||||
.doc(a)?
|
||||
.accessibility_check(&Options { rules: [Rule::TaggedFormFields].into(), pages: None })
|
||||
.ok_or_else(|| failed("the document can't be read"))?;
|
||||
let result = report.result(Rule::TaggedFormFields).ok_or_else(|| failed("the form field check is unavailable"))?;
|
||||
Ok(
|
||||
json!({ "status": status_id(result.status), "remaining_findings": result.findings.len(), "note": "Only form widgets were tagged. Review text, figures, links, reading order, and other document content separately." }),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn accessibility_autotag_preview(&self, a: &Args) -> Result<Value> {
|
||||
let page = self.page(a)?;
|
||||
let preview = self.doc(a)?.autotag_preview(page).map_err(failed)?;
|
||||
@@ -269,6 +286,14 @@ impl Automation {
|
||||
Ok(report_json(&r))
|
||||
}
|
||||
|
||||
/// Report only untagged page content, annotations, and AcroForm widgets.
|
||||
pub(crate) fn a11y_find_untagged(&self, a: &Args) -> Result<Value> {
|
||||
let pages = a.opt_ints("pages")?.map(|_| self.pages(a, "pages")).transpose()?;
|
||||
let rules = [Rule::TaggedContent, Rule::TaggedAnnotations, Rule::TaggedFormFields].into_iter().collect();
|
||||
let report = self.doc(a)?.accessibility_check(&Options { rules, pages }).ok_or_else(|| failed("the document can't be read for checking"))?;
|
||||
Ok(report_json(&report))
|
||||
}
|
||||
|
||||
pub(crate) fn a11y_report(&self, a: &Args) -> Result<Value> {
|
||||
let o = self.a11y_options(a)?;
|
||||
let doc = self.doc(a)?;
|
||||
@@ -441,11 +466,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 +508,17 @@ impl Automation {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({ "count": tags.len(), "tags": tags }))
|
||||
Ok(json!({ "count": count, "tags": tags, "next": next }))
|
||||
}
|
||||
|
||||
pub(crate) fn accessibility_tag_content(&self, a: &Args) -> Result<Value> {
|
||||
let object = u32::try_from(a.int("object")?).map_err(|_| ToolError::InvalidArgs("object must be a positive tag object number".into()))?;
|
||||
let generation =
|
||||
u16::try_from(a.int("generation")?).map_err(|_| ToolError::InvalidArgs("generation must be a PDF object generation".into()))?;
|
||||
let content = self.doc(a)?.tag_content(pdfcraft_cos::ObjRef { num: object, generation }).map_err(failed)?;
|
||||
Ok(
|
||||
json!({ "count": content.len(), "content": content.into_iter().map(|item| json!({ "page": item.page.map(|p| p + 1), "mcid": item.mcid, "text": item.text })).collect::<Vec<_>>() }),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn accessibility_set_tag_property(&mut self, a: &Args) -> Result<Value> {
|
||||
@@ -476,6 +539,18 @@ impl Automation {
|
||||
self.accessibility_tags(a)
|
||||
}
|
||||
|
||||
pub(crate) fn accessibility_mark_tag_artifact(&mut self, a: &Args) -> Result<Value> {
|
||||
let object = u32::try_from(a.int("object")?).map_err(|_| ToolError::InvalidArgs("object must be a positive tag object number".into()))?;
|
||||
if object == 0 {
|
||||
return Err(ToolError::InvalidArgs("object must be a positive tag object number".into()));
|
||||
}
|
||||
let generation = u16::try_from(a.int("generation")?)
|
||||
.map_err(|_| ToolError::InvalidArgs("generation must be a PDF object generation from accessibility_tags".into()))?;
|
||||
let id = self.doc(a)?.id;
|
||||
self.session.apply(id, pdfcraft_engine::Edit::MarkTagArtifact { object: pdfcraft_cos::ObjRef::new(object, generation) }).map_err(failed)?;
|
||||
self.accessibility_tags(a)
|
||||
}
|
||||
|
||||
pub(crate) fn accessibility_move_tag(&mut self, a: &Args) -> Result<Value> {
|
||||
let reference = |number: &str, generation: &str| -> Result<pdfcraft_cos::ObjRef> {
|
||||
let n = u32::try_from(a.int(number)?).map_err(|_| ToolError::InvalidArgs(format!("{number} must be a positive tag object number")))?;
|
||||
@@ -524,7 +599,7 @@ impl Automation {
|
||||
// other tools (the write check passes missing files, which stay per-file errors below).
|
||||
let paths = a.strs("paths")?;
|
||||
for p in &paths {
|
||||
self.resolve(p, true)?;
|
||||
self.resolve_for(p, crate::file_access::Access::Read, true)?;
|
||||
}
|
||||
std::fs::create_dir_all(&folder).map_err(|e| failed(e.to_string()))?;
|
||||
let ocr = pdfcraft_engine::ocr::engine().map_err(failed)?;
|
||||
|
||||
@@ -511,7 +511,8 @@ impl Automation {
|
||||
let text_at = |t: &str| {
|
||||
let w = (pdfcraft_engine::annot_text::text_width(t, size) + 8.0).clamp(20.0, 600.0);
|
||||
let h = size * 1.2 + 6.0;
|
||||
Shape::Typewriter { rect: [at[0], at[1] - h, at[0] + w, at[1]], font_size: size }
|
||||
let rect = pdfcraft_engine::text_box_at(i64::from(info.rotation), at, w, h).unwrap_or([at[0], at[1] - h, at[0] + w, at[1]]);
|
||||
Shape::Typewriter { rect, font_size: size }
|
||||
};
|
||||
let (shape, contents) = match kind {
|
||||
"text" => {
|
||||
@@ -618,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) });
|
||||
}
|
||||
@@ -626,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)
|
||||
|
||||
@@ -69,7 +69,7 @@ impl Automation {
|
||||
let doc = self.doc(a)?;
|
||||
let mut per_page: std::collections::HashMap<usize, usize> = Default::default();
|
||||
let items: Vec<Value> = doc
|
||||
.added
|
||||
.added()
|
||||
.iter()
|
||||
.map(|it| {
|
||||
let index = per_page.entry(it.page).or_default();
|
||||
@@ -110,7 +110,7 @@ impl Automation {
|
||||
let mut t = AddedText { rect, text: a.str("text")?.to_string(), ..AddedText::default() };
|
||||
style(a, &mut t)?;
|
||||
let mut out = self.apply(a, Edit::AddText { page, text: t })?;
|
||||
out["index"] = json!(self.doc(a)?.added.iter().filter(|x| x.page == page).count());
|
||||
out["index"] = json!(self.doc(a)?.added().iter().filter(|x| x.page == page).count());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ impl Automation {
|
||||
let rect = rect_arg(a, "rect")?.map(|r| to_display(r, ph));
|
||||
let mut out = self.apply(a, Edit::AddImage { page, rect, name, bytes: std::sync::Arc::new(bytes) })?;
|
||||
let doc = self.doc(a)?;
|
||||
let mine: Vec<_> = doc.added.iter().filter(|x| x.page == page).collect();
|
||||
let mine: Vec<_> = doc.added().iter().filter(|x| x.page == page).collect();
|
||||
out["index"] = json!(mine.len());
|
||||
out["rect"] = json!(mine.last().map(|x| to_view(x.content.rect(), ph)));
|
||||
Ok(out)
|
||||
@@ -135,7 +135,7 @@ impl Automation {
|
||||
let index = a.int("index")?;
|
||||
let ph = doc.info.pages[page].height as f64;
|
||||
let it = doc
|
||||
.added
|
||||
.added()
|
||||
.iter()
|
||||
.filter(|x| x.page == page)
|
||||
.nth((index - 1).max(0) as usize)
|
||||
|
||||
@@ -2,15 +2,31 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Args, Automation, Content, Result, ToolError, encode_png, failed, info, tools};
|
||||
use pdfcraft_engine::commands;
|
||||
use pdfcraft_render::{RenderRequest, RequestKind};
|
||||
|
||||
/// The most steps one `command_batch` runs.
|
||||
pub(crate) const MAX_BATCH_STEPS: usize = 1000;
|
||||
|
||||
/// Why `id` has no tool to run: a tool name passed as a command id, an id the registry doesn't
|
||||
/// know, or a real command that nothing automates yet. They need different fixes from the caller.
|
||||
fn no_tool_for(id: &str) -> ToolError {
|
||||
if commands::command(id).is_some() {
|
||||
return failed(format!("command {id:?} has no headless tool; see command_list"));
|
||||
}
|
||||
match tools::find(id) {
|
||||
Some(def) => match def.command {
|
||||
Some(command) => failed(format!("{id:?} is a tool, not a command id; use the command id {command:?}, or call the tool directly")),
|
||||
None => failed(format!("{id:?} is a tool, not a command id; call it directly (command ids are in command_list)")),
|
||||
},
|
||||
None => failed(format!("unknown command id {id:?}; see command_list")),
|
||||
}
|
||||
}
|
||||
|
||||
impl Automation {
|
||||
pub(crate) fn command_run(&mut self, args: &Args) -> Result<Vec<Content>> {
|
||||
let id = args.str("id")?;
|
||||
let tool = tools::tool_for_command(id).ok_or_else(|| failed(format!("command {id:?} has no headless tool; see command_list")))?;
|
||||
let tool = tools::tool_for_command(id).ok_or_else(|| no_tool_for(id))?;
|
||||
let def = tools::find(tool).ok_or_else(|| failed("command tool is unavailable"))?;
|
||||
let mut params = match args.get("params") {
|
||||
None | Some(Value::Null) => serde_json::Map::new(),
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
//! The folders automation may read and write.
|
||||
//!
|
||||
//! `pdfcraft-cli mcp --automation-read-root <dir> --automation-write-root <dir>` confines the
|
||||
//! files an agent's tools reach, with the names and wording VectorCraft uses. [`AutomationRoots`]
|
||||
//! holds the two folders. Every path a tool reads is checked with [`Access::Read`] and every path
|
||||
//! it writes with [`Access::Write`], in `Automation::resolve`, which all of them go through:
|
||||
//!
|
||||
//! - The path is made absolute against the working directory, as the file system reads it. Its
|
||||
//! links are followed: for a file that doesn't exist yet, the links of its nearest existing
|
||||
//! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so
|
||||
//! is a link that leads nowhere).
|
||||
//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside
|
||||
//! `/work`), without regard to case on Windows.
|
||||
//! - Read and write access are separate: a root given alone grants only its own kind of access.
|
||||
//!
|
||||
//! Without roots nothing is checked. A path is checked, then opened: a process that can change the
|
||||
//! folders in between (swapping in a link) can still win that race. The roots keep an agent to the
|
||||
//! files it was given; they don't guard against other programs on the computer.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Read access or write access.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Access {
|
||||
Read,
|
||||
Write,
|
||||
}
|
||||
|
||||
/// The folders automation may read and write; a root left out grants no access of its kind.
|
||||
#[derive(Debug)]
|
||||
pub struct AutomationRoots {
|
||||
read: Option<Root>,
|
||||
write: Option<Root>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Root {
|
||||
/// The folder made absolute, links followed: what paths are compared with.
|
||||
resolved: PathBuf,
|
||||
/// The same without Windows' `\\?\` prefix, for people.
|
||||
shown: PathBuf,
|
||||
}
|
||||
|
||||
impl Root {
|
||||
/// The folder at `path`, which must exist.
|
||||
fn open(path: &Path, access: Access) -> Result<Self, String> {
|
||||
let kind = match access {
|
||||
Access::Read => "read",
|
||||
Access::Write => "write",
|
||||
};
|
||||
let resolved = std::fs::canonicalize(path)
|
||||
.and_then(|p| if p.is_dir() { Ok(p) } else { Err(std::io::ErrorKind::NotADirectory.into()) })
|
||||
.map_err(|e| format!("cannot open the automation {kind} root `{}`: {e}", path.display()))?;
|
||||
Ok(Self { shown: plain(resolved.clone()), resolved })
|
||||
}
|
||||
}
|
||||
|
||||
impl AutomationRoots {
|
||||
/// The roots `read` and `write`, which must be existing folders; `None` when neither is given
|
||||
/// (nothing is confined then).
|
||||
pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result<Option<Arc<Self>>, String> {
|
||||
if read.is_none() && write.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let read = read.map(|p| Root::open(p, Access::Read)).transpose()?;
|
||||
let write = write.map(|p| Root::open(p, Access::Write)).transpose()?;
|
||||
Ok(Some(Arc::new(Self { read, write })))
|
||||
}
|
||||
|
||||
/// The read root, absolute with its links followed.
|
||||
pub fn read_root(&self) -> Option<&Path> {
|
||||
self.read.as_ref().map(|r| r.shown.as_path())
|
||||
}
|
||||
|
||||
/// The write root, absolute with its links followed.
|
||||
pub fn write_root(&self) -> Option<&Path> {
|
||||
self.write.as_ref().map(|r| r.shown.as_path())
|
||||
}
|
||||
|
||||
/// May the file or folder at `path` be read (or written)? `Err` says why not.
|
||||
pub fn check(&self, path: &str, access: Access) -> Result<(), String> {
|
||||
self.check_path(path, Path::new(path), access)
|
||||
}
|
||||
|
||||
/// [`check`](Self::check) for a path that was already put together from `shown` (the way the
|
||||
/// caller wrote it, which is what refusals quote).
|
||||
pub fn check_path(&self, shown: &str, target: &Path, access: Access) -> Result<(), String> {
|
||||
let root = match access {
|
||||
Access::Read => self.read.as_ref(),
|
||||
Access::Write => self.write.as_ref(),
|
||||
};
|
||||
let Some(Root { resolved: folder, shown: root_shown }) = root else {
|
||||
return match access {
|
||||
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {shown}")),
|
||||
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {shown}")),
|
||||
};
|
||||
};
|
||||
if inside(&resolve(shown, target)?, folder) {
|
||||
return Ok(());
|
||||
}
|
||||
let root = root_shown.display();
|
||||
match access {
|
||||
Access::Read => Err(format!("automation path rejected: outside the read root {root}: {shown}")),
|
||||
Access::Write => Err(format!("automation path rejected: outside the write root {root}: {shown}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `path` without Windows' `\\?\` prefix (which `canonicalize` adds), for people to read.
|
||||
fn plain(path: PathBuf) -> PathBuf {
|
||||
if !cfg!(windows) {
|
||||
return path;
|
||||
}
|
||||
let text = path.to_string_lossy();
|
||||
if let Some(rest) = text.strip_prefix(r"\\?\UNC\") {
|
||||
return PathBuf::from(format!(r"\\{rest}"));
|
||||
}
|
||||
match text.strip_prefix(r"\\?\") {
|
||||
Some(rest) => PathBuf::from(rest),
|
||||
None => path,
|
||||
}
|
||||
}
|
||||
|
||||
/// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest
|
||||
/// existing folder's links followed and the rest appended.
|
||||
fn resolve(shown: &str, path: &Path) -> Result<PathBuf, String> {
|
||||
if path.as_os_str().is_empty() {
|
||||
return Err("automation path rejected: path is empty".into());
|
||||
}
|
||||
// Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows.
|
||||
if cfg!(windows) && path.components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) {
|
||||
return Err(format!("automation path rejected: reserved device names are not allowed: {shown}"));
|
||||
}
|
||||
let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {shown}: {e}"))?;
|
||||
// The names below the nearest existing folder, last first.
|
||||
let mut missing: Vec<OsString> = vec![];
|
||||
let mut at = absolute.as_path();
|
||||
loop {
|
||||
match std::fs::canonicalize(at) {
|
||||
Ok(mut found) => {
|
||||
found.extend(missing.iter().rev());
|
||||
return Ok(found);
|
||||
}
|
||||
Err(e) => match std::fs::symlink_metadata(at) {
|
||||
Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {shown}")),
|
||||
// There, yet its real path can't be had (no permission): nothing to compare.
|
||||
Ok(_) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")),
|
||||
},
|
||||
}
|
||||
let parent = at.parent();
|
||||
match (at.components().next_back(), parent) {
|
||||
(Some(Component::Normal(name)), Some(parent)) => {
|
||||
missing.push(name.to_os_string());
|
||||
at = parent;
|
||||
}
|
||||
(Some(Component::ParentDir), _) => {
|
||||
return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {shown}"));
|
||||
}
|
||||
// Down to the root (or drive) and it doesn't exist either.
|
||||
_ => {
|
||||
let e = std::io::Error::from(std::io::ErrorKind::NotFound);
|
||||
return Err(format!("automation path rejected: cannot resolve {shown}: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `path` (resolved) `root` or inside it, component by component (without regard to case on
|
||||
/// Windows, whose file systems ignore it)?
|
||||
fn inside(path: &Path, root: &Path) -> bool {
|
||||
let mut path = path.components();
|
||||
root.components().all(|r| path.next().is_some_and(|p| same_component(p, r)))
|
||||
}
|
||||
|
||||
fn same_component(a: Component, b: Component) -> bool {
|
||||
if a == b {
|
||||
return true;
|
||||
}
|
||||
if !cfg!(windows) {
|
||||
return false;
|
||||
}
|
||||
match (a.as_os_str().to_str(), b.as_os_str().to_str()) {
|
||||
(Some(a), Some(b)) => a.to_lowercase() == b.to_lowercase(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A Windows device name (`CON`, `NUL`, `COM1`… also with an extension), which names no file.
|
||||
fn is_device_name(component: &str) -> bool {
|
||||
let stem = component.split('.').next().unwrap_or(component).trim_end_matches(' ').to_ascii_uppercase();
|
||||
let numbered = |prefix: &str| stem.strip_prefix(prefix).is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"));
|
||||
matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$") || numbered("COM") || numbered("LPT")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A fresh folder holding `in` (read root), `out` (write root) and `outside`.
|
||||
fn folders(name: &str) -> (PathBuf, Arc<AutomationRoots>) {
|
||||
let base = std::env::temp_dir().join(format!("pc-file-access-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
for d in ["in/sub", "out", "outside", "in2"] {
|
||||
std::fs::create_dir_all(base.join(d)).unwrap();
|
||||
}
|
||||
std::fs::write(base.join("in/a.pdf"), "x").unwrap();
|
||||
std::fs::write(base.join("outside/secret.pdf"), "x").unwrap();
|
||||
let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap();
|
||||
(base, roots)
|
||||
}
|
||||
|
||||
fn s(p: PathBuf) -> String {
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_inside_the_roots_pass_and_others_dont() {
|
||||
let (base, roots) = folders("inside");
|
||||
assert_eq!(roots.check(&s(base.join("in/a.pdf")), Access::Read), Ok(()));
|
||||
assert_eq!(roots.check(&s(base.join("in")), Access::Read), Ok(()), "the root itself");
|
||||
assert_eq!(roots.check(&s(base.join("in/sub/new.pdf")), Access::Read), Ok(()), "missing files are checked by their folder");
|
||||
assert_eq!(roots.check(&s(base.join("out/new/deeper/x.png")), Access::Write), Ok(()), "and by their nearest folder");
|
||||
let e = roots.check(&s(base.join("outside/secret.pdf")), Access::Read).unwrap_err();
|
||||
assert!(e.starts_with("automation path rejected: outside the read root"), "{e}");
|
||||
// Read and write access are separate.
|
||||
let e = roots.check(&s(base.join("in/a.pdf")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("outside the write root"), "{e}");
|
||||
assert!(roots.check(&s(base.join("out/x.png")), Access::Read).unwrap_err().contains("outside the read root"));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sibling_with_the_same_prefix_is_outside() {
|
||||
let (base, roots) = folders("prefix");
|
||||
std::fs::write(base.join("in2/b.pdf"), "x").unwrap();
|
||||
assert!(roots.check(&s(base.join("in2/b.pdf")), Access::Read).is_err(), "in2 is not inside in");
|
||||
assert!(roots.check(&format!("{}2/c.pdf", s(base.join("in"))), Access::Read).is_err());
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_traversal_is_resolved_or_refused() {
|
||||
let (base, roots) = folders("dotdot");
|
||||
let sep = std::path::MAIN_SEPARATOR;
|
||||
let up = format!("{}{sep}..{sep}outside{sep}secret.pdf", s(base.join("in")));
|
||||
assert!(roots.check(&up, Access::Read).unwrap_err().contains("outside the read root"), "`..` out of the root");
|
||||
let back = format!("{}{sep}..{sep}a.pdf", s(base.join("in/sub")));
|
||||
assert_eq!(roots.check(&back, Access::Read), Ok(()), "`..` that stays inside");
|
||||
let missing = format!("{}{sep}nope{sep}..{sep}..{sep}outside{sep}x.pdf", s(base.join("out")));
|
||||
let e = roots.check(&missing, Access::Write).unwrap_err();
|
||||
// Windows resolves `..` by name first (so does opening the file there): then it's outside.
|
||||
assert!(e.contains("parent traversal below a folder that doesn't exist") || e.contains("outside the write root"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_paths_resolve_against_the_working_directory() {
|
||||
let (base, roots) = folders("relative");
|
||||
// The tests' working directory is the crate's folder: not inside the roots.
|
||||
assert!(roots.check("a.pdf", Access::Read).unwrap_err().contains("outside the read root"));
|
||||
assert!(roots.check("", Access::Read).unwrap_err().contains("path is empty"));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_root_is_an_error_and_none_is_no_confinement() {
|
||||
let (base, _) = folders("missing");
|
||||
let e = AutomationRoots::new(Some(&base.join("nope")), None).unwrap_err();
|
||||
assert!(e.starts_with("cannot open the automation read root"), "{e}");
|
||||
let e = AutomationRoots::new(None, Some(&base.join("in/a.pdf"))).unwrap_err();
|
||||
assert!(
|
||||
e.starts_with("cannot open the automation write root")
|
||||
&& e.ends_with(&std::io::Error::from(std::io::ErrorKind::NotADirectory).to_string()),
|
||||
"{e}"
|
||||
);
|
||||
assert!(AutomationRoots::new(Some(Path::new("")), None).unwrap_err().starts_with("cannot open the automation read root ``"));
|
||||
assert!(AutomationRoots::new(None, None).unwrap().is_none());
|
||||
// One root alone grants only its own access.
|
||||
let read_only = AutomationRoots::new(Some(&base.join("in")), None).unwrap().unwrap();
|
||||
let e = read_only.check(&s(base.join("in/x.pdf")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("write authority is absent"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_are_followed_before_comparing() {
|
||||
let (base, roots) = folders("links");
|
||||
#[cfg(unix)]
|
||||
let made = |target: &Path, link: &Path| std::os::unix::fs::symlink(target, link).is_ok();
|
||||
#[cfg(windows)]
|
||||
let made = |target: &Path, link: &Path| {
|
||||
let made =
|
||||
if target.is_dir() { std::os::windows::fs::symlink_dir(target, link) } else { std::os::windows::fs::symlink_file(target, link) };
|
||||
made.is_ok()
|
||||
};
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
let made = |_: &Path, _: &Path| false;
|
||||
// Windows needs Developer Mode or elevation to make links: nothing to test without them.
|
||||
if !made(&base.join("outside/secret.pdf"), &base.join("in/leak.pdf")) {
|
||||
eprintln!("skipped: symbolic links can't be made here");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
return;
|
||||
}
|
||||
assert!(roots.check(&s(base.join("in/leak.pdf")), Access::Read).unwrap_err().contains("outside the read root"), "a file link out");
|
||||
assert!(made(&base.join("outside"), &base.join("out/away")));
|
||||
let e = roots.check(&s(base.join("out/away/new.png")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("outside the write root"), "a folder link out: {e}");
|
||||
assert!(made(&base.join("outside/gone.pdf"), &base.join("out/dangling.png")));
|
||||
let e = roots.check(&s(base.join("out/dangling.png")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("a link that leads nowhere"), "writing would create the file outside: {e}");
|
||||
// A link to a folder inside the root stays inside.
|
||||
assert!(made(&base.join("in/sub"), &base.join("in/alias")));
|
||||
assert_eq!(roots.check(&s(base.join("in/alias/x.pdf")), Access::Read), Ok(()));
|
||||
// A root given through a link is the folder it leads to.
|
||||
assert!(made(&base.join("in"), &base.join("in-link")));
|
||||
let linked = AutomationRoots::new(Some(&base.join("in-link")), None).unwrap().unwrap();
|
||||
assert_eq!(linked.check(&s(base.join("in/a.pdf")), Access::Read), Ok(()));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_ignores_case_and_refuses_devices() {
|
||||
let (base, roots) = folders("case");
|
||||
let upper = s(base.join("IN/A.PDF"));
|
||||
assert_eq!(roots.check(&upper, Access::Read), Ok(()), "{upper}");
|
||||
assert_eq!(roots.check(&s(base.join("In/Sub/New.pdf")), Access::Read), Ok(()));
|
||||
let e = roots.check(&s(base.join("out/NUL")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("reserved device names"), "{e}");
|
||||
assert!(roots.check(&s(base.join("out/com1.txt")), Access::Write).is_err());
|
||||
assert_eq!(roots.check(&s(base.join("out/console.txt")), Access::Write), Ok(()));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// Every file a tool reads or writes goes through `Automation::resolve`, which holds it to the
|
||||
/// roots. A function that reaches for the file system without resolving a path first would
|
||||
/// bypass them, so each use of `std::fs` or `File::open` must come after a `resolve` call in
|
||||
/// the same function (or be one of the helpers that only ever get a resolved path).
|
||||
#[test]
|
||||
fn tools_do_not_touch_the_file_system_without_resolving_the_path() {
|
||||
// Helpers that are only called with a resolved path. `doc_batch` (batch.rs) resolves its
|
||||
// inputs and out_dir, walks only inside them (skipping links) and resolves every
|
||||
// discovered file again before `process` reads it or `publish` writes it.
|
||||
const RESOLVED_BY_CALLER: &[&str] = &["write_atomic", "write_atomic_with", "read_review_sidecar", "process", "linked", "walk", "publish"];
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let mut offenders = vec![];
|
||||
for entry in std::fs::read_dir(&dir).unwrap() {
|
||||
let path = entry.unwrap().path();
|
||||
if path.extension().is_none_or(|e| e != "rs")
|
||||
|| path.file_name().is_some_and(|n| n == "file_access.rs" || n.to_string_lossy().starts_with("tests"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
// Test modules (which build their own fixtures) come last in each file.
|
||||
let code = text.split("\n#[cfg(test)]").next().unwrap_or("");
|
||||
let lines: Vec<&str> = code.lines().collect();
|
||||
for (n, line) in lines.iter().enumerate() {
|
||||
let trimmed = line.trim_start();
|
||||
if trimmed.starts_with("//")
|
||||
|| !(trimmed.contains("std::fs")
|
||||
|| trimmed.contains("File::open")
|
||||
|| trimmed.contains("File::create")
|
||||
|| trimmed.contains("OpenOptions"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let start = (0..=n).rev().find(|&i| lines[i].trim_start().contains("fn ") && lines[i].trim_start().ends_with('{')).unwrap_or(0);
|
||||
let name = lines[start].split("fn ").nth(1).and_then(|r| r.split(['(', '<']).next()).unwrap_or("");
|
||||
if !RESOLVED_BY_CALLER.contains(&name) && !lines[start..=n].iter().any(|l| l.contains("resolve")) {
|
||||
offenders.push(format!("{}:{}: in `{name}`: {}", path.display(), n + 1, trimmed));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(offenders.is_empty(), "resolve the path with `self.resolve` first: {offenders:#?}");
|
||||
}
|
||||
}
|
||||
+313
-42
@@ -7,7 +7,10 @@
|
||||
//! server ([`mcp`]), `pdfcraft-cli run` and (later) the UI control channel.
|
||||
//! - Pages are **1-based** in every tool, as people number them. Rectangles are in PDF points
|
||||
//! with the origin at the top-left of the displayed page.
|
||||
//! - An optional root directory confines every path a tool reads or writes.
|
||||
//! - An optional root directory confines every path a tool reads or writes. Separately,
|
||||
//! [`file_access::AutomationRoots`] (`pdfcraft-cli mcp --automation-read-root` and
|
||||
//! `--automation-write-root`) holds a read folder and a write folder, each left out meaning no
|
||||
//! access of its kind.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
@@ -16,6 +19,7 @@ mod batch;
|
||||
mod comments;
|
||||
mod content;
|
||||
mod conventions;
|
||||
pub mod file_access;
|
||||
mod forms;
|
||||
mod links;
|
||||
#[cfg(feature = "mcp")]
|
||||
@@ -33,10 +37,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.
|
||||
@@ -107,6 +112,8 @@ const FIND_MAX_WARNING_BYTES: usize = 1024;
|
||||
pub struct Automation {
|
||||
session: Session,
|
||||
root: Option<PathBuf>,
|
||||
/// The read and write folders of `--automation-read-root` / `--automation-write-root`.
|
||||
roots: Option<Arc<file_access::AutomationRoots>>,
|
||||
/// Synchronous renderers, rebuilt when a document's working bytes change.
|
||||
renderers: HashMap<DocId, (Arc<Vec<u8>>, PageRenderer)>,
|
||||
/// Extracted page text, under one memory budget across all open documents.
|
||||
@@ -121,7 +128,7 @@ impl Default for Automation {
|
||||
|
||||
impl Automation {
|
||||
pub fn new() -> Self {
|
||||
Self { session: Session::new(), root: None, renderers: HashMap::new(), texts: TextCache::default() }
|
||||
Self { session: Session::new(), root: None, roots: None, renderers: HashMap::new(), texts: TextCache::default() }
|
||||
}
|
||||
|
||||
/// Confine every path the tools read or write to `root` (relative paths resolve inside it).
|
||||
@@ -134,26 +141,42 @@ impl Automation {
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Confine the files the tools read to the read root and the files they write to the write
|
||||
/// root (`None`: no confinement). Applies on top of [`with_root`](Self::with_root) when both
|
||||
/// are set.
|
||||
pub fn with_automation_roots(mut self, roots: Option<Arc<file_access::AutomationRoots>>) -> Self {
|
||||
self.roots = roots;
|
||||
self
|
||||
}
|
||||
|
||||
/// Use a fixed clock for saves (deterministic output in tests).
|
||||
pub fn with_clock(mut self, clock: fn() -> i64) -> Self {
|
||||
self.session = std::mem::take(&mut self.session).with_clock(clock);
|
||||
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() && self.roots.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)
|
||||
}
|
||||
@@ -186,6 +209,7 @@ impl Automation {
|
||||
"page_render" => return self.page_render(&a).map(|c| vec![c]),
|
||||
"comment_image_preview" => return self.comment_image_preview(&a),
|
||||
"text_extract" => self.text_extract(&a)?,
|
||||
"text_word_count" => self.text_word_count(&a)?,
|
||||
"text_find" => self.text_find(&a)?,
|
||||
"page_rotate" => {
|
||||
let degrees = a.int("degrees")?;
|
||||
@@ -352,7 +376,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 {
|
||||
@@ -466,6 +492,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,
|
||||
@@ -517,6 +552,12 @@ impl Automation {
|
||||
.iter()
|
||||
.map(|p| self.resolve(p.as_str().ok_or_else(|| ToolError::InvalidArgs("directory must be a path".into()))?, false))
|
||||
.collect::<Result<Vec<_>>>()?
|
||||
} else if let Some(roots) = &self.roots {
|
||||
// The installed fonts are outside the roots: look in the read root instead.
|
||||
let read = roots
|
||||
.read_root()
|
||||
.ok_or_else(|| failed("automation filesystem access is not granted: read authority is absent: installed fonts"))?;
|
||||
vec![self.resolve(&read.to_string_lossy(), false)?]
|
||||
} else if let Some(root) = &self.root {
|
||||
vec![root.clone()]
|
||||
} else {
|
||||
@@ -722,8 +763,10 @@ impl Automation {
|
||||
"comment_set_status" => self.comment_set_status(&a)?,
|
||||
"sign_list" => self.sign_list(&a)?,
|
||||
"accessibility_check" => self.a11y_check(&a)?,
|
||||
"accessibility_find_untagged" => self.a11y_find_untagged(&a)?,
|
||||
"accessibility_autotag_preview" => self.accessibility_autotag_preview(&a)?,
|
||||
"accessibility_autotag_apply" => self.accessibility_autotag_apply(&a)?,
|
||||
"accessibility_autotag_form_fields" => self.accessibility_autotag_form_fields(&a)?,
|
||||
"ocr_recognize" => self.ocr_recognize(&a)?,
|
||||
"js_run" => self.js_run(&a)?,
|
||||
"js_document_scripts" => self.js_document_scripts(&a)?,
|
||||
@@ -754,7 +797,9 @@ impl Automation {
|
||||
"accessibility_figures" => self.accessibility_figures(&a)?,
|
||||
"accessibility_set_alt" => self.accessibility_set_alt(&a)?,
|
||||
"accessibility_tags" => self.accessibility_tags(&a)?,
|
||||
"accessibility_tag_content" => self.accessibility_tag_content(&a)?,
|
||||
"accessibility_set_tag_property" => self.accessibility_set_tag_property(&a)?,
|
||||
"accessibility_mark_tag_artifact" => self.accessibility_mark_tag_artifact(&a)?,
|
||||
"accessibility_move_tag" => self.accessibility_move_tag(&a)?,
|
||||
"sign_id_create" => self.sign_id_create(&a)?,
|
||||
"sign_document" => self.sign_document(&a)?,
|
||||
@@ -819,7 +864,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> {
|
||||
@@ -866,7 +912,14 @@ impl Automation {
|
||||
let id = doc.id;
|
||||
let target = match a.opt_str("path")? {
|
||||
Some(p) => self.resolve(p, true)?,
|
||||
None => PathBuf::from(doc.path.clone().ok_or_else(|| failed("the document has never been saved: pass a path"))?),
|
||||
None => {
|
||||
// Saving in place writes where the file was opened from, which the read root
|
||||
// allows and the write root may not.
|
||||
let own = doc.path.clone().ok_or_else(|| failed("the document has never been saved: pass a path"))?;
|
||||
let own_path = PathBuf::from(&own);
|
||||
self.check_roots(&own, &own_path, file_access::Access::Write)?;
|
||||
own_path
|
||||
}
|
||||
};
|
||||
let same_file = doc.path.as_deref().is_some_and(|p| Path::new(p) == target);
|
||||
// Saving to a new file is a full rewrite unless asked otherwise, like Save As.
|
||||
@@ -879,11 +932,24 @@ impl Automation {
|
||||
// A save that adds or removes encryption, or must not keep the old revision (Sanitize,
|
||||
// redaction), is rewritten in full even when an incremental save was asked for.
|
||||
let incremental = !full && bytes.starts_with(&self.doc(a)?.saved_bytes());
|
||||
self.publish_save(id, &target, bytes, incremental, write)
|
||||
}
|
||||
|
||||
/// Validate the prepared bytes before publishing them or marking the document saved.
|
||||
fn publish_save(
|
||||
&mut self,
|
||||
id: DocId,
|
||||
target: &Path,
|
||||
bytes: Arc<Vec<u8>>,
|
||||
incremental: bool,
|
||||
write: impl FnOnce(&Path, &[u8]) -> Result<()>,
|
||||
) -> Result<Value> {
|
||||
self.session.check_save(id, &bytes).map_err(failed)?;
|
||||
write(&target, &bytes)?;
|
||||
write(target, &bytes)?;
|
||||
let path = target.to_string_lossy().into_owned();
|
||||
self.session.mark_saved(id, bytes.clone(), Some(path.clone())).map_err(failed)?;
|
||||
Ok(json!({ "path": path, "bytes": bytes.len(), "incremental": incremental, "document": summary(self.doc(a)?) }))
|
||||
let doc = self.session.get(id).ok_or_else(|| failed("the saved document is no longer open"))?;
|
||||
Ok(json!({ "path": path, "bytes": bytes.len(), "incremental": incremental, "document": summary(doc) }))
|
||||
}
|
||||
|
||||
fn doc_revisions(&mut self, a: &Args) -> Result<Value> {
|
||||
@@ -993,11 +1059,14 @@ impl Automation {
|
||||
}
|
||||
|
||||
fn doc_optimize(&mut self, a: &Args) -> Result<Value> {
|
||||
use pdfcraft_engine::optimize::{Compression, ImageSettings, Settings};
|
||||
use pdfcraft_engine::optimize::{Compression, ImageSettings, Preset};
|
||||
let id = self.doc(a)?.id;
|
||||
let before = self.doc(a)?.bytes.len();
|
||||
let path = self.resolve(a.str("path")?, true)?;
|
||||
let mut settings = Settings::default();
|
||||
let mut settings = match a.opt_str("preset")? {
|
||||
None => Preset::Standard.settings(),
|
||||
Some(p) => Preset::from_name(p).ok_or_else(|| ToolError::InvalidArgs(format!("unknown preset {p:?} (standard, mobile)")))?.settings(),
|
||||
};
|
||||
let images = |key: &str, base: ImageSettings| -> Result<ImageSettings> {
|
||||
let Some(v) = a.get(key) else { return Ok(base) };
|
||||
let mut s = base;
|
||||
@@ -1025,6 +1094,7 @@ impl Automation {
|
||||
settings.color = images("color", settings.color)?;
|
||||
settings.gray = images("gray", settings.gray)?;
|
||||
for (key, flag) in [
|
||||
("merge_fragments", &mut settings.merge_fragments),
|
||||
("discard_thumbnails", &mut settings.discard_thumbnails),
|
||||
("discard_alternate_images", &mut settings.discard_alternate_images),
|
||||
("discard_tags", &mut settings.discard_tags),
|
||||
@@ -1057,6 +1127,8 @@ impl Automation {
|
||||
"path": path.to_string_lossy(),
|
||||
"bytes_before": before,
|
||||
"bytes_after": bytes.len(),
|
||||
"merged_images": o.merged_images,
|
||||
"fragments_merged": o.fragments_merged,
|
||||
"images": o.images,
|
||||
"images_resampled": o.images_resampled,
|
||||
"images_recompressed": o.images_recompressed,
|
||||
@@ -1197,7 +1269,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 }));
|
||||
}
|
||||
@@ -1279,6 +1351,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)?;
|
||||
@@ -1687,12 +1811,23 @@ impl Automation {
|
||||
return Err(ToolError::InvalidArgs(format!("dpi must be between 1 and {MAX_DPI}")));
|
||||
}
|
||||
let scale = (dpi / 72.0) as f32;
|
||||
let out = if let Some(block) = a.opt_int("hide_text_block")? {
|
||||
let block = a.opt_int("hide_text_block")?;
|
||||
let added = a.opt_int("hide_added_text")?;
|
||||
if block.is_some() && added.is_some() {
|
||||
return Err(ToolError::InvalidArgs("choose only one of hide_text_block and hide_added_text".into()));
|
||||
}
|
||||
let out = if let Some(block) = block {
|
||||
let index = usize::try_from(block)
|
||||
.ok()
|
||||
.and_then(|n| n.checked_sub(1))
|
||||
.ok_or_else(|| ToolError::InvalidArgs("hide_text_block must be a positive paragraph index from text_paragraphs".into()))?;
|
||||
self.doc(a)?.text_edit_preview(page, index).map_err(failed)?.render_background(scale).map_err(failed)?
|
||||
} else if let Some(added) = added {
|
||||
let index = usize::try_from(added)
|
||||
.ok()
|
||||
.and_then(|n| n.checked_sub(1))
|
||||
.ok_or_else(|| ToolError::InvalidArgs("hide_added_text must be a positive added text index from object_list".into()))?;
|
||||
self.doc(a)?.added_text_preview(page, index).map_err(failed)?.render_background(scale).map_err(failed)?
|
||||
} else {
|
||||
self.renderer(id)?.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale, tag: 0, preview: false })
|
||||
};
|
||||
@@ -1703,6 +1838,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")?;
|
||||
@@ -1757,6 +1913,28 @@ impl Automation {
|
||||
Ok(pages.iter().map(|p| (*p, results.get(p).cloned().unwrap_or_else(|| Err("text extraction returned no result".into())))).collect())
|
||||
}
|
||||
|
||||
fn text_word_count(&self, a: &Args) -> Result<Value> {
|
||||
use pdfcraft_engine::word_count::{self, Counts, Options};
|
||||
let doc = self.doc_to_copy(a)?;
|
||||
let pages = match a.opt_ints("pages")? {
|
||||
Some(_) => self.pages(a, "pages")?,
|
||||
None => (0..doc.info.pages.len()).collect(),
|
||||
};
|
||||
let options = Options {
|
||||
exclude_tables: a.opt_bool("exclude_tables")?.unwrap_or(false),
|
||||
exclude_code: a.opt_bool("exclude_code")?.unwrap_or(false),
|
||||
exclude_images: a.opt_bool("exclude_images")?.unwrap_or(false),
|
||||
};
|
||||
let report = word_count::count(doc.export_source(), &pages, options, |_| true).map_err(failed)?;
|
||||
let counts = |c: Counts| json!({ "words": c.words, "characters": c.characters, "characters_without_spaces": c.characters_without_spaces });
|
||||
Ok(json!({
|
||||
"total": counts(report.total),
|
||||
"pages": report.pages.into_iter().map(|(p, c)| json!({ "page": p + 1, "counts": counts(c) })).collect::<Vec<_>>(),
|
||||
"pages_without_text": report.pages_without_text,
|
||||
"unclassified_structure": report.unclassified_structure,
|
||||
}))
|
||||
}
|
||||
|
||||
fn text_extract(&mut self, a: &Args) -> Result<Value> {
|
||||
let doc = self.doc_to_copy(a)?;
|
||||
let id = doc.id;
|
||||
@@ -1882,6 +2060,29 @@ impl Automation {
|
||||
/// namespace is refused without touching it, and the deepest existing ancestor (links
|
||||
/// followed) must be inside the root before anything about the rest is reported.
|
||||
fn resolve(&self, path: &str, for_write: bool) -> Result<PathBuf> {
|
||||
let access = if for_write { file_access::Access::Write } else { file_access::Access::Read };
|
||||
self.resolve_for(path, access, for_write)
|
||||
}
|
||||
|
||||
/// [`resolve`](Self::resolve) with the kind of access named, for the places where a file that
|
||||
/// is only read may still be missing (a path checked up front, whose absence is reported per
|
||||
/// file later). The automation roots, if any, get the last word.
|
||||
fn resolve_for(&self, path: &str, access: file_access::Access, may_be_missing: bool) -> Result<PathBuf> {
|
||||
let target = self.resolve_in_root(path, may_be_missing)?;
|
||||
self.check_roots(path, &target, access)?;
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
/// Hold `target`, which `path` named, to the automation read or write root. Always passes
|
||||
/// without roots.
|
||||
fn check_roots(&self, path: &str, target: &Path, access: file_access::Access) -> Result<()> {
|
||||
match &self.roots {
|
||||
Some(roots) => roots.check_path(path, target, access).map_err(failed),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_in_root(&self, path: &str, for_write: bool) -> Result<PathBuf> {
|
||||
let p = Path::new(path);
|
||||
let Some(root) = &self.root else { return Ok(p.to_path_buf()) };
|
||||
let outside = || failed(format!("{path} is outside the allowed directory {}", root.display()));
|
||||
@@ -1908,8 +2109,17 @@ impl Automation {
|
||||
return Err(outside());
|
||||
}
|
||||
if !rest.is_empty() && !for_write {
|
||||
// Missing, below a folder inside the root: say why, as the system reports it.
|
||||
real = joined.canonicalize().map_err(|e| failed(format!("{path}: {e}")))?;
|
||||
// Missing, below a folder inside the root: say where it was looked for, so a path
|
||||
// written for another working directory can be corrected (#1890), and otherwise
|
||||
// why, as the system reports it. `joined` is under the root, so naming it reveals
|
||||
// nothing outside.
|
||||
real = joined.canonicalize().map_err(|e| {
|
||||
let (looked, root) = (joined.display(), root.display());
|
||||
match e.kind() {
|
||||
std::io::ErrorKind::NotFound => failed(format!("{path} not found (looked for {looked}, --root {root})")),
|
||||
_ => failed(format!("{path}: {e} (looked for {looked}, --root {root})")),
|
||||
}
|
||||
})?;
|
||||
if !real.starts_with(root) {
|
||||
return Err(outside()); // it appeared, as a link out, since the check above
|
||||
}
|
||||
@@ -2068,7 +2278,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();
|
||||
@@ -2078,7 +2288,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() {
|
||||
@@ -2087,6 +2300,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() {
|
||||
@@ -2209,6 +2452,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
|
||||
@@ -2223,9 +2473,11 @@ fn info(d: &Document) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// A user-space rectangle on 0-based `page` in displayed-page coordinates; unchanged when the
|
||||
/// page is unknown (a malformed annotation that points at no page).
|
||||
fn view_rect(i: &pdfcraft_render::DocInfo, page: usize, rect: [f32; 4]) -> [f32; 4] {
|
||||
/// A user-space rectangle on 0-based `page` in displayed-page coordinates (points from the
|
||||
/// top-left of the page as displayed, y down, after `/Rotate`), the way every tool reports
|
||||
/// rectangles; unchanged when the page is unknown (a malformed annotation that points at no page).
|
||||
/// Public so `pdfcraft-cli` reports the same rectangles as the tools.
|
||||
pub fn view_rect(i: &pdfcraft_render::DocInfo, page: usize, rect: [f32; 4]) -> [f32; 4] {
|
||||
i.pages.get(page).map_or(rect, |p| comments::rect_to_view(p, rect))
|
||||
}
|
||||
|
||||
@@ -2329,32 +2581,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)]
|
||||
@@ -2731,6 +2971,37 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_save_bytes_never_reach_publication_or_mark_saved() {
|
||||
let dir = staging_dir("invalid-save-bytes");
|
||||
let target = dir.join("out.pdf");
|
||||
let mut a = Automation::new().with_root(&dir).unwrap();
|
||||
let id = search_document(&mut a, 2);
|
||||
json(&mut a, "doc_save", json!({"doc": id.0, "path": "out.pdf"}));
|
||||
let old = std::fs::read(&target).unwrap();
|
||||
json(&mut a, "page_rotate", json!({"doc": id.0, "pages": [1], "degrees": 90}));
|
||||
let before = summary(a.session.get(id).unwrap());
|
||||
// Exercise both parse failure and a valid PDF that loses a page. This does not
|
||||
// depend on a malformed input remaining unrepaired by future reader improvements.
|
||||
for bytes in [b"not a PDF".to_vec(), numbered_pages(1)] {
|
||||
let mut published = false;
|
||||
let result = a.publish_save(id, &target, Arc::new(bytes), false, |_, _| {
|
||||
published = true;
|
||||
Ok(())
|
||||
});
|
||||
assert!(result.is_err(), "invalid output must be refused");
|
||||
assert!(result.unwrap_err().to_string().contains("could not be reopened"));
|
||||
assert!(!published, "validation must precede the writer");
|
||||
assert_eq!(summary(a.session.get(id).unwrap()), before, "failed validation must preserve document state");
|
||||
assert_eq!(std::fs::read(&target).unwrap(), old);
|
||||
assert_eq!(listing(&dir), ["out.pdf"]);
|
||||
}
|
||||
json(&mut a, "doc_save", json!({"doc": id.0}));
|
||||
assert!(!a.session.get(id).unwrap().dirty);
|
||||
assert_ne!(std::fs::read(&target).unwrap(), old);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_save_sync_keeps_the_document_dirty_and_can_be_retried() {
|
||||
let dir = staging_dir("sync-dirty");
|
||||
|
||||
@@ -65,18 +65,34 @@ 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
|
||||
.links()
|
||||
.iter()
|
||||
.map(|l| {
|
||||
let p = &doc.info.pages[l.page];
|
||||
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
|
||||
})
|
||||
@@ -163,7 +179,7 @@ impl Automation {
|
||||
let index = a.int("index")?;
|
||||
let doc = self.doc(a)?;
|
||||
let l = doc
|
||||
.links
|
||||
.links()
|
||||
.iter()
|
||||
.find(|l| l.page == page && l.index as i64 + 1 == index)
|
||||
.ok_or_else(|| failed(format!("page {} has no link {index} (see link_list)", page + 1)))?;
|
||||
@@ -201,8 +217,8 @@ impl Automation {
|
||||
None => None,
|
||||
};
|
||||
let before = match &pages {
|
||||
Some(ps) => self.doc(a)?.links.iter().filter(|l| ps.contains(&l.page)).count(),
|
||||
None => self.doc(a)?.links.len(),
|
||||
Some(ps) => self.doc(a)?.links().iter().filter(|l| ps.contains(&l.page)).count(),
|
||||
None => self.doc(a)?.links().len(),
|
||||
};
|
||||
let mut out = self.apply(a, Edit::RemoveLinks { pages })?;
|
||||
out["removed"] = json!(before);
|
||||
|
||||
@@ -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, crops: Vec::new() };
|
||||
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)?;
|
||||
|
||||
@@ -173,14 +173,16 @@ impl Automation {
|
||||
}
|
||||
|
||||
pub(crate) fn doc_audit_redactions(&self, a: &Args) -> Result<Value> {
|
||||
let findings = self.doc(a)?.audit_redactions().map_err(ToolError::Failed)?;
|
||||
let d = self.doc(a)?;
|
||||
let findings = d.audit_redactions().map_err(ToolError::Failed)?;
|
||||
let items: Vec<Value> = findings
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
json!({
|
||||
"page": f.page + 1,
|
||||
"kind": f.kind.id(),
|
||||
"rect": f.rect,
|
||||
// Points from the top-left of the displayed page, like `comment_list` and `doc_info`.
|
||||
"rect": crate::view_rect(&d.info, f.page, f.rect.map(|v| v as f32)),
|
||||
"covered_text": f.covered_text,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
+109
-23
@@ -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"])),
|
||||
@@ -161,7 +161,8 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600).")
|
||||
.ro()
|
||||
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "dpi": { "type": "number", "minimum": 1, "maximum": 600 },
|
||||
"hide_text_block": { "type": "integer", "minimum": 1, "description": "Render an inline editor background without this paragraph (index from text_paragraphs). Does not modify the document." }
|
||||
"hide_text_block": { "type": "integer", "minimum": 1, "description": "Render an inline editor background without this paragraph (index from text_paragraphs). Does not modify the document." },
|
||||
"hide_added_text": { "type": "integer", "minimum": 1, "description": "Render a retyping background without this added text item (added index from object_list). Mutually exclusive with hide_text_block. Does not modify the document." }
|
||||
}), &["doc", "page"])),
|
||||
t("object_list", "List editable objects", "List existing paragraphs, Image/Form artwork and added content once, with source kind/index references, displayed rectangles and document generation. Indexes change when content changes; use generation in object_move to reject stale references.")
|
||||
.ro().with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}}), &["doc","page"])),
|
||||
@@ -171,6 +172,12 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"type":"object","properties":{"kind":{"type":"string","enum":["added","text","image"]},"index":{"type":"integer","minimum":1}},"required":["kind","index"],"additionalProperties":false}},
|
||||
"offset":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":2},
|
||||
"generation":{"type":"integer","minimum":0,"description":"Use generation returned by object_list to reject a stale selection."}}), &["doc","page","objects","offset"])),
|
||||
t("text_word_count", "Word count", "Count Unicode words and characters on selected pages (default all), with totals and per-page counts. Table/code exclusions use PDF structure tags, not visual guesses; unclassified text is retained. Image exclusion removes Figure-tagged and invisible/OCR text. Does not run OCR. Copy permission is required. Fails rather than returning a partial total when a page cannot be read.")
|
||||
.ro()
|
||||
.cmd("file.word_count")
|
||||
.with(schema(json!({ "doc": doc(), "pages": pages("to count (default: all)"),
|
||||
"exclude_tables": { "type": "boolean" }, "exclude_code": { "type": "boolean" }, "exclude_images": { "type": "boolean" }
|
||||
}), &["doc"])),
|
||||
t("text_extract", "Extract text", "Extract the text of some or all pages, in reading order. With rect, only the text inside that rectangle on each page, as Column select takes it: one row per visual line, side-by-side pieces (table cells) separated by a tab.")
|
||||
.ro()
|
||||
.cmd("edit.column_select")
|
||||
@@ -223,7 +230,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).")
|
||||
@@ -366,8 +373,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." },
|
||||
@@ -393,10 +399,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"] } }),
|
||||
@@ -468,7 +471,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t(
|
||||
"doc_audit_redactions",
|
||||
"Audit faux redactions",
|
||||
"Find faux redactions: opaque filled rectangles painted over text that is still extractable, and /Redact annotations that were marked but never applied. Paint-order and colour-aware: a rectangle counts only when painted after the text and dark enough to hide it (page backgrounds and light shading are not flagged). Each finding reports the 1-based page, the kind (covered-text or unapplied-mark), the rectangle, and the hidden text. Fails closed: unreadable content is an error, never a clean bill. A properly applied redaction removes the text and never flags.",
|
||||
"Find faux redactions: opaque filled rectangles painted over text that is still extractable, and /Redact annotations that were marked but never applied. Paint-order and colour-aware: a rectangle counts only when painted after the text and dark enough to hide it (page backgrounds and light shading are not flagged). Each finding reports the 1-based page, the kind (covered-text or unapplied-mark), the rectangle (points from the top-left of the displayed page, like comment_list), and the hidden text. Fails closed: unreadable content is an error, never a clean bill. A properly applied redaction removes the text and never flags.",
|
||||
)
|
||||
.ro()
|
||||
.with(schema(json!({ "doc": doc() }), &["doc"])),
|
||||
@@ -479,7 +482,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t(
|
||||
"doc_print",
|
||||
"Print",
|
||||
"Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed; default all); subset odd/even; reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, or A0/A1/A2/A3/A4/A5 (short names or full preset labels); use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).",
|
||||
"Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed; default all); subset odd/even; reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, or A0/A1/A2/A3/A4/A5/A6 (short names or full preset labels); use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).",
|
||||
)
|
||||
.with(schema(
|
||||
json!({
|
||||
@@ -506,6 +509,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"],
|
||||
@@ -516,7 +521,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(
|
||||
@@ -673,7 +678,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"],
|
||||
@@ -686,7 +691,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!({
|
||||
@@ -711,6 +716,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"],
|
||||
@@ -749,13 +779,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(),
|
||||
})),
|
||||
@@ -794,6 +825,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",
|
||||
@@ -878,6 +929,9 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])),
|
||||
t("accessibility_autotag_apply", "Automatically tag text-only PDF", "Write marked content, a Document structure element, paragraph tags and a parent tree for every text object in an untagged, text-only PDF. Refuses graphics, images, annotations, forms, existing tags and malformed streams before changing anything. Review all resulting roles; this does not certify accessibility. Undoable.")
|
||||
.with(schema(json!({ "doc": doc() }), &["doc"])),
|
||||
t("accessibility_autotag_form_fields", "Tag form fields", "Write `/Form` tags, widget structure-parent keys, and a parent tree for all page widgets in an untagged AcroForm. Existing tag trees are refused for review. Text, figures, links, and other content are not tagged.")
|
||||
.cmd("a11y.autotag_fields")
|
||||
.with(schema(json!({ "doc": doc() }), &["doc"])),
|
||||
t("accessibility_check", "Check for accessibility", "Run the Accessibility Checker's full check (32 rules in 7 categories: document, page_content, forms, alternate_text, tables, lists, headings). Each rule is passed, failed (with findings and pages), manual (needs a person) or skipped. Findings on locatable figures, annotations and form widgets include bbox_user_space in PDF coordinates. Colour contrast is off unless all is true; rules (ids such as tagged-pdf, figures-alt-text) or categories narrow the check; pages limit the page rules.")
|
||||
.ro()
|
||||
.cmd("a11y.check")
|
||||
@@ -891,6 +945,10 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
}),
|
||||
&["doc"],
|
||||
)),
|
||||
t("accessibility_find_untagged", "Find untagged content", "Find untagged painted page content, annotations, and form widgets. This focused check does not modify the PDF or claim that its other accessibility rules pass.")
|
||||
.ro()
|
||||
.cmd("a11y.find_untagged")
|
||||
.with(schema(json!({ "doc": doc(), "pages": pages("to search (default: all)") }), &["doc"])),
|
||||
t("accessibility_report", "Accessibility report", "Run the full check and write the accessibility report (HTML) to path; returns the results too. Takes the same options as accessibility_check.")
|
||||
.destructive()
|
||||
.cmd("a11y.report")
|
||||
@@ -942,10 +1000,24 @@ 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_tag_content", "List tag content", "List the marked-content references directly owned by one reachable indirect structure tag, including page, MCID and extracted text.")
|
||||
.ro()
|
||||
.cmd("a11y.tags")
|
||||
.with(schema(json!({ "doc": doc(), "object": { "type": "integer", "minimum": 1 }, "generation": { "type": "integer", "minimum": 0, "maximum": 65535 } }), &["doc", "object", "generation"])),
|
||||
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 },
|
||||
@@ -953,6 +1025,11 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"property": { "type": "string", "enum": ["type", "title", "actual_text", "alternate_text", "language"] },
|
||||
"value": { "type": "string" },
|
||||
}), &["doc", "object", "generation", "property"])),
|
||||
t("accessibility_mark_tag_artifact", "Mark tag content as artifact", "Mark a reachable leaf structure tag's direct marked content as an artifact and remove the tag from the reading structure. Use object and generation from accessibility_tags. Tags with child structure elements, no direct marked content, or content not found on its page are rejected. Undoable.")
|
||||
.with(schema(json!({
|
||||
"doc": doc(), "object": { "type": "integer", "minimum": 1 },
|
||||
"generation": { "type": "integer", "minimum": 0, "maximum": 65535 },
|
||||
}), &["doc", "object", "generation"])),
|
||||
t("accessibility_move_tag", "Move structure tag", "Move a reachable indirect structure tag before or after a direct sibling in their parent's /K array, changing tagged reading order without changing page appearance. Use object and generation values from accessibility_tags. Rejects distinct parents, duplicate references and positions that are already current. Undoable.")
|
||||
.with(schema(json!({
|
||||
"doc": doc(), "object": { "type": "integer", "minimum": 1 },
|
||||
@@ -1021,7 +1098,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t("doc_compare_mark", "Mark differences as comments", "Add the differences from other (older) to doc (newer) as comments in doc: highlights over replaced (blue) and inserted (green) text, notes where text was deleted (red), authored Compare. Undoable.")
|
||||
.cmd("doc.compare")
|
||||
.with(schema(json!({ "doc": doc(), "other": { "type": "integer" } }), &["doc", "other"])),
|
||||
t("form_detect_fields", "Detect form fields", "Prepare a form ▸ automatic field detection: find the blanks a printed form asks to be filled (underscore runs, lines, empty boxes, small squares for check boxes) and name each field from its label. With add (default true) the fields are created as one undoable step; otherwise they are only proposed. Returns page (1-based), kind, name and rect (points, origin bottom-left).")
|
||||
t("form_detect_fields", "Detect form fields", "Prepare a form ▸ automatic field detection: find the blanks a printed form asks to be filled (underscore runs, lines, empty boxes, small squares for check boxes; on scanned pages read from the pixels) and name each field from its label. With add (default true) the fields are created as one undoable step; otherwise they are only proposed. Returns page (1-based), kind, name and rect (points, origin bottom-left).")
|
||||
.cmd("form.detect")
|
||||
.with(schema(json!({ "doc": doc(), "pages": pages("to look at (default: all)"), "add": { "type": "boolean" } }), &["doc"])),
|
||||
t("form_actions", "Field actions", "Field Properties ▸ Actions: the field's action for each trigger (mouse_up, mouse_down, mouse_enter, mouse_exit, on_focus, on_blur).")
|
||||
@@ -1068,7 +1145,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!({
|
||||
@@ -1164,14 +1241,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" },
|
||||
@@ -1226,12 +1303,14 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
.with(schema(json!({ "doc": doc() }), &["doc"])),
|
||||
t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.")
|
||||
.with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])),
|
||||
t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.")
|
||||
t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.")
|
||||
.cmd("optimize.advanced")
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(),
|
||||
"path": { "type": "string" },
|
||||
"preset": { "type": "string", "enum": ["standard", "mobile"] },
|
||||
"merge_fragments": { "type": "boolean" },
|
||||
"color": { "type": "object" },
|
||||
"gray": { "type": "object" },
|
||||
"discard_thumbnails": { "type": "boolean" },
|
||||
@@ -1245,9 +1324,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.")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,376 @@
|
||||
//! `pdfcraft-cli mcp --automation-read-root <dir> --automation-write-root <dir>`: through the MCP
|
||||
//! protocol, files open and load only from inside the read root and are written only inside the
|
||||
//! write root. Paths that leave a root (`..`, another folder, a sibling with the same prefix, a
|
||||
//! link) are refused with the wording VectorCraft uses, nothing is read into the result and
|
||||
//! nothing is written outside.
|
||||
#![cfg(feature = "mcp")]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use pdfcraft_automation::Automation;
|
||||
use pdfcraft_automation::file_access::AutomationRoots;
|
||||
use pdfcraft_automation::mcp::McpServer;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// A 1x1 PNG, so the tools that take a picture have something real to read.
|
||||
const PNG: &[u8] = &[
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf, 0xc0, 0xf0,
|
||||
0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
||||
];
|
||||
|
||||
/// A one-page PDF reading `text`, with a 1x1 picture on it.
|
||||
fn pdf(text: &str) -> Vec<u8> {
|
||||
let body = format!("q 100 0 0 100 20 20 cm /Im1 Do Q BT /F1 24 Tf 20 150 Td ({text}) Tj ET");
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec(),
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> /XObject << /Im1 6 0 R >> >> >>".to_vec(),
|
||||
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes(),
|
||||
b"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length 3 >>\nstream\n\xff\x00\x00\nendstream"
|
||||
.to_vec(),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
out.extend_from_slice(o);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// A fresh folder holding `in` (the read root, with `a.pdf`, `b.pdf` and `pic.png`), `out` (the
|
||||
/// write root), `outside` (with `secret.pdf` and a picture) and `in2`.
|
||||
fn folders(tag: &str) -> PathBuf {
|
||||
let base = std::env::temp_dir().join(format!("pdfcraft-mcp-roots-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
for d in ["in", "out", "outside", "in2"] {
|
||||
std::fs::create_dir_all(base.join(d)).unwrap();
|
||||
}
|
||||
std::fs::write(base.join("in/a.pdf"), pdf("Inside page")).unwrap();
|
||||
std::fs::write(base.join("in/b.pdf"), pdf("Second inside")).unwrap();
|
||||
std::fs::write(base.join("in/pic.png"), PNG).unwrap();
|
||||
std::fs::write(base.join("outside/secret.pdf"), pdf("TOP SECRET")).unwrap();
|
||||
std::fs::write(base.join("outside/pic.png"), PNG).unwrap();
|
||||
std::fs::write(base.join("in2/c.pdf"), pdf("Sibling")).unwrap();
|
||||
base
|
||||
}
|
||||
|
||||
/// A server confined to `in` and `out` (either may be left out).
|
||||
fn confined(tag: &str, read: bool, write: bool) -> (McpServer, PathBuf) {
|
||||
let base = folders(tag);
|
||||
let (in_dir, out_dir) = (base.join("in"), base.join("out"));
|
||||
let roots = AutomationRoots::new(read.then_some(in_dir.as_path()), write.then_some(out_dir.as_path())).unwrap();
|
||||
(McpServer::new(Automation::new().with_automation_roots(roots).with_clock(|| 1_700_000_000)), base)
|
||||
}
|
||||
|
||||
fn s(p: &Path) -> String {
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn rpc(server: &mut McpServer, method: &str, params: Value) -> Value {
|
||||
let line = json!({"jsonrpc": "2.0", "id": 1, "method": method, "params": params}).to_string();
|
||||
serde_json::from_str(&server.handle_line(&line).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// `tools/call` -> (isError, all the text of the reply).
|
||||
fn call(server: &mut McpServer, name: &str, args: Value) -> (bool, String) {
|
||||
let reply = rpc(server, "tools/call", json!({"name": name, "arguments": args}));
|
||||
let result = &reply["result"];
|
||||
let mut text =
|
||||
result["content"].as_array().map(|c| c.iter().filter_map(|c| c["text"].as_str()).collect::<Vec<_>>().join("\n")).unwrap_or_default();
|
||||
text.push_str(&result["structuredContent"].to_string());
|
||||
text.push_str(&reply["error"].to_string());
|
||||
(result["isError"] == true || reply["error"].is_object(), text)
|
||||
}
|
||||
|
||||
fn ok(server: &mut McpServer, name: &str, args: Value) -> Value {
|
||||
let reply = rpc(server, "tools/call", json!({"name": name, "arguments": args}));
|
||||
assert!(reply["result"]["isError"] == false, "{name} {args}: {reply}");
|
||||
reply["result"]["structuredContent"].clone()
|
||||
}
|
||||
|
||||
fn refused(server: &mut McpServer, name: &str, args: Value, why: &str) {
|
||||
let (error, text) = call(server, name, args.clone());
|
||||
assert!(error && text.contains(why), "{name} {args} should be refused ({why}): {text}");
|
||||
assert!(!text.contains("TOP SECRET"), "{name} {args} let the file's text through: {text}");
|
||||
}
|
||||
|
||||
/// The text of document 1, through the resource a client can read it with.
|
||||
fn text_of(server: &mut McpServer, doc: u64) -> String {
|
||||
rpc(server, "resources/read", json!({"uri": format!("pdfcraft://doc/{doc}/text")}))["result"]["contents"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
fn names(dir: &Path) -> Vec<String> {
|
||||
let mut names: Vec<String> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_open_only_from_inside_the_read_root() {
|
||||
let (mut server, base) = confined("open", true, true);
|
||||
let sep = std::path::MAIN_SEPARATOR;
|
||||
ok(&mut server, "doc_open", json!({"path": s(&base.join("in/a.pdf"))}));
|
||||
assert!(text_of(&mut server, 1).contains("Inside page"));
|
||||
|
||||
refused(&mut server, "doc_open", json!({"path": s(&base.join("outside/secret.pdf"))}), "outside the read root");
|
||||
refused(&mut server, "doc_open", json!({"path": format!("{}{sep}..{sep}outside{sep}secret.pdf", s(&base.join("in")))}), "outside the read root");
|
||||
// A sibling whose name starts with the root's name is not inside it.
|
||||
refused(&mut server, "doc_open", json!({"path": s(&base.join("in2/c.pdf"))}), "outside the read root");
|
||||
// The write root is not readable: the two kinds of access are separate.
|
||||
std::fs::write(base.join("out/d.pdf"), pdf("Written")).unwrap();
|
||||
refused(&mut server, "doc_open", json!({"path": s(&base.join("out/d.pdf"))}), "outside the read root");
|
||||
// A relative path is the working directory's (the crate's folder here): outside.
|
||||
refused(&mut server, "doc_open", json!({"path": "Cargo.toml"}), "outside the read root");
|
||||
// Nothing was opened by the refused calls.
|
||||
assert_eq!(ok(&mut server, "doc_list", json!({}))["documents"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_that_read_a_path_are_held_to_the_read_root() {
|
||||
let (mut server, base) = confined("reads", true, true);
|
||||
ok(&mut server, "doc_open", json!({"path": s(&base.join("in/a.pdf"))}));
|
||||
let (secret, pic, gone) = (s(&base.join("outside/secret.pdf")), s(&base.join("outside/pic.png")), s(&base.join("outside/gone.bin")));
|
||||
let out = s(&base.join("out/result.pdf"));
|
||||
for (tool, args) in [
|
||||
("page_insert_file", json!({"doc": 1, "path": secret, "at": 1})),
|
||||
("page_replace", json!({"doc": 1, "pages": [1], "path": secret})),
|
||||
("doc_combine", json!({"paths": [s(&base.join("in/a.pdf")), secret], "out": out})),
|
||||
("doc_create_multiple", json!({"paths": [secret], "mode": "combine", "out": out})),
|
||||
("doc_create", json!({"from": "images", "paths": [pic]})),
|
||||
("stamp_custom", json!({"doc": 1, "page": 1, "path": secret, "at": [10, 10]})),
|
||||
("page_add_image", json!({"doc": 1, "page": 1, "path": pic})),
|
||||
("image_edit", json!({"doc": 1, "page": 1, "image": 1, "action": "replace", "path": pic})),
|
||||
("comment_add", json!({"doc": 1, "page": 1, "type": "attachment", "at": [10, 10], "path": secret})),
|
||||
("fill_sign_add", json!({"doc": 1, "page": 1, "type": "signature", "at": [10, 10], "path": pic})),
|
||||
("form_set_image", json!({"doc": 1, "field": "f", "path": pic})),
|
||||
("doc_import_data", json!({"doc": 1, "path": secret})),
|
||||
("doc_watermark", json!({"doc": 1, "file": pic})),
|
||||
("doc_background", json!({"doc": 1, "file": pic})),
|
||||
("form_merge_data", json!({"paths": [secret], "path": out})),
|
||||
("ocr_recognize_files", json!({"paths": [secret], "folder": s(&base.join("out/ocr"))})),
|
||||
("sign_trust", json!({"paths": [secret]})),
|
||||
("sign_document", json!({"doc": 1, "id": secret, "out": out})),
|
||||
("text_font_import", json!({"doc": 1, "page": 1, "path": gone})),
|
||||
("text_font_files", json!({"directories": [s(&base.join("outside"))]})),
|
||||
] {
|
||||
refused(&mut server, tool, args, "outside the read root");
|
||||
}
|
||||
// The refused calls changed neither the document nor the disk.
|
||||
assert!(!text_of(&mut server, 1).contains("TOP SECRET"));
|
||||
assert_eq!(ok(&mut server, "doc_list", json!({}))["documents"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(names(&base.join("out")), Vec::<String>::new());
|
||||
|
||||
// One file per output: a file that can't be read is reported in its own entry, with the same refusal.
|
||||
let (_, text) = call(&mut server, "doc_create_multiple", json!({"paths": [secret], "mode": "separate", "out_dir": s(&base.join("out/sep"))}));
|
||||
assert!(text.contains("outside the read root") && !text.contains("TOP SECRET"), "{text}");
|
||||
assert!(names(&base.join("out/sep")).is_empty());
|
||||
let (_, text) = call(&mut server, "action_run", json!({"paths": [secret], "folder": s(&base.join("out/act")), "action": "Add Page Numbers"}));
|
||||
assert!(text.contains("outside the read root") && !text.contains("TOP SECRET"), "{text}");
|
||||
assert!(names(&base.join("out/act")).is_empty());
|
||||
std::fs::remove_dir_all(base.join("out/act")).unwrap();
|
||||
|
||||
// The same tools work on files inside the root.
|
||||
std::fs::remove_dir_all(base.join("out/sep")).unwrap();
|
||||
ok(&mut server, "page_insert_file", json!({"doc": 1, "path": s(&base.join("in/b.pdf")), "at": 2}));
|
||||
assert!(text_of(&mut server, 1).contains("Second inside"));
|
||||
ok(&mut server, "page_add_image", json!({"doc": 1, "page": 1, "path": s(&base.join("in/pic.png"))}));
|
||||
ok(&mut server, "doc_combine", json!({"paths": [s(&base.join("in/a.pdf")), s(&base.join("in/b.pdf"))], "out": out}));
|
||||
assert_eq!(names(&base.join("out")), ["result.pdf"]);
|
||||
// Without a folder to list, the installed fonts are outside the read root: it is searched instead.
|
||||
ok(&mut server, "text_font_files", json!({}));
|
||||
ok(&mut server, "text_font_files", json!({"directories": [s(&base.join("in"))]}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_that_write_a_path_are_held_to_the_write_root() {
|
||||
let (mut server, base) = confined("writes", true, true);
|
||||
let input = |name: &str| s(&base.join("in").join(name));
|
||||
ok(&mut server, "doc_open", json!({"path": input("a.pdf")}));
|
||||
ok(&mut server, "doc_open", json!({"path": input("b.pdf")}));
|
||||
let writes = |dir: &str| -> Vec<(&'static str, Value)> {
|
||||
let file = |name: &str| s(&base.join(dir).join(name));
|
||||
vec![
|
||||
("doc_save", json!({"doc": 1, "path": file("saved.pdf")})),
|
||||
("page_extract", json!({"doc": 1, "pages": [1], "out": file("extract.pdf")})),
|
||||
("page_extract", json!({"doc": 1, "pages": [1], "separate": true, "out_dir": file("pages")})),
|
||||
("doc_combine", json!({"paths": [input("a.pdf"), input("b.pdf")], "out": file("combined.pdf")})),
|
||||
("doc_create_multiple", json!({"paths": [input("a.pdf")], "mode": "combine", "out": file("multiple.pdf")})),
|
||||
("doc_create_multiple", json!({"paths": [input("a.pdf")], "mode": "separate", "out_dir": file("separate")})),
|
||||
("doc_split", json!({"doc": 1, "every": 1, "out_dir": file("parts")})),
|
||||
("doc_reduce", json!({"doc": 1, "path": file("reduced.pdf")})),
|
||||
("doc_optimize", json!({"doc": 1, "path": file("optimized.pdf")})),
|
||||
("doc_print", json!({"doc": 1, "path": file("printed.pdf")})),
|
||||
("doc_export_text", json!({"doc": 1, "path": file("text.txt")})),
|
||||
("doc_export_office", json!({"doc": 1, "path": file("export.docx")})),
|
||||
("doc_export_data", json!({"doc": 1, "path": file("data.xfdf")})),
|
||||
("doc_export_images", json!({"doc": 1, "folder": file("renders")})),
|
||||
("doc_export_all_images", json!({"doc": 1, "folder": file("images")})),
|
||||
("image_save", json!({"doc": 1, "page": 1, "image": 1, "path": file("image.png")})),
|
||||
("measure_export", json!({"doc": 1, "out": file("measurements.csv")})),
|
||||
("comments_summarize", json!({"doc": 1, "out": file("summary.pdf")})),
|
||||
("accessibility_report", json!({"doc": 1, "path": file("report.html")})),
|
||||
("accessibility_review_record", json!({"before": 1, "after": 2, "path": file("record.json")})),
|
||||
("doc_compare_report", json!({"doc": 1, "other": 2, "path": file("compare.pdf")})),
|
||||
("form_merge_data", json!({"paths": [input("a.pdf")], "path": file("merged.csv")})),
|
||||
("action_run", json!({"paths": [input("a.pdf")], "folder": file("actions"), "action": "Add Page Numbers"})),
|
||||
("ocr_recognize_files", json!({"paths": [input("a.pdf")], "folder": file("ocr")})),
|
||||
("sign_id_create", json!({"name": "Test", "password": "secret12", "path": file("id.p12"), "key": "p256"})),
|
||||
]
|
||||
};
|
||||
|
||||
for (tool, args) in writes("outside") {
|
||||
refused(&mut server, tool, args, "outside the write root");
|
||||
}
|
||||
// The read root is not writable either.
|
||||
for (tool, args) in writes("in") {
|
||||
refused(&mut server, tool, args, "outside the write root");
|
||||
}
|
||||
// `..` out of the write root, and a sibling that shares its prefix.
|
||||
let sep = std::path::MAIN_SEPARATOR;
|
||||
refused(
|
||||
&mut server,
|
||||
"doc_save",
|
||||
json!({"doc": 1, "path": format!("{}{sep}..{sep}outside{sep}up.pdf", s(&base.join("out")))}),
|
||||
"outside the write root",
|
||||
);
|
||||
std::fs::create_dir_all(base.join("out2")).unwrap();
|
||||
refused(&mut server, "doc_save", json!({"doc": 1, "path": s(&base.join("out2/x.pdf"))}), "outside the write root");
|
||||
// Saving in place writes to the folder the file was opened from: the read root, not the write root.
|
||||
refused(&mut server, "doc_save", json!({"doc": 1}), "outside the write root");
|
||||
|
||||
assert_eq!(names(&base.join("outside")), ["pic.png", "secret.pdf"], "nothing was written outside the roots");
|
||||
assert_eq!(names(&base.join("in")), ["a.pdf", "b.pdf", "pic.png"], "nothing was written to the read root");
|
||||
assert!(names(&base.join("out2")).is_empty());
|
||||
assert!(names(&base.join("out")).is_empty());
|
||||
|
||||
// The same calls land in the write root.
|
||||
for (tool, args) in writes("out") {
|
||||
let (error, text) = call(&mut server, tool, args.clone());
|
||||
// (Printing to a PDF and the like may not apply to this fixture: a refusal for the roots is the failure.)
|
||||
assert!(!text.contains("automation path rejected") && !text.contains("automation filesystem access"), "{tool} {args}: {text}");
|
||||
let _ = error;
|
||||
}
|
||||
assert!(base.join("out/saved.pdf").is_file() && base.join("out/combined.pdf").is_file() && base.join("out/text.txt").is_file());
|
||||
assert_eq!(names(&base.join("outside")), ["pic.png", "secret.pdf"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signing_writes_and_reads_only_inside_the_roots() {
|
||||
// One folder for both roots, so a digital ID can be made and then read back.
|
||||
let base = folders("signing");
|
||||
let work = base.join("work");
|
||||
std::fs::create_dir_all(&work).unwrap();
|
||||
std::fs::write(work.join("a.pdf"), pdf("Work page")).unwrap();
|
||||
let roots = AutomationRoots::new(Some(&work), Some(&work)).unwrap();
|
||||
let mut server = McpServer::new(Automation::new().with_automation_roots(roots).with_clock(|| 1_700_000_000));
|
||||
ok(&mut server, "doc_open", json!({"path": s(&work.join("a.pdf"))}));
|
||||
ok(&mut server, "sign_id_create", json!({"name": "Test", "password": "secret12", "path": s(&work.join("id.p12")), "key": "p256"}));
|
||||
refused(
|
||||
&mut server,
|
||||
"sign_id_create",
|
||||
json!({"name": "Test", "password": "secret12", "path": s(&base.join("outside/id.p12")), "key": "p256"}),
|
||||
"outside the write root",
|
||||
);
|
||||
refused(
|
||||
&mut server,
|
||||
"sign_document",
|
||||
json!({"doc": 1, "id": s(&work.join("id.p12")), "password": "secret12", "out": s(&base.join("outside/signed.pdf"))}),
|
||||
"outside the write root",
|
||||
);
|
||||
ok(&mut server, "sign_document", json!({"doc": 1, "id": s(&work.join("id.p12")), "password": "secret12", "out": s(&work.join("signed.pdf"))}));
|
||||
assert!(work.join("signed.pdf").is_file());
|
||||
assert_eq!(names(&base.join("outside")), ["pic.png", "secret.pdf"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_root_left_out_grants_none_of_its_access() {
|
||||
let (mut server, base) = confined("read-only", true, false);
|
||||
ok(&mut server, "doc_open", json!({"path": s(&base.join("in/a.pdf"))}));
|
||||
for target in ["out/doc.pdf", "in/doc.pdf"] {
|
||||
refused(&mut server, "doc_save", json!({"doc": 1, "path": s(&base.join(target))}), "write authority is absent");
|
||||
}
|
||||
refused(&mut server, "doc_save", json!({"doc": 1}), "write authority is absent");
|
||||
refused(&mut server, "doc_export_text", json!({"doc": 1, "path": s(&base.join("out/t.txt"))}), "write authority is absent");
|
||||
refused(&mut server, "page_extract", json!({"doc": 1, "pages": [1], "out": s(&base.join("out/e.pdf"))}), "write authority is absent");
|
||||
refused(&mut server, "doc_split", json!({"doc": 1, "every": 1, "out_dir": s(&base.join("out/parts"))}), "write authority is absent");
|
||||
assert!(names(&base.join("out")).is_empty() && names(&base.join("in")) == ["a.pdf", "b.pdf", "pic.png"]);
|
||||
// A tool that gives its result back instead of writing it still can.
|
||||
ok(&mut server, "page_extract", json!({"doc": 1, "pages": [1]}));
|
||||
|
||||
let (mut server, base) = confined("write-only", false, true);
|
||||
refused(&mut server, "doc_open", json!({"path": s(&base.join("in/a.pdf"))}), "read authority is absent");
|
||||
refused(
|
||||
&mut server,
|
||||
"doc_combine",
|
||||
json!({"paths": [s(&base.join("in/a.pdf")), s(&base.join("in/b.pdf"))], "out": s(&base.join("out/c.pdf"))}),
|
||||
"read authority is absent",
|
||||
);
|
||||
refused(&mut server, "text_font_files", json!({}), "read authority is absent");
|
||||
ok(&mut server, "doc_create", json!({"from": "blank", "pages": 1, "width": 200, "height": 300}));
|
||||
ok(&mut server, "doc_save", json!({"doc": 1, "path": s(&base.join("out/blank.pdf"))}));
|
||||
assert!(base.join("out/blank.pdf").is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_out_of_a_root_are_refused() {
|
||||
let (mut server, base) = confined("links", true, true);
|
||||
#[cfg(unix)]
|
||||
let linked = std::os::unix::fs::symlink(base.join("outside"), base.join("out/away")).is_ok()
|
||||
&& std::os::unix::fs::symlink(base.join("outside/secret.pdf"), base.join("in/leak.pdf")).is_ok()
|
||||
&& std::os::unix::fs::symlink(base.join("outside/nowhere.pdf"), base.join("out/dangling.pdf")).is_ok();
|
||||
#[cfg(windows)]
|
||||
let linked = std::os::windows::fs::symlink_dir(base.join("outside"), base.join("out/away")).is_ok()
|
||||
&& std::os::windows::fs::symlink_file(base.join("outside/secret.pdf"), base.join("in/leak.pdf")).is_ok()
|
||||
&& std::os::windows::fs::symlink_file(base.join("outside/nowhere.pdf"), base.join("out/dangling.pdf")).is_ok();
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
let linked = false;
|
||||
// Windows needs Developer Mode or elevation to make links.
|
||||
if !linked {
|
||||
eprintln!("skipped: symbolic links can't be made here");
|
||||
return;
|
||||
}
|
||||
ok(&mut server, "doc_open", json!({"path": s(&base.join("in/a.pdf"))}));
|
||||
refused(&mut server, "doc_open", json!({"path": s(&base.join("in/leak.pdf"))}), "outside the read root");
|
||||
refused(&mut server, "doc_save", json!({"doc": 1, "path": s(&base.join("out/away/doc.pdf"))}), "outside the write root");
|
||||
refused(&mut server, "doc_save", json!({"doc": 1, "path": s(&base.join("out/away/deeper/doc.pdf"))}), "outside the write root");
|
||||
refused(&mut server, "doc_save", json!({"doc": 1, "path": s(&base.join("out/dangling.pdf"))}), "a link that leads nowhere");
|
||||
assert_eq!(names(&base.join("outside")), ["pic.png", "secret.pdf"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_roots_nothing_is_confined() {
|
||||
let base = folders("none");
|
||||
let mut server = McpServer::new(Automation::new());
|
||||
ok(&mut server, "doc_open", json!({"path": s(&base.join("outside/secret.pdf"))}));
|
||||
assert!(text_of(&mut server, 1).contains("TOP SECRET"));
|
||||
ok(&mut server, "doc_save", json!({"doc": 1, "path": s(&base.join("in2/copy.pdf"))}));
|
||||
assert!(base.join("in2/copy.pdf").is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_roots_apply_on_top_of_a_root() {
|
||||
let base = folders("both");
|
||||
let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap();
|
||||
// `--root` resolves relative paths inside `in`, which the read root allows and the write root does not.
|
||||
let mut server = McpServer::new(Automation::new().with_root(base.join("in")).unwrap().with_automation_roots(roots));
|
||||
ok(&mut server, "doc_open", json!({"path": "a.pdf"}));
|
||||
refused(&mut server, "doc_save", json!({"doc": 1, "path": "copy.pdf"}), "outside the write root");
|
||||
refused(&mut server, "doc_open", json!({"path": "../outside/secret.pdf"}), "outside the allowed directory");
|
||||
assert!(!base.join("in/copy.pdf").exists());
|
||||
// The write root alone is not enough: the other root still has its say.
|
||||
refused(&mut server, "doc_save", json!({"doc": 1, "path": "../out/copy.pdf"}), "outside the allowed directory");
|
||||
}
|
||||
@@ -12,3 +12,8 @@ let m = Matrix::from_operands(&op.operands); // cm / Tm arithmetic, invert, bbo
|
||||
The parser is tolerant (unreadable bytes are skipped and counted, never fatal) and never
|
||||
panics. `redact` and (later) `edit` build their interpreters on it; the graphics-state tracker
|
||||
and the `Patch` API of architecture §6 grow here.
|
||||
|
||||
Unfiltered inline images with built-in or Indexed colour spaces, and image masks, use their
|
||||
sample layout (including per-row byte padding) to locate `EI`. This preserves pixel bytes
|
||||
that look like the terminator. Filtered images and resource-defined colour spaces retain
|
||||
the tolerant delimiter heuristic; damaged or truncated layouts fall back to it too.
|
||||
|
||||
@@ -175,12 +175,83 @@ fn inline_image(data: &[u8], pos: usize) -> Result<(Dict, Vec<u8>, usize), usize
|
||||
}
|
||||
// One whitespace byte separates ID from the data.
|
||||
let begin = (lx.pos + 1).min(data.len());
|
||||
Ok(match end_of_image(data, begin) {
|
||||
let end = unfiltered_image_end(data, begin, &dict).or_else(|| end_of_image(data, begin));
|
||||
Ok(match end {
|
||||
Some((end_data, end)) => (dict, data[begin..end_data].to_vec(), end),
|
||||
None => (dict, data[begin..].to_vec(), data.len()),
|
||||
})
|
||||
}
|
||||
|
||||
/// An unfiltered image's samples determine its length, including byte padding per row.
|
||||
/// Binary samples may themselves contain whitespace + `EI` + whitespace, so use the known
|
||||
/// length when the actual terminator follows it. Unknown or damaged layouts keep the heuristic.
|
||||
fn unfiltered_image_end(data: &[u8], begin: usize, dict: &Dict) -> Option<(usize, usize)> {
|
||||
for key in [b"Filter".as_slice(), b"F"] {
|
||||
if let Some(filter) = dict.get(key)
|
||||
&& !matches!(filter, Object::Null)
|
||||
&& !matches!(filter, Object::Array(filters) if filters.is_empty())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
}
|
||||
for (full, short) in
|
||||
[(b"Width".as_slice(), b"W".as_slice()), (b"Height", b"H"), (b"ColorSpace", b"CS"), (b"BitsPerComponent", b"BPC"), (b"ImageMask", b"IM")]
|
||||
{
|
||||
if let (Some(full), Some(short)) = (dict.get(full), dict.get(short))
|
||||
&& full != short
|
||||
{
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let get = |full: &[u8], short: &[u8]| dict.get(full).or_else(|| dict.get(short));
|
||||
let width = usize::try_from(get(b"Width", b"W")?.as_int()?).ok().filter(|n| *n > 0)?;
|
||||
let height = usize::try_from(get(b"Height", b"H")?.as_int()?).ok().filter(|n| *n > 0)?;
|
||||
let mask = match get(b"ImageMask", b"IM") {
|
||||
None | Some(Object::Null | Object::Bool(false)) => false,
|
||||
Some(Object::Bool(true)) => true,
|
||||
_ => return None,
|
||||
};
|
||||
let bits = match get(b"BitsPerComponent", b"BPC") {
|
||||
None | Some(Object::Null) => None,
|
||||
Some(value) => Some(value.as_int()?),
|
||||
};
|
||||
let (components, bits) = if mask {
|
||||
if bits.is_some_and(|bits| bits != 1) {
|
||||
return None;
|
||||
}
|
||||
(1usize, 1usize)
|
||||
} else {
|
||||
let (components, max_bits) = match get(b"ColorSpace", b"CS")? {
|
||||
Object::Name(name) => match name.as_slice() {
|
||||
b"DeviceGray" | b"G" => (1, 16),
|
||||
b"DeviceRGB" | b"RGB" => (3, 16),
|
||||
b"DeviceCMYK" | b"CMYK" => (4, 16),
|
||||
_ => return None,
|
||||
},
|
||||
Object::Array(space) if space.len() == 4 && matches!(space.first().and_then(Object::as_name), Some(b"Indexed" | b"I")) => (1, 8),
|
||||
_ => return None,
|
||||
};
|
||||
let bits = match bits? {
|
||||
bits @ (1 | 2 | 4 | 8 | 16) => bits as usize,
|
||||
_ => return None,
|
||||
};
|
||||
if bits > max_bits {
|
||||
return None;
|
||||
}
|
||||
(components, bits)
|
||||
};
|
||||
let row = width.checked_mul(components)?.checked_mul(bits)?.div_ceil(8);
|
||||
let end_data = begin.checked_add(row.checked_mul(height)?)?;
|
||||
let after = data.get(end_data..)?;
|
||||
if !after.first().is_some_and(|b| is_ws(*b)) {
|
||||
return None;
|
||||
}
|
||||
let separator = after.iter().position(|b| !is_ws(*b))?;
|
||||
let ei = end_data.checked_add(separator)?;
|
||||
let end = ei.checked_add(2)?;
|
||||
if data.get(ei..end) == Some(b"EI") && data.get(end).is_none_or(|b| is_ws(*b) || is_delim(*b)) { Some((end_data, end)) } else { None }
|
||||
}
|
||||
|
||||
/// Where inline image data starting at `begin` ends: (end of the data, position after `EI`). The
|
||||
/// data ends at whitespace + `EI` + (whitespace, a delimiter or the end).
|
||||
fn end_of_image(data: &[u8], begin: usize) -> Option<(usize, usize)> {
|
||||
|
||||
@@ -38,6 +38,171 @@ fn inline_images_stay_whole() {
|
||||
assert_eq!(again.ops[2].inline, p.ops[2].inline);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfiltered_inline_image_keeps_whitespace_bounded_ei_pixels() {
|
||||
let src = b"q 4 0 0 1 0 0 cm BI /W 4 /H 1 /BPC 8 /CS /G ID EI \nEI Q";
|
||||
let parsed = parse(src);
|
||||
assert_eq!(parsed.ops[2].inline.as_ref().unwrap().1, b" EI ");
|
||||
assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::<Vec<_>>(), [b"q".as_slice(), b"cm", b"BI", b"Q"]);
|
||||
let rewritten = serialize_ops(&parsed.ops);
|
||||
assert_eq!(parse(&rewritten).ops[2].inline, parsed.ops[2].inline);
|
||||
}
|
||||
|
||||
fn inline_pixels(parameters: &[u8], pixels: &[u8]) -> Parsed {
|
||||
let mut src = b"q BI ".to_vec();
|
||||
src.extend_from_slice(parameters);
|
||||
src.extend_from_slice(b" ID ");
|
||||
src.extend_from_slice(pixels);
|
||||
src.extend_from_slice(b"\nEI Q");
|
||||
parse(&src)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfiltered_inline_image_lengths_use_components_and_parameter_aliases() {
|
||||
for (parameters, pixels) in [
|
||||
(b"/W 4 /H 1 /CS /G /BPC 8".as_slice(), b" EI ".as_slice()),
|
||||
(b"/W 4 /H 1 /CS /G /BPC 8 /Filter null", b" EI "),
|
||||
(b"/W 4 /H 1 /CS /G /BPC 8 /F []", b" EI "),
|
||||
(b"/W 4 /Width 4 /H 1 /CS /G /BPC 8", b" EI "),
|
||||
(b"/Width 4 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b" EI "),
|
||||
(b"/W 2 /H 1 /CS /RGB /BPC 8", b"\0 EI \xff"),
|
||||
(b"/Width 2 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8", b"\0 EI \xff"),
|
||||
(b"/W 1 /H 1 /CS /CMYK /BPC 8", b" EI "),
|
||||
(b"/Width 1 /Height 1 /ColorSpace /DeviceCMYK /BitsPerComponent 8", b" EI "),
|
||||
] {
|
||||
let parsed = inline_pixels(parameters, pixels);
|
||||
assert_eq!(parsed.skipped, 0);
|
||||
assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::<Vec<_>>(), [b"q".as_slice(), b"BI", b"Q"]);
|
||||
assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, pixels);
|
||||
assert_eq!(parse(&serialize_ops(&parsed.ops)).ops[1].inline, parsed.ops[1].inline);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfiltered_inline_image_rows_are_byte_aligned_for_every_sample_depth() {
|
||||
// Two rows of two bytes each. Padding belongs to each row, not only the final row.
|
||||
for (bits, width) in [(1, 9), (2, 5), (4, 3), (8, 2), (16, 1)] {
|
||||
let parameters = format!("/W {width} /H 2 /BPC {bits} /CS /G");
|
||||
let parsed = inline_pixels(parameters.as_bytes(), b" EI ");
|
||||
assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b" EI ", "bits={bits}");
|
||||
assert_eq!(parsed.ops.len(), 3);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfiltered_inline_masks_default_to_one_bit_and_indexed_images_have_one_component() {
|
||||
for parameters in [b"/W 9 /H 2 /IM true".as_slice(), b"/Width 9 /Height 2 /ImageMask true /BitsPerComponent 1"] {
|
||||
let parsed = inline_pixels(parameters, b" EI ");
|
||||
assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b" EI ");
|
||||
assert_eq!(parsed.ops.len(), 3);
|
||||
}
|
||||
for name in ["Indexed", "I"] {
|
||||
// A valid 256-entry RGB lookup table; samples are indices regardless of the base space.
|
||||
let parameters = format!("/W 4 /H 1 /BPC 8 /CS [/{name} /DeviceRGB 255 <{}>]", "00".repeat(768));
|
||||
let parsed = inline_pixels(parameters.as_bytes(), b" EI ");
|
||||
assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b" EI ");
|
||||
assert_eq!(parsed.ops.len(), 3);
|
||||
// Indexed samples permit at most 8 bits, even though device spaces permit 16.
|
||||
let invalid = parameters.replace("/W 4", "/W 2").replace("/BPC 8", "/BPC 16");
|
||||
let parsed = inline_pixels(invalid.as_bytes(), b" EI ");
|
||||
assert!(parsed.ops[1].inline.as_ref().unwrap().1.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfiltered_inline_image_terminator_must_follow_its_samples() {
|
||||
for separator in [b" ".as_slice(), b"\r\n", b"\0\t\x0c\n"] {
|
||||
let mut src = b"BI /W 4 /H 1 /BPC 8 /CS /G ID EI ".to_vec();
|
||||
src.extend_from_slice(separator);
|
||||
src.extend_from_slice(b"EI/Q q");
|
||||
let parsed = parse(&src);
|
||||
assert_eq!(parsed.ops[0].inline.as_ref().unwrap().1, b" EI ");
|
||||
assert_eq!(&src[parsed.ops[0].span.clone()], &src[..src.len() - b"/Q q".len()]);
|
||||
}
|
||||
let parsed = parse(b"BI /W 4 /H 1 /BPC 8 /CS /G ID EI \nEI");
|
||||
assert_eq!(parsed.ops[0].inline.as_ref().unwrap().1, b" EI ");
|
||||
// EI is a keyword: a prefix of a larger token is not a terminator.
|
||||
let parsed = parse(b"BI /W 4 /H 1 /BPC 8 /CS /G ID abcd EIx\nEI Q");
|
||||
assert_eq!(parsed.ops[0].inline.as_ref().unwrap().1, b"abcd EIx");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filtered_unknown_and_damaged_inline_images_keep_the_existing_fallback() {
|
||||
for parameters in [
|
||||
b"/W 4 /H 1 /BPC 8 /CS /G /F /A85".as_slice(),
|
||||
b"/W 4 /H 1 /BPC 8 /CS /ResourceColor",
|
||||
b"/W 0 /H 1 /BPC 8 /CS /G",
|
||||
b"/W -1 /H 1 /BPC 8 /CS /G",
|
||||
b"/W 4 /H -1 /BPC 8 /CS /G",
|
||||
b"/W 4 /H 1 /BPC 3 /CS /G",
|
||||
b"/W 4 /H 1 /IM true /BPC 8",
|
||||
b"/W 4 /H 1 /IM true /BPC /Bad",
|
||||
b"/Width 4 /W 1 /H 1 /BPC 8 /CS /G",
|
||||
b"/W 4 /H 1 /BPC 8 /CS /G /ImageMask false /IM true",
|
||||
b"/W 9223372036854775807 /H 9223372036854775807 /BPC 16 /CS /CMYK",
|
||||
b"/W 9223372036854775807 /H 2 /BPC 8 /CS /G",
|
||||
] {
|
||||
let parsed = inline_pixels(parameters, b"! EI !!~>");
|
||||
assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b"!", "{}", String::from_utf8_lossy(parameters));
|
||||
}
|
||||
// A short payload still uses the tolerant recovery, including when there is no EI.
|
||||
for src in [b"BI /W 4 /H 1 /BPC 8 /CS /G ID abc\nEI Q".as_slice(), b"BI /W 4 /H 1 /BPC 8 /CS /G ID abc"] {
|
||||
assert_eq!(parse(src).ops[0].inline.as_ref().unwrap().1, b"abc");
|
||||
}
|
||||
}
|
||||
|
||||
fn inline_pdf(content: &[u8], object_streams: bool) -> Vec<u8> {
|
||||
use pdfcraft_cos::{Document, SaveOptions, Stream, write_full};
|
||||
|
||||
let mut doc = Document::new_empty();
|
||||
let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap();
|
||||
let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), content)));
|
||||
let page = doc.add(Object::Dict(
|
||||
[
|
||||
(b"Type".to_vec(), Object::name("Page")),
|
||||
(b"Parent".to_vec(), Object::Ref(pages)),
|
||||
(b"MediaBox".to_vec(), Object::Array([0, 0, 4, 1].map(Object::Int).to_vec())),
|
||||
(b"Resources".to_vec(), Object::Dict(Dict::new())),
|
||||
(b"Contents".to_vec(), Object::Ref(contents)),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
));
|
||||
doc.update_dict(pages, |dict| {
|
||||
dict.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)]));
|
||||
dict.set(b"Count".to_vec(), Object::Int(1));
|
||||
})
|
||||
.unwrap();
|
||||
write_full(&doc, &SaveOptions { object_streams, ..SaveOptions::default() }).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewritten_inline_image_pixels_survive_saving_and_reopening_a_pdf() {
|
||||
use pdfcraft_cos::{Document, Object};
|
||||
use std::sync::Arc;
|
||||
|
||||
let original = b"q 4 0 0 1 0 0 cm BI /W 4 /H 1 /BPC 8 /CS /G ID EI \nEI Q";
|
||||
let rewritten = serialize_ops(&parse(original).ops);
|
||||
for object_streams in [false, true] {
|
||||
let bytes = inline_pdf(&rewritten, object_streams);
|
||||
let doc = Document::open(Arc::new(bytes.clone())).unwrap();
|
||||
assert!(doc.repair_log().is_empty());
|
||||
let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap();
|
||||
let pages = doc.get(pages);
|
||||
let page = doc.resolve(&pages.as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap()[0]);
|
||||
let content = doc.resolve(page.as_dict().unwrap().get(b"Contents").unwrap());
|
||||
let Object::Stream(stream) = &*content else { panic!("page content stream") };
|
||||
assert_eq!(parse(&stream.decoded_strict().unwrap()).ops[2].inline.as_ref().unwrap().1, b" EI ");
|
||||
// Opt-in artifacts for independent readers/renderers; synthetic PDFs are never committed.
|
||||
if let Some(dir) = std::env::var_os("PDFCRAFT_INLINE_FIXTURE_DIR") {
|
||||
let dir = std::path::PathBuf::from(dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("original.pdf"), inline_pdf(original, false)).unwrap();
|
||||
std::fs::write(dir.join(if object_streams { "rewritten-packed.pdf" } else { "rewritten-classic.pdf" }), bytes).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damage_is_skipped_not_fatal() {
|
||||
let p = parse(b"1 0 0 RG ) ] 10 20 m 30 40 l S (unterminated");
|
||||
|
||||
@@ -14,6 +14,8 @@ pdfcraft-filters = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pdfcraft-oracle = { workspace = true }
|
||||
pdfcraft-testkit = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
hayro-syntax = { workspace = true }
|
||||
|
||||
@@ -53,7 +53,7 @@ another. Edits never touch the original bytes.
|
||||
headers. Repairs are *recorded*, never silent, so the UI can say the file was damaged.
|
||||
- **Tolerant, never fatal.** The lexer works on a byte slice and a position; it allocates nothing
|
||||
for skipped data, bounds-checks every index, and caps nesting depth. Malformed input yields
|
||||
`CosError`, never a panic — this crate is the first thing a hostile file meets.
|
||||
`CosError`, never a panic — this crate is the first thing a hostile file meets. Exhausted xref entries are skipped with a repair log; exhausted /Size hints allocate unused numbers without overflowing or replacing existing objects.
|
||||
- **Lazy.** Objects are parsed on first use and cached. (Whole-file buffering is still the case;
|
||||
`ByteSource` streaming is `core.lazy-loading`, not yet done.)
|
||||
- **Encryption** is handled on load and save through `pdfcraft-crypt`: every standard-security
|
||||
|
||||
+872
-53
File diff suppressed because it is too large
Load Diff
@@ -34,12 +34,14 @@ pub(crate) fn encrypt_dict(doc: &Document, d: &Dict) -> EncryptDict {
|
||||
}
|
||||
let v = int(b"V", 0);
|
||||
// V4: the key length comes from the standard crypt filter's /Length (bytes, or bits in
|
||||
// some files); /Length on the dictionary is often a stale 40.
|
||||
// some files); /Length on the dictionary is often a stale 40. A length that isn't
|
||||
// positive means nothing and reads as if absent.
|
||||
let cf_len = d
|
||||
.get(b"CF")
|
||||
.map(|c| doc.resolve(c))
|
||||
.and_then(|c| c.as_dict().and_then(|c| c.get(b"StdCF").map(|f| doc.resolve(f))))
|
||||
.and_then(|f| f.as_dict().and_then(|f| f.int(b"Length")));
|
||||
.and_then(|f| f.as_dict().and_then(|f| f.int(b"Length")))
|
||||
.filter(|n| *n > 0);
|
||||
let length_bits = match (v, cf_len) {
|
||||
(5.., _) => 256,
|
||||
(4, Some(n)) if n <= 32 => n * 8,
|
||||
|
||||
@@ -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() })
|
||||
}
|
||||
|
||||
@@ -202,24 +202,23 @@ fn unencrypted_metadata_is_left_readable() {
|
||||
let _ = ObjRef::new(1, 0);
|
||||
}
|
||||
|
||||
/// Second independent oracle when qpdf is installed: it must accept both passwords, identify
|
||||
/// the owner password, and find no structural errors.
|
||||
/// The second independent oracle: qpdf must accept both passwords, identify the owner password,
|
||||
/// and find no structural errors. For R2-R4 this is the *only* check of owner-password
|
||||
/// authentication (Algorithm 7) that does not go through our own code, so it is the one place a
|
||||
/// silent skip would cost the most -- see `pdfcraft-oracle`, which makes absence loud.
|
||||
#[test]
|
||||
fn qpdf_agrees() {
|
||||
if std::process::Command::new("qpdf").arg("--version").output().is_err() {
|
||||
eprintln!("qpdf not installed; skipping");
|
||||
return;
|
||||
}
|
||||
let qpdf = pdfcraft_oracle::or_skip!(pdfcraft_oracle::qpdf());
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-enc-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for alg in ALL {
|
||||
let path = dir.join(format!("{alg:?}.pdf"));
|
||||
std::fs::write(&path, protect(alg, "user", "owner", -1)).unwrap();
|
||||
for (pw, expect) in [("user", "user password"), ("owner", "owner password")] {
|
||||
let out = std::process::Command::new("qpdf").arg(format!("--password={pw}")).arg("--show-encryption").arg(&path).output().unwrap();
|
||||
let out = qpdf.command().arg(format!("--password={pw}")).arg("--show-encryption").arg(&path).output().unwrap();
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(text.contains(&format!("Supplied password is {expect}")), "{alg:?}/{pw}: {text}");
|
||||
let check = std::process::Command::new("qpdf").arg(format!("--password={pw}")).arg("--check").arg(&path).output().unwrap();
|
||||
let check = qpdf.command().arg(format!("--password={pw}")).arg("--check").arg(&path).output().unwrap();
|
||||
assert!(check.status.success(), "{alg:?}/{pw}: {}", String::from_utf8_lossy(&check.stdout));
|
||||
}
|
||||
}
|
||||
@@ -279,3 +278,61 @@ fn signature_contents_stay_unencrypted() {
|
||||
assert_eq!(string(2, b"Contents"), b"Private note", "{alg:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nonpositive_crypt_filter_length_reads_as_the_default_key_length() {
|
||||
// A V4 crypt filter's /Length counts bytes (≤ 32) or bits. A zero or negative value is
|
||||
// meaningless and is read as if absent (128 bits), not multiplied into an overflow (#1816).
|
||||
let original = protect(Algorithm::Aes128, "", "owner", -1);
|
||||
for length in [i64::MIN, i64::MIN / 8 - 1, i64::MIN / 8, -1, 0, 16, 128] {
|
||||
let mut doc = Document::open(Arc::new(original.clone())).unwrap();
|
||||
let encrypt = doc.trailer().reference(b"Encrypt").unwrap();
|
||||
doc.update_dict(encrypt, |e| {
|
||||
let Some(Object::Dict(cf)) = e.get(b"CF") else { panic!("no /CF") };
|
||||
let Some(Object::Dict(std_cf)) = cf.get(b"StdCF") else { panic!("no /StdCF") };
|
||||
let (mut cf, mut std_cf) = (cf.clone(), std_cf.clone());
|
||||
std_cf.set(b"Length".to_vec(), Object::Int(length));
|
||||
cf.set(b"StdCF".to_vec(), Object::Dict(std_cf));
|
||||
e.set(b"CF".to_vec(), Object::Dict(cf));
|
||||
})
|
||||
.unwrap();
|
||||
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
|
||||
let again = Document::open(Arc::new(saved)).unwrap();
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
+83
-39
@@ -4,34 +4,23 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full, write_incremental};
|
||||
use pdfcraft_testkit::Pdf;
|
||||
|
||||
/// Four pages, a shared font, document info with a non-ASCII title.
|
||||
fn fixture() -> Vec<u8> {
|
||||
let mut objs: Vec<String> = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()];
|
||||
let kids: Vec<String> = (0..4).map(|i| format!("{} 0 R", 4 + 2 * i)).collect();
|
||||
objs.push(format!("<< /Type /Pages /Kids [{}] /Count 4 /MediaBox [0 0 200 300] >>", kids.join(" ")));
|
||||
objs.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into());
|
||||
let mut pdf = Pdf::new()
|
||||
.version("1.4")
|
||||
.obj("<< /Type /Catalog /Pages 2 0 R >>")
|
||||
.obj(format!("<< /Type /Pages /Kids [{}] /Count 4 /MediaBox [0 0 200 300] >>", kids.join(" ")))
|
||||
.obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>");
|
||||
for i in 0..4 {
|
||||
objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + 2 * i));
|
||||
let body = format!("BT /F1 24 Tf 20 150 Td (Page {}) Tj ET", i + 1);
|
||||
objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()));
|
||||
pdf = pdf
|
||||
.obj(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + 2 * i))
|
||||
.stream("", format!("BT /F1 24 Tf 20 150 Td (Page {}) Tj ET", i + 1));
|
||||
}
|
||||
objs.push("<< /Title (Caf\\351 \\(draft\\)) >>".into());
|
||||
let mut out = b"%PDF-1.4\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(
|
||||
format!("trailer\n<< /Size {} /Root 1 0 R /Info {} 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1, objs.len()).as_bytes(),
|
||||
);
|
||||
out
|
||||
let info = pdf.next_obj();
|
||||
pdf.obj("<< /Title (Caf\\351 \\(draft\\)) >>").trailer_entry(&format!("/Info {info} 0 R")).build()
|
||||
}
|
||||
|
||||
fn title(doc: &Document) -> Vec<u8> {
|
||||
@@ -159,18 +148,14 @@ fn classic_table() -> Vec<u8> {
|
||||
bytes
|
||||
}
|
||||
|
||||
fn qpdf_check(bytes: &[u8]) -> Option<std::process::Output> {
|
||||
let Ok(version) = std::process::Command::new("qpdf").arg("--version").output() else {
|
||||
return None;
|
||||
};
|
||||
assert!(version.status.success());
|
||||
fn qpdf_check(qpdf: &pdfcraft_oracle::Oracle, bytes: &[u8]) -> std::process::Output {
|
||||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let path = std::env::temp_dir().join(format!("pdfcraft-xref-{}-{n}.pdf", std::process::id()));
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
let check = std::process::Command::new("qpdf").arg("--check").arg(&path).output().unwrap();
|
||||
let check = qpdf.command().arg("--check").arg(&path).output().unwrap();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
Some(check)
|
||||
check
|
||||
}
|
||||
|
||||
fn page(doc: &Document) -> Dict {
|
||||
@@ -181,10 +166,8 @@ fn page(doc: &Document) -> Dict {
|
||||
}
|
||||
|
||||
fn assert_qpdf_clean(bytes: &[u8]) {
|
||||
let Some(check) = qpdf_check(bytes) else {
|
||||
eprintln!("qpdf not installed: skipped");
|
||||
return;
|
||||
};
|
||||
let qpdf = pdfcraft_oracle::or_skip!(pdfcraft_oracle::qpdf());
|
||||
let check = qpdf_check(&qpdf, bytes);
|
||||
let report = format!("{}{}", String::from_utf8_lossy(&check.stdout), String::from_utf8_lossy(&check.stderr));
|
||||
assert!(check.status.success() && !report.contains("WARNING"), "{report}");
|
||||
}
|
||||
@@ -247,16 +230,12 @@ fn incremental_save_keeps_a_trailer_only_edit() {
|
||||
|
||||
#[test]
|
||||
fn qpdf_accepts_object_stream_output() {
|
||||
let Ok(out) = std::process::Command::new("qpdf").arg("--version").output() else {
|
||||
eprintln!("qpdf not installed: skipped");
|
||||
return;
|
||||
};
|
||||
assert!(out.status.success());
|
||||
let qpdf = pdfcraft_oracle::or_skip!(pdfcraft_oracle::qpdf());
|
||||
let doc = Document::open(Arc::new(fixture())).unwrap();
|
||||
let packed = write_full(&doc, &SaveOptions::default()).unwrap();
|
||||
let path = std::env::temp_dir().join(format!("pdfcraft-objstm-{}.pdf", std::process::id()));
|
||||
std::fs::write(&path, &packed).unwrap();
|
||||
let check = std::process::Command::new("qpdf").arg("--check").arg(&path).output().unwrap();
|
||||
let check = qpdf.command().arg("--check").arg(&path).output().unwrap();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let report = String::from_utf8_lossy(&check.stdout);
|
||||
assert!(check.status.success() && report.contains("No syntax or stream encoding errors"), "{report}");
|
||||
@@ -348,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"));
|
||||
}
|
||||
|
||||
@@ -179,8 +179,9 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec<u8>), String>
|
||||
Space::Gray => rgb.extend_from_slice(&[v(0); 3]),
|
||||
Space::Rgb => rgb.extend_from_slice(&[v(0), v(1), v(2)]),
|
||||
Space::Cmyk => rgb.extend_from_slice(&cmyk(v(0), v(1), v(2), v(3))),
|
||||
Space::Indexed(base, table) => {
|
||||
let i = px.first().copied().unwrap_or(0) as usize;
|
||||
Space::Indexed(base, hival, table) => {
|
||||
// Indexed samples outside the declared range use the nearest endpoint (ISO 32000-1, 8.6.6.3).
|
||||
let i = px.first().copied().unwrap_or(0).min(*hival) as usize;
|
||||
let k = base.components();
|
||||
let e = table.get(i * k..i * k + k).unwrap_or(&[0, 0, 0, 0][..k]);
|
||||
rgb.extend_from_slice(&match **base {
|
||||
@@ -310,8 +311,8 @@ enum Space {
|
||||
Gray,
|
||||
Rgb,
|
||||
Cmyk,
|
||||
/// Base space and lookup table (one byte per base component).
|
||||
Indexed(Box<Space>, Vec<u8>),
|
||||
/// Base space, highest valid palette index, and lookup table.
|
||||
Indexed(Box<Space>, u8, Vec<u8>),
|
||||
}
|
||||
|
||||
impl Space {
|
||||
@@ -352,12 +353,16 @@ fn space(doc: &Document, cs: Option<&Object>) -> Result<Space, String> {
|
||||
if matches!(base, Space::Indexed(..)) {
|
||||
return Err("nested indexed colour spaces aren't valid".into());
|
||||
}
|
||||
let hival = match a.get(2).map(|value| doc.resolve(value)).as_deref() {
|
||||
Some(Object::Int(value)) if (0..=255).contains(value) => *value as u8,
|
||||
_ => return Err("an indexed colour space with an invalid hival".into()),
|
||||
};
|
||||
let table = match a.get(3).map(|t| doc.resolve(t)).as_deref() {
|
||||
Some(Object::String(s)) => s.bytes.clone(),
|
||||
Some(Object::Stream(t)) => t.decoded().map_err(|e| e.to_string())?,
|
||||
_ => return Err("an indexed colour space without a lookup table".into()),
|
||||
};
|
||||
Ok(Space::Indexed(Box::new(base), table))
|
||||
Ok(Space::Indexed(Box::new(base), hival, table))
|
||||
}
|
||||
Some(other) => unsupported(other),
|
||||
None => Err("a damaged colour space".into()),
|
||||
|
||||
@@ -744,6 +744,137 @@ fn damaged_masks_and_sizes_never_panic() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_image_export_clamps_samples_to_hival() {
|
||||
for palette in [vec![255, 0, 0, 0, 0, 255], vec![255, 0, 0, 0, 0, 255, 0, 255, 0, 255, 255, 255]] {
|
||||
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
|
||||
let page = pdfcraft_model::pages(&doc)[0].clone();
|
||||
let res = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap();
|
||||
let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap();
|
||||
let image = xo.iter().next().unwrap().1.as_ref().unwrap();
|
||||
let mut dict = Dict::new();
|
||||
dict.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
dict.set(b"Subtype".to_vec(), Object::name("Image"));
|
||||
dict.set(b"Width".to_vec(), Object::Int(4));
|
||||
dict.set(b"Height".to_vec(), Object::Int(1));
|
||||
dict.set(b"BitsPerComponent".to_vec(), Object::Int(2));
|
||||
dict.set(
|
||||
b"ColorSpace".to_vec(),
|
||||
Object::Array(vec![Object::name("Indexed"), Object::name("DeviceRGB"), Object::Int(1), Object::String(PdfString::literal(palette))]),
|
||||
);
|
||||
// Default Decode preserves sample values0,1,2,3. Values2 and3 clamp to hival1.
|
||||
doc.set(image, Object::Stream(Stream::from_raw(dict, vec![0b0001_1011])));
|
||||
let out = extract_images(&doc, &[0], 0);
|
||||
assert!(out.skipped.is_empty(), "{:?}", out.skipped);
|
||||
assert_eq!(out.images.len(), 1);
|
||||
assert_eq!(exported_png_pixels(&out.images[0].data), vec![255, 0, 0, 0, 0, 255, 0, 0, 255, 0, 0, 255]);
|
||||
}
|
||||
}
|
||||
|
||||
fn exported_png_pixels(bytes: &[u8]) -> Vec<u8> {
|
||||
let decoder = png::Decoder::new(std::io::Cursor::new(bytes));
|
||||
let mut reader = decoder.read_info().unwrap();
|
||||
let mut pixels = vec![0; reader.output_buffer_size().unwrap()];
|
||||
let info = reader.next_frame(&mut pixels).unwrap();
|
||||
pixels.truncate(info.buffer_size());
|
||||
pixels
|
||||
}
|
||||
|
||||
fn indexed_export_fixture(
|
||||
hival: Object,
|
||||
palette: Vec<u8>,
|
||||
stream_table: bool,
|
||||
indirect_hival: bool,
|
||||
bpc: i64,
|
||||
samples: Vec<u8>,
|
||||
) -> (Document, pdfcraft_cos::ObjRef) {
|
||||
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
|
||||
let page = pdfcraft_model::pages(&doc)[0].clone();
|
||||
let res = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap();
|
||||
let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap();
|
||||
let image = xo.iter().next().unwrap().1.as_ref().unwrap();
|
||||
let table = if stream_table {
|
||||
Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &palette))))
|
||||
} else {
|
||||
Object::String(PdfString::literal(palette))
|
||||
};
|
||||
let hival = if indirect_hival { Object::Ref(doc.add(hival)) } else { hival };
|
||||
let mut dict = Dict::new();
|
||||
dict.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
dict.set(b"Subtype".to_vec(), Object::name("Image"));
|
||||
dict.set(b"Width".to_vec(), Object::Int(4));
|
||||
dict.set(b"Height".to_vec(), Object::Int(1));
|
||||
dict.set(b"BitsPerComponent".to_vec(), Object::Int(bpc));
|
||||
dict.set(b"ColorSpace".to_vec(), Object::Array(vec![Object::name("Indexed"), Object::name("DeviceRGB"), hival, table]));
|
||||
doc.set(image, Object::Stream(Stream::from_raw(dict, samples)));
|
||||
(doc, image)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_hival_resolves_indirect_values_and_stream_palettes() {
|
||||
for (bpc, samples) in [(2, vec![0x1b]), (4, vec![0x01, 0x23]), (8, vec![0, 1, 2, 255])] {
|
||||
let (doc, image) = indexed_export_fixture(Object::Int(1), vec![255, 0, 0, 0, 0, 255], true, true, bpc, samples);
|
||||
let (ext, bytes) = crate::extract::image_file(&doc, image).unwrap();
|
||||
assert_eq!(ext, "png");
|
||||
assert_eq!(exported_png_pixels(&bytes), vec![255, 0, 0, 0, 0, 255, 0, 0, 255, 0, 0, 255]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_hival_boundary_values_keep_valid_palette_entries() {
|
||||
let palette: Vec<u8> = (0..=255u8).flat_map(|i| [i, 255 - i, 17]).collect();
|
||||
for high in [0, 255] {
|
||||
let (doc, image) = indexed_export_fixture(Object::Int(high), palette.clone(), false, false, 8, vec![0, 1, 254, 255]);
|
||||
let (_, bytes) = crate::extract::image_file(&doc, image).unwrap();
|
||||
let expected: Vec<u8> = [0u8, 1, 254, 255]
|
||||
.into_iter()
|
||||
.flat_map(|i| {
|
||||
let i = i.min(high as u8);
|
||||
[i, 255 - i, 17]
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(exported_png_pixels(&bytes), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_hival_rejects_invalid_values_without_panicking() {
|
||||
for high in [Object::Int(-1), Object::Int(256), Object::Real(1.0), Object::Null, Object::name("bad")] {
|
||||
let (doc, image) = indexed_export_fixture(high, vec![255, 0, 0], false, false, 8, vec![0; 4]);
|
||||
assert!(crate::extract::image_file(&doc, image).unwrap_err().contains("invalid hival"));
|
||||
let out = extract_images(&doc, &[0], 0);
|
||||
assert!(out.images.is_empty());
|
||||
assert_eq!(out.skipped.len(), 1);
|
||||
assert!(out.skipped[0].2.contains("invalid hival"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_hival_clamping_preserves_raw_colour_key_mask_samples() {
|
||||
let (mut doc, image) = indexed_export_fixture(Object::Int(1), vec![255, 0, 0, 0, 0, 255], false, false, 2, vec![0x1b]);
|
||||
let Object::Stream(mut stream) = (*doc.get(image)).clone() else { panic!("image stream") };
|
||||
stream.dict.set(b"Mask".to_vec(), Object::Array(vec![Object::Int(2), Object::Int(2)]));
|
||||
doc.set(image, Object::Stream(stream));
|
||||
let (_, bytes) = crate::extract::image_file(&doc, image).unwrap();
|
||||
assert_eq!(exported_png_pixels(&bytes), vec![255, 0, 0, 255, 0, 0, 255, 255, 0, 0, 255, 0, 0, 0, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_hival_missing_element_returns_export_error() {
|
||||
let (mut doc, image) = indexed_export_fixture(Object::Int(1), vec![255, 0, 0], false, false, 8, vec![0; 4]);
|
||||
let Object::Stream(mut stream) = (*doc.get(image)).clone() else { panic!("image stream") };
|
||||
stream.dict.set(b"ColorSpace".to_vec(), Object::Array(vec![Object::name("Indexed"), Object::name("DeviceRGB")]));
|
||||
doc.set(image, Object::Stream(stream));
|
||||
assert!(crate::extract::image_file(&doc, image).unwrap_err().contains("invalid hival"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_hival_keeps_existing_short_palette_fallback() {
|
||||
let (doc, image) = indexed_export_fixture(Object::Int(1), vec![255, 0, 0], false, false, 2, vec![0x1b]);
|
||||
let (_, bytes) = crate::extract::image_file(&doc, image).unwrap();
|
||||
assert_eq!(exported_png_pixels(&bytes), vec![255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
}
|
||||
|
||||
/// An 8×8 baseline JPEG of one flat colour with four components, stored as `samples`, and an
|
||||
/// Adobe APP14 segment with `transform` (0: CMYK, 2: YCCK).
|
||||
fn flat_four_component_jpeg(samples: [u8; 4], transform: u8) -> Vec<u8> {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
+74
-19
@@ -11,10 +11,12 @@
|
||||
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, n, page_list, place_tagged};
|
||||
use crate::{EditError, MarkKind, check, contents, matrix_number, n, page_list, place_tagged};
|
||||
|
||||
const TAG: &str = "Added";
|
||||
|
||||
@@ -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 => {
|
||||
@@ -467,6 +501,11 @@ pub fn text_rect(t: &AddedText) -> [f64; 4] {
|
||||
[r[0], r[3] - h, r[2], r[3]]
|
||||
}
|
||||
|
||||
/// The widest explicit line before wrapping, measured with the PDF writing fonts.
|
||||
pub fn text_width(t: &AddedText) -> f64 {
|
||||
t.text.split('\n').map(|line| shaped_width(t, line, unicode_bidi::get_base_direction(line) == unicode_bidi::Direction::Rtl)).fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
fn font_name(base: &str) -> String {
|
||||
format!("PCF{}", base.replace('-', ""))
|
||||
}
|
||||
@@ -479,7 +518,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 +563,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 +703,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,11 +738,21 @@ 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!("q {} {} {} {} {} {} cm\n", n(view[0]), n(view[1]), n(view[2]), n(view[3]), n(view[4]), n(view[5])).into_bytes();
|
||||
let mut out = format!(
|
||||
"q {} {} {} {} {} {} cm\n",
|
||||
matrix_number(view[0]),
|
||||
matrix_number(view[1]),
|
||||
matrix_number(view[2]),
|
||||
matrix_number(view[3]),
|
||||
matrix_number(view[4]),
|
||||
matrix_number(view[5])
|
||||
)
|
||||
.into_bytes();
|
||||
match c {
|
||||
Content::Text(t) => {
|
||||
let base = t.family.base_font(t.bold, t.italic);
|
||||
@@ -715,7 +766,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,
|
||||
@@ -420,6 +424,19 @@ fn exclusive_clip(ops: &[Op], op: usize) -> Option<(usize, Matrix)> {
|
||||
Some((at, Matrix(ops.get(at + 3)?.nums::<6>()?)))
|
||||
}
|
||||
|
||||
/// The `q cm … Q` whose only content after its `cm` is `start..=end` (a `Do`, or a balanced
|
||||
/// `q … Q` scope), with the index and value of that `cm`. Another transform of the same
|
||||
/// placement is folded into it, so repeated moves and resizes don't nest one more `q` each.
|
||||
fn enclosing_transform(ops: &[Op], start: usize, end: usize) -> Option<(usize, Matrix)> {
|
||||
let at = start.checked_sub(1)?;
|
||||
let cm = ops.get(at)?;
|
||||
if !ops.get(at.checked_sub(1)?)?.is("q") || !ops.get(end.checked_add(1)?)?.is("Q") || !cm.is("cm") || cm.operands.len() != 6 {
|
||||
return None;
|
||||
}
|
||||
let m = Matrix(cm.nums::<6>()?);
|
||||
m.0.iter().all(|v| v.is_finite()).then_some((at, m))
|
||||
}
|
||||
|
||||
/// The `cm` to put before the clip so that user-space transform `t` also moves the clip:
|
||||
/// the clip is drawn in the CTM before the image's own `cm` (`cm` · that = `draw`).
|
||||
fn clip_matrix(draw: Matrix, cm: Matrix, t: Matrix) -> Option<Matrix> {
|
||||
@@ -444,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(
|
||||
@@ -603,6 +623,20 @@ fn transform_image(
|
||||
if !local.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
|
||||
return Err(EditError::Invalid("the image transform is too large".into()));
|
||||
}
|
||||
// Already alone in a `q cm … Q` (its own placement, or the wrapper an earlier edit added):
|
||||
// change that `cm` in place. The CTM inside is `local · cm · outer` either way.
|
||||
if let Some((at, cm)) = enclosing_transform(&ops, start, end) {
|
||||
let folded = local.then(&cm);
|
||||
if !folded.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
|
||||
return Err(EditError::Invalid("the image transform is too large".into()));
|
||||
}
|
||||
let changed = crate::text::splice(&pieces, &ops, |index| {
|
||||
if index == at { (vec![Op::new("cm", folded.0.map(Object::Real).to_vec())], 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))?;
|
||||
return Ok(());
|
||||
}
|
||||
let changed = crate::text::splice(&pieces, &ops, |index| {
|
||||
if index == start {
|
||||
let mut out = vec![Op::new("q", vec![]), Op::new("cm", local.0.map(Object::Real).to_vec())];
|
||||
@@ -625,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> {
|
||||
@@ -672,10 +786,25 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize
|
||||
plans.remove(&(stream, index));
|
||||
clips.insert((stream, at), c);
|
||||
}
|
||||
// An image already alone in `q cm Do Q` moves by changing that `cm`, not by nesting another.
|
||||
let mut folds = std::collections::BTreeMap::new();
|
||||
for index in 0..ops.len() {
|
||||
let Some(&(movement, _, _)) = plans.get(&(stream, index)) else { continue };
|
||||
let Some((at, cm)) = enclosing_transform(&ops, index, index) else { continue };
|
||||
let folded = movement.then(&cm);
|
||||
if !folded.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) {
|
||||
return Err(EditError::Invalid("the image move is too large".into()));
|
||||
}
|
||||
plans.remove(&(stream, index));
|
||||
folds.insert(at, folded);
|
||||
}
|
||||
let changed = crate::text::splice(&pieces, &ops, |index| {
|
||||
if let Some(c) = clips.remove(&(stream, index)) {
|
||||
return (vec![Op::new("cm", c.0.map(Object::Real).to_vec())], true);
|
||||
}
|
||||
if let Some(m) = folds.remove(&index) {
|
||||
return (vec![Op::new("cm", m.0.map(Object::Real).to_vec())], false);
|
||||
}
|
||||
let Some((matrix, _, _)) = plans.remove(&(stream, index)) else { return (Vec::new(), true) };
|
||||
let original = ops.get(index).cloned().into_iter();
|
||||
// Local translations can be tiny under a large CTM. Preserve their precision
|
||||
|
||||
+51
-23
@@ -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 {
|
||||
@@ -172,6 +194,13 @@ pub struct Context {
|
||||
pub date: (i64, u32, u32),
|
||||
}
|
||||
|
||||
// Model-derived transforms can contain tiny scale factors; retain their PDF decimal precision.
|
||||
fn matrix_number(value: f64) -> String {
|
||||
let mut bytes = Vec::new();
|
||||
pdfcraft_cos::serialize(&Object::Real(value), &mut bytes);
|
||||
String::from_utf8_lossy(&bytes).into_owned()
|
||||
}
|
||||
|
||||
fn n(v: f64) -> String {
|
||||
let s = format!("{:.3}", if v.abs() < 5e-4 { 0.0 } else { v });
|
||||
let s = s.trim_end_matches('0').trim_end_matches('.');
|
||||
@@ -365,27 +394,17 @@ fn begin(kind: MarkKind, subtype: &str, matrix: [f64; 6]) -> String {
|
||||
format!(
|
||||
"q\n/Artifact <</Type /Pagination /Subtype /{subtype} /PCMark /{}>> BDC\n{} {} {} {} {} {} cm\n",
|
||||
kind.tag(),
|
||||
n(matrix[0]),
|
||||
n(matrix[1]),
|
||||
n(matrix[2]),
|
||||
n(matrix[3]),
|
||||
n(matrix[4]),
|
||||
n(matrix[5])
|
||||
matrix_number(matrix[0]),
|
||||
matrix_number(matrix[1]),
|
||||
matrix_number(matrix[2]),
|
||||
matrix_number(matrix[3]),
|
||||
matrix_number(matrix[4]),
|
||||
matrix_number(matrix[5])
|
||||
)
|
||||
}
|
||||
|
||||
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))
|
||||
@@ -411,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()));
|
||||
}
|
||||
@@ -431,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();
|
||||
@@ -491,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()
|
||||
@@ -626,6 +653,7 @@ pub mod images;
|
||||
pub use images::{ImageChange, PageImage, change_image, page_images, reading_images, rect_to_rect, turn_about_centre};
|
||||
pub mod bidi;
|
||||
pub mod indic;
|
||||
mod linebreak;
|
||||
pub mod text;
|
||||
pub use text::{
|
||||
BlockStyle, LineEdit, RangeStyle, TextBlock, TextLine, clear_block, reading_blocks, replace_block, replace_line, rewrite_block, text_blocks,
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Where a row of rewrapped text may end besides a space: between two characters when either is
|
||||
//! written without spaces between words (Chinese and Japanese ideographs and kana), following the
|
||||
//! simple cases of Unicode line breaking (UAX #14, class ID). The row must not then begin with a
|
||||
//! character that closes or follows (closing brackets and quotes, commas and full stops, small
|
||||
//! kana, the prolonged sound mark, iteration marks) or end with one that opens (opening brackets
|
||||
//! and quotes, currency signs before an amount): the line-start and line-end prohibitions of
|
||||
//! JIS X 4051 ("kinsoku"), which UAX #14 gives as classes CL, CP, EX, IS, NS and OP, PR.
|
||||
//!
|
||||
//! Korean is written with spaces between words and keeps breaking only there; so does Thai, whose
|
||||
//! words need a dictionary to find.
|
||||
|
||||
/// Writing with no spaces between words, where a row may end between two characters (UAX #14 ID).
|
||||
fn ideographic(c: char) -> bool {
|
||||
matches!(c,
|
||||
'\u{2e80}'..='\u{2fdf}' // CJK radicals, Kangxi radicals
|
||||
| '\u{3000}'..='\u{303f}' // CJK symbols and punctuation
|
||||
| '\u{3040}'..='\u{30ff}' // hiragana, katakana
|
||||
| '\u{3190}'..='\u{31ff}' // kanbun, CJK strokes, katakana phonetic extensions
|
||||
| '\u{3400}'..='\u{4dbf}' // CJK extension A
|
||||
| '\u{4e00}'..='\u{9fff}' // CJK unified ideographs
|
||||
| '\u{f900}'..='\u{faff}' // CJK compatibility ideographs
|
||||
| '\u{fe30}'..='\u{fe4f}' // CJK compatibility forms
|
||||
| '\u{ff00}'..='\u{ffef}' // half-width and full-width forms
|
||||
| '\u{20000}'..='\u{3fffd}' // supplementary ideographic planes
|
||||
)
|
||||
}
|
||||
|
||||
/// Characters that must not begin a row: closing brackets and quotes, and the punctuation, small
|
||||
/// kana and marks that belong to the character before them (UAX #14 CL, CP, EX, IS, NS). The
|
||||
/// ideographic space breaks after itself, not before (BA): it is Japanese spacing and indentation.
|
||||
fn no_start(c: char) -> bool {
|
||||
matches!(c,
|
||||
')' | ']' | '}' | '»' | '’' | '”' | '!' | '?' | ',' | '.' | ':' | ';' | '%' | '‰' | '°' | '℃'
|
||||
| '‐' | '–' | '〜' | '‼' | '⁇' | '⁈' | '⁉' | '・' | '\u{3000}'
|
||||
| '、' | '。' | '〃' | '〆' | '々' | '〻' | '〕' | '〉' | '》' | '」' | '』' | '】' | '〙' | '〗' | '〟' | '〞'
|
||||
| 'ぁ' | 'ぃ' | 'ぅ' | 'ぇ' | 'ぉ' | 'っ' | 'ゃ' | 'ゅ' | 'ょ' | 'ゎ' | 'ゕ' | 'ゖ'
|
||||
| 'ァ' | 'ィ' | 'ゥ' | 'ェ' | 'ォ' | 'ッ' | 'ャ' | 'ュ' | 'ョ' | 'ヮ' | 'ヵ' | 'ヶ'
|
||||
| '゛' | '゜' | 'ゝ' | 'ゞ' | '゠' | 'ー' | 'ヽ' | 'ヾ'
|
||||
| '\u{31f0}'..='\u{31ff}' // small katakana for Ainu
|
||||
| '!' | '%' | ')' | ',' | '.' | ':' | ';' | '?' | ']' | '}' | '⦆' | '~'
|
||||
| '。' | '」' | '、' | '・' | 'ー' | '゙' | '゚'
|
||||
| '\u{ff67}'..='\u{ff6f}' // small half-width katakana
|
||||
)
|
||||
}
|
||||
|
||||
/// Characters that must not end a row: opening brackets and quotes, and currency signs written
|
||||
/// before an amount (UAX #14 OP, PR).
|
||||
fn no_end(c: char) -> bool {
|
||||
matches!(
|
||||
c,
|
||||
'(' | '['
|
||||
| '{'
|
||||
| '«'
|
||||
| '‘'
|
||||
| '“'
|
||||
| '$'
|
||||
| '£'
|
||||
| '¥'
|
||||
| '€'
|
||||
| '〈'
|
||||
| '《'
|
||||
| '「'
|
||||
| '『'
|
||||
| '【'
|
||||
| '〔'
|
||||
| '〖'
|
||||
| '〘'
|
||||
| '〝'
|
||||
| '('
|
||||
| '['
|
||||
| '{'
|
||||
| '⦅'
|
||||
| '「'
|
||||
| '$'
|
||||
| '£'
|
||||
| '¥'
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether a row may end between `before` and `after`, neither of them a space.
|
||||
pub(crate) fn between(before: char, after: char) -> bool {
|
||||
(ideographic(before) || ideographic(after)) && !no_start(after) && !no_end(before)
|
||||
}
|
||||
|
||||
/// `word` (text without spaces) cut where a row may end inside it; the pieces join with nothing.
|
||||
pub(crate) fn pieces(word: &str) -> Vec<&str> {
|
||||
let mut out = Vec::new();
|
||||
let mut start = 0;
|
||||
let mut prev: Option<char> = None;
|
||||
for (at, c) in word.char_indices() {
|
||||
if let Some(before) = prev
|
||||
&& between(before, c)
|
||||
{
|
||||
out.extend(word.get(start..at));
|
||||
start = at;
|
||||
}
|
||||
prev = Some(c);
|
||||
}
|
||||
out.extend(word.get(start..).filter(|rest| !rest.is_empty()));
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ideographs_and_kana_break_between_characters_except_around_kinsoku_marks() {
|
||||
assert_eq!(pieces("日本語の文章"), ["日", "本", "語", "の", "文", "章"]);
|
||||
// Closing punctuation and small kana stay with the character before them; an opening
|
||||
// bracket stays with the one after it.
|
||||
assert_eq!(pieces("ちょっと、「はい」。"), ["ちょっ", "と、", "「は", "い」。"]);
|
||||
assert_eq!(pieces("コーヒー"), ["コー", "ヒー"]);
|
||||
assert_eq!(pieces("(例)です"), ["(例)", "で", "す"]);
|
||||
// Latin text inside a Japanese sentence breaks before and after it, never inside it.
|
||||
assert_eq!(pieces("PDFを編集"), ["PDF", "を", "編", "集"]);
|
||||
assert_eq!(pieces("価格は¥100です"), ["価", "格", "は", "¥100", "で", "す"]);
|
||||
// Words of space-separated writing are never cut.
|
||||
assert_eq!(pieces("wrapping"), ["wrapping"]);
|
||||
assert_eq!(pieces("한국어"), ["한국어"]);
|
||||
assert_eq!(pieces(""), Vec::<&str>::new());
|
||||
assert!(!between('a', 'b') && between('字', 'a') && !between('字', '。') && !between('「', '字'));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
@@ -1346,6 +1559,28 @@ fn independent_text_objects_with_different_transforms_stay_separate() {
|
||||
assert_eq!(text::text_lines(&doc, 0).unwrap().len(), 2);
|
||||
}
|
||||
|
||||
/// A negative font size or `Tz` runs the glyphs backwards along the text matrix: each word's
|
||||
/// `ink` box still spans all its glyphs, not only the first.
|
||||
#[test]
|
||||
fn word_boxes_span_whole_words_when_glyphs_run_backwards() {
|
||||
// "someone@example.com" in Helvetica 11 is 121.814 pt wide.
|
||||
for content in [
|
||||
"BT /F1 -11 Tf -1 0 0 1 300 742 Tm (Mail the team: someone@example.com) Tj ET",
|
||||
"BT /F1 11 Tf -100 Tz 300 742 Td (Mail the team: someone@example.com) Tj ET",
|
||||
] {
|
||||
let lines = text::text_lines(&text_page(content), 0).unwrap();
|
||||
assert_eq!(lines.len(), 1, "{content}");
|
||||
let ink = &lines[0].ink;
|
||||
assert_eq!(ink.len(), 4, "{content}: {ink:?}");
|
||||
let width = |r: &[f64; 4]| r[2] - r[0];
|
||||
assert!(ink.iter().all(|r| width(r) > 5.0), "{content}: {ink:?}");
|
||||
let address = ink.iter().map(width).fold(0.0, f64::max);
|
||||
assert!((address - 121.814).abs() < 0.01, "{content}: {ink:?}");
|
||||
let (x0, x1) = ink.iter().fold((f64::MAX, f64::MIN), |(a, b), r| (a.min(r[0]), b.max(r[2])));
|
||||
assert!(x0 >= lines[0].rect[0] - 0.01 && x1 <= lines[0].rect[2] + 0.01, "{content}: {ink:?} {:?}", lines[0].rect);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_lines_are_found_and_replaced_in_place() {
|
||||
let mut doc =
|
||||
@@ -1630,6 +1865,61 @@ fn close(a: [f64; 4], b: [f64; 4]) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6)
|
||||
}
|
||||
|
||||
/// The deepest `q` nesting on the page.
|
||||
fn q_depth(doc: &Document) -> usize {
|
||||
let mut depth = 0_usize;
|
||||
let mut deepest = 0;
|
||||
for op in pdfcraft_content::parse(&page_content_bytes(doc, 0)).ops {
|
||||
if op.is("q") {
|
||||
depth += 1;
|
||||
deepest = deepest.max(depth);
|
||||
} else if op.is("Q") {
|
||||
depth = depth.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
deepest
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_image_edits_change_one_transform_instead_of_nesting() {
|
||||
let variants = [
|
||||
// Its own placement, a private crop, and a bare `Do` that the first edit wraps.
|
||||
"q 200 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Caption) Tj ET",
|
||||
"q 100 100 200 100 re W* n 200 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Caption) Tj ET",
|
||||
"200 0 0 100 100 100 cm /Im0 Do",
|
||||
];
|
||||
for content in variants {
|
||||
let mut doc = image_page_with(content);
|
||||
let resize = |doc: &mut Document, n: usize| -> usize {
|
||||
let mut depth = 0;
|
||||
for i in 0..n {
|
||||
let r = images::page_images(doc, 0).unwrap()[0].visible_rect.unwrap();
|
||||
// Grow by 3% about the lower-left corner and move right by 2, staying on the page.
|
||||
let target = [r[0] + 2.0, r[1], r[0] + 2.0 + (r[2] - r[0]) * 1.03, r[1] + (r[3] - r[1]) * 1.03];
|
||||
images::change_image(doc, 0, 0, &images::ImageChange::Transform(images::rect_to_rect(r, target))).unwrap();
|
||||
let after = images::page_images(doc, 0).unwrap()[0].visible_rect.unwrap();
|
||||
assert!(after.iter().zip(target).all(|(a, b)| (a - b).abs() < 1e-6), "{content}: {after:?} vs {target:?}");
|
||||
if i == 0 {
|
||||
depth = q_depth(doc);
|
||||
}
|
||||
}
|
||||
depth
|
||||
};
|
||||
let first = resize(&mut doc, 20);
|
||||
assert_eq!(q_depth(&doc), first, "{content}: twenty resizes nest no deeper than one");
|
||||
let mut doc = reopen(&doc);
|
||||
let before = images::page_images(&doc, 0).unwrap()[0].rect;
|
||||
for _ in 0..20 {
|
||||
images::translate_images(&mut doc, 0, &[0], [1.0, -2.0]).unwrap();
|
||||
}
|
||||
let after = images::page_images(&doc, 0).unwrap()[0].rect;
|
||||
assert!(close(after, [before[0] + 20.0, before[1] - 40.0, before[2] + 20.0, before[3] - 40.0]), "{content}: {after:?}");
|
||||
assert_eq!(q_depth(&doc), first, "{content}: group moves change the same transform");
|
||||
let caption: Vec<_> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect();
|
||||
assert_eq!(caption.is_empty(), !content.contains("Caption"), "{content}: the text after the image is untouched");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_images_move_turn_replace_and_delete() {
|
||||
let mut doc = image_page();
|
||||
@@ -2116,6 +2406,33 @@ fn hebrew_edits_are_drawn_right_to_left_and_keep_their_right_edge() {
|
||||
assert!((line.rect[0] - before[0]).abs() < 0.5, "left-aligned: {:?} vs {before:?}", line.rect);
|
||||
}
|
||||
|
||||
/// A generated Type 3 font numbers its glyphs from 1, so code 32 is a character, not the space:
|
||||
/// justified rows are stretched after each space, never with word spacing.
|
||||
#[test]
|
||||
fn justified_text_in_the_type3_fallback_stretches_its_spaces() {
|
||||
let src = "BT /F1 12 Tf 14 TL 100 700 Td (Some words to fill a row of text) Tj T* (and another one below it) Tj T* (end) Tj ET";
|
||||
let mut doc = text_page(src);
|
||||
let rect = text::text_blocks(&doc, 0).unwrap()[0].rect;
|
||||
// More than 32 different characters, so one of them gets code 32.
|
||||
let typed = "日本語の 文章を 書きます 東京 大阪 京都 名古屋 札幌 福岡 神戸 横浜 仙台 広島 千葉 新潟 静岡 熊本 岡山 金沢";
|
||||
let style = text::BlockStyle { align: Some(added::Align::Justify), ..Default::default() };
|
||||
let rewritten = text::rewrite_block(&mut doc, 0, 0, Some(typed), &style);
|
||||
if pdfcraft_fonts::document_japanese_font().is_none() {
|
||||
assert!(rewritten.is_err());
|
||||
eprintln!("built without a craft-fonts Japanese face: the Type 3 path is checked with CRAFT_FONTS_DIR");
|
||||
return;
|
||||
}
|
||||
rewritten.unwrap();
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
assert!(lines.len() > 2, "{lines:?}");
|
||||
for l in &lines[..lines.len() - 1] {
|
||||
assert!((l.rect[2] - rect[2]).abs() < 1.0, "{:?} spans {:?}, not {rect:?}", l.text, l.rect);
|
||||
}
|
||||
let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops;
|
||||
assert!(ops.iter().any(|o| o.is("TJ")), "{ops:?}");
|
||||
assert!(ops.iter().filter(|o| o.is("Tw")).all(|o| o.operands.first().and_then(|n| n.as_f64()) == Some(0.0)), "{ops:?}");
|
||||
}
|
||||
|
||||
/// Letters the page's font lacks are drawn with the Hebrew face from craft-fonts; a build
|
||||
/// without it says so and changes nothing. Hebrew points are refused, not misdrawn.
|
||||
#[test]
|
||||
@@ -2162,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]
|
||||
@@ -2680,6 +3028,55 @@ fn group_moves_keep_split_stream_tokens_and_shared_form_resources() {
|
||||
assert_eq!(page_content_bytes(&doc, 1), source_page);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn justified_text_in_a_composite_font_fills_each_row_with_adjustments_after_its_spaces() {
|
||||
// Two-byte codes, so word spacing (single-byte code 32 only) can't stretch the spaces.
|
||||
let hex = |s: &str| s.bytes().map(|b| format!("00{b:02x}")).collect::<String>();
|
||||
let src = format!(
|
||||
"BT /F1 10 Tf 12 TL 100 700 Td <{}> Tj T* <{}> Tj T* <{}> Tj ET",
|
||||
hex("One two three four five six seven"),
|
||||
hex("eight nine ten eleven twelve"),
|
||||
hex("and then the end of it")
|
||||
);
|
||||
let mut doc = text_page(&src);
|
||||
let unicode = doc.add(Object::Stream(Stream::flate(
|
||||
Dict::new(),
|
||||
b"1 begincodespacerange <0000> <ffff> endcodespacerange 1 beginbfrange <0020> <007e> <0020> endbfrange",
|
||||
)));
|
||||
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("SyntheticCID")),
|
||||
(b"Encoding".to_vec(), Object::name("Identity-H")),
|
||||
(b"ToUnicode".to_vec(), Object::Ref(unicode)),
|
||||
(
|
||||
b"DescendantFonts".to_vec(),
|
||||
Object::Array(vec![Object::Dict(Dict::from_iter([
|
||||
(b"Subtype".to_vec(), Object::name("CIDFontType2")),
|
||||
(b"DW".to_vec(), Object::Int(500)),
|
||||
]))]),
|
||||
),
|
||||
]);
|
||||
let p = pdfcraft_model::pages(&doc)[0].obj;
|
||||
let resources = Dict::from_iter([(b"Font".to_vec(), Object::Dict(Dict::from_iter([(b"F1".to_vec(), Object::Dict(font))])))]);
|
||||
doc.update_dict(p, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap();
|
||||
let before = text::text_blocks(&doc, 0).unwrap()[0].clone();
|
||||
let right = before.rect[2];
|
||||
text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { align: Some(added::Align::Justify), ..Default::default() }).unwrap();
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
assert!(lines.len() > 2, "{lines:?}");
|
||||
for l in &lines[..lines.len() - 1] {
|
||||
assert!((l.rect[2] - right).abs() < 1.0, "{:?} ends at {} not {right}", l.text, l.rect[2]);
|
||||
}
|
||||
// The source font shows the text, with its spaces, and the text reads as before, rewrapped.
|
||||
let flat = |s: &str| s.replace('\n', " ");
|
||||
assert_eq!(flat(&text::text_blocks(&doc, 0).unwrap()[0].editor_text), flat(&before.editor_text));
|
||||
let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops;
|
||||
assert!(ops.iter().all(|o| !o.is("Tf") || o.operands.first().and_then(|n| n.as_name()) == Some(b"F1".as_slice())), "{ops:?}");
|
||||
assert!(ops.iter().any(|o| o.is("TJ")), "{ops:?}");
|
||||
assert!(ops.iter().filter(|o| o.is("Tw")).all(|o| o.operands.first().and_then(|n| n.as_f64()) == Some(0.0)), "{ops:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_moves_preserve_encoded_glyphs_and_refuse_vertical_cmaps() {
|
||||
let mut doc = text_page("BT /F1 12 Tf 40 400 Td <004100420043> Tj ET");
|
||||
@@ -3034,6 +3431,30 @@ fn mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns() {
|
||||
assert_eq!(blocks[2].text, "Other column");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_row_with_mixed_styles_grows_before_wrapping() {
|
||||
let mut doc = text_page("BT /F1 10 Tf 72 582 Td (Plain ) Tj /F2 10 Tf (bold) Tj /F1 10 Tf ( text.) Tj ET");
|
||||
doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |f| {
|
||||
*f = Dict::new();
|
||||
f.set(b"Type".to_vec(), Object::name("Font"));
|
||||
f.set(b"Subtype".to_vec(), Object::name("Type1"));
|
||||
f.set(b"BaseFont".to_vec(), Object::name("Helvetica-Bold"));
|
||||
})
|
||||
.unwrap();
|
||||
let blocks = text::text_blocks(&doc, 0).unwrap();
|
||||
let index = blocks.iter().position(|b| b.editor_text == "Plain bold text.").unwrap();
|
||||
let source = &blocks[index];
|
||||
assert_eq!(source.rows.len(), 1);
|
||||
assert!(source.lines.len() > 1);
|
||||
let draft = "Plain bold text. More words on the same line";
|
||||
text::replace_block(&mut doc, 0, index, draft).unwrap();
|
||||
let edited = text::text_blocks(&reopen(&doc), 0).unwrap();
|
||||
let block = edited.iter().find(|b| b.text == draft).unwrap();
|
||||
assert_eq!(block.rows.len(), 1, "a style boundary must not pin a single row to its original width");
|
||||
assert!((block.rect[0] - source.rect[0]).abs() < 0.1);
|
||||
assert!(block.rect[2] > source.rect[2] + 20.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_editor_rows_survive_single_run_edit_and_reopen() {
|
||||
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Original heading) Tj ET");
|
||||
@@ -3083,6 +3504,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();
|
||||
@@ -3420,6 +3862,104 @@ fn subset_indic_font_without_legacy_name_keeps_logical_text() {
|
||||
assert_eq!(lines[0].text, "\u{092C}\u{0922}\u{093C}\u{093F}\u{092F}\u{093E}");
|
||||
}
|
||||
|
||||
/// Lines of a paragraph drawn turned on the page (a landscape page drawn a quarter turn round)
|
||||
/// are separate rows, and adding a word keeps them apart (#1443).
|
||||
#[test]
|
||||
fn turned_paragraph_keeps_its_line_breaks_when_a_word_is_added() {
|
||||
for cm in ["0 1 -1 0 600 0", "0 -1 1 0 0 800", "-1 0 0 -1 600 800"] {
|
||||
let original =
|
||||
text_page(&format!("q {cm} cm BT /F1 12 Tf 72 500 Td (First line here) Tj 0 -14 Td (Second line) Tj 0 -14 Td (Third) Tj ET Q"));
|
||||
let block = text::text_blocks(&original, 0).unwrap().remove(0);
|
||||
assert_eq!(block.editor_text, "First line here\nSecond line\nThird", "{cm}");
|
||||
let mut doc = original.clone();
|
||||
text::rewrite_block(&mut doc, 0, 0, Some("First line here\nSecond line\nThird added"), &text::BlockStyle::default()).unwrap();
|
||||
let reopened = reopen(&doc);
|
||||
assert_eq!(text::text_blocks(&reopened, 0).unwrap()[0].editor_text, "First line here\nSecond line\nThird added", "{cm}");
|
||||
let (before, after) = (text::text_lines(&original, 0).unwrap(), text::text_lines(&reopened, 0).unwrap());
|
||||
assert_eq!(after.len(), 3, "{cm}");
|
||||
for (a, b) in after.iter().zip(&before).take(2) {
|
||||
assert!(close(a.rect, b.rect), "{cm}: untouched rows stay put: {:?} {:?}", a.rect, b.rect);
|
||||
}
|
||||
// The last row grows along its own baseline from where it started.
|
||||
let (grown, old) = (after[2].rect, before[2].rect);
|
||||
assert!((0..2).all(|i| grown[i] <= old[i] + 0.01) && (2..4).all(|i| grown[i] >= old[i] - 0.01), "{cm}: {grown:?} {old:?}");
|
||||
assert!((after[2].origin_baseline() - before[2].origin_baseline()).abs() < 0.01, "{cm}");
|
||||
}
|
||||
// A turned single line grows rather than wrapping across the page's other axis.
|
||||
let mut doc = text_page("BT /F1 12 Tf 0 1 -1 0 300 300 Tm (Only line) Tj ET");
|
||||
text::rewrite_block(&mut doc, 0, 0, Some("Only line with several more words added"), &text::BlockStyle::default()).unwrap();
|
||||
let blocks = text::text_blocks(&reopen(&doc), 0).unwrap();
|
||||
assert_eq!(blocks[0].editor_text, "Only line with several more words added");
|
||||
// Formatting part of turned text isn't laid out yet: refused, not drawn along the page's x axis.
|
||||
let mut doc = text_page("BT /F1 12 Tf 0 1 -1 0 300 300 Tm (Only line) Tj ET");
|
||||
let range = text::RangeStyle {
|
||||
range: 0..4,
|
||||
family: None,
|
||||
resource: None,
|
||||
size: Some(20.0),
|
||||
color: None,
|
||||
underline: None,
|
||||
char_spacing: None,
|
||||
scale: None,
|
||||
};
|
||||
let style = text::BlockStyle { ranges: vec![range], ..Default::default() };
|
||||
assert!(text::rewrite_block(&mut doc, 0, 0, Some("Only line"), &style).is_err());
|
||||
}
|
||||
|
||||
/// A paragraph in a Chrome-style subset font: `/W` leaves out the glyphs of the `/DW` width.
|
||||
/// Adding a word keeps every row as it was and shows the word in the page's own font (#1443).
|
||||
#[test]
|
||||
fn default_width_subset_paragraph_keeps_its_rows_when_a_word_is_added() {
|
||||
let hex = |s: &str| s.chars().map(|c| format!("00{:02X}", u32::from(c))).collect::<String>();
|
||||
let rows = ["This is the first line", "the second", "and third"];
|
||||
let content = rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, row)| format!("BT /F3 12 Tf 1 0 0 1 72 {} Tm <{}> Tj ET", 700 - 14 * i, hex(row)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let mut doc = text_page(&content);
|
||||
let cmap = b"/CIDInit /ProcSet findresource begin 1 begincodespacerange <0000> <FFFF> endcodespacerange \
|
||||
1 beginbfrange <0020> <007E> <0020> endbfrange end";
|
||||
let to_unicode = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec())));
|
||||
let mut cid = Dict::new();
|
||||
cid.set(b"Type".to_vec(), Object::name("Font"));
|
||||
cid.set(b"Subtype".to_vec(), Object::name("CIDFontType2"));
|
||||
cid.set(b"BaseFont".to_vec(), Object::name("AAAAAA+LiberationSans"));
|
||||
cid.set(b"CIDToGIDMap".to_vec(), Object::name("Identity"));
|
||||
cid.set(b"DW".to_vec(), Object::Int(500));
|
||||
cid.set(
|
||||
b"W".to_vec(),
|
||||
Object::Array(vec![Object::Int(32), Object::Array(vec![Object::Int(278)]), Object::Int(84), Object::Array(vec![Object::Int(611)])]),
|
||||
);
|
||||
let mut font = Dict::new();
|
||||
font.set(b"Type".to_vec(), Object::name("Font"));
|
||||
font.set(b"Subtype".to_vec(), Object::name("Type0"));
|
||||
font.set(b"BaseFont".to_vec(), Object::name("AAAAAA+LiberationSans"));
|
||||
font.set(b"Encoding".to_vec(), Object::name("Identity-H"));
|
||||
font.set(b"DescendantFonts".to_vec(), Object::Array(vec![Object::Dict(cid)]));
|
||||
font.set(b"ToUnicode".to_vec(), Object::Ref(to_unicode));
|
||||
let font = doc.add(Object::Dict(font));
|
||||
let page = pdfcraft_model::pages(&doc)[0].clone();
|
||||
let mut resources = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone();
|
||||
let mut fonts = doc.resolve(resources.get(b"Font").unwrap()).as_dict().unwrap().clone();
|
||||
fonts.set(b"F3".to_vec(), Object::Ref(font));
|
||||
resources.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap();
|
||||
let block = text::text_blocks(&doc, 0).unwrap().remove(0);
|
||||
assert_eq!(block.editor_text, rows.join("\n"));
|
||||
let before = text::text_lines(&doc, 0).unwrap();
|
||||
let edit = text::rewrite_block(&mut doc, 0, 0, Some(&format!("{} added", block.editor_text)), &text::BlockStyle::default()).unwrap();
|
||||
assert_eq!(edit.substituted, None, "the subset shows every letter of the new word");
|
||||
let reopened = reopen(&doc);
|
||||
assert_eq!(text::text_blocks(&reopened, 0).unwrap()[0].editor_text, "This is the first line\nthe second\nand third added");
|
||||
let after = text::text_lines(&reopened, 0).unwrap();
|
||||
for (a, b) in after.iter().zip(&before).take(2) {
|
||||
assert_eq!(a.text, b.text);
|
||||
assert!(close(a.rect, b.rect), "untouched rows keep their place and width: {:?} {:?}", a.rect, b.rect);
|
||||
}
|
||||
}
|
||||
|
||||
/// An added item is written out as a specific standard-14 face (`/Times-Italic`, `/Helvetica-Bold`
|
||||
/// …), so it has to be *measured* with that same face. Measuring the upright face while writing
|
||||
/// the italic one mis-sizes every box: Times-Italic `A` is 611 against Times-Roman's 722.
|
||||
@@ -3443,3 +3983,269 @@ fn an_items_measured_face_is_the_face_it_is_written_as() {
|
||||
let courier = |italic| Family::Courier.width("Anna", 12.0, false, italic);
|
||||
assert_eq!(courier(false), courier(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_marks_and_added_content_preserve_fractional_user_unit_matrices() {
|
||||
for rotation in [0, 90] {
|
||||
let mut doc = fixture();
|
||||
let page = pdfcraft_model::pages(&doc)[2].obj;
|
||||
doc.update_dict(page, |d| {
|
||||
let bounds = Object::Array([0.0, 0.0, 0.1, 0.1].into_iter().map(Object::Real).collect());
|
||||
d.set(b"MediaBox".to_vec(), bounds.clone());
|
||||
d.set(b"CropBox".to_vec(), bounds);
|
||||
d.set(b"UserUnit".to_vec(), Object::Int(4096));
|
||||
d.set(b"Rotate".to_vec(), Object::Int(rotation));
|
||||
})
|
||||
.unwrap();
|
||||
let mut hf = HeaderFooter::default();
|
||||
hf.text[0] = "Matrix header".into();
|
||||
add_header_footer(&mut doc, &[2], &hf, false, &cx()).unwrap();
|
||||
let text = AddedText { rect: [72.0, 100.0, 200.0, 144.0], text: "Matrix text".into(), size: 12.0, ..AddedText::default() };
|
||||
add_content(&mut doc, 2, &Content::Text(text)).unwrap();
|
||||
let saved = reopen(&doc);
|
||||
let contents = streams(&saved, 2);
|
||||
for marker in ["/PCMark /HeaderFooter", "(Matrix text) Tj"] {
|
||||
let matching: Vec<_> = contents.iter().filter(|s| s.contains(marker)).collect();
|
||||
assert_eq!(matching.len(), 1, "{contents:?}");
|
||||
let stream = matching[0];
|
||||
let parsed = pdfcraft_content::parse(stream.as_bytes());
|
||||
assert_eq!(parsed.skipped, 0);
|
||||
let cm = parsed.ops.iter().find(|op| op.is("cm")).unwrap();
|
||||
assert_eq!(cm.operands.len(), 6);
|
||||
assert!(!stream[cm.span.clone()].contains('e') && !stream[cm.span.clone()].contains('E'), "PDF numbers cannot use exponents");
|
||||
let matrix = cm.nums::<6>().unwrap();
|
||||
let expected = if rotation == 0 {
|
||||
[0.000244140625, 0.0, 0.0, 0.000244140625, 0.0, 0.0]
|
||||
} else {
|
||||
[0.0, 0.000244140625, -0.000244140625, 0.0, 0.1, 0.0]
|
||||
};
|
||||
assert_eq!(matrix, expected, "rotation={rotation}, stream={stream}");
|
||||
// A fixed physical display point must remain at its intended raw page position.
|
||||
let raw = [matrix[0] * 72.0 + matrix[2] * 144.0 + matrix[4], matrix[1] * 72.0 + matrix[3] * 144.0 + matrix[5]];
|
||||
let expected_raw = if rotation == 0 { [0.017578125, 0.03515625] } else { [0.06484375, 0.017578125] };
|
||||
for (actual, expected) in raw.into_iter().zip(expected_raw) {
|
||||
assert!((actual - expected).abs() < 1e-12);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_the_edit_does_not_reach_keep_their_source_operators() {
|
||||
// Word gaps in TJ, as justified text from a layout program has; the text state alone
|
||||
// can't reproduce them, so a rewritten row would lose them.
|
||||
let rows = [
|
||||
"[(justified) -400 ( text) -400 ( uses) -400 ( wide) -400 ( gaps)] TJ",
|
||||
"[(between) -400 ( every) -400 ( single) -400 ( word) -400 ( here)] TJ",
|
||||
"(last line.) Tj",
|
||||
];
|
||||
let content = format!("BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm {} T* {} T* {} ET", rows[0], rows[1], rows[2]);
|
||||
let row_texts = |doc: &Document| -> Vec<(String, [f64; 2])> {
|
||||
text::text_lines(doc, 0).unwrap().into_iter().map(|l| (l.text.clone(), l.baseline())).collect()
|
||||
};
|
||||
let source = row_texts(&text_page(&content));
|
||||
assert_eq!(source.len(), 3);
|
||||
for (row, from, to) in [(2, "last line.", "last line!"), (0, "justified", "justify"), (1, "single", "lone")] {
|
||||
let mut doc = text_page(&content);
|
||||
let block = text::text_blocks(&doc, 0).unwrap().into_iter().position(|b| b.text.contains("justified")).unwrap();
|
||||
let draft = text::text_blocks(&doc, 0).unwrap()[block].editor_text.replacen(from, to, 1);
|
||||
text::rewrite_block(&mut doc, 0, block, Some(&draft), &text::BlockStyle::default()).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let bytes = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
|
||||
let after = row_texts(&doc);
|
||||
assert_eq!(after.len(), 3, "{to}: {after:?}");
|
||||
for (i, ((text, at), (old, was))) in after.iter().zip(&source).enumerate() {
|
||||
assert!((at[0] - was[0]).abs() < 0.01 && (at[1] - was[1]).abs() < 0.01, "{to}: row {i} stays at {was:?}, not {at:?}");
|
||||
if i == row {
|
||||
assert_eq!(*text, old.replacen(from, to, 1), "{to}: the edited row");
|
||||
assert!(!bytes.contains(rows[i]), "{to}: the edited row is rewritten");
|
||||
} else {
|
||||
assert_eq!(text, old, "{to}: row {i}");
|
||||
assert_eq!(bytes.matches(rows[i]).count(), 1, "{to}: row {i} keeps its own operator, kerning included:\n{bytes}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// Colouring a selection in the last row leaves the rows above it as they were.
|
||||
let mut doc = text_page(&content);
|
||||
let block = text::text_blocks(&doc, 0).unwrap().into_iter().position(|b| b.text.contains("justified")).unwrap();
|
||||
let draft = text::text_blocks(&doc, 0).unwrap()[block].editor_text.clone();
|
||||
let at = draft.find("line").unwrap();
|
||||
let range = text::RangeStyle {
|
||||
range: at..at + 4,
|
||||
family: None,
|
||||
resource: None,
|
||||
size: None,
|
||||
color: Some([1.0, 0.0, 0.0]),
|
||||
underline: None,
|
||||
char_spacing: None,
|
||||
scale: None,
|
||||
};
|
||||
text::rewrite_block(&mut doc, 0, block, Some(&draft), &text::BlockStyle { ranges: vec![range], ..Default::default() }).unwrap();
|
||||
let bytes = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
|
||||
assert!(rows[..2].iter().all(|row| bytes.matches(row).count() == 1) && !bytes.contains(rows[2]), "{bytes}");
|
||||
assert!(bytes.contains("1 0 0 rg"), "{bytes}");
|
||||
// The coloured word is its own run now, so only the rows above compare whole.
|
||||
assert_eq!(row_texts(&reopen(&doc))[..2], source[..2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_rows_stay_in_place_when_rows_wrap_or_move_with_quote_operators() {
|
||||
let lines_of = |doc: &Document| -> Vec<(String, [f64; 2])> {
|
||||
text::text_lines(doc, 0).unwrap().into_iter().map(|l| (l.text.clone(), l.baseline())).collect()
|
||||
};
|
||||
let edit = |content: &str, from: &str, to: &str| {
|
||||
let mut doc = text_page(content);
|
||||
let block = text::text_blocks(&doc, 0).unwrap().into_iter().position(|b| b.editor_text.contains(from)).unwrap();
|
||||
let draft = text::text_blocks(&doc, 0).unwrap()[block].editor_text.replacen(from, to, 1);
|
||||
text::rewrite_block(&mut doc, 0, block, Some(&draft), &text::BlockStyle::default()).unwrap();
|
||||
reopen(&doc)
|
||||
};
|
||||
// `'` moves to the next line as it shows: a row after a rewritten `'` row can't stay as it is.
|
||||
for (quoted, from, to) in [
|
||||
("BT /F1 12 Tf 14 TL 72 700 Td (first row here) Tj (second row here) ' (third row here) ' ET", "second", "2nd"),
|
||||
("BT /F1 12 Tf 14 TL 72 700 Td (first row here) Tj (second row here) ' (third row here) ' ET", "third", "3rd"),
|
||||
// Here the first row's own `'` makes the step down that the rows after it build on.
|
||||
("BT /F1 12 Tf 14 TL 72 714 Td (first row here) ' (second row here) ' (third row here) ' ET", "first", "1st"),
|
||||
] {
|
||||
let source = lines_of(&text_page(quoted));
|
||||
let after = lines_of(&edit(quoted, from, to));
|
||||
assert_eq!(after.len(), 3, "{to}: {after:?}");
|
||||
for ((text, at), (old, was)) in after.iter().zip(&source) {
|
||||
assert_eq!(*text, old.replacen(from, to, 1));
|
||||
assert!((at[0] - was[0]).abs() < 0.01 && (at[1] - was[1]).abs() < 0.01, "{to}: {text} stays at {was:?}, not {at:?}");
|
||||
}
|
||||
}
|
||||
// A row that grows past the paragraph's width wraps and pushes the rows below it down;
|
||||
// those are rewritten in their new places, never left behind as well.
|
||||
let rows = "BT /F1 12 Tf 14.4 TL 72 700 Td (one two three four) Tj T* (five six seven eight) Tj T* (nine ten eleven twelve) Tj ET";
|
||||
let doc = edit(rows, "two", "two and a great many more words");
|
||||
let text: String = lines_of(&doc).into_iter().map(|(t, _)| t + " ").collect();
|
||||
for word in ["one", "five", "nine", "twelve"] {
|
||||
assert_eq!(text.matches(word).count(), 1, "{word} appears once: {text}");
|
||||
}
|
||||
assert!(lines_of(&doc).len() > 3, "the edited row wrapped: {text}");
|
||||
}
|
||||
|
||||
/// A page whose `/F1` is Inter embedded as a simple WinAnsi TrueType font, under `base_font`.
|
||||
fn embedded_program_page(content: &str, base_font: &str) -> Document {
|
||||
let mut doc = text_page(content);
|
||||
let program = doc.add(Object::Stream(Stream::flate(Dict::new(), include_bytes!("../../../assets/fonts/Inter-Regular.ttf"))));
|
||||
let mut descriptor = Dict::new();
|
||||
descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor"));
|
||||
descriptor.set(b"FontName".to_vec(), Object::name(base_font));
|
||||
descriptor.set(b"FontFile2".to_vec(), Object::Ref(program));
|
||||
let mut font = Dict::new();
|
||||
font.set(b"Type".to_vec(), Object::name("Font"));
|
||||
font.set(b"Subtype".to_vec(), Object::name("TrueType"));
|
||||
font.set(b"BaseFont".to_vec(), Object::name(base_font));
|
||||
font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
|
||||
font.set(b"FirstChar".to_vec(), Object::Int(32));
|
||||
font.set(b"LastChar".to_vec(), Object::Int(126));
|
||||
font.set(b"Widths".to_vec(), Object::Array((32..=126).map(|_| Object::Int(560)).collect()));
|
||||
font.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor));
|
||||
doc.set(pdfcraft_cos::ObjRef::new(5, 0), Object::Dict(font));
|
||||
reopen(&doc)
|
||||
}
|
||||
|
||||
/// The font programs and subtypes of the page's font resources.
|
||||
fn page_font_programs(doc: &Document) -> Vec<(Vec<u8>, Option<Vec<u8>>)> {
|
||||
let page = pdfcraft_model::pages(doc).remove(0);
|
||||
let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
|
||||
let fonts = resources.get(b"Font").and_then(|f| doc.resolve(f).as_dict().cloned()).unwrap_or_default();
|
||||
fonts
|
||||
.iter()
|
||||
.filter_map(|(_, f)| doc.resolve(f).as_dict().cloned())
|
||||
.map(|f| (f.name(b"Subtype").unwrap_or_default().to_vec(), pdfcraft_fonts::pdf::embedded_sfnt(doc, &f)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_character_comes_from_the_source_fonts_own_embedded_program() {
|
||||
let inter = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");
|
||||
for (content, from, to) in [
|
||||
// One row (the single-style path) and a row of a two-row paragraph (the row editor's path).
|
||||
("BT /F1 12 Tf 72 700 Td (Stable text) Tj ET", "text", "tőxt"),
|
||||
("BT /F1 12 Tf 14 TL 72 700 Td (First row) Tj T* (Second row) Tj ET", "Second", "Sőcond"),
|
||||
] {
|
||||
let mut doc = embedded_program_page(content, "Inter-Regular");
|
||||
let block = text::text_blocks(&doc, 0).unwrap()[0].clone();
|
||||
let draft = block.editor_text.replacen(from, to, 1);
|
||||
let edit = text::rewrite_block(&mut doc, 0, 0, Some(&draft), &text::BlockStyle::default()).unwrap();
|
||||
assert_eq!(edit.substituted, None, "{to}: no other family stands in");
|
||||
let doc = reopen(&doc);
|
||||
// The new character is its own run in the other resource, read back into the same word.
|
||||
assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, draft, "{to}");
|
||||
let fonts = page_font_programs(&doc);
|
||||
assert!(fonts.iter().all(|(subtype, _)| subtype != b"Type3"), "{to}: no fallback glyphs");
|
||||
assert!(
|
||||
fonts.iter().any(|(subtype, program)| subtype == b"Type0" && program.as_deref() == Some(inter.as_slice())),
|
||||
"{to}: the source program draws it"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_subset_program_is_not_trusted_for_new_characters_and_the_error_names_the_character() {
|
||||
let mut doc = embedded_program_page("BT /F1 12 Tf 72 700 Td (Stable text) Tj ET", "ABCDEF+Inter-Regular");
|
||||
let before = page_content_bytes(&doc, 0);
|
||||
let result = text::rewrite_block(&mut doc, 0, 0, Some("Stable tőxt"), &text::BlockStyle::default());
|
||||
if pdfcraft_fonts::document_japanese_font().is_some() {
|
||||
eprintln!("built with craft-fonts: the fallback font draws the new character");
|
||||
assert!(result.is_ok(), "{result:?}");
|
||||
return;
|
||||
}
|
||||
let Err(EditError::Invalid(msg)) = result else { panic!("expected a clear error, got {result:?}") };
|
||||
assert!(msg.contains("ő (U+0151)") && msg.contains("Load font") && !msg.contains("Japanese"), "{msg}");
|
||||
assert_eq!(page_content_bytes(&doc, 0), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_layout_matches_the_paragraph_an_applied_edit_draws() {
|
||||
let content = "BT /F1 12 Tf 14 TL 72 700 Td (A first paragraph of words) Tj T* (with a second row) Tj ET \
|
||||
BT /F1 12 Tf 72 600 Td (Another paragraph below) Tj ET BT /F1 12 Tf 320 700 Td (Side note) Tj ET";
|
||||
let doc = text_page(content);
|
||||
let block = text::text_blocks(&doc, 0).unwrap().into_iter().position(|b| b.text.starts_with("A first")).unwrap();
|
||||
let source = text::text_blocks(&doc, 0).unwrap()[block].clone();
|
||||
for draft in [
|
||||
source.editor_text.replacen("words", "many more words than before", 1),
|
||||
source.editor_text.replacen(" of words", "", 1),
|
||||
format!("{}\nand a third row", source.editor_text),
|
||||
] {
|
||||
let layout = text::text_edit_layout(&doc, 0, block, &draft, &text::BlockStyle::default()).unwrap();
|
||||
// Apply the same edit and measure every run that isn't one of the page's other runs.
|
||||
let mut applied = doc.clone();
|
||||
text::rewrite_block(&mut applied, 0, block, Some(&draft), &text::BlockStyle::default()).unwrap();
|
||||
let others: Vec<_> =
|
||||
text::text_lines(&doc, 0).unwrap().into_iter().enumerate().filter(|(i, _)| !source.lines.contains(i)).map(|(_, l)| l.rect).collect();
|
||||
let drawn = text::text_lines(&applied, 0)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|l| l.rect)
|
||||
.filter(|r| !others.contains(r))
|
||||
.reduce(|a, b| [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])]);
|
||||
assert_eq!(layout.draft_rect, drawn, "{draft:?}");
|
||||
assert_eq!(layout.source_rect, source.rect);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn japanese_paragraphs_wrap_between_characters_without_closing_marks_at_a_row_start() {
|
||||
if pdfcraft_fonts::document_japanese_font().is_none() {
|
||||
eprintln!("skipping the Japanese wrapping checks: built without craft-fonts (set CRAFT_FONTS_DIR)");
|
||||
return;
|
||||
}
|
||||
let text = "日本語の文章は単語の間に空白がないので、どの文字の間でも行を折り返せます。ただし「句読点」や小さい「っ」は行頭に来ません。";
|
||||
// One paragraph (the single-style path), and a heading row before it (the row editor's path).
|
||||
for draft in [text.to_string(), format!("見出し\n{text}")] {
|
||||
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Placeholder text) Tj ET");
|
||||
text::rewrite_block(&mut doc, 0, 0, Some(&draft), &text::BlockStyle { width: Some(120.0), ..Default::default() }).unwrap();
|
||||
let block = text::text_blocks(&reopen(&doc), 0).unwrap().remove(0);
|
||||
let rows: Vec<&str> = block.editor_text.split('\n').collect();
|
||||
assert!(rows.len() > 4, "{rows:?}");
|
||||
assert_eq!(rows.concat(), draft.replace('\n', ""), "no space is added between the pieces");
|
||||
assert!(block.rect[2] - block.rect[0] <= 120.5, "{:?} {rows:?}", block.rect);
|
||||
for row in &rows {
|
||||
assert!(!row.starts_with(['、', '。', '」', 'っ']) && !row.ends_with('「'), "{row:?} in {rows:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+445
-68
@@ -31,6 +31,10 @@ pub struct TextLine {
|
||||
pub text: String,
|
||||
/// Its box in user space.
|
||||
pub rect: [f64; 4],
|
||||
/// The boxes of the words it draws, in user space: from each word's first glyph to the end of
|
||||
/// its last, without the spaces and `TJ` adjustments around it. `rect` also spans gaps that
|
||||
/// draw nothing, such as the adjustment an applied redaction leaves where text was removed.
|
||||
pub ink: Vec<[f64; 4]>,
|
||||
/// The font's resource name and `/BaseFont` (or Type 3 descriptor's `/FontName`), and its size in text space.
|
||||
pub font: String,
|
||||
pub base_font: String,
|
||||
@@ -140,7 +144,10 @@ pub struct TextEditLayout {
|
||||
}
|
||||
|
||||
pub fn text_edit_layout(doc: &Document, page: usize, block: usize, text: &str, style: &BlockStyle) -> Result<TextEditLayout, EditError> {
|
||||
let original = text_blocks(doc, page)?;
|
||||
// This runs on every keystroke: read the page once for the paragraphs, the untouched runs
|
||||
// and the rewrite, which starts from the same unchanged page.
|
||||
let lines = text_lines(doc, page)?;
|
||||
let original = group_blocks(&lines);
|
||||
let source = original.get(block).ok_or(EditError::Invalid("text block does not exist".into()))?;
|
||||
if text == source.editor_text && *style == BlockStyle::default() {
|
||||
return Ok(TextEditLayout { source_rect: source.rect, draft_rect: Some(source.rect), overflow: false });
|
||||
@@ -150,13 +157,13 @@ pub fn text_edit_layout(doc: &Document, page: usize, block: usize, text: &str, s
|
||||
let key =
|
||||
|line: &TextLine| (line.text.clone(), line.font.clone(), line.rect.map(f64::to_bits), line.size.to_bits(), line.color.map(f64::to_bits));
|
||||
let mut untouched = std::collections::BTreeMap::new();
|
||||
for (index, line) in text_lines(doc, page)?.iter().enumerate() {
|
||||
for (index, line) in lines.iter().enumerate() {
|
||||
if !source.lines.contains(&index) {
|
||||
*untouched.entry(key(line)).or_insert(0_usize) += 1;
|
||||
}
|
||||
}
|
||||
let mut draft = doc.clone();
|
||||
rewrite_block(&mut draft, page, block, Some(text), style)?;
|
||||
rewrite_block_from(&mut draft, page, lines, block, Some(text), style, MAX_TYPE3_GLYPHS)?;
|
||||
let mut rect: Option<[f64; 4]> = None;
|
||||
for line in text_lines(&draft, page)? {
|
||||
if let Some(count) = untouched.get_mut(&key(&line))
|
||||
@@ -263,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() {
|
||||
@@ -282,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;
|
||||
@@ -304,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 {
|
||||
@@ -322,6 +450,47 @@ impl TextLine {
|
||||
let m = Matrix(self.origin.tm).then(&Matrix(self.origin.ctm)).0;
|
||||
m[1].atan2(m[0]).to_degrees()
|
||||
}
|
||||
|
||||
/// The unit direction its baseline runs in, in user space.
|
||||
fn direction(&self) -> (f64, f64) {
|
||||
direction(&Matrix(self.origin.tm).then(&Matrix(self.origin.ctm)))
|
||||
}
|
||||
|
||||
/// Whether it reads left to right along the page's x axis (most text; a page drawn
|
||||
/// turned a quarter, as landscape pages often are, isn't).
|
||||
pub(crate) fn upright(&self) -> bool {
|
||||
let (x, y) = self.direction();
|
||||
(x - 1.0).abs() < 1e-3 && y.abs() < 1e-3
|
||||
}
|
||||
|
||||
/// Where it starts as `(along, across)` its baseline: user space turned to the text's direction.
|
||||
fn frame_origin(&self) -> (f64, f64) {
|
||||
to_frame(self.direction(), (self.origin.x, self.origin.baseline))
|
||||
}
|
||||
|
||||
/// Its box in the same frame, `[along0, across0, along1, across1]` (for upright text, `rect`).
|
||||
fn frame_rect(&self) -> [f64; 4] {
|
||||
let dir = self.direction();
|
||||
let r = self.rect;
|
||||
[(r[0], r[1]), (r[2], r[1]), (r[0], r[3]), (r[2], r[3])]
|
||||
.into_iter()
|
||||
.map(|p| to_frame(dir, p))
|
||||
.fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)])
|
||||
}
|
||||
}
|
||||
|
||||
/// The unit x axis of a text rendering matrix in user space (`(1, 0)` when it is degenerate).
|
||||
fn direction(m: &Matrix) -> (f64, f64) {
|
||||
let (x, y) = (m.0[0], m.0[1]);
|
||||
let length = x.hypot(y);
|
||||
if length.is_finite() && length > 1e-9 { (x / length, y / length) } else { (1.0, 0.0) }
|
||||
}
|
||||
|
||||
/// User-space point `p` as `(along, across)` a baseline running in unit direction `dir`. Upright
|
||||
/// text (`dir` = `(1, 0)`) keeps its coordinates, so lines and rows of turned text are found the
|
||||
/// same way (#1443).
|
||||
fn to_frame(dir: (f64, f64), p: (f64, f64)) -> (f64, f64) {
|
||||
(p.0 * dir.0 + p.1 * dir.1, p.1 * dir.0 - p.0 * dir.1)
|
||||
}
|
||||
|
||||
/// What replacing a line did.
|
||||
@@ -470,9 +639,12 @@ struct Shown {
|
||||
bt_state: Ts,
|
||||
text: String,
|
||||
rect: [f64; 4],
|
||||
/// The boxes of its words (see [`TextLine::ink`]).
|
||||
ink: Vec<[f64; 4]>,
|
||||
baseline: f64,
|
||||
start_x: f64,
|
||||
end_x: f64,
|
||||
end_y: f64,
|
||||
bt: usize,
|
||||
font: Vec<u8>,
|
||||
base_font: String,
|
||||
@@ -487,6 +659,43 @@ struct Shown {
|
||||
actual_text: bool,
|
||||
}
|
||||
|
||||
impl Shown {
|
||||
/// The unit direction of its baseline, where it starts and ends along it, and the baseline's
|
||||
/// offset across it (for upright text: the user-space x, x and y).
|
||||
fn span(&self) -> ((f64, f64), f64, f64, f64) {
|
||||
let dir = direction(&self.tm.then(&self.state.ctm));
|
||||
let (start, across) = to_frame(dir, (self.start_x, self.baseline));
|
||||
(dir, start, to_frame(dir, (self.end_x, self.end_y)).0, across)
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the previous run of a line ended: its text object, baseline direction, offset across
|
||||
/// the baseline, end along it and size.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Pen {
|
||||
bt: usize,
|
||||
dir: (f64, f64),
|
||||
across: f64,
|
||||
end: f64,
|
||||
size: f64,
|
||||
}
|
||||
|
||||
impl Pen {
|
||||
fn after(s: &Shown) -> Self {
|
||||
let (dir, _, end, across) = s.span();
|
||||
Pen { bt: s.bt, dir, across, end, size: s.size.max(1.0) }
|
||||
}
|
||||
|
||||
/// Whether `s` could continue the same line on geometry alone: on the same baseline, starting
|
||||
/// at most a word gap (`reach` sizes) after it. Also the gap along the baseline.
|
||||
fn continues(&self, s: &Shown, reach: f64) -> (bool, f64) {
|
||||
let (dir, start, _, across) = s.span();
|
||||
let gap = start - self.end;
|
||||
let same_dir = (dir.0 - self.dir.0).abs() < 1e-3 && (dir.1 - self.dir.1).abs() < 1e-3;
|
||||
(same_dir && (across - self.across).abs() < self.size * 0.3 && gap > -self.size && gap < self.size * reach, gap)
|
||||
}
|
||||
}
|
||||
|
||||
/// The graphics state carried from one of a page's content streams to the next: the streams
|
||||
/// are one stream in pieces (§7.8.2), so a `cm` (AutoCAD scales the whole page in the first
|
||||
/// stream), an unbalanced `q`, the font and the colour still apply in the streams after it.
|
||||
@@ -564,6 +773,7 @@ fn interpret(
|
||||
first.rect[2].max(shown.rect[2]),
|
||||
first.rect[3].max(shown.rect[3]),
|
||||
];
|
||||
first.ink.extend(shown.ink);
|
||||
}
|
||||
first.extra_ops.extend([open, i]);
|
||||
// Only right-to-left ActualText is already in typing order and must stay
|
||||
@@ -574,6 +784,7 @@ fn interpret(
|
||||
first.text = logical;
|
||||
first.start_x = first.rect[0];
|
||||
first.end_x = first.rect[2];
|
||||
first.end_y = first.baseline;
|
||||
first.advance = (first.end_x - first.start_x) / first.tm.then(&first.state.ctm).0[0].abs().max(1e-6);
|
||||
out.push(first);
|
||||
}
|
||||
@@ -664,16 +875,34 @@ fn interpret(
|
||||
let mut text = String::new();
|
||||
let mut decodable = true;
|
||||
let mut x_text = 0.0;
|
||||
// Where each word's glyphs start and end along the run (text space), and whether
|
||||
// the next glyph continues the last word.
|
||||
let mut words: Vec<(f64, f64)> = Vec::new();
|
||||
let mut in_word = false;
|
||||
let mut glyph_count = 0_usize;
|
||||
for p in &pieces {
|
||||
match p {
|
||||
Object::String(s) => {
|
||||
for (code, len) in m.codes(&s.bytes) {
|
||||
glyph_count = glyph_count.saturating_add(1);
|
||||
match m.text_of(code) {
|
||||
let shown = m.text_of(code);
|
||||
let blank =
|
||||
m.is_space(code, len) || shown.as_deref().is_some_and(|t| !t.is_empty() && t.chars().all(char::is_whitespace));
|
||||
match shown {
|
||||
Some(t) => text.push_str(&t),
|
||||
None => decodable = false,
|
||||
}
|
||||
if !blank {
|
||||
let ink_end = x_text + m.width(code) * ts.size * ts.scale;
|
||||
// A negative font size or `Tz` runs the glyphs backwards:
|
||||
// the word is the union of its glyphs, whichever way they go.
|
||||
let (lo, hi) = (x_text.min(ink_end), x_text.max(ink_end));
|
||||
match words.last_mut() {
|
||||
Some(word) if in_word => *word = (word.0.min(lo), word.1.max(hi)),
|
||||
_ => words.push((lo, hi)),
|
||||
}
|
||||
}
|
||||
in_word = !blank;
|
||||
let w = m.width(code) * ts.size + ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 };
|
||||
x_text += w * ts.scale;
|
||||
}
|
||||
@@ -685,6 +914,11 @@ fn interpret(
|
||||
if dx > ts.size * 0.2 && !text.ends_with(' ') {
|
||||
text.push(' ');
|
||||
}
|
||||
// Kerning keeps a word together; a jump either way of more
|
||||
// than 0.2 em ends it (whatever the sign of the size or `Tz`).
|
||||
if dx.abs() > (ts.size * ts.scale).abs() * 0.2 {
|
||||
in_word = false;
|
||||
}
|
||||
x_text += dx;
|
||||
}
|
||||
}
|
||||
@@ -702,6 +936,15 @@ fn interpret(
|
||||
let rect = corners
|
||||
.iter()
|
||||
.fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]);
|
||||
let ink = words
|
||||
.iter()
|
||||
.map(|&(a, b)| {
|
||||
let (y0, y1) = (ts.rise + m.descent * ts.size, ts.rise + m.ascent * ts.size);
|
||||
[trm0.apply(a, y0), trm0.apply(b, y0), trm0.apply(a, y1), trm0.apply(b, y1)]
|
||||
.iter()
|
||||
.fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |r, p| [r[0].min(p.0), r[1].min(p.1), r[2].max(p.0), r[3].max(p.1)])
|
||||
})
|
||||
.collect();
|
||||
let size_user = (trm0.0[2].powi(2) + trm0.0[3].powi(2)).sqrt() * ts.size;
|
||||
// A numeric-only TJ adjusts the current text matrix but paints no glyphs.
|
||||
// It must not create a phantom editable line or enlarge its neighbours' boxes.
|
||||
@@ -720,9 +963,11 @@ fn interpret(
|
||||
bt_state: at_bt.1.clone(),
|
||||
text,
|
||||
rect,
|
||||
ink,
|
||||
baseline: start.1,
|
||||
start_x: start.0,
|
||||
end_x: end.0,
|
||||
end_y: end.1,
|
||||
bt,
|
||||
font: name,
|
||||
base_font: m.base_font.clone(),
|
||||
@@ -759,7 +1004,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
|
||||
let pieces = Pieces::join(&content_streams(doc, &p.dict).into_iter().map(|(_, d)| d).collect::<Vec<_>>());
|
||||
let ops = pieces.parse().ops;
|
||||
let shown = interpret(doc, &ops, &fonts_res, &mut cache, &mut carry, None, true);
|
||||
let mut last: Option<(usize, usize, f64, f64, f64)> = None; // (piece, bt, baseline, end_x, size)
|
||||
let mut last: Option<(usize, Pen)> = None; // the stream piece and where its last run ended
|
||||
for mut s in shown {
|
||||
// A single RTL glyph's ActualText (e.g. lam-alef) is an inline ligature, not a
|
||||
// separate logical line. Put its letters into visual order before joining the
|
||||
@@ -772,7 +1017,9 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
|
||||
// A line belongs to one stream piece. Within it, PDF producers often put every word
|
||||
// in its own BT/ET: geometry and text state, not object boundaries, define a line.
|
||||
let si = ops.get(s.op).map_or(0, |op| pieces.pieces_of(op).0);
|
||||
let joins = last.is_some_and(|(piece, bt, base, end, size)| {
|
||||
let joins = last.is_some_and(|(piece, pen)| {
|
||||
let bt = pen.bt;
|
||||
let (near, gap) = pen.continues(&s, if bt == s.bt { 3.0 } else { 1.5 });
|
||||
piece == si
|
||||
&& lines.last().is_some_and(|l| l.scope == s.scope && l.logical_order == s.logical_order)
|
||||
&& (bt == s.bt || lines.last().is_some_and(|l| compatible_word(l, &s)))
|
||||
@@ -781,20 +1028,19 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
|
||||
|| lines.last().is_some_and(|l| {
|
||||
(s.size - l.size).abs() < 0.01 && s.bold == l.bold && s.italic == l.italic && fill_color(&s.state.fill) == l.color
|
||||
}))
|
||||
&& (s.baseline - base).abs() < size * 0.3
|
||||
&& s.start_x > end - size
|
||||
&& s.start_x - end < size * if bt == s.bt { 3.0 } else { 1.5 }
|
||||
&& near
|
||||
// An OCR layer is one text object over the whole scan: its words continue a line
|
||||
// only across a word space, not across a table cell or a tab stop.
|
||||
&& (s.state.render_mode != 3 || s.start_x - end < size * 0.6)
|
||||
&& (s.state.render_mode != 3 || gap < pen.size * 0.6)
|
||||
});
|
||||
if joins && let Some(l) = lines.last_mut() {
|
||||
let gap = s.start_x - last.map_or(s.start_x, |x| x.3);
|
||||
let gap = last.map_or(0.0, |(_, pen)| pen.continues(&s, 0.0).1);
|
||||
if gap > l.size * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') {
|
||||
l.text.push(' ');
|
||||
}
|
||||
l.text.push_str(&s.text);
|
||||
l.rect = [l.rect[0].min(s.rect[0]), l.rect[1].min(s.rect[1]), l.rect[2].max(s.rect[2]), l.rect[3].max(s.rect[3])];
|
||||
l.ink.extend(s.ink.iter().copied());
|
||||
l.ops.push(s.op);
|
||||
l.ops.extend(s.extra_ops.iter().copied());
|
||||
l.decodable &= s.decodable;
|
||||
@@ -805,6 +1051,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
|
||||
lines.push(TextLine {
|
||||
text: s.text.clone(),
|
||||
rect: s.rect,
|
||||
ink: s.ink.clone(),
|
||||
font: String::from_utf8_lossy(&s.font).into_owned(),
|
||||
base_font: s.base_font.clone(),
|
||||
size: s.size,
|
||||
@@ -832,7 +1079,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
|
||||
},
|
||||
});
|
||||
}
|
||||
last = Some((si, s.bt, s.baseline, s.end_x, s.size.max(1.0)));
|
||||
last = Some((si, Pen::after(&s)));
|
||||
}
|
||||
// Content streams draw right-to-left lines in visual order; the editor works in typing order.
|
||||
for l in lines.iter_mut().filter(|l| !l.logical_order) {
|
||||
@@ -862,8 +1109,9 @@ fn compatible_word(line: &TextLine, shown: &Shown) -> bool {
|
||||
|
||||
/// The line `s` continues, or a new one: `last` is the previous run of the same stream
|
||||
/// (`bt`, baseline, end x, size).
|
||||
fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<(usize, f64, f64, f64)>, s: Shown, stream: usize) {
|
||||
let joins = last.is_some_and(|(bt, base, end, size)| {
|
||||
fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<Pen>, s: Shown, stream: usize) {
|
||||
let joins = last.is_some_and(|pen| {
|
||||
let bt = pen.bt;
|
||||
lines.last().is_some_and(|l| l.scope == s.scope && l.logical_order == s.logical_order)
|
||||
&& (bt == s.bt || lines.last().is_some_and(|l| compatible_word(l, &s)))
|
||||
&& (s.text.trim().is_empty() || lines.last().is_some_and(|l| same_font(l, &s.font, s.face.as_deref())))
|
||||
@@ -871,17 +1119,16 @@ fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<(usize, f64, f64, f64)
|
||||
|| lines.last().is_some_and(|l| {
|
||||
(s.size - l.size).abs() < 0.01 && s.bold == l.bold && s.italic == l.italic && fill_color(&s.state.fill) == l.color
|
||||
}))
|
||||
&& (s.baseline - base).abs() < size * 0.3
|
||||
&& s.start_x > end - size
|
||||
&& s.start_x - end < size * if bt == s.bt { 3.0 } else { 1.5 }
|
||||
&& pen.continues(&s, if bt == s.bt { 3.0 } else { 1.5 }).0
|
||||
});
|
||||
if joins && let Some(l) = lines.last_mut() {
|
||||
let gap = s.start_x - last.map_or(s.start_x, |x| x.2);
|
||||
let gap = last.map_or(0.0, |pen| pen.continues(&s, 0.0).1);
|
||||
if gap > l.size * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') {
|
||||
l.text.push(' ');
|
||||
}
|
||||
l.text.push_str(&s.text);
|
||||
l.rect = [l.rect[0].min(s.rect[0]), l.rect[1].min(s.rect[1]), l.rect[2].max(s.rect[2]), l.rect[3].max(s.rect[3])];
|
||||
l.ink.extend(s.ink.iter().copied());
|
||||
l.ops.push(s.op);
|
||||
l.ops.extend(s.extra_ops.iter().copied());
|
||||
l.decodable &= s.decodable;
|
||||
@@ -891,6 +1138,7 @@ fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<(usize, f64, f64, f64)
|
||||
lines.push(TextLine {
|
||||
text: s.text.clone(),
|
||||
rect: s.rect,
|
||||
ink: s.ink.clone(),
|
||||
font: String::from_utf8_lossy(&s.font).into_owned(),
|
||||
base_font: s.base_font.clone(),
|
||||
size: s.size,
|
||||
@@ -918,7 +1166,7 @@ fn add_shown(lines: &mut Vec<TextLine>, last: &mut Option<(usize, f64, f64, f64)
|
||||
},
|
||||
});
|
||||
}
|
||||
*last = Some((s.bt, s.baseline, s.end_x, s.size.max(1.0)));
|
||||
*last = Some(Pen::after(&s));
|
||||
}
|
||||
|
||||
/// How deep form XObjects nest before reading stops (they may also refer to themselves).
|
||||
@@ -1166,6 +1414,22 @@ fn unicode_hex(ch: char) -> String {
|
||||
encoded.iter().map(|u| format!("{u:04X}")).collect()
|
||||
}
|
||||
|
||||
/// No font this build has can draw `text`. Only Japanese text is about the Japanese face: other
|
||||
/// characters are missing from the paragraph's font, and choosing a font that has them works too.
|
||||
fn no_fallback_font(text: &str) -> EditError {
|
||||
let japanese = |c: char| matches!(u32::from(c), 0x3000..=0x30FF | 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF);
|
||||
if text.chars().any(japanese) {
|
||||
return no_japanese_font();
|
||||
}
|
||||
let Some(ch) = text.chars().find(|c| !c.is_whitespace() && !is_win_ansi_char(*c)) else { return no_japanese_font() };
|
||||
EditError::Invalid(format!(
|
||||
"the paragraph's font has no glyph for {ch} (U+{:04X}) and this build has no fallback font for it; \
|
||||
choose a font that has it (Load font… or Installed fonts…), or use an official release, which includes \
|
||||
PdfCraft's fallback fonts (to build them in, set CRAFT_FONTS_DIR to a craft-fonts checkout)",
|
||||
u32::from(ch)
|
||||
))
|
||||
}
|
||||
|
||||
/// This build has no Japanese face to draw replacement text with.
|
||||
fn no_japanese_font() -> EditError {
|
||||
EditError::Invalid(
|
||||
@@ -1230,7 +1494,7 @@ fn type3_font(
|
||||
// character. When none has them all, the best face reports the character it lacks. Hebrew
|
||||
// text keeps the Hebrew face and takes each character it lacks from the next face that has it.
|
||||
let face = if hebrew { faces.first().copied() } else { fallback_face(&faces, &chars, |face, ch| japanese_glyph_from(face, ch).is_ok()) }
|
||||
.ok_or_else(no_japanese_font)?;
|
||||
.ok_or_else(|| no_fallback_font(text))?;
|
||||
let others: &[&CraftFont] = if hebrew { faces.get(1..).unwrap_or_default() } else { &[] };
|
||||
// Every outline is read before the document is touched, so a character that fails leaves it
|
||||
// as it was. Each font takes the next `per_font` characters; codes restart at 1 in each.
|
||||
@@ -1338,6 +1602,28 @@ fn type3_encode(fallback: &Type3Fallback, text: &str) -> Option<Vec<(String, Vec
|
||||
runs.into_iter().map(|(font, bytes)| Some((fallback.names.get(font)?.clone(), bytes))).collect()
|
||||
}
|
||||
|
||||
/// The source font's own TrueType/OpenType program, when the document embeds a complete one whose
|
||||
/// licence allows editable embedding. A subset (an `ABCDEF+` name) is left out: a character can keep
|
||||
/// its mapping there after the subsetter dropped its outline.
|
||||
fn source_program(doc: &Document, fonts: &Dict, name: &str) -> Option<Vec<u8>> {
|
||||
let font = fonts.get(name.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned())?;
|
||||
let base = Metrics::from_dict(doc, &font).base_font;
|
||||
if base.split_once('+').is_some_and(|(tag, _)| tag.len() == 6 && tag.bytes().all(|b| b.is_ascii_uppercase())) {
|
||||
return None;
|
||||
}
|
||||
let bytes = pdfcraft_fonts::pdf::embedded_sfnt(doc, &font)?;
|
||||
pdfcraft_fonts::embedding::validate(&bytes).is_ok().then_some(bytes)
|
||||
}
|
||||
|
||||
/// Whether `text` has characters `line`'s font can't encode that its own program can draw.
|
||||
fn source_program_completes(doc: &Document, fonts: &Dict, line: &TextLine, text: &str) -> bool {
|
||||
let Some(metrics) = fonts.get(line.font.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned()).map(|f| Metrics::from_dict(doc, &f)) else {
|
||||
return false;
|
||||
};
|
||||
let missing: String = text.chars().filter(|c| !c.is_whitespace() && !source_encodes(&metrics, &c.to_string())).collect();
|
||||
!missing.is_empty() && source_program(doc, fonts, &line.font).is_some_and(|bytes| pdfcraft_fonts::embedding::covers(&bytes, &missing))
|
||||
}
|
||||
|
||||
/// A subset may contain all the letters but no space glyph: producers use another subset
|
||||
/// or a positioning operator for spaces. Do the same instead of substituting the whole font.
|
||||
fn source_encodes(metrics: &Metrics, text: &str) -> bool {
|
||||
@@ -1611,21 +1897,32 @@ fn paragraph_separator(c: char) -> bool {
|
||||
c.is_whitespace() && c != '\u{3000}'
|
||||
}
|
||||
|
||||
/// Greedy word wrapping to `width` with `advance` giving a string's width.
|
||||
/// Greedy word wrapping to `width` with `advance` giving a string's width. Each `\n` starts a
|
||||
/// new line. A row may also end inside a word of Chinese or Japanese text ([`crate::linebreak`]);
|
||||
/// those pieces join without a space.
|
||||
fn wrap(text: &str, width: f64, advance: impl Fn(&str) -> f64) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut line = String::new();
|
||||
for word in text.split(paragraph_separator).filter(|word| !word.is_empty()) {
|
||||
let candidate = if line.is_empty() { word.to_string() } else { format!("{line} {word}") };
|
||||
if !line.is_empty() && advance(&candidate) > width {
|
||||
out.push(std::mem::take(&mut line));
|
||||
line = word.to_string();
|
||||
} else {
|
||||
line = candidate;
|
||||
for row in text.split('\n') {
|
||||
let mut line = String::new();
|
||||
let start = out.len();
|
||||
for word in row.split(paragraph_separator).filter(|word| !word.is_empty()) {
|
||||
for (i, piece) in crate::linebreak::pieces(word).into_iter().enumerate() {
|
||||
let candidate = match (line.is_empty(), i) {
|
||||
(true, _) => piece.to_string(),
|
||||
(false, 0) => format!("{line} {piece}"),
|
||||
(false, _) => format!("{line}{piece}"),
|
||||
};
|
||||
if !line.is_empty() && advance(&candidate) > width {
|
||||
out.push(std::mem::take(&mut line));
|
||||
line = piece.to_string();
|
||||
} else {
|
||||
line = candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !line.is_empty() || out.len() == start {
|
||||
out.push(line);
|
||||
}
|
||||
}
|
||||
if !line.is_empty() || out.is_empty() {
|
||||
out.push(line);
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -1696,6 +1993,19 @@ pub(crate) fn rewrite_block_with_limit(
|
||||
max_glyphs: usize,
|
||||
) -> Result<LineEdit, EditError> {
|
||||
let lines = text_lines(doc, page)?;
|
||||
rewrite_block_from(doc, page, lines, block, text, style, max_glyphs)
|
||||
}
|
||||
|
||||
/// [`rewrite_block_with_limit`] with the page's [`text_lines`], read from `doc` as it is now.
|
||||
fn rewrite_block_from(
|
||||
doc: &mut Document,
|
||||
page: usize,
|
||||
lines: Vec<TextLine>,
|
||||
block: usize,
|
||||
text: Option<&str>,
|
||||
style: &BlockStyle,
|
||||
max_glyphs: usize,
|
||||
) -> Result<LineEdit, EditError> {
|
||||
let blocks = group_blocks(&lines);
|
||||
let b = blocks.get(block).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no paragraph {}", page + 1, block + 1)))?;
|
||||
let members: Vec<&TextLine> = b.lines.iter().map(|i| &lines[*i]).collect();
|
||||
@@ -1712,8 +2022,15 @@ pub(crate) fn rewrite_block_with_limit(
|
||||
"formatting part of right-to-left (Hebrew) text, or choosing a document font for it, isn't supported yet".into(),
|
||||
));
|
||||
}
|
||||
// Text turned on the page (a landscape page drawn a quarter turn round) is rewritten in its
|
||||
// own text space below; the styled region path lays rows out along the page's x axis.
|
||||
let upright = members.iter().all(|line| line.upright());
|
||||
if !upright && (!style.ranges.is_empty() || style.resource.is_some()) {
|
||||
return Err(EditError::Invalid("formatting part of turned text, or choosing a document font for it, isn't supported yet".into()));
|
||||
}
|
||||
let visual_indic = members.iter().any(|line| line.visual_order);
|
||||
if !hebrew
|
||||
if upright
|
||||
&& !hebrew
|
||||
&& !visual_indic
|
||||
&& (!style.ranges.is_empty()
|
||||
|| style.resource.is_some()
|
||||
@@ -1722,6 +2039,7 @@ pub(crate) fn rewrite_block_with_limit(
|
||||
|| pdfcraft_fonts::source_shaping::required(draft)
|
||||
|| !pdfcraft_fonts::embedding::matching_faces(&b.base_font).is_empty()
|
||||
}) || registered_face
|
||||
|| text.is_some_and(|draft| source_program_completes(doc, &fonts_res, first, draft))
|
||||
|| b.rows.iter().any(|row| row.len() > 1)
|
||||
|| members.iter().any(|line| line.font != first.font || line.size != first.size || line.color != first.color))
|
||||
&& style.family.is_none()
|
||||
@@ -1732,7 +2050,15 @@ pub(crate) fn rewrite_block_with_limit(
|
||||
let streams = content_streams(doc, &p.dict);
|
||||
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
|
||||
let ops = pieces.parse().ops;
|
||||
let text = text.unwrap_or(&b.text).split(paragraph_separator).filter(|word| !word.is_empty()).collect::<Vec<_>>().join(" ");
|
||||
// Explicit line breaks stay (#1443); within a line, runs of white space become one space.
|
||||
let text = text
|
||||
.unwrap_or(&b.text)
|
||||
.split('\n')
|
||||
.map(|row| row.split(paragraph_separator).filter(|word| !word.is_empty()).collect::<Vec<_>>().join(" "))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
// The characters to show, for choosing fonts: the breaks are moves, not glyphs.
|
||||
let flat = text.replace('\n', " ");
|
||||
let o = &first.origin;
|
||||
let (font_name, old_size) = o.state.font.clone().ok_or_else(|| EditError::Invalid("the paragraph has no font".into()))?;
|
||||
let k = o.k.max(1e-6);
|
||||
@@ -1749,7 +2075,7 @@ pub(crate) fn rewrite_block_with_limit(
|
||||
let o_state = ts_state.clone();
|
||||
let metrics = fonts_res.get(font_name.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned()).map(|d| Metrics::from_dict(doc, &d));
|
||||
let visual_indic = metrics.as_ref().is_some_and(|m| m.visual_order) || first.visual_order;
|
||||
let indic_text = if visual_indic { crate::indic::to_visual(&text) } else { text.clone() };
|
||||
let indic_text = if visual_indic { crate::indic::to_visual(&flat) } else { flat.clone() };
|
||||
let reuse = style.family.is_none() && style.bold.is_none() && metrics.as_ref().is_some_and(|m| source_encodes(m, &indic_text));
|
||||
let space = metrics.as_ref().map_or(0.33, |m| source_space(doc, &fonts_res, m));
|
||||
// The chosen style also determines the real Japanese fallback outlines and advances.
|
||||
@@ -1764,14 +2090,22 @@ pub(crate) fn rewrite_block_with_limit(
|
||||
}
|
||||
// Right-to-left text (more Hebrew or Arabic letters than Latin) is right-aligned unless an
|
||||
// alignment is asked for, and a single right-to-left line keeps its right edge as it grows.
|
||||
let rtl = crate::bidi::rtl_base(&text);
|
||||
let anchored_right = rtl && style.width.is_none() && members.len() == 1;
|
||||
let rtl = crate::bidi::rtl_base(&flat);
|
||||
let anchored_right = rtl && upright && style.width.is_none() && members.len() == 1;
|
||||
// The paragraph's width along its lines: in user space unless it is turned.
|
||||
let frame = if upright {
|
||||
b.rect
|
||||
} else {
|
||||
members.iter().map(|l| l.frame_rect()).reduce(|a, f| [a[0].min(f[0]), a[1].min(f[1]), a[2].max(f[2]), a[3].max(f[3])]).unwrap_or(b.rect)
|
||||
};
|
||||
// A paragraph keeps its width (or takes the one asked for); a single line grows to the right
|
||||
// (to the left when right-to-left), up to the page's margin.
|
||||
let width = match style.width {
|
||||
// Capped at the largest page PDF allows (14 400 pt).
|
||||
Some(w) if w.is_finite() => w.clamp(size * k, 14_400.0),
|
||||
Some(_) => return Err(EditError::Invalid("the paragraph's width must be a number".into())),
|
||||
// Turned text: the page's margins are along another axis. A single line grows (below).
|
||||
None if !upright => (frame[2] - frame[0]).max(size * k),
|
||||
None if anchored_right => (b.rect[2] - b.rect[0]).max(size * k).max(b.rect[2] + dx - (p.crop(doc)[0] + 36.0)),
|
||||
None if members.len() == 1 => (b.rect[2] - b.rect[0]).max(size * k).max(p.crop(doc)[2] - 36.0 - (b.rect[0] + dx)),
|
||||
None => (b.rect[2] - b.rect[0]).max(size * k),
|
||||
@@ -1794,7 +2128,7 @@ pub(crate) fn rewrite_block_with_limit(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let type3 = if !reuse && needs_type3(&text) { Some(type3_font(doc, &mut fonts_res, &text, family, bold, max_glyphs)?) } else { None };
|
||||
let type3 = if !reuse && needs_type3(&flat) { Some(type3_font(doc, &mut fonts_res, &flat, family, bold, max_glyphs)?) } else { None };
|
||||
let advance = |s: &str| -> f64 {
|
||||
let t = match (&metrics, reuse) {
|
||||
(Some(m), true) => {
|
||||
@@ -1815,6 +2149,13 @@ pub(crate) fn rewrite_block_with_limit(
|
||||
};
|
||||
t * o_state.scale * k
|
||||
};
|
||||
// A turned single line grows to fit, as far as the largest page PDF allows.
|
||||
let width = if !upright && style.width.is_none() && members.len() == 1 {
|
||||
text.split('\n').map(&advance).fold(width, f64::max).min(14_400.0)
|
||||
} else {
|
||||
width
|
||||
};
|
||||
let right_edge = if anchored_right { right_edge } else { width };
|
||||
let wrapped = wrap(&text, width + 0.5, advance);
|
||||
let mut substituted = None;
|
||||
let new_font = !reuse;
|
||||
@@ -1826,11 +2167,11 @@ pub(crate) fn rewrite_block_with_limit(
|
||||
let first = fallback.names.first().cloned().ok_or_else(no_japanese_font)?;
|
||||
(first, Box::new(move |s: &str| type3_encode(&fallback, s)))
|
||||
} else {
|
||||
let win = pdfcraft_fonts::win_ansi(&text);
|
||||
let win = pdfcraft_fonts::win_ansi(&flat);
|
||||
let back: String = win.iter().map(|c| char::from_u32(u32::from(*c)).unwrap_or('?')).collect();
|
||||
let base = family.base_font(bold, italic);
|
||||
if text.chars().zip(back.chars()).any(|(a, c)| c == '?' && a != '?') {
|
||||
return Err(EditError::Invalid(format!("\"{text}\" has characters neither {} nor {base} can show", b.base_font)));
|
||||
if flat.chars().zip(back.chars()).any(|(a, c)| c == '?' && a != '?') {
|
||||
return Err(EditError::Invalid(format!("\"{flat}\" has characters neither {} nor {base} can show", b.base_font)));
|
||||
}
|
||||
let mut f = Dict::new();
|
||||
f.set(b"Type".to_vec(), Object::name("Font"));
|
||||
@@ -1848,7 +2189,16 @@ pub(crate) fn rewrite_block_with_limit(
|
||||
// Line spacing in text space: the paragraph's own (scaled with the size), or 1.2 × the size.
|
||||
let lead = match style.line_spacing {
|
||||
Some(m) => size * m.clamp(0.5, 5.0),
|
||||
None if members.len() > 1 => (members[0].origin.baseline - members[1].origin.baseline) / k * size / old_size,
|
||||
// From the first two rows (a row may hold several runs).
|
||||
None if let Some((upper, lower)) = b
|
||||
.rows
|
||||
.first()
|
||||
.zip(b.rows.get(1))
|
||||
.and_then(|(upper, lower)| upper.first().zip(lower.first()))
|
||||
.and_then(|(upper, lower)| lines.get(*upper).zip(lines.get(*lower))) =>
|
||||
{
|
||||
(upper.frame_origin().1 - lower.frame_origin().1) / k * size / old_size
|
||||
}
|
||||
None if o_state.leading > 0.0 => o_state.leading,
|
||||
None => size * 1.2,
|
||||
};
|
||||
@@ -1884,9 +2234,15 @@ pub(crate) fn rewrite_block_with_limit(
|
||||
_ => 0.0,
|
||||
}
|
||||
};
|
||||
// Justify: word spacing (single-byte code 32 only) so each line but the last fills the width.
|
||||
let single_byte = !reuse || metrics.as_ref().is_some_and(|m| !m.composite);
|
||||
let justify = align == Some(crate::added::Align::Justify) && single_byte;
|
||||
// Justify: each line but the last fills the width. Word spacing reaches only single-byte code 32
|
||||
// (ISO 32000-1 §9.3.3), so where a space is shown with another code (a composite font, a
|
||||
// generated Type 3 font, a subset without a space) the extra space follows each space as a
|
||||
// TJ adjustment instead, and code 32, which may then be a letter, gets none.
|
||||
let justify = align == Some(crate::added::Align::Justify);
|
||||
let word_spacing_reaches_spaces = match metrics.as_ref().filter(|_| reuse) {
|
||||
Some(m) => m.encode(" ").is_some_and(|b| m.codes(&b).iter().all(|(c, len)| m.is_space(*c, *len))),
|
||||
None => type3.is_none(),
|
||||
};
|
||||
let mut x = 0.0;
|
||||
let mut tw_set = 0.0;
|
||||
let mut underlines: Vec<(f64, f64, f64)> = Vec::new(); // (x0, x1, y) in text space
|
||||
@@ -1900,24 +2256,34 @@ pub(crate) fn rewrite_block_with_limit(
|
||||
block_ops.push(Op::new("Td", vec![n(dx - x), n(if i > 0 { -lead } else { 0.0 })]));
|
||||
}
|
||||
x = dx;
|
||||
let stretch = if word_spacing_reaches_spaces { 0.0 } else { tw };
|
||||
let tw = tw - stretch;
|
||||
if tw != tw_set {
|
||||
block_ops.push(Op::new("Tw", vec![n(tw)]));
|
||||
tw_set = tw;
|
||||
}
|
||||
// Each wrapped line in typing order; drawn left to right (right-to-left runs reversed).
|
||||
let visual = crate::bidi::to_visual(line);
|
||||
if let Some(metrics) = metrics.as_ref().filter(|_| reuse) {
|
||||
let indic_line = if metrics.visual_order { crate::indic::to_visual(&visual) } else { visual };
|
||||
block_ops.extend(source_show(metrics, &indic_line, size, space, o_state.char_spacing, tw)?);
|
||||
} else {
|
||||
for (font, bytes) in encode(&visual).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))? {
|
||||
if font != shown_font {
|
||||
block_ops.push(Op::new("Tf", vec![Object::name(&font), n(size)]));
|
||||
shown_font = font;
|
||||
// Stretched without word spacing: shown up to and including each space, then moved on.
|
||||
let pieces = if stretch > 0.0 { visual.split_inclusive(' ').collect() } else { vec![visual.as_str()] };
|
||||
for piece in pieces {
|
||||
if let Some(metrics) = metrics.as_ref().filter(|_| reuse) {
|
||||
let indic_piece = if metrics.visual_order { crate::indic::to_visual(piece) } else { piece.to_owned() };
|
||||
block_ops.extend(source_show(metrics, &indic_piece, size, space, o_state.char_spacing, tw)?);
|
||||
} else {
|
||||
for (font, bytes) in encode(piece).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))? {
|
||||
if font != shown_font {
|
||||
block_ops.push(Op::new("Tf", vec![Object::name(&font), n(size)]));
|
||||
shown_font = font;
|
||||
}
|
||||
block_ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))]));
|
||||
}
|
||||
block_ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))]));
|
||||
}
|
||||
if stretch > 0.0 && piece.ends_with(' ') {
|
||||
block_ops.push(Op::new("TJ", vec![Object::Array(vec![n(-1000.0 * stretch / size)])]));
|
||||
}
|
||||
}
|
||||
let tw = tw + stretch;
|
||||
let w = (advance(line) + tw * spaces as f64 * o_state.scale * k) / k;
|
||||
underlines.push((dx, dx + w, -(i as f64) * lead - size * 0.12));
|
||||
}
|
||||
@@ -2205,6 +2571,17 @@ mod tests {
|
||||
assert_eq!(wrap(" \tA B\r\nC ", 3.0, width), ["A B", "C"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn japanese_text_wraps_between_characters_but_not_before_closing_marks() {
|
||||
let width = |s: &str| s.chars().count() as f64;
|
||||
assert_eq!(wrap("日本語の文章です。", 3.0, width), ["日本語", "の文章", "です。"]);
|
||||
// The comma can't begin a row, so the character before it moves down with it.
|
||||
assert_eq!(wrap("あいう、えお", 3.0, width), ["あい", "う、え", "お"]);
|
||||
// Pieces of one word join without a space; separate words keep theirs.
|
||||
assert_eq!(wrap("Edit PDFを編集", 6.0, width), ["Edit", "PDFを編集"]);
|
||||
assert_eq!(wrap("一行目 二行目", 4.0, width), ["一行目 ", "二行目"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_face_moves_on_when_the_best_face_lacks_a_character() {
|
||||
static JPAN: &[&str] = &["Jpan"];
|
||||
|
||||
+130
-18
@@ -25,6 +25,12 @@ fn row_rect(row: &[usize], lines: &[TextLine]) -> Option<[f64; 4]> {
|
||||
row.iter().filter_map(|i| lines.get(*i)).map(|l| l.rect).reduce(union)
|
||||
}
|
||||
|
||||
/// [`row_rect`] in its text's own frame ([`TextLine::frame_rect`]), so rows of turned text
|
||||
/// compare along and across their baselines.
|
||||
fn frame_row_rect(row: &[usize], lines: &[TextLine]) -> Option<[f64; 4]> {
|
||||
row.iter().filter_map(|i| lines.get(*i)).map(TextLine::frame_rect).reduce(union)
|
||||
}
|
||||
|
||||
pub(super) fn group_blocks(lines: &[TextLine]) -> Vec<TextBlock> {
|
||||
group_layout(lines, true)
|
||||
}
|
||||
@@ -62,14 +68,14 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
|
||||
let mut rows: Vec<Vec<usize>> = Vec::new();
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let joins = rows.last().and_then(|r| r.last()).and_then(|i| lines.get(*i)).is_some_and(|prev| {
|
||||
let gap = line.rect[0] - prev.rect[2];
|
||||
let gap = line.frame_rect()[0] - prev.frame_rect()[2];
|
||||
// OCR words (invisible text over a scan) join only across a word space: a wider gap
|
||||
// is a table cell or a tab stop, which a rewrite would close up.
|
||||
let scanned = !mixed || (invisible(prev) == invisible(line) && (!invisible(line) || gap < prev.size.max(line.size) * 0.6));
|
||||
scanned
|
||||
&& (mixed || same_style(prev, line))
|
||||
&& same_plane(prev, line)
|
||||
&& (prev.origin.baseline - line.origin.baseline).abs() < prev.size.min(line.size) * 0.2
|
||||
&& (prev.frame_origin().1 - line.frame_origin().1).abs() < prev.size.min(line.size) * 0.2
|
||||
&& gap >= -prev.size.min(line.size) * 0.2
|
||||
&& gap < prev.size.max(line.size) * 1.5
|
||||
// Separate same-style text objects that merely touch or overlap, with no space
|
||||
@@ -90,14 +96,15 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
|
||||
let mut blocks: Vec<TextBlock> = Vec::new();
|
||||
for row in rows {
|
||||
let Some(first) = row.first().and_then(|i| lines.get(*i)) else { continue };
|
||||
let Some(rect) = row_rect(&row, lines) else { continue };
|
||||
let (Some(rect), Some(frame)) = (row_rect(&row, lines), frame_row_rect(&row, lines)) else { continue };
|
||||
let joins = blocks.last().is_some_and(|block| {
|
||||
let Some(prev_row) = block.rows.last() else { return false };
|
||||
let Some(prev) = prev_row.first().and_then(|i| lines.get(*i)) else { return false };
|
||||
let Some(pr) = row_rect(prev_row, lines) else { return false };
|
||||
let gap = prev.origin.baseline - first.origin.baseline;
|
||||
let Some(pr) = frame_row_rect(prev_row, lines) else { return false };
|
||||
let gap = prev.frame_origin().1 - first.frame_origin().1;
|
||||
let tol = prev.size.max(first.size).max(1.0);
|
||||
let aligned = (pr[0] - rect[0]).abs() < tol || ((pr[0] + pr[2] - rect[0] - rect[2]) / 2.0).abs() < tol || (pr[2] - rect[2]).abs() < tol;
|
||||
let aligned =
|
||||
(pr[0] - frame[0]).abs() < tol || ((pr[0] + pr[2] - frame[0] - frame[2]) / 2.0).abs() < tol || (pr[2] - frame[2]).abs() < tol;
|
||||
// OCR lines stay apart: the scan fixes where each one is, so they never rewrap together.
|
||||
(!mixed || !(invisible(prev) || invisible(first)))
|
||||
&& (mixed || same_style(prev, first))
|
||||
@@ -135,7 +142,7 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
|
||||
if let Some(prev) = block.lines.last().filter(|i| row.contains(i)).and_then(|i| lines.get(*i))
|
||||
&& !row_text.ends_with(char::is_whitespace)
|
||||
&& !line.text.starts_with(char::is_whitespace)
|
||||
&& line.rect[0] - prev.rect[2] > line.size * 0.12
|
||||
&& line.frame_rect()[0] - prev.frame_rect()[2] > line.size * 0.12
|
||||
{
|
||||
row_text.push(' ');
|
||||
block.editor_text.push(' ');
|
||||
@@ -192,6 +199,46 @@ fn shape_source(
|
||||
Err(EditError::Invalid("complex text needs the complete source TrueType/OpenType face; load that font before editing".into()))
|
||||
}
|
||||
|
||||
/// For each output row (a range of the draft's characters), whether it is exactly the source row
|
||||
/// at the same position: the same characters, none of them inserted or changed by the edit.
|
||||
fn untouched_rows(block: &TextBlock, draft: &str, rows: &[std::ops::Range<usize>]) -> Vec<bool> {
|
||||
let old: Vec<char> = block.editor_text.chars().collect();
|
||||
let new: Vec<char> = draft.chars().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();
|
||||
// The source character a draft character is, if the edit left it alone.
|
||||
let origin = |i: usize| {
|
||||
if i < prefix {
|
||||
Some(i)
|
||||
} else if i >= new.len().saturating_sub(suffix) {
|
||||
(old.len() + i).checked_sub(new.len())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
let mut source_rows = Vec::new();
|
||||
let mut start = 0;
|
||||
for (i, ch) in old.iter().enumerate() {
|
||||
if *ch == '\n' {
|
||||
source_rows.push(start..i);
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
source_rows.push(start..old.len());
|
||||
// A line break inside a source run would pair editor rows with the wrong visual rows.
|
||||
if source_rows.len() != block.rows.len() {
|
||||
return vec![false; rows.len()];
|
||||
}
|
||||
rows.iter()
|
||||
.enumerate()
|
||||
.map(|(r, range)| {
|
||||
source_rows.get(r).is_some_and(|source| {
|
||||
!range.is_empty() && range.len() == source.len() && range.clone().zip(source.clone()).all(|(i, j)| origin(i) == Some(j))
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Rewrite visual rows while keeping the source style on each surviving character. Resource
|
||||
/// names may differ between Unicode subsets of the same face; encoding searches those subsets.
|
||||
pub(super) fn rewrite_styled_block(
|
||||
@@ -227,6 +274,7 @@ pub(super) fn rewrite_styled_block(
|
||||
{
|
||||
return Err(EditError::Invalid("text style ranges must be disjoint UTF-8 boundaries with finite formatting (at most 1024 ranges)".into()));
|
||||
}
|
||||
let source_count = lines.len();
|
||||
let mut styled_lines = lines.to_vec();
|
||||
let mut styled_spans = Vec::new();
|
||||
let mut underlines = HashMap::new();
|
||||
@@ -331,8 +379,9 @@ pub(super) fn rewrite_styled_block(
|
||||
if !dx.is_finite() || !dy.is_finite() || style.width.is_some_and(|w| !w.is_finite()) {
|
||||
return Err(EditError::Invalid("the paragraph's position and width must be finite".into()));
|
||||
}
|
||||
// A rewritten OCR line grows to the right rather than wrapping onto the scanned line below.
|
||||
let natural = if block.invisible {
|
||||
// A visual row grows even when it contains several source/font runs. Multi-row
|
||||
// paragraphs retain their container width; an explicit width always wins.
|
||||
let natural = if block.invisible || block.rows.len() == 1 {
|
||||
(p.crop(doc)[2] - 36.0 - (block.rect[0] + dx)).max(block.rect[2] - block.rect[0])
|
||||
} else {
|
||||
block.rect[2] - block.rect[0]
|
||||
@@ -343,6 +392,7 @@ pub(super) fn rewrite_styled_block(
|
||||
let mut substituted = None;
|
||||
let mut fallback_names: std::collections::BTreeMap<(String, char), String> = std::collections::BTreeMap::new();
|
||||
let mut full_faces: HashMap<String, String> = HashMap::new();
|
||||
let mut programs: HashMap<String, Option<std::sync::Arc<Vec<u8>>>> = HashMap::new();
|
||||
for (range, index) in styled_spans {
|
||||
let source = lines.get(index).unwrap_or(first);
|
||||
for ch in draft.get(range).unwrap_or_default().chars() {
|
||||
@@ -378,6 +428,28 @@ pub(super) fn rewrite_styled_block(
|
||||
full_faces.insert(key, name.clone());
|
||||
name
|
||||
}
|
||||
} else if let Some(bytes) = programs
|
||||
.entry(source.font.clone())
|
||||
.or_insert_with(|| source_program(doc, &fonts, &source.font).map(std::sync::Arc::new))
|
||||
.clone()
|
||||
.filter(|bytes| pdfcraft_fonts::embedding::covers(bytes, &s))
|
||||
{
|
||||
// The document's own complete program of this font has the glyph: draw it with
|
||||
// that face rather than another family's fallback.
|
||||
let key = format!("source {}", source.font);
|
||||
if let Some(name) = full_faces.get(&key) {
|
||||
name.clone()
|
||||
} else {
|
||||
let family = pdfcraft_fonts::embedding::names(&bytes).map_or_else(|_| source.base_font.clone(), |(family, _)| family);
|
||||
let name = pdfcraft_fonts::embedding::embed(doc, &mut fonts, &bytes, &family).map_err(EditError::Invalid)?;
|
||||
let font = fonts
|
||||
.get(name.as_bytes())
|
||||
.and_then(|f| doc.dict(f))
|
||||
.ok_or_else(|| EditError::Invalid("the source font could not be loaded".into()))?;
|
||||
metrics.insert(name.clone(), Metrics::from_dict(doc, &font));
|
||||
full_faces.insert(key, name.clone());
|
||||
name
|
||||
}
|
||||
} else {
|
||||
let family = source_family(&source.base_font);
|
||||
if needs_type3(&s) {
|
||||
@@ -488,6 +560,9 @@ pub(super) fn rewrite_styled_block(
|
||||
Ok(width)
|
||||
};
|
||||
let mut rows: Vec<Vec<(char, usize, String)>> = Vec::new();
|
||||
// Each row's characters, as indexes into the draft's characters (`chars` holds all of them).
|
||||
let mut row_ranges = Vec::new();
|
||||
let mut row_start = 0;
|
||||
let mut row = Vec::new();
|
||||
let mut row_width = 0.0;
|
||||
// Wrap at word boundaries, retaining explicit source row breaks and whitespace.
|
||||
@@ -495,14 +570,20 @@ pub(super) fn rewrite_styled_block(
|
||||
while at < chars.len() {
|
||||
let Some(item) = chars.get(at) else { break };
|
||||
if item.0 == '\n' {
|
||||
row_ranges.push(row_start..row_start + row.len());
|
||||
rows.push(std::mem::take(&mut row));
|
||||
row_width = 0.0;
|
||||
at += 1;
|
||||
row_start = at;
|
||||
continue;
|
||||
}
|
||||
let mut end = at + 1;
|
||||
if !item.0.is_whitespace() {
|
||||
while chars.get(end).is_some_and(|c| !c.0.is_whitespace()) {
|
||||
// A word ends at a space, or where Chinese or Japanese text may break between characters.
|
||||
while let (Some(before), Some(c)) = (chars.get(end - 1), chars.get(end))
|
||||
&& !c.0.is_whitespace()
|
||||
&& !crate::linebreak::between(before.0, c.0)
|
||||
{
|
||||
end += 1;
|
||||
}
|
||||
}
|
||||
@@ -512,17 +593,47 @@ pub(super) fn rewrite_styled_block(
|
||||
while row.last().is_some_and(|c: &(char, usize, String)| c.0.is_whitespace()) {
|
||||
row.pop();
|
||||
}
|
||||
row_ranges.push(row_start..row_start + row.len());
|
||||
rows.push(std::mem::take(&mut row));
|
||||
row_width = 0.0;
|
||||
row_start = at;
|
||||
}
|
||||
row.extend_from_slice(word);
|
||||
row_width += w;
|
||||
at = end;
|
||||
}
|
||||
if !row.is_empty() {
|
||||
row_ranges.push(row_start..row_start + row.len());
|
||||
rows.push(row);
|
||||
}
|
||||
for row in &rows {
|
||||
let streams = content_streams(doc, &p.dict);
|
||||
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
|
||||
let ops = pieces.parse().ops;
|
||||
// A row the edit doesn't reach keeps its source operators, so its kerning, TJ word gaps and
|
||||
// glyph widths stay exactly as they were instead of being rebuilt from the text state.
|
||||
let plain = BlockStyle { ranges: Vec::new(), ..style.clone() } == BlockStyle::default();
|
||||
let mut kept = if plain {
|
||||
untouched_rows(block, draft, &row_ranges).into_iter().zip(&rows).map(|(same, row)| same && row.iter().all(|c| c.1 < source_count)).collect()
|
||||
} else {
|
||||
vec![false; rows.len()]
|
||||
};
|
||||
let kept_lines: std::collections::HashSet<usize> =
|
||||
kept.iter().enumerate().filter(|(_, k)| **k).filter_map(|(r, _)| block.rows.get(r)).flatten().copied().collect();
|
||||
let rewritten: Vec<&TextLine> = block.lines.iter().filter(|i| !kept_lines.contains(i)).filter_map(|i| lines.get(*i)).collect();
|
||||
// The rewrite goes where the first rewritten row was drawn, so it must share the paragraph's
|
||||
// space. A dropped `'` or `"` also moved to the next line, which a kept row relies on.
|
||||
let lead = rewritten.first().copied().filter(|lead| {
|
||||
lead.origin.ctm.iter().zip(first.origin.ctm).all(|(a, b)| (a - b).abs() < 1e-9)
|
||||
&& !rewritten.iter().flat_map(|l| &l.ops).any(|i| ops.get(*i).is_some_and(|op| op.is("'") || op.is("\"")))
|
||||
});
|
||||
let (members, lead) = match lead {
|
||||
Some(lead) if !kept_lines.is_empty() => (rewritten, lead),
|
||||
_ => {
|
||||
kept.iter_mut().for_each(|k| *k = false);
|
||||
(block.lines.iter().filter_map(|i| lines.get(*i)).collect(), first)
|
||||
}
|
||||
};
|
||||
for (row, _) in rows.iter().zip(&kept).filter(|(_, k)| !**k) {
|
||||
let text: String = row.iter().map(|v| v.0).collect();
|
||||
if pdfcraft_fonts::source_shaping::required(&text) && row.first().is_some_and(|first| row.iter().any(|v| v.1 != first.1 || v.2 != first.2)) {
|
||||
return Err(EditError::Invalid(
|
||||
@@ -534,7 +645,7 @@ pub(super) fn rewrite_styled_block(
|
||||
let measured: Vec<_> = rows.iter().map(|r| measure(r)).collect::<Result<_, _>>()?;
|
||||
let mut shaped_runs = HashMap::new();
|
||||
let mut segment_advances = HashMap::new();
|
||||
for (row_index, row) in rows.iter().enumerate() {
|
||||
for (row_index, row) in rows.iter().enumerate().filter(|(r, _)| !kept.get(*r).copied().unwrap_or(false)) {
|
||||
let mut at = 0;
|
||||
while let Some(item) = row.get(at) {
|
||||
let mut end = at + 1;
|
||||
@@ -589,6 +700,11 @@ pub(super) fn rewrite_styled_block(
|
||||
baseline = anchor.origin.baseline;
|
||||
}
|
||||
let anchor = anchor.unwrap_or(first);
|
||||
if kept.get(row_index).copied().unwrap_or(false) {
|
||||
// Kept rows only occur without spacing or size changes, so this is the step below.
|
||||
baseline -= source_leading;
|
||||
continue;
|
||||
}
|
||||
let w = measured.get(row_index).copied().unwrap_or(0.0);
|
||||
let align = style.align.unwrap_or(if centered { crate::added::Align::Center } else { crate::added::Align::Left });
|
||||
let original_rect = block.rows.get(row_index).and_then(|row| row_rect(row, lines)).unwrap_or(block.rect);
|
||||
@@ -692,14 +808,10 @@ pub(super) fn rewrite_styled_block(
|
||||
};
|
||||
}
|
||||
block_ops.push(Op::new("Q", vec![]));
|
||||
let streams = content_streams(doc, &p.dict);
|
||||
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
|
||||
let ops = pieces.parse().ops;
|
||||
let members: Vec<_> = block.lines.iter().filter_map(|i| lines.get(*i)).collect();
|
||||
let mut drop = coincident_ops(lines, &members);
|
||||
drop.extend(members.iter().flat_map(|l| l.ops.iter().copied()));
|
||||
let o = &first.origin;
|
||||
let start = first.ops.first().copied().unwrap_or(o.bt_op);
|
||||
let o = &lead.origin;
|
||||
let start = lead.ops.first().copied().unwrap_or(o.bt_op);
|
||||
let split = ops
|
||||
.get(o.bt_op..start)
|
||||
.unwrap_or_default()
|
||||
|
||||
@@ -41,6 +41,7 @@ png = "0.18"
|
||||
url = "2.5"
|
||||
idna = "1.1"
|
||||
icu_properties = "2.3"
|
||||
unicode-segmentation = "1.13"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
# Already in the graph through lopdf; used only for the local UTC offset ("today").
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
The façade every frontend talks to. It holds the open documents, their edit history and the tool
|
||||
catalogue; frontends never reach past it into the parsing, rendering or editing crates.
|
||||
|
||||
`word_count::count` provides cancellable, page-at-a-time Unicode word and character statistics
|
||||
with optional semantic exclusions; see [word-count behavior and limits](../../docs/word-count.md).
|
||||
|
||||
- **Layer:** L6. It depends on most of L1–L4 and on nothing above itself. **No UI toolkit** —
|
||||
`egui`, `winit`, `eframe` and `rfd` are forbidden below L7, and `xtask layers` enforces it.
|
||||
- The crate denies `unwrap`/`expect`/`panic!`/`todo!`. Entry points that run untrusted-document
|
||||
|
||||
@@ -24,6 +24,11 @@ pub struct ToolItem {
|
||||
pub icon: &'static str,
|
||||
pub command: &'static str,
|
||||
pub availability: Availability,
|
||||
/// Further commands of the same tool, shown as a drop-down beside the entry: Acrobat's
|
||||
/// Watermark control offers Add, Update and Remove. The first is `command` itself. A UI
|
||||
/// enables each one by its own command's needs, so Update and Remove stay greyed out until
|
||||
/// the document has that kind of mark. Empty for a plain entry.
|
||||
pub actions: &'static [&'static str],
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -45,7 +50,16 @@ pub struct ToolGroup {
|
||||
}
|
||||
|
||||
const fn item(label: &'static str, icon: &'static str, command: &'static str, availability: Availability) -> ToolItem {
|
||||
ToolItem { label, icon, command, availability }
|
||||
ToolItem { label, icon, command, availability, actions: &[] }
|
||||
}
|
||||
|
||||
/// An entry that opens a menu of `actions` (the first one is its `command`).
|
||||
const fn menu_item(label: &'static str, icon: &'static str, actions: &'static [&'static str]) -> ToolItem {
|
||||
let command = match actions {
|
||||
[first, ..] => *first,
|
||||
[] => "",
|
||||
};
|
||||
ToolItem { label, icon, command, availability: Ready, actions }
|
||||
}
|
||||
|
||||
use Availability::{Planned, Provider, Ready};
|
||||
@@ -115,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),
|
||||
@@ -125,14 +140,13 @@ 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),
|
||||
item("Header and footer", "heading", "edit.header_footer", Ready),
|
||||
item("Watermark", "stamp", "edit.watermark", Ready),
|
||||
menu_item("Header and footer", "heading", &["edit.header_footer", "edit.header_footer.update", "edit.header_footer.remove"]),
|
||||
menu_item("Watermark", "stamp", &["edit.watermark", "edit.watermark.update", "edit.watermark.remove"]),
|
||||
item("Link", "link-2", "edit.link", Ready),
|
||||
item("Bates numbering", "hash", "edit.bates", Ready),
|
||||
item("Background", "palette", "edit.background", Ready),
|
||||
menu_item("Background", "palette", &["edit.background", "edit.background.update", "edit.background.remove"]),
|
||||
item("Attach file", "paperclip", "edit.attach", Planned("M12")),
|
||||
],
|
||||
},
|
||||
@@ -458,8 +472,10 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
|
||||
title: "Accessibility",
|
||||
items: &[
|
||||
item("Automatically tag PDF", "tag", "a11y.autotag", Ready),
|
||||
item("Tag form fields", "tag", "a11y.autotag_fields", Ready),
|
||||
item("Change reading options", "book-open", "a11y.reading_options", Ready),
|
||||
item("Check for accessibility", "accessibility", "a11y.check", Ready),
|
||||
item("Find untagged content", "search", "a11y.find_untagged", Ready),
|
||||
item("Open accessibility report", "file-text", "a11y.report", Ready),
|
||||
item("Add alternate text", "image", "a11y.alt_text", Ready),
|
||||
item("Tags panel", "tag", "a11y.tags", Ready),
|
||||
@@ -555,4 +571,38 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_entries_list_registered_commands_starting_with_their_own() {
|
||||
let mut menus = 0;
|
||||
for g in TOOL_GROUPS {
|
||||
for s in g.sections {
|
||||
for i in s.items {
|
||||
if i.actions.is_empty() {
|
||||
continue;
|
||||
}
|
||||
menus += 1;
|
||||
assert_eq!(i.actions[0], i.command, "{}: the first action is the entry's command", i.label);
|
||||
for id in i.actions {
|
||||
assert!(crate::commands::command(id).is_some(), "{id} is not a registered command");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Watermark, Header and footer, Background: Add / Update / Remove each (Acrobat's menu).
|
||||
assert!(menus >= 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_update_and_remove_command_is_reachable_from_a_tool_entry() {
|
||||
let reachable: HashSet<&str> =
|
||||
TOOL_GROUPS.iter().flat_map(|g| g.sections.iter().flat_map(|s| s.items.iter())).flat_map(|i| i.actions.iter().copied()).collect();
|
||||
for id in crate::commands::COMMANDS
|
||||
.iter()
|
||||
.map(|c| c.id)
|
||||
.filter(|id| id.starts_with("edit.") && (id.ends_with(".update") || id.ends_with(".remove")))
|
||||
{
|
||||
assert!(reachable.contains(id), "{id} is in no tool menu");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,6 +190,7 @@ pub const COMMANDS: &[CommandSpec] = &[
|
||||
c("file.revert", "Revert", FILE, None, Undo, "rotate-ccw"),
|
||||
c("print.dialog", "Print…", FILE, Some(Shortcut::cmd("P")), Document, "printer"),
|
||||
c("file.properties", "Document properties…", FILE, Some(Shortcut::cmd("D")), Document, "info"),
|
||||
c("file.word_count", "Word count…", FILE, None, Copying, "hash"),
|
||||
ct("edit.undo", "Undo", EDIT, Some(Shortcut::cmd("Z")), Undo, "undo-2"),
|
||||
ct("edit.redo", "Redo", EDIT, Some(Shortcut::cmd_shift("Z")), Redo, "redo-2"),
|
||||
c("edit.find", "Find…", EDIT, Some(Shortcut::cmd("F")), Document, "search"),
|
||||
@@ -212,6 +213,8 @@ pub const COMMANDS: &[CommandSpec] = &[
|
||||
// Acrobat's column select: drag a rectangle and select only the text inside it. Alt/Option-
|
||||
// drag with the Select tool does the same; this is the way in where the desktop takes
|
||||
// Alt-drag for moving windows (several Linux window managers).
|
||||
// Copy events stay with the frontend so focused text inputs and page clipboard routing keep ownership.
|
||||
c("edit.copy", "Copy", EDIT, None, Copying, "copy"),
|
||||
c("edit.column_select", "Column select", EDIT, None, Document, "text-select"),
|
||||
c("edit.snapshot", "Take a snapshot", EDIT, None, Copying, "camera"),
|
||||
c("view.full_screen", "Full screen mode", VIEW, Some(Shortcut::cmd("L")), Document, "maximize"),
|
||||
@@ -221,6 +224,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"),
|
||||
@@ -326,10 +332,12 @@ pub const COMMANDS: &[CommandSpec] = &[
|
||||
c("ocr.enhance_camera", "Enhance camera image", None, None, Modification, "camera"),
|
||||
c("ocr.recognize_batch", "Recognize text in multiple files…", None, None, Nothing, "files"),
|
||||
c("a11y.check", "Check for accessibility…", None, None, Document, "accessibility"),
|
||||
c("a11y.find_untagged", "Find untagged content", None, None, Document, "search"),
|
||||
c("a11y.autotag", "Automatically tag PDF", None, None, Document, "tag"),
|
||||
c("a11y.report", "Open accessibility report", None, None, Document, "file-text"),
|
||||
c("a11y.reading_options", "Change reading options…", None, None, Document, "book-open"),
|
||||
c("a11y.alt_text", "Add alternate text…", None, None, Modification, "image"),
|
||||
c("a11y.autotag_fields", "Tag form fields", None, None, Modification, "tag"),
|
||||
c("a11y.tags", "Tags panel", None, None, Document, "tag"),
|
||||
c("export.all_images", "Export all images…", FILE, None, Copying, "image"),
|
||||
c("edit.header_footer", "Add header & footer…", None, None, Modification, "heading"),
|
||||
@@ -390,7 +398,7 @@ pub fn is_enabled(spec: &CommandSpec, session: &Session, active: Option<DocId>)
|
||||
Annotate => doc.is_some_and(|d| d.allows_annotation()),
|
||||
Copying => doc.is_some_and(|d| d.allows_copying()),
|
||||
FillForms => doc.is_some_and(|d| d.allows_form_filling() && !d.form.is_empty()),
|
||||
Marks(k) => doc.is_some_and(|d| d.allows_modification() && d.marks.contains(&k)),
|
||||
Marks(k) => doc.is_some_and(|d| d.allows_modification() && d.marks().contains(&k)),
|
||||
HasComments => doc.is_some_and(|d| d.allows_modification() && !d.info.annotations.is_empty()),
|
||||
HasFields => doc.is_some_and(|d| d.allows_modification() && !d.form.is_empty()),
|
||||
HasRedactions => doc.is_some_and(|d| d.allows_modification() && d.redaction_marks() > 0),
|
||||
|
||||
@@ -56,7 +56,7 @@ const AR_DAYS: [&str; 7] = ["الأحد", "الاثنين", "الثلاثاء",
|
||||
|
||||
/// The languages month and weekday names can be written in (Preferences ▸ Date format ▸ Language):
|
||||
/// the interface languages, in the same order and with the same names.
|
||||
pub static DATE_LANGUAGES: [DateLanguage; 22] = [
|
||||
pub static DATE_LANGUAGES: [DateLanguage; 23] = [
|
||||
DateLanguage {
|
||||
code: "en",
|
||||
name: "English",
|
||||
@@ -360,6 +360,44 @@ pub static DATE_LANGUAGES: [DateLanguage; 22] = [
|
||||
days: ["sekmadienis", "pirmadienis", "antradienis", "trečiadienis", "ketvirtadienis", "penktadienis", "šeštadienis"],
|
||||
days_short: ["sk", "pr", "an", "tr", "kt", "pn", "št"],
|
||||
},
|
||||
// Vietnamese: numeric month forms (`tháng 1` with a day, `Tháng 1` standing alone).
|
||||
// Fill & Sign still refuses any format whose final text fails the existing WinAnsi check
|
||||
// (for example weekday names); `dd.mm.yyyy` remains writable.
|
||||
DateLanguage {
|
||||
code: "vi",
|
||||
name: "Tiếng Việt",
|
||||
months: [
|
||||
"Tháng 1",
|
||||
"Tháng 2",
|
||||
"Tháng 3",
|
||||
"Tháng 4",
|
||||
"Tháng 5",
|
||||
"Tháng 6",
|
||||
"Tháng 7",
|
||||
"Tháng 8",
|
||||
"Tháng 9",
|
||||
"Tháng 10",
|
||||
"Tháng 11",
|
||||
"Tháng 12",
|
||||
],
|
||||
months_with_day: Some([
|
||||
"tháng 1",
|
||||
"tháng 2",
|
||||
"tháng 3",
|
||||
"tháng 4",
|
||||
"tháng 5",
|
||||
"tháng 6",
|
||||
"tháng 7",
|
||||
"tháng 8",
|
||||
"tháng 9",
|
||||
"tháng 10",
|
||||
"tháng 11",
|
||||
"tháng 12",
|
||||
]),
|
||||
months_short: ["Thg 1", "Thg 2", "Thg 3", "Thg 4", "Thg 5", "Thg 6", "Thg 7", "Thg 8", "Thg 9", "Thg 10", "Thg 11", "Thg 12"],
|
||||
days: ["Chủ Nhật", "Thứ Hai", "Thứ Ba", "Thứ Tư", "Thứ Năm", "Thứ Sáu", "Thứ Bảy"],
|
||||
days_short: ["CN", "T2", "T3", "T4", "T5", "T6", "T7"],
|
||||
},
|
||||
];
|
||||
|
||||
/// The date language for `code` (any case), or `None` when there's none.
|
||||
@@ -631,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");
|
||||
@@ -683,6 +741,25 @@ mod tests {
|
||||
assert_eq!(s.date_language(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vietnamese_month_and_weekday_names() {
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
s.set_date_language(Some("vi")).unwrap();
|
||||
assert_eq!(s.date_language(), Some("vi"));
|
||||
s.set_date_format("d mmmm yyyy").unwrap();
|
||||
assert_eq!(s.today_text(None, None).as_deref(), Ok("14 tháng 11 2023"));
|
||||
s.set_date_format("mmmm yyyy").unwrap();
|
||||
assert_eq!(s.today_text(None, None).as_deref(), Ok("Tháng 11 2023"));
|
||||
s.set_date_format("ddd, d mmm").unwrap();
|
||||
assert_eq!(s.today_text(None, None).as_deref(), Ok("T3, 14 Thg 11"));
|
||||
// Encoding follows the spelled text: `14 tháng 11 2023` is WinAnsi-representable;
|
||||
// a weekday name such as `Thứ Ba` is not, so Fill & Sign refuses that format.
|
||||
assert_eq!(s.today_text_for_pdf(Some("d mmmm yyyy"), Some("vi")).as_deref(), Ok("14 tháng 11 2023"));
|
||||
let e = s.today_text_for_pdf(Some("dddd d mmmm yyyy"), Some("vi")).unwrap_err();
|
||||
assert!(e.contains("can't be written into the PDF yet") && e.contains("dd.mm.yyyy"), "{e}");
|
||||
assert!(s.today_text_for_pdf(Some("dd.mm.yyyy"), Some("vi")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dates_the_pdf_cannot_hold_are_refused_not_damaged() {
|
||||
assert_eq!(unwritable("8. října 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");
|
||||
}
|
||||
}
|
||||
|
||||
+43
-17
@@ -250,6 +250,15 @@ fn detection_words(text: &pdfcraft_render::PageText, info: &pdfcraft_render::Pag
|
||||
/// A page's words in reading order, in user space ([x0, y0, x1, y1]); runs of underscores are
|
||||
/// words of their own.
|
||||
pub(crate) fn page_words(text: &pdfcraft_render::PageText, info: &pdfcraft_render::PageInfo) -> Vec<(String, [f64; 4])> {
|
||||
words_with(text, |x, y| info.view_to_user(x, y))
|
||||
.into_iter()
|
||||
.map(|(text, r)| (text, [f64::from(r[0]), f64::from(r[1]), f64::from(r[2]), f64::from(r[3])]))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A page's words in reading order, their boxes mapped from view space by `map` (and ordered
|
||||
/// again, so that [x0, y0, x1, y1] holds whatever way `map` turns them).
|
||||
pub(crate) fn words_with(text: &pdfcraft_render::PageText, map: impl Fn(f32, f32) -> [f32; 2]) -> Vec<(String, [f32; 4])> {
|
||||
let mut words: Vec<(String, [f32; 4])> = Vec::new();
|
||||
let mut last_line = u32::MAX;
|
||||
// A space glyph ends the word before it.
|
||||
@@ -281,18 +290,23 @@ pub(crate) fn page_words(text: &pdfcraft_render::PageText, info: &pdfcraft_rende
|
||||
words
|
||||
.into_iter()
|
||||
.map(|(text, r)| {
|
||||
let a = info.view_to_user(r[0], r[1]);
|
||||
let b = info.view_to_user(r[2], r[3]);
|
||||
let rect = [a[0].min(b[0]) as f64, a[1].min(b[1]) as f64, a[0].max(b[0]) as f64, a[1].max(b[1]) as f64];
|
||||
(text, rect)
|
||||
let a = map(r[0], r[1]);
|
||||
let b = map(r[2], r[3]);
|
||||
(text, [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])])
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Prepare a form ▸ automatic field detection: the fields `pages` (0-based; empty = all)
|
||||
/// seem to ask for, named from their labels, as (page, candidate).
|
||||
/// seem to ask for, named from their labels, as (page, candidate). Scanned pages (a picture
|
||||
/// and no drawn shapes) are read from their pixels, see [`crate::scan_fields`].
|
||||
pub fn detect_fields(&self, id: DocId, pages: &[usize]) -> Vec<(usize, pdfcraft_forms::detect::Candidate)> {
|
||||
self.detect_fields_turned(id, pages).into_iter().map(|(page, c, _)| (page, c)).collect()
|
||||
}
|
||||
|
||||
/// [`Self::detect_fields`], with how far each field turns (`/MK /R`) to read upright.
|
||||
fn detect_fields_turned(&self, id: DocId, pages: &[usize]) -> Vec<(usize, pdfcraft_forms::detect::Candidate, i64)> {
|
||||
let Some(doc) = self.get(id) else { return Vec::new() };
|
||||
let Some(cos) = doc.editor.as_ref().map(|e| &e.cos) else { return Vec::new() };
|
||||
let config = pdfcraft_render::RenderConfig { password: doc.password.as_deref().map(std::sync::Arc::from), ..Default::default() };
|
||||
@@ -302,13 +316,19 @@ impl Session {
|
||||
let list: Vec<usize> = if pages.is_empty() { (0..all).collect() } else { pages.iter().copied().filter(|p| *p < all).collect() };
|
||||
let mut out = Vec::new();
|
||||
for page in list {
|
||||
let Some(info) = doc.info.pages.get(page) else { continue };
|
||||
let res = r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() });
|
||||
let words = res.text.map(|t| detection_words(&t, &doc.info.pages[page])).unwrap_or_default();
|
||||
let shapes = pdfcraft_forms::detect::page_shapes(cos, page);
|
||||
let existing: Vec<[f64; 4]> = doc.form.iter().flat_map(|f| f.widgets.iter()).filter(|w| w.page == Some(page)).map(|w| w.rect).collect();
|
||||
for c in pdfcraft_forms::detect::detect(&words, &shapes, &existing, &taken) {
|
||||
let (found, turn) = if shapes.boxes.is_empty() && shapes.rules.is_empty() && crate::scan_fields::is_scan(doc, page, info) {
|
||||
self.detect_on_scan(id, &mut r, page, res.text.as_deref(), &existing, &taken)
|
||||
} else {
|
||||
let words = res.text.map(|t| detection_words(&t, info)).unwrap_or_default();
|
||||
(pdfcraft_forms::detect::detect(&words, &shapes, &existing, &taken), 0)
|
||||
};
|
||||
for c in found {
|
||||
taken.push(c.name.clone());
|
||||
out.push((page, c));
|
||||
out.push((page, c, turn));
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -316,23 +336,29 @@ impl Session {
|
||||
|
||||
/// Detect fields and add them, as one undoable step. Returns the new fields' names.
|
||||
pub fn auto_detect_fields(&mut self, id: DocId, pages: &[usize]) -> Result<Vec<String>, EditError> {
|
||||
let found = self.detect_fields(id, pages);
|
||||
let found = self.detect_fields_turned(id, pages);
|
||||
if found.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let names = found.iter().map(|(_, c)| c.name.clone()).collect();
|
||||
let edits = found
|
||||
.into_iter()
|
||||
.map(|(page, c)| Edit::AddField {
|
||||
let names = found.iter().map(|(_, c, _)| c.name.clone()).collect();
|
||||
let mut edits = Vec::with_capacity(found.len());
|
||||
for (page, c, turn) in found {
|
||||
// A turned field keeps its place: the rectangle is already the one on the page.
|
||||
let props = (turn != 0).then(|| pdfcraft_forms::FieldProps { rotation: Some((0, turn)), rect: Some((0, c.rect)), ..Default::default() });
|
||||
let tall = if turn % 180 == 0 { c.rect[3] - c.rect[1] } else { c.rect[2] - c.rect[0] };
|
||||
edits.push(Edit::AddField {
|
||||
page,
|
||||
rect: c.rect,
|
||||
kind: match c.kind {
|
||||
pdfcraft_forms::detect::Kind::Text => pdfcraft_forms::NewField::Text { multiline: c.rect[3] - c.rect[1] > 30.0 },
|
||||
pdfcraft_forms::detect::Kind::Text => pdfcraft_forms::NewField::Text { multiline: tall > 30.0 },
|
||||
pdfcraft_forms::detect::Kind::CheckBox => pdfcraft_forms::NewField::CheckBox,
|
||||
},
|
||||
name: Some(c.name),
|
||||
})
|
||||
.collect();
|
||||
name: Some(c.name.clone()),
|
||||
});
|
||||
if let Some(props) = props {
|
||||
edits.push(Edit::SetFieldProps { name: c.name, props: Box::new(props) });
|
||||
}
|
||||
}
|
||||
self.apply(id, Edit::Batch { label: "Detect form fields".into(), edits })?;
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
+473
-69
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
|
||||
@@ -17,6 +17,8 @@ pub enum OptimizeStage {
|
||||
/// Removing the user data chosen under Discard User Data.
|
||||
#[default]
|
||||
Discarding,
|
||||
/// Merging the fragments of a page picture into one image.
|
||||
Fragments,
|
||||
/// About to process image `done` (0-based) of `total`.
|
||||
Images { done: usize, total: usize },
|
||||
/// Discarding objects and compressing unencoded streams.
|
||||
@@ -33,6 +35,7 @@ impl OptimizeStage {
|
||||
pub fn fraction(self) -> f32 {
|
||||
match self {
|
||||
OptimizeStage::Discarding => 0.0,
|
||||
OptimizeStage::Fragments => 0.03,
|
||||
OptimizeStage::Images { done, total } => 0.05 + 0.75 * if total == 0 { 1.0 } else { done.min(total) as f32 / total as f32 },
|
||||
OptimizeStage::CleaningUp => 0.8,
|
||||
OptimizeStage::Merging => 0.85,
|
||||
@@ -85,6 +88,7 @@ impl OptimizeJob {
|
||||
let discarded = if discard.is_empty() { Vec::new() } else { pdfcraft_redact::sanitize::remove_hidden(&mut cos, &discard)? };
|
||||
let report = optimize::optimize_with_progress(&mut cos, &settings, &mut |s| {
|
||||
step(match s {
|
||||
optimize::Stage::Fragments => OptimizeStage::Fragments,
|
||||
optimize::Stage::Images { done, total } => OptimizeStage::Images { done, total },
|
||||
optimize::Stage::CleanUp => OptimizeStage::CleaningUp,
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
//! Prepare a form ▸ field detection on scanned pages. A page that is a picture has no text and
|
||||
//! no drawn shapes to read, so it is rendered and its lines and boxes are read from the pixels
|
||||
//! ([`pdfcraft_forms::detect::raster`]). The labels come from the page's text layer (an earlier
|
||||
//! Recognize text), else from text recognition when its models are installed. Without either,
|
||||
//! the fields are still found and get the default names (Text1, Check Box1, …).
|
||||
//!
|
||||
//! Detection works in the page as displayed (after `/Rotate`), in points with y up, so a scan
|
||||
//! stored sideways and turned upright by `/Rotate` reads like any other page. The fields are
|
||||
//! mapped back to user space and turned with the page (`/MK /R`), so typed text reads upright.
|
||||
|
||||
use pdfcraft_forms::detect::{Candidate, Word, detect, raster};
|
||||
use pdfcraft_render::{PageInfo, PageRenderer, PageText, RenderRequest};
|
||||
|
||||
use crate::{DocId, Document, Session, ocr};
|
||||
|
||||
/// Pixels per point the page is rendered at for its lines (144 dpi).
|
||||
const SCALE: f32 = 2.0;
|
||||
/// The longest side of that rendering, in pixels.
|
||||
const MAX_SIDE: f32 = 3000.0;
|
||||
/// A page is a scan when its images cover at least this share of it.
|
||||
const COVERED: f64 = 0.5;
|
||||
/// Recognised "words" less tall than this (points) are specks read as letters, and would make
|
||||
/// the box they sit on look filled in.
|
||||
const MIN_WORD_HEIGHT: f64 = 4.0;
|
||||
|
||||
/// Whether `page` is a picture of a page: images cover most of it.
|
||||
pub(crate) fn is_scan(doc: &Document, page: usize, info: &PageInfo) -> bool {
|
||||
let [cx0, cy0, cx1, cy1] = info.crop.map(f64::from);
|
||||
let page_area = (cx1 - cx0) * (cy1 - cy0);
|
||||
if !page_area.is_finite() || page_area <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
let covered: f64 = doc
|
||||
.page_images(page)
|
||||
.iter()
|
||||
.filter(|i| !i.is_form && i.width > 1 && i.height > 1)
|
||||
.map(|i| {
|
||||
let r = i.visible_rect.unwrap_or(i.rect);
|
||||
let w = (r[2].min(cx1) - r[0].max(cx0)).max(0.0);
|
||||
let h = (r[3].min(cy1) - r[1].max(cy0)).max(0.0);
|
||||
w * h
|
||||
})
|
||||
.filter(|a| a.is_finite())
|
||||
.sum();
|
||||
covered >= COVERED * page_area
|
||||
}
|
||||
|
||||
/// The page as displayed, in points with y up: where detection on a scan works.
|
||||
struct Frame<'a>(&'a PageInfo);
|
||||
|
||||
impl Frame<'_> {
|
||||
/// The box around view-space points (y down).
|
||||
fn view_box(&self, points: impl IntoIterator<Item = [f32; 2]>) -> Option<[f64; 4]> {
|
||||
let h = f64::from(self.0.height);
|
||||
let mut out = [f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY];
|
||||
for [x, y] in points {
|
||||
let (x, y) = (f64::from(x), h - f64::from(y));
|
||||
out = [out[0].min(x), out[1].min(y), out[2].max(x), out[3].max(y)];
|
||||
}
|
||||
out.iter().all(|v| v.is_finite()).then_some(out)
|
||||
}
|
||||
|
||||
/// A user-space rectangle (or the box around user-space points) in the frame.
|
||||
fn user_box(&self, points: impl IntoIterator<Item = [f64; 2]>) -> Option<[f64; 4]> {
|
||||
self.view_box(points.into_iter().map(|[x, y]| self.0.user_to_view(x as f32, y as f32)))
|
||||
}
|
||||
|
||||
/// A frame rectangle in user space.
|
||||
fn user_rect(&self, r: [f64; 4]) -> [f64; 4] {
|
||||
let h = f64::from(self.0.height);
|
||||
let a = self.0.view_to_user(r[0] as f32, (h - r[1]) as f32);
|
||||
let b = self.0.view_to_user(r[2] as f32, (h - r[3]) as f32);
|
||||
[f64::from(a[0].min(b[0])), f64::from(a[1].min(b[1])), f64::from(a[0].max(b[0])), f64::from(a[1].max(b[1]))]
|
||||
}
|
||||
}
|
||||
|
||||
fn corners(r: [f64; 4]) -> [[f64; 2]; 4] {
|
||||
[[r[0], r[1]], [r[2], r[1]], [r[2], r[3]], [r[0], r[3]]]
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The fields on scanned `page` of `id`, in user space, and how far the page turns them
|
||||
/// (its `/Rotate`). `text` is the page's text layer, `existing` its widget rectangles.
|
||||
pub(crate) fn detect_on_scan(
|
||||
&self,
|
||||
id: DocId,
|
||||
r: &mut PageRenderer,
|
||||
page: usize,
|
||||
text: Option<&PageText>,
|
||||
existing: &[[f64; 4]],
|
||||
taken: &[String],
|
||||
) -> (Vec<Candidate>, i64) {
|
||||
let Some(info) = self.get(id).and_then(|d| d.info.pages.get(page)) else { return (Vec::new(), 0) };
|
||||
let frame = Frame(info);
|
||||
let scale = SCALE.min(MAX_SIDE / info.width.max(info.height).max(1.0));
|
||||
let shot = r.render(RenderRequest { page, scale, ..Default::default() });
|
||||
if shot.error.is_some() || shot.width == 0 {
|
||||
return (Vec::new(), 0);
|
||||
}
|
||||
let px_per_pt = f64::from(shot.width) / f64::from(info.width.max(1e-3));
|
||||
let shapes = raster::shapes(&shot.rgba, shot.width, shot.height, px_per_pt);
|
||||
if shapes.boxes.is_empty() && shapes.rules.is_empty() {
|
||||
return (Vec::new(), 0);
|
||||
}
|
||||
let mut words: Vec<Word> = text
|
||||
.map(|t| crate::js::words_with(t, |x, y| [x, y]))
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|(text, v)| Some(Word { text, rect: frame.view_box([[v[0], v[1]], [v[2], v[3]]])? }))
|
||||
.collect();
|
||||
if words.is_empty() {
|
||||
words = self.recognized_words(id, page, &frame);
|
||||
}
|
||||
let existing: Vec<[f64; 4]> = existing.iter().filter_map(|r| frame.user_box(corners(*r))).collect();
|
||||
let found = detect(&words, &shapes, &existing, taken);
|
||||
let turn = i64::from(info.rotation % 360);
|
||||
(found.into_iter().map(|c| Candidate { rect: frame.user_rect(c.rect), ..c }).collect(), turn)
|
||||
}
|
||||
|
||||
/// The words text recognition reads on `page`, in the frame; none without the models.
|
||||
fn recognized_words(&self, id: DocId, page: usize, frame: &Frame) -> Vec<Word> {
|
||||
if !ocr::available() {
|
||||
return Vec::new();
|
||||
}
|
||||
let (Ok(engine), Some(job)) = (ocr::engine(), self.ocr_job(id, &[page], ocr::OcrSettings::default())) else {
|
||||
return Vec::new();
|
||||
};
|
||||
job.run(&engine, |_, _| true)
|
||||
.into_iter()
|
||||
.flat_map(|p| p.words)
|
||||
.filter_map(|w| {
|
||||
let o = w.origin;
|
||||
let points = [
|
||||
o,
|
||||
[o[0] + w.across[0], o[1] + w.across[1]],
|
||||
[o[0] + w.up[0], o[1] + w.up[1]],
|
||||
[o[0] + w.across[0] + w.up[0], o[1] + w.across[1] + w.up[1]],
|
||||
];
|
||||
Some(Word { rect: frame.user_box(points)?, text: w.text })
|
||||
})
|
||||
.filter(|w| w.rect[3] - w.rect[1] >= MIN_WORD_HEIGHT)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
+920
-36
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,20 @@ impl TextEditPreview {
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Background for retyping an added text item, without changing its saved content.
|
||||
pub fn added_text_preview(&self, page: usize, index: usize) -> Result<TextEditPreview, String> {
|
||||
guard(|| {
|
||||
if !self.added().iter().filter(|a| a.page == page).nth(index).is_some_and(|a| matches!(a.content, crate::AddedContent::Text(_))) {
|
||||
return Err("the selected added item is not text".into());
|
||||
}
|
||||
let editor = self.editor.as_ref().ok_or("the document can't be read")?;
|
||||
let mut cos = editor.cos.clone();
|
||||
pdfcraft_edit::delete_content(&mut cos, page, index).map_err(|e| e.to_string())?;
|
||||
let bytes = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).map_err(|e| e.to_string())?);
|
||||
Ok(TextEditPreview { page, bytes, config: self.config.clone() })
|
||||
})?
|
||||
}
|
||||
|
||||
/// Measure a draft without changing bytes, dirty state, or undo history.
|
||||
pub fn text_edit_layout(
|
||||
&self,
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
//! Read-only document statistics, processing one page at a time without rasterizing it.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use pdfcraft_cos::{Dict, ObjRef, Object};
|
||||
use pdfcraft_render::{PageRenderer, text::TextFilter};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
use crate::export::ExportSource;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Options {
|
||||
pub exclude_tables: bool,
|
||||
pub exclude_code: bool,
|
||||
/// Figure-marked text and invisible text (including OCR). This does not run OCR.
|
||||
pub exclude_images: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Counts {
|
||||
pub words: usize,
|
||||
/// Unicode scalar values, including whitespace.
|
||||
pub characters: usize,
|
||||
pub characters_without_spaces: usize,
|
||||
}
|
||||
|
||||
impl Counts {
|
||||
pub fn of(text: &str) -> Self {
|
||||
Self {
|
||||
words: text.unicode_words().count(),
|
||||
characters: text.chars().count(),
|
||||
characters_without_spaces: text.chars().filter(|c| !c.is_whitespace()).count(),
|
||||
}
|
||||
}
|
||||
|
||||
fn add(&mut self, other: Self) {
|
||||
self.words = self.words.saturating_add(other.words);
|
||||
self.characters = self.characters.saturating_add(other.characters);
|
||||
self.characters_without_spaces = self.characters_without_spaces.saturating_add(other.characters_without_spaces);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Report {
|
||||
pub total: Counts,
|
||||
/// Zero-based pages, in the requested order, without duplicates.
|
||||
pub pages: Vec<(usize, Counts)>,
|
||||
pub pages_without_text: usize,
|
||||
/// Some structure could not be classified safely; that text was retained.
|
||||
pub unclassified_structure: bool,
|
||||
}
|
||||
|
||||
/// Count the current document snapshot. `progress` can cancel between pages. A failed page is
|
||||
/// an error, never a successful zero or an unlabelled partial total.
|
||||
pub fn count(source: ExportSource, pages: &[usize], options: Options, progress: impl FnMut(usize) -> bool) -> Result<Report, String> {
|
||||
crate::guard(|| count_inner(source, pages, options, progress))?
|
||||
}
|
||||
|
||||
fn count_inner(source: ExportSource, pages: &[usize], options: Options, mut progress: impl FnMut(usize) -> bool) -> Result<Report, String> {
|
||||
if pages.iter().any(|&page| page >= source.pages) {
|
||||
return Err("the word-count page range is out of bounds".into());
|
||||
}
|
||||
if !progress(0) {
|
||||
return Err("word count cancelled".into());
|
||||
}
|
||||
let (mut excluded, unclassified) = if options != Options::default() { exclusions(&source, options)? } else { (HashMap::new(), false) };
|
||||
let mut renderer = PageRenderer::new(source.bytes, source.config);
|
||||
let mut report = Report { unclassified_structure: unclassified, ..Report::default() };
|
||||
let mut seen = HashSet::new();
|
||||
for &page in pages {
|
||||
if !seen.insert(page) {
|
||||
continue;
|
||||
}
|
||||
if !progress(report.pages.len()) {
|
||||
return Err("word count cancelled".into());
|
||||
}
|
||||
let filter = TextFilter {
|
||||
tables: options.exclude_tables,
|
||||
code: options.exclude_code,
|
||||
images: options.exclude_images,
|
||||
mcids: excluded.remove(&page).unwrap_or_default(),
|
||||
};
|
||||
let text = renderer.filtered_text(page, filter).map_err(|e| format!("page {}: {e}", page.saturating_add(1)))?.plain_text();
|
||||
let counts = Counts::of(&text);
|
||||
report.pages_without_text += usize::from(text.trim().is_empty());
|
||||
report.total.add(counts);
|
||||
report.pages.push((page, counts));
|
||||
}
|
||||
if !progress(report.pages.len()) {
|
||||
return Err("word count cancelled".into());
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn excluded_role(role: &[u8], options: Options) -> bool {
|
||||
(options.exclude_tables && matches!(role, b"Table" | b"TR" | b"TD" | b"TH" | b"THead" | b"TBody" | b"TFoot"))
|
||||
|| (options.exclude_code && role == b"Code")
|
||||
|| (options.exclude_images && role == b"Figure")
|
||||
}
|
||||
|
||||
fn standard_role(doc: &pdfcraft_cos::Document, map: &Dict, role: &[u8]) -> Vec<u8> {
|
||||
let mut role = role.to_vec();
|
||||
for _ in 0..32 {
|
||||
let Some(next) = map.get(&role).map(|o| doc.resolve(o)).and_then(|o| o.as_name().map(<[u8]>::to_vec)) else { break };
|
||||
if next == role {
|
||||
break;
|
||||
}
|
||||
role = next;
|
||||
}
|
||||
role
|
||||
}
|
||||
|
||||
type Exclusions = HashMap<usize, HashSet<i32>>;
|
||||
|
||||
/// Resolve page-local MCIDs through the structure hierarchy, including inherited roles and
|
||||
/// RoleMap aliases. Form XObjects have a separate MCID namespace that the text device does not
|
||||
/// expose: retain their page's MCID-tagged text instead of excluding an unrelated same-numbered
|
||||
/// span. Explicit Table/Code/Figure marked-content tags still work on those pages.
|
||||
fn exclusions(source: &ExportSource, options: Options) -> Result<(Exclusions, bool), String> {
|
||||
let doc = pdfcraft_cos::Document::open_with_password(source.bytes.clone(), source.config.password.as_deref()).map_err(|e| e.to_string())?;
|
||||
let catalog = doc.root().and_then(|id| doc.get(id).as_dict().cloned()).unwrap_or_default();
|
||||
let Some(root) = catalog.get(b"StructTreeRoot").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()) else {
|
||||
return Ok((HashMap::new(), options.exclude_tables || options.exclude_code));
|
||||
};
|
||||
let roles = root.get(b"RoleMap").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default();
|
||||
let pages = pdfcraft_model::pages(&doc);
|
||||
let page_index: HashMap<_, _> = pages.iter().enumerate().map(|(index, page)| (page.obj, index)).collect();
|
||||
let mut stack = root.get(b"K").map(|k| vec![(k.clone(), None::<ObjRef>, false, 0usize)]).unwrap_or_default();
|
||||
let mut seen = HashSet::new();
|
||||
let mut out: Exclusions = HashMap::new();
|
||||
let mut unclassified = false;
|
||||
let mut visited = 0usize;
|
||||
while let Some((object, page, excluded, depth)) = stack.pop() {
|
||||
visited += 1;
|
||||
if visited > 100_000 || depth > 128 {
|
||||
unclassified = true;
|
||||
break;
|
||||
}
|
||||
if let Some(id) = object.as_ref()
|
||||
&& !seen.insert(id)
|
||||
{
|
||||
unclassified = true;
|
||||
continue;
|
||||
}
|
||||
let object = doc.resolve(&object);
|
||||
let mut keep = |id: i64, page: Option<ObjRef>| {
|
||||
if excluded {
|
||||
if let Some((page, id)) = page.and_then(|p| page_index.get(&p).copied()).zip(i32::try_from(id).ok().filter(|id| *id >= 0)) {
|
||||
out.entry(page).or_default().insert(id);
|
||||
} else {
|
||||
unclassified = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
match &*object {
|
||||
Object::Int(id) => keep(*id, page),
|
||||
Object::Array(items) => {
|
||||
if stack.len().saturating_add(items.len()) > 100_000 {
|
||||
unclassified = true;
|
||||
break;
|
||||
}
|
||||
stack.extend(items.iter().rev().map(|child| (child.clone(), page, excluded, depth + 1)));
|
||||
}
|
||||
Object::Dict(dict) => {
|
||||
let page = dict.get(b"Pg").and_then(Object::as_ref).or(page);
|
||||
if dict.contains(b"Stm") {
|
||||
unclassified = true;
|
||||
} else if let Some(id) = dict.get(b"MCID").and_then(Object::as_int) {
|
||||
keep(id, page);
|
||||
} else if let Some(kids) = dict.get(b"K") {
|
||||
let excluded = excluded || dict.name(b"S").is_some_and(|role| excluded_role(&standard_role(&doc, &roles, role), options));
|
||||
stack.push((kids.clone(), page, excluded, depth + 1));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for (index, page) in pages.iter().enumerate() {
|
||||
if !out.contains_key(&index) {
|
||||
continue;
|
||||
}
|
||||
let resources = page.dict.get(b"Resources").map(|o| doc.resolve(o));
|
||||
let objects = resources.as_ref().and_then(|o| o.as_dict()).and_then(|d| d.get(b"XObject")).map(|o| doc.resolve(o));
|
||||
let has_form = objects.as_ref().and_then(|o| o.as_dict()).is_some_and(|d| {
|
||||
d.iter().any(|(_, value)| matches!(&*doc.resolve(value), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Form")))
|
||||
});
|
||||
if has_form {
|
||||
out.remove(&index);
|
||||
unclassified = true;
|
||||
}
|
||||
}
|
||||
Ok((out, unclassified))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn source(bodies: &[&str], structure: &str) -> ExportSource {
|
||||
let kids = (0..bodies.len()).map(|i| format!("{} 0 R", 4 + i * 2)).collect::<Vec<_>>().join(" ");
|
||||
let mut objects = vec![
|
||||
format!("<< /Type /Catalog /Pages 2 0 R {structure} >>"),
|
||||
format!("<< /Type /Pages /Kids [{kids}] /Count {} /MediaBox [0 0 200 300] >>", bodies.len()),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
|
||||
];
|
||||
for (i, body) in bodies.iter().enumerate() {
|
||||
objects.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + i * 2));
|
||||
objects.push(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(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for offset in offsets {
|
||||
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(format!("trailer\n<< /Root 1 0 R /Size {} >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
ExportSource { bytes: std::sync::Arc::new(bytes), config: Default::default(), pages: bodies.len(), sizes: vec![(200.0, 300.0); bodies.len()] }
|
||||
}
|
||||
|
||||
const MARKED: &str = "BT /F1 12 Tf 10 270 Td (Body words) Tj ET
|
||||
/Table BMC BT /F1 12 Tf 10 240 Td (Table words) Tj ET EMC
|
||||
/Code BMC BT /F1 12 Tf 10 210 Td (Code words) Tj ET EMC
|
||||
/Figure BMC BT /F1 12 Tf 10 180 Td (Figure words) Tj ET EMC
|
||||
BT /F1 12 Tf 3 Tr 10 150 Td (OCR words) Tj ET";
|
||||
|
||||
#[test]
|
||||
fn word_count_filters_are_independent_and_do_not_change_normal_extraction() {
|
||||
let src = source(&[MARKED], "");
|
||||
for (options, expected) in [
|
||||
(Options::default(), 10),
|
||||
(Options { exclude_tables: true, ..Options::default() }, 8),
|
||||
(Options { exclude_code: true, ..Options::default() }, 8),
|
||||
(Options { exclude_images: true, ..Options::default() }, 6),
|
||||
(Options { exclude_tables: true, exclude_code: true, exclude_images: true }, 2),
|
||||
] {
|
||||
assert_eq!(count(src.clone(), &[0], options, |_| true).unwrap().total.words, expected);
|
||||
}
|
||||
let mut renderer = PageRenderer::new(src.bytes, src.config);
|
||||
assert_eq!(Counts::of(&renderer.filtered_text(0, TextFilter::default()).unwrap().plain_text()).words, 10);
|
||||
assert_eq!(renderer.stats().rasterizations, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_count_resolves_structure_roles_and_page_local_mcids() {
|
||||
let body = "BT /F1 12 Tf 10 260 Td (Body words) Tj ET /Span << /MCID 0 >> BDC BT /F1 12 Tf 10 220 Td (Tagged words) Tj ET EMC";
|
||||
let src = source(
|
||||
&[body, body],
|
||||
"/StructTreeRoot << /RoleMap << /Grid /Table >> /K [<< /S /Grid /Pg 4 0 R /K << /S /Span /K 0 >> >> << /S /Code /Pg 6 0 R /K << /Type /MCR /MCID 0 >> >>] >>",
|
||||
);
|
||||
let tables = count(src.clone(), &[0, 1], Options { exclude_tables: true, ..Options::default() }, |_| true).unwrap();
|
||||
assert_eq!(tables.pages.iter().map(|(_, c)| c.words).collect::<Vec<_>>(), vec![2, 4]);
|
||||
assert!(!tables.unclassified_structure);
|
||||
let code = count(src, &[1, 0], Options { exclude_code: true, ..Options::default() }, |_| true).unwrap();
|
||||
assert_eq!(code.pages.iter().map(|(_, c)| c.words).collect::<Vec<_>>(), vec![2, 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_count_handles_empty_duplicate_invalid_and_cancelled_ranges() {
|
||||
let src = source(&["BT /F1 12 Tf 10 220 Td (Two words) Tj ET", ""], "");
|
||||
let report = count(src.clone(), &[1, 0, 1], Options::default(), |_| true).unwrap();
|
||||
assert_eq!(report.total.words, 2);
|
||||
assert_eq!(report.pages.len(), 2);
|
||||
assert_eq!(report.pages_without_text, 1);
|
||||
assert_eq!(count(src.clone(), &[], Options::default(), |_| true).unwrap().total, Counts::default());
|
||||
assert!(count(src.clone(), &[2], Options::default(), |_| true).unwrap_err().contains("bounds"));
|
||||
assert!(count(src.clone(), &[0, 1], Options::default(), |done| done < 1).unwrap_err().contains("cancelled"));
|
||||
let invalid = ExportSource { bytes: std::sync::Arc::new(b"not a PDF".to_vec()), ..src };
|
||||
assert!(count(invalid, &[0], Options::default(), |_| true).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_count_actual_text_cannot_reintroduce_excluded_words() {
|
||||
let all = source(&["/Span << /ActualText (OCR words) >> BDC BT /F1 12 Tf 3 Tr 10 220 Td (xx) Tj ET EMC"], "");
|
||||
let images = Options { exclude_images: true, ..Options::default() };
|
||||
assert_eq!(count(all.clone(), &[0], Options::default(), |_| true).unwrap().total.words, 2);
|
||||
assert_eq!(count(all, &[0], images, |_| true).unwrap().total.words, 0);
|
||||
let mixed = source(
|
||||
&[
|
||||
"/Span << /ActualText (Body and code) >> BDC BT /F1 12 Tf 10 220 Td (body) Tj ET /Code BMC BT /F1 12 Tf 50 220 Td (code) Tj ET EMC EMC",
|
||||
],
|
||||
"",
|
||||
);
|
||||
assert_eq!(count(mixed.clone(), &[0], Options::default(), |_| true).unwrap().total.words, 3);
|
||||
assert!(count(mixed, &[0], Options { exclude_code: true, ..Options::default() }, |_| true).unwrap_err().contains("replacement text"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_count_cyclic_structure_is_bounded_and_reported() {
|
||||
// The structure reaches the catalog again instead of a structure child.
|
||||
let src = source(&[MARKED], "/StructTreeRoot << /K << /S /Table /Pg 4 0 R /K [0 1 0 R] >> >> /K 1 0 R");
|
||||
let report = count(src, &[0], Options { exclude_tables: true, ..Options::default() }, |_| true).unwrap();
|
||||
assert!(report.unclassified_structure);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_count_retains_form_namespaces_and_reports_the_limitation() {
|
||||
let body = "/Span << /MCID 0 >> BDC BT /F1 12 Tf 10 220 Td (Page words) Tj ET EMC /Fm Do";
|
||||
let mut src = source(&[body], "/StructTreeRoot << /K << /S /Table /Pg 4 0 R /K 0 >> >>");
|
||||
let mut doc = pdfcraft_cos::Document::open(src.bytes.clone()).unwrap();
|
||||
let page = ObjRef::new(4, 0);
|
||||
let mut resources = doc.get(page).as_dict().unwrap().get(b"Resources").unwrap().as_dict().unwrap().clone();
|
||||
let mut form = Dict::new();
|
||||
form.set(b"Type".to_vec(), Object::Name(b"XObject".to_vec()));
|
||||
form.set(b"Subtype".to_vec(), Object::Name(b"Form".to_vec()));
|
||||
form.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 200.into(), 300.into()]));
|
||||
form.set(b"Resources".to_vec(), resources.clone());
|
||||
let form = doc.add(pdfcraft_cos::Stream::from_raw(form, b"/Span << /MCID 0 >> BDC BT /F1 12 Tf 10 180 Td (Form words) Tj ET EMC".to_vec()));
|
||||
let mut objects = Dict::new();
|
||||
objects.set(b"Fm".to_vec(), form);
|
||||
resources.set(b"XObject".to_vec(), objects);
|
||||
// Inherit resources from the page tree too.
|
||||
doc.update_dict(page, |d| {
|
||||
d.remove(b"Resources");
|
||||
})
|
||||
.unwrap();
|
||||
doc.update_dict(ObjRef::new(2, 0), |d| d.set(b"Resources".to_vec(), resources)).unwrap();
|
||||
src.bytes = std::sync::Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap());
|
||||
let report = count(src, &[0], Options { exclude_tables: true, ..Options::default() }, |_| true).unwrap();
|
||||
assert_eq!(report.total.words, 4, "the unrelated form's MCID must not be excluded");
|
||||
assert!(report.unclassified_structure);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_words_ignore_punctuation_and_keep_apostrophes() {
|
||||
assert_eq!(Counts::of("Hello, Welt! don't … 123").words, 4);
|
||||
assert_eq!(Counts::of("Привет мир").words, 2);
|
||||
assert_eq!(Counts::of("\t \n...").words, 0);
|
||||
assert_eq!(Counts::of("ä b").characters, 3);
|
||||
assert_eq!(Counts::of("ä b").characters_without_spaces, 2);
|
||||
}
|
||||
}
|
||||
+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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ Layer L2. Font helpers for the appearance streams PdfCraft generates (comments,
|
||||
|
||||
Today it holds what generated appearances need without a font program:
|
||||
|
||||
- `helvetica_width`: an approximation of Helvetica's proportions by character class. No metrics
|
||||
file or font program from any vendor is bundled; widths are PdfCraft's own estimates, good
|
||||
enough for line breaking and alignment, not for typesetting.
|
||||
- `helvetica_width`: the **exact** standard-14 advance widths, via `Std14::Helvetica`. It was an
|
||||
estimate by character class until the generated tables landed; those tables are Adobe-authored
|
||||
non-visual technical data, the item `AGENTS.md` §1.1 permits, carried in `std14_metrics.rs` with
|
||||
`adobe_data = true` in `ATTRIBUTION.toml`. **No font program or typeface from any vendor is
|
||||
bundled** — widths and encodings only. Reach for `Std14` directly for any other face.
|
||||
- `wrap`: greedy line breaking with that measure (paragraphs on newlines, long words split).
|
||||
- `win_ansi`: Unicode → WinAnsiEncoding bytes (`?` for characters it can't represent), and
|
||||
`literal` to write bytes as a PDF literal string. Nothing writes that `?` silently. Callers
|
||||
@@ -35,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.
|
||||
|
||||
@@ -172,6 +172,10 @@ pub fn embed_mapping(
|
||||
if name.is_empty() || name.len() > 128 {
|
||||
return Err("the font family name is invalid".into());
|
||||
}
|
||||
let glyph_map = if cff { cid_encoding.as_bytes() } else { gids.as_slice() };
|
||||
if let Some(resource) = same_resource(doc, fonts, &name, bytes, glyph_map, cmap.as_bytes()) {
|
||||
return Ok(resource);
|
||||
}
|
||||
let mut descriptor = Dict::new();
|
||||
descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor"));
|
||||
descriptor.set(b"FontName".to_vec(), Object::name(&name));
|
||||
@@ -187,10 +191,13 @@ pub fn embed_mapping(
|
||||
b"FontBBox".to_vec(),
|
||||
Object::Array([bounds.x_min, bounds.y_min, bounds.x_max, bounds.y_max].map(|v| Object::Real(f64::from(v) * scale)).to_vec()),
|
||||
);
|
||||
// Reuse a full program registered on another page; source subset programs stay untouched.
|
||||
let existing = doc.scan_objects().take(8192).find_map(|(r, o)| match o.as_ref() {
|
||||
Object::Stream(s) if s.dict.get(b"PCFullFont").is_some() && s.raw.as_ref() == bytes => Some(Object::Ref(r)),
|
||||
_ => None,
|
||||
// Reuse the program another resource of this page already has, else one registered on another
|
||||
// page; source subset programs stay untouched.
|
||||
let existing = written(doc, fonts).find(|w| w.has_program(doc, bytes)).map(|w| w.program).or_else(|| {
|
||||
doc.scan_objects().take(8192).find_map(|(r, o)| match o.as_ref() {
|
||||
Object::Stream(s) if s.dict.get(b"PCFullFont").is_some() && s.raw.as_ref() == bytes => Some(Object::Ref(r)),
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
let program = existing.unwrap_or_else(|| {
|
||||
let mut d = Dict::new();
|
||||
@@ -239,6 +246,58 @@ pub fn embed_mapping(
|
||||
Ok(resource)
|
||||
}
|
||||
|
||||
/// The decoded data of the stream `object` refers to.
|
||||
fn stream_data(doc: &Document, object: Option<&Object>) -> Option<Vec<u8>> {
|
||||
match doc.resolve(object?).as_ref() {
|
||||
Object::Stream(s) => s.decoded_within(17 << 20).ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A `PCFull` resource that [`embed_mapping`] wrote.
|
||||
struct Written {
|
||||
resource: String,
|
||||
top: Dict,
|
||||
cid: Dict,
|
||||
/// The reference to its full program.
|
||||
program: Object,
|
||||
}
|
||||
|
||||
impl Written {
|
||||
/// Whether its program is `bytes`. The length it was written with is compared before decoding.
|
||||
fn has_program(&self, doc: &Document, bytes: &[u8]) -> bool {
|
||||
let Object::Stream(s) = doc.resolve(&self.program).as_ref().clone() else { return false };
|
||||
s.dict.get(b"PCFullFont").is_some()
|
||||
&& s.dict.get(b"Length1").and_then(|l| doc.resolve(l).as_int()) == i64::try_from(bytes.len()).ok()
|
||||
&& s.decoded_within(17 << 20).is_ok_and(|data| data == bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// The `PCFull` resources in `fonts`, in order.
|
||||
fn written<'a>(doc: &'a Document, fonts: &'a Dict) -> impl Iterator<Item = Written> + 'a {
|
||||
fonts.iter().filter(|(key, _)| key.starts_with(b"PCFull")).take(256).filter_map(move |(key, value)| {
|
||||
let top = doc.dict(value)?;
|
||||
let cid = doc.dict(doc.resolve(top.get(b"DescendantFonts")?).as_array()?.first()?)?;
|
||||
let descriptor = doc.dict(cid.get(b"FontDescriptor")?)?;
|
||||
let program = descriptor.get(b"FontFile2").or_else(|| descriptor.get(b"FontFile3")).filter(|p| matches!(p, Object::Ref(_)))?.clone();
|
||||
Some(Written { resource: String::from_utf8_lossy(key).into_owned(), top, cid, program })
|
||||
})
|
||||
}
|
||||
|
||||
/// A resource in `fonts` that already is this embedding: the same font name, program, glyph map
|
||||
/// and Unicode map. Editing the same text again reuses it instead of adding another copy.
|
||||
fn same_resource(doc: &Document, fonts: &Dict, name: &str, bytes: &[u8], glyph_map: &[u8], unicode: &[u8]) -> Option<String> {
|
||||
written(doc, fonts)
|
||||
.find(|w| {
|
||||
let glyphs = w.cid.get(b"CIDToGIDMap").or_else(|| w.top.get(b"Encoding"));
|
||||
w.top.name(b"BaseFont") == Some(name.as_bytes())
|
||||
&& stream_data(doc, w.top.get(b"ToUnicode")).is_some_and(|d| d == unicode)
|
||||
&& stream_data(doc, glyphs).is_some_and(|d| d == glyph_map)
|
||||
&& w.has_program(doc, bytes)
|
||||
})
|
||||
.map(|w| w.resource)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -274,4 +333,50 @@ mod tests {
|
||||
assert_eq!(doc.scan_objects().filter(|(_, o)| matches!(o.as_ref(), Object::Stream(s) if s.dict.get(b"PCFullFont").is_some())).count(), 1);
|
||||
assert_eq!(face_key("ABCDEF+Droid-Sans-Fallback"), face_key("Droid Sans Fallback"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedding_the_same_text_again_reuses_its_resource_and_program() {
|
||||
let bytes = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");
|
||||
let face = FontRef::new(bytes).unwrap();
|
||||
let glyph = |c| face.charmap().map(c).unwrap();
|
||||
let programs = |doc: &Document| {
|
||||
doc.scan_objects().filter(|(_, o)| matches!(o.as_ref(), Object::Stream(s) if s.dict.get(b"PCFullFont").is_some())).count()
|
||||
};
|
||||
// Objects before the program push it past the first 8192 that the document-wide search reads.
|
||||
let mut doc = Document::new_empty();
|
||||
for _ in 0..9000 {
|
||||
doc.add(Object::Null);
|
||||
}
|
||||
let mut fonts = Dict::new();
|
||||
let full = embed(&mut doc, &mut fonts, bytes, "Inter").unwrap();
|
||||
let objects = doc.scan_objects().count();
|
||||
assert_eq!(embed(&mut doc, &mut fonts, bytes, "Inter").unwrap(), full, "the same embedding is the same resource");
|
||||
let shaped = [("fi".to_string(), glyph('f')), ("é".to_string(), glyph('é'))];
|
||||
let first = embed_mapping(&mut doc, &mut fonts, bytes, "Inter", &shaped).unwrap();
|
||||
assert_ne!(first, full, "another mapping is another resource");
|
||||
assert_eq!(embed_mapping(&mut doc, &mut fonts, bytes, "Inter", &shaped).unwrap(), first);
|
||||
assert_eq!(embed_mapping(&mut doc, &mut fonts, bytes, "Inter", &shaped[..1]).map(|r| r != first), Ok(true), "a different mapping");
|
||||
assert_eq!(programs(&doc), 1, "one program for every mapping");
|
||||
assert_eq!(fonts.iter().count(), 3);
|
||||
assert!(doc.scan_objects().count() > objects);
|
||||
// Another writer may have compressed the streams since; the page still recognises them.
|
||||
for (r, o) in doc.scan_objects().collect::<Vec<_>>() {
|
||||
if let Object::Stream(st) = o.as_ref()
|
||||
&& st.dict.get(b"Filter").is_none()
|
||||
{
|
||||
let data = st.decoded().unwrap();
|
||||
doc.set(r, Object::Stream(Stream::flate(st.dict.clone(), &data)));
|
||||
}
|
||||
}
|
||||
let objects = doc.scan_objects().count();
|
||||
assert_eq!(embed(&mut doc, &mut fonts, bytes, "Inter").unwrap(), full);
|
||||
assert_eq!(embed_mapping(&mut doc, &mut fonts, bytes, "Inter", &shaped).unwrap(), first);
|
||||
assert_eq!(doc.scan_objects().count(), objects, "nothing new is written");
|
||||
// A different program under the same name is not taken for this one.
|
||||
let other = include_bytes!("../../../assets/fonts/Inter-Medium.ttf");
|
||||
let mut more = fonts.clone();
|
||||
let medium = embed(&mut doc, &mut more, other, "Inter").unwrap();
|
||||
assert!(!fonts.contains(medium.as_bytes()), "{medium} is new");
|
||||
assert_eq!(programs(&doc), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
+97
-1
@@ -37,6 +37,9 @@ pub struct Metrics {
|
||||
/// Code → CID for embedded non-identity CMaps (`cidrange` / `cidchar`).
|
||||
cid_map: Vec<(u32, u32, u32)>,
|
||||
default: f64,
|
||||
/// Composite fonts with an explicit `/DW`: `/W` may leave out the glyphs that have that
|
||||
/// width (Chrome and Skia do), so a CID missing from `/W` is not a missing glyph.
|
||||
explicit_default: bool,
|
||||
/// The standard-14 face to measure with when the dictionary carries no `/Widths`.
|
||||
std14: Option<crate::Std14>,
|
||||
/// Glyph units → text space (0.001, or `/FontMatrix[0]` for Type 3).
|
||||
@@ -61,6 +64,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 +135,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<()> {
|
||||
@@ -525,6 +563,7 @@ impl Metrics {
|
||||
cid_ranges: Vec::new(),
|
||||
cid_map: Vec::new(),
|
||||
default: 500.0,
|
||||
explicit_default: false,
|
||||
std14: Some(crate::Std14::Helvetica),
|
||||
scale: 0.001,
|
||||
ascent: 0.9,
|
||||
@@ -538,6 +577,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 +598,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,
|
||||
@@ -611,6 +654,7 @@ impl Metrics {
|
||||
.unwrap_or_default();
|
||||
if let Some(dw) = desc.get(b"DW").and_then(|d| doc.resolve(d).as_f64()) {
|
||||
m.default = dw;
|
||||
m.explicit_default = true;
|
||||
}
|
||||
if let Some(w) = desc.get(b"W").map(|w| doc.resolve(w)).and_then(|w| w.as_array().cloned()) {
|
||||
let mut i = 0;
|
||||
@@ -749,10 +793,20 @@ impl Metrics {
|
||||
}
|
||||
|
||||
/// Whether the font has a glyph for `code` (subset fonts lack the glyphs they don't use).
|
||||
/// Only asked for codes the font's Unicode mapping gives: a subset's `ToUnicode` lists the
|
||||
/// glyphs it kept, while its `/W` leaves out those of the default width (#1443).
|
||||
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)
|
||||
// No readable TrueType program to ask (e.g. CFF or missing): an explicit /DW
|
||||
// still says /W may leave out glyphs of that width (#1443).
|
||||
|| (self.outlined_cids.is_empty() && self.explicit_default);
|
||||
}
|
||||
match code.checked_sub(self.first).and_then(|i| self.widths.get(i as usize)) {
|
||||
Some(w) => *w > 0.0 || code == 32,
|
||||
@@ -1330,6 +1384,48 @@ mod tests {
|
||||
assert_eq!(m.encode("Z"), None);
|
||||
}
|
||||
|
||||
/// Chrome's subsets leave the glyphs of `/DW` width out of `/W` (#1443): "s" (CID 0x56) is
|
||||
/// still in the font, but nothing is when the font gives no default width to point at.
|
||||
#[test]
|
||||
fn composite_subset_glyphs_of_the_default_width_are_present() {
|
||||
let metrics = |dw: bool| {
|
||||
let mut doc = Document::new_empty();
|
||||
let cmap = b"/CIDInit /ProcSet findresource begin 1 begincodespacerange <0000> <FFFF> endcodespacerange \
|
||||
1 beginbfrange <0055> <0057> <0072> endbfrange end";
|
||||
let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec())));
|
||||
let mut desc = Dict::new();
|
||||
desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2"));
|
||||
if dw {
|
||||
desc.set(b"DW".to_vec(), Object::Int(500));
|
||||
}
|
||||
desc.set(
|
||||
b"W".to_vec(),
|
||||
Object::Array(vec![
|
||||
Object::Int(0x55),
|
||||
Object::Array(vec![Object::Int(333)]),
|
||||
Object::Int(0x57),
|
||||
Object::Array(vec![Object::Int(277)]),
|
||||
]),
|
||||
);
|
||||
let f = font(
|
||||
&mut doc,
|
||||
vec![
|
||||
("Subtype", Object::name("Type0")),
|
||||
("BaseFont", Object::name("AAAAAA+LiberationSans")),
|
||||
("Encoding", Object::name("Identity-H")),
|
||||
("DescendantFonts", Object::Array(vec![Object::Dict(desc)])),
|
||||
("ToUnicode", Object::Ref(tu)),
|
||||
],
|
||||
);
|
||||
Metrics::from_dict(&doc, &f)
|
||||
};
|
||||
let chrome = metrics(true);
|
||||
assert_eq!(chrome.encode("rst").as_deref(), Some(&[0, 0x55, 0, 0x56, 0, 0x57][..]));
|
||||
assert_eq!(chrome.width(0x56), 0.5);
|
||||
assert_eq!(chrome.encode("x"), None, "a character the ToUnicode map doesn't give");
|
||||
assert_eq!(metrics(false).encode("s"), None);
|
||||
}
|
||||
|
||||
/// A `/W` run that starts at the last CID stops there instead of overflowing onto CID 0.
|
||||
#[test]
|
||||
fn composite_width_run_past_the_last_cid_is_dropped() {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+46
-5
@@ -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))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -624,10 +626,8 @@ pub fn parse_date(text: &str, fmt: &str) -> Option<DateTime> {
|
||||
match k {
|
||||
// Literal digits in the format may be typed or left out ("15.05.26" or "15.05"
|
||||
// for "dd.mm.26"); a different number there is left over and rejected below.
|
||||
'#' => {
|
||||
if it.peek() == Some(&literal) {
|
||||
it.next();
|
||||
}
|
||||
'#' if it.peek() == Some(&literal) => {
|
||||
it.next();
|
||||
}
|
||||
'm' => {
|
||||
dt.m = match month_name {
|
||||
@@ -866,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> {
|
||||
@@ -1026,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]
|
||||
|
||||
+153
-18
@@ -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 };
|
||||
@@ -285,6 +318,93 @@ fn frame(doc: &Document, wd: &Dict, w: f64, h: f64) -> (String, f64) {
|
||||
(c, 0.0)
|
||||
}
|
||||
|
||||
/// The width [`frame`] insets the content by: the `/BS` border width when the widget has a
|
||||
/// border colour, doubled for beveled and inset borders, and 0 without a border.
|
||||
fn border_width(doc: &Document, wd: &Dict) -> f64 {
|
||||
let mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned());
|
||||
let bs = wd.get(b"BS").map(|b| doc.resolve(b)).and_then(|b| b.as_dict().cloned());
|
||||
let bw = bs.as_ref().and_then(|b| b.get(b"W")).and_then(|x| doc.resolve(x).as_f64()).unwrap_or(1.0).max(0.0);
|
||||
if color_array(doc, mk.as_ref(), b"BC").is_none() || bw <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let style = bs.as_ref().and_then(|b| b.name(b"S").map(<[u8]>::to_vec)).unwrap_or_default();
|
||||
if style == b"B" || style == b"I" { 2.0 * bw } else { bw }
|
||||
}
|
||||
|
||||
/// Where a list box draws its options: one row per option from the top, starting with option
|
||||
/// `top` (`/TI`, §12.7.4.4). The appearance PdfCraft writes and the viewer's in-place list share
|
||||
/// it, so a click picks the option drawn under the pointer (#1524).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ListRows {
|
||||
/// The first option shown (`/TI`).
|
||||
pub top: usize,
|
||||
/// From the widget's top edge to the first row, in points.
|
||||
pub inset: f64,
|
||||
/// The height of one row, in points.
|
||||
pub row: f64,
|
||||
/// The text size, in points.
|
||||
pub size: f64,
|
||||
}
|
||||
|
||||
impl ListRows {
|
||||
/// How many whole rows fit in a list box `height` points tall (at least one).
|
||||
pub fn visible(&self, height: f64) -> usize {
|
||||
if !(height.is_finite() && self.row > 0.0) {
|
||||
return 1;
|
||||
}
|
||||
let rows = ((height - self.inset) / self.row).floor();
|
||||
if rows >= 1.0 { rows.min(10_000.0) as usize } else { 1 }
|
||||
}
|
||||
|
||||
/// The option at `y` points below the widget's top edge when the list starts at option `top`,
|
||||
/// or `None` above the first row. The caller checks it against the number of options.
|
||||
pub fn option_at(&self, top: usize, y: f64) -> Option<usize> {
|
||||
if !(y.is_finite() && self.row > 0.0) || y < self.inset {
|
||||
return None;
|
||||
}
|
||||
let k = ((y - self.inset) / self.row).floor();
|
||||
(k < 1.0e6).then(|| top.saturating_add(k as usize))
|
||||
}
|
||||
|
||||
/// The top index that shows as many of the `selected` option indices as fit in `visible`
|
||||
/// rows, staying at `current` when they are all shown already, among `count` options.
|
||||
pub fn top_showing(current: usize, selected: &[usize], visible: usize, count: usize) -> usize {
|
||||
let visible = visible.max(1);
|
||||
let last_top = count.saturating_sub(visible);
|
||||
let (Some(&first), Some(&last)) = (selected.iter().min(), selected.iter().max()) else { return current.min(last_top) };
|
||||
let top = if first >= current && last < current.saturating_add(visible) {
|
||||
current
|
||||
} else if last.saturating_sub(first) >= visible || first < current {
|
||||
first
|
||||
} else {
|
||||
// Scroll down just far enough that the last selected option is the bottom row.
|
||||
last.saturating_add(1).saturating_sub(visible)
|
||||
};
|
||||
top.min(last_top)
|
||||
}
|
||||
}
|
||||
|
||||
/// `/TI` of a list box: on the widget, else on the field.
|
||||
fn top_index(doc: &Document, wd: &Dict, field: Option<&Dict>) -> usize {
|
||||
let ti = |d: &Dict| d.get(b"TI").and_then(|o| doc.resolve(o).as_int());
|
||||
ti(wd).or_else(|| field.and_then(ti)).and_then(|i| usize::try_from(i).ok()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The rows of a list box whose field's `/DA` is `f_da`, in widget `wd`.
|
||||
pub(crate) fn list_rows_of(doc: &Document, f_da: &str, field: Option<&Dict>, wd: &Dict) -> ListRows {
|
||||
let da = parse_da(wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).as_deref().unwrap_or(f_da));
|
||||
let size = if da.size > 0.0 { da.size } else { 12.0 };
|
||||
ListRows { top: top_index(doc, wd, field), inset: 2.0 + border_width(doc, wd), row: size * 1.15, size }
|
||||
}
|
||||
|
||||
/// The rows of list box `f` as drawn in widget `w`.
|
||||
pub fn list_rows(doc: &Document, f: &Field, w: &Widget) -> ListRows {
|
||||
let wobj = doc.get(w.obj);
|
||||
let wd = wobj.as_dict().cloned().unwrap_or_default();
|
||||
let fobj = doc.get(f.obj);
|
||||
list_rows_of(doc, &f.da, fobj.as_dict(), &wd)
|
||||
}
|
||||
|
||||
/// The appearance of a text or choice field's widget showing `values`. Characters its font can't
|
||||
/// hold come out as `?`; [`field_appearance_drawn`] says which.
|
||||
pub fn field_appearance(doc: &Document, f: &Field, w: &Widget, values: &[String]) -> Stream {
|
||||
@@ -367,14 +487,13 @@ pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[S
|
||||
let mut size = da.size;
|
||||
match f.kind {
|
||||
FieldKind::List => {
|
||||
// Every option, one per line from the top; selected ones highlighted.
|
||||
if size == 0.0 {
|
||||
size = 12.0;
|
||||
}
|
||||
let line = size * 1.15;
|
||||
let top = wd.get(b"TI").and_then(|o| doc.resolve(o).as_int()).unwrap_or(0).max(0) as usize;
|
||||
let mut y = height - pad;
|
||||
for (export, display) in f.options.iter().skip(top) {
|
||||
// Every option, one per line from the top (from `/TI`); selected ones highlighted.
|
||||
let fobj = doc.get(f.obj);
|
||||
let rows = list_rows_of(doc, &f.da, fobj.as_dict(), &wd);
|
||||
size = rows.size;
|
||||
let line = rows.row;
|
||||
let mut y = height - rows.inset;
|
||||
for (export, display) in f.options.iter().skip(rows.top) {
|
||||
if y - line < 0.0 {
|
||||
break;
|
||||
}
|
||||
@@ -389,11 +508,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 +520,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;
|
||||
@@ -588,4 +704,23 @@ mod tests {
|
||||
assert_eq!(parse_da("garbage").font, "Helv");
|
||||
assert_eq!(stroke_op("1 0 0 rg"), "1 0 0 RG");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_rows_visibility_and_scrolling() {
|
||||
let rows = ListRows { top: 0, inset: 2.0, row: 11.5, size: 10.0 };
|
||||
assert_eq!(rows.visible(46.0), 3);
|
||||
assert_eq!(rows.option_at(0, 1.0), None);
|
||||
assert_eq!(rows.option_at(0, 5.0), Some(0));
|
||||
assert_eq!(rows.option_at(0, 15.0), Some(1));
|
||||
assert_eq!(rows.option_at(2, 5.0), Some(2));
|
||||
|
||||
// When Tue (1) and Thu (3) are selected in 5 options with 3 visible rows:
|
||||
assert_eq!(ListRows::top_showing(0, &[1, 3], 3, 5), 1);
|
||||
// When staying within view:
|
||||
assert_eq!(ListRows::top_showing(1, &[1, 2], 3, 5), 1);
|
||||
// When selecting before current top:
|
||||
assert_eq!(ListRows::top_showing(2, &[0], 3, 5), 0);
|
||||
// When empty selection:
|
||||
assert_eq!(ListRows::top_showing(0, &[], 3, 5), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -655,6 +657,7 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel
|
||||
locked: false,
|
||||
hidden: false,
|
||||
rotation: 0,
|
||||
list: None,
|
||||
};
|
||||
let ap = appearance::check_box_states(doc, &w, FieldKind::Radio, export);
|
||||
doc.update_dict(widget, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?;
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
//! field from the label next to it (Acrobat's auto-naming).
|
||||
//!
|
||||
//! [`detect`] is a pure function over the page's words and drawn shapes; [`page_shapes`] reads
|
||||
//! the shapes from a page's content.
|
||||
//! the shapes from a page's content, [`raster::shapes`] from a picture of the page (a scan).
|
||||
|
||||
use pdfcraft_content::{Matrix, parse};
|
||||
use pdfcraft_cos::{Document, Object};
|
||||
|
||||
pub mod raster;
|
||||
|
||||
/// A piece of text on the page, in user space.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Word {
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
//! Field detection on scanned pages: the [`Shapes`] that [`detect`](super::detect) needs, read
|
||||
//! from a picture of the page instead of its content stream.
|
||||
//!
|
||||
//! Steps:
|
||||
//! - Ink is a pixel clearly darker than the paper around it (a local mean, so the uneven paper
|
||||
//! of a scan or photo still reads as paper).
|
||||
//! - Straight lines are runs of ink at least [`MIN_LINE`] long, horizontal or vertical, with
|
||||
//! short gaps (noise, a faint print) bridged.
|
||||
//! - A box is a patch of paper that the lines enclose and that is close to a rectangle. Its
|
||||
//! writing area is the whole box when it is empty, or else the largest band of empty rows or
|
||||
//! columns in it (many official forms print a small label in the corner of each box).
|
||||
//! - The remaining horizontal lines, the ones that are not the edge of a box, are rules.
|
||||
//!
|
||||
//! Everything is bounded: pictures larger than [`MAX_PIXELS`] are refused (the caller renders
|
||||
//! at a resolution that fits), and the results are capped.
|
||||
|
||||
use super::Shapes;
|
||||
|
||||
/// The largest picture analysed (about A3 at 200 dpi).
|
||||
pub const MAX_PIXELS: usize = 12_000_000;
|
||||
/// The shortest run of ink that counts as a line, in points (a check box side is at least 5).
|
||||
const MIN_LINE: f64 = 4.0;
|
||||
/// Gaps up to this long (points) inside a line are bridged.
|
||||
const GAP: f64 = 1.0;
|
||||
/// Lines thicker than this (points, on average) are dark areas, not rules.
|
||||
const MAX_THICKNESS: f64 = 3.0;
|
||||
/// The shortest rule reported, in points ([`detect`](super::detect) wants 40 for a field).
|
||||
const MIN_RULE: f64 = 20.0;
|
||||
/// A box is at least this share of its bounding rectangle (a skewed scan loses a little).
|
||||
const RECTANGULAR: f64 = 0.75;
|
||||
/// Boxes taller than this (points) are frames around parts of the form, not places to write.
|
||||
const MAX_BOX_HEIGHT: f64 = 120.0;
|
||||
/// At most this many boxes and rules are reported.
|
||||
const MAX_SHAPES: usize = 4000;
|
||||
|
||||
/// A grey picture (0 black, 255 white) and its size.
|
||||
struct Grey {
|
||||
w: usize,
|
||||
h: usize,
|
||||
v: Vec<u8>,
|
||||
}
|
||||
|
||||
/// The shapes in `rgba` (premultiplied RGBA8, `width` × `height`, the whole page as displayed),
|
||||
/// where `px_per_pt` pixels make a point. Coordinates are points with the origin at the
|
||||
/// picture's bottom-left and y up, the frame [`detect`](super::detect) expects. Empty when the
|
||||
/// picture is malformed or larger than [`MAX_PIXELS`].
|
||||
pub fn shapes(rgba: &[u8], width: u32, height: u32, px_per_pt: f64) -> Shapes {
|
||||
let (w, h) = (width as usize, height as usize);
|
||||
let valid = w >= 8
|
||||
&& h >= 8
|
||||
&& px_per_pt.is_finite()
|
||||
&& (0.25..=20.0).contains(&px_per_pt)
|
||||
&& w.checked_mul(h).is_some_and(|n| n <= MAX_PIXELS)
|
||||
&& w.checked_mul(h).and_then(|n| n.checked_mul(4)) == Some(rgba.len());
|
||||
if !valid {
|
||||
return Shapes::default();
|
||||
}
|
||||
let grey = grey(rgba, w, h);
|
||||
let ink = ink(&grey, px_per_pt);
|
||||
let px = |pt: f64| (pt * px_per_pt).round().max(1.0) as usize;
|
||||
let (min_line, gap) = (px(MIN_LINE), px(GAP));
|
||||
let horizontal = runs(&ink, w, h, min_line, gap, true);
|
||||
let vertical = runs(&ink, w, h, min_line, gap, false);
|
||||
// The lines, a pixel thicker so that a hairline crack in a corner still encloses.
|
||||
let mut lines: Vec<bool> = horizontal.iter().zip(&vertical).map(|(a, b)| *a || *b).collect();
|
||||
lines = dilate(&lines, w, h);
|
||||
let areas = enclosed(&lines, &ink, w, h, px_per_pt);
|
||||
let to_pt = |x: usize, y: usize| [x as f64 / px_per_pt, (h as f64 - y as f64) / px_per_pt];
|
||||
let mut out = Shapes::default();
|
||||
for [x0, y0, x1, y1] in areas.into_iter().take(MAX_SHAPES) {
|
||||
let (a, b) = (to_pt(x0, y1), to_pt(x1, y0));
|
||||
out.boxes.push([a[0], a[1], b[0], b[1]]);
|
||||
}
|
||||
let tol = px(2.0);
|
||||
// Whether a vertical line passes the end of a horizontal one at column `x`.
|
||||
let attached = |x: usize, l: &Component| {
|
||||
(l.y0.saturating_sub(tol)..l.y1.saturating_add(tol).min(h))
|
||||
.any(|y| (x.saturating_sub(tol)..x.saturating_add(tol + 1).min(w)).any(|x| vertical.get(y * w + x).copied().unwrap_or(false)))
|
||||
};
|
||||
for l in components(&horizontal, w, h) {
|
||||
let len = l.x1 - l.x0;
|
||||
let thick = l.count as f64 / len.max(1) as f64;
|
||||
if (len as f64) < MIN_RULE * px_per_pt || thick > MAX_THICKNESS * px_per_pt {
|
||||
continue;
|
||||
}
|
||||
let y = l.y_sum as f64 / l.count.max(1) as f64;
|
||||
// The scan's own border is not a line on the form.
|
||||
if y < tol as f64 || y > h.saturating_sub(tol) as f64 {
|
||||
continue;
|
||||
}
|
||||
// Closed at both ends: the edge of a box, a table or a frame, not a line to write on.
|
||||
if attached(l.x0, &l) && attached(l.x1 - 1, &l) {
|
||||
continue;
|
||||
}
|
||||
let y = (h as f64 - y) / px_per_pt;
|
||||
if out.rules.len() < MAX_SHAPES {
|
||||
out.rules.push([l.x0 as f64 / px_per_pt, y, l.x1 as f64 / px_per_pt, y]);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Luminance over white paper (a transparent pixel is paper).
|
||||
fn grey(rgba: &[u8], w: usize, h: usize) -> Grey {
|
||||
let v = rgba
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|&[r, g, b, a]| {
|
||||
let (r, g, b, a) = (u32::from(r), u32::from(g), u32::from(b), u32::from(a));
|
||||
// Premultiplied over white: c + (255 - a).
|
||||
let l = (r * 77 + g * 150 + b * 29) >> 8;
|
||||
(l + (255 - a)).min(255) as u8
|
||||
})
|
||||
.collect();
|
||||
Grey { w, h, v }
|
||||
}
|
||||
|
||||
/// Which pixels are ink: darker than 80 % of the mean around them (a window of about 20 points),
|
||||
/// or dark outright.
|
||||
fn ink(g: &Grey, px_per_pt: f64) -> Vec<bool> {
|
||||
let (w, h) = (g.w, g.h);
|
||||
let r = ((10.0 * px_per_pt).round() as usize).clamp(2, 200);
|
||||
// A separable box mean: rows first, then columns. Sums stay below 255 × 401 × 401 < 2^32.
|
||||
let mut rows = vec![0u32; w * h];
|
||||
for y in 0..h {
|
||||
let line = g.v.get(y * w..(y + 1) * w).unwrap_or_default();
|
||||
let mut sum: u32 = line.iter().take(r + 1).map(|v| u32::from(*v)).sum();
|
||||
let mut n = (r + 1).min(w) as u32;
|
||||
for x in 0..w {
|
||||
if let Some(slot) = rows.get_mut(y * w + x) {
|
||||
*slot = sum / n.max(1);
|
||||
}
|
||||
if let Some(v) = line.get(x + r + 1) {
|
||||
sum += u32::from(*v);
|
||||
n += 1;
|
||||
}
|
||||
if x >= r
|
||||
&& let Some(v) = line.get(x - r)
|
||||
{
|
||||
sum = sum.saturating_sub(u32::from(*v));
|
||||
n = n.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = vec![false; w * h];
|
||||
for x in 0..w {
|
||||
let at = |y: usize| rows.get(y * w + x).copied().unwrap_or(255);
|
||||
let mut sum: u32 = (0..=r.min(h - 1)).map(at).sum();
|
||||
let mut n = (r + 1).min(h) as u32;
|
||||
for y in 0..h {
|
||||
let mean = sum / n.max(1);
|
||||
let i = y * w + x;
|
||||
let v = u32::from(g.v.get(i).copied().unwrap_or(255));
|
||||
if let Some(slot) = out.get_mut(i) {
|
||||
*slot = v < 96 || (v * 5 < mean * 4 && v < 224);
|
||||
}
|
||||
if y + r + 1 < h {
|
||||
sum += at(y + r + 1);
|
||||
n += 1;
|
||||
}
|
||||
if y >= r {
|
||||
sum = sum.saturating_sub(at(y - r));
|
||||
n = n.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The pixels on straight runs of ink at least `min` long, along rows (`across`) or columns,
|
||||
/// with gaps up to `gap` bridged. A run is nearly solid: a word in small print, its letters
|
||||
/// run together by noise, has more gaps than a line.
|
||||
fn runs(ink: &[bool], w: usize, h: usize, min: usize, gap: usize, across: bool) -> Vec<bool> {
|
||||
let mut out = vec![false; w * h];
|
||||
let (outer, inner) = if across { (h, w) } else { (w, h) };
|
||||
let index = |o: usize, i: usize| if across { o * w + i } else { i * w + o };
|
||||
for o in 0..outer {
|
||||
// The current run: its first and last inked position and how many are inked.
|
||||
let mut run: Option<(usize, usize, usize)> = None;
|
||||
let close = |run: Option<(usize, usize, usize)>, out: &mut Vec<bool>| {
|
||||
if let Some((a, b, n)) = run
|
||||
&& b + 1 - a >= min
|
||||
&& n * 4 >= (b + 1 - a) * 3
|
||||
{
|
||||
for i in a..=b {
|
||||
if let Some(slot) = out.get_mut(index(o, i)) {
|
||||
*slot = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
for i in 0..inner {
|
||||
if !ink.get(index(o, i)).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
run = match run {
|
||||
Some((a, b, n)) if i - b <= gap + 1 => Some((a, i, n + 1)),
|
||||
other => {
|
||||
close(other, &mut out);
|
||||
Some((i, i, 1))
|
||||
}
|
||||
};
|
||||
}
|
||||
close(run, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `mask` grown by one pixel in each of the four directions.
|
||||
fn dilate(mask: &[bool], w: usize, h: usize) -> Vec<bool> {
|
||||
let at = |x: usize, y: usize| mask.get(y * w + x).copied().unwrap_or(false);
|
||||
let mut out = vec![false; w * h];
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let on = at(x, y) || (x > 0 && at(x - 1, y)) || (x + 1 < w && at(x + 1, y)) || (y > 0 && at(x, y - 1)) || (y + 1 < h && at(x, y + 1));
|
||||
if let Some(slot) = out.get_mut(y * w + x) {
|
||||
*slot = on;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Where to write in the rectangular patches of paper that `lines` enclose (none that touch
|
||||
/// the picture's edge), in pixels: [x0, y0, x1, y1) with y down.
|
||||
fn enclosed(lines: &[bool], ink: &[bool], w: usize, h: usize, px_per_pt: f64) -> Vec<[usize; 4]> {
|
||||
let mut label = vec![0u32; w * h];
|
||||
let mut out = Vec::new();
|
||||
let mut stack: Vec<usize> = Vec::new();
|
||||
let mut next: u32 = 0;
|
||||
let max_h = (MAX_BOX_HEIGHT * px_per_pt) as usize;
|
||||
for start in 0..w * h {
|
||||
if lines.get(start).copied().unwrap_or(true) || label.get(start).copied().unwrap_or(1) != 0 {
|
||||
continue;
|
||||
}
|
||||
next = next.saturating_add(1);
|
||||
if next == u32::MAX {
|
||||
break;
|
||||
}
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0, 0);
|
||||
let mut count = 0usize;
|
||||
let mut border = false;
|
||||
stack.clear();
|
||||
stack.push(start);
|
||||
if let Some(l) = label.get_mut(start) {
|
||||
*l = next;
|
||||
}
|
||||
while let Some(i) = stack.pop() {
|
||||
let (x, y) = (i % w, i / w);
|
||||
count += 1;
|
||||
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x + 1), y1.max(y + 1));
|
||||
border |= x == 0 || y == 0 || x + 1 == w || y + 1 == h;
|
||||
let mut visit = |j: usize| {
|
||||
if !lines.get(j).copied().unwrap_or(true)
|
||||
&& let Some(l) = label.get_mut(j)
|
||||
&& *l == 0
|
||||
{
|
||||
*l = next;
|
||||
stack.push(j);
|
||||
}
|
||||
};
|
||||
if x > 0 {
|
||||
visit(i - 1);
|
||||
}
|
||||
if x + 1 < w {
|
||||
visit(i + 1);
|
||||
}
|
||||
if y > 0 {
|
||||
visit(i - w);
|
||||
}
|
||||
if y + 1 < h {
|
||||
visit(i + w);
|
||||
}
|
||||
}
|
||||
let (bw, bh) = (x1.saturating_sub(x0), y1.saturating_sub(y0));
|
||||
let min = (MIN_LINE * px_per_pt) as usize;
|
||||
if border || bw < min || bh < min || bh > max_h || (count as f64) < RECTANGULAR * (bw * bh) as f64 {
|
||||
continue;
|
||||
}
|
||||
if let Some(area) = writing_area(&label, ink, [w, h], [x0, y0, x1, y1], next, px_per_pt) {
|
||||
out.push(area);
|
||||
}
|
||||
if out.len() >= MAX_SHAPES {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Where to write in the region `id` with bounding rectangle `rect`, in pixels: all of it when it is empty,
|
||||
/// else the largest band of empty rows big enough for a line of handwriting (below or above a
|
||||
/// label), else the largest such band of columns (beside one). A small square (a check box)
|
||||
/// with ink in it is already ticked or is a letter, not a blank. Ink counts anywhere in the
|
||||
/// rectangle except inside other regions: the strokes of a label are lines of their own.
|
||||
///
|
||||
/// A side of the area that is a side of the region reaches out to the middle of the line around
|
||||
/// it (the region stops short of it by the dilation and half a line); a side that faces a label
|
||||
/// keeps a point away from it.
|
||||
fn writing_area(label: &[u32], ink: &[bool], [w, h]: [usize; 2], rect: [usize; 4], id: u32, px_per_pt: f64) -> Option<[usize; 4]> {
|
||||
let [x0, y0, x1, y1] = rect;
|
||||
let px = |pt: f64| (pt * px_per_pt).round().max(1.0) as usize;
|
||||
let (pad, gap) = (1 + px(0.5), px(1.0));
|
||||
// `area` within `rect`: its sides on the region's sides padded, the others moved in.
|
||||
let place = |a: [usize; 4]| {
|
||||
let lo = |v: usize, edge: usize| if v == edge { v.saturating_sub(pad) } else { v + gap };
|
||||
let hi = |v: usize, edge: usize, max: usize| if v == edge { v.saturating_add(pad).min(max) } else { v.saturating_sub(gap) };
|
||||
let out = [lo(a[0], x0), lo(a[1], y0), hi(a[2], x1, w), hi(a[3], y1, h)];
|
||||
(out[0] < out[2] && out[1] < out[3]).then_some(out)
|
||||
};
|
||||
let (bw, bh) = (x1 - x0, y1 - y0);
|
||||
let mut rows = vec![0usize; bh];
|
||||
let mut cols = vec![0usize; bw];
|
||||
for y in y0..y1 {
|
||||
for x in x0..x1 {
|
||||
let i = y * w + x;
|
||||
let mine = matches!(label.get(i), Some(&l) if l == id || l == 0);
|
||||
if mine && ink.get(i).copied().unwrap_or(false) {
|
||||
if let Some(r) = rows.get_mut(y - y0) {
|
||||
*r += 1;
|
||||
}
|
||||
if let Some(c) = cols.get_mut(x - x0) {
|
||||
*c += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A few specks per row are noise, not print.
|
||||
let (row_noise, col_noise) = ((bw / 200).max(1), (bh / 200).max(1));
|
||||
if rows.iter().all(|n| *n <= row_noise) {
|
||||
return place(rect);
|
||||
}
|
||||
if bw as f64 <= 24.0 * px_per_pt && bh as f64 <= 24.0 * px_per_pt {
|
||||
return None;
|
||||
}
|
||||
let longest = |counts: &[usize], allowed: usize| {
|
||||
let (mut best, mut start) = ((0, 0), None);
|
||||
for (i, c) in counts.iter().chain(std::iter::once(&usize::MAX)).enumerate() {
|
||||
match (start, *c <= allowed) {
|
||||
(None, true) => start = Some(i),
|
||||
(Some(s), false) => {
|
||||
if i - s > best.1 - best.0 {
|
||||
best = (s, i);
|
||||
}
|
||||
start = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
best
|
||||
};
|
||||
let (r0, r1) = longest(&rows, row_noise);
|
||||
let (c0, c1) = longest(&cols, col_noise);
|
||||
let tall = |n: usize| n as f64 >= 12.0 * px_per_pt;
|
||||
let wide = |n: usize| n as f64 >= 40.0 * px_per_pt;
|
||||
let band = (tall(r1 - r0) && wide(bw)).then_some([x0, y0 + r0, x1, y0 + r1]);
|
||||
let column = (wide(c1 - c0) && tall(bh)).then_some([x0 + c0, y0, x0 + c1, y1]);
|
||||
band.or(column).and_then(place)
|
||||
}
|
||||
|
||||
/// A connected piece of a line mask.
|
||||
struct Component {
|
||||
x0: usize,
|
||||
x1: usize,
|
||||
y0: usize,
|
||||
y1: usize,
|
||||
count: usize,
|
||||
/// The sum of the rows of its pixels (u64: on 32-bit targets it outgrows `usize`).
|
||||
y_sum: u64,
|
||||
}
|
||||
|
||||
/// The 8-connected pieces of `mask`.
|
||||
fn components(mask: &[bool], w: usize, h: usize) -> Vec<Component> {
|
||||
let mut seen = vec![false; w * h];
|
||||
let mut out = Vec::new();
|
||||
let mut stack: Vec<usize> = Vec::new();
|
||||
for start in 0..w * h {
|
||||
if !mask.get(start).copied().unwrap_or(false) || seen.get(start).copied().unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let mut c = Component { x0: usize::MAX, x1: 0, y0: usize::MAX, y1: 0, count: 0, y_sum: 0 };
|
||||
stack.clear();
|
||||
stack.push(start);
|
||||
if let Some(s) = seen.get_mut(start) {
|
||||
*s = true;
|
||||
}
|
||||
while let Some(i) = stack.pop() {
|
||||
let (x, y) = (i % w, i / w);
|
||||
c.x0 = c.x0.min(x);
|
||||
c.x1 = c.x1.max(x + 1);
|
||||
c.y0 = c.y0.min(y);
|
||||
c.y1 = c.y1.max(y + 1);
|
||||
c.count += 1;
|
||||
c.y_sum = c.y_sum.saturating_add(y as u64);
|
||||
for dy in [-1isize, 0, 1] {
|
||||
for dx in [-1isize, 0, 1] {
|
||||
let (Some(nx), Some(ny)) = (x.checked_add_signed(dx), y.checked_add_signed(dy)) else { continue };
|
||||
if nx >= w || ny >= h {
|
||||
continue;
|
||||
}
|
||||
let j = ny * w + nx;
|
||||
if mask.get(j).copied().unwrap_or(false)
|
||||
&& let Some(s) = seen.get_mut(j)
|
||||
&& !*s
|
||||
{
|
||||
*s = true;
|
||||
stack.push(j);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A white page `w` × `h` pixels to draw a form on, two pixels to the point.
|
||||
struct Page {
|
||||
w: usize,
|
||||
h: usize,
|
||||
px: Vec<u8>,
|
||||
}
|
||||
|
||||
const S: f64 = 2.0;
|
||||
|
||||
impl Page {
|
||||
fn new(w: usize, h: usize) -> Self {
|
||||
Page { w, h, px: vec![255; w * h * 4] }
|
||||
}
|
||||
fn fill(&mut self, x0: usize, y0: usize, x1: usize, y1: usize, v: u8) {
|
||||
for y in y0..y1.min(self.h) {
|
||||
for x in x0..x1.min(self.w) {
|
||||
let i = (y * self.w + x) * 4;
|
||||
self.px[i..i + 3].copy_from_slice(&[v, v, v]);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// An outlined box, `t` pixels thick.
|
||||
fn frame(&mut self, x0: usize, y0: usize, x1: usize, y1: usize, t: usize) {
|
||||
self.fill(x0, y0, x1, y0 + t, 0);
|
||||
self.fill(x0, y1 - t, x1, y1, 0);
|
||||
self.fill(x0, y0, x0 + t, y1, 0);
|
||||
self.fill(x1 - t, y0, x1, y1, 0);
|
||||
}
|
||||
/// Something like a word: short strokes, none a line.
|
||||
fn word(&mut self, x: usize, y: usize, letters: usize) {
|
||||
for i in 0..letters {
|
||||
self.fill(x + i * 12, y, x + i * 12 + 2, y + 14, 30);
|
||||
self.fill(x + i * 12 + 4, y + 6, x + i * 12 + 9, y + 8, 30);
|
||||
}
|
||||
}
|
||||
fn shapes(&self) -> Shapes {
|
||||
shapes(&self.px, self.w as u32, self.h as u32, S)
|
||||
}
|
||||
}
|
||||
|
||||
/// Points (y up) of a pixel rectangle on a page `h` pixels high.
|
||||
fn pt(h: usize, [x0, y0, x1, y1]: [usize; 4]) -> [f64; 4] {
|
||||
[x0 as f64 / S, (h - y1) as f64 / S, x1 as f64 / S, (h - y0) as f64 / S]
|
||||
}
|
||||
|
||||
fn near(a: [f64; 4], b: [f64; 4], tol: f64) -> bool {
|
||||
a.iter().zip(b).all(|(p, q)| (p - q).abs() <= tol)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scanned_form_gives_its_rules_boxes_and_check_boxes() {
|
||||
let mut p = Page::new(600, 400);
|
||||
// A label and the rule to write on after it.
|
||||
p.word(20, 40, 4);
|
||||
p.fill(80, 56, 400, 58, 0);
|
||||
// An empty box, and a box with a small label in its top-left corner.
|
||||
p.frame(20, 100, 420, 150, 2);
|
||||
p.frame(20, 180, 420, 260, 2);
|
||||
p.word(26, 186, 3);
|
||||
// A check box (10 pt) with a label to its right.
|
||||
p.frame(20, 300, 40, 320, 2);
|
||||
p.word(50, 303, 5);
|
||||
let s = p.shapes();
|
||||
assert_eq!(s.rules.len(), 1, "{s:?}");
|
||||
let r = s.rules[0];
|
||||
assert!((r[0] - 40.0).abs() < 1.5 && (r[2] - 200.0).abs() < 1.5 && (r[1] - (400.0 - 57.0) / S).abs() < 1.0, "{r:?}");
|
||||
assert_eq!(s.boxes.len(), 3, "{s:?}");
|
||||
// The empty box is the inside of its frame (give or take the line).
|
||||
assert!(s.boxes.iter().any(|b| near(*b, pt(400, [20, 100, 420, 150]), 2.0)), "{s:?}");
|
||||
// The labelled box: only the part below the label.
|
||||
let lower = s.boxes.iter().find(|b| (b[1] - (400.0 - 260.0) / S).abs() < 2.0).expect("labelled box");
|
||||
assert!(lower[3] <= (400.0 - 200.0) / S + 2.0 && lower[3] - lower[1] >= 12.0, "{lower:?}");
|
||||
// The check box is square and about 10 pt.
|
||||
assert!(s.boxes.iter().any(|b| near(*b, pt(400, [20, 300, 40, 320]), 1.5)), "{s:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_and_a_frame_around_the_page_are_not_shapes() {
|
||||
let mut p = Page::new(400, 300);
|
||||
for line in 0..6 {
|
||||
p.word(30, 30 + line * 30, 20);
|
||||
}
|
||||
// A frame around the whole page, and a solid dark bar (a logo, a censored part).
|
||||
p.frame(4, 4, 396, 296, 2);
|
||||
p.fill(40, 240, 360, 270, 0);
|
||||
let s = p.shapes();
|
||||
assert!(s.boxes.is_empty() && s.rules.is_empty(), "{s:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noise_and_uneven_paper_do_not_hide_the_lines() {
|
||||
let mut p = Page::new(600, 300);
|
||||
p.fill(80, 56, 400, 58, 0);
|
||||
p.frame(20, 100, 420, 150, 2);
|
||||
p.frame(20, 200, 40, 220, 2);
|
||||
// Paper that darkens to the right (a shadow) and speckled with deterministic noise; the
|
||||
// lines lose some pixels.
|
||||
let mut seed: u32 = 12345;
|
||||
for y in 0..p.h {
|
||||
for x in 0..p.w {
|
||||
seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12345);
|
||||
let noise = (seed >> 16) % 41;
|
||||
let i = (y * p.w + x) * 4;
|
||||
let v = p.px[i];
|
||||
let shade = (x * 60 / p.w) as u8;
|
||||
let v = if v == 0 { if noise < 3 { 200 } else { 40 + noise as u8 } } else { 255u8.saturating_sub(shade).saturating_sub(noise as u8) };
|
||||
p.px[i..i + 3].copy_from_slice(&[v, v, v]);
|
||||
}
|
||||
}
|
||||
let s = p.shapes();
|
||||
assert_eq!(s.rules.len(), 1, "{s:?}");
|
||||
assert_eq!(s.boxes.len(), 2, "{s:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_pictures_give_nothing() {
|
||||
let white = vec![255u8; 64 * 64 * 4];
|
||||
assert_eq!(shapes(&white, 64, 64, 2.0), Shapes::default());
|
||||
// Size and data disagree, sizes overflow, or the scale is nonsense.
|
||||
assert_eq!(shapes(&white, 64, 65, 2.0), Shapes::default());
|
||||
assert_eq!(shapes(&white, u32::MAX, u32::MAX, 2.0), Shapes::default());
|
||||
assert_eq!(shapes(&[], 0, 0, 2.0), Shapes::default());
|
||||
for s in [0.0, -1.0, f64::NAN, f64::INFINITY, 1e9] {
|
||||
assert_eq!(shapes(&white, 64, 64, s), Shapes::default(), "{s}");
|
||||
}
|
||||
// All black: one dark area, no lines.
|
||||
let black: Vec<u8> = [0u8, 0, 0, 255].repeat(64 * 64);
|
||||
assert_eq!(shapes(&black, 64, 64, 2.0), Shapes::default());
|
||||
}
|
||||
}
|
||||
+73
-1
@@ -103,6 +103,8 @@ pub struct Widget {
|
||||
pub hidden: bool,
|
||||
/// `/MK /R` as 0, 90, 180 or 270 degrees counterclockwise. Anything else is stored as 0.
|
||||
pub rotation: i64,
|
||||
/// List boxes: where the options are drawn, so a click can pick the one under the pointer.
|
||||
pub list: Option<appearance::ListRows>,
|
||||
}
|
||||
|
||||
/// A terminal form field.
|
||||
@@ -800,6 +802,7 @@ fn walk(
|
||||
locked: annot_flags & 128 != 0,
|
||||
hidden: annot_flags & (2 | 32) != 0,
|
||||
rotation: appearance::mk_rotation(doc, wd),
|
||||
list: (kind == FieldKind::List).then(|| appearance::list_rows_of(doc, inh.da.as_deref().unwrap_or("/Helv 0 Tf 0 g"), Some(d), wd)),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -854,10 +857,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 +1051,21 @@ 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);
|
||||
// A list box scrolls (`/TI`) so the picked options are among the rows it draws, as
|
||||
// in other viewers: a pick below the last visible row would otherwise not show (#1524).
|
||||
let top = match (f.kind, f.widgets.first()) {
|
||||
(FieldKind::List, Some(w)) => w.list.map(|rows| {
|
||||
let height = if matches!(w.rotation, 90 | 270) { w.rect[2] - w.rect[0] } else { w.rect[3] - w.rect[1] };
|
||||
let picked: Vec<usize> = indices.iter().filter_map(|i| i.as_int()).filter_map(|i| usize::try_from(i).ok()).collect();
|
||||
(rows.top, appearance::ListRows::top_showing(rows.top, &picked, rows.visible(height), f.options.len()))
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
doc.update_dict(f.obj, |d| {
|
||||
match exports.as_slice() {
|
||||
[] => {
|
||||
@@ -1022,7 +1079,22 @@ fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scripts: &mut
|
||||
} else {
|
||||
d.set(b"I".to_vec(), Object::Array(indices));
|
||||
}
|
||||
if let Some((old, new)) = top
|
||||
&& old != new
|
||||
{
|
||||
d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0)));
|
||||
}
|
||||
})?;
|
||||
// A widget with its own `/TI` (read before the field's) scrolls too.
|
||||
if let Some((old, new)) = top
|
||||
&& old != new
|
||||
{
|
||||
for w in f.widgets.iter().filter(|w| w.obj != f.obj) {
|
||||
if doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"TI")) {
|
||||
doc.update_dict(w.obj, |d| d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0))))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
redraw(doc, f, &exports, scripts)
|
||||
}
|
||||
(FieldKind::PushButton, _) => invalid(format!("{:?} is a button; it has no value", f.name)),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user