Merge upstream/main into feat/recent-reveal-5452

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76andClaude Opus 5.5 committed 2026-11-09 23:09:47 +01:00
commit eef4bec503
654 files changed
+98597 -5965

No files matched your search

+2 -2
View File
@@ -84,7 +84,7 @@ An **asset** is any non-code material that the project includes, ships, embeds,
### 1.2 Only open, provable licences
Every asset must be one of:
- **Open source:** ISC, MIT, BSD-2-Clause, BSD-3-Clause, Apache-2.0, OFL-1.1, Ubuntu-Font-1.0 or Bitstream-Vera.
- **Open source:** ISC, MIT, BSD-2-Clause, BSD-3-Clause, Apache-2.0, OFL-1.1, Ubuntu-Font-1.0, Bitstream-Vera or Unicode-3.0 (Unicode, Inc. data such as ICU4X's line-breaking dictionaries; already allowed for code in `deny.toml`).
- **Creative Commons:** CC0-1.0, CC-BY-4.0 or CC-BY-SA-4.0.
- **Public domain.**
- **Contributor-original:** created, photographed or generated by a contributor who owns it and licenses it under the project licence (`MIT OR Apache-2.0`).
@@ -150,7 +150,7 @@ If you find an asset that breaks these rules, stop and remove it from the reposi
## 3. Agent control (automation and MCP)
- **Everything is reachable headlessly.** Every user-facing feature must be callable without the GUI: an engine API, then a tool in `crates/automation` (`pdfcraft-cli run` and MCP use the same table). A feature is not done until its tool exists and has an end-to-end test (`crates/automation/README.md`, "Adding a tool").
- **The UI control channel is opt-in too.** It runs only when the app is launched with `--control <file>`. It binds to loopback, uses a fresh random token per launch, and writes it to an owner-only file. Never enable it by default, widen the bind address, or log the token.
- **The UI control channel is opt-in too.** It runs only when the app is launched with `--control <file>`. It binds to loopback, uses a fresh random token per launch, and writes it to an owner-only file (mode 0600 on Unix; on Windows an access list of the user and SYSTEM only, which `pdfcraft_platform::owner_only` sets and checks, and which the CLI checks again before reading the token). Never enable it by default, widen the bind address, or log the token.
- **The MCP server is opt-in.** Never make the app, the CLI or an installer start it, register it with an agent, or listen on a port by default. It runs only when a user (or an agent the user configured) launches `pdfcraft-cli mcp`. Any future network transport must be off by default, bound to loopback and authenticated with a token.
- **Safe by default.** Tools must never discard unsaved work silently, must write atomically, must honour `--root`, and must return errors an agent can act on (say what was wrong and what is allowed).
- **Track parity honestly.** Every user-facing feature has an entry in `parity/acrobat-features.toml`. Mark it `shipped` only when it works end to end and the entry cites a passing test (`evidence`); `cargo xtask parity` (part of `xtask ci`) rejects claims whose commands, tools or tests don't exist.
+2 -1
View File
@@ -267,7 +267,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `assets/icons/a-large-small.svg` | Lucide icon "a-large-small" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/a-large-small.svg) | UI icon, embedded via crates/ui-egui/src/widgets/icon_data.rs |
| `assets/icons/move-horizontal.svg` | Lucide icon "move-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
## Compiled in through dependencies (6)
## Compiled in through dependencies (7)
| Crate | File | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|---|
@@ -277,6 +277,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `epaint_default_fonts` 0.37.0 | `fonts/emoji-icon-font.ttf` | emoji-icon-font | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | egui monochrome fallback font (egui feature monochrome_emoji_fonts) |
| `epaint_default_fonts` 0.37.0 | `fonts/egui-icons.ttf` | egui icons (monochrome emoji-icon-font subset) | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | Small special-icon subset embedded by egui default_fonts; retained in FontDefinitions::default(). Provenance: https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; complete MIT copyright and permission text in NOTICE. |
| `hayro-interpret` 0.7.0 | `src/font/generated/metrics.rs` | Standard-14 font metrics and encodings (as Rust tables) (non-visual data, AGENTS.md §1.1) | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | MIT OR Apache-2.0 | ISO 32000-2 Annex D; Adobe Core14 AFM files (via vendored hayro-interpret) | Glyph widths and encodings for non-embedded standard fonts. Non-visual data, allowed by AGENTS.md §1.1 |
| `icu_segmenter_data` 2.3.0 | `data/segmenter_dictionary_extended_v1.rs.data` | Southeast Asian word dictionaries for line breaking (Thai, Lao, Khmer, Burmese) | The ICU4X Project Developers; word lists from the ICU project (Unicode, Inc.) | Unicode-3.0 | https://github.com/unicode-org/icu4x (via icu_segmenter_data) | Word boundaries for wrapping Thai text in Edit text and Add text (crates/edit/src/linebreak.rs). Non-visual data; the line segmenter loads only these dictionaries, not the Chinese and Japanese one |
## Downloaded at build time (22)
+12 -1
View File
@@ -2263,6 +2263,18 @@ adobe_data = true
usage = "Glyph widths and encodings for non-embedded standard fonts. Non-visual data, allowed by AGENTS.md §1.1"
sha256 = "2ec9e608573eda459de1e1d9bda3bb494315e36bee885acc88912a11fe0caa41"
[[bundled]]
crate = "icu_segmenter_data"
version = "2.3.0"
path = "data/segmenter_dictionary_extended_v1.rs.data"
title = "Southeast Asian word dictionaries for line breaking (Thai, Lao, Khmer, Burmese)"
author = "The ICU4X Project Developers; word lists from the ICU project (Unicode, Inc.)"
source = "https://github.com/unicode-org/icu4x (via icu_segmenter_data)"
licence = "Unicode-3.0"
kind = "data"
usage = "Word boundaries for wrapping Thai text in Edit text and Add text (crates/edit/src/linebreak.rs). Non-visual data; the line segmenter loads only these dictionaries, not the Chinese and Japanese one"
sha256 = "cad3821ca44fdb88ed856ff3336444aa200afe7b9d19e34ac28ae6a4459d4c6d"
[[fetched]]
file = "PlayfairDisplay[wght].ttf"
url = "https://raw.githubusercontent.com/google/fonts/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/playfairdisplay/PlayfairDisplay%5Bwght%5D.ttf"
@@ -3232,4 +3244,3 @@ licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
sha256 = "864ab2f19254e6ac7240bc6a98323197332e8f714d4d3f94831f41309e5ef2f7"
Generated
+49 -14
View File
@@ -1289,7 +1289,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff"
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
dependencies = [
"chrono",
"dunce",
@@ -3133,6 +3133,28 @@ dependencies = [
"zerovec",
]
[[package]]
name = "icu_segmenter"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db"
dependencies = [
"icu_collections",
"icu_locale_fallback",
"icu_provider",
"icu_segmenter_data",
"potential_utf",
"smallvec",
"utf8_iter",
"zerovec",
]
[[package]]
name = "icu_segmenter_data"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad"
[[package]]
name = "idna"
version = "1.1.0"
@@ -4246,6 +4268,7 @@ dependencies = [
"fmv-macos-events",
"log",
"pdfcraft-engine",
"pdfcraft-platform",
"pdfcraft-ui-egui",
"pollster 1.0.1",
"raw-window-handle",
@@ -4400,6 +4423,7 @@ name = "pdfcraft-edit"
version = "0.18.1"
dependencies = [
"hayro-syntax",
"icu_segmenter",
"kurbo 0.13.1",
"pdfcraft-annot",
"pdfcraft-content",
@@ -4569,6 +4593,7 @@ dependencies = [
"pdfcraft-render",
"proptest",
"thiserror 2.0.21",
"thread-priority",
"zune-jpeg",
]
@@ -4656,8 +4681,10 @@ dependencies = [
"pdfcraft-testkit",
"pdfcraft-xfa",
"regex",
"roxmltree 0.21.2",
"sha2 0.11.1",
"thiserror 2.0.21",
"unicode-normalization",
]
[[package]]
@@ -4672,12 +4699,16 @@ dependencies = [
"kurbo 0.13.1",
"log",
"lopdf",
"pdfcraft-content",
"pdfcraft-cos",
"pdfcraft-fonts",
"pdfcraft-geom",
"pdfcraft-testkit",
"serde",
"skrifa 0.44.0",
"thiserror 2.0.21",
"unicode-bidi",
"url",
]
[[package]]
@@ -4741,6 +4772,7 @@ dependencies = [
"egui",
"egui_extras",
"egui_kittest",
"flate2",
"fontconfig-parser",
"getrandom 0.4.3",
"image",
@@ -4811,10 +4843,12 @@ dependencies = [
name = "pdfcraft-xfdf"
version = "0.18.1"
dependencies = [
"base64 0.23.1",
"pdfcraft-annot",
"pdfcraft-cos",
"pdfcraft-forms",
"pdfcraft-model",
"proptest",
"roxmltree 0.21.2",
"thiserror 2.0.21",
]
@@ -6932,6 +6966,20 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "thread-priority"
version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d2e834949be5111506bb252643498af1514f600d9e1dceedaa42afae155b67f"
dependencies = [
"bitflags 2.13.2",
"cfg-if",
"libc",
"log",
"rustversion",
"windows",
]
[[package]]
name = "tiff"
version = "0.11.3"
@@ -8724,7 +8772,6 @@ dependencies = [
"nix",
"typed-path",
"windows-sys 0.61.2",
"zopfli",
]
[[package]]
@@ -8739,18 +8786,6 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zopfli"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zune-core"
version = "0.5.3"
+1 -1
View File
@@ -28,7 +28,7 @@ craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.
# Whether a path stored in a document may be opened (GoToR link targets).
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff", features = ["egui"] }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] }
# internal
pdfcraft-geom = { path = "crates/geom", version = "0.18.1" }
pdfcraft-filters = { path = "crates/filters", version = "0.18.1" }
+14 -1
View File
@@ -16,7 +16,7 @@ sha256 = "cd7032ea33b71427f188e34ffb4aee9d6868a57fd196037c5c8b2be03a2a0352"
[[document]]
source = "NOTICE"
destination = "NOTICE"
sha256 = "9f854f730719af962cd840529324d089e05e68a30bd245c50e33f96e009b4314"
sha256 = "d9ba162ad2bc95d22ab9885065e253bb823bd6795068828e7b39858344b660eb"
[[document]]
source = "assets/app-icon/LICENSE-document.txt"
@@ -60,6 +60,13 @@ destination = "licenses/CMap-LICENSE.txt"
sha256 = "742665db9c8e1bc72603c6d319ca3e90b83bd6d95202f0cd4ef11068a07a9c29"
origin = "https://static.crates.io/crates/hayro-cmap/hayro-cmap-0.1.0.crate#assets/LICENSE.txt"
[[document]]
source = "licenses/ICU4X-LICENSE.txt"
licences = ["Unicode-3.0"]
destination = "licenses/ICU4X-LICENSE.txt"
sha256 = "f367c1b8e1aa262435251e442901da4607b4650e0e63a026f5044473ecfb90f2"
origin = "https://static.crates.io/crates/icu_segmenter_data/icu_segmenter_data-2.3.0.crate#LICENSE"
[[document]]
source = "licenses/egui-Hack-Regular.txt"
licences = ["MIT", "Bitstream-Vera"]
@@ -164,3 +171,9 @@ crate = "hayro-interpret"
path = "src/font/generated/metrics.rs"
licence = "MIT OR Apache-2.0"
documents = ["vendor/hayro-interpret/LICENSE-MIT", "vendor/hayro-interpret/LICENSE-APACHE"]
[[bundled]]
crate = "icu_segmenter_data"
path = "data/segmenter_dictionary_extended_v1.rs.data"
licence = "Unicode-3.0"
documents = ["licenses/ICU4X-LICENSE.txt"]
+29
View File
@@ -36,6 +36,9 @@ list (author, source, license) is in ATTRIBUTION.md.
- egui default fonts (epaint_default_fonts, via eframe) — Ubuntu Light (Ubuntu Font Licence 1.0),
Hack (MIT / Bitstream Vera licence), Noto Emoji (SIL OFL 1.1), emoji-icon-font (MIT).
- Colour profile in hayro-interpret — CGATS001Compat-v2-micro.icc (CC0-1.0, Clinton Ingram).
- ICU4X line-breaking data (icu_segmenter_data, via crates.io) — Unicode-3.0. Its Thai, Lao, Khmer
and Burmese word dictionaries are compiled in for wrapping Thai text; notice below, full licence in
licenses/ICU4X-LICENSE.txt.
Required notices:
@@ -48,6 +51,32 @@ Required notices:
(https://github.com/adobe-type-tools/cmap-resources/blob/master/LICENSE.txt). Neither the name of
Adobe nor the names of its contributors are used to endorse or promote this product.
ICU4X segmenter data (bundled by icu_segmenter_data 2.3.0; word lists, not visual assets):
Copyright © 2020-2024 Unicode, Inc. Portions adapted from ICU4C and/or ICU4J:
ICU 1.8.1 to ICU 57.1 © 1995-2016 International Business Machines Corporation and others.
Permission is hereby granted, free of charge, to any person obtaining a copy of data files and any
associated documentation (the "Data Files") or software and any associated documentation (the
"Software") to deal in the Data Files or Software without restriction, including without
limitation the rights to use, copy, modify, merge, publish, distribute, and/or sell copies of the
Data Files or Software, and to permit persons to whom the Data Files or Software are furnished to
do so, provided that either (a) this copyright and permission notice appear with all copies of
the Data Files or Software, or (b) this copyright and permission notice appear in associated
Documentation.
THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
PURPOSE AND NONINFRINGEMENT OF THIRD PARTY RIGHTS.
IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE BE LIABLE FOR ANY CLAIM,
OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF
USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA FILES OR SOFTWARE.
Except as contained in this notice, the name of a copyright holder shall not be used in
advertising or otherwise to promote the sale, use or other dealings in these Data Files or
Software without prior written authorization of the copyright holder.
Showcase PDF (built by `cargo xtask demo-pdf`, not committed): fonts are downloaded at build time from
google/fonts at a pinned commit, verified by SHA-256, all under the SIL Open Font License 1.1 — see the
"Downloaded at build time" table in ATTRIBUTION.md.
+12 -3
View File
@@ -331,7 +331,7 @@ Edits stay in memory, undoable, until `doc_save`. Saving to the same file append
Start the desktop app with `pdfcraft --control ~/.pdfcraft-control.json` and an agent can see and operate the real interface: the widget tree with labels and positions (from the accessibility tree), clicks, typing, keys, commands, view options and screenshots. This is also off by default. It listens only on loopback, and every connection must present the random token written to that file, which only you can read.
Keep the control file in a folder only you can write, not a shared one such as `/tmp`: another user could create the file there first and receive your commands. `pdfcraft-cli ui` refuses a control file that is a symbolic link, and on macOS, Linux and FreeBSD one that another user owns or can read or write. The app doesn't start if it can't write the file.
Keep the control file in a folder only you can write, not a shared one such as `/tmp`: another user could create the file there first and receive your commands. `pdfcraft-cli ui` refuses a control file that is a symbolic link, or one that another user owns, or that another user or a group such as Administrators can read or write: on macOS, Linux and FreeBSD by its owner and mode (the app writes it with mode 600), on Windows by its owner and access list (the app gives it one with only you and SYSTEM on it). The app doesn't start if it can't write the file. On Windows, cmd.exe doesn't expand `~`: use `%USERPROFILE%\.pdfcraft-control.json` there.
```sh
pdfcraft-cli ui --control ~/.pdfcraft-control.json inspect query=rotate # find widgets
@@ -472,11 +472,14 @@ Installers and executables are code-signed.
The portable zip runs from any folder, a USB stick included. Its `portable.txt` keeps the settings,
logs and crash recovery in a `PdfCraftData` folder next to `pdfcraft.exe`, so nothing is written to
`%APPDATA%`; delete that file to use the normal per-user folders.
Windows doesn't offer the portable copy as a default PDF app on its own, since it registers nothing;
`portable.txt` has the PowerShell lines that register it for your user only, and how to undo them.
The MSI installs for your account only by default, without administrator privileges. Choose
"Everyone on this computer" on its first page, or pass `ALLUSERS=1` from an elevated terminal, to install
for all users. It adds "Edit with PdfCraft"
to the right-click menu of PDF files and "Create PDF with PdfCraft…" to the right-click menu of
and "Combine files in PdfCraft" (select several PDFs: they land in the Combine files tab of the
running PdfCraft) 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 (add `ALLUSERS=1`, from an elevated terminal, for an all-users install):
@@ -488,7 +491,7 @@ msiexec /i "pdfcraft-<ver>-windows-x64.msi" /qn /norestart INSTALLDESKTOPSHORTCU
| Property | `=0` leaves out |
|---|---|
| `INSTALLDESKTOPSHORTCUT` | the desktop shortcut |
| `INSTALLPDFCONTEXTMENU` | "Edit with PdfCraft" on PDF files |
| `INSTALLPDFCONTEXTMENU` | "Edit with PdfCraft" and "Combine files in 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
@@ -499,6 +502,12 @@ didn't have a menu yet (0.6.0 and earlier for the PDF menu, 0.2.1 and earlier fo
adds it, as a first install would. Use the MSI for your architecture. Per-user installation
overrides are not supported.
To install somewhere other than the default `ArtCraft\PdfCraft` folder, change the folder on the
welcome screen (type it, or Browse…) or pass it on the command line:
`INSTALLFOLDER="D:\Apps\PdfCraft"`. It is installed for whoever the first screen chose (`ALLUSERS=1`
on the command line for everyone). An upgrade keeps the folder the installed version uses unless
the command line gives another one.
**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
+36 -16
View File
@@ -199,27 +199,28 @@ What's strong and missing in each: [target-app-parity.md](docs/target-app-parity
## Languages
Twelve key languages (historical % snapshot vs 2,143 keys, except Vietnamese, Brazilian
Portuguese and Spanish vs the current union). Detail and script notes: [localization-parity.md](docs/localization-parity.md).
Twelve key languages: the share of the 2,897 strings in the i18n report's denominator (the interface's strings plus every catalog key), from
`cargo xtask i18n-report` on 2026-11-03. Detail and script notes: [localization-parity.md](docs/localization-parity.md).
| Language | Code | Strings | Status |
|---|---|---|---|
| English | `en` | 100% | full |
| Simplified Chinese | `zh-hans` | 97.4% | partial (native LXGW WenKai Lite; no Hans face in the web build, #826) |
| Spanish | `es` | 100% of current union | partial (dialogs / tooltips; no help) |
| Hindi | `hi` | 0% | none |
| Arabic | `ar` | 100% | partial (logical catalog, explicit RTL labels, mirrored stock chrome; native/browser/IME workflows unqualified) |
| French | `fr` | 99.3% | partial |
| Portuguese (Brazil) | `pt-br` | 100% of current union | partial |
| Indonesian | `id` | 0% | none |
| Japanese | `ja` | 99.1% | partial |
| German | `de` | 99.3% | partial |
| Korean | `ko` | 0% | none |
| Vietnamese | `vi` | 100% of current union | partial (dialogs / tooltips; no help) |
| Simplified Chinese | `zh-hans` | 88.4% | partial (native LXGW WenKai Lite; no Hans face in the web build, #826) |
| Spanish | `es` | 98.4% | partial (dialogs / tooltips; no help) |
| Hindi | `hi` | 97.5% | partial (Devanagari rendering unqualified) |
| Arabic | `ar` | 97.9% | partial (logical catalog, explicit RTL labels, mirrored stock chrome; native/browser/IME workflows unqualified) |
| French | `fr` | 98.4% | partial |
| Portuguese (Brazil) | `pt-br` | 98.4% | partial |
| Indonesian | `id` | 97.5% | partial |
| Japanese | `ja` | 89.6% | partial |
| German | `de` | 89.7% | partial |
| Korean | `ko` | 97.5% | partial (no Hangul UI face) |
| Vietnamese | `vi` | 98.4% | partial (dialogs / tooltips; no help) |
Also shipped: Traditional Chinese, Russian, Bulgarian, Telugu, Hungarian, Ukrainian, Italian,
Kazakh, Polish, Slovenian, Greek, Turkish, Lithuanian, Romanian and Czech: **15 more catalogs**
(24 interface languages including English).
Kazakh, Polish, Slovenian, Greek, Turkish, Lithuanian, Finnish, Albanian, Catalan, Romanian,
Norwegian Bokmål, Persian, Central Kurdish, Thai, Lao and Czech: **23 more catalogs**
(35 interface languages including English).
## Upcoming
@@ -240,6 +241,12 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
## Progress log
- **2026-10-13 (M8, redact by regex):** `redact_mark` takes `regex` (caller expression, linear-time engine, length/size/nesting caps, empty-matching refused) with optional `validate` (luhn, mod97, iban). Headless only; issue #1886.
- **2026-11-08 (M5, comment exchange):** Comments keep their formatting and appearance through XFDF and FDF. XFDF export and import carry rich text, default style, border width, style and dash pattern, rotation, justification, fringe, line leaders and captions, the caret symbol and redaction overlay text as XFDF 3.0 (ISO 19444-1) names them, and a stamp's appearance as `<appearance>` (the DICT/STREAM/DATA encoding, bounded); copy and paste carries every comment's appearance. FDF export keeps a stamp's /AP with its forms and images, as XFDF does, and FDF import keeps any /AP the file carries (ISO 32000-2 §12.7.8.3.4). The intent goes out as both `intent` and `IT`. An unreadable XFDF appearance is left out, said in the import report and the repair log, and the comment is drawn by PdfCraft; an FDF past the import budget is refused whole. A redaction mark is always drawn by PdfCraft.
- **2026-11-07 (M2, #6449):** A non-embedded Times New Roman set as a TrueType CID font (`TimesNewRoman,Bold`, `TimesNewRomanPSMT` and the other faces) draws the glyphs its codes name, as the non-embedded Arial does: its first 258 glyphs follow the same standard Macintosh order, which the embedded Times New Roman subsets of a public French form confirm in 941 of 942 `/ToUnicode` entries (vendored hayro-interpret patch 88). That form drew "Salarié(e) du régime général" as "Salari²*g+f w't²i lo g'ì²p²tcn" and "changement" as "ej angement". Times and Times New Roman MT Std are not read this way.
- **2026-11-05 (M2, #5889):** A non-embedded Arial set as a TrueType CID font (`CIDFontType2`, `Identity-H`, as Word and PDFMaker write it) draws the glyphs its codes name. Those codes are glyph indexes in the author's Arial, whose first 258 glyphs follow the standard Macintosh order, so the standard substitute now draws each glyph by name (vendored hayro-interpret patch 76). Before, it took each code as one of its own glyph indexes, and "Low Power" drew as "@ck 'Dck Yf". Arial Narrow, Black and Unicode MS, and `CIDFontType0` fonts, are not read this way. Copying or searching those runs still needs a `/ToUnicode`.
- **2026-11-07 (M7, #2964 / #3185):** Supported Form text can be prepared for direct editing; groups left grouped (unsafe compositing, text nested past eight levels) are reported. Legacy Thai keeps the original font programs, Regular/Bold and text settings, also across same-face subsets; the narrow THSarabunPSK map repair is checked glyph by glyph against the font program; cross-family replacement is explicit. Builds on #3067 and #2811. Unsafe groups and mixed contextual styles remain partial; estimates unchanged.
- **2026-10-28 (M10, #4119 partial):** Scan & OCR ▸ Enhance scanned file cleans scanned pages in place: background removal (level 0–100, as the camera clean-up), edge shadow removal (book gutters, copier borders) and despeckle. Each scan image is decoded, cleaned and written back into its own object at the same size and colour space (JPEG stays JPEG, others lossless, 1-bit stays 1-bit), so the OCR text layer, comments and links stay; one undo step, shared images once, unsupported images (CMYK, indexed, JPEG 2000, JBIG2, CCITT, broken data) reported. `page_enhance_scan` drives it headlessly. Not yet an option of Recognize text. Estimates unchanged.
- **2026-11-07 (M10, `ocr.ocr-web`, port onto #977):** Web OCR rebuilt on main's engines and preprocessing (#977). `OcrJob::run` is now `OcrJob::steps` a page at a time: the page renders, crops to the region and cleans each page, a Web Worker runs ocrs on the result, and `OcrShot::place` maps the words back through the region and preprocessing. Every strategy reads like this when only one engine is present, and the web build has only ocrs. The downloaded models are installed into the engine cache (`ocr::install`), so the Verify screen and the engine-status checks see them too. The batch keeps main's skipped-files bucket, low-confidence notes and cancel. The translations moved to craft-i18n (storytold/craft-i18n#141). The Verify screen starts the model download itself and runs once the models land; before, on a fresh page it failed with the desktop's "set PDFCRAFT_MODELS" message. Known limits: the cleaning steps, and the Verify screen's Run OCR and re-reads, run on the page. Estimates unchanged.
@@ -250,14 +257,17 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-11-03 (M5, #5850):** Fill & Sign Enter inserts a line break in a growing editor; clicking outside commits and Escape cancels. Nonempty text keeps leading, internal and trailing line breaks. The headless fill_sign_add path sizes its typewriter box for the same wrapped lines so saved appearances show every row. Production-order keyboard and engine save/reopen/render regressions exercise the UI and headless callers. Estimates unchanged.
- **2026-11-03 (community, M7, Thai word breaking):** Thai rows end between words, found with ICU4X's dictionary line segmenter (`icu_segmenter` 2.3, compiled dictionary data, no LSTM model; Unicode-3.0), instead of only at spaces when rewrapping and at any cluster in Add text. A row never starts with a following vowel, tone or other mark, Mai Yamok or Paiyannoi, nor ends with a leading vowel; a word wider than the row still breaks between clusters, and the spaces where an Add text row ends are dropped as in Latin text. Rewrapping existing text works out the breaks once per run of non-space characters. Rewriting a paragraph into Thai still needs a source font with the Thai glyphs (there is no Thai fallback face for it yet). The compiled dictionaries (Thai, Lao, Khmer, Burmese) add about 1.8 MB to a stripped release binary. Estimates unchanged.
- **2026-11-03 (M7, #5835):** In image crop mode, a right-click on the image, its crop box or its handles keeps the crop and opens no menu, so the menu no longer switches as the pointer moves. A right-click elsewhere clears the selection and opens the page menu. UI tests cover all four page rotations, overlapping images, a 45° image, and a check that the Done/Cancel/Remove Crop pills at a page edge still keep the crop.
- **2026-11-02 (M12, #1297):** `model_add` embeds a U3D or PRC file, recognised by its header, as a 3D annotation with a chosen activation and a poster (an image or PDF page fitted in the box, or a labelled frame); an older file's catalog `/Version` is raised to what the model needs. Views, a rendered poster and a UI command are later steps. Estimates unchanged.
- **2026-11-03 (M14 docs, #5805):** `docs/localization-parity.md` and the Languages table here count every catalog with `cargo xtask i18n-report`: the share of the 2,897 strings in its denominator, on `14ae839a`. The catalog-union measure it replaces had grown to 2,895 keys and left four catalogs at "100%" that were 44 keys short. Hindi, Indonesian and Korean are listed with their catalogs (97.5% each) instead of 0, and Thai, Lao and Central Kurdish join the table. The ≈ 68% localization score is not re-scored.
- **2026-11-02 (M10, #5743):** macOS print jobs name their title and owner (`com.apple.print.JobInfo.PMJobName`, `PMJobOwner`), as the system print dialog does, because a Kyocera filter crashes on a job without them. Checked by a test and through `doc_print` against a stand-in `lp`; not yet on a physical printer.
- **2026-10-31 (M3, #5452):** Native Home Recent rows offer Reveal in Finder / File Explorer from their context menu. They reuse the tab action’s platform dispatch and missing-file notification without opening the PDF or changing Recent. Browser behavior and milestone estimates unchanged.
- **2026-11-02 (M3, #5708):** Pages shorter than the window sit in its middle, one page or row at a time and when scrolling continuously with every page in view.
- **2026-11-02 (M3, #5716):** A snapshot is drawn at 300 ppi, or at the zoom if that is finer, so one taken zoomed out stays readable; an area too large for one 4096-pixel tile comes out as fine as a tile allows (about 147 ppi for 2000 pt). A choice of resolution and a selection that stays active remain.
- **2026-11-02 (M14, #5737, #5460):** Preferences can turn off the update check at start. It stays on by default, and Help ▸ Check for updates still asks.
- **2026-11-01 (M7, #5657):** Header/footer, watermark and background additions and replacements stage the complete call before committing. Synthetic later-page failures preserve exact incremental bytes, object count, dirty state and allocation cursor; success controls retain other marks and shared/inherited resources. Estimates unchanged.
@@ -306,6 +316,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-23 (M11, JBIG2):** The PDF Optimizer can store black-and-white images as lossless JBIG2 (a generic region, written from the JBIG2 draft standard and checked against its arithmetic-coder test sequence), about a third smaller than Group 4 on 300 dpi text, and Export ▸ Images reads JBIG2 images. JBIG2 symbol coding, where its largest gains on text are, is next.
- **2026-10-23 (M11, monochrome compression):** The PDF Optimizer and Reduce File Size store black-and-white images (scans, image masks) losslessly as CCITT Group 4 when that is smaller, with a Monochrome Images setting in the Optimizer and `doc_optimize` `mono`. JBIG2 and monochrome downsampling are next.
- **2026-10-23 (M6, calculation checks):** The calculation order now flags a formula that reads a field the form doesn't have (a field deleted from under a sum, a typo in notation, a script's `getField`) and a script that builds the names it reads at run time. Prepare a form ▸ Calculation order marks both on the row and in a card that names the broken formulas, in place of its all-clear. `form_calculation_order` gives each field's formula, and `form_fields` shows a custom Calculate script.
- **2026-10-23 (M4, #3923):** A recovered document's original path is only a suggestion: the first Save after recovering opens Save As at that path instead of writing it, since anyone who can write the recovery folder (a shared portable `PdfCraftData`, for example) can name any file in a sidecar. The file chosen is the document's file from then on. Sidecars are read through a 64 KiB cap; a longer one is not read, and its snapshot recovers without metadata. Estimates unchanged.
- **2026-10-23 (M7, #4280):** Edit text names each standard stand-in font after its face (`PCEdTimesBold`, ...) instead of one shared `PCEdHelv`. Rewriting a bold paragraph whose subset lacks an accent no longer repoints the paragraphs set earlier in a regular stand-in at Times-Bold, and the editor's frame stays on the paragraph being edited. Pages saved by older builds keep their shared name and are not repaired. Estimates unchanged.
- **2026-10-23 (M8, #3205 follow-up):** Phone-pattern redaction matches complete labelled Italian geographic numbers such as `Tel. +39 0426/123456`, with synthetic matcher and mark/apply/save/reopen coverage; unlabelled international slash numbers remain unsupported.
- **2026-10-23 (M7, #2636 partial):** A new `PasteContent` edit pastes an added text box or image copied in the same document or another, keeping its style, picture, turns, flips and crop, as one undo step; `content_copy` drives it headlessly. A paste lands beside earlier copies (18 pt steps) and stays on the page. A pasted image reuses its picture object in its own document and after the first paste into another; there a colour profile is dropped and CMYK colours stored losslessly are turned to RGB. The desktop keys and menus follow in separate PRs. Estimates unchanged.
@@ -435,6 +446,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-16 (M11, #1201):** Optimize decodes a JPEG with its colour transform: an Adobe APP14 marker's flag, else the filter's `/ColorTransform` (ISO 32000-2 §7.4.8). An RGB JPEG stored untransformed (`/ColorTransform 0`, no marker) used to be converted from YCbCr anyway and came out in other colours, recompressed or merged from strips; it now keeps them. A JPEG whose marker and `/ColorTransform` disagree, or whose components are named R, G and B without `/ColorTransform 0`, is left as it is, since readers differ on it. Estimates unchanged.
- **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged.
- **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged.
- **2026-10-15 (M14, gap 16, #1683):** Split view. View ▸ Split (⌘\\ / Ctrl+\\) and a tab's Split right / Split down show two panes, each with its own tab strip, scroll and zoom; a tab dropped on the right half of the page area splits right, and the divider drags. One document in both panes is one document with one Undo; closing one of its views keeps it open. The pane last clicked is the one menus, keys and panels act on. Nothing about the panes is kept across restarts. `view.split-window` planned → shipped. Multiple windows stay planned. Estimates unchanged.
- **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `<occur>` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. A page area's `occur min` is honoured too: one the body never reached gets the pages it still needs after the body (the once-only closing page of a form, with its button, comes last). Acrobat Pro also puts that page last, and when printing keeps it but leaves it blank (`relevant="-print"` on the page area), which PdfCraft does not do yet; both re-observed on a synthetic fixture, see the 2026-10-19 line. Synthetic regression tests. Estimates unchanged.
- **2026-10-19 (M12, XFA, #2574):** The Acrobat observations behind the line above were first made on a public real-world form, which the Privacy rule does not allow; they are replaced by ones made on a synthetic form (three `breakBefore pageArea` sections, then a page area with `occur min="1" max="1"`, with and without `relevant="-print"`). Acrobat Pro shows the closing page last in both and prints it in both, blank only with `relevant="-print"`: confirmed. The synthetic form also shows a difference not fixed yet: Acrobat opens it as 5 pages, with an empty first body page before the first section, where PdfCraft lays out 4. Two more synthetic forms narrow it down: with `breakBefore targetType="pageArea"` on the first section, with or without `startNew`, Acrobat opens an empty body page first; without a break on the first section it lays out 4, as PdfCraft does (so `startNew` is not the cause). Not fixed yet: `layout.rs` skips a break while the page is still empty. Estimates unchanged.
- **2026-10-15 (M8, #2447):** Removing links or comments (Remove Hidden Information, Sanitize) also drops the tagged-PDF structure tree's object references to them, so the full rewrite no longer keeps a removed link's URL or a comment's text. Regression seen to fail without the fix. Estimates unchanged.
@@ -445,6 +457,9 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-14 (M12, reading colours, #2059):** Pending page backgrounds use the document's reading colour immediately, preventing white flashes while scrolling to uncached pages. A headless canvas regression covers three pending pages in every preset and a custom background. Estimates unchanged.
- **2026-10-15 (community, M6, #2608):** In Prepare a form the selected field moves with the arrow keys: a point per press, ten with Shift, as the object nudge and dragging it do. Each press is one undo step ("Change field properties"), and the keys no longer turn the page while a field is selected. Estimates unchanged.
- **2026-10-15 (M3, closable community card):** The ArtCraft community card on Home has a close button that hides it for the rest of the run; it returns on the next launch, and its links stay in the top bar's Discord button and the Help menu. A UI regression covers closing and the fresh-launch default. Estimates unchanged.
- **2026-10-15 (M14, Combine files from File Explorer, #585):** The Windows MSI adds "Combine files in PdfCraft" to PDF files' right-click menu (with the Edit verb, same switch); each selected file starts `pdfcraft --combine`, and the single-instance hand-off (`"combine": true`, `OsEvent::Combine`) collects them into the running PdfCraft's Combine files tab. Tests: the request round trip and hand-off in `single_instance.rs`, the OS event in `tests/ui.rs`, the verb rows in `test-msi.ps1`. Estimates unchanged.
- **2026-10-15 (M14, MSI install folder, #1139):** The Windows installer's welcome dialog has an "Install to" row with a native folder browser, starting on the folder chosen for the per-user or per-machine scope (the recorded one on an update). `test-msi.ps1` checks the dialog wiring: both Install buttons set the folder before the dialog closes, and Browse's OK and Cancel. Estimates unchanged.
- **2026-10-15 (M8, #2448):** Applying redactions also removes the document information entries and the document's and pages' XMP metadata that repeat the removed text, so a redacted word can't survive in `/Title`, `/Keywords` or the XMP that mirrors them. Estimates unchanged.
- **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged.
- **2026-10-18 (M1, PR #1143 repair policy):** A live in-file reference resolves when its generation matches the xref row or a same-number header at that indexed offset. Neither-match and free slots remain null, including when old object bytes remain. Opening records bounded xref/header disagreements without parsing object bodies or expanding compact xref rows. Synthetic controls cover read order, clones, overlays, dictionary edits, full classic/packed saves and an independent reader of incremental output. **Correction to the 2026-10-15 note below:** its async-opening-test sentence was not evidenced by this COS change; no fresh async-opening or GUI validation is claimed. The full-reference stream-length recursion key is defensive hardening, not an independently isolated regression fix. Estimates unchanged.
- **2026-10-15 (M1, PR #1143 refresh):** COS reference lookups and full saves distinguish complete object references, so stale generations cannot expose replacement content. Snapshot restoration also keeps edits to objects with nonzero generations before incremental saves. Synthetic regressions cover caches, overlays, snapshots, stream lengths and classic/packed saves. Current upstream async-opening test corrections are included. Estimates unchanged.
@@ -515,6 +530,10 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-15 (M3, #1248):** A fresh install follows the desktop's appearance: the theme preference defaults to System (dark on a dark desktop, Light where the desktop says nothing); saved settings and `--theme` still win. Dark page rendering is Reading colours, and the page reading themes are #1732.
- **2026-10-15 (M3, #1767):** Restore last view settings when reopening documents, as Acrobat's preference does: a file opened again shows the page, zoom and page display it was last shown with (remembered for the last 100 files when a tab closes or settings are saved), over the file's own initial view; on by default, Preferences ▸ Documents and view turns it off.
- **2026-10-14 (M10, #2001):** The Print dialog remembers two-sided, grayscale, collate, page sizing (with its custom percent) and Comments & forms across restarts, under the names `doc_print` uses; copies, pages, layout and orientation still start fresh for each job. The option names now live in one place (`pdfcraft_print::names`) for the tool and the settings file.
- **2026-10-14 (M2, #623):** A page whose content was cut at the renderer's limits no longer looks complete: a notice names it (and how many), `ui.state` lists the affected pages under `active.cut_pages`, and `text_extract` marks its text `truncated`, since the cut part's text can't be found or copied. The renderer already reported the cut; nothing showed it.
- **2026-10-14 (M5, comment pop-ups; reworked 2026-11-01):** A double-click on a comment opens its pop-up window on the page: a title bar with the comment's kind, author and date; its text and its replies' typed in place, the replies as panels each inside the one it answers, a Reply button under each; the window keeps its `/Rect` size on screen as the page zooms, scrolls its thread, drags by its title bar, resizes by its corner and closes with ×, each an undo step saved in the file's `/Popup` so other viewers agree. A faint wedge in the comment's colour joins the comment to its open window on roll-over or selection. `comment_popup` does the same headlessly and `comment_list` reports the pop-up.
- **2026-10-15 (M6, #1741):** Custom stamps can be dynamic: Create Custom Stamp adds an optional line under the picture, a template with {name}, {date} and {time} filled in when the stamp is placed (the built-in dynamic stamps' format), kept in the stamp library; `stamp_custom` takes the same `line`.
- **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.
@@ -886,6 +905,7 @@ Newest first: the date, what moved, and the new overall estimate.
- **2026-10-21 (M4, #3865):** A new bookmark records where the reader is: the top-left of the selected text, or the top of the window, as `/XYZ left top null` (zoom kept), instead of the page top (`Edit::AddBookmark` takes `left` and `top`; `bookmark_add` takes `top` and `left`; `PageInfo::xyz_for_view`).
- **2026-10-18 (renderer, hayro patches 19/35):** A mesh shading (types 4–7) is sampled over its own triangles instead of the whole path: a BricsCAD plan with 310 one-triangle `sh` fills took 2 s per 1024 px tile at every zoom (and hit the 20 s watchdog on a busy machine); now about 70 ms, so zooming into dense topology drawings stays fluid. Estimates unchanged.
- **2026-10-22 (M3, #3530, builds on PR #3547):** A find step keeps its match in the middle of the window after zooming: a zoom pending in the same frame no longer puts the page-top jump off to the next frame, and the rest of an eased ⌘/Ctrl-wheel zoom anchors on the match instead of the pointer. Synthetic UI regressions step through matches on pages larger and smaller than the window at 10–3200% and in every page display. Estimates unchanged.
- **2026-10-22 (PR #3227 integration):** M11: integrate upstream `80b7d215`; retain partial target-version compatibility and upstream content-boundary and image-size regression coverage. Scope and estimates unchanged.
+13
View File
@@ -6,12 +6,25 @@
//! cross-compile from macOS or Linux still links, unless `PDFCRAFT_REQUIRE_WINRES=1` turns it
//! into an error (for release builds).
/// The in-box DLLs only printing uses (Windows.Data.Pdf draws the job, prntvpt reads the
/// driver's print capabilities). Trimmed Windows installations (NTLite) can lack them, and a
/// load-time import of a missing DLL stops the program before `main` (#2630). Delay-loaded, they
/// load on the first print; `pdfcraft_print::spool` checks they exist before calling in.
/// Same list in `apps/pdfcraft{,-cli}/build.rs`, `crates/print/build.rs` and `crates/print/src/spool.rs`.
const DELAY_LOADED: [&str; 2] = ["windows.data.pdf.dll", "prntvpt.dll"];
fn main() {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-env-changed=PDFCRAFT_REQUIRE_WINRES");
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
return;
}
if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") {
for dll in DELAY_LOADED {
println!("cargo:rustc-link-arg-bins=/DELAYLOAD:{dll}");
}
println!("cargo:rustc-link-arg-bins=delayimp.lib");
}
let mut res = winresource::WindowsResource::new();
res.set("ProductName", "PdfCraft")
.set("FileDescription", "PdfCraft command-line tool")
+29 -8
View File
@@ -43,7 +43,18 @@ pub(crate) fn check_one(args: &[String]) -> Result<(), CliError> {
let path = *positional(args, CHECK_ONE_OPTIONS).first().ok_or("check-one: missing file")?;
let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("36"))?;
let start = Instant::now();
let bytes = read(path)?;
// A file that can't be read (past the input cap, say) is this file's record, not a crash of the child.
let bytes = match read(path) {
Ok(bytes) => bytes,
Err(e) => {
let status = if e.contains("larger than") { "input-too-large" } else { "read-error" };
let line = serde_json::json!({ "file": path, "status": status, "pages": 0, "failed_pages": 0, "warnings": 0,
"ms": start.elapsed().as_millis() as u64, "detail": e.chars().take(400).collect::<String>() });
stdout_line(format_args!("{line}"))?;
// (the record is the answer `check` reads; the error and exit status are for a direct run)
return Err(e.into());
}
};
let (status, pages, failed, detail, warnings) = match inspect(bytes.clone(), None) {
Err(e) => (format!("open-error:{}", short(&e.to_string())), 0, 0, e.to_string(), 0),
Ok(info) => {
@@ -65,7 +76,7 @@ pub(crate) fn check_one(args: &[String]) -> Result<(), CliError> {
// `--edit` (fuzzing): also run our own object layer end to end, with no panic safety net,
// so a panic in cos shows up as a crash of this process.
if args.iter().any(|a| a == "--edit") {
edit_round_trip(&std::fs::read(path).map_err(|e| e.to_string())?);
edit_round_trip(&read(path)?);
}
let line = serde_json::json!({ "file": path, "status": status, "pages": pages, "failed_pages": failed, "warnings": warnings,
"ms": start.elapsed().as_millis() as u64, "detail": detail.chars().take(400).collect::<String>() });
@@ -76,13 +87,19 @@ pub(crate) fn check_one(args: &[String]) -> Result<(), CliError> {
/// Open with cos, touch every object, edit the catalog, save incrementally and in full (both
/// output styles), and reopen each result. Errors are fine; panics, hangs and aborts are bugs.
fn edit_round_trip(bytes: &[u8]) {
use pdfcraft_cos::{Document, Object, SaveOptions, write_full, write_incremental};
use pdfcraft_cos::{DecodeBudget, Document, Object, SaveOptions, write_full, write_incremental};
let Ok(mut doc) = Document::open(Arc::new(bytes.to_vec())) else { return };
// One allowance for every stream, charged for what each decode did (failed ones too), so
// many small bombs can't each inflate to the per-stream limit.
let mut budget = DecodeBudget::page();
for num in doc.object_numbers().into_iter().take(20_000) {
if budget.exhausted() {
break;
}
if let Ok(o) = doc.try_get(num)
&& let Object::Stream(s) = &*o
{
let _ = s.decoded();
let _ = budget.decode(s);
}
}
if let Some(root) = doc.root() {
@@ -113,7 +130,7 @@ fn collect(paths: &[&str]) -> Vec<PathBuf> {
let identity = match p.canonicalize() {
Ok(identity) => identity,
Err(error) => {
eprintln!("{}: could not resolve directory: {error}", p.display());
eprintln!("{}: could not resolve directory: {error}", p.display().to_string().escape_debug());
continue;
}
};
@@ -158,12 +175,16 @@ pub(crate) fn check(args: &[String]) -> Result<(), CliError> {
let status = r["status"].as_str().unwrap_or("?").to_string();
*counts.entry(status.split(':').next().unwrap_or("?").to_string()).or_default() += 1;
if status != "ok" {
// One record per line: a file name or detail with a line break or a terminal control in
// it is written escaped, so it can't forge another record (the JSON report keeps both).
let detail: String = r["detail"].as_str().unwrap_or("").chars().take(160).collect();
eprintln!(
"[{}/{}] {status:<28} {} {}",
"[{}/{}] {:<28} {} {}",
n + 1,
files.len(),
f.display(),
r["detail"].as_str().unwrap_or("").chars().take(160).collect::<String>()
status.escape_debug().to_string(),
f.display().to_string().escape_debug(),
detail.escape_debug()
);
}
results.push(r);
+61 -17
View File
@@ -4,10 +4,24 @@ use super::options::{
};
use super::{Arc, CliError, ImageFormat, PageRenderer, Path, PathBuf, RenderConfig, RenderRequest, RequestKind, Write, inspect, stdout_line};
/// Largest input file read into memory (512 MiB, as automation's `doc_open` reads).
pub(crate) const MAX_INPUT: u64 = 512 * 1024 * 1024;
/// The file at `path`, read no further than [`MAX_INPUT`]: a name that leads to something
/// endless (a device, a pipe nobody closes) fails at the cap instead of filling memory.
pub(crate) fn read(path: &str) -> Result<Arc<Vec<u8>>, String> {
std::fs::read(path).map(Arc::new).map_err(|e| format!("{path}: {e}"))
use std::io::Read as _;
let mut bytes = Vec::new();
std::fs::File::open(path).and_then(|f| f.take(MAX_INPUT + 1).read_to_end(&mut bytes)).map_err(|e| format!("{path}: {e}"))?;
if bytes.len() as u64 > MAX_INPUT {
return Err(format!("{path}: the file is larger than 512 MiB"));
}
Ok(Arc::new(bytes))
}
/// `info FILE`: a JSON summary of the document as the app opens it (pages, fields, metadata, the
/// standards it declares, and the notes about damage repaired while opening it), with none of
/// its scripts run.
pub(crate) fn info(args: &[String]) -> Result<(), CliError> {
validate_options("info", args, INFO_OPTIONS)?;
let path = *positional(args, INFO_OPTIONS).first().ok_or("info: missing file")?;
@@ -18,24 +32,33 @@ pub(crate) fn info(args: &[String]) -> Result<(), CliError> {
let mut info = inspect(bytes.clone(), render_password.as_deref()).map_err(|e| e.to_string())?;
let pdf_pages = info.pages.len();
let acroform_fields = info.fields.len();
// The document as the app opens it: the notes about damage repaired while opening, which the
// app shows in Document Properties, and for a dynamic XFA form the laid-out pages and fields.
// Inspection runs none of the document's scripts: an XFA form's initialize and calculate
// scripts, which the app runs on opening, are left alone, as with JavaScript turned off.
let mut session = pdfcraft_engine::Session::new();
session.set_javascript(false);
let opened = session.open("info.pdf", None, bytes.clone(), password).ok().and_then(|id| session.get(id));
// Dynamic XFA forms: say what laying the template out gives (what the app shows).
let xfa_layout = (info.xfa == Some(pdfcraft_render::Xfa::Dynamic))
.then(|| {
let mut s = pdfcraft_engine::Session::new();
let id = s.open("info.pdf", None, bytes.clone(), password).ok()?;
let d = s.get(id)?;
d.xfa.as_ref().map(|x| {
info = d.info.clone();
// The in-memory layout is an appended revision, not the file being inspected.
info.file_size = bytes.len();
serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings })
})
let xfa_layout = opened.filter(|_| info.xfa == Some(pdfcraft_render::Xfa::Dynamic)).and_then(|d| {
d.xfa.as_ref().map(|x| {
info = d.info.clone();
// The in-memory layout is an appended revision, not the file being inspected.
info.file_size = bytes.len();
serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings })
})
.flatten();
// What the document declares as a standard, read the way the Standards panel reads it, so
// `info` and the app agree. A file that declares nothing leaves these null and empty.
let declared =
pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map(|doc| pdfcraft_preflight::declared(&doc)).unwrap_or_default();
});
// What the document declares as a standard, as the Standards panel reads it, and the notes
// Document Properties shows, which include what the app repaired after reading the objects (a
// page tree rebuilt from the pages found by scanning), both from the session's own reading. A
// file the app cannot open is read once more for what the object reader declares and repaired.
// A file that declares nothing leaves the standards null and empty.
let (declared, repairs) = match opened {
Some(d) => (d.standards(), d.repair_log()),
None => pdfcraft_cos::Document::open_with_password(bytes.clone(), password)
.map(|cos| (pdfcraft_preflight::declared(&cos), cos.repair_log().to_vec()))
.unwrap_or_default(),
};
let json = serde_json::json!({
"file": path,
"pdf_version": info.pdf_version,
@@ -56,6 +79,7 @@ pub(crate) fn info(args: &[String]) -> Result<(), CliError> {
"custom_properties": info.custom.len(),
"page_labels": info.pages.iter().take(8).map(|p| p.label.clone()).collect::<Vec<_>>(),
"warnings": info.warnings,
"repairs": repairs,
});
stdout_line(format_args!("{}", serde_json::to_string_pretty(&json).unwrap_or_default()))?;
Ok(())
@@ -374,6 +398,14 @@ pub(crate) fn render(args: &[String]) -> Result<(), CliError> {
// The renderer takes only the user password of R2–R4 files; `Session::open` and the display
// copy take either.
let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password);
// A page image is an image taken out of the document, as a snapshot or Export is in the app:
// like `text`, it needs the document to allow copying (or the permissions password).
let mut session = pdfcraft_engine::Session::new();
if let Some(doc) = session.open(path, None, bytes.clone(), password).ok().and_then(|id| session.get(id)) {
doc.check_copying().map_err(
|_| "render: the document's permissions don't allow copying its pages as images; pass the owner password with --password to render it",
)?;
}
let display = if inspect(bytes.clone(), render_password.as_deref()).is_ok_and(|info| info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) {
let mut session = pdfcraft_engine::Session::new();
let id = session.open(path, None, bytes, password).map_err(|e| e.to_string())?;
@@ -408,6 +440,18 @@ pub(crate) fn render(args: &[String]) -> Result<(), CliError> {
let _ =
writeln!(std::io::stderr().lock(), "warning: page {page}: a colour that could not be computed was painted in a fallback colour");
}
pdfcraft_render::RenderWarning::MalformedIndexedImage => {
let _ = writeln!(
std::io::stderr().lock(),
"warning: page {page}: an image with a malformed Indexed colour table may be drawn wrongly or not at all"
);
}
pdfcraft_render::RenderWarning::Type3WidthsMissing => {
let _ = writeln!(
std::io::stderr().lock(),
"warning: page {page}: a Type 3 font has no /Widths; its glyphs were spaced by the widths their glyph procedures give"
);
}
}
}
Ok(())
+72 -15
View File
@@ -50,18 +50,23 @@ pub(crate) fn ui(args: &[String]) -> Result<(), CliError> {
/// Most a control file may hold. The app writes under 100 bytes.
const CONTROL_FILE_MAX: u64 = 64 * 1024;
#[cfg(not(windows))]
const CONTROL_FILE_ADVICE: &str =
"Start the app with a control file in a folder only you can write, such as `pdfcraft --control ~/.pdfcraft-control.json`";
// cmd.exe doesn't expand `~` (#3922).
#[cfg(windows)]
const CONTROL_FILE_ADVICE: &str =
r"Start the app with a control file in a folder only you can write, such as `pdfcraft --control %USERPROFILE%\.pdfcraft-control.json`";
/// Read the file written by `pdfcraft --control FILE`.
///
/// It names the port that receives the token and every command, typed text included, so it must
/// be the user's own. In a shared folder such as `/tmp`, another local user can create the file
/// first; the app then can't write it, and the CLI would talk to the other user's listener.
/// Refused: a symbolic link, anything but a regular file, and on Unix a file owned by someone
/// else or open to other users (the app writes it with mode 600).
/// Refused: a symbolic link, anything but a regular file, and a file owned by someone else or
/// open to other users: on Unix by its owner and mode (the app writes it with mode 600), on
/// Windows by its owner and access list (the app gives it one of its user and SYSTEM, #3922).
fn read_control_file(file: &str) -> Result<serde_json::Value, String> {
use std::io::Read as _;
let at_path = std::fs::symlink_metadata(file).map_err(|e| format!("{file}: {e}"))?;
if at_path.file_type().is_symlink() {
return Err(format!("{file}: is a symbolic link, which could point at another user's file. {CONTROL_FILE_ADVICE}"));
@@ -69,21 +74,41 @@ fn read_control_file(file: &str) -> Result<serde_json::Value, String> {
if !at_path.is_file() {
return Err(format!("{file}: not a regular file. {CONTROL_FILE_ADVICE}"));
}
let f = std::fs::File::open(file).map_err(|e| format!("{file}: {e}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
// Checked on the open file, so nothing swapped in after `symlink_metadata` gets through.
let opened = f.metadata().map_err(|e| format!("{file}: {e}"))?;
if (opened.dev(), opened.ino()) != (at_path.dev(), at_path.ino()) {
return Err(format!("{file}: was replaced while being opened. {CONTROL_FILE_ADVICE}"));
#[cfg(not(windows))]
let text = {
use std::io::Read as _;
let f = std::fs::File::open(file).map_err(|e| format!("{file}: {e}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
// Checked on the open file, so nothing swapped in after `symlink_metadata` gets through.
let opened = f.metadata().map_err(|e| format!("{file}: {e}"))?;
if (opened.dev(), opened.ino()) != (at_path.dev(), at_path.ino()) {
return Err(format!("{file}: was replaced while being opened. {CONTROL_FILE_ADVICE}"));
}
if let Some(problem) = control_file_problem(opened.uid(), opened.mode(), current_uid()) {
return Err(format!("{file}: {problem}. {CONTROL_FILE_ADVICE}"));
}
}
if let Some(problem) = control_file_problem(opened.uid(), opened.mode(), current_uid()) {
let mut text = String::new();
f.take(CONTROL_FILE_MAX.saturating_add(1)).read_to_string(&mut text).map_err(|e| format!("{file}: {e}"))?;
text
};
// Windows: the same owner and access check, on the security descriptor of the very handle the
// contents are read through, so nothing swapped in at the path gets through (#3922). A file
// planted by another user, or one other users can read, is refused; if the check itself can't
// run, the file isn't trusted either.
#[cfg(windows)]
let text = {
use pdfcraft_platform::owner_only;
let me = owner_only::current_user_sid().map_err(|e| format!("{file}: can't check who owns it: {e}. {CONTROL_FILE_ADVICE}"))?;
let (sddl, bytes) = owner_only::read_with_sddl(std::path::Path::new(file), CONTROL_FILE_MAX)
.map_err(|e| format!("{file}: can't check who owns it: {e}. {CONTROL_FILE_ADVICE}"))?;
if let Some(problem) = owner_only::foreign_access(&sddl, &me) {
return Err(format!("{file}: {problem}. {CONTROL_FILE_ADVICE}"));
}
}
let mut text = String::new();
f.take(CONTROL_FILE_MAX.saturating_add(1)).read_to_string(&mut text).map_err(|e| format!("{file}: {e}"))?;
String::from_utf8(bytes).map_err(|e| format!("{file}: {e}"))?
};
if u64::try_from(text.len()).unwrap_or(u64::MAX) > CONTROL_FILE_MAX {
return Err(format!("{file}: too large for a control file (over {} KiB)", CONTROL_FILE_MAX / 1024));
}
@@ -112,6 +137,38 @@ fn current_uid() -> u32 {
mod tests {
use super::control_file_problem;
/// #3922: on Windows the CLI checks the control file's owner and access list. A file other
/// users can read is refused; the same file restricted to its owner is accepted.
#[cfg(windows)]
#[test]
fn on_windows_a_control_file_other_users_can_read_is_refused() {
let dir = std::env::temp_dir().join(format!("pdfcraft-cli-control-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let users = std::process::Command::new("icacls").arg(&dir).arg("/grant").arg("*S-1-5-32-545:(OI)(CI)R").output().unwrap();
assert!(users.status.success());
let file = dir.join("ctl.json");
std::fs::write(&file, r#"{"port":4242,"token":"t0ken"}"#).unwrap();
let path = file.to_string_lossy().into_owned();
let refused = super::read_control_file(&path).unwrap_err();
assert!(refused.contains("another account") && refused.contains("%USERPROFILE%"), "{refused}");
pdfcraft_platform::owner_only::restrict_to_owner(&file, &pdfcraft_platform::owner_only::current_user_sid().unwrap()).unwrap();
assert_eq!(super::read_control_file(&path).unwrap()["token"].as_str(), Some("t0ken"));
// The contents come with the check, capped: exactly the limit is read, one byte more is refused.
let padded = |len: usize| {
let json = r#"{"port":4242,"token":"t0ken"}"#;
json.to_string() + &" ".repeat(len - json.len())
};
std::fs::write(&file, padded(64 * 1024)).unwrap();
assert_eq!(super::read_control_file(&path).unwrap()["port"].as_u64(), Some(4242));
std::fs::write(&file, padded(64 * 1024 + 1)).unwrap();
assert!(super::read_control_file(&path).unwrap_err().contains("too large"));
// Valid JSON once the byte is replaced by U+FFFD, so only a strict UTF-8 read refuses it.
std::fs::write(&file, b"{\"port\":4242,\"token\":\"\xFF\"}").unwrap();
assert!(super::read_control_file(&path).is_err(), "not UTF-8");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_control_file_must_be_yours_and_private() {
// Regular file type bits (0o100000) don't matter, only the owner and the permission bits.
+15
View File
@@ -129,3 +129,18 @@ fn a_bad_timeout_or_dpi_is_refused_before_scanning() {
);
}
}
/// A failing file's name goes to stderr escaped: a line break in it can't start a second,
/// forged record. The JSON report keeps the name as it is.
#[cfg(unix)]
#[test]
fn a_file_name_cannot_forge_a_stderr_record() {
let dir = Workdir::new("escaped-name");
let name = "bad\n[9] ok forged.pdf";
std::fs::write(dir.0.join("input").join(name), b"not a pdf").unwrap();
let out = Command::new(BIN).arg("check").arg(dir.0.join("input")).args(["--dpi", "36", "--timeout", "5"]).output().unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
let records: Vec<&str> = stderr.lines().filter(|l| l.starts_with('[')).collect();
assert_eq!(records.len(), 1, "{stderr}");
assert!(records[0].contains("bad\\n[9] ok forged.pdf"), "{stderr}");
}
@@ -0,0 +1,75 @@
//! `info` lists the notes about damage repaired while opening, as the app shows them in Document
//! Properties and as `doc_info` reports them in `repairs`: a rebuilt cross-reference index, and a
//! page tree rebuilt from the pages found by scanning (a note the app adds after reading).
use std::process::Command;
const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
/// A PDF with `pages` pages, each drawing a rectangle (a page found by scanning has content), and
/// `catalog` as object 1; `startxref` points at its cross-reference table, or at `bad_startxref`.
fn fixture(catalog: &str, pages: usize, bad_startxref: Option<usize>) -> Vec<u8> {
let content = "0 0 0 rg 10 10 40 20 re f";
let kids: String = (0..pages).map(|i| format!("{} 0 R ", 4 + i)).collect();
let mut objs = vec![
catalog.to_string(),
format!("<< /Type /Pages /Kids [{kids}] /Count {pages} >>"),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
];
objs.extend((0..pages).map(|_| "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 60] /Contents 3 0 R >>".to_string()));
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 off in &offsets {
out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
}
let start = bad_startxref.unwrap_or(xref);
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{start}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
const CATALOG: &str = "<< /Type /Catalog /Pages 2 0 R >>";
fn info_of(bytes: &[u8], name: &str) -> serde_json::Value {
let dir = std::env::temp_dir().join(format!("pdfcraft-info-repairs-{}-{name}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("f.pdf");
std::fs::write(&path, bytes).unwrap();
let out = Command::new(BIN).args(["info", path.to_str().unwrap()]).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let json = serde_json::from_slice(&out.stdout).expect("info prints JSON");
let _ = std::fs::remove_dir_all(&dir);
json
}
fn repairs(json: &serde_json::Value) -> Vec<String> {
json["repairs"].as_array().unwrap_or_else(|| panic!("no repairs array: {json}")).iter().map(|l| l.as_str().unwrap().to_string()).collect()
}
#[test]
fn info_lists_a_rebuilt_cross_reference_index() {
// `startxref` points into the middle of an object, so the index is rebuilt from the headers.
let json = info_of(&fixture(CATALOG, 1, Some(20)), "xref");
assert_eq!(json["pages"], serde_json::json!(1), "{json}");
let lines = repairs(&json);
assert!(lines.iter().any(|l| l.contains("rebuilt cross-reference index")), "{lines:?}");
}
#[test]
fn info_lists_a_page_tree_the_app_rebuilt_by_scanning() {
// The catalog names a page tree object the file does not have (#1656).
let json = info_of(&fixture("<< /Type /Catalog /Pages 9 0 R >>", 2, None), "pagetree");
let lines = repairs(&json);
assert!(lines.iter().any(|l| l.contains("page tree is missing")), "{lines:?}");
}
#[test]
fn info_lists_no_repairs_for_a_sound_file() {
let json = info_of(&fixture(CATALOG, 1, None), "sound");
assert_eq!(json["repairs"], serde_json::json!([]), "{json}");
}
+1
View File
@@ -8,6 +8,7 @@ mod check;
mod edit;
mod extract;
mod help;
mod info_repairs;
mod info_standards;
mod mcp_compact;
mod options;
+11
View File
@@ -113,3 +113,14 @@ fn an_argument_that_is_not_utf8_is_an_error_not_a_panic() {
assert!(!stderr.contains("panicked"), "{stderr}");
}
}
/// A file `check-one` cannot read is a record on stdout with its own status and the reason, which
/// `check` relays, not an empty stdout that reads as a crash.
#[test]
fn check_one_reports_an_unreadable_file_as_a_record() {
let out = Command::new(BIN).args(["check-one", "missing.pdf"]).output().expect("run cli");
let stdout = String::from_utf8_lossy(&out.stdout);
let record: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| panic!("{e}: {stdout:?}"));
assert_eq!(record["status"], "read-error");
assert!(record["detail"].as_str().is_some_and(|d| d.contains("missing.pdf")), "{record}");
}
@@ -130,6 +130,12 @@ fn render_accepts_the_owner_password() {
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
if pw == "user" {
// The user password opens it, but a page image is a copy the permissions forbid.
assert!(!out.status.success() && stderr.contains("don't allow copying"), "{alg:?} with {pw:?}: {stderr}");
assert!(!png.exists(), "{alg:?}: no image is written");
continue;
}
assert!(out.status.success(), "{alg:?} with {pw:?}: {stderr}");
assert_rendered_page(&std::fs::read(&png).unwrap(), DPI, &format!("{alg:?} with {pw:?}"));
}
+18
View File
@@ -86,6 +86,24 @@ fn write_private(path: &Path, text: &str) {
opts.mode(0o600);
}
opts.open(path).unwrap().write_all(text.as_bytes()).unwrap();
// On Windows the file inherits its folder's access list, as the app's would before it
// restricts it (#3922).
#[cfg(windows)]
{
use pdfcraft_platform::owner_only;
owner_only::restrict_to_owner(path, &owner_only::current_user_sid().unwrap()).unwrap();
// The file the CLI is handed must already pass its check, or a refusal below would be
// the helper's fault, not the CLI's.
let sddl = owner_only::sddl_of(path).unwrap();
let listing = |p: &Path| Command::new("icacls").arg(p).output().map(|o| String::from_utf8_lossy(&o.stdout).into_owned());
let folder = path.parent().map(listing);
assert_eq!(
owner_only::foreign_access(&sddl, &owner_only::current_user_sid().unwrap()),
None,
"restricted file: {sddl}; icacls: {:?}; folder: {folder:?}",
listing(path)
);
}
}
fn ui(control: &Path, method: &str) -> Output {
+41 -2
View File
@@ -7,6 +7,11 @@ use std::sync::Arc;
const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
fn fixture(test: &str) -> PathBuf {
fixture_with(test, "")
}
/// The two-page form, with `event` inside its `name` field.
fn fixture_with(test: &str, event: &str) -> PathBuf {
let xdp = r#"<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
<subform name="form" layout="tb">
@@ -15,11 +20,12 @@ fn fixture(test: &str) -> PathBuf {
<medium short="200pt" long="300pt"/>
</pageArea></pageSet>
<draw w="160pt" h="20pt"><value><text>First XFA page</text></value></draw>
<field name="name" w="160pt" h="20pt"><ui><textEdit/></ui><value><text>Ada</text></value></field>
<field name="name" w="160pt" h="20pt"><ui><textEdit/></ui><value><text>Ada</text></value>{event}</field>
<subform name="last" layout="tb"><breakBefore targetType="pageArea"/>
<draw w="160pt" h="20pt"><value><text>Second XFA page</text></value></draw>
</subform>
</subform></template></xdp:xdp>"#;
</subform></template></xdp:xdp>"#
.replace("{event}", event);
let objects = [
"<< /Type /Catalog /Pages 2 0 R /NeedsRendering true /AcroForm << /XFA 5 0 R /Fields [] >> >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
@@ -67,6 +73,21 @@ fn info_reports_the_laid_out_pages_and_fields() {
assert_eq!(std::fs::read(path).unwrap(), original, "inspection must not write the layout to the input");
}
/// `info` inspects an untrusted file: a form's initialize script, which the app runs on opening
/// and which here would change the field's value, does not run.
#[test]
fn info_runs_none_of_the_forms_scripts() {
let script = r#"<event activity="initialize"><script contentType="application/x-javascript">this.rawValue = "Changed";</script></event>"#;
let path = fixture_with("info-script", script);
let output = Command::new(BIN).arg("info").arg(&path).output().unwrap();
success(&output);
let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(info["xfa_layout"]["pages"], 2, "the form is still laid out: {info}");
let warnings = info["warnings"].as_array().unwrap();
assert!(!warnings.iter().any(|w| w.as_str().is_some_and(|w| w.contains("scripts changed it on opening"))), "{warnings:?}");
assert_eq!(info["repairs"], serde_json::json!([]), "{info}");
}
#[test]
fn text_reads_the_layout_including_a_page_absent_from_the_placeholder() {
let path = fixture("text");
@@ -134,3 +155,21 @@ fn a_laid_out_form_keeps_the_original_copy_permissions() {
assert_eq!(info["file_size"], original.len());
assert_eq!(std::fs::read(path).unwrap(), original);
}
/// A damaged dynamic XFA form: `info` lists the cross-reference index rebuilt while opening it,
/// as Document Properties does after the form is laid out as one more revision.
#[test]
fn info_lists_the_repairs_of_a_laid_out_form() {
let path = fixture("info-damaged");
let bytes = std::fs::read(&path).unwrap();
let at = bytes.windows(10).rposition(|w| w == b"startxref\n").unwrap() + 10;
let mut damaged = bytes[..at].to_vec();
damaged.extend_from_slice(b"20\n%%EOF\n");
std::fs::write(&path, &damaged).unwrap();
let output = Command::new(BIN).arg("info").arg(&path).output().unwrap();
success(&output);
let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(info["xfa_layout"]["pages"], 2, "the form is still laid out: {info}");
let repairs = info["repairs"].as_array().unwrap();
assert!(repairs.iter().any(|l| l.as_str().is_some_and(|l| l.contains("rebuilt cross-reference index"))), "{repairs:?}");
}
+6
View File
@@ -14,6 +14,8 @@ workspace = true
pdfcraft-ui-egui = { workspace = true }
# `build_info`: the version and compile-time build label for `--version` and the log.
pdfcraft-engine = { workspace = true }
# `owner_only`: the --control file is restricted to its user on Windows (#3922).
pdfcraft-platform = { workspace = true }
# wgpu draws; OpenGL (glow) is the fallback when wgpu can't start on an old or unusual GPU or
# driver (#461, #435, #392), and can be chosen with PDFCRAFT_RENDERER=gl.
eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "wayland", "x11", "persistence", "links", "clipboard"] }
@@ -62,6 +64,10 @@ windows = { version = "0.62", default-features = false, features = ["std", "UI_V
# socket. `std` has no Unix sockets on Windows, and calling Winsock directly needs unsafe, which
# our workspace forbids. Already in the lockfile through zbus; pinned exactly, review before bumping.
uds_windows = "=1.2.1"
# Full-resolution taskbar and Alt+Tab icon (`ICON_BIG`, issue #3818). Set both window icons
# directly so eframe cannot replace ICON_BIG with a system-sized copy. Already in the tree
# through eframe and covered by the workspace's winit patch.
winit = { version = "0.30", default-features = false }
[build-dependencies]
# Icon + VERSIONINFO for pdfcraft.exe; build.rs only uses it when targeting Windows.
+13
View File
@@ -9,6 +9,13 @@
#[path = "src/windows_manifest.rs"]
mod windows_manifest;
/// The in-box DLLs only printing uses (Windows.Data.Pdf draws the job, prntvpt reads the
/// driver's print capabilities). Trimmed Windows installations (NTLite) can lack them, and a
/// load-time import of a missing DLL stops the program before `main` (#2630). Delay-loaded, they
/// load on the first print; `pdfcraft_print::spool` checks they exist before calling in.
/// Same list in `apps/pdfcraft{,-cli}/build.rs`, `crates/print/build.rs` and `crates/print/src/spool.rs`.
const DELAY_LOADED: [&str; 2] = ["windows.data.pdf.dll", "prntvpt.dll"];
fn main() {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=../../assets/app-icon/pdfcraft.ico");
@@ -18,6 +25,12 @@ fn main() {
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
return;
}
if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") {
for dll in DELAY_LOADED {
println!("cargo:rustc-link-arg-bins=/DELAYLOAD:{dll}");
}
println!("cargo:rustc-link-arg-bins=delayimp.lib");
}
let mut res = winresource::WindowsResource::new();
res.set_icon("../../assets/app-icon/pdfcraft.ico")
.set_icon_with_id("../../assets/app-icon/pdfcraft-document.ico", "2")
+5 -18
View File
@@ -102,25 +102,12 @@ pub fn timestamp(t: SystemTime) -> String {
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
}
/// Days since 1970-01-01 → proleptic Gregorian (year, month, day).
///
/// `timestamp` clamps a pre-1970 clock to the epoch, so `days` is an unsigned count that can run
/// past `NaiveDate`'s ±262143-year range. The Gregorian calendar repeats every 400 years
/// (146_097 days), so chrono converts the day within one cycle and whole cycles go back into the
/// year (an `i128`, then saturated into `i64`), keeping chrono as the only calendar logic and
/// reproducing the old `civil_from_days` result exactly without hand-rolled day math. 1970-01-01
/// is `num_days_from_ce` 719_163.
/// Days since 1970-01-01 → proleptic Gregorian (year, month, day): `pdfcraft_engine::civil_from_days`,
/// which keeps the date past chrono's range by whole 400-year cycles. `days` is a `u64` second
/// count over 86_400, at most about 2.1e14, so it always fits an `i64`.
fn civil_from_days(days: u64) -> (i64, u32, u32) {
use chrono::Datelike;
let (cycles, rest) = (days / 146_097, days % 146_097);
// `rest + 719_163` is 719_163..865_259, always a valid CE day.
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
Some(d) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(i64::MAX), d.month(), d.day())
}
None => (1970, 1, 1), // unreachable
}
// The fallback is unreachable: `days` is at most `u64::MAX / 86_400`, far below `i64::MAX`.
pdfcraft_engine::civil_from_days(i64::try_from(days).unwrap_or(i64::MAX))
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
+150 -33
View File
@@ -12,6 +12,8 @@
//! --cover on|off --default-layout single|continuous|two-up|two-page --default-continuous on|off --default-zoom fit-width|fit-page|<percent> --always-default-view on|off
//! --page-gaps on|off --default-page-gaps on|off --recent-files 0..50
//! --combine-view grid|list --combine-zoom 60..200`
//! `--combine files…` adds the files to the Combine files tab (File Explorer's "Combine files in
//! PdfCraft", #585), of the PdfCraft already running when there is one.
//!
//! `--new-window` opens a window of its own. Without it, on Windows, a launch that only names files
//! hands them to the PdfCraft already running, where they open as tabs (`single_instance`).
@@ -69,6 +71,7 @@ Options:
--two-paged open command-line PDFs in noncontinuous two-page view
--new-window open a window of its own instead of passing the files to a running PdfCraft
--create-images [images…] put the images in one PDF and ask for the page DPI
--combine files… add the files to the Combine files tab, of a running PdfCraft if there is one
--control <file> enable the UI control channel (pdfcraft-cli ui --control <file> …)
--page N, --zoom 150, --layout continuous, --theme dark, …
view settings applied once the files are open
@@ -231,7 +234,7 @@ fn main() -> eframe::Result {
}
startup.files = rejoin_split_paths(startup.files, |p| std::path::Path::new(p).is_file());
let may_hand_off = startup.may_hand_off();
let startup::Arguments { files, options, control_file, create_images, two_paged, .. } = startup;
let startup::Arguments { files, options, control_file, create_images, combine, two_paged, .. } = startup;
let integrated = cfg!(target_os = "macos");
// 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() };
@@ -255,13 +258,13 @@ fn main() -> eframe::Result {
// Absolute for this instance too: links relative to a document resolve against its folder,
// never the working directory.
let files = single_instance::absolute(&files);
let instance = match single_instance::claim(&files, may_hand_off) {
let instance = match single_instance::claim(&files, may_hand_off, combine) {
single_instance::Claim::HandedOff => return Ok(()),
single_instance::Claim::Primary(server) => Some(server),
single_instance::Claim::Alone => None,
};
let choice = renderer_choice(std::env::var("PDFCRAFT_RENDERER").ok().as_deref());
let launch = Launch { files, options, control_file, create_images, integrated, window_controls, two_paged };
let launch = Launch { files, options, control_file, create_images, combine, integrated, window_controls, two_paged };
// Finder, Open With and the Dock deliver files as Apple events, not arguments; catch the one
// that launched us as well as later ones. Lives until the event loop returns.
#[cfg(target_os = "macos")]
@@ -337,7 +340,15 @@ fn native_options(integrated: bool, app_frame: bool, renderer: eframe::Renderer)
.with_drag_and_drop(true)
// Wayland app id: matches packaging/linux/ai.storyteller.pdfcraft.desktop.
.with_app_id(APP_ID);
// eframe replaces ICON_BIG with a system-sized icon on its first active frame.
// An explicitly empty icon disables that setter (None selects eframe's default).
// Windows installs both sizes directly through winit in app_creator instead.
#[cfg(target_os = "windows")]
{
viewport = viewport.with_icon(egui::IconData::default());
}
// Dock, taskbar, Alt-Tab and launcher icon when running unbundled.
#[cfg(not(target_os = "windows"))]
match eframe::icon_data::from_png_bytes(APP_ICON_PNG) {
Ok(icon) => viewport = viewport.with_icon(icon),
Err(e) => log::warn!("app icon: {e}"),
@@ -368,6 +379,8 @@ struct Launch {
options: Vec<(String, String)>,
control_file: Option<String>,
create_images: bool,
/// `--combine`: the files go to the Combine files tab.
combine: bool,
integrated: bool,
/// The window buttons the app draws, when the desktop leaves the frame to it.
window_controls: Option<pdfcraft_ui_egui::window_controls::Layout>,
@@ -386,13 +399,55 @@ fn native_wayland(_: &eframe::CreationContext<'_>) -> bool {
false
}
/// Decode `png` as a [`winit::window::Icon`] for the Windows taskbar and Alt+Tab (`ICON_BIG`).
#[cfg(target_os = "windows")]
fn taskbar_icon(png: &[u8]) -> Option<winit::window::Icon> {
let icon = match eframe::icon_data::from_png_bytes(png) {
Ok(icon) => icon,
Err(e) => {
log::warn!("taskbar icon: {e}");
return None;
}
};
match winit::window::Icon::from_rgba(icon.rgba, icon.width, icon.height) {
Ok(icon) => Some(icon),
Err(e) => {
log::warn!("taskbar icon: {e}");
None
}
}
}
/// Install the decoded [`winit::window::Icon`] from `png` via `set` when decoding succeeds.
#[cfg(target_os = "windows")]
fn install_taskbar_icon(png: &[u8], set: impl FnOnce(winit::window::Icon)) {
if let Some(icon) = taskbar_icon(png) {
set(icon);
}
}
/// Install both window icons before the first frame. eframe's icon setter is disabled in
/// `native_options`, so it cannot replace the full-resolution taskbar/Alt+Tab icon (#3818).
#[cfg(target_os = "windows")]
fn set_taskbar_icon(window: &winit::window::Window) {
use winit::platform::windows::{IconExtWindows as _, WindowExtWindows as _};
install_taskbar_icon(APP_ICON_PNG, |icon| window.set_taskbar_icon(Some(icon)));
// The title bar's icon: the small image drawn for this scale in the exe's own icon (build.rs).
let side = (16.0 * window.scale_factor()).round().clamp(16.0, 256.0) as u32;
match winit::window::Icon::from_resource(1, Some(winit::dpi::PhysicalSize::new(side, side))) {
Ok(icon) => window.set_window_icon(Some(icon)),
Err(e) => log::warn!("window icon: {e}"),
}
}
/// Build the [`eframe::AppCreator`] closure that initializes [`PdfCraftApp`] once the window and renderer are ready.
fn app_creator<'a>(
launch: Launch,
started: Rc<Cell<bool>>,
instance: Option<&'a single_instance::Server>,
#[cfg(target_os = "macos")] apple_events: &'a apple_events::AppleEvents,
) -> eframe::AppCreator<'a> {
let Launch { files, options, control_file, create_images, integrated, window_controls, two_paged } = launch;
let Launch { files, options, control_file, create_images, combine, integrated, window_controls, two_paged } = launch;
Box::new(move |cc| {
started.set(true);
let mut app = PdfCraftApp::new();
@@ -413,6 +468,9 @@ fn app_creator<'a>(
// it is set to On or Off.
#[cfg(target_os = "windows")]
{
if let Some(window) = cc.winit_window() {
set_taskbar_icon(window);
}
app.motion.source = Some(motion::source());
}
app.set_startup_update_check(true);
@@ -466,6 +524,9 @@ fn app_creator<'a>(
if let Err(e) = app.begin_image_import_paths(&files) {
app.notify(e);
}
} else if combine {
app.reopen_last_files(&[]);
app.combine_paths(&files);
} else {
// With the preference on, last session's files come back first; files named on
// the command line open after them, in front (#442).
@@ -506,7 +567,9 @@ fn open_control_channel(file: &str, endpoint: std::io::Result<pdfcraft_ui_egui::
Ok(())
}
Err(e) => {
let message = format!("--control {file}: {e}. Use a control file in a folder only you can write, such as ~/.pdfcraft-control.json");
// cmd.exe doesn't expand `~` (#3922).
let example = if cfg!(windows) { r"%USERPROFILE%\.pdfcraft-control.json" } else { "~/.pdfcraft-control.json" };
let message = format!("--control {file}: {e}. Use a control file in a folder only you can write, such as {example}");
log::error!("{message}");
Err(message)
}
@@ -515,15 +578,25 @@ fn open_control_channel(file: &str, endpoint: std::io::Result<pdfcraft_ui_egui::
/// Write the control endpoint so that only the current user can read the token.
///
/// The JSON goes to a new file next to `path`, created fresh (owner-only on Unix), which then
/// replaces `path`. Opening `path` itself would follow a link planted there and truncate whatever
/// it points at; a rename replaces the link and leaves its target alone. A reader polling for the
/// file also never sees it half-written.
/// The JSON goes to a new file, created fresh and owner-only, which then replaces `path`. On Unix
/// the file sits next to `path` with mode 0600. On Windows it sits in a new folder next to `path`
/// whose access list, of this user and SYSTEM only, it inherits when it is created, so no other
/// account can open it at any time (#3922; review on #3956). Opening `path` itself would follow a
/// link planted there and truncate whatever it points at; a rename replaces the link and leaves
/// its target alone. A reader polling for the file also never sees it half-written.
fn write_control_file(path: &str, port: u16, token: &str) -> std::io::Result<()> {
use std::io::{ErrorKind, Write};
use std::io::ErrorKind;
let json = serde_json::json!({ "port": port, "token": token, "pid": std::process::id() }).to_string();
let path = std::path::Path::new(path);
let name = path.file_name().ok_or_else(|| std::io::Error::new(ErrorKind::InvalidInput, "not a file name"))?;
// Windows: the user's SID, read once for the private folder and for the file in it, since each
// lookup starts `whoami` (review on #3956).
#[cfg(windows)]
let me = pdfcraft_platform::owner_only::current_user_sid()?;
#[cfg(windows)]
let restrict = |file: &std::path::Path| pdfcraft_platform::owner_only::restrict_to_owner(file, &me);
#[cfg(not(windows))]
let restrict = |_: &std::path::Path| Ok::<(), std::io::Error>(());
// Unpredictable suffixes, so the name can't be planted in advance. `RandomState` is keyed from
// the operating system's random source.
let random = std::hash::RandomState::new();
@@ -532,35 +605,79 @@ fn write_control_file(path: &str, port: u16, token: &str) -> std::io::Result<()>
staged.push(name);
staged.push(format!(".{:016x}.tmp", std::hash::BuildHasher::hash_one(&random, i)));
let staged = path.with_file_name(staged);
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let file = match opts.open(&staged) {
Ok(f) => f,
Err(e) if e.kind() == ErrorKind::AlreadyExists => continue,
// Windows refuses `create_new` on a folder with "access denied"; the name is taken all
// the same. A folder we can't write to, with nothing at the name, still fails here.
Err(e) if e.kind() == ErrorKind::PermissionDenied && staged.symlink_metadata().is_ok() => continue,
Err(e) => return Err(e),
// Windows: a file created next to `path` would inherit that folder's access list, which may
// let other users read it, and restricting it afterwards leaves a window in which one of
// them opens a handle; Windows checks access only when a handle is opened, so a later
// change of the list doesn't revoke it. The file is created in a private folder instead,
// and that folder is removed once the file has left it.
#[cfg(windows)]
let (staging, staged) = {
match pdfcraft_platform::owner_only::create_private_dir(&staged, &me) {
Ok(()) => {}
Err(e) if e.kind() == ErrorKind::AlreadyExists => continue,
// As for a file below: something at the name we may not open still takes it.
Err(e) if e.kind() == ErrorKind::PermissionDenied && staged.symlink_metadata().is_ok() => continue,
Err(e) => return Err(e),
}
let file = staged.join(name);
(staged, file)
};
// The block closes the file before it is renamed.
let written = {
let mut file = file;
file.write_all(json.as_bytes())
};
let done = written.and_then(|()| std::fs::rename(&staged, path));
if done.is_err() {
let _ = std::fs::remove_file(&staged);
let done = stage_control_file(&staged, path, &json, &restrict);
#[cfg(windows)]
let _ = std::fs::remove_dir(&staging);
match done {
Some(done) => return done,
None => continue,
}
return done;
}
Err(std::io::Error::new(ErrorKind::AlreadyExists, "no free name for the control file's temporary copy"))
}
/// Create `staged` fresh and owner-only, `restrict` it, write `json` to it and rename it to `path`.
/// `None` when something already has the name, so the caller tries another. On any other failure
/// the new file is removed and nothing is left at `path`'s side.
fn stage_control_file(
staged: &std::path::Path,
path: &std::path::Path,
json: &str,
restrict: &dyn Fn(&std::path::Path) -> std::io::Result<()>,
) -> Option<std::io::Result<()>> {
use std::io::{ErrorKind, Write};
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let file = match opts.open(staged) {
Ok(f) => f,
Err(e) if e.kind() == ErrorKind::AlreadyExists => return None,
// Windows refuses `create_new` on a folder with "access denied"; the name is taken all
// the same. A folder we can't write to, with nothing at the name, still fails here.
Err(e) if e.kind() == ErrorKind::PermissionDenied && staged.symlink_metadata().is_ok() => return None,
Err(e) => return Some(Err(e)),
};
// Windows: the file inherited the private folder's list. It also gets a list of its own, so it
// stays private wherever the rename puts it, and that is checked before the token is written;
// nothing is written if that fails (#3922). Unix has mode 0600 above, and `restrict` does nothing.
if let Err(e) = restrict(staged) {
drop(file);
let _ = std::fs::remove_file(staged);
return Some(Err(e));
}
// The block closes the file before it is renamed.
let written = {
let mut file = file;
file.write_all(json.as_bytes())
};
let done = written.and_then(|()| std::fs::rename(staged, path));
if done.is_err() {
let _ = std::fs::remove_file(staged);
}
Some(done)
}
/// How wgpu finds a GPU. Each choice yields to its wgpu environment variable.
///
/// - Draw on the integrated GPU unless `WGPU_POWER_PREF` says otherwise. A PDF viewer has no use
+99 -37
View File
@@ -4,8 +4,9 @@
//! 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
//! 1803 and later) it is in `%LOCALAPPDATA%\PdfCraft` (a portable copy's too, as
//! `portable-instance-…` and never in its own folder, #889) and is named after the Windows
//! session (`instance-s1.sock` for session 1), 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
@@ -13,7 +14,9 @@
//! 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.
//! paths exactly as File ▸ Open would. A `--combine` launch (File Explorer's "Combine files in
//! PdfCraft", #585, one launch per selected file) hands them over the same way with
//! `"combine": true`, and the running app adds them to its Combine files tab instead.
//!
//! 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
@@ -57,7 +60,10 @@ pub enum Claim {
/// Files that later launches handed over, and how to wake the UI for them.
#[derive(Default)]
struct Inbox {
paths: Vec<String>,
/// The requests in the order they arrived: [`OsEvent::Open`] or, for `--combine`,
/// [`OsEvent::Combine`]. One queue, so the UI handles an Open that came after a Combine after
/// it, and that Open comes to the front instead of opening behind the Combine files tab.
events: Vec<OsEvent>,
wake: Option<Box<dyn Fn() + Send>>,
}
@@ -73,11 +79,20 @@ impl Server {
Self { inbox: Arc::default() }
}
/// Queue files from another launch and wake the UI.
/// Queue files from another launch (to open, or to combine) and wake the UI.
#[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), allow(dead_code))]
fn deliver(&self, paths: Vec<String>) {
fn deliver(&self, paths: Vec<String>, combine: bool) {
if paths.is_empty() {
return;
}
let mut inbox = self.inbox.lock().unwrap_or_else(PoisonError::into_inner);
inbox.paths.extend(paths);
// A run of requests of one kind (File Explorer starts one launch per selected file) stays
// one event; a request of the other kind starts a new one after it.
match (inbox.events.last_mut(), combine) {
(Some(OsEvent::Combine(queued)), true) | (Some(OsEvent::Open(queued)), false) => queued.extend(paths),
(_, true) => inbox.events.push(OsEvent::Combine(paths)),
(_, false) => inbox.events.push(OsEvent::Open(paths)),
}
if let Some(wake) = &inbox.wake {
wake();
}
@@ -94,8 +109,8 @@ impl Server {
let inbox = Arc::clone(&self.inbox);
let ctx = ctx.clone();
Box::new(move || {
let paths = std::mem::take(&mut inbox.lock().unwrap_or_else(PoisonError::into_inner).paths);
if paths.is_empty() {
let events = std::mem::take(&mut inbox.lock().unwrap_or_else(PoisonError::into_inner).events);
if events.is_empty() {
return Vec::new();
}
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
@@ -103,27 +118,37 @@ impl Server {
if native_wayland {
ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Informational));
}
vec![OsEvent::Open(paths)]
events
})
}
}
/// The request a launch sends: `{"open":[…]}` and a newline.
/// The request a launch sends: `{"open":[…]}` (with `"combine":true` for `--combine`) and a
/// newline.
#[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();
pub fn request(paths: &[String], combine: bool) -> String {
let mut value = serde_json::json!({ "open": paths });
if combine {
value["combine"] = serde_json::Value::Bool(true);
}
let mut line = value.to_string();
line.push('\n');
line
}
/// 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.
/// The paths a request names and whether they are for Combine files, or `None` when it isn't a
/// request: anything but one JSON object whose `open` is a non-empty list of non-empty strings
/// (`combine`, when there, must be a boolean).
#[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), allow(dead_code))]
pub fn parse_request(line: &str) -> Option<Vec<String>> {
pub fn parse_request(line: &str) -> Option<(Vec<String>, bool)> {
let value: serde_json::Value = serde_json::from_str(line.trim_end()).ok()?;
let open = value.get("open")?.as_array()?;
let paths = open.iter().map(|p| p.as_str().filter(|s| !s.is_empty()).map(str::to_string)).collect::<Option<Vec<String>>>()?;
(!paths.is_empty()).then_some(paths)
let combine = match value.get("combine") {
None => false,
Some(c) => c.as_bool()?,
};
(!paths.is_empty()).then_some((paths, combine))
}
/// File arguments made absolute against this launch's working folder, which the running app
@@ -133,18 +158,19 @@ 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 {
/// when the launch asked for anything besides opening (or, with `combine`, combining) files, so
/// it gets its own window.
pub fn claim(files: &[String], may_hand_off: bool, combine: bool) -> Claim {
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
{
match socket_path() {
Some(path) => claim_at(&path, files, may_hand_off),
Some(path) => claim_at(&path, files, may_hand_off, combine),
None => Claim::Alone,
}
}
#[cfg(not(any(windows, all(unix, not(target_os = "macos")))))]
{
let _ = (files, may_hand_off);
let _ = (files, may_hand_off, combine);
Claim::Alone
}
}
@@ -238,7 +264,7 @@ fn election(socket: &std::path::Path) -> Option<std::fs::File> {
/// [`claim`] with the socket at `path`.
#[cfg(any(windows, all(unix, not(target_os = "macos"))))]
fn claim_at(path: &std::path::Path, files: &[String], may_hand_off: bool) -> Claim {
fn claim_at(path: &std::path::Path, files: &[String], may_hand_off: bool, combine: bool) -> Claim {
// 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
@@ -249,7 +275,7 @@ fn claim_at(path: &std::path::Path, files: &[String], may_hand_off: bool) -> Cla
if !may_hand_off {
return Claim::Alone;
}
return match hand_off(&stream, files) {
return match hand_off(&stream, files, combine) {
Ok(()) => Claim::HandedOff,
Err(e) => {
log::warn!("couldn't hand the files to the running PdfCraft ({e}); opening a new window");
@@ -295,12 +321,12 @@ 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(any(windows, all(unix, not(target_os = "macos"))))]
fn hand_off(stream: &UnixStream, files: &[String]) -> std::io::Result<()> {
fn hand_off(stream: &UnixStream, files: &[String], combine: bool) -> std::io::Result<()> {
use std::io::{BufRead, Read, Write};
stream.set_read_timeout(Some(TIMEOUT))?;
stream.set_write_timeout(Some(TIMEOUT))?;
let mut writer = stream;
writer.write_all(request(files).as_bytes())?;
writer.write_all(request(files, combine).as_bytes())?;
let mut reply = String::new();
std::io::BufReader::new(stream.take(16)).read_line(&mut reply)?;
if reply == "ok\n" { Ok(()) } else { Err(std::io::Error::other("the running PdfCraft didn't confirm")) }
@@ -342,8 +368,8 @@ fn receive(stream: &UnixStream, server: &Server) -> std::io::Result<()> {
if line.is_empty() {
return Ok(());
}
let paths = parse_request(&line).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "not an open request"))?;
server.deliver(paths);
let (paths, combine) = parse_request(&line).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "not an open request"))?;
server.deliver(paths, combine);
let mut writer = stream;
writer.write_all(b"ok\n")
}
@@ -355,16 +381,31 @@ mod tests {
#[test]
fn a_request_names_the_paths_and_round_trips() {
let paths = vec![r"C:\Users\me\Documents\Quote.pdf".to_string(), r"D:\Scans\Plan „A“ 1.pdf".to_string()];
let line = request(&paths);
let line = request(&paths, false);
assert!(line.ends_with('\n') && !line.trim_end().contains('\n'), "one line");
assert_eq!(parse_request(&line), Some(paths));
assert_eq!(parse_request(&line), Some((paths.clone(), false)));
assert!(!line.contains("combine"), "an open request says nothing about combining");
let line = request(&paths, true);
assert_eq!(parse_request(&line), Some((paths, true)), "a --combine launch's request");
}
#[test]
fn anything_but_a_list_of_paths_is_refused() {
for line in ["", "ok\n", "{}", r#"{"open":[]}"#, r#"{"open":[""]}"#, r#"{"open":["a.pdf",3]}"#, r#"{"open":"a.pdf"}"#, "[\"a.pdf\"]"] {
for line in [
"",
"ok\n",
"{}",
r#"{"open":[]}"#,
r#"{"open":[""]}"#,
r#"{"open":["a.pdf",3]}"#,
r#"{"open":"a.pdf"}"#,
"[\"a.pdf\"]",
r#"{"open":["a.pdf"],"combine":"yes"}"#,
r#"{"open":["a.pdf"],"combine":1}"#,
] {
assert_eq!(parse_request(line), None, "{line:?}");
}
assert_eq!(parse_request(r#"{"open":["a.pdf"],"combine":false}"#), Some((vec!["a.pdf".to_string()], false)));
}
#[test]
@@ -397,13 +438,31 @@ mod tests {
fn delivered_files_reach_the_ui_once_wake_it_and_bring_the_window_forward() {
let server = Server::new();
// Before the window exists: queued, nothing to wake yet.
server.deliver(vec!["a.pdf".into()]);
server.deliver(vec!["a.pdf".into()], false);
let ctx = egui::Context::default();
let mut poll = server.connect(&ctx, false);
server.deliver(vec!["b.pdf".into()]);
server.deliver(vec!["b.pdf".into()], false);
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");
// Files to combine (one launch each, from File Explorer) arrive as one Combine event,
// apart from files to open.
server.deliver(vec!["c.pdf".into()], true);
server.deliver(vec!["e.pdf".into()], true);
let events = poll();
assert!(matches!(events.as_slice(), [OsEvent::Combine(c)] if c == &["c.pdf", "e.pdf"]), "{events:?}");
// #2503 review: requests reach the UI in the order they arrived, so an Open after a
// Combine is handled after it and comes to the front instead of opening behind the
// Combine files tab.
server.deliver(vec!["f.pdf".into()], true);
server.deliver(vec!["g.pdf".into()], false);
server.deliver(vec!["h.pdf".into()], true);
let events = poll();
assert!(
matches!(events.as_slice(), [OsEvent::Combine(f), OsEvent::Open(g), OsEvent::Combine(h)] if f == &["f.pdf"] && g == &["g.pdf"] && h == &["h.pdf"]),
"{events:?}"
);
assert!(poll().is_empty());
}
#[test]
@@ -441,12 +500,15 @@ mod tests {
#[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 Claim::Primary(first) = claim_at(&path, &[], true, false) else { panic!("the first launch listens") };
let mut poll = first.connect(&egui::Context::default(), false);
assert!(matches!(claim_at(&path, &["C:\\a.pdf".into()], true), Claim::HandedOff));
assert!(matches!(claim_at(&path, &["C:\\a.pdf".into()], true, false), Claim::HandedOff));
assert!(matches!(poll().as_slice(), [OsEvent::Open(p)] if p == &["C:\\a.pdf"]));
// A --combine launch hands its file over for the Combine files tab (#585).
assert!(matches!(claim_at(&path, &["C:\\c.pdf".into()], true, true), Claim::HandedOff));
assert!(matches!(poll().as_slice(), [OsEvent::Combine(p)] if p == &["C:\\c.pdf"]));
// A launch with options keeps its own window and doesn't take over the socket.
assert!(matches!(claim_at(&path, &["C:\\b.pdf".into()], false), Claim::Alone));
assert!(matches!(claim_at(&path, &["C:\\b.pdf".into()], false, false), Claim::Alone));
assert!(poll().is_empty());
}
@@ -461,7 +523,7 @@ mod tests {
let (path, barrier) = (path.clone(), Arc::clone(&barrier));
std::thread::spawn(move || {
barrier.wait();
claim_at(&path, &[format!("C:\\{i}.pdf")], true)
claim_at(&path, &[format!("C:\\{i}.pdf")], true, false)
})
})
.collect();
@@ -482,6 +544,6 @@ mod tests {
}));
// `symlink_metadata`, not `exists`, which follows the socket's reparse point and can fail.
assert!(std::fs::symlink_metadata(&path).is_ok(), "the socket file stays behind");
assert!(matches!(claim_at(&path, &["C:\\a.pdf".into()], true), Claim::Primary(_)));
assert!(matches!(claim_at(&path, &["C:\\a.pdf".into()], true, false), Claim::Primary(_)));
}
}
+5
View File
@@ -7,6 +7,8 @@ pub(super) struct Arguments {
pub options: Vec<(String, String)>,
pub control_file: Option<String>,
pub create_images: bool,
/// `--combine`: the files go to the Combine files tab.
pub combine: bool,
pub new_window: bool,
pub two_paged: bool,
pub version: bool,
@@ -33,6 +35,9 @@ impl Arguments {
}
"--control" => parsed.control_file = args.next(),
"--create-images" => parsed.create_images = true,
// File Explorer's "Combine files in PdfCraft" (#585): the files go to the Combine
// files tab, of the running PdfCraft when there is one.
"--combine" => parsed.combine = true,
"--new-window" => parsed.new_window = true,
"--two-paged" => parsed.two_paged = true,
flag if flag.starts_with("--two-paged=") => {
+61
View File
@@ -137,6 +137,31 @@ fn the_desktop_frame_is_turned_off_only_when_the_app_draws_one() {
assert_eq!(native_options(false, true, eframe::Renderer::Glow).viewport.decorations, Some(false));
}
#[test]
fn the_window_and_windows_taskbar_icons_load_from_the_bundled_png() {
let icon = eframe::icon_data::from_png_bytes(super::APP_ICON_PNG).expect("the bundled PNG decodes as the window icon");
assert!(icon.width > 0 && icon.height > 0);
assert_eq!(icon.rgba.len(), (icon.width as usize) * (icon.height as usize) * 4);
#[cfg(not(target_os = "windows"))]
assert_eq!(super::native_options(false, false, eframe::Renderer::Glow).viewport.icon.as_deref(), Some(&icon));
#[cfg(target_os = "windows")]
{
// None would load eframe's default icon; only the explicit empty sentinel prevents
// its first-frame setter from overwriting our full-resolution ICON_BIG.
for renderer in [eframe::Renderer::Glow, eframe::Renderer::Wgpu] {
let viewport = super::native_options(false, false, renderer).viewport;
assert_eq!(viewport.icon.as_deref(), Some(&egui::IconData::default()));
}
assert_eq!((icon.width, icon.height), (256, 256));
let mut installed = None;
super::install_taskbar_icon(super::APP_ICON_PNG, |icon| installed = Some(icon));
assert!(installed.is_some(), "issue #3818: ICON_BIG loads and installs from APP_ICON_PNG");
let mut bad = None;
super::install_taskbar_icon(b"not a png", |icon| bad = Some(icon));
assert!(bad.is_none());
}
}
#[test]
fn a_software_renderer_is_shown_to_the_user() {
use eframe::wgpu::DeviceType;
@@ -654,6 +679,42 @@ fn the_control_file_replaces_the_one_a_previous_run_left() {
let _ = std::fs::remove_dir_all(&dir);
}
/// #3922: a control file created in a folder that lets every user read what's in it is still
/// readable by its user and SYSTEM only, like mode 0600 on Unix.
#[cfg(windows)]
#[test]
fn the_control_file_is_owner_only_in_a_folder_other_users_can_read() {
use pdfcraft_platform::owner_only;
let dir = scratch();
let users = std::process::Command::new("icacls").arg(&dir).arg("/grant").arg("*S-1-5-32-545:(OI)(CI)R").output().unwrap();
assert!(users.status.success());
// What the folder hands down, for comparison: a plain file there is readable by Users.
std::fs::write(dir.join("plain.txt"), "x").unwrap();
let me = owner_only::current_user_sid().unwrap();
assert!(owner_only::foreign_access(&owner_only::sddl_of(&dir.join("plain.txt")).unwrap(), &me).is_some());
super::write_control_file(&dir.join("ctl.json").to_string_lossy(), 4242, "t0ken").unwrap();
let sddl = owner_only::sddl_of(&dir.join("ctl.json")).unwrap();
assert_eq!(owner_only::foreign_access(&sddl, &me), None, "{sddl}");
let (json, names) = written(&dir);
assert_eq!(json["token"].as_str(), Some("t0ken"));
assert_eq!(names, ["ctl.json", "plain.txt"], "no private staging folder left behind");
let _ = std::fs::remove_dir_all(&dir);
}
/// Review on #3956: writing the control file reads the user's SID once, for both the private
/// folder and the file in it, rather than starting `whoami` for each.
#[cfg(windows)]
#[test]
fn writing_the_control_file_looks_up_the_users_sid_once() {
use pdfcraft_platform::owner_only::sid_lookups_on_this_thread;
let dir = scratch();
let before = sid_lookups_on_this_thread();
super::write_control_file(&dir.join("ctl.json").to_string_lossy(), 4242, "t0ken").unwrap();
assert_eq!(sid_lookups_on_this_thread().saturating_sub(before), 1, "whoami runs once per control file");
assert_eq!(written(&dir).0["token"].as_str(), Some("t0ken"));
let _ = std::fs::remove_dir_all(&dir);
}
/// X11 startup notification and docks match a window to its launcher by `StartupWMClass`,
/// so it has to be the class the window carries: the app id, on Wayland and X11 alike.
#[test]
+7 -1
View File
@@ -106,7 +106,13 @@ pub(crate) fn elements(doc: &Document) -> Vec<Element> {
if !seen.insert(r) {
continue;
}
let Some(d) = doc.get(r).as_dict().cloned() else { continue };
let target = doc.get(r);
if matches!(&*target, Object::Array(_) | Object::Ref(_)) {
// An indirect /K array is a container, not an element or a parent.
stack.push(((*target).clone(), pg, parent, depth));
continue;
}
let Some(d) = target.as_dict().cloned() else { continue };
if d.contains(b"S") {
let pg = d.get(b"Pg").and_then(Object::as_ref).or(pg);
if let Some(kids) = d.get(b"K") {
+181 -95
View File
@@ -1,14 +1,21 @@
//! Create a valid, reviewable structure tree for PDFs whose painted content is text only.
//! Create a valid, reviewable structure tree for untagged PDFs.
//!
//! The writer refuses mixed content and existing tags. Each text object becomes a paragraph,
//! which can then be retagged with the existing tag-property tool after human review.
//! Each text object becomes a paragraph, which can then be retagged with the existing
//! tag-property tool after human review. Painted paths, shadings and images outside text objects
//! are wrapped in `/Artifact BMC … EMC` so the resulting page passes the tagged-content check.
use pdfcraft_content::{Op, parse, serialize_ops};
use std::collections::{HashMap, HashSet};
use pdfcraft_content::{Op, Pieces, serialize_ops};
use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream};
const MAX_PAGES: usize = 1_000;
const MAX_PAGE_BYTES: usize = 16 * 1024 * 1024;
const MAX_OPERATORS: usize = 100_000;
/// What all pages together may decode and keep: pages can share one stream, so the per-page limits
/// alone let a small file retain a plan for every page. Real documents stay far under these.
const MAX_TOTAL_BYTES: usize = 128 * 1024 * 1024;
const MAX_TOTAL_OPERATORS: usize = 2_000_000;
const MAX_TEXT_OBJECTS: usize = 4_096;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -37,25 +44,27 @@ struct Plan {
page: ObjRef,
ops: Vec<Op>,
spans: Vec<(usize, usize)>,
artifacts: HashMap<usize, usize>,
}
fn unsupported(page: usize, reason: impl Into<String>) -> AutoTagError {
AutoTagError::Unsupported { page: page + 1, reason: reason.into() }
}
fn content_bytes(doc: &Document, page: &pdfcraft_model::Page, index: usize) -> Result<Vec<u8>, AutoTagError> {
let Some(contents) = page.dict.get(b"Contents") else { return Ok(Vec::new()) };
fn content_pieces(doc: &Document, page: &pdfcraft_model::Page, index: usize) -> Result<Pieces, AutoTagError> {
let Some(contents) = page.dict.get(b"Contents") else { return Ok(Pieces::join::<&[u8]>(&[])) };
let resolved = doc.resolve(contents);
let pieces: Vec<Object> = match &*resolved {
let items: Vec<Object> = match &*resolved {
Object::Array(items) => items.clone(),
Object::Stream(_) => vec![contents.clone()],
_ => return Err(unsupported(index, "contents are not a stream or array of streams")),
};
let mut all = Vec::new();
for item in pieces {
let mut pieces = Vec::with_capacity(items.len());
let mut total = 0usize;
for item in items {
let value = doc.resolve(&item);
let Object::Stream(stream) = &*value else { return Err(unsupported(index, "contents include a non-stream object")) };
let remaining = MAX_PAGE_BYTES.saturating_sub(all.len().saturating_add(1));
let remaining = MAX_PAGE_BYTES.saturating_sub(total.saturating_add(1));
if remaining == 0 {
return Err(unsupported(index, "decoding the content takes more than 16 MiB"));
}
@@ -75,10 +84,18 @@ fn content_bytes(doc: &Document, page: &pdfcraft_model::Page, index: usize) -> R
unsupported(index, format!("content cannot be decoded strictly: {why}"))
}
})?;
all.extend_from_slice(&decoded);
all.push(b' ');
total = total.saturating_add(decoded.len()).saturating_add(1);
pieces.push(decoded);
}
Ok(all)
Ok(Pieces::join(&pieces))
}
fn page_xobjects(doc: &Document, page: &Dict) -> Dict {
page.get(b"Resources")
.map(|r| doc.resolve(r))
.and_then(|r| r.as_dict().and_then(|r| r.get(b"XObject").map(|x| doc.resolve(x))))
.and_then(|x| x.as_dict().cloned())
.unwrap_or_default()
}
fn harmless_outside(op: &[u8]) -> bool {
@@ -106,9 +123,35 @@ fn harmless_outside(op: &[u8]) -> bool {
| b"sc"
| b"SCN"
| b"scn"
| b"m"
| b"l"
| b"c"
| b"v"
| b"y"
| b"h"
| b"re"
| b"W"
| b"W*"
| b"n"
)
}
fn painted_outside(doc: &Document, xobjects: &Dict, op: &Op) -> bool {
match op.op.as_slice() {
b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"S" | b"s" | b"sh" | b"BI" => true,
b"Do" => op
.name(0)
.and_then(|n| xobjects.get(n))
.map(|o| doc.resolve(o))
.and_then(|o| match &*o {
Object::Stream(s) => s.dict.name(b"Subtype").map(<[u8]>::to_vec),
_ => None,
})
.is_some_and(|st| st == b"Image"),
_ => false,
}
}
fn harmless_inside(op: &[u8]) -> bool {
matches!(
op,
@@ -138,17 +181,38 @@ fn harmless_inside(op: &[u8]) -> bool {
)
}
/// The `(BT, ET)` operator index pairs of each text object that shows text, or why the page cannot be tagged.
fn text_spans(ops: &[Op], index: usize) -> Result<Vec<(usize, usize)>, AutoTagError> {
let mut start = None;
let mut has_text = false;
let mut spans = Vec::new();
for (at, op) in ops.iter().enumerate() {
/// `totals` is the decoded bytes and operators of the pages planned so far, bounded by
/// `MAX_TOTAL_BYTES` and `MAX_TOTAL_OPERATORS`.
fn plan_page(doc: &Document, page: &pdfcraft_model::Page, index: usize, totals: &mut (usize, usize)) -> Result<Plan, AutoTagError> {
if page.dict.contains(b"StructParents") {
return Err(unsupported(index, "page already has a structure parent key"));
}
if page.dict.get(b"Annots").is_some_and(|a| match &*doc.resolve(a) {
Object::Array(items) => !items.is_empty(),
_ => true,
}) {
return Err(unsupported(index, "annotations need separate structure tags"));
}
let pieces = content_pieces(doc, page, index)?;
totals.0 = totals.0.saturating_add(pieces.data().len());
let parsed = pieces.parse();
if parsed.skipped > 0 || parsed.ambiguous_images > 0 || parsed.ops.len() > MAX_OPERATORS {
return Err(unsupported(index, "content has malformed or too many operators"));
}
totals.1 = totals.1.saturating_add(parsed.ops.len());
if totals.0 > MAX_TOTAL_BYTES || totals.1 > MAX_TOTAL_OPERATORS {
return Err(unsupported(index, "the document has too much content to auto-tag in one edit"));
}
let xobjects = page_xobjects(doc, &page.dict);
let (mut start, mut has_text, mut spans, mut artifacts) = (None, false, Vec::new(), HashMap::new());
// Marked-content boundaries must enclose the whole path, not just its paint operator.
let mut path = None;
for (at, op) in parsed.ops.iter().enumerate() {
if path.is_some() && matches!(op.op.as_slice(), b"BT" | b"Do" | b"BI" | b"sh") {
return Err(unsupported(index, "painted content interrupts an unfinished path"));
}
match op.op.as_slice() {
b"BT" if start.is_none() => {
start = Some(at);
has_text = false;
}
b"BT" if start.is_none() => (start, has_text) = (Some(at), false),
b"ET" => {
let Some(first) = start.take() else { return Err(unsupported(index, "unmatched ET operator")) };
if has_text {
@@ -160,14 +224,31 @@ fn text_spans(ops: &[Op], index: usize) -> Result<Vec<(usize, usize)>, AutoTagEr
}
b"Tj" | b"TJ" | b"'" | b"\"" if start.is_some() => has_text = true,
name if start.is_some() && harmless_inside(name) => {}
name if start.is_none() && harmless_outside(name) => {}
name if start.is_none() && harmless_outside(name) => match name {
b"m" | b"re" => {
path.get_or_insert(at);
}
b"n" => path = None,
_ => {}
},
_ if start.is_none() && painted_outside(doc, &xobjects, op) => {
let first = if matches!(op.op.as_slice(), b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"S" | b"s") {
path.take().unwrap_or(at)
} else {
at
};
artifacts.insert(first, at);
}
_ => return Err(unsupported(index, format!("unsupported or painted operator {}", String::from_utf8_lossy(&op.op)))),
}
}
if start.is_some() {
return Err(unsupported(index, "unterminated BT text object"));
}
Ok(spans)
if path.is_some() {
return Err(unsupported(index, "unterminated path object"));
}
Ok(Plan { page: page.obj, ops: parsed.ops, spans, artifacts })
}
fn plan(doc: &Document) -> Result<Vec<Plan>, AutoTagError> {
@@ -186,24 +267,11 @@ fn plan(doc: &Document) -> Result<Vec<Plan>, AutoTagError> {
}
let mut out = Vec::with_capacity(pages.len());
let mut total = 0usize;
let mut totals = (0usize, 0usize);
for (index, page) in pages.iter().enumerate() {
if page.dict.contains(b"StructParents") {
return Err(unsupported(index, "page already has a structure parent key"));
}
if page.dict.get(b"Annots").is_some_and(|a| match &*doc.resolve(a) {
Object::Array(items) => !items.is_empty(),
_ => true,
}) {
return Err(unsupported(index, "annotations need separate structure tags"));
}
let bytes = content_bytes(doc, page, index)?;
let parsed = parse(&bytes);
if parsed.skipped > 0 || parsed.ops.len() > MAX_OPERATORS {
return Err(unsupported(index, "content has malformed or too many operators"));
}
let spans = text_spans(&parsed.ops, index)?;
total = total.saturating_add(spans.len());
out.push(Plan { page: page.obj, ops: parsed.ops, spans });
let planned = plan_page(doc, page, index, &mut totals)?;
total = total.saturating_add(planned.spans.len());
out.push(planned);
}
if total == 0 {
return Err(AutoTagError::NoText);
@@ -211,62 +279,36 @@ fn plan(doc: &Document) -> Result<Vec<Plan>, AutoTagError> {
Ok(out)
}
/// Tag every text object in an untagged, text-only PDF as a paragraph. The full structure tree,
/// parent tree and marked-content IDs are written together. Mixed content is refused before any
/// mutation. On success the caller can use tag-property edits to assign reviewed heading roles.
pub fn autotag_text(doc: &mut Document) -> Result<AutoTagResult, AutoTagError> {
let plans = plan(doc)?;
let mut next = doc.clone();
let catalog = next.root().ok_or(AutoTagError::NoCatalog)?;
let root = next.add(Object::Null);
let document = next.add(Object::Null);
let mut children = Vec::new();
let mut numbers = Vec::new();
let mut tagged_pages = 0usize;
let mut paragraphs = 0usize;
for page in plans {
if page.spans.is_empty() {
continue;
fn mark_ops(ops: Vec<Op>, starts: &HashMap<usize, usize>, ends: &HashSet<usize>, artifacts: &HashMap<usize, usize>) -> Result<Vec<Op>, AutoTagError> {
let artifact_ends: HashSet<usize> = artifacts.values().copied().collect();
let extra = starts.len().saturating_add(artifacts.len()).saturating_mul(2);
let mut marked = Vec::with_capacity(ops.len().saturating_add(extra));
for (at, op) in ops.into_iter().enumerate() {
let is_artifact = artifacts.contains_key(&at);
if let Some(mcid) = starts.get(&at) {
let mut properties = Dict::new();
properties.set(b"MCID".to_vec(), Object::Int(i64::try_from(*mcid).map_err(|_| AutoTagError::TooManyPages)?));
marked.push(Op::new("BDC", vec![Object::name("P"), Object::Dict(properties)]));
} else if is_artifact {
marked.push(Op::new("BMC", vec![Object::name("Artifact")]));
}
let key = i64::try_from(tagged_pages).map_err(|_| AutoTagError::TooManyPages)?;
let mut parents = Vec::with_capacity(page.spans.len());
let mut starts = std::collections::HashMap::new();
let mut ends = std::collections::HashSet::new();
for (mcid, (first, last)) in page.spans.iter().enumerate() {
let mut element = Dict::new();
element.set(b"Type".to_vec(), Object::name("StructElem"));
element.set(b"S".to_vec(), Object::name("P"));
element.set(b"P".to_vec(), Object::Ref(document));
element.set(b"Pg".to_vec(), Object::Ref(page.page));
element.set(b"K".to_vec(), Object::Int(i64::try_from(mcid).map_err(|_| AutoTagError::TooManyPages)?));
let reference = next.add(element);
parents.push(Object::Ref(reference));
children.push(Object::Ref(reference));
starts.insert(*first, mcid);
ends.insert(*last);
paragraphs = paragraphs.saturating_add(1);
marked.push(op);
if ends.contains(&at) || artifact_ends.contains(&at) {
marked.push(Op::new("EMC", Vec::new()));
}
let mut marked = Vec::with_capacity(page.ops.len().saturating_add(page.spans.len().saturating_mul(2)));
for (at, op) in page.ops.into_iter().enumerate() {
if let Some(mcid) = starts.get(&at) {
let mut properties = Dict::new();
properties.set(b"MCID".to_vec(), Object::Int(i64::try_from(*mcid).map_err(|_| AutoTagError::TooManyPages)?));
marked.push(Op::new("BDC", vec![Object::name("P"), Object::Dict(properties)]));
}
marked.push(op);
if ends.contains(&at) {
marked.push(Op::new("EMC", Vec::new()));
}
}
let stream = next.add(Stream::flate(Dict::new(), &serialize_ops(&marked)));
next.update_dict(page.page, |d| {
d.set(b"Contents".to_vec(), Object::Ref(stream));
d.set(b"StructParents".to_vec(), Object::Int(key));
})?;
numbers.push(Object::Int(key));
numbers.push(Object::Array(parents));
tagged_pages += 1;
}
Ok(marked)
}
fn write_structure(
next: &mut Document,
catalog: ObjRef,
root: ObjRef,
document: ObjRef,
children: Vec<Object>,
numbers: Vec<Object>,
tagged_pages: usize,
) -> Result<(), AutoTagError> {
let mut tree = Dict::new();
tree.set(b"Nums".to_vec(), Object::Array(numbers));
let parent_tree = next.add(tree);
@@ -289,6 +331,50 @@ pub fn autotag_text(doc: &mut Document) -> Result<AutoTagResult, AutoTagError> {
d.set(b"StructTreeRoot".to_vec(), Object::Ref(root));
d.set(b"MarkInfo".to_vec(), Object::Dict(mark_info));
})?;
Ok(())
}
/// Tag every text object in an untagged PDF as a paragraph and mark painted paths, shadings and
/// images outside text objects as artifacts. The full structure tree, parent tree and
/// marked-content IDs are written together. Unsupported operators are refused before any
/// mutation. On success the caller can use tag-property edits to assign reviewed heading roles.
pub fn autotag_text(doc: &mut Document) -> Result<AutoTagResult, AutoTagError> {
let plans = plan(doc)?;
let mut next = doc.clone();
let catalog = next.root().ok_or(AutoTagError::NoCatalog)?;
let (root, document) = (next.add(Object::Null), next.add(Object::Null));
let (mut children, mut numbers, mut tagged_pages, mut paragraphs) = (Vec::new(), Vec::new(), 0usize, 0usize);
for page in plans {
if page.spans.is_empty() && page.artifacts.is_empty() {
continue;
}
let (mut starts, mut ends) = (HashMap::new(), HashSet::new());
if !page.spans.is_empty() {
let key = i64::try_from(tagged_pages).map_err(|_| AutoTagError::TooManyPages)?;
let mut parents = Vec::with_capacity(page.spans.len());
for (mcid, (first, last)) in page.spans.iter().enumerate() {
let mut element = Dict::new();
element.set(b"Type".to_vec(), Object::name("StructElem"));
element.set(b"S".to_vec(), Object::name("P"));
element.set(b"P".to_vec(), Object::Ref(document));
element.set(b"Pg".to_vec(), Object::Ref(page.page));
element.set(b"K".to_vec(), Object::Int(i64::try_from(mcid).map_err(|_| AutoTagError::TooManyPages)?));
let reference = next.add(element);
parents.push(Object::Ref(reference));
children.push(Object::Ref(reference));
starts.insert(*first, mcid);
ends.insert(*last);
paragraphs = paragraphs.saturating_add(1);
}
next.update_dict(page.page, |d| d.set(b"StructParents".to_vec(), Object::Int(key)))?;
numbers.extend([Object::Int(key), Object::Array(parents)]);
tagged_pages += 1;
}
let marked = mark_ops(page.ops, &starts, &ends, &page.artifacts)?;
let stream = next.add(Stream::flate(Dict::new(), &serialize_ops(&marked)));
next.update_dict(page.page, |d| d.set(b"Contents".to_vec(), Object::Ref(stream)))?;
}
write_structure(&mut next, catalog, root, document, children, numbers, tagged_pages)?;
*doc = next;
Ok(AutoTagResult { pages: tagged_pages, paragraphs })
}
+157 -20
View File
@@ -10,6 +10,23 @@ 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 root_document_ref(doc: &Document, k: Option<&Object>) -> Result<ObjRef, String> {
let Some(k) = k else {
return Err("the structure tree root K must be an indirect reference".into());
};
if let Some(r) = k.as_ref()
&& doc.get(r).as_dict().is_some()
{
return Ok(r);
}
if let Some([item]) = doc.resolve(k).as_array().map(Vec::as_slice)
&& let Some(r) = item.as_ref()
{
return Ok(r);
}
Err("the structure tree root K must be an indirect reference".into())
}
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")?;
@@ -31,22 +48,32 @@ 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") {
if !catalog.get(b"AcroForm").is_some_and(|v| !matches!(*doc.resolve(v), Object::Null)) {
return Err("the PDF has no AcroForm fields".into());
}
let existing_root = catalog.get(b"StructTreeRoot").cloned();
let existing_root = catalog.get(b"StructTreeRoot").filter(|v| !matches!(*doc.resolve(v), Object::Null)).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();
// A shared indirect /Annots array is scanned for the first page that lists it: its widgets
// are tagged once anyway, so scanning it again for every page only repeats the work.
let mut arrays = std::collections::HashSet::new();
for page in &pages {
if let Some(array) = page.dict.get(b"Annots").and_then(Object::as_ref)
&& !arrays.insert(array)
{
continue;
}
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) {
let is_widget = dict.get(b"Subtype").map(|s| doc.resolve(s)).as_deref().and_then(Object::as_name) == Some(b"Widget");
let has_parent = dict.get(b"StructParent").map(|v| doc.resolve(v)).and_then(|v| v.as_int()).is_some_and(|k| k >= 0);
if is_widget && !has_parent && seen.insert(reference) {
widgets.push((page.obj, reference));
}
}
@@ -54,16 +81,29 @@ pub fn tag_form_fields(doc: &mut Document) -> Result<usize, String> {
if widgets.is_empty() {
return Err("the AcroForm has no untagged page widgets".into());
}
let count = i64::try_from(widgets.len()).map_err(|_| "too many form widgets")?;
let (struct_root, document_ref, parent_tree, mut parent_nums, first_key, new_tree) = if let Some(existing_root) = existing_root {
let (struct_root, document_ref, mut existing_kids, 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")) {
let document_ref = root_document_ref(doc, structure.get(b"K"))?;
let document_obj = doc.get(document_ref);
let Some(document_dict) = document_obj.as_dict() else {
return Err("the structure tree root must point directly to a Document element".into());
};
if document_dict.get(b"S").map(|s| doc.resolve(s)).as_deref().and_then(Object::as_name) != Some(b"Document") {
return Err("the structure tree root must point directly to a Document element".into());
}
let existing_kids = match document_dict.get(b"K") {
None => Vec::new(),
Some(k) => match &*doc.resolve(k) {
Object::Null => Vec::new(),
Object::Array(items) => items.clone(),
_ => vec![k.clone()],
},
};
let parent_tree = reference(structure.get(b"ParentTree"), "structure parent tree")?;
let parent_nums = nums(doc, parent_tree)?;
if parent_nums.len() % 2 != 0 {
@@ -71,14 +111,26 @@ pub fn tag_form_fields(doc: &mut Document) -> Result<usize, String> {
}
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()) };
let Some(key) = doc.resolve(&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)
let mut first_key = greatest.checked_add(1).ok_or("the structure parent tree has no available key")?;
if let Some(next_obj) = structure.get(b"ParentTreeNextKey") {
let resolved_next = doc.resolve(next_obj);
if !matches!(*resolved_next, Object::Null) {
let next_key = resolved_next.as_int().ok_or("the structure parent tree next key is invalid")?;
if next_key < 0 {
return Err("the structure parent tree next key is negative".into());
}
first_key = first_key.max(next_key);
}
}
(struct_root, document_ref, existing_kids, 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()));
@@ -88,9 +140,10 @@ pub fn tag_form_fields(doc: &mut Document) -> Result<usize, String> {
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)
(struct_root, document_ref, Vec::new(), parent_tree, Vec::new(), 0, true)
};
let mut kids = Vec::with_capacity(widgets.len());
// ISO 32000-1 14.7.4.4: reserve the next unused key before adding any parent-tree entries.
let next_key = first_key.checked_add(count).ok_or("the structure parent tree has no available next key")?;
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();
@@ -105,16 +158,10 @@ pub fn tag_form_fields(doc: &mut Document) -> Result<usize, String> {
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));
existing_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));
d.set(b"K".to_vec(), Object::Array(existing_kids));
})
.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())?;
@@ -122,6 +169,12 @@ pub fn tag_form_fields(doc: &mut Document) -> Result<usize, String> {
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));
d.set(b"ParentTreeNextKey".to_vec(), Object::Int(next_key));
})
.map_err(|e| e.to_string())?;
} else {
doc.update_dict(struct_root, |d| {
d.set(b"ParentTreeNextKey".to_vec(), Object::Int(next_key));
})
.map_err(|e| e.to_string())?;
}
@@ -136,3 +189,87 @@ pub fn tag_form_fields(doc: &mut Document) -> Result<usize, String> {
.map_err(|e| e.to_string())?;
Ok(widgets.len())
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
use crate::tags;
fn pdf(bodies: &[String]) -> Document {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, b) in bodies.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{b}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", bodies.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes());
Document::open(Arc::new(out)).unwrap()
}
fn existing_tree(next: &str) -> Document {
pdf(&[
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 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 200 200] /Annots [4 0 R] >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [20 60 120 80] >>".into(),
format!("<< /Type /StructTreeRoot /K 6 0 R /ParentTree 7 0 R {next} >>"),
"<< /S /Document /P 5 0 R /K [] >>".into(),
"<< /Nums [7 []] >>".into(),
"null".into(),
])
}
#[test]
fn extending_tree_creates_missing_next_key() {
for next in ["", "/ParentTreeNextKey null", "/ParentTreeNextKey 8 0 R"] {
let mut doc = existing_tree(next);
assert_eq!(tag_form_fields(&mut doc).unwrap(), 1);
assert_eq!(doc.get(ObjRef::new(4, 0)).as_dict().unwrap().int(b"StructParent"), Some(8));
assert_eq!(doc.get(ObjRef::new(5, 0)).as_dict().unwrap().int(b"ParentTreeNextKey"), Some(9));
}
}
#[test]
fn exhausted_next_key_rejects_before_mutation() {
let mut doc = existing_tree(&format!("/ParentTreeNextKey {}", i64::MAX));
let before = doc.get(ObjRef::new(4, 0));
let objects = doc.object_numbers().len();
assert!(tag_form_fields(&mut doc).is_err());
assert_eq!(doc.get(ObjRef::new(4, 0)), before);
assert_eq!(doc.object_numbers().len(), objects);
}
#[test]
fn extends_existing_tree_with_array_root_k_indirect_document_kids_and_indirect_nums_keys() {
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] /StructParent null >>".into(),
"<< /Type /StructTreeRoot /K [7 0 R] /ParentTree 8 0 R /ParentTreeNextKey 5 >>".into(),
"<< /S /Document /P 6 0 R /K 10 0 R >>".into(),
"<< /Nums [11 0 R [9 0 R]] >>".into(),
"<< /S /P /P 7 0 R /Pg 3 0 R /K 0 >>".into(),
"[9 0 R]".into(),
"0".into(),
]);
assert_eq!(tag_form_fields(&mut doc).unwrap(), 1);
// Existing paragraph (9 0 R) and new Form tag remain flat siblings under /Document (7 0 R).
let doc_k = doc.get(ObjRef::new(7, 0)).as_dict().unwrap().get(b"K").unwrap().as_array().unwrap().clone();
assert_eq!(doc_k.len(), 2);
assert_eq!(doc_k[0].as_ref(), Some(ObjRef::new(9, 0)));
let listed: Vec<_> = tags(&doc).into_iter().map(|t| t.kind).collect();
assert_eq!(listed, ["Document", "P", "Form"]);
// Widget got key 5 from /ParentTreeNextKey, and /ParentTreeNextKey advanced to 6.
assert_eq!(doc.get(ObjRef::new(5, 0)).as_dict().unwrap().int(b"StructParent"), Some(5));
assert_eq!(doc.get(ObjRef::new(6, 0)).as_dict().unwrap().int(b"ParentTreeNextKey"), Some(6));
}
}
+14 -9
View File
@@ -73,7 +73,8 @@ impl Walk<'_> {
// Marked-content stack: whether each level is tagged or an artifact.
let mut stack: Vec<bool> = Vec::new();
let mut untagged = 0;
for op in pdfcraft_content::parse(data).ops {
// One operator at a time: the walk keeps counts, never the stream's operators.
for op in pdfcraft_content::ops(data) {
let inside = covered || stack.last().copied().unwrap_or(false);
match op.op.as_slice() {
b"BMC" => stack.push(inside || op.name(0) == Some(b"Artifact")),
@@ -261,11 +262,13 @@ fn annotation_rect(doc: &Document, annot: &Dict) -> Option<[f64; 4]> {
pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usize], scan: &Scan, tagged: bool) -> Vec<Finding> {
let mut out = Vec::new();
let annots = |p: &Page| -> Vec<(ObjRef, Dict)> {
// The annotation objects are shared, never deep-copied: a reference repeated across a page's
// Annots array (or its pages) must not copy the dictionary each time.
let annots = |p: &Page| -> Vec<(ObjRef, std::sync::Arc<Object>)> {
let a = p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default();
a.iter()
.filter_map(|x| x.as_ref().and_then(|r| doc.get(r).as_dict().cloned().map(|d| (r, d))))
.filter(|(_, d)| d.name(b"Subtype") != Some(b"Popup"))
.filter_map(|x| x.as_ref().map(|r| (r, doc.get(r))))
.filter(|(_, o)| o.as_dict().is_some_and(|d| d.name(b"Subtype") != Some(b"Popup")))
.collect()
};
match rule {
@@ -283,7 +286,8 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz
Rule::TaggedAnnotations | Rule::TaggedMultimedia | Rule::TaggedFormFields => {
for &p in list {
let Some(page) = pages.get(p) else { continue };
for (_, a) in annots(page) {
for (_, obj) in annots(page) {
let Some(a) = obj.as_dict() else { continue };
let st = a.name(b"Subtype").unwrap_or(b"");
let applies = match rule {
Rule::TaggedFormFields => st == b"Widget",
@@ -298,11 +302,11 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz
let pattern = if st == b"Widget" {
"Form field on page {p} isn't tagged"
} else {
values.push(("{}", subtype_name(&a)));
values.push(("{}", subtype_name(a)));
"{} annotation on page {p} isn't tagged"
};
let mut finding = Finding::worded(Some(p), pattern, values);
finding.bbox = annotation_rect(doc, &a);
finding.bbox = annotation_rect(doc, a);
out.push(finding);
}
}
@@ -344,7 +348,8 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz
let mut seen = HashSet::new();
for &p in list {
let Some(page) = pages.get(p) else { continue };
for (r, a) in annots(page) {
for (r, obj) in annots(page) {
let Some(a) = obj.as_dict() else { continue };
if a.name(b"Subtype") != Some(b"Widget") {
continue;
}
@@ -361,7 +366,7 @@ pub(crate) fn page_rule(doc: &Document, rule: Rule, pages: &[Page], list: &[usiz
"Field \"{}\" on page {p} has no description (tooltip)",
vec![("{}", full_name(doc, field)), ("{p}", (p + 1).to_string())],
);
finding.bbox = annotation_rect(doc, &a);
finding.bbox = annotation_rect(doc, a);
out.push(finding);
}
}
+29 -4
View File
@@ -37,9 +37,16 @@ struct Walker<'a> {
visits: usize,
/// Per page: the text each MCID shows (read once per page).
texts: HashMap<usize, HashMap<i64, String>>,
/// What is left of [`MAX_TITLE_SOURCE_BYTES`].
title_left: usize,
out: Vec<Heading>,
}
/// Bytes of `/ActualText`, `/Alt` and `/T` strings the headings of one document decode in all.
/// Titles are cut to [`MAX_TITLE`] characters, but many headings can name one large string, and
/// each would otherwise be decoded in full first.
const MAX_TITLE_SOURCE_BYTES: usize = 32 << 20;
/// The headings, in document order. Headings without text or a page are left out.
pub fn headings(doc: &Document) -> Vec<Heading> {
let Some(root) = tree_root(doc) else { return Vec::new() };
@@ -52,6 +59,7 @@ pub fn headings(doc: &Document) -> Vec<Heading> {
seen: HashSet::new(),
visits: 0,
texts: HashMap::new(),
title_left: MAX_TITLE_SOURCE_BYTES,
out: Vec::new(),
};
if let Some(k) = root.get(b"K") {
@@ -131,17 +139,34 @@ impl Walker<'_> {
self.marks(kids, pg, depth + 1, &mut marks);
}
let Some(page) = pg.or_else(|| marks.iter().find_map(|m| m.0)).and_then(|p| self.page_index.get(&p).copied()) else { return };
let doc = self.doc;
let attr = |key: &[u8]| d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text())).filter(|t| !t.trim().is_empty());
let title = match attr(b"ActualText") {
let title = match self.attr(d, b"ActualText") {
Some(t) => Some(t),
None => self.marked_text(&marks, page).or_else(|| attr(b"Alt")).or_else(|| attr(b"T")),
None => match self.marked_text(&marks, page) {
Some(t) => Some(t),
None => self.attr(d, b"Alt").or_else(|| self.attr(d, b"T")),
},
};
let Some(title) = title else { return };
let title: String = title.split_whitespace().collect::<Vec<_>>().join(" ").chars().take(MAX_TITLE).collect();
self.out.push(Heading { obj, level, title, page });
}
/// The non-blank text of the string `key` holds, charged against the title allowance by the
/// longer of its raw and decoded size (a string that does not fit is not decoded).
fn attr(&mut self, d: &Dict, key: &[u8]) -> Option<String> {
let o = self.doc.resolve(d.get(key)?);
let s = o.as_string()?;
// A text string decodes to at most three bytes per raw byte, so one whose worst case does
// not fit the remaining allowance is never decoded: the raw check alone let a string at
// the raw limit decode to three times it before the allowance was reduced.
if s.bytes.len().saturating_mul(3) > self.title_left {
return None;
}
let text = s.to_text();
self.title_left = self.title_left.saturating_sub(text.len().max(s.bytes.len()));
Some(text).filter(|t| !t.trim().is_empty())
}
/// The text the marked content shows (`page`: where marks without a page of their own are).
fn marked_text(&mut self, marks: &[(Option<ObjRef>, i64)], page: usize) -> Option<String> {
let mut parts = Vec::new();
+359
View File
@@ -0,0 +1,359 @@
//! A layout table as plain blocks: a table used only to place content (label and value pairs, a
//! letterhead grid) is read as a data table, with headers and cell navigation it doesn't have. One
//! edit turns the table, its row groups and rows into Div elements and its cells into P elements
//! (Div when a cell holds tags of its own), keeping the content, its order and everything inside
//! the cells, and drops the Table attributes (summary, scope, spans) they no longer have (#4105).
use std::collections::HashSet;
use pdfcraft_cos::{CosError, Dict, Document, ObjRef, Object};
use crate::alt;
/// The most structure elements one table may have: past it the edit is refused.
const MAX_ELEMENTS: usize = 100_000;
/// The deepest nesting of direct (inline) elements followed.
const MAX_DEPTH: usize = 256;
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum LayoutTableError {
#[error("object {0} is not a reachable indirect structure tag")]
NotATag(ObjRef),
#[error("the tag is not a table")]
NotATable,
#[error("the table has too many tags, or tags nested too deeply")]
TooLarge,
#[error(transparent)]
Cos(#[from] CosError),
}
/// What a table part becomes; `None` for anything else (which keeps its type and contents).
fn block_type(ty: &[u8], has_tags: bool, root: bool) -> Option<&'static str> {
match ty {
b"Table" if root => Some("Div"),
b"THead" | b"TBody" | b"TFoot" | b"TR" => Some("Div"),
b"TH" | b"TD" | b"Caption" => Some(if has_tags { "Div" } else { "P" }),
_ => None,
}
}
struct Retag<'a> {
doc: &'a Document,
role_map: Dict,
/// Indirect elements under the table still to look at.
queue: Vec<ObjRef>,
/// How many elements changed.
changed: usize,
}
impl Retag<'_> {
fn standard(&self, d: &Dict) -> Option<Vec<u8>> {
Some(alt::standard(self.doc, &self.role_map, d.name(b"S")?))
}
/// Whether children `k` include structure elements (indirect or direct, also in an indirect
/// array of children).
fn has_tags(&self, k: &Object, depth: usize) -> bool {
if depth > MAX_DEPTH {
return false;
}
match k {
Object::Array(items) => items.iter().any(|i| self.has_tags(i, depth + 1)),
Object::Ref(r) => match &*self.doc.get(*r) {
Object::Dict(d) => d.contains(b"S"),
array @ Object::Array(_) => self.has_tags(array, depth + 1),
_ => false,
},
Object::Dict(d) => d.contains(b"S"),
_ => false,
}
}
/// Element `d` as a block when it is a part of the table (`root`: the table itself), with its
/// direct children converted the same way and its indirect ones queued; `None` otherwise.
fn convert(&mut self, d: &Dict, root: bool, depth: usize) -> Result<Option<Dict>, LayoutTableError> {
if depth > MAX_DEPTH {
return Err(LayoutTableError::TooLarge);
}
let Some(ty) = self.standard(d) else { return Ok(None) };
// As written: a single child is often a reference to it, which must stay one.
let kids = d.get(b"K").cloned();
let has_tags = kids.as_ref().is_some_and(|k| self.has_tags(k, 0));
let Some(block) = block_type(&ty, has_tags, root) else { return Ok(None) };
let mut out = d.clone();
out.set(b"S".to_vec(), Object::name(block));
self.drop_table_attributes(&mut out);
if let Some(kids) = kids {
let kids = self.convert_kids(&kids, depth + 1)?;
out.set(b"K".to_vec(), kids);
}
self.changed = self.changed.saturating_add(1);
Ok(Some(out))
}
/// Children `k` with direct table parts converted and indirect elements queued (references
/// stay references; an indirect array of children is written out only when a direct table
/// part in it changed).
fn convert_kids(&mut self, k: &Object, depth: usize) -> Result<Object, LayoutTableError> {
if depth > MAX_DEPTH {
return Err(LayoutTableError::TooLarge);
}
Ok(match k {
Object::Array(items) => Object::Array(items.iter().map(|i| self.convert_kids(i, depth)).collect::<Result<_, _>>()?),
Object::Ref(r) => match &*self.doc.get(*r) {
Object::Dict(d) if d.contains(b"S") => {
self.queue.push(*r);
k.clone()
}
array @ Object::Array(_) => {
let converted = self.convert_kids(array, depth + 1)?;
if converted == *array { k.clone() } else { converted }
}
_ => k.clone(),
},
Object::Dict(d) if d.contains(b"S") => match self.convert(d, false, depth)? {
Some(block) => Object::Dict(block),
None => k.clone(),
},
_ => k.clone(),
})
}
/// Remove the Table attribute objects (`/O /Table`) from `d`'s `/A`, keeping the others and
/// their revision numbers; an `/A` left empty goes.
fn drop_table_attributes(&self, d: &mut Dict) {
let Some(a) = d.get(b"A").cloned() else { return };
let is_table = |o: &Object| self.doc.resolve(o).as_dict().is_some_and(|x| x.name(b"O") == Some(b"Table"));
let resolved = self.doc.resolve(&a);
let Object::Array(items) = &*resolved else {
if is_table(&a) {
d.remove(b"A");
}
return;
};
let mut kept = Vec::with_capacity(items.len());
let mut dropping = false;
for item in items {
match item {
// A revision number goes with the attribute object before it.
Object::Int(_) if dropping => {}
_ => {
dropping = is_table(item);
if !dropping {
kept.push(item.clone());
}
}
}
}
if kept.is_empty() {
d.remove(b"A");
} else if kept.len() != items.len() {
d.set(b"A".to_vec(), Object::Array(kept));
}
}
}
/// Turn layout table `table` (a reachable indirect tag whose type is Table after the role map) into
/// Div and P elements as the module documentation says; returns how many elements changed. Tables
/// nested in its cells stay tables.
pub fn layout_table_to_blocks(doc: &mut Document, table: ObjRef) -> Result<usize, LayoutTableError> {
let Some(element) = alt::elements(doc).into_iter().find(|e| e.object == table) else {
return Err(LayoutTableError::NotATag(table));
};
let role_map = alt::tree_root(doc).and_then(|r| r.get(b"RoleMap").map(|o| doc.resolve(o))).and_then(|o| o.as_dict().cloned()).unwrap_or_default();
let mut retag = Retag { doc: &*doc, role_map, queue: Vec::new(), changed: 0 };
if retag.standard(&element.dict).as_deref() != Some(b"Table".as_slice()) {
return Err(LayoutTableError::NotATable);
}
// Read everything first, then write: a refused table changes nothing.
let mut updates: Vec<(ObjRef, Dict)> = Vec::new();
let mut seen = HashSet::new();
if let Some(block) = retag.convert(&element.dict, true, 0)? {
seen.insert(table);
updates.push((table, block));
}
while let Some(r) = retag.queue.pop() {
if !seen.insert(r) {
continue;
}
if seen.len() > MAX_ELEMENTS {
return Err(LayoutTableError::TooLarge);
}
let Some(d) = retag.doc.get(r).as_dict().cloned() else { continue };
if let Some(block) = retag.convert(&d, false, 0)? {
updates.push((r, block));
}
}
let changed = retag.changed;
for (r, block) in updates {
doc.update_dict(r, |d| *d = block)?;
}
Ok(changed)
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use pdfcraft_cos::{Document, ObjRef, Object};
use super::*;
/// A tagged document: the catalog (1), the structure tree root (2, `root` its other entries)
/// over the Document element (3), then `objects` from 4 on.
fn doc(root: &str, objects: &[&str]) -> Document {
let mut bodies = vec!["<< /Type /Catalog /StructTreeRoot 2 0 R >>".to_string(), format!("<< /Type /StructTreeRoot /K 3 0 R {root} >>")];
bodies.extend(objects.iter().map(|s| s.to_string()));
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, b) in bodies.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{b}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", bodies.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes());
Document::open(Arc::new(out)).unwrap()
}
fn r(n: u32) -> ObjRef {
ObjRef::new(n, 0)
}
fn dict(doc: &Document, n: u32) -> Dict {
doc.get(r(n)).as_dict().cloned().unwrap()
}
fn ty(doc: &Document, n: u32) -> Vec<u8> {
dict(doc, n).name(b"S").unwrap().to_vec()
}
#[test]
fn a_label_and_value_table_becomes_divs_and_paragraphs() {
let mut d = doc(
"",
&[
"<< /S /Document /K [4 0 R] >>",
// 4: the table, with a layout attribute and a summary (revision 1).
"<< /S /Table /K [5 0 R 8 0 R] /A [ << /O /Layout /Placement /Block >> << /O /Table /Summary (s) >> 1 ] >>",
"<< /S /TR /K [6 0 R 7 0 R] >>",
"<< /S /TH /K 0 /A << /O /Table /Scope /Row >> >>",
"<< /S /TD /K 1 >>",
"<< /S /TR /K [9 0 R 10 0 R] >>",
"<< /S /TD /K 2 >>",
"<< /S /TD /K 3 /A [ << /O /Table /ColSpan 1 >> << /O /Layout /TextAlign /End >> ] >>",
],
);
let before: Vec<ObjRef> = crate::tags(&d).iter().map(|t| t.object).collect();
assert_eq!(layout_table_to_blocks(&mut d, r(4)), Ok(7));
for (n, want) in [(4, "Div"), (5, "Div"), (6, "P"), (7, "P"), (8, "Div"), (9, "P"), (10, "P")] {
assert_eq!(ty(&d, n), want.as_bytes(), "object {n}");
}
// The content and the order stay; Table attributes go, layout ones stay.
assert_eq!(crate::tags(&d).iter().map(|t| t.object).collect::<Vec<_>>(), before);
assert_eq!(dict(&d, 6).get(b"K"), Some(&Object::Int(0)));
assert_eq!(dict(&d, 6).get(b"A"), None);
let Some(Object::Array(a)) = dict(&d, 4).get(b"A").cloned() else { panic!("{:?}", dict(&d, 4)) };
assert_eq!(a.len(), 1, "the summary and its revision number go: {a:?}");
assert_eq!(a[0].as_dict().and_then(|x| x.name(b"O")), Some(&b"Layout"[..]));
let Some(Object::Array(a)) = dict(&d, 10).get(b"A").cloned() else { panic!() };
assert_eq!(a.len(), 1);
}
#[test]
fn what_the_cells_hold_stays_and_a_table_in_a_cell_stays_a_table() {
let mut d = doc(
"",
&[
"<< /S /Document /K [4 0 R] >>",
"<< /S /Table /K [5 0 R] >>",
"<< /S /TR /K [6 0 R 7 0 R] >>",
// 6: a cell holding a paragraph; 7: a cell holding a table of its own.
"<< /S /TD /K [8 0 R] >>",
"<< /S /TD /K [9 0 R] >>",
"<< /S /P /K 0 >>",
"<< /S /Table /K [10 0 R] >>",
"<< /S /TR /K [11 0 R] >>",
"<< /S /TD /K 1 >>",
],
);
layout_table_to_blocks(&mut d, r(4)).unwrap();
for (n, want) in [(4, "Div"), (5, "Div"), (6, "Div"), (7, "Div"), (8, "P"), (9, "Table"), (10, "TR"), (11, "TD")] {
assert_eq!(ty(&d, n), want.as_bytes(), "object {n}");
}
}
#[test]
fn direct_rows_and_cells_are_converted_in_place() {
let mut d = doc(
"/RoleMap << /Grid /Table /Row /TR >>",
&[
"<< /S /Document /K [4 0 R] >>",
"<< /S /Grid /K [ << /S /Row /K [ << /S /TD /K 0 >> << /S /TD /K [5 0 R] >> ] >> ] >>",
"<< /S /Span /K 1 >>",
],
);
assert_eq!(layout_table_to_blocks(&mut d, r(4)), Ok(4));
let table = dict(&d, 4);
assert_eq!(table.name(b"S"), Some(&b"Div"[..]), "the role map makes /Grid a table");
let Some(Object::Array(rows)) = table.get(b"K").cloned() else { panic!() };
let row = rows[0].as_dict().unwrap();
assert_eq!(row.name(b"S"), Some(&b"Div"[..]));
let Some(Object::Array(cells)) = row.get(b"K").cloned() else { panic!() };
assert_eq!(cells[0].as_dict().and_then(|c| c.name(b"S")), Some(&b"P"[..]));
assert_eq!(cells[1].as_dict().and_then(|c| c.name(b"S")), Some(&b"Div"[..]), "it holds a Span tag");
assert_eq!(ty(&d, 5), b"Span");
}
#[test]
fn only_a_reachable_table_is_changed() {
let mut d = doc("", &["<< /S /Document /K [4 0 R] >>", "<< /S /P /K 0 >>", "<< /S /Table >>"]);
assert_eq!(layout_table_to_blocks(&mut d, r(4)), Err(LayoutTableError::NotATable));
assert_eq!(layout_table_to_blocks(&mut d, r(5)), Err(LayoutTableError::NotATag(r(5))), "not in the structure tree");
assert_eq!(layout_table_to_blocks(&mut d, ObjRef::new(4, 1)), Err(LayoutTableError::NotATag(ObjRef::new(4, 1))));
assert_eq!(ty(&d, 5), b"Table");
}
#[test]
fn a_single_child_written_as_a_reference_stays_its_own_object() {
// /K 5 0 R: the table's one row, the row's one cell; 8: a row whose cells are an indirect
// array (object 9) holding a direct cell and a reference.
let mut d = doc(
"",
&[
"<< /S /Document /K [4 0 R 7 0 R] >>",
"<< /S /Table /K 5 0 R >>",
"<< /S /TR /K 6 0 R >>",
"<< /S /TD /K 0 >>",
"<< /S /Table /K 8 0 R >>",
"<< /S /TR /K 9 0 R >>",
"[ << /S /TD /K 1 >> 10 0 R ]",
"<< /S /TD /K 2 >>",
],
);
assert_eq!(layout_table_to_blocks(&mut d, r(4)), Ok(3));
assert_eq!(dict(&d, 4).get(b"K"), Some(&Object::Ref(r(5))), "the row stays a reference");
assert_eq!(dict(&d, 5).get(b"K"), Some(&Object::Ref(r(6))));
for (n, want) in [(4, "Div"), (5, "Div"), (6, "P")] {
assert_eq!(ty(&d, n), want.as_bytes(), "object {n}");
}
assert!(crate::tags(&d).iter().any(|t| t.object == r(6)), "the cell is still listed");
assert_eq!(layout_table_to_blocks(&mut d, r(7)), Ok(4));
let Some(Object::Array(cells)) = dict(&d, 8).get(b"K").cloned() else { panic!("{:?}", dict(&d, 8)) };
assert_eq!(cells[0].as_dict().and_then(|c| c.name(b"S")), Some(&b"P"[..]));
assert_eq!(cells[1], Object::Ref(r(10)));
assert_eq!(ty(&d, 10), b"P");
}
#[test]
fn rows_that_point_at_each_other_end() {
// A row listed twice and a cell that lists its own row: each is converted once.
let mut d =
doc("", &["<< /S /Document /K [4 0 R] >>", "<< /S /Table /K [5 0 R 5 0 R] >>", "<< /S /TR /K [6 0 R] >>", "<< /S /TD /K [5 0 R] >>"]);
assert_eq!(layout_table_to_blocks(&mut d, r(4)), Ok(3));
assert_eq!((ty(&d, 5), ty(&d, 6)), (b"Div".to_vec(), b"Div".to_vec()));
}
}
+2
View File
@@ -17,6 +17,7 @@ mod autotag;
mod autotag_fields;
mod content;
mod headings;
mod layout_table;
mod report;
mod structure;
mod tags;
@@ -41,6 +42,7 @@ pub use alt::{AltError, ArtifactError, Figure, figures, mark_decorative, mark_ta
pub use autotag::{AutoTagError, AutoTagResult, autotag_text};
pub use autotag_fields::tag_form_fields;
pub use headings::{Heading, headings};
pub use layout_table::{LayoutTableError, layout_table_to_blocks};
pub use report::{REPORT_WORDS, report_html};
pub use tags::{TagContentItem, TagError, TagInfo, TagPlacement, TagProperty, move_tag, set_tag_property, tag_content, tags};
+267 -28
View File
@@ -101,6 +101,8 @@ pub enum TagError {
NotSiblings,
#[error("the parent must list each tag exactly once in its /K array")]
AmbiguousChildren,
#[error("the tag has too much or too deeply nested marked content")]
ContentLimit,
#[error("the tag is already at the requested position")]
AlreadyThere,
#[error("{0} applies only to {1} tags")]
@@ -118,7 +120,13 @@ pub fn move_tag(doc: &mut Document, object: ObjRef, anchor: ObjRef, placement: T
return Err(TagError::NotSiblings);
}
let reachable = alt::elements(doc);
if !reachable.iter().any(|e| e.object == object) || !reachable.iter().any(|e| e.object == anchor) {
let Some(obj_elem) = reachable.iter().find(|e| e.object == object) else {
return Err(TagError::NotSiblings);
};
let Some(anc_elem) = reachable.iter().find(|e| e.object == anchor) else {
return Err(TagError::NotSiblings);
};
if obj_elem.parent != anc_elem.parent {
return Err(TagError::NotSiblings);
}
let parent_of = |r| doc.get(r).as_dict().and_then(|d| d.get(b"P").and_then(Object::as_ref));
@@ -126,8 +134,23 @@ pub fn move_tag(doc: &mut Document, object: ObjRef, anchor: ObjRef, placement: T
if parent_of(anchor) != Some(parent) {
return Err(TagError::NotSiblings);
}
let valid_parent = match obj_elem.parent {
Some(expected) => parent == expected,
None => doc
.root()
.and_then(|r| doc.get(r).as_dict().and_then(|c| c.get(b"StructTreeRoot").and_then(Object::as_ref)))
.is_some_and(|root_ref| parent == root_ref),
};
if !valid_parent {
return Err(TagError::NotSiblings);
}
let owner = doc.get(parent);
let Some(children) = owner.as_dict().and_then(|d| d.get(b"K").and_then(Object::as_array)) else {
let mut k_obj = owner.as_dict().and_then(|d| d.get(b"K")).cloned();
for _ in 0..16 {
let Some(Object::Ref(r)) = k_obj else { break };
k_obj = Some((*doc.get(r)).clone());
}
let Some(children) = k_obj.and_then(|k| k.as_array().cloned()) else {
return Err(TagError::AmbiguousChildren);
};
let one_position = |child| {
@@ -135,7 +158,7 @@ pub fn move_tag(doc: &mut Document, object: ObjRef, anchor: ObjRef, placement: T
matches.next().and_then(|(index, _)| matches.next().is_none().then_some(index))
};
let (Some(from), Some(to)) = (one_position(object), one_position(anchor)) else { return Err(TagError::AmbiguousChildren) };
let mut reordered = children.clone();
let mut reordered = children;
let moved = reordered.remove(from);
let target = if from < to { to - 1 } else { to };
let destination = target + usize::from(placement == TagPlacement::After);
@@ -151,6 +174,10 @@ fn text(doc: &Document, d: &pdfcraft_cos::Dict, key: &[u8]) -> Option<String> {
d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(PdfString::to_text))
}
fn tag_type_name(doc: &Document, d: &Dict) -> Option<Vec<u8>> {
d.get(b"S").map(|s| doc.resolve(s)).and_then(|s| s.as_name().map(<[u8]>::to_vec))
}
/// List reachable, indirect tags in structure-tree order.
pub fn tags(doc: &Document) -> Vec<TagInfo> {
let pages = pdfcraft_model::pages(doc);
@@ -162,7 +189,7 @@ pub fn tags(doc: &Document) -> Vec<TagInfo> {
object: e.object,
parent: e.parent,
depth: e.depth,
kind: String::from_utf8_lossy(e.dict.name(b"S")?).into_owned(),
kind: String::from_utf8_lossy(&tag_type_name(doc, &e.dict)?).into_owned(),
title: text(doc, &e.dict, b"T"),
actual_text: text(doc, &e.dict, b"ActualText"),
alt: text(doc, &e.dict, b"Alt"),
@@ -176,13 +203,16 @@ pub fn tags(doc: &Document) -> Vec<TagInfo> {
}
/// List marked-content references directly owned by an indirect tag.
/// Returns `ContentLimit` rather than a partial list beyond 65,536 marks or 512 child/reference
/// levels. Child structure elements do not count as direct marks.
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();
let mut seen = std::collections::HashSet::new();
if let Some(kids) = element.dict.get(b"K") {
direct_marks(doc, kids, element.page, &mut marks);
direct_marks(doc, kids, element.page, &mut seen, &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();
@@ -206,25 +236,63 @@ pub fn tag_content(doc: &Document, object: ObjRef) -> Result<Vec<TagContentItem>
.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));
}
/// Bound both graph work and the queued array entries. Following references never uses the
/// call stack; the depth limit also rejects pathologically nested direct content explicitly.
fn direct_marks(
doc: &Document,
item: &Object,
page: Option<ObjRef>,
seen: &mut std::collections::HashSet<ObjRef>,
out: &mut Vec<(Option<ObjRef>, i64)>,
) -> Result<(), TagError> {
const MAX_DEPTH: usize = 512;
const MAX_MARKS: usize = 65_536;
// Account for the root `/K` container separately so an array of 65,536 entries passes.
let mut remaining = MAX_MARKS + 1;
let mut stack = vec![(std::sync::Arc::new(item.clone()), 0usize)];
while let Some((item, depth)) = stack.pop() {
remaining = remaining.checked_sub(1).ok_or(TagError::ContentLimit)?;
if depth > MAX_DEPTH {
return Err(TagError::ContentLimit);
}
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));
match &*item {
Object::Int(mcid) => {
if out.len() >= MAX_MARKS {
return Err(TagError::ContentLimit);
}
out.push((page, *mcid));
}
Object::Array(items) => {
if items.len() > remaining.saturating_sub(stack.len()) {
return Err(TagError::ContentLimit);
}
stack.extend(items.iter().rev().map(|item| (std::sync::Arc::new(item.clone()), depth + 1)));
}
Object::Dict(dict) if dict.get(b"Type").map(|t| doc.resolve(t)).as_deref().and_then(Object::as_name) == Some(b"MCR") => {
if let Some(mcid) = dict.get(b"MCID").map(|m| doc.resolve(m)).and_then(|m| m.as_int()) {
if out.len() >= MAX_MARKS {
return Err(TagError::ContentLimit);
}
out.push((dict.get(b"Pg").and_then(Object::as_ref).or(page), mcid));
}
}
Object::Ref(reference) if seen.insert(*reference) => {
let target = doc.get(*reference);
// Resolved non-MCR dictionaries are child structure elements, not direct content,
// so do not push them onto the stack or charge them a second time.
let is_non_mcr_dict = matches!(
&*target,
Object::Dict(dict)
if dict.get(b"Type").map(|t| doc.resolve(t)).as_deref().and_then(Object::as_name) != Some(b"MCR")
);
if !is_non_mcr_dict {
stack.push((target, depth + 1));
}
}
_ => {}
}
_ => {}
}
Ok(())
}
/// The attribute objects of a tag's `/A` entry (one object, or an array of them each optionally
@@ -236,8 +304,8 @@ fn attribute_entries(doc: &Document, d: &Dict) -> (Vec<(Object, Option<Object>)>
let Object::Array(items) = &*resolved else { return (vec![(a.clone(), None)], false) };
let mut out: Vec<(Object, Option<Object>)> = Vec::new();
for item in items {
match (item, out.last_mut()) {
(Object::Int(_), Some((_, revision @ None))) => *revision = Some(item.clone()),
match (doc.resolve(item).as_int(), out.last_mut()) {
(Some(_), Some((_, revision @ None))) => *revision = Some(item.clone()),
_ => out.push((item.clone(), None)),
}
}
@@ -245,13 +313,18 @@ fn attribute_entries(doc: &Document, d: &Dict) -> (Vec<(Object, Option<Object>)>
}
/// Whether attribute object `o` (resolved) is a Table attribute object.
fn is_table_attributes(o: &Object) -> bool {
o.as_dict().is_some_and(|d| d.name(b"O") == Some(b"Table"))
fn is_table_attributes(doc: &Document, o: &Object) -> bool {
o.as_dict().and_then(|d| d.get(b"O")).map(|v| doc.resolve(v)).as_deref().and_then(Object::as_name) == Some(b"Table")
}
/// The value of `key` in the first Table attribute object of tag `d` that has it.
fn table_attribute(doc: &Document, d: &Dict, key: &[u8]) -> Option<Object> {
attribute_entries(doc, d).0.iter().map(|(o, _)| doc.resolve(o)).filter(|o| is_table_attributes(o)).find_map(|o| o.as_dict()?.get(key).cloned())
attribute_entries(doc, d)
.0
.iter()
.map(|(o, _)| doc.resolve(o))
.filter(|o| is_table_attributes(doc, o))
.find_map(|o| o.as_dict()?.get(key).map(|v| (*doc.resolve(v)).clone()).filter(|v| !matches!(v, Object::Null)))
}
/// Set `key` in the Table attribute object of `object`'s `/A` entry that holds it (else the first
@@ -266,11 +339,12 @@ fn set_table_attribute(doc: &mut Document, object: ObjRef, key: &[u8], value: Op
let mut entries: Vec<(Object, Option<Object>, Option<Dict>)> = entries
.into_iter()
.map(|(o, r)| {
let table = Some(doc.resolve(&o)).filter(|a| is_table_attributes(a)).and_then(|a| a.as_dict().cloned());
let table = Some(doc.resolve(&o)).filter(|a| is_table_attributes(doc, a)).and_then(|a| a.as_dict().cloned());
(o, r, table)
})
.collect();
let holding = |e: &(Object, Option<Object>, Option<Dict>)| e.2.as_ref().is_some_and(|d| d.contains(key));
let holding =
|e: &(Object, Option<Object>, Option<Dict>)| e.2.as_ref().and_then(|d| d.get(key)).is_some_and(|v| !matches!(*doc.resolve(v), Object::Null));
match value {
Some(v) => match entries.iter().position(holding).or_else(|| entries.iter().position(|e| e.2.is_some())) {
Some(i) => {
@@ -331,7 +405,7 @@ pub fn set_tag_property(doc: &mut Document, object: ObjRef, property: TagPropert
if let Some(owner) = property.table_attribute_of() {
let role_map =
alt::tree_root(doc).and_then(|r| r.get(b"RoleMap").map(|o| doc.resolve(o))).and_then(|o| o.as_dict().cloned()).unwrap_or_default();
let ty = element.dict.name(b"S").map(|s| alt::standard(doc, &role_map, s)).unwrap_or_default();
let ty = tag_type_name(doc, &element.dict).map(|s| alt::standard(doc, &role_map, &s)).unwrap_or_default();
if ty != owner.as_bytes() {
let name = if property == TagProperty::Summary { "summary" } else { "scope" };
return Err(TagError::WrongTagType(name, owner));
@@ -357,3 +431,168 @@ pub fn set_tag_property(doc: &mut Document, object: ObjRef, property: TagPropert
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
fn pdf(bodies: &[String]) -> Document {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, b) in bodies.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{b}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", bodies.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes());
Document::open(Arc::new(out)).unwrap()
}
#[test]
fn tag_content_and_table_attributes_resolve_indirect_entries() {
let content = "/P << /MCID 0 >> BDC BT /F1 12 Tf (Hello) Tj ET EMC /P << /MCID 1 >> BDC BT /F1 12 Tf (World) Tj ET EMC";
let doc = pdf(&[
"<< /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 200 200] /Contents 4 0 R /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >> >>".into(),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
"<< /Type /StructTreeRoot /K 6 0 R >>".into(),
"<< /S /Document /P 5 0 R /K [7 0 R 8 0 R] >>".into(),
"<< /S /Table /P 6 0 R /Pg 3 0 R /A << /O 11 0 R /Summary 12 0 R >> /K 9 0 R >>".into(),
"<< /S /TH /P 6 0 R /Pg 3 0 R /A [ << /O 11 0 R /Scope 13 0 R >> 14 0 R ] /K 10 0 R >>".into(),
"[15 0 R << /Type /MCR /MCID 16 0 R >>]".into(),
"15 0 R".into(),
"/Table".into(),
"(Indirect summary)".into(),
"/Column".into(),
"1".into(),
"0".into(),
"1".into(),
]);
let listed = tags(&doc);
let table = listed.iter().find(|t| t.object == ObjRef::new(7, 0)).unwrap();
let th = listed.iter().find(|t| t.object == ObjRef::new(8, 0)).unwrap();
assert_eq!(table.summary.as_deref(), Some("Indirect summary"));
assert_eq!(th.scope.as_deref(), Some("Column"));
let table_marks = tag_content(&doc, ObjRef::new(7, 0)).unwrap();
assert_eq!(
table_marks,
[TagContentItem { page: Some(0), mcid: 0, text: "Hello".into() }, TagContentItem { page: Some(0), mcid: 1, text: "World".into() },]
);
let th_marks = tag_content(&doc, ObjRef::new(8, 0)).unwrap();
assert_eq!(th_marks, [TagContentItem { page: Some(0), mcid: 0, text: "Hello".into() }]);
}
#[test]
fn move_tag_resolves_indirect_array_children_and_cycles() {
let mut doc = pdf(&[
"<< /Type /Catalog /StructTreeRoot 2 0 R >>".into(),
"<< /Type /StructTreeRoot /K 3 0 R >>".into(),
"<< /S /Document /P 2 0 R /K 7 0 R >>".into(),
"<< /S /P /P 3 0 R >>".into(),
"<< /S /P /P 3 0 R >>".into(),
"[4 0 R 6 0 R 5 0 R]".into(),
"6 0 R".into(),
]);
let first = ObjRef::new(4, 7);
let second = ObjRef::new(5, 9);
doc.set(first, (*doc.get(ObjRef::new(4, 0))).clone());
doc.set(second, (*doc.get(ObjRef::new(5, 0))).clone());
doc.set(ObjRef::new(6, 0), Object::Array(vec![Object::Ref(first), Object::Ref(ObjRef::new(6, 0)), Object::Ref(second)]));
assert_eq!(tags(&doc).iter().map(|t| t.object).collect::<Vec<_>>(), [ObjRef::new(3, 0), first, second]);
move_tag(&mut doc, second, first, TagPlacement::Before).unwrap();
assert_eq!(tags(&doc).iter().map(|t| t.object).collect::<Vec<_>>(), [ObjRef::new(3, 0), second, first]);
}
#[test]
fn tag_content_rejects_excessive_indirect_depth() {
let mut doc =
pdf(&["<< /Type /Catalog /StructTreeRoot 2 0 R >>".into(), "<< /Type /StructTreeRoot /K 3 0 R >>".into(), "<< /S /P /P 2 0 R >>".into()]);
let mut kid = doc.add(Object::Int(42));
for _ in 0..128 {
kid = doc.add(Object::Ref(kid));
}
doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"K".to_vec(), Object::Ref(kid))).unwrap();
assert_eq!(tag_content(&doc, ObjRef::new(3, 0)).unwrap().iter().map(|m| m.mcid).collect::<Vec<_>>(), [42]);
for _ in 0..1024 {
kid = doc.add(Object::Ref(kid));
}
doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"K".to_vec(), Object::Ref(kid))).unwrap();
assert_eq!(tag_content(&doc, ObjRef::new(3, 0)), Err(TagError::ContentLimit));
}
#[test]
fn tag_content_rejects_excessive_mark_count() {
let mut doc =
pdf(&["<< /Type /Catalog /StructTreeRoot 2 0 R >>".into(), "<< /Type /StructTreeRoot /K 3 0 R >>".into(), "<< /S /P /P 2 0 R >>".into()]);
doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"K".to_vec(), Object::Array(vec![Object::Int(1); 65_537]))).unwrap();
assert_eq!(tag_content(&doc, ObjRef::new(3, 0)), Err(TagError::ContentLimit));
}
#[test]
fn tag_content_accepts_full_mark_limit() {
let mut doc =
pdf(&["<< /Type /Catalog /StructTreeRoot 2 0 R >>".into(), "<< /Type /StructTreeRoot /K 3 0 R >>".into(), "<< /S /P /P 2 0 R >>".into()]);
doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"K".to_vec(), Object::Array(vec![Object::Int(1); 65_536]))).unwrap();
assert_eq!(tag_content(&doc, ObjRef::new(3, 0)).unwrap().len(), 65_536);
}
#[test]
fn tag_content_skips_many_child_elements_without_spending_mark_budget() {
let mut doc =
pdf(&["<< /Type /Catalog /StructTreeRoot 2 0 R >>".into(), "<< /Type /StructTreeRoot /K 3 0 R >>".into(), "<< /S /P /P 2 0 R >>".into()]);
let mut kids = Vec::new();
for _ in 0..33_000 {
let mut child = Dict::new();
child.set(b"S".to_vec(), Object::name("P"));
kids.push(Object::Ref(doc.add(Object::Dict(child))));
}
kids.push(Object::Int(42));
doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"K".to_vec(), Object::Array(kids))).unwrap();
assert_eq!(tag_content(&doc, ObjRef::new(3, 0)).unwrap().iter().map(|m| m.mcid).collect::<Vec<_>>(), [42]);
}
#[test]
fn tag_content_keeps_order_and_stops_indirect_cycles() {
let doc = pdf(&[
"<< /Type /Catalog /StructTreeRoot 2 0 R >>".into(),
"<< /Type /StructTreeRoot /K 3 0 R >>".into(),
"<< /S /P /P 2 0 R /K 4 0 R >>".into(),
"[1 5 0 R 4 0 R 3]".into(),
"<< /Type /MCR /MCID 2 >>".into(),
]);
assert_eq!(tag_content(&doc, ObjRef::new(3, 0)).unwrap().iter().map(|m| m.mcid).collect::<Vec<_>>(), [1, 2, 3]);
}
#[test]
fn move_tag_rejects_non_sibling_tags_sharing_stale_parent_ref() {
// 8 0 R is a child of 6 0 R, while 9 0 R is a child of 7 0 R, even though both point /P at 6 0 R.
let mut doc = pdf(&[
"<< /Type /Catalog /StructTreeRoot 2 0 R >>".into(),
"<< /Type /StructTreeRoot /K 3 0 R >>".into(),
"<< /S /Document /P 2 0 R /K [6 0 R 7 0 R] >>".into(),
"<< /S /P >>".into(),
"<< /S /P >>".into(),
"<< /S /Sect /P 3 0 R /K [8 0 R 9 0 R] >>".into(),
"<< /S /Sect /P 3 0 R /K [9 0 R] >>".into(),
"<< /S /P /P 6 0 R >>".into(),
"<< /S /P /P 6 0 R >>".into(),
]);
// Give 6 0 R only [8 0 R] in the reachable tree, and put [8 0 R 9 0 R] on an unreachable object 4 0 R.
doc.update_dict(ObjRef::new(6, 0), |d| d.set(b"K".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))]))).unwrap();
doc.update_dict(ObjRef::new(4, 0), |d| {
d.set(b"K".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0)), Object::Ref(ObjRef::new(9, 0))]));
})
.unwrap();
doc.update_dict(ObjRef::new(8, 0), |d| d.set(b"P".to_vec(), Object::Ref(ObjRef::new(4, 0)))).unwrap();
doc.update_dict(ObjRef::new(9, 0), |d| d.set(b"P".to_vec(), Object::Ref(ObjRef::new(4, 0)))).unwrap();
assert_eq!(move_tag(&mut doc, ObjRef::new(9, 0), ObjRef::new(8, 0), TagPlacement::Before), Err(TagError::NotSiblings));
}
}
+174 -2
View File
@@ -142,17 +142,120 @@ fn autotag_text_says_why_it_cannot_read_a_page() {
}
#[test]
fn autotag_text_refuses_paint_and_does_not_modify_the_document() {
fn autotag_text_marks_vector_graphics_and_images_as_artifacts() {
let mut doc = pdf(
&[
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R 7 0 R] /Count 2 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im1 6 0 R >> >> >>".into(),
stream("0 0 200 200 m 200 0 l 200 200 l 0 200 l h f 10 10 50 50 re B BT /F1 12 Tf 20 100 Td (Hello) Tj ET q 20 0 0 20 80 80 cm /Im1 Do Q"),
FONT.into(),
"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\n\u{0}\nendstream".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 8 0 R >>".into(),
stream("0 0 100 100 re S"),
],
"",
);
let result = autotag_text(&mut doc).unwrap();
assert_eq!(result, AutoTagResult { pages: 1, paragraphs: 1 });
let report = check(&doc, &Options { rules: [Rule::TaggedPdf, Rule::TaggedContent].into(), pages: None });
assert_eq!(status(&report, Rule::TaggedPdf), Status::Passed);
assert_eq!(status(&report, Rule::TaggedContent), Status::Passed);
let bytes = pdfcraft_cos::write_incremental(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap();
let reopened = Document::open(Arc::new(bytes)).unwrap();
let reopened_report = check(&reopened, &Options { rules: [Rule::TaggedPdf, Rule::TaggedContent].into(), pages: None });
assert_eq!(status(&reopened_report, Rule::TaggedPdf), Status::Passed);
assert_eq!(status(&reopened_report, Rule::TaggedContent), Status::Passed);
}
#[test]
fn autotag_artifacts_wrap_complete_paths_and_leave_unpainted_clips_outside() {
let content = "0 0 m 10 0 l 10 10 l h f 0 0 20 20 re W B 0 0 50 50 re W* n /Shade sh BT /F1 12 Tf (Text) Tj ET 3 3 5 5 re S";
let mut doc = pdf(
&[
"<< /Type /Catalog /Pages 2 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 /Resources << /Font << /F1 5 0 R >> >> >>".into(),
stream("BT /F1 12 Tf 20 100 Td (Hello) Tj ET 0 0 50 50 re f"),
stream(content),
FONT.into(),
],
"",
);
autotag_text(&mut doc).unwrap();
let bytes = pdfcraft_cos::write_incremental(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap();
let doc = Document::open(Arc::new(bytes)).unwrap();
let page = pdfcraft_model::pages(&doc).remove(0);
let content = doc.resolve(page.dict.get(b"Contents").unwrap());
let Object::Stream(content) = &*content else { panic!("page stream") };
let ops = pdfcraft_content::parse(&content.decoded_strict().unwrap()).ops;
let names: Vec<&[u8]> = ops.iter().map(|op| op.op.as_slice()).collect();
assert_eq!(
names,
vec![
b"BMC".as_slice(),
b"m",
b"l",
b"l",
b"h",
b"f",
b"EMC",
b"BMC",
b"re",
b"W",
b"B",
b"EMC",
b"re",
b"W*",
b"n",
b"BMC",
b"sh",
b"EMC",
b"BDC",
b"BT",
b"Tf",
b"Tj",
b"ET",
b"EMC",
b"BMC",
b"re",
b"S",
b"EMC",
]
);
}
#[test]
fn autotag_rejects_text_interleaved_with_an_unfinished_path_atomically() {
for tail in ["10 10 l S", "n"] {
let mut doc = pdf(
&[
"<< /Type /Catalog /Pages 2 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 /Resources << /Font << /F1 5 0 R >> >> >>".into(),
stream(&format!("0 0 m BT /F1 12 Tf (Text) Tj ET {tail}")),
FONT.into(),
],
"",
);
let before = pdfcraft_cos::write_incremental(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap();
assert!(matches!(autotag_text(&mut doc), Err(AutoTagError::Unsupported { .. })));
assert_eq!(pdfcraft_cos::write_incremental(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap(), before);
}
}
#[test]
fn autotag_text_refuses_form_xobjects_and_does_not_modify_the_document() {
let mut doc = pdf(
&[
"<< /Type /Catalog /Pages 2 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 /Resources << /Font << /F1 5 0 R >> /XObject << /Fm1 6 0 R >> >> >>".into(),
stream("BT /F1 12 Tf 20 100 Td (Hello) Tj ET /Fm1 Do"),
FONT.into(),
"<< /Type /XObject /Subtype /Form /BBox [0 0 50 50] /Length 12 >>\nstream\n0 0 10 10 re f\nendstream".into(),
],
"",
);
assert!(matches!(autotag_text(&mut doc), Err(AutoTagError::Unsupported { .. })));
assert!(!doc.get(doc.root().unwrap()).as_dict().unwrap().contains(b"StructTreeRoot"));
assert_eq!(autotag_text(&mut pdf(&good(), "")), Err(AutoTagError::AlreadyTagged));
@@ -1559,6 +1662,24 @@ fn q_inside_a_text_object_restores_the_text_matrices() {
assert_eq!(titles, ["Chapter One", "Chapter Two"]);
}
#[test]
fn headings_naming_one_large_string_stop_decoding_it_at_the_allowance() {
let count = 100;
let kids = (0..count).map(|i| format!("{} 0 R", 8 + i)).collect::<Vec<_>>().join(" ");
let mut objects = vec![
"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R >>".to_string(),
stream("BT ET"),
"<< /Type /StructTreeRoot /K 6 0 R >>".to_string(),
format!("<< /S /Document /P 5 0 R /K [{kids}] >>"),
format!("({})", "a".repeat(512 << 10)),
];
objects.extend((0..count).map(|_| "<< /S /H1 /P 6 0 R /Pg 3 0 R /ActualText 7 0 R >>".to_string()));
let found = headings(&pdf(&objects, ""));
assert!(!found.is_empty() && found.len() < count, "{}", found.len());
}
#[test]
fn an_untagged_document_has_no_headings() {
let mut objs = headings_doc();
@@ -1745,3 +1866,54 @@ fn marking_a_tag_artifact_refuses_content_past_the_decode_budget() {
assert_eq!(mark_tag_artifact(&mut doc, ObjRef::new(7, 0)), Err(ArtifactError::TooLarge));
assert_eq!(doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents"), Some(&contents), "the page keeps its content");
}
#[test]
fn autotag_text_refuses_pages_that_share_content_past_the_total_operator_allowance() {
// Each page is just under the per-page limit; sharing one stream makes the total 21 times it.
let content = format!("BT (a) Tj ET {}", "q Q ".repeat(49_990));
let pages = 21;
let kids: Vec<String> = (0..pages).map(|i| format!("{} 0 R", 4 + i)).collect();
let mut bodies = vec![
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
format!("<< /Type /Pages /Kids [{}] /Count {pages} /MediaBox [0 0 200 200] >>", kids.join(" ")),
stream(&content),
];
bodies.extend((0..pages).map(|_| "<< /Type /Page /Parent 2 0 R /Contents 3 0 R >>".to_string()));
let mut doc = pdf(&bodies, "");
let Err(AutoTagError::Unsupported { reason, .. }) = autotag_text(&mut doc) else { panic!("expected a refusal") };
assert!(reason.contains("too much content"), "{reason}");
}
#[test]
fn autotag_form_fields_tags_widgets_of_a_shared_annots_array_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 7 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 7 0 R >>".into(),
"[5 0 R 5 0 R]".into(),
],
"",
);
assert_eq!(tag_form_fields(&mut doc).unwrap(), 1);
}
#[test]
fn checker_reports_each_untagged_annotation_without_copying_shared_dictionaries() {
let doc = pdf(
&[
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [4 0 R 4 0 R 5 0 R] >>".into(),
"<< /Type /Annot /Subtype /Text /Rect [20 60 120 80] >>".into(),
"<< /Type /Annot /Subtype /Popup /Rect [20 60 120 80] >>".into(),
],
"",
);
let pages = pdfcraft_model::pages(&doc);
let found = content::page_rule(&doc, Rule::TaggedAnnotations, &pages, &[0], &Default::default(), false);
assert_eq!(found.len(), 2, "the popup is skipped, each listed text annotation reported");
}
+18 -8
View File
@@ -6,13 +6,19 @@ Comments (annotations), ISO 32000-2 §12.5. Layer L3, depends on `pdfcraft-cos`,
- **Builders** for the comment types Acrobat's commenting tools create: sticky note (`Text` +
`Popup`), highlight, underline, strikethrough, squiggly, rectangle (`Square`), oval (`Circle`),
line and arrow (`Line` with `/LE`), freehand drawing (`Ink`) and text box (`FreeText`).
line and arrow (`Line` with `/LE`), freehand drawing (`Ink`), text box and callout
(`FreeText`), polygon (including cloudy borders), polyline, caret, rubber and custom stamp,
and file attachment.
- **Appearance streams** (`appearance::build`) generated from the annotation dictionary, so new
and restyled comments look the same in every viewer. Highlights multiply (`/BM /Multiply`).
A line's leader lines (`/LL`, `/LLE`, `/LLO`) and caption (`/Cap`, `/CP`, `/CO`) are drawn as
ISO 32000-2 Table 178 places them, the caption in black Helvetica 10.
Note icons are PdfCraft's own drawings. Text boxes use standard Helvetica (WinAnsi), with
line breaking by approximate Helvetica proportions (no font program is bundled). Text it can't
line breaking by Helvetica's standard-14 metrics. Thai in a text box, callout or Fill & Sign
text is shaped and drawn from the craft-fonts Thai face with Add text's writer
(`pdfcraft_fonts::shaped_text`: Type 3 glyphs with a ToUnicode map), the box sized to the ink
of its stacked marks and descenders; a build without a Thai face refuses it
(`AnnotError::Undrawable`) instead of drawing `?`. Other text it can't
draw (in a text box, callout, Fill & Sign text, line caption, measurement label or dynamic
stamp's by-line; `appearance::undrawable_text`) is kept as typed and drawn as `?`, and the comment is marked with
the first such character (`pdfcraft_fonts::UNDRAWN_MARK`) so the engine reports the edit and
@@ -21,7 +27,9 @@ Comments (annotations), ISO 32000-2 §12.5. Layer L3, depends on `pdfcraft-cos`,
comment another program drew with a font that has those characters, whose appearance is kept
rather than redrawn with `?` (new text for it is taken).
- **Edits:** reply (`/IRT`), set review status (a `/State` reply, as Acrobat's "Set status"
does), change text, move, resize, restyle, delete (with pop-up and replies).
does), change text, move, resize, restyle, delete (with pop-up and replies), and open, close
or move the pop-up window (`set_popup`: `/Popup` `/Open` and `/Rect`, created beside a comment
that has none; a note's own `/Open` follows). `summaries` report each comment's pop-up.
## API sketch
@@ -43,8 +51,8 @@ deterministic; the engine supplies them.
- Unknown keys are kept. Inline annotation dictionaries are promoted to indirect objects only
when an edit needs a reference to them.
- `set_style` refuses (and changes nothing) when it cannot redraw the comment, e.g. stamps,
cloudy borders or callouts, instead of leaving a stale appearance.
- `set_style` refuses when it cannot redraw the comment, e.g. a stamp
whose appearance cannot be regenerated, instead of leaving a stale appearance.
- Replies get the parent's `/Rect` and an empty appearance: they appear in comment lists but
never paint a second icon.
- Resizing a stamp changes its `/Rect` and preserves its original appearance and resources;
@@ -59,7 +67,9 @@ deterministic; the engine supplies them.
(vendored hayro-interpret patch 37), and `Summary::no_rotate` tells the UI where the icon is
drawn.
## Not yet
## Rich text and remaining limits
Callouts, polygons and polylines, clouds, carets (insert/replace text), stamps, file
attachments, rich text (`/RC`, `/DS`), and the shared named-appearance cache Acrobat writes.
Text-box text and colour edits regenerate `/RC` (XHTML) and `/DS` from the plain
`/Contents`, `/DA` and `/Q`, so rich-text editors show the same text and style. These edits
replace imported rich-text formatting. The shared named-appearance cache Acrobat writes
is not implemented.
+525 -60
View File
@@ -3,16 +3,51 @@
//! [`build`] draws a normal appearance (`/AP /N`) from the annotation dictionary and the
//! objects it points at, so the same code serves new comments and restyled ones. Drawings are in page space with
//! `/BBox = /Rect` (identity matrix), except note icons, which are drawn in a 20 × 20 box.
//! It returns `None` for anything it cannot draw faithfully (cloudy borders, unknown line
//! endings), so callers never replace an appearance with a worse one.
//! It returns `None` for anything it cannot draw faithfully (unknown line endings, a cloudy
//! callout), so callers never replace an appearance with a worse one. It resolves `/QuadPoints`
//! itself; for the other values, callers that hold the document follow references first
//! (`draw::with_direct_values`).
//!
//! The note icons are PdfCraft's own drawings (AGENTS.md §1). Text boxes use the standard
//! Helvetica font with WinAnsi encoding; line breaking uses [`text_width`], an approximation of
//! Helvetica's proportions by character class (no font program or metrics file is bundled).
//! The note icons are PdfCraft's own drawings (AGENTS.md §1). Text boxes use a standard text
//! font with WinAnsi encoding: Helvetica, or the face their `/DA` or `/DS` asks for
//! ([`crate::text_style`]), and draw Thai from the craft-fonts Thai face as Type 3 glyphs
//! (`pdfcraft_fonts::shaped_text`, the writer Add text uses); lines break at that face's
//! standard-14 widths and the Thai face's shaped advances (no other font program is bundled).
use pdfcraft_cos::{Dict, Object, PdfString, Stream};
use pdfcraft_fonts::{first_undrawable, literal, win_ansi};
pub use pdfcraft_fonts::{helvetica_width as text_width, wrap};
use pdfcraft_fonts::shaped_text;
use pdfcraft_fonts::{first_undrawable, literal, win_ansi, wrap_with};
/// The width of `text` at `size` as appearances draw it: Helvetica, with Thai shaped in the Thai
/// face that text boxes draw it with.
pub fn text_width(text: &str, size: f64) -> f64 {
text_width_in(pdfcraft_fonts::Std14::Helvetica, text, size)
}
/// [`text_width`] in the standard `face` a text box draws its other text in.
pub fn text_width_in(face: pdfcraft_fonts::Std14, text: &str, size: f64) -> f64 {
shaped_text::thai_width(text, size, |s, size| face.text_width(s, size))
}
/// `text` wrapped `width` wide as [`text_width`] measures it; a Thai mark stays on its letter.
pub fn wrap(text: &str, size: f64, width: f64) -> Vec<String> {
wrap_with(text, size, width, text_width)
}
/// How far a text box's `lines` reach, in em: above the first baseline (0.9 of the 1.2 em line)
/// and below the last (0.3), or further where Thai stacked marks or descenders reach, so the box
/// is sized to their ink and doesn't clip them.
fn reach(lines: &[String]) -> [f64; 2] {
let ink = |line: Option<&String>| line.and_then(|l| shaped_text::thai_reach(l));
[ink(lines.first()).map_or(0.9, |r| r[1].max(0.9)), ink(lines.last()).map_or(0.3, |r| (-r[0]).max(0.3))]
}
/// The height a text box's `lines` take at `size`, inside its padding: 1.2 em a line, and more
/// for Thai that reaches further ([`reach`]).
pub fn text_lines_height(lines: &[String], size: f64) -> f64 {
let [up, down] = reach(lines);
lines.len().max(1) as f64 * size * 1.2 + (up - 0.9 + down - 0.3) * size
}
use crate::{Document, NOTE_SIZE, Rgb, n};
@@ -26,7 +61,9 @@ pub fn arrow_size(w: f64) -> f64 {
6.0 + 3.0 * w.max(0.0)
}
/// Nominal radius of a cloudy border's bumps for a line of width `w` (intensity 1).
/// Nominal radius of a cloudy border's bumps for a line of width `w` (intensity 1). ISO 32000-2
/// Table 169 leaves the size open; this is the size PdfCraft's polygon clouds already had, so
/// squares, circles and text boxes match them.
pub fn cloud_radius(w: f64) -> f64 {
6.0 + 1.5 * w.max(0.0)
}
@@ -120,6 +157,29 @@ pub fn smooth_segments(pts: &[(f64, f64)]) -> Vec<[(f64, f64); 3]> {
.collect()
}
/// The points where the cubic Bézier segment from `p0` (control points `c1`, `c2`) to `p3` turns
/// back in x or in y, strictly between its ends: with its ends, they bound the segment.
fn cubic_extremes(p0: (f64, f64), c1: (f64, f64), c2: (f64, f64), p3: (f64, f64)) -> Vec<(f64, f64)> {
let at = |t: f64| {
let u = 1.0 - t;
let w = [u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t];
(w[0] * p0.0 + w[1] * c1.0 + w[2] * c2.0 + w[3] * p3.0, w[0] * p0.1 + w[1] * c1.1 + w[2] * c2.1 + w[3] * p3.1)
};
let mut out = Vec::new();
for (q0, q1, q2, q3) in [(p0.0, c1.0, c2.0, p3.0), (p0.1, c1.1, c2.1, p3.1)] {
// The derivative over 3: a t² + 2 b t + c.
let (a, b, c) = (-q0 + 3.0 * q1 - 3.0 * q2 + q3, q0 - 2.0 * q1 + q2, q1 - q0);
let roots = if a.abs() < 1e-12 {
if b.abs() < 1e-12 { vec![] } else { vec![-c / (2.0 * b)] }
} else {
let disc = b * b - a * c;
if disc < 0.0 { vec![] } else { vec![(-b + disc.sqrt()) / a, (-b - disc.sqrt()) / a] }
};
out.extend(roots.into_iter().filter(|t| *t > 0.0 && *t < 1.0).map(at));
}
out
}
/// `c` operators for [`smooth_segments`] (the path already starts at the first point).
fn smooth_curve(pts: &[(f64, f64)]) -> String {
smooth_segments(pts).iter().map(|[a, b, e]| format!("{} {} {} {} {} {} c\n", n(a.0), n(a.1), n(b.0), n(b.1), n(e.0), n(e.1))).collect()
@@ -128,6 +188,80 @@ fn smooth_curve(pts: &[(f64, f64)]) -> String {
/// Bumps one cloudy outline draws at most (plus one per edge); past it the bumps grow.
pub(crate) const MAX_CLOUD_BUMPS: usize = 20_000;
/// A cloudy border's intensity (ISO 32000-2 §12.5.4, Table 169: `/BE /I`, "in the range 0 to 2.
/// Default value: 0"), or `None` for no cloud: no `/BE`, a style other than `/C`, or intensity 0,
/// which draws the border plain.
pub(crate) fn cloud_intensity(d: &Dict) -> Option<f64> {
let be = d.get(b"BE").and_then(|b| b.as_dict())?;
if be.name(b"S") != Some(b"C") {
return None;
}
let i = be.get(b"I").and_then(|i| i.as_f64()).unwrap_or(0.0);
(i.is_finite() && i > 0.0).then(|| i.min(2.0))
}
/// The bump radius for a cloudy border of width `w` at `intensity` (0–2].
pub(crate) fn cloud_bump(w: f64, intensity: f64) -> f64 {
cloud_radius(w) * intensity.clamp(0.5, 2.0)
}
/// How far past its outline a cloud with bumps of radius `bump` around an outline `perimeter`
/// long can reach. `cloud_path` rounds each edge to whole bumps, so one may span up to three
/// radii and bulge 1.5 radii; it also grows the radius to keep within its bump budget.
pub(crate) fn cloud_reach(perimeter: f64, bump: f64) -> f64 {
1.5 * bump.max(perimeter / (2.0 * MAX_CLOUD_BUMPS as f64))
}
/// `/RD` when it is four finite, non-negative numbers (Tables 170 and 180).
fn rd_of(d: &Dict) -> Option<Vec<f64>> {
nums(d, b"RD").filter(|r| r.len() == 4 && r.iter().all(|x| x.is_finite() && *x >= 0.0))
}
/// `rect` less `rd`, whose four numbers are the left, top, right and bottom differences.
fn less_rd(rect: [f64; 4], rd: &[f64]) -> [f64; 4] {
let at = |i: usize| rd.get(i).copied().unwrap_or(0.0);
[rect[0] + at(0), rect[1] + at(3), rect[2] - at(2), rect[3] - at(1)]
}
/// The box a plain (not cloudy) square or circle is drawn in: `/Rect` less a usable `/RD` (Table
/// 180: the differences between `/Rect` and "the actual boundaries of the underlying square or
/// circle"), or `None` when there is no `/RD`, it isn't four finite non-negative numbers, or it
/// leaves less than a point either way, so the caller draws in `/Rect` as without one.
fn rect_less_rd(d: &Dict, rect: [f64; 4]) -> Option<[f64; 4]> {
let inner = less_rd(rect, &rd_of(d)?);
(inner[2] - inner[0] >= 1.0 && inner[3] - inner[1] >= 1.0).then_some(inner)
}
/// The shape a cloudy square, circle or text box draws its bumps around: `/Rect` less `/RD`
/// (Table 180: the differences between "the left, top, right, and bottom coordinates of Rect and
/// those of the square or circle, respectively"), but at least the bumps' greatest reach and half
/// the line from each side, so they stay inside `/Rect`; without a usable `/RD`, that margin.
fn cloud_inner(d: &Dict, rect: [f64; 4], bump: f64, w: f64) -> Option<[f64; 4]> {
// The cloud's reach and half the line keep it inside `/Rect`; an `/RD` leaving more is
// followed, one leaving less is not.
let perimeter = 2.0 * ((rect[2] - rect[0]) + (rect[3] - rect[1]));
let m = cloud_reach(perimeter, bump) + w / 2.0;
let safe = [rect[0] + m, rect[1] + m, rect[2] - m, rect[3] - m];
let inner = match rd_of(d) {
Some(rd) => {
let r = less_rd(rect, &rd);
[r[0].max(safe[0]), r[1].max(safe[1]), r[2].min(safe[2]), r[3].min(safe[3])]
}
None => safe,
};
(inner[2] - inner[0] >= 1.0 && inner[3] - inner[1] >= 1.0).then_some(inner)
}
/// Points around the ellipse inscribed in `r`, counter-clockwise, one per bump of radius `bump`
/// (each edge of the outline becomes one bump), for a cloudy circle.
fn ellipse_points(r: [f64; 4], bump: f64) -> Vec<(f64, f64)> {
let (cx, cy, rx, ry) = ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0, (r[2] - r[0]) / 2.0, (r[3] - r[1]) / 2.0);
// Ramanujan's approximation of the perimeter; the count stays within the bump budget.
let perimeter = std::f64::consts::PI * (3.0 * (rx + ry) - ((3.0 * rx + ry) * (rx + 3.0 * ry)).max(0.0).sqrt());
let count = (perimeter / (2.0 * bump.max(0.5))).round().clamp(8.0, MAX_CLOUD_BUMPS as f64) as u32;
(0..count).map(|i| f64::from(i) * std::f64::consts::TAU / f64::from(count)).map(|a| (cx + rx * a.cos(), cy + ry * a.sin())).collect()
}
/// A closed cloudy outline through `pts` (§12.5.4 `/BE /S /C`): each edge becomes a row of
/// half-circle bumps of about `r`, bulging outwards.
pub fn cloud_path(pts: &[(f64, f64)], r: f64) -> String {
@@ -178,11 +312,24 @@ fn quads(context: &AppearanceContext<'_>) -> Option<Vec<[f64; 8]>> {
(!q.is_empty()).then_some(q)
}
/// `/C`-style colour arrays: none (transparent), gray, RGB or CMYK.
fn color(d: &Dict, key: &[u8]) -> Option<Option<Rgb>> {
/// `/C`-style colour arrays (ISO 32000-2 Table 166): none (transparent), gray, RGB or CMYK.
/// The entries should all be numbers; a four-entry array with a non-number in it, such as
/// `[1 0 0 null]`, is still read as CMYK with that entry 0, as PDFium, MuPDF and Poppler read it.
/// A reference is not such an entry: it stands for a value this builder can't see (§7.3.10), so
/// the colour is unreadable, as the other colour and number readers here treat one.
pub(crate) fn color(d: &Dict, key: &[u8]) -> Option<Option<Rgb>> {
let c = match d.get(key) {
None => return Some(None),
Some(o) => o.as_array()?.iter().map(|o| o.as_f64()).collect::<Option<Vec<f64>>>()?,
Some(o) => match o.as_array()? {
a if a.len() == 4 => a
.iter()
.map(|o| match o {
Object::Ref(_) => None,
o => Some(o.as_f64().unwrap_or(0.0)),
})
.collect::<Option<Vec<f64>>>()?,
a => a.iter().map(|o| o.as_f64()).collect::<Option<Vec<f64>>>()?,
},
};
Some(match c.as_slice() {
[] => None,
@@ -269,13 +416,17 @@ pub fn da_border(d: &Dict) -> Option<Rgb> {
col
}
/// `/DA` of a text box: (text colour, font size). Defaults to black 12 pt.
/// `/DA` of a text box: (text colour, font size). Defaults to black 12 pt: a `Tf` without a
/// usable size (missing, zero, negative or not a number) keeps 12 pt, and sizes past 400 pt are
/// capped. A text box has no automatic size, so a zero size, which means auto-size in a form
/// field's `/DA` (ISO 32000-2 §12.7.4.3), is the default here. An operand that isn't a finite
/// number (`NaN`, `inf`, which Rust's parser would otherwise accept) is ignored with its operator.
pub fn parse_da(d: &Dict) -> (Rgb, f64) {
let da = d.get(b"DA").and_then(|o| o.as_string()).map(|s| String::from_utf8_lossy(&s.bytes).into_owned()).unwrap_or_default();
let toks: Vec<&str> = da.split_whitespace().collect();
let mut col = [0.0; 3];
let mut size = 12.0;
let num = |i: usize| toks.get(i).and_then(|t| t.parse::<f64>().ok());
let num = |i: usize| toks.get(i).and_then(|t| t.parse::<f64>().ok()).filter(|v| v.is_finite());
for (i, t) in toks.iter().enumerate() {
match *t {
"rg" if i >= 3 => {
@@ -392,16 +543,20 @@ pub(crate) fn inset(d: &Dict) -> Option<[f64; 4]> {
/// 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 line's caption, a measurement's label, a
/// text of a text box, callout or Fill & Sign text (whose Thai the Thai face draws, where this
/// build has one), a line's caption, a measurement's label, a
/// dynamic stamp's "By …" line, and a redaction mark's overlay text (drawn when it is applied). `None` when all of
/// it can be drawn.
pub fn undrawable_text(d: &Dict) -> Option<(&'static str, char)> {
let text = |key: &[u8]| d.get(key).and_then(Object::as_string).map(PdfString::to_text);
let (what, text) = match d.name(b"Subtype")? {
b"FreeText" => ("the comment's text", text(b"Contents")?),
// A text box draws Thai too.
b"FreeText" => return Some(("the comment's text", shaped_text::first_undrawable(&text(b"Contents")?)?)),
b"Line" if !d.contains(b"PCMeasureValue") => ("the line's caption", line_caption(d)?),
b"Line" | b"PolyLine" | b"Polygon" => ("the measurement label", text(b"PCMeasureValue")?),
b"Stamp" if matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) => ("the stamp's \"By\" line", text(b"PCByLine")?),
b"Stamp" if matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) || d.contains(b"PCPicture") => {
("the stamp's \"By\" line", text(b"PCByLine")?)
}
// Drawn in Helvetica when the redaction is applied: refused when it is marked.
b"Redact" => ("the redaction's overlay text", text(b"OverlayText")?),
_ => return None,
@@ -411,14 +566,20 @@ pub fn undrawable_text(d: &Dict) -> Option<(&'static str, char)> {
/// `/Font` resources naming `/Helv`: standard Helvetica with WinAnsiEncoding.
pub(crate) fn helvetica_resource() -> Object {
Object::Dict(font_resources(pdfcraft_fonts::Std14::Helvetica))
}
/// `/Font` resources naming one standard face with WinAnsiEncoding, under the resource name
/// [`crate::text_style::resource_name`] gives it (`/Helv` for Helvetica).
fn font_resources(face: pdfcraft_fonts::Std14) -> Dict {
let mut font = Dict::new();
font.set(b"Type".to_vec(), Object::name("Font"));
font.set(b"Subtype".to_vec(), Object::name("Type1"));
font.set(b"BaseFont".to_vec(), Object::name("Helvetica"));
font.set(b"BaseFont".to_vec(), Object::name(face.base_font()));
font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
let mut fonts = Dict::new();
fonts.set(b"Helv".to_vec(), Object::Dict(font));
Object::Dict(fonts)
fonts.set(crate::text_style::resource_name(face).as_bytes().to_vec(), Object::Dict(font));
fonts
}
/// Where a caret and its paragraph symbol go within `rect`: the box the caret is drawn in, and for
@@ -464,7 +625,8 @@ struct AppearanceContext<'a> {
dict: &'a Dict,
subtype: &'a [u8],
rect: [f64; 4],
cloudy: bool,
/// A cloudy border's intensity ([`cloud_intensity`]), or `None` for a plain one.
cloud: Option<f64>,
/// The line's opacity (`parts.line`).
opacity: f64,
parts: PartOpacity,
@@ -498,13 +660,22 @@ impl AppearanceDrawing {
/// Text the standard fonts can't draw comes out as `?`: [`undrawable_text`] says which, and
/// [`crate::set_appearance`] marks the annotation with it (`pdfcraft_fonts::UNDRAWN_MARK`).
pub fn build(doc: &Document, d: &Dict) -> Option<Stream> {
let context = appearance_context(doc, d)?;
build_on_page(doc, d, None)
}
/// [`build`] for an annotation on a page whose visible region is `page` (`Page::crop` in
/// pdfcraft-model): a text markup, line, polyline, polygon or ink annotation whose points reach
/// past its `/Rect` is drawn through them as far as the page shows ([`point_reach`]), and its
/// appearance's `/BBox` says how far ([`widened_rect`]). Without a page it is drawn within `/Rect`.
pub(crate) fn build_on_page(doc: &Document, d: &Dict, page: Option<[f64; 4]>) -> Option<Stream> {
let context = appearance_context(doc, d, page)?;
let drawing = AppearanceDrawing::new(&context);
match context.subtype {
b"Text" => return draw_icon(&context, "Note", [1.0, 0.82, 0.0], drawing),
b"FileAttachment" => return draw_icon(&context, "PushPin", [0.0, 0.47, 0.84], drawing),
b"Stamp" => return draw_stamp(&context, drawing),
b"FreeText" => return draw_free_text(&context, drawing),
// The face a text box's `/DA` font names in the form's `/DR`, when it names one.
b"FreeText" => return draw_free_text(&context, drawing, crate::text_style::dr_face(doc, d)),
_ => {}
}
let mut drawing = drawing;
@@ -512,18 +683,20 @@ pub fn build(doc: &Document, d: &Dict) -> Option<Stream> {
finish_appearance(&context, drawing)
}
fn appearance_context<'a>(doc: &'a Document, d: &'a Dict) -> Option<AppearanceContext<'a>> {
fn appearance_context<'a>(doc: &'a Document, d: &'a Dict, page: Option<[f64; 4]>) -> Option<AppearanceContext<'a>> {
// Imported measurement appearances may include leaders, captions and formatting that
// this builder cannot reproduce. Preserve them rather than silently losing detail.
if d.contains(b"Measure") && !d.contains(b"PCMeasureValue") {
return None;
}
let subtype = d.name(b"Subtype")?;
let rect = nums(d, b"Rect").filter(|r| r.len() == 4)?;
let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])];
// Cloudy borders (§12.5.4) are drawn for polygons only.
let cloudy = d.get(b"BE").and_then(|b| b.as_dict()).is_some_and(|b| b.name(b"S") == Some(b"C"));
if cloudy && subtype != b"Polygon" {
// A number too long for a double reads as infinite, and two near its limit are an infinite
// width apart: nothing is drawn rather than "inf" written into the appearance (review on #4195).
let rect = normalised_rect(d).filter(|r| r.iter().all(|x| x.is_finite()) && (r[2] - r[0]).is_finite() && (r[3] - r[1]).is_finite())?;
let rect = page.and_then(|page| point_reach(doc, d, rect, page)).unwrap_or(rect);
// Cloudy borders (§12.5.4, Table 169) apply to squares, circles, polygons and text boxes.
let cloud = cloud_intensity(d);
if cloud.is_some() && !matches!(subtype, b"Square" | b"Circle" | b"Polygon" | b"FreeText") {
return None;
}
Some(AppearanceContext {
@@ -531,7 +704,7 @@ fn appearance_context<'a>(doc: &'a Document, d: &'a Dict) -> Option<AppearanceCo
dict: d,
subtype,
rect,
cloudy,
cloud,
opacity: part_opacity(d).line,
parts: part_opacity(d),
stroke: color(d, b"C")?,
@@ -539,6 +712,174 @@ fn appearance_context<'a>(doc: &'a Document, d: &'a Dict) -> Option<AppearanceCo
})
}
/// `/Rect` as lower-left and upper-right corners (a rectangle may be written with either pair of
/// opposite corners, ISO 32000-2 §7.9.5); `None` unless it is four numbers.
fn normalised_rect(d: &Dict) -> Option<[f64; 4]> {
let [x0, y0, x1, y1] = <[f64; 4]>::try_from(nums(d, b"Rect")?).ok()?;
Some([x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)])
}
/// The subtypes drawn through points of their own rather than within `/Rect`: text markup over
/// its `/QuadPoints` (ISO 32000-2 §12.5.6.10), lines along `/L` (§12.5.6.7), polygons and
/// polylines through `/Vertices` (§12.5.6.9) and ink through `/InkList` (§12.5.6.13).
pub(crate) fn is_drawn_from_points(d: &Dict) -> bool {
matches!(d.name(b"Subtype"), Some(b"Highlight" | b"Underline" | b"StrikeOut" | b"Squiggly" | b"Line" | b"PolyLine" | b"Polygon" | b"Ink"))
}
/// At most this many quadrilaterals widen a text markup's box ([`point_reach`]). Each takes in a
/// word or a group of contiguous words (ISO 32000-2 Table 182), usually a line, so even written
/// word by word a markup over a whole dense page stays well below it; a larger one is drawn
/// within its `/Rect`, as before the box was widened.
pub(crate) const MAX_MARKUP_QUADS: usize = 10_000;
/// At most this many points widen a line's, polyline's, polygon's or ink annotation's box
/// ([`point_reach`]): as many as [`MAX_MARKUP_QUADS`] quadrilaterals have. A larger one is drawn
/// within its `/Rect`.
pub(crate) const MAX_REACH_POINTS: usize = 4 * MAX_MARKUP_QUADS;
/// The points `d` is drawn through, as `x y` pairs, and how far its strokes reach past them:
/// half the border width, and for a line ending (`/LE`) or a cloudy border (§12.5.4) the size
/// they are drawn at. `None` for other subtypes, more points than the caps allow, and points that
/// aren't all finite numbers.
fn drawn_points(doc: &Document, d: &Dict) -> Option<(Vec<f64>, f64)> {
let w = border_width(d);
let has_ending = || d.get(b"LE").and_then(Object::as_array).is_some_and(|e| e.iter().any(|e| e.as_name().is_some_and(|e| e != b"None")));
let (points, margin) = match d.name(b"Subtype")? {
subtype @ (b"Highlight" | b"Underline" | b"StrikeOut" | b"Squiggly") => {
// The quads `draw_markup` draws (`quads`: resolved, malformed ones skipped), counted
// before the numbers are read.
let array = doc.resolve(d.get(b"QuadPoints")?);
let entries = array.as_array()?;
if entries.len() / 8 > MAX_MARKUP_QUADS {
return None;
}
let quads = pdfcraft_geom::quad_points::usable_quads(entries.iter().map(|x| doc.resolve(x).as_f64()));
if quads.is_empty() {
return None;
}
// How far each quad's stroke reaches past it: half the line's width, and a squiggle's
// peaks above a quad lower than they are (`draw_markup`).
let margin = quads.iter().map(|q| markup_overreach(subtype, (q[0] - q[4]).hypot(q[1] - q[5]))).fold(0.0, f64::max);
(quads.into_iter().flatten().collect(), margin)
}
b"Ink" => {
let strokes = d.get(b"InkList")?.as_array()?;
let count = strokes.iter().try_fold(0usize, |sum, s| sum.checked_add(s.as_array()?.len()))?;
if count / 2 > MAX_REACH_POINTS {
return None;
}
let mut points = Vec::with_capacity(count.saturating_mul(3));
for s in strokes {
let stroke: Vec<f64> = s.as_array()?.iter().map(Object::as_f64).collect::<Option<_>>()?;
// A stroke's odd last number is no point (`draw_ink` skips it too).
let pts: Vec<(f64, f64)> = stroke.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect();
points.extend(pts.iter().flat_map(|p| [p.0, p.1]));
// The smooth curve through three or more points (`smooth_segments`) can bulge
// past them: its turning points count too.
if pts.len() > 2 {
let mut start = pts.first().copied().unwrap_or_default();
for [c1, c2, end] in smooth_segments(&pts) {
points.extend(cubic_extremes(start, c1, c2, end).iter().flat_map(|p| [p.0, p.1]));
start = end;
}
}
}
(points, w / 2.0)
}
subtype @ (b"PolyLine" | b"Polygon") => {
let count = d.get(b"Vertices")?.as_array()?.len();
if count < 4 || count % 2 != 0 || count / 2 > MAX_REACH_POINTS {
return None;
}
let vertices = nums(d, b"Vertices")?;
// A cloud's bumps (`draw_polygon`) reach as far past the outline as `cloud_reach` says.
let reach = match (subtype, cloud_intensity(d)) {
(b"Polygon", Some(intensity)) => {
let pts: Vec<(f64, f64)> = vertices.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect();
let perimeter: f64 = pts.iter().zip(pts.iter().cycle().skip(1)).map(|(a, b)| (b.0 - a.0).hypot(b.1 - a.1)).sum();
cloud_reach(if perimeter.is_finite() { perimeter } else { 0.0 }, cloud_bump(w, intensity))
}
(b"PolyLine", _) if has_ending() => arrow_size(w),
_ => 0.0,
};
(vertices, w / 2.0 + reach)
}
b"Line" => {
let [x0, y0, x1, y1] = <[f64; 4]>::try_from(nums(d, b"L")?).ok()?;
if ![x0, y0, x1, y1].iter().all(|v| v.is_finite()) {
return None;
}
// Leader lines move the line off `/L` (§12.5.6.7, Table 178).
let layout = line_layout(d, [(x0, y0), (x1, y1)], w);
let mut points = vec![x0, y0, x1, y1];
for (x, y) in layout.line.iter().chain(layout.leaders.iter().flatten()).chain(layout.tick.iter().flatten()) {
points.extend([*x, *y]);
}
// The caption's box: its width along the text matrix, from the descender to the
// cap height and a little over (Helvetica, as `draw_line` sets it).
if let Some((text, m)) = &layout.caption {
let width = text_width(text, CAPTION_SIZE);
for (x, y) in [(0.0, -0.25), (width, -0.25), (0.0, 1.0), (width, 1.0)].map(|(x, y)| (x, y * CAPTION_SIZE)) {
points.extend([m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]]);
}
}
(points, w / 2.0 + if has_ending() { arrow_size(w) } else { 0.0 })
}
_ => return None,
};
(points.iter().all(|v| v.is_finite()) && margin.is_finite()).then_some((points, margin))
}
/// An annotation's `/Rect` (normalised, `rect`) grown to take in the points it is drawn through
/// ([`drawn_points`]) where they reach past it, as far as the page's visible region `page` goes;
/// `None` when they don't, aren't usable or number more than the caps allow, and for subtypes
/// drawn within `/Rect` ([`is_drawn_from_points`]). The points are what the annotation draws (ISO
/// 32000-2 Table 182 `/QuadPoints`, Table 178 `/L`, Table 181 `/Vertices`, Table 185 `/InkList`),
/// and the appearance is mapped onto `/Rect` (§12.5.5), so a `/Rect` that leaves them out would
/// cut off what is drawn: a highlight or ink stroke whose `/Rect` lies beside it drew nothing.
/// Once a point is outside, the box takes in the strokes' width and line endings too. Nothing
/// outside the crop box is shown or printed (§14.11.2), so the box grows no further than it; it
/// never shrinks `rect`, and a `/Rect` that holds every point is kept as it is.
fn point_reach(doc: &Document, d: &Dict, rect: [f64; 4], page: [f64; 4]) -> Option<[f64; 4]> {
if !rect.iter().all(|v| v.is_finite()) {
return None;
}
let (points, margin) = drawn_points(doc, d)?;
let mut reach = [f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY];
for p in points.as_chunks::<2>().0 {
reach = [reach[0].min(p[0]), reach[1].min(p[1]), reach[2].max(p[0]), reach[3].max(p[1])];
}
let clip = |b: [f64; 4]| [b[0].max(page[0]), b[1].max(page[1]), b[2].min(page[2]), b[3].min(page[3])];
let shown = clip(reach);
// Strokes wholly off the page widen nothing. Points `/Rect` already holds don't either, and
// points off the page whose strokes reach onto it only as far as `/Rect` holds.
let stroked = clip([reach[0] - margin, reach[1] - margin, reach[2] + margin, reach[3] + margin]);
if stroked[0] > stroked[2] || stroked[1] > stroked[3] {
return None;
}
let inside = |b: [f64; 4]| rect[0] <= b[0] && rect[1] <= b[1] && b[2] <= rect[2] && b[3] <= rect[3];
let on_page = shown[0] <= shown[2] && shown[1] <= shown[3];
if inside(if on_page { shown } else { stroked }) {
return None;
}
let reach = stroked;
let out = [rect[0].min(reach[0]), rect[1].min(reach[1]), rect[2].max(reach[2]), rect[3].max(reach[3])];
// A `/Rect` of its own can still be too wide for a width or height (`-1e308` to `1e308`).
let usable = (out[2] - out[0]).is_finite() && (out[3] - out[1]).is_finite();
(usable && out != rect).then_some(out)
}
/// The `/Rect` an annotation takes with its built appearance `stream`: the appearance's `/BBox`
/// where [`point_reach`] widened it past `/Rect`, as the appearance is mapped onto `/Rect`
/// (§12.5.5); `None` (keep `/Rect`) otherwise and for subtypes drawn within `/Rect`.
pub(crate) fn widened_rect(d: &Dict, stream: &Stream) -> Option<[f64; 4]> {
if !is_drawn_from_points(d) {
return None;
}
let bbox = <[f64; 4]>::try_from(nums(&stream.dict, b"BBox")?).ok()?;
(bbox.iter().all(|v| v.is_finite()) && Some(bbox) != normalised_rect(d)).then_some(bbox)
}
fn draw_body(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> {
match context.subtype {
b"Highlight" | b"Underline" | b"StrikeOut" | b"Squiggly" => draw_markup(context, drawing),
@@ -574,6 +915,30 @@ fn finish_appearance(context: &AppearanceContext<'_>, mut drawing: AppearanceDra
Some(form(context.rect, &out, drawing.resources))
}
/// The line width of an underline or strike-out over a quad `h` high.
fn markup_line_width(h: f64) -> f64 {
(h * 0.07).clamp(0.5, 3.0)
}
/// A squiggle's line width and amplitude over a quad `h` high: its peaks stand twice the
/// amplitude above the quad's bottom edge.
fn squiggle(h: f64) -> (f64, f64) {
((h * 0.05).clamp(0.5, 2.0), (h * 0.06).max(0.75))
}
/// How far a text markup of `subtype` drawn over a quad `h` high reaches past the quad: half its
/// line's width, and for a squiggle, how far its peaks stand above a quad lower than they are.
fn markup_overreach(subtype: &[u8], h: f64) -> f64 {
match subtype {
b"Underline" | b"StrikeOut" => markup_line_width(h) / 2.0,
b"Squiggly" => {
let (lw, amp) = squiggle(h);
lw / 2.0 + (amp * 2.0 - h).max(0.0)
}
_ => 0.0,
}
}
fn draw_markup(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> {
let subtype = context.subtype;
let stroke = context.stroke;
@@ -592,15 +957,14 @@ fn draw_markup(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing)
c.push_str(&format!("{} {} m {} {} l {} {} l {} {} l h f\n", n(p1.0), n(p1.1), n(p2.0), n(p2.1), n(p4.0), n(p4.1), n(p3.0), n(p3.1)));
}
b"Underline" | b"StrikeOut" => {
let lw = (h * 0.07).clamp(0.5, 3.0);
let lw = markup_line_width(h);
let t = if subtype == b"Underline" { 0.08 } else { 0.42 };
let (a, b) = (at(p3, p1, t), at(p4, p2, t));
c.push_str(&format!("{}{} w\n{} {} m {} {} l S\n", rg_stroke(col), n(lw), n(a.0), n(a.1), n(b.0), n(b.1)));
}
_ => {
// Squiggly: a zigzag along the bottom of the quad.
let lw = (h * 0.05).clamp(0.5, 2.0);
let amp = (h * 0.06).max(0.75);
let (lw, amp) = squiggle(h);
let len = (p4.0 - p3.0).hypot(p4.1 - p3.1);
let step = (h / 4.0).max(1.5);
let steps = ((len / step).ceil() as usize).clamp(1, 10_000);
@@ -621,11 +985,31 @@ fn draw_markup(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing)
}
fn draw_redaction(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> {
let d = context.dict;
let stroke = context.stroke;
let c = &mut drawing.content;
// While marked: the outline of each area (the fill comes when applied).
let q = quads(context)?;
let col = stroke.unwrap_or([0.89, 0.13, 0.13]);
// ISO 32000-2 gives a mark no outline colour (§12.5.6.23, Table 195). An array `/OC` of
// three numbers is the one some writers give it, and is drawn (the observation is in
// `parity/acrobat-features.toml`); `/OC` as ISO 32000-2 defines it (Table 166) is an
// optional-content dictionary, which this leaves alone.
let outline = nums(d, b"OC").and_then(|c| match c.as_slice() {
[r, g, b] if c.iter().all(|x| x.is_finite()) => Some([r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0)]),
_ => None,
});
// Quadrilaterals are outlined in the `/OC` colour, else in `/C` as they always were.
let (q, col) = match d.get(b"QuadPoints") {
Some(_) => (quads(context)?, outline.or(stroke)),
// Without `/QuadPoints`, `/Rect` is the area (Table 195): outlined just inside its
// edges when the mark names an outline colour, else not drawn while marked.
None => {
let outline = outline?;
let rect = context.rect;
let [x0, y0, x1, y1] = [rect[0] + 0.5, rect[1] + 0.5, rect[2] - 0.5, rect[3] - 0.5];
(vec![[x0, y1, x1, y1, x0, y0, x1, y0]], Some(outline))
}
};
let col = col.unwrap_or([0.89, 0.13, 0.13]);
c.push_str(&format!("{}1 w\n", rg_stroke(col)));
for quad in q.iter() {
c.push_str(&format!(
@@ -654,15 +1038,27 @@ fn draw_box(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) ->
if stroke.is_none() && fill.is_none() {
return Some(());
}
// A plain square or circle lies within /Rect less /RD (Table 180), its stroke centred
// half the width inside that box.
let shape = rect_less_rd(d, rect).unwrap_or(rect);
let inset = w / 2.0;
let [x0, y0, x1, y1] = [rect[0] + inset, rect[1] + inset, rect[2] - inset, rect[3] - inset];
let [x0, y0, x1, y1] = [shape[0] + inset, shape[1] + inset, shape[2] - inset, shape[3] - inset];
if let Some(f) = fill {
c.push_str(&rg(f));
}
if let Some(s) = stroke {
c.push_str(&format!("{}{} w\n{}", rg_stroke(s), n(w), dash(d)));
}
if subtype == b"Square" {
if let Some(intensity) = context.cloud {
let bump = cloud_bump(w, intensity);
let inner = cloud_inner(d, rect, bump, w)?;
let pts = if subtype == b"Square" {
vec![(inner[0], inner[1]), (inner[2], inner[1]), (inner[2], inner[3]), (inner[0], inner[3])]
} else {
ellipse_points(inner, bump)
};
c.push_str(&format!("1 j\n{}", cloud_path(&pts, bump)));
} else if subtype == b"Square" {
c.push_str(&format!("{} {} {} {} re\n", n(x0), n(y0), n(x1 - x0), n(y1 - y0)));
} else {
c.push_str(&ellipse(x0, y0, x1, y1));
@@ -723,7 +1119,6 @@ fn draw_polygon(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing
let subtype = context.subtype;
let stroke = context.stroke;
let w = context.width;
let cloudy = context.cloudy;
let c = &mut drawing.content;
let v = nums(d, b"Vertices").filter(|v| v.len() >= 4 && v.len() % 2 == 0 && v.iter().all(|x| x.is_finite()))?;
let pts: Vec<(f64, f64)> = v.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect();
@@ -751,9 +1146,8 @@ fn draw_polygon(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing
if let Some(s) = col {
c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(s), n(w), dash(d)));
}
if cloudy {
let intensity = d.get(b"BE").and_then(|b| b.as_dict()).and_then(|b| b.get(b"I")).and_then(|i| i.as_f64()).unwrap_or(1.0);
c.push_str(&cloud_path(&pts, cloud_radius(w) * intensity.clamp(0.5, 2.0)));
if let Some(intensity) = context.cloud {
c.push_str(&cloud_path(&pts, cloud_bump(w, intensity)));
} else {
for (i, p) in pts.iter().enumerate() {
c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" }));
@@ -870,7 +1264,8 @@ fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -
return Some(form(rect, c.as_bytes(), res));
}
// A custom stamp: its picture (an image, or a form mapped to /PCPictureSize) fills
// the rectangle. An image signature added to a turned page (`/PCPictureRotate`) gets
// the rectangle. An image signature or a stamp with a line added to a turned page
// (`/PCPictureRotate`) gets
// the standard counter-rotation as its form `/Matrix`: viewers fit the turned
// bounding box to `/Rect` (ISO 32000-2 §12.5.5), so it reads upright as displayed.
if let Some(pic) = d.get(b"PCPicture").and_then(Object::as_ref) {
@@ -879,14 +1274,61 @@ fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -
[w, h] if *w > 0.0 && *h > 0.0 => Some((*w, *h)),
_ => None,
});
let place = picture_place(rect, image, size)?;
// A text line (#1741) takes the bottom of the rectangle, and the picture keeps its
// shape, centred above it. Without one the picture fills the rectangle.
let line = d.get(b"PCByLine").and_then(Object::as_string).map(PdfString::to_text).filter(|l| !l.trim().is_empty());
let turn = picture_turn(d);
// A lined stamp on a quarter-turned page is laid out in its displayed box (the
// rectangle with its sides swapped) and turned into `/Rect` by the `/Matrix` below.
// Laid out in user space, the viewer's fit of the turned box to `/Rect` would scale
// the two displayed axes differently and squash the picture and its line.
let bbox = if line.is_some() && matches!(turn, 90 | 270) { [0.0, 0.0, rect[3] - rect[1], rect[2] - rect[0]] } else { rect };
let [x0, y0, x1, y1] = bbox;
let band = if line.is_some() { (y1 - y0) * crate::CUSTOM_LINE_BAND } else { 0.0 };
let (w, h) = (x1 - x0, y1 - y0 - band);
let place = match (&line, size) {
(Some(_), Some((sw, sh))) => {
let k = (w / sw).min(h / sh).max(0.0);
let (pw, ph) = (sw * k, sh * k);
let (px, py) = (x0 + (w - pw) / 2.0, y0 + band + (h - ph) / 2.0);
if image {
format!("{} 0 0 {} {} {} cm", n(pw), n(ph), n(px), n(py))
} else {
format!("{} 0 0 {} {} {} cm", n(k), n(k), n(px), n(py))
}
}
// Without a line the band is empty and this is the rectangle itself.
_ => picture_place([x0, y0 + band, x1, y1], image, size)?,
};
c.push_str(&format!("q {place} /Pic Do Q\n"));
// Bytes from here: the line is WinAnsi, not UTF-8.
let mut content = std::mem::take(c).into_bytes();
if let Some(l) = &line {
// Centred in the band, at its usual size unless the stamp was made narrower.
let mut size = band * crate::CUSTOM_LINE_TEXT;
let tw = text_width(l, size);
if tw > w - 4.0 && tw > 0.0 {
size *= ((w - 4.0) / tw).max(0.0);
}
let tw = text_width(l, size);
let ty = y0 + (band - size * 0.72) / 2.0;
content.extend(format!("BT\n0.2 0.2 0.2 rg\n/Helv {} Tf\n1 0 0 1 {} {} Tm ", n(size), n(x0 + (w - tw) / 2.0), n(ty)).bytes());
content.extend(literal(&win_ansi(l)));
content.extend_from_slice(b" Tj\nET\n");
let mut 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"));
f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
let mut fonts = Dict::new();
fonts.set(b"Helv".to_vec(), Object::Dict(f));
res.set(b"Font".to_vec(), Object::Dict(fonts));
}
let mut xo = Dict::new();
xo.set(b"Pic".to_vec(), Object::Ref(pic));
res.set(b"XObject".to_vec(), Object::Dict(xo));
let mut stream = form(rect, c.as_bytes(), res);
let turn = picture_turn(d);
if image && turn != 0 {
let mut stream = form(bbox, &content, res);
if (image || line.is_some()) && turn != 0 {
// The page's view matrix without its offset: display axes in user space.
let m = pdfcraft_model::view_matrix_for(turn, [0.0; 4]);
let matrix = [m[0], m[1], m[2], m[3], 0.0, 0.0];
@@ -999,7 +1441,7 @@ fn shaped_border(
Some(rect)
}
fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -> Option<Stream> {
fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing, dr_face: Option<pdfcraft_fonts::Std14>) -> Option<Stream> {
let d = context.dict;
let parts = context.parts;
let rect = context.rect;
@@ -1007,11 +1449,12 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin
let mut c = std::mem::take(&mut drawing.content);
let mut res = std::mem::take(&mut drawing.resources);
let _tail = std::mem::take(&mut drawing.tail);
let (text_color, size) = parse_da(d);
let crate::text_style::TextStyle { face, size, color: text_color } = crate::text_style::text_style(d, dr_face);
// The box's border and a callout's leader line: their own colour, else the text's.
let line_color = da_border(d).unwrap_or(text_color);
let bg = stroke;
let bw = if d.contains(b"BS") || d.contains(b"Border") { border_width(d) } else { 0.0 };
// Without `/BS` or `/Border`, the `/Border` default [0 0 1]: a 1-pt border (Table 166).
let bw = border_width(d);
// 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
@@ -1031,7 +1474,11 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin
let mut rect = rect;
if d.contains(b"CL") {
let cl = nums(d, b"CL").filter(|l| l.len() == 4 || l.len() == 6)?;
let rd = nums(d, b"RD").filter(|r| r.len() == 4 && r.iter().all(|x| *x >= 0.0))?;
// `/RD` is optional (Table 177; null is absent, §7.3.9): without it the text box is `/Rect`.
let rd = match d.get(b"RD") {
None | Some(Object::Null) => vec![0.0; 4],
Some(_) => nums(d, b"RD").filter(|r| r.len() == 4 && r.iter().all(|x| *x >= 0.0))?,
};
rect = [full[0] + rd[0], full[1] + rd[1], full[2] - rd[2], full[3] - rd[3]];
if rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 {
return None;
@@ -1061,7 +1508,20 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin
// `/RD` and keeps its place: background and border take the shape, the text is laid out
// in the text box as in a rectangle.
let shape = crate::BorderShape::of(d);
if shape != crate::BorderShape::Rectangle {
// A cloudy box (not a callout): its background and border follow the cloud around
// the text, which keeps to the cloud's inner rectangle.
if let Some(intensity) = context.cloud.filter(|_| !d.contains(b"CL")) {
let cw = if bw > 0.0 { bw } else { 1.0 };
let bump = cloud_bump(cw, intensity);
let inner = cloud_inner(d, rect, bump, cw)?;
let pts = [(inner[0], inner[1]), (inner[2], inner[1]), (inner[2], inner[3]), (inner[0], inner[3])];
c.push_str(&format!("{}{}{} w 1 j\n{}", rg_stroke(line_color), rg(bg.unwrap_or([1.0; 3])), n(cw), dash(d)));
c.push_str(&cloud_path(&pts, bump));
c.push_str(if bg.is_some() { "B\n[] 0 d\n" } else { "S\n[] 0 d\n" });
rect = inner;
} else if context.cloud.is_some() {
return None;
} else if shape != crate::BorderShape::Rectangle {
rect = shaped_border(d, shape, full, turn, bw, bg, line_color, &mut c)?;
} else {
if let Some(bg) = bg {
@@ -1092,37 +1552,42 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin
c.push_str("/GS1 gs\n");
}
c.push_str(&format!(
"{} {} {} {} re W n\nBT\n/Helv {} Tf\n{}",
"{} {} {} {} re W n\nBT\n/{} {} Tf\n{}",
n(rect[0]),
n(rect[1]),
n(rect[2] - rect[0]),
n(rect[3] - rect[1]),
crate::text_style::resource_name(face),
n(size),
rg(text_color)
));
let mut out = c.into_bytes();
let lines = wrap(&text, size, width);
let mut y = rect[3] - pad - size * 0.9;
let lines = pdfcraft_fonts::wrap_with(&text, size, width, |s, size| text_width_in(face, s, size));
let mut y = rect[3] - pad - size * reach(&lines)[0];
if round {
y -= ((rect[3] - rect[1] - 2.0 * pad - lines.len() as f64 * size * 1.2) / 2.0).max(0.0);
y -= ((rect[3] - rect[1] - 2.0 * pad - text_lines_height(&lines, size)) / 2.0).max(0.0);
}
for line in lines {
// Thai is shaped and drawn from the Thai face (Type 3 fonts in the form's resources, whose
// streams `apply_appearance` stores in the document); the rest, and Thai it can't draw (as
// `?`, which `undrawable_text` reports), in the box's standard face.
let mut fonts = font_resources(face);
let (pieces, shaped) = shaped_text::appearance_lines(&lines, &mut fonts);
let mut current = None;
for pieces in &pieces {
if y < rect[1] - size {
break;
}
let lw = text_width(&line, size);
let lw = shaped_text::pieces_width(pieces, size, 0.0, |s, size| face.text_width(s, size));
let x = match q {
1 => rect[0] + pad + (width - lw) / 2.0,
2 => rect[2] - pad - lw,
_ => rect[0] + pad,
};
out.extend(format!("1 0 0 1 {} {} Tm ", n(x), n(y)).bytes());
out.extend(literal(&win_ansi(&line)));
out.extend_from_slice(b" Tj\n");
shaped.show_line(&mut out, pieces, x, y, size, crate::text_style::resource_name(face), win_ansi, |s| face.text_width(s, size), &mut current);
y -= size * 1.2;
}
out.extend_from_slice(b"ET\n");
res.set(b"Font".to_vec(), helvetica_resource());
res.set(b"Font".to_vec(), Object::Dict(fonts));
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()));
+169 -17
View File
@@ -1,6 +1,6 @@
use super::draw::draw_appearance;
use super::lookup::{annot_dict, annot_ref, annots, fill_sign_shape, page_ref, set_annots};
use super::modify::{free_text_da, n, rich_text, set_part_keys, shape_text_box};
use super::modify::{free_text_da, n, refuse_undrawable_thai, rich_text, set_part_keys, shape_text_box};
use super::types::{
FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, OverlayLook, Rgb, Shape, StampGroup, Style,
};
@@ -103,12 +103,8 @@ pub(crate) fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotEr
}
grow(ink_bounds(strokes).unwrap_or_default(), half + 1.0)
}
Shape::Polygon { vertices, cloud } => {
let b = bounds(vertices.iter().copied())
.filter(|b| vertices.len() >= 3 && vertices.iter().all(|p| finite(p)) && b[2] - b[0] >= 1.0 && b[3] - b[1] >= 1.0);
let b = b.ok_or_else(|| bad("polygon (it needs three points)"))?;
grow(b, half + 1.0 + if *cloud { 1.5 * appearance::cloud_radius(style.width) } else { 0.0 })
}
// A new cloud has intensity 1 (`/BE /I 1`).
Shape::Polygon { vertices, cloud } => polygon_rect(vertices, style.width, cloud.then_some(1.0))?,
Shape::PolyLine { vertices, start, end } => {
let b = bounds(vertices.iter().copied())
.filter(|b| vertices.len() >= 2 && vertices.iter().all(|p| finite(p)) && (b[2] - b[0]).max(b[3] - b[1]) >= 1.0);
@@ -135,6 +131,19 @@ pub(crate) fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotEr
Ok(r)
}
/// Validate a polygon's corners and compute its `/Rect`: room for half the line, and with a
/// cloud of `cloud` intensity, for the farthest its bumps reach (`appearance::cloud_reach`).
pub(crate) fn polygon_rect(vertices: &[[f64; 2]], width: f64, cloud: Option<f64>) -> Result<[f64; 4], AnnotError> {
let b = bounds(vertices.iter().copied())
.filter(|b| vertices.len() >= 3 && vertices.iter().all(|p| finite(p)) && b[2] - b[0] >= 1.0 && b[3] - b[1] >= 1.0);
let b = b.ok_or_else(|| AnnotError::Invalid("invalid polygon (it needs three points)".into()))?;
let reach = cloud.map_or(0.0, |intensity| {
let perimeter: f64 = vertices.iter().zip(vertices.iter().cycle().skip(1)).map(|(a, b)| (b[0] - a[0]).hypot(b[1] - a[1])).sum();
appearance::cloud_reach(perimeter, appearance::cloud_bump(width, intensity))
});
Ok(grow(b, width.max(0.0) / 2.0 + 1.0 + reach))
}
pub(crate) fn base_dict(subtype: &str, rect: [f64; 4], page: ObjRef, contents: &str, author: &str, meta: &Meta) -> Dict {
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("Annot"));
@@ -195,14 +204,110 @@ fn subject(shape: &Shape) -> &'static str {
pub fn callout_attach(rect: [f64; 4], knee: [f64; 2]) -> [f64; 2] {
let [x0, y0, x1, y1] = normalize(rect);
let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
if knee[0] < x0 {
[x0, cy]
} else if knee[0] > x1 {
[x1, cy]
} else if knee[1] > y1 {
[cx, y1]
match callout_side(rect, knee) {
Side::Left => [x0, cy],
Side::Right => [x1, cy],
Side::Top => [cx, y1],
Side::Bottom => [cx, y0],
}
}
/// A side of a callout's text box.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Side {
Left,
Right,
Top,
Bottom,
}
impl Side {
/// Whether a knee line on this side is horizontal (it meets the left or right side). Acrobat
/// lets a knee line be resized along its own direction only (Acrobat DC Help, 11/28/2019,
/// p. 357).
pub fn horizontal(self) -> bool {
matches!(self, Side::Left | Side::Right)
}
}
/// The side of the text box `rect` that faces `knee`: the one [`callout_attach`] attaches to.
///
/// Every position has one side (review on #4094):
/// - left or right of the box (`x < x0`, `x > x1`): that side, whatever `y` is, so a knee off a
/// corner diagonally, or on the line of the top or bottom edge past the box, is beside it;
/// - otherwise above or below it (`y > y1`, `y < y0`): that side, so a knee on the line of the
/// left or right edge past the box is above or below it;
/// - on the box's outline, or within `CALLOUT_SLACK` (0.01 pt) inside it, since the page hands
/// positions back as `f32` values: the side whose edge it lies on, the left or right edge
/// first, so a corner takes the left or right side as a knee beside the box does. The line
/// stays out of the box (it was sent across the box to the bottom side);
/// - inside the box, or with a coordinate that isn't a number: the bottom side.
pub fn callout_side(rect: [f64; 4], knee: [f64; 2]) -> Side {
let [x0, y0, x1, y1] = normalize(rect);
let [x, y] = knee;
// Before any comparison: one with NaN is false, so the other coordinate alone would decide.
if x.is_nan() || y.is_nan() {
return Side::Bottom;
}
if x < x0 {
Side::Left
} else if x > x1 {
Side::Right
} else if y > y1 {
Side::Top
} else if y < y0 {
Side::Bottom
} else if x - x0 < CALLOUT_SLACK {
Side::Left
} else if x1 - x < CALLOUT_SLACK {
Side::Right
} else if y1 - y < CALLOUT_SLACK {
Side::Top
} else {
[cx, y0]
Side::Bottom
}
}
/// A callout's knee line when Acrobat lays it out: 12 pt, out from the middle of the side. Seen in
/// Acrobat Pro DC, a 2021 Continuous release: a new callout's knee line, and one laid out again
/// after its knee was dragged into the box or the side it meets was moved, are all 12 pt, and the
/// knee can't come nearer the box than that.
pub const CALLOUT_KNEE_LINE: f64 = 12.0;
/// Where a knee goes on `side` of the text box `rect`: [`CALLOUT_KNEE_LINE`] out from the middle
/// of that side, so the knee line is level (left, right) or upright (top, bottom).
pub fn callout_knee_on(rect: [f64; 4], side: Side) -> [f64; 2] {
let [x0, y0, x1, y1] = normalize(rect);
let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
match side {
Side::Left => [x0 - CALLOUT_KNEE_LINE, cy],
Side::Right => [x1 + CALLOUT_KNEE_LINE, cy],
Side::Top => [cx, y1 + CALLOUT_KNEE_LINE],
Side::Bottom => [cx, y0 - CALLOUT_KNEE_LINE],
}
}
/// A new callout's knee: on the side of its text box `rect` that faces the arrow `point`
/// ([`callout_knee_on`]).
pub fn callout_knee(rect: [f64; 4], point: [f64; 2]) -> [f64; 2] {
callout_knee_on(rect, callout_side(rect, point))
}
/// How far apart, in points, two positions of a callout's box or line may be and still count as the
/// same. The page hands a box back as `f32` values, which moves a coordinate such as 323.05 by a few
/// hundred-thousandths of a point (review on #4094).
pub(crate) const CALLOUT_SLACK: f64 = 0.01;
/// Whether `end` lies on `side` of the text box `rect`: the leader line can stay where it meets it.
pub(crate) fn on_side(rect: [f64; 4], side: Side, end: [f64; 2]) -> bool {
let [x0, y0, x1, y1] = normalize(rect);
let near = |a: f64, b: f64| (a - b).abs() < CALLOUT_SLACK;
let within = |v: f64, lo: f64, hi: f64| v > lo - CALLOUT_SLACK && v < hi + CALLOUT_SLACK;
match side {
Side::Left => near(end[0], x0) && within(end[1], y0, y1),
Side::Right => near(end[0], x1) && within(end[1], y0, y1),
Side::Top => near(end[1], y1) && within(end[0], x0, x1),
Side::Bottom => near(end[1], y0) && within(end[0], x0, x1),
}
}
@@ -287,15 +392,20 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
let list = contours.iter().filter(|c| c.len() > 2).map(|c| num_array(&c.iter().flat_map(|p| [p[0], p[1]]).collect::<Vec<_>>())).collect();
d.set(b"PCOutline".to_vec(), Object::Array(list));
}
Shape::CustomStamp { name, picture, image, size, .. } => {
Shape::CustomStamp { name, picture, image, size, line, .. } => {
let clean: String = name.chars().filter(char::is_ascii_alphanumeric).collect();
d.set(b"Name".to_vec(), Object::name(&format!("PCCustom{clean}")));
d.set(b"PCPicture".to_vec(), Object::Ref(*picture));
d.set(b"PCPictureImage".to_vec(), Object::Bool(*image));
d.set(b"PCPictureSize".to_vec(), num_array(&[size.0, size.1]));
let lined = line.as_deref().map(str::trim).filter(|l| !l.is_empty());
if let Some(l) = lined {
d.set(b"PCByLine".to_vec(), PdfString::text(l));
}
// An image signature's rectangle is in user space; on a turned page its appearance is
// turned back (see `appearance::build`), so it reads upright as displayed.
if *image && matches!(clean.as_str(), "Signature" | "Initials") {
// turned back (see `appearance::build`), so it reads upright as displayed. A stamp
// with a line is laid out in the displayed box, so it needs the turn too.
if lined.is_some() || (*image && matches!(clean.as_str(), "Signature" | "Initials")) {
let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc));
if turn != 0 {
d.set(b"PCPictureRotate".to_vec(), Object::Int(turn));
@@ -398,6 +508,7 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
if matches!(turn, 90 | 180 | 270) {
d.set(b"PCTextRotate".to_vec(), Object::Int(turn));
}
let rect = fit_thai_ink(rect, turn, &new.contents, size, 2.0 + appearance::border_width(&d));
// An oval or a circle goes around the text box, its text wrapped as displayed:
// after `/PCTextRotate`, which swaps the box's sides on a quarter-turned page.
shape_text_box(&mut d, rect, style.border_shape, style.width);
@@ -417,6 +528,7 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
{
return Err(AnnotError::Undrawable(pdfcraft_fonts::undrawable_message(what, c)));
}
refuse_undrawable_thai(d.name(b"Subtype") == Some(b"FreeText"), &new.contents)?;
let r = doc.add(Object::Dict(d.clone()));
draw_appearance(doc, r)?;
let mut list = annots(doc, page);
@@ -427,6 +539,30 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
Ok(index)
}
/// Text box `tb` (user space; its text upright on a page turned by `turn`), grown down as displayed
/// until the ink of `text`'s Thai (stacked marks above its first line, descenders below its last)
/// fits inside the padding `pad`, as the appearance lays it out at `size`. Other text, and a box
/// tall enough already, keep the box they were given.
fn fit_thai_ink(tb: [f64; 4], turn: i64, text: &str, size: f64, pad: f64) -> [f64; 4] {
if !text.chars().any(pdfcraft_fonts::shaped_text::is_thai) {
return tb;
}
let [x0, y0, x1, y1] = normalize(tb);
let (w, h) = if matches!(turn, 90 | 270) { (y1 - y0, x1 - x0) } else { (x1 - x0, y1 - y0) };
let need = appearance::text_lines_height(&appearance::wrap(text, size, (w - 2.0 * pad).max(1.0)), size) + 2.0 * pad + 2.0;
let by = need - h;
if !(by.is_finite() && by > 0.0) {
return tb;
}
// Displayed down is user -y unturned, +x on a page turned 90°, +y at 180° and -x at 270°.
match turn {
90 => [x0, y0, x1 + by, y1],
180 => [x0, y0, x1, y1 + by],
270 => [x0 - by, y0, x1, y1],
_ => [x0, y0 - by, x1, y1],
}
}
fn validate_style(style: &Style) -> Result<(), AnnotError> {
if !finite(&style.color) || style.border_color.is_some_and(|c| !finite(&c)) || !style.opacity.is_finite() || !style.width.is_finite() {
return Err(AnnotError::Invalid("invalid style".into()));
@@ -465,6 +601,22 @@ fn add_popup(doc: &mut Document, parent: ObjRef, rect: Option<[f64; 4]>, annots:
Ok(())
}
/// The share of a custom stamp's height its text line takes (the rest is the picture).
pub const CUSTOM_LINE_BAND: f64 = 0.22;
/// A custom stamp line's text height as a share of its band.
pub const CUSTOM_LINE_TEXT: f64 = 0.62;
/// The natural size of a custom stamp whose picture is `picture` points (#1741): with `line`,
/// the band under the picture is added, and the stamp is as wide as the line at its usual size
/// when that is wider than the picture.
pub fn custom_stamp_size(picture: (f64, f64), line: Option<&str>) -> (f64, f64) {
let Some(line) = line else { return picture };
let h = picture.1 / (1.0 - CUSTOM_LINE_BAND);
let text = appearance::text_width(line, h * CUSTOM_LINE_BAND * CUSTOM_LINE_TEXT) + 8.0;
(picture.0.max(text), h)
}
/// Counterrotate a newly placed picture stamp's appearance into displayed-page axes.
/// The picture may be an image or a PDF page. The normal appearance's transformed bounding
/// box is fitted to `/Rect` by PDF viewers.
+254 -29
View File
@@ -33,14 +33,70 @@ pub(crate) fn redraw_or_restore(doc: &mut Document, r: ObjRef, before: Dict) ->
pub(crate) fn draw_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> {
let d = annot_dict(doc, r);
let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
let Some(stream) = build_appearance(doc, &d) else { return Err(AnnotError::Unsupported(subtype)) };
apply_appearance(doc, r, &d, stream)
let page = appearance::is_drawn_from_points(&d).then(|| page_box_listing(doc, r)).flatten();
let direct = with_direct_values(doc, &d);
let Some(stream) = build_appearance(doc, &d, &direct, page) else { return Err(AnnotError::Unsupported(subtype)) };
let built = Built::new(stream, &direct);
apply_appearance(doc, r, &d, built)
}
/// Draw the appearance for an annotation dictionary: the expensive half of [`set_appearance`].
/// Pure from the dictionary and the objects it points at, so [`AppearanceCache`] can reuse it.
fn build_appearance(doc: &Document, d: &Dict) -> Option<Stream> {
let mut stream = appearance::build(doc, d)?;
/// Whether [`set_appearance`] can draw the annotation `d`: read as the display reads it, with
/// references followed and the same fallback. Edits check this before changing anything.
pub(crate) fn can_draw(doc: &Document, d: &Dict) -> bool {
build_appearance(doc, d, &with_direct_values(doc, d), None).is_some()
}
/// The visible region (`Page::crop`) of the page that lists `r` among its annotations: the page
/// its `/P` names when that page lists it, read up its own `/Parent` chain, else the first page
/// of the tree that lists it. An annotation is listed by one page only (ISO 32000-2 §12.5.2), so
/// both find the same page, and the first reads one page instead of walking the whole tree for
/// every comment redrawn.
fn page_box_listing(doc: &Document, r: ObjRef) -> Option<[f64; 4]> {
let named = annot_dict(doc, r).reference(b"P").filter(|p| annots(doc, *p).iter().any(|a| a.as_ref() == Some(r)));
if let Some(page) = named {
return Some(page_with_inherited_boxes(doc, page).crop(doc));
}
pdfcraft_model::pages(doc).into_iter().find(|p| annots(doc, p.obj).iter().any(|a| a.as_ref() == Some(r))).map(|p| p.crop(doc))
}
/// The page `page` with the `/MediaBox` and `/CropBox` it inherits from its ancestors filled in
/// (ISO 32000-2 §7.7.3.4), enough for [`pdfcraft_model::Page::crop`]. Follows at most 64
/// `/Parent` links and stops at a loop.
fn page_with_inherited_boxes(doc: &Document, page: ObjRef) -> pdfcraft_model::Page {
let mut dict = doc.get(page).as_dict().cloned().unwrap_or_default();
let mut seen = std::collections::HashSet::from([page]);
let mut up = dict.reference(b"Parent");
while let Some(node) = up {
if seen.len() > 64 || !seen.insert(node) {
break;
}
let obj = doc.get(node);
let Some(d) = obj.as_dict() else { break };
for key in [&b"MediaBox"[..], b"CropBox"] {
if !dict.contains(key)
&& let Some(v) = d.get(key)
{
dict.set(key.to_vec(), v.clone());
}
}
up = d.reference(b"Parent");
}
pdfcraft_model::Page { obj: page, dict }
}
/// Draw the appearance for an annotation dictionary on a page whose visible region is `page`
/// (used by annotations drawn through points of their own, [`appearance::build_on_page`]): the
/// expensive half of [`set_appearance`]. `direct` is `d` with references followed
/// ([`with_direct_values`]). Pure from the dictionary, the objects it points at and the page, so
/// [`AppearanceCache`] can reuse it.
fn build_appearance(doc: &Document, d: &Dict, direct: &Dict, page: Option<[f64; 4]>) -> Option<Stream> {
// References followed first. Where that turns up something the builder doesn't draw (a
// cloudy `/BE` on a square), the drawing made without them stays, rather than none.
let mut stream = match appearance::build_on_page(doc, direct, page) {
Some(stream) => stream,
None if !std::ptr::eq(direct, d) => appearance::build_on_page(doc, d, page)?,
None => return None,
};
// Picture-stamp restyling must retain the placement's page-axis correction.
if d.get(b"PCPicture").is_some() {
let matrix = d.get(b"AP").and_then(|ap| {
@@ -56,10 +112,172 @@ fn build_appearance(doc: &Document, d: &Dict) -> Option<Stream> {
Some(stream)
}
/// Entries that link the annotation to other objects rather than describe how it looks: left as
/// references by [`with_direct_values`].
const LINK_KEYS: [&[u8]; 5] = [b"P", b"Popup", b"IRT", b"Parent", b"AP"];
/// How deep inside an entry [`with_direct_values`] follows references: `/InkList` is an array of
/// arrays, `/Border` may end in a dash array.
const INLINE_DEPTH: usize = 4;
/// Values [`with_direct_values`] copies out of referenced arrays and dictionaries, or walks
/// before finding one too large to copy, for one annotation, so a file can't have a large object
/// copied or scanned for every entry that points at it.
pub(crate) const INLINE_BUDGET: usize = 1 << 16;
/// Values copied or walked for all the annotations one display pass draws
/// ([`with_missing_appearances_cached`]), so a large object that many annotations point at is
/// not copied, or scanned and found too large, for each of them without bound. Past it,
/// references are left as they are.
pub(crate) const PASS_BUDGET: usize = 1 << 22;
/// `d` with the references among the values [`appearance::build`] reads followed. Any value may be
/// given by reference (ISO 32000-2 §7.3.10): a border style dictionary (`/BS 5 0 R`, Table 168), a
/// colour, `/RD`, `/CL`, a `/W` inside `/BS`. The builder reads only direct values (apart from
/// `/QuadPoints`, which it resolves itself), so it draws from this copy. The entries in
/// [`LINK_KEYS`] and streams stay references; past [`INLINE_DEPTH`] levels or [`INLINE_BUDGET`]
/// copied values a reference is left as it is, which the builder treats as unreadable. `d` itself
/// when nothing is a reference.
pub(crate) fn with_direct_values<'d>(doc: &Document, d: &'d Dict) -> std::borrow::Cow<'d, Dict> {
with_direct_values_within(doc, d, &mut INLINE_BUDGET.clone())
}
/// [`with_direct_values`], copying at most [`INLINE_BUDGET`] values and no more than `pass` has
/// left, which is reduced by what was copied.
pub(crate) fn with_direct_values_within<'d>(doc: &Document, d: &'d Dict, pass: &mut usize) -> std::borrow::Cow<'d, Dict> {
let allowed = INLINE_BUDGET.min(*pass);
let mut budget = allowed;
let mut out: Option<Dict> = None;
for (key, value) in d.iter() {
if LINK_KEYS.contains(&key.as_slice()) {
continue;
}
if let Some(direct) = direct_value(doc, value, INLINE_DEPTH, &mut budget) {
out.get_or_insert_with(|| d.clone()).set(key.clone(), direct);
}
}
*pass = pass.saturating_sub(allowed.saturating_sub(budget));
out.map_or(std::borrow::Cow::Borrowed(d), std::borrow::Cow::Owned)
}
/// The number of values in `o`, counting into its arrays and dictionaries (not through
/// references, which are charged when followed), or, past `limit`, `Err` with the values it
/// visited or queued before finding that out (at least one), so a rejected walk is charged too.
/// Walks at most `limit` values and holds no more than `limit` waiting.
fn weight(o: &Object, limit: usize) -> Result<usize, usize> {
let mut count = 0usize;
let mut stack = vec![o];
while let Some(o) = stack.pop() {
let spent = count.saturating_add(stack.len()).max(1);
count = match count.checked_add(1).filter(|c| *c <= limit) {
Some(c) => c,
None => return Err(spent),
};
let inner = match o {
Object::Array(items) => items.len(),
Object::Dict(entries) => entries.len(),
_ => continue,
};
// Every value waiting is counted later, so more than `limit` in all is over it already.
if count.saturating_add(stack.len()).saturating_add(inner) > limit {
return Err(spent.saturating_add(1));
}
match o {
Object::Array(items) => stack.extend(items.iter()),
Object::Dict(entries) => stack.extend(entries.iter().map(|(_, v)| v)),
_ => {}
}
}
Ok(count)
}
/// `o` with references followed `depth` levels into arrays and dictionaries, or `None` when there
/// is nothing to follow (or it may not be: a stream, an over-budget object, a chain of references).
fn direct_value(doc: &Document, o: &Object, depth: usize, budget: &mut usize) -> Option<Object> {
let held;
let (base, followed) = match o {
Object::Ref(_) => {
// A spent budget follows nothing more, not even to find out what is there.
if *budget == 0 {
return None;
}
held = doc.resolve(o);
if matches!(&*held, Object::Stream(_) | Object::Ref(_)) {
return None;
}
// Everything copied counts, inner arrays and dictionaries too, and so does the walk
// that finds an object too large: many references to one would otherwise each walk
// it again for free.
match weight(&held, *budget) {
Ok(n) => *budget = budget.checked_sub(n)?,
Err(spent) => {
*budget = budget.saturating_sub(spent);
return None;
}
}
(&*held, true)
}
other => (other, false),
};
let Some(next) = depth.checked_sub(1) else { return followed.then(|| base.clone()) };
match base {
Object::Array(items) => {
let mut changed: Option<Vec<Object>> = None;
for (i, item) in items.iter().enumerate() {
if let Some(direct) = direct_value(doc, item, next, budget) {
let copy = changed.get_or_insert_with(|| items.clone());
if let Some(slot) = copy.get_mut(i) {
*slot = direct;
}
}
}
changed.map(Object::Array).or_else(|| followed.then(|| base.clone()))
}
Object::Dict(entries) => {
let mut changed: Option<Dict> = None;
for (key, value) in entries.iter() {
if let Some(direct) = direct_value(doc, value, next, budget) {
changed.get_or_insert_with(|| entries.clone()).set(key.clone(), direct);
}
}
changed.map(Object::Dict).or_else(|| followed.then(|| base.clone()))
}
_ => followed.then(|| base.clone()),
}
}
/// A built appearance and what storing it needs from the dictionary it was drawn from, read
/// with references followed, so a cached one is stored without following them again.
#[derive(Clone, Debug)]
struct Built {
stream: Stream,
/// The first character the standard fonts can't draw (it came out as `?`).
missing: Option<char>,
/// A note's or attachment's `/Rect`, as written.
rect: Option<Vec<f64>>,
/// Points (/QuadPoints, /L, /Vertices, /InkList) past /Rect: its appearance covers them, so
/// /Rect does too.
markup_rect: Option<[f64; 4]>,
}
impl Built {
fn new(stream: Stream, direct: &Dict) -> Self {
let missing = appearance::undrawable_text(direct).map(|(_, c)| c);
let rect = matches!(direct.name(b"Subtype"), Some(b"Text" | b"FileAttachment"))
.then(|| direct.get(b"Rect")?.as_array()?.iter().map(|o| o.as_f64()).collect::<Option<Vec<f64>>>())
.flatten();
let markup_rect = appearance::widened_rect(direct, &stream);
Built { stream, missing, rect, markup_rect }
}
}
/// Store a built appearance on the annotation: the cheap half of [`set_appearance`].
fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, stream: Stream) -> Result<(), AnnotError> {
// Characters the standard fonts can't draw came out as `?`: mark the annotation with the first.
let missing = appearance::undrawable_text(d).map(|(_, c)| c);
fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, built: Built) -> Result<(), AnnotError> {
// `missing`: characters the standard fonts can't draw came out as `?`; the annotation is
// marked with the first.
let Built { mut stream, missing, rect, markup_rect } = built;
// A text box's Thai glyphs are streams of their own.
pdfcraft_fonts::shaped_text::store_streams(doc, &mut stream.dict);
let ap = doc.add(Object::Stream(stream));
// A copy (a shared /AP is left alone) that keeps unknown entries; the old down and rollover
// appearances would show the previous look on press or hover, so they go with the old /N.
@@ -70,17 +288,13 @@ fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, stream: Stream) ->
// A note icon has a fixed size (`NOTE_SIZE`); viewers draw it at the upper-left corner of `/Rect`.
// The appearance's box is mapped onto `/Rect`, so any other `/Rect` (a large one, or one with
// no width, as LaTeX `pdfcomment` writes) would stretch the icon or shrink it to nothing.
let icon_rect = matches!(d.name(b"Subtype"), Some(b"Text" | b"FileAttachment"))
.then(|| d.get(b"Rect")?.as_array()?.iter().map(|o| o.as_f64()).collect::<Option<Vec<f64>>>())
.flatten()
.filter(|q| q.len() == 4 && finite(q))
.map(|q| {
let (x, top) = (q[0].min(q[2]), q[1].max(q[3]));
[x, top - NOTE_SIZE, x + NOTE_SIZE, top]
});
let icon_rect = rect.filter(|q| q.len() == 4 && finite(q)).map(|q| {
let (x, top) = (q[0].min(q[2]), q[1].max(q[3]));
[x, top - NOTE_SIZE, x + NOTE_SIZE, top]
});
doc.update_dict(r, |d| {
d.set(b"AP".to_vec(), Object::Dict(apd));
if let Some(rect) = icon_rect {
if let Some(rect) = icon_rect.or(markup_rect) {
d.set(b"Rect".to_vec(), num_array(&rect));
}
d.remove(b"AS");
@@ -101,7 +315,9 @@ fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, stream: Stream) ->
/// refreshes) and keep it for content-only ones.
#[derive(Clone, Debug, Default)]
pub struct AppearanceCache {
entries: std::collections::HashMap<ObjRef, Stream>,
/// Each appearance with the page region it was drawn for (see [`appearance::build_on_page`]):
/// a changed crop box draws it again.
entries: std::collections::HashMap<ObjRef, (Option<[f64; 4]>, Built)>,
bytes: usize,
hits: u64,
builds: u64,
@@ -116,20 +332,21 @@ impl AppearanceCache {
*self = Self::default();
}
fn get(&mut self, r: ObjRef) -> Option<Stream> {
let stream = self.entries.get(&r)?.clone();
fn get(&mut self, r: ObjRef, crop: Option<[f64; 4]>) -> Option<Built> {
let built = self.entries.get(&r).filter(|(drawn_for, _)| *drawn_for == crop)?.1.clone();
self.hits += 1;
Some(stream)
Some(built)
}
fn insert(&mut self, r: ObjRef, stream: &Stream) {
fn insert(&mut self, r: ObjRef, crop: Option<[f64; 4]>, built: &Built) {
let stream = &built.stream;
self.builds += 1;
if self.entries.len() >= 1_000_000 {
self.clear();
}
if self.bytes.saturating_add(stream.raw.len()) <= Self::MAX_BYTES {
self.bytes += stream.raw.len();
self.entries.insert(r, stream.clone());
self.entries.insert(r, (crop, built.clone()));
}
// Else over budget: keep serving the entries already cached instead of growing.
}
@@ -163,6 +380,9 @@ pub fn with_missing_appearances_cached(doc: &Document, cache: &mut AppearanceCac
// reads `doc`, which never gets the appearance, so every repeat used to add another stream to
// the copy, and the display bytes grew with the number of listings.
let mut seen = std::collections::HashSet::new();
// Pages' visible regions, read once and only when an annotation drawn through its points is.
let mut crops: Option<std::collections::HashMap<ObjRef, [f64; 4]>> = None;
let mut budget = PASS_BUDGET;
for page in page_refs(doc).ok()? {
for r in annots(doc, page).iter().filter_map(Object::as_ref) {
if !seen.insert(r) {
@@ -176,9 +396,12 @@ pub fn with_missing_appearances_cached(doc: &Document, cache: &mut AppearanceCac
if has_normal {
continue;
}
if let Some(stream) = cache.get(r) {
let crop = appearance::is_drawn_from_points(&d)
.then(|| crops.get_or_insert_with(|| pdfcraft_model::pages(doc).iter().map(|p| (p.obj, p.crop(doc))).collect()).get(&page).copied())
.flatten();
if let Some(built) = cache.get(r, crop) {
// Fails only when `r` isn't a dictionary, which the cached build ruled out.
apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, stream).ok();
apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, built).ok();
continue;
}
// Another program's stamp gets a drawing for display only (ISO 32000-2 Table 184). Its
@@ -188,10 +411,12 @@ pub fn with_missing_appearances_cached(doc: &Document, cache: &mut AppearanceCac
bare.remove(b"AP");
appearance::display_stamp(&bare)
};
let Some(stream) = build_appearance(doc, &d).or_else(other_stamp) else { continue };
cache.insert(r, &stream);
let direct = with_direct_values_within(doc, &d, &mut budget);
let Some(stream) = build_appearance(doc, &d, &direct, crop).or_else(other_stamp) else { continue };
let built = Built::new(stream, &direct);
cache.insert(r, crop, &built);
// Fails only when `r` isn't a dictionary, which the build just ruled out.
apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, stream).ok();
apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, built).ok();
}
}
copy
+138 -22
View File
@@ -1,7 +1,8 @@
use super::create::finite;
use super::draw::set_appearance;
use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, drawn_upright, is_fill_sign, page_ref, page_refs};
use super::modify::{fillable, touch, unlocked};
use super::modify::{callout_line, callout_text_box, fillable, touch, unlocked};
use super::text_style;
use super::types::{BorderShape, FLAG_LOCKED, LineEnding, Meta, NoteIcon, Rgb};
use super::{AnnotError, Dict, Document, ObjRef, Object, PdfString, appearance};
@@ -23,7 +24,8 @@ pub struct Summary {
pub in_reply_to: Option<String>,
/// The page and `/Annots` position `/IRT` points at, by reference (`/NM` need not be unique).
pub parent: Option<(usize, usize)>,
/// `/RT /Group`: grouped with the `/IRT` annotation (Replace Text's caret), not a reply to it.
/// `/RT /Group`: with an `/IRT` reference, a subordinate of the group whose primary that is
/// ([`is_group_subordinate`]), not a reply to it.
pub grouped: bool,
pub rect: [f32; 4],
pub color: Option<[f32; 3]>,
@@ -35,9 +37,66 @@ pub struct Summary {
pub fill_sign: bool,
/// The NoRotate flag (`/F` bit 5), as `pdfcraft_render::Annotation::no_rotate`.
pub no_rotate: bool,
/// A line's two ends (`/L`), or the corners of connected lines, a polygon or a cloud
/// (`/Vertices`), in user space; empty for other comments.
/// A line's two ends (`/L`), the corners of connected lines, a polygon or a cloud
/// (`/Vertices`), or a callout's arrow point and knee (`/CL` without its end, which follows
/// the text box), in user space; empty for other comments.
pub points: Vec<[f32; 2]>,
/// A callout's text box (`/Rect` inset by `/RD`), in user space; `None` for other comments.
pub text_box: Option<[f32; 4]>,
/// The comment's pop-up window (`/Popup`), when it has one.
pub popup: Option<PopupInfo>,
}
impl Summary {
/// A subordinate of a group (`/RT /Group` with an `/IRT` to a listed comment, ISO 32000-2
/// §12.5.6.2): part of its primary's comment, not a reply. As
/// `pdfcraft_render::Annotation::is_group_member`, which also drops a looping `/IRT`.
pub fn is_group_member(&self) -> bool {
self.grouped && self.parent.is_some()
}
}
/// A comment's pop-up window (ISO 32000-2 §12.5.6.14): where it sits on the page and whether it
/// is shown open.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PopupInfo {
/// `/Rect` of the `/Popup` annotation, in user space.
pub rect: [f32; 4],
/// `/Open` of the `/Popup` annotation.
pub open: bool,
}
/// The pop-up the comment `annot` (the object `own`, when it is one of its own) names in
/// `/Popup`, when it is the comment's: a pop-up annotation whose `/Parent`, if it has one, is a
/// reference to the comment. A `/Popup` naming another annotation, or a pop-up of another
/// comment, isn't. ISO 32000-2:2020 §12.5.6.14, Table 186: `/Parent` is "(Optional; shall be
/// an indirect reference)".
pub(crate) fn own_popup(doc: &Document, own: Option<ObjRef>, annot: &Dict) -> Option<ObjRef> {
let pr = annot.reference(b"Popup")?;
let is_own = doc.get(pr).as_dict().is_some_and(|pd| {
pd.name(b"Subtype") == Some(b"Popup")
&& pd
.get(b"Parent")
.is_none_or(|parent| parent.as_ref().is_some_and(|parent| own.is_some_and(|own| final_ref(doc, parent) == final_ref(doc, own))))
});
is_own.then_some(pr)
}
/// The comment's own pop-up (see [`own_popup`]), as [`PopupInfo`]: `None` when it has none or it
/// is unreadable.
pub(crate) fn popup_info(doc: &Document, own: Option<ObjRef>, annot: &Dict) -> Option<PopupInfo> {
// Another annotation's rectangle and open state aren't this comment's (review on #2137).
let r = own_popup(doc, own, annot)?;
let p = doc.get(r);
let p = p.as_dict()?;
let rect: Vec<f32> = doc.resolve(p.get(b"Rect")?).as_array()?.iter().filter_map(|v| doc.resolve(v).as_f64().map(|x| x as f32)).collect();
// Four numbers that are finite as `f32` (a huge one becomes infinite), corners in any order.
let [x0, y0, x1, y1] = rect[..] else { return None };
if !rect.iter().all(|v| v.is_finite()) {
return None;
}
let open = p.get(b"Open").map(|o| doc.resolve(o)).is_some_and(|o| matches!(&*o, Object::Bool(true)));
Some(PopupInfo { rect: [x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)], open })
}
pub(crate) fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option<String> {
@@ -74,6 +133,15 @@ fn final_ref(doc: &Document, mut r: ObjRef) -> ObjRef {
r
}
/// Whether an annotation is a subordinate of a group rather than a reply: `/RT /Group` with an
/// `/IRT` reference (ISO 32000-2 §12.5.6.2, Table 172). The group's primary is the `/IRT`
/// annotation, and the subordinate's own text, author, colour, dates and pop-up are ignored.
/// `/RT` is `R` by default, and a value other than `Group` reads as `R` too. Keep in step with
/// `pdfcraft_render::inspect`.
pub fn is_group_subordinate(d: &Dict) -> bool {
d.name(b"RT") == Some(b"Group") && d.reference(b"IRT").is_some()
}
/// Every comment (see [`is_comment_subtype`]), ordered by page and then top edge.
pub fn summaries(doc: &Document) -> Vec<Summary> {
let mut out = Vec::new();
@@ -105,7 +173,10 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
let r = nums(b"Rect");
let rect = if r.len() == 4 { [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])] } else { [0.0; 4] };
let c = nums(b"C");
let mut color = (c.len() == 3).then(|| [c[0], c[1], c[2]]);
let rgb = (c.len() == 3).then(|| [c[0], c[1], c[2]]);
// Other comments report the stroke colour as it is drawn (grey, CMYK, a null entry
// read as 0), like `props`; three numbers, some given by reference, are still read.
let mut color = if subtype == "FreeText" { rgb } else { summary_color(doc, d).or(rgb) };
if subtype == "FreeText"
&& let Some(da) = text_value(doc, d, b"DA")
{
@@ -127,15 +198,29 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
pdfcraft_geom::quad_points::usable_quads(entries).into_iter().map(|q| q.map(|v| v as f32)).collect()
})
.unwrap_or_default();
// Keep in step with `pdfcraft_render::inspect`.
// Keep in step with `pdfcraft_render::inspect`: a callout gives its points only with a
// readable text box, without which they can't be changed either.
let callout = (subtype == "FreeText").then(|| callout_line(doc, d).zip(callout_text_box(doc, d))).flatten();
let points = match subtype.as_str() {
"Line" => Some(nums(b"L")).filter(|l| l.len() == 4).map(|l| l.as_chunks::<2>().0.to_vec()).unwrap_or_default(),
"PolyLine" | "Polygon" => nums(b"Vertices").as_chunks::<2>().0.to_vec(),
"FreeText" => callout
.as_ref()
.and_then(|(line, _)| line.split_last())
.map(|(_, lead)| lead.iter().map(|p| [p[0] as f32, p[1] as f32]).collect())
.unwrap_or_default(),
_ => Vec::new(),
};
let text_box = callout.map(|(_, t)| t.map(|v| v as f32));
let irt = d.get(b"IRT").map(|o| o.as_ref().map_or_else(|| doc.resolve(o), |r| doc.get(final_ref(doc, r))));
let in_reply_to = irt.and_then(|o| o.as_dict().cloned()).and_then(|p| text_value(doc, &p, b"NM"));
let parent = d.get(b"IRT").and_then(Object::as_ref).and_then(|r| slots.get(&final_ref(doc, r)).copied());
// A group is one page's, as Acrobat reads it: a subordinate whose primary is on
// another page is a comment of its own (keep in step with
// `pdfcraft_render::link_replies`).
let grouped = d.name(b"RT") == Some(b"Group");
let cross_page = grouped && parent.is_some_and(|(p, _)| p != page);
let (parent, in_reply_to) = if cross_page { (None, None) } else { (parent, in_reply_to) };
out.push(Summary {
page,
index,
@@ -148,16 +233,24 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
name: text_value(doc, d, b"NM"),
in_reply_to,
parent,
grouped: d.name(b"RT") == Some(b"Group"),
grouped,
rect,
color,
state: text_value(doc, d, b"State"),
// A reply that gives only `/StateModel` has the model's default state (ISO 32000-2
// Table 175), as `last_state_reply` and the inspector read it.
state: text_value(doc, d, b"State").or_else(|| match text_value(doc, d, b"StateModel").as_deref() {
Some("Marked") => Some("Unmarked".into()),
Some("Review") => Some("None".into()),
_ => None,
}),
quads,
locked: annotation_flags(doc, d) & FLAG_LOCKED != 0,
intent: d.get(b"IT").and_then(|o| doc.resolve(o).as_name().map(|n| String::from_utf8_lossy(n).into_owned())),
fill_sign: is_fill_sign(doc, d),
no_rotate: drawn_upright(doc, d),
points,
text_box,
popup: popup_info(doc, entry.as_ref().map(|r| final_ref(doc, r)), d),
});
}
}
@@ -242,13 +335,20 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
let p = page_ref(doc, page).ok()?;
let entry = annots(doc, p).get(index).cloned()?;
let obj = doc.resolve(&entry);
let d = obj.as_dict()?;
// Values given by reference (`/C 7 0 R`, `/BS 5 0 R`, ISO 32000-2 §7.3.10) read as the display draws them.
let raw = obj.as_dict()?;
let direct = crate::draw::with_direct_values(doc, raw);
let d = direct.as_ref();
let subtype = String::from_utf8_lossy(d.name(b"Subtype")?).into_owned();
let c: Vec<f64> = d.get(b"C").and_then(|o| o.as_array().map(|a| a.iter().filter_map(|x| x.as_f64()).collect())).unwrap_or_default();
let color = if subtype == "FreeText" { Some(appearance::parse_da(d).0) } else { (c.len() == 3).then(|| [c[0], c[1], c[2]]) };
// A text box reports the colour and size it is drawn in (`/DA`, or `/DS` where it sets one).
let style = (subtype == "FreeText").then(|| text_style::text_style(d, None));
let color = match &style {
Some(s) => Some(s.color),
None => drawn_color(d, b"C"),
};
let width = matches!(subtype.as_str(), "Square" | "Circle" | "Line" | "Ink" | "Polygon" | "PolyLine" | "FreeText").then(|| {
if subtype == "FreeText" {
if !d.contains(b"BS") && !d.contains(b"Border") { 0.0 } else { appearance::border_width(d) }
appearance::border_width(d)
} else {
d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0)
}
@@ -261,11 +361,11 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
width,
icon: (subtype == "Text").then(|| d.name(b"Name").and_then(|n| NoteIcon::from_name(&String::from_utf8_lossy(n))).unwrap_or(NoteIcon::Note)),
modified: text_value(doc, d, b"M"),
restylable: appearance::build(doc, d).is_some(),
restylable: crate::draw::can_draw(doc, raw),
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),
font_size: style.map(|s| s.size),
border_color: (subtype == "FreeText").then(|| appearance::da_border(d)).flatten(),
border_shape: (subtype == "FreeText" && !d.contains(b"CL")).then(|| BorderShape::of(d)),
fill_opacity: fillable(&subtype).then(|| appearance::part_opacity(d).fill),
@@ -275,16 +375,32 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
})
}
/// A shape’s `/IC` or FreeText `/C` as RGB. Grey and CMYK fills are converted.
/// A shape’s `/IC` or FreeText `/C` as RGB, read as it is drawn. Grey and CMYK fills are converted.
fn fill_of(d: &Dict) -> Option<Rgb> {
let key: &[u8] = if d.name(b"Subtype") == Some(b"FreeText") { b"C" } else { b"IC" };
let c: Vec<f64> = d.get(key)?.as_array()?.iter().filter_map(|x| x.as_f64()).collect();
let rgb = match c.as_slice() {
[g] => [*g; 3],
[r, g, b] => [*r, *g, *b],
[c, m, y, k] => [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)],
_ => return None,
};
drawn_color(d, key)
}
/// `/C` as [`drawn_color`] reads it, the array itself followed when it is given by reference.
/// Only an array of at most four entries is copied out of the reference.
fn summary_color(doc: &Document, d: &Dict) -> Option<[f32; 3]> {
let rgb = match d.get(b"C") {
Some(o @ Object::Ref(_)) => {
let held = doc.resolve(o);
let entries = held.as_array().filter(|a| a.len() <= 4)?;
let mut direct = Dict::new();
direct.set(b"C".to_vec(), Object::Array(entries.clone()));
drawn_color(&direct, b"C")
}
_ => drawn_color(d, b"C"),
}?;
Some(rgb.map(|x| x as f32))
}
/// The colour array at `key` as RGB, read as the appearance builder draws it: grey and CMYK are
/// converted, and a four-entry array with a non-number in it is CMYK with that entry 0.
fn drawn_color(d: &Dict, key: &[u8]) -> Option<Rgb> {
let rgb = appearance::color(d, key).flatten()?;
finite(&rgb).then(|| rgb.map(|x| x.clamp(0.0, 1.0)))
}
+9 -5
View File
@@ -29,9 +29,12 @@ mod types;
#[cfg(test)]
use create::num_array;
pub use create::{add_annotation, callout_attach, orient_image_stamp};
pub use create::{
CALLOUT_KNEE_LINE, CUSTOM_LINE_BAND, CUSTOM_LINE_TEXT, Side, add_annotation, callout_attach, callout_knee, callout_knee_on, callout_side,
custom_stamp_size, orient_image_stamp,
};
pub use draw::{AppearanceCache, set_appearance, with_missing_appearances, with_missing_appearances_cached};
pub use info::{Props, Summary, is_comment_subtype, props, set_info, summaries};
pub use info::{PopupInfo, Props, Summary, is_comment_subtype, is_group_subordinate, props, set_info, summaries};
#[cfg(test)]
use lookup::{ANNOTS_ENTRIES_COPIED, PageWalk, annot_dict, annot_ref};
use lookup::{annots, page_ref};
@@ -41,9 +44,9 @@ pub use lookup::{
pub use model3d::{Model3d, Model3dKind, model_3d, model_3d_annotations};
pub(crate) use modify::n;
pub use modify::{
add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation,
set_border_color, set_border_shape, set_contents, set_fill, set_font_size, set_locked, set_marked, set_part_opacity, set_points, set_rect,
set_review_state, set_style, with_replies_and_popups,
add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation, release_members,
set_border_color, set_border_shape, set_contents, set_fill, set_font_size, set_locked, set_marked, set_part_opacity, set_points, set_popup,
set_rect, set_review_state, set_style, with_replies_and_popups,
};
pub use threed::{Activation as Activation3d, MAX_MODEL_BYTES, ModelFormat, NewModel3d, PosterPicture, add_3d_model, check_model};
pub use types::{
@@ -53,6 +56,7 @@ pub use types::{
pub mod appearance;
pub mod links;
pub mod text_style;
#[cfg(test)]
mod tests;
+165 -16
View File
@@ -8,13 +8,77 @@ use crate::{AnnotError, annots, page_ref, page_refs, set_annots};
/// Where a link goes.
#[derive(Clone, Debug, PartialEq)]
pub enum LinkAction {
/// A page of this document (0-based), shown fitting the window (`/Fit`).
Page(usize),
/// A page of this document (0-based), shown at `zoom`.
Page {
page: usize,
zoom: LinkZoom,
},
Uri(String),
/// Something PdfCraft doesn't edit yet (named destination, JavaScript…): kept as is.
Other(String),
}
/// How a go-to-page link shows its page: Acrobat's Go to a page view zoom, written as an
/// explicit destination (ISO 32000-2 §12.3.2.2, Table 149). Positions are written `null`, so the
/// reader keeps its position in that direction, as a new bookmark does.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum LinkZoom {
/// `/Fit`: the whole page in the window.
FitPage,
/// `/FitH null`: the page's width across the window.
FitWidth,
/// `/FitV null`: the page's height down the window.
FitHeight,
/// `/FitB`: the page's content (its bounding box) in the window.
FitVisible,
/// `/XYZ null null null`: the reader's zoom is kept.
Inherit,
/// `/XYZ null null z` with z = percent / 100; 100 is Actual Size. A new zoom is written
/// within 1–6400%; a link rewritten with the zoom it has keeps its own factor.
Percent(f64),
}
impl LinkZoom {
/// The zoom an explicit destination array (`[page /XYZ …]`, `[page /Fit]`, …) asks for. The
/// boxed variants read as their unboxed ones, and a `/FitR` rectangle or a missing or unknown
/// type as Fit Page, the view links had before they carried a zoom. A link whose page and zoom
/// are unchanged is not rewritten, so its position is kept; one given a new page keeps how its
/// zoom is written (`write_action`) but not its position.
fn of_dest(doc: &Document, dest: &[Object]) -> Self {
let kind = dest.get(1).map(|k| doc.resolve(k)).and_then(|k| k.as_name().map(<[u8]>::to_vec));
match kind.as_deref() {
Some(b"FitB") => LinkZoom::FitVisible,
Some(b"FitH" | b"FitBH") => LinkZoom::FitWidth,
Some(b"FitV" | b"FitBV") => LinkZoom::FitHeight,
Some(b"XYZ") => match dest.get(4).and_then(|z| doc.resolve(z).as_f64()) {
// Finite even for a factor near f64::MAX. A link given a new page with this zoom
// keeps the factor itself, however far outside 1–6400% (`write_action`).
Some(z) if z.is_finite() && z > 0.0 => LinkZoom::Percent((z * 100.0).min(f64::MAX)),
// A zoom of 0 or null keeps the reader's (Table 149).
_ => LinkZoom::Inherit,
},
_ => LinkZoom::FitPage,
}
}
/// The destination's elements after the page reference.
fn operands(self) -> Result<Vec<Object>, AnnotError> {
Ok(match self {
LinkZoom::FitPage => vec![Object::name("Fit")],
LinkZoom::FitWidth => vec![Object::name("FitH"), Object::Null],
LinkZoom::FitHeight => vec![Object::name("FitV"), Object::Null],
LinkZoom::FitVisible => vec![Object::name("FitB")],
LinkZoom::Inherit => vec![Object::name("XYZ"), Object::Null, Object::Null, Object::Null],
LinkZoom::Percent(p) => {
if !(p.is_finite() && p > 0.0) {
return Err(AnnotError::Invalid("the link's zoom must be a positive percentage".into()));
}
vec![Object::name("XYZ"), Object::Null, Object::Null, Object::Real((p / 100.0).clamp(0.01, 64.0))]
}
})
}
}
/// Acrobat's Link Properties ▸ Appearance.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct LinkStyle {
@@ -87,17 +151,18 @@ fn nums(doc: &Document, o: Option<&Object>) -> Vec<f64> {
}
fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef], uri_budget: &mut usize) -> LinkAction {
let dest_page = |dest: &Object| -> Option<usize> {
let page_view = |dest: &Object| -> Option<LinkAction> {
let a = doc.resolve(dest);
let first = a.as_array()?.first()?.clone();
match first {
Object::Ref(r) => pages.iter().position(|p| *p == r),
Object::Int(i) => usize::try_from(i).ok(),
let a = a.as_array()?;
let page = match a.first()? {
Object::Ref(r) => pages.iter().position(|p| p == r),
Object::Int(i) => usize::try_from(*i).ok(),
_ => None,
}
}?;
Some(LinkAction::Page { page, zoom: LinkZoom::of_dest(doc, a) })
};
if let Some(dest) = d.get(b"Dest") {
return dest_page(dest).map_or_else(|| LinkAction::Other("named destination".into()), LinkAction::Page);
return page_view(dest).unwrap_or_else(|| LinkAction::Other("named destination".into()));
}
let Some(a) = d.get(b"A").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()) else { return LinkAction::Other("no action".into()) };
match a.name(b"S") {
@@ -115,7 +180,7 @@ fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef], uri_budget: &mut usize)
None => LinkAction::Other("web address too long".into()),
}
}
Some(b"GoTo") => a.get(b"D").and_then(dest_page).map_or_else(|| LinkAction::Other("named destination".into()), LinkAction::Page),
Some(b"GoTo") => a.get(b"D").and_then(page_view).unwrap_or_else(|| LinkAction::Other("named destination".into())),
Some(s) => LinkAction::Other(String::from_utf8_lossy(s).into_owned()),
None => LinkAction::Other("no action".into()),
}
@@ -214,12 +279,52 @@ fn uri_bytes(u: &str) -> Vec<u8> {
out
}
/// The explicit destination array link `d` goes to now: its `/Dest`, or a GoTo action's `/D`.
fn current_dest(doc: &Document, d: &Dict) -> Option<Vec<Object>> {
let dest = match d.get(b"Dest") {
Some(dest) => doc.resolve(dest),
None => {
let action = doc.resolve(d.get(b"A")?);
let go_to = action.as_dict().filter(|a| a.name(b"S") == Some(b"GoTo"))?;
doc.resolve(go_to.get(b"D")?)
}
};
dest.as_array().cloned()
}
fn write_action(doc: &Document, d: &mut Dict, a: &LinkAction) -> Result<(), AnnotError> {
match a {
LinkAction::Page(p) => {
let target = page_ref(doc, *p)?;
LinkAction::Page { page, zoom } => {
let target = page_ref(doc, *page)?;
let mut operands = zoom.operands()?;
let current = current_dest(doc, d).filter(|c| LinkZoom::of_dest(doc, c) == *zoom);
if let Some(c) = current {
// The page and zoom the link has (`link_edit` naming its own page): the
// destination is left as it is, position on the page and GoTo form included.
let same_page = match c.first() {
Some(Object::Ref(r)) => *r == target,
Some(Object::Int(i)) => usize::try_from(*i).ok() == Some(*page),
_ => false,
};
if same_page {
return Ok(());
}
// A new page with the zoom the link has keeps how that zoom is written: a boxed
// fit (`/FitBH`, `/FitBV`) and its own `/XYZ` factor, even one outside the 1–6400%
// a new zoom is written within. Positions belong to the old page and are dropped.
let (slot, own) = match zoom {
LinkZoom::Percent(_) => (operands.get_mut(3), c.get(4)),
LinkZoom::FitWidth | LinkZoom::FitHeight => (operands.get_mut(0), c.get(1)),
_ => (None, None),
};
if let (Some(slot), Some(own)) = (slot, own) {
*slot = doc.resolve(own).as_ref().clone();
}
}
let mut dest = vec![Object::Ref(target)];
dest.extend(operands);
d.remove(b"A");
d.set(b"Dest".to_vec(), Object::Array(vec![Object::Ref(target), Object::name("Fit")]));
d.set(b"Dest".to_vec(), Object::Array(dest));
}
LinkAction::Uri(u) => {
let u = u.trim();
@@ -398,10 +503,24 @@ fn email_at(chars: &[char], start: usize) -> Option<(usize, String)> {
Some((end, format!("mailto:{address}")))
}
/// The longest web address [`find_urls`] reads, in characters (browsers and servers commonly
/// stop near 2,000). Each address it might be is read this far at most, so text whose brackets
/// never close (`https://a/(https://a/(…`, without a space) costs this much per address in it,
/// not a pass over the rest of the page each.
const MAX_URL_CHARS: usize = 2048;
/// Find web addresses in text: `http(s)://…`, `www.…` and bare e-mail addresses become
/// `mailto:`. Returns character ranges and the URI for each.
///
/// No other scheme is ever found, and a `www.` or e-mail address that is part of one written with
/// another scheme (`ftp://www.example.com`, `smb://admin@example.org`) is not taken out of it:
/// it would open with a scheme the text doesn't say. Brackets inside a web address belong to it
/// when they pair up (`…/wiki/Mercury_(planet)`); an unpaired one ends it.
pub fn find_urls(chars: &[char]) -> Vec<(std::ops::Range<usize>, String)> {
let url_char = |c: char| !c.is_whitespace() && !"<>\"'()[]{}".contains(c);
// Characters found inside an address but not between words: a `www.` after one is part of
// a longer address, and so is an e-mail address after a slash.
let inside_address = |i: usize, set: &str| i > 0 && chars.get(i - 1).is_some_and(|c| set.contains(*c));
// Case-insensitive prefix test that never steps past the end. Comparing against the original
// characters keeps every offset in `chars`'s own index space: case-folding a copy can change
// its length (U+0130 becomes "i" plus a combining dot), so a folded copy must never supply
@@ -412,11 +531,38 @@ pub fn find_urls(chars: &[char]) -> Vec<(std::ops::Range<usize>, String)> {
let mut i = 0;
while i < chars.len() {
let boundary = i == 0 || chars.get(i - 1).is_some_and(|c| !c.is_alphanumeric());
if boundary && (starts_with(i, "http://") || starts_with(i, "https://") || starts_with(i, "www.")) {
let www = starts_with(i, "www.") && !inside_address(i, "/\\:@.-_~%+=&#");
if boundary && (starts_with(i, "http://") || starts_with(i, "https://") || www) {
let mut j = i;
while chars.get(j).is_some_and(|c| url_char(*c)) {
// Brackets pair up inside the address: `depth` are open, the outermost at `opened`.
let (mut depth, mut opened) = (0usize, i);
let mut too_long = false;
loop {
if j.saturating_sub(i) >= MAX_URL_CHARS {
too_long = true;
break;
}
match chars.get(j) {
Some('(') => {
if depth == 0 {
opened = j;
}
depth = depth.saturating_add(1);
}
Some(')') if depth > 0 => depth -= 1,
Some(c) if url_char(*c) => {}
_ => break,
}
j += 1;
}
// A bracket left open isn't the address's: it ends before it. A run of address
// characters longer than any address is none, and the search goes on after it.
if depth > 0 {
j = opened;
} else if too_long {
i = j;
continue;
}
// Trailing punctuation belongs to the sentence.
while j > i && chars.get(j - 1).is_some_and(|c| ".,;:!?".contains(*c)) {
j -= 1;
@@ -429,7 +575,10 @@ pub fn find_urls(chars: &[char]) -> Vec<(std::ops::Range<usize>, String)> {
continue;
}
}
if boundary && let Some((end, uri)) = email_at(chars, i) {
if boundary
&& !inside_address(i, "/\\")
&& let Some((end, uri)) = email_at(chars, i)
{
out.push((i..end, uri));
i = end;
continue;
+515 -88
View File
@@ -1,14 +1,17 @@
use super::create::{
add_annotation, base_dict, bounds, callout_attach, ending_draws, ending_pair, finite, grow, ink_bounds, normalize, num_array, rect_for, rgb,
CALLOUT_SLACK, add_annotation, base_dict, bounds, callout_attach, callout_knee, callout_side, ending_draws, ending_pair, finite, grow,
ink_bounds, normalize, num_array, on_side, polygon_rect, rect_for, rgb,
};
use super::draw::{draw_appearance, redraw_or_restore, set_appearance};
use super::info::{line_endings_of, text_value};
use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, page_ref, set_annots};
use super::info::{is_comment_subtype, is_group_subordinate, line_endings_of, own_popup, popup_info, text_value};
use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, is_fill_sign, page_ref, set_annots};
use super::text_style::{self, TextStyle};
use super::types::{
BorderShape, FLAG_HIDDEN, FLAG_LOCKED, FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NewAnnotation, ReviewState, Rgb,
Shape, Style, StyleChange, TextReplacement,
};
use super::{AnnotError, Dict, Document, HashMap, HashSet, ObjRef, Object, PdfString, appearance};
use pdfcraft_fonts::Std14;
/// A comment another program drew, whose text the standard fonts can't draw. Redrawing it would
/// replace those characters with `?`, so the edit is refused before the dictionary changes.
@@ -25,6 +28,15 @@ pub(crate) fn refuse_foreign_text(doc: &Document, d: &Dict) -> Result<(), AnnotE
Ok(())
}
/// Thai typed into a text box (`free_text`) that this build can't draw (it has no Thai face): refused
/// with what to do, rather than drawn as `?`.
pub(crate) fn refuse_undrawable_thai(free_text: bool, text: &str) -> Result<(), AnnotError> {
match free_text.then(|| pdfcraft_fonts::shaped_text::thai_refusal(text)).flatten() {
Some(why) => Err(AnnotError::Undrawable(why)),
None => Ok(()),
}
}
/// Format a number for content streams and DA strings.
pub(crate) fn n(v: f64) -> String {
let s = format!("{:.3}", if v.abs() < 5e-4 { 0.0 } else { v });
@@ -34,11 +46,36 @@ pub(crate) fn n(v: f64) -> String {
// ── edits ───────────────────────────────────────────────────────────────────────────────────
/// A text box's rich text (§12.7.4.3): `/DS` (default style) and `/RC` (XHTML) written from its
/// plain `/Contents`, `/DA` and `/Q`, so viewers that edit rich text show the same thing.
/// [`rich_text_in`] for a text box whose `/DA` was just written (in Helvetica, as PdfCraft writes it).
pub(crate) fn rich_text(d: &mut Dict) {
let (color, size) = appearance::parse_da(d);
rich_text_in(d, TextStyle { face: Std14::Helvetica, size, color });
}
/// Give a text box the text `style`: `/DA` keeps the font resource it names (`/Helv` when it names
/// none) and the border colour it gives (`RG`, [`appearance::da_border`]) with the new colour and
/// size, and `/DS` and `/RC` carry the face, so the text keeps the face it is drawn in (ISO
/// 32000-2 Table 177).
fn restyle_text(d: &mut Dict, style: TextStyle) {
let border = appearance::da_border(d);
restyle_text_bordered(d, style, border);
}
/// [`restyle_text`] with the border colour `border` (`None`: the text's).
fn restyle_text_bordered(d: &mut Dict, style: TextStyle, border: Option<crate::Rgb>) {
let font = text_style::da_font_name(d).unwrap_or_else(|| "Helv".into());
let [r, g, b] = style.color.map(|x| x.clamp(0.0, 1.0));
let stroke = border.map(|c| c.map(|x| x.clamp(0.0, 1.0))).map(|[r, g, b]| format!("{} {} {} RG ", n(r), n(g), n(b))).unwrap_or_default();
d.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg {stroke}/{font} {} Tf", n(r), n(g), n(b), n(style.size)).into_bytes()));
rich_text_in(d, TextStyle { color: [r, g, b], ..style });
}
/// A text box's rich text (§12.7.4.3): `/DS` (default style) and `/RC` (XHTML) written from its
/// plain `/Contents`, its text `style` and `/Q`, so viewers that edit rich text show the same thing.
fn rich_text_in(d: &mut Dict, style: TextStyle) {
let text = d.get(b"Contents").and_then(|c| c.as_string()).map(PdfString::to_text).unwrap_or_default();
let (col, size) = appearance::parse_da(d);
let TextStyle { face, size, color: col } = style;
let (family, weight, slant) = text_style::css(face);
let hex = format!(
"#{:02X}{:02X}{:02X}",
(col[0].clamp(0.0, 1.0) * 255.0).round() as u8,
@@ -53,13 +90,15 @@ pub(crate) fn rich_text(d: &mut Dict) {
let esc = |s: &str| s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;");
let body: String = text.split('\n').map(|line| format!("<p dir=\"ltr\">{}</p>", esc(line.trim_end_matches('\r')))).collect();
let style = format!(
"font-size:{}pt;text-align:{align};color:{hex};font-weight:normal;font-style:normal;font-family:Helvetica;font-stretch:normal",
"font-size:{}pt;text-align:{align};color:{hex};font-weight:{weight};font-style:{slant};font-family:{family};font-stretch:normal",
n(size)
);
let rc = format!(
"<?xml version=\"1.0\"?><body xmlns=\"http://www.w3.org/1999/xhtml\" xmlns:xfa=\"http://www.xfa.org/schema/xfa-data/1.0/\" xfa:spec=\"2.0.2\" style=\"{style}\">{body}</body>"
);
d.set(b"DS".to_vec(), PdfString::text(&format!("font: Helvetica {}pt; text-align:{align}; color:{hex}", n(size))));
// "font: Helvetica 12pt" for PdfCraft's own boxes; other faces add their slant and weight.
let keywords = format!("{}{}", if slant == "normal" { "" } else { "italic " }, if weight == "normal" { "" } else { "bold " });
d.set(b"DS".to_vec(), PdfString::text(&format!("font: {keywords}{family} {}pt; text-align:{align}; color:{hex}", n(size))));
d.set(b"RC".to_vec(), PdfString::text(&rc));
}
@@ -70,9 +109,14 @@ pub(crate) fn touch(d: &mut Dict, meta: &Meta) {
}
/// Delete a comment together with its pop-up and its replies (and theirs), as Acrobat does.
/// Refused while it, or a reply that would go with it, is locked.
/// Refused while it, or a reply that would go with it, is locked. A member of a group (`/RT
/// /Group`), its primary included, goes alone, as Acrobat deletes the member clicked and keeps the
/// rest of its group, although ISO 32000-2 §12.5.6.2 lists deletion among the operations that act
/// on the entire group; a subordinate left without its primary is a comment of its own and
/// loses its `/IRT` ([`release_members`]). Replace Text's strikeout and caret are one comment and
/// go together from either ([`own_deletion`]).
pub fn delete_annotation(doc: &mut Document, page: usize, index: usize) -> Result<(), AnnotError> {
deletable(doc, page, index)?;
let index = deleted_index(doc, page, index)?;
lift_annotation(doc, page, index)
}
@@ -80,23 +124,44 @@ pub fn delete_annotation(doc: &mut Document, page: usize, index: usize) -> Resul
/// changing anything: what a Delete command asks before it is offered. It is refused while the
/// annotation is locked, or a reply that would go with it is.
pub fn deletable(doc: &Document, page: usize, index: usize) -> Result<(), AnnotError> {
deleted_index(doc, page, index).map(|_| ())
}
/// [`deletable`], returning the index [`delete_annotation`] deletes from: the caret for Replace
/// Text's strikeout, else `index`.
fn deleted_index(doc: &Document, page: usize, index: usize) -> Result<usize, AnnotError> {
let list = annots(doc, page_ref(doc, page)?);
let target = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?;
let mut target = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?;
let mut index = index;
if let Some(r) = target.as_ref() {
unlocked(doc, r)?;
}
let doomed = with_replies_and_popups(doc, &list, seed(target));
// Replace Text is one comment: its strikeout goes with its caret, the group's primary, and
// the caret with it, as Acrobat deletes the pair from either.
let caret = doc
.resolve(target)
.as_dict()
.filter(|d| is_group_subordinate(d) && !own_deletion(doc, d))
.and_then(|d| d.reference(b"IRT"))
.and_then(|irt| list.iter().enumerate().find(|(_, e)| e.as_ref() == Some(irt)));
if let Some((i, e)) = caret {
(target, index) = (e, i);
}
let doomed = with_replies_and_popups(doc, &list, seed(doc, target));
// A locked reply can't be deleted on its own, so it doesn't go with the comment or reply it
// answers either. Pop-ups follow their comment.
for (_, e) in list.iter().enumerate().filter(|(i, e)| *i != index && goes_with(e, &doomed)) {
// answers either (ISO 32000-2 §12.5.3, Table 167: Locked); nor does the other half of Replace
// Text. Pop-ups follow their comment.
for (i, e) in list.iter().enumerate().filter(|(i, e)| (*i != index || caret.is_some()) && goes_with(doc, e, &doomed)) {
let obj = doc.resolve(e);
if let Some(d) = obj.as_dict().filter(|d| d.name(b"Subtype") != Some(b"Popup".as_slice()))
&& annotation_flags(doc, d) & FLAG_LOCKED != 0
{
return Err(AnnotError::Invalid("a reply to it is locked".into()));
let grouped = is_group_subordinate(d) || caret.is_some_and(|(c, _)| c == i);
let what = if grouped { "a comment in its group is locked" } else { "a reply to it is locked" };
return Err(AnnotError::Invalid(what.into()));
}
}
Ok(())
Ok(index)
}
/// Take the annotation at `(page, index)` out of its page with its pop-up and replies, as
@@ -106,31 +171,95 @@ pub fn lift_annotation(doc: &mut Document, page: usize, index: usize) -> Result<
let p = page_ref(doc, page)?;
let list = annots(doc, p);
let target = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?;
let doomed = with_replies_and_popups(doc, &list, seed(target));
let kept: Vec<Object> = list.iter().enumerate().filter(|(i, e)| *i != index && !goes_with(e, &doomed)).map(|(_, e)| e.clone()).collect();
let doomed = with_replies_and_popups(doc, &list, seed(doc, target));
let kept: Vec<Object> = list.iter().enumerate().filter(|(i, e)| *i != index && !goes_with(doc, e, &doomed)).map(|(_, e)| e.clone()).collect();
let kept = release_members(doc, kept, &doomed)?;
Ok(set_annots(doc, p, kept)?)
}
/// The `/Annots` entries `kept` after deleting `going`, with every group subordinate that stays
/// ([`own_deletion`]) but whose `/IRT` names an annotation going made a comment of its own: its
/// `/IRT` is removed and its `/RT /Group` kept, as Acrobat does when a group's primary is deleted.
/// A deleted primary then stays reachable from nothing, so a full save leaves its text out. An
/// inline entry is rewritten in the list; a referenced one is updated in place. Each entry is
/// read once, following no chain.
pub fn release_members(doc: &mut Document, kept: Vec<Object>, going: &HashSet<ObjRef>) -> Result<Vec<Object>, pdfcraft_cos::CosError> {
let orphaned = |d: &Dict| is_group_subordinate(d) && d.reference(b"IRT").is_some_and(|r| going.contains(&r));
let mut out = Vec::with_capacity(kept.len());
for e in kept {
match e.as_ref() {
Some(r) => {
if doc.get(r).as_dict().is_some_and(orphaned) {
doc.update_dict(r, |d| {
d.remove(b"IRT");
})?;
}
out.push(e);
}
None => match e {
Object::Dict(mut d) if orphaned(&d) => {
d.remove(b"IRT");
out.push(Object::Dict(d));
}
other => out.push(other),
},
}
}
Ok(out)
}
/// What deleting the `/Annots` entry `target` starts from: its reference, or for an inline
/// comment (which has none to collect) the pop-up it names, which still goes.
fn seed(target: &Object) -> Option<ObjRef> {
target.as_ref().or_else(|| target.as_dict().and_then(|d| d.reference(b"Popup")))
/// comment (which has none to collect) the pop-up it names, which still goes. The strikeout of a
/// Replace Text proposal also takes the caret it is grouped under, so the two go together
/// whichever is deleted.
fn seed(doc: &Document, target: &Object) -> Vec<ObjRef> {
let own = target.as_ref().or_else(|| target.as_dict().and_then(|d| d.reference(b"Popup")));
own.into_iter().chain(replaced_by(doc, target)).collect()
}
/// The caret a Replace Text strikeout is grouped under (`/IT /StrikeOutTextEdit`, `/RT /Group`,
/// `/IRT` to a caret): the proposal's primary, which holds the replacement.
fn replaced_by(doc: &Document, strike: &Object) -> Option<ObjRef> {
let obj = doc.resolve(strike);
let d = obj.as_dict()?;
let edit = d.name(b"Subtype") == Some(b"StrikeOut") && d.name(b"IT") == Some(b"StrikeOutTextEdit") && d.name(b"RT") == Some(b"Group");
let caret = d.reference(b"IRT").filter(|_| edit)?;
(annot_dict(doc, caret).name(b"Subtype") == Some(b"Caret")).then_some(caret)
}
/// Whether the `/Annots` entry `entry` goes with `going` (from [`with_replies_and_popups`]): it is
/// one of them, or it is written inline (a file can, though the standard asks for references) and
/// replies to (`/IRT`) or pops up for (`/Parent`) one of them. An inline entry has no reference
/// for the walk to collect, so it is matched here.
pub fn goes_with(entry: &Object, going: &HashSet<ObjRef>) -> bool {
pub fn goes_with(doc: &Document, entry: &Object, going: &HashSet<ObjRef>) -> bool {
match entry.as_ref() {
Some(r) => going.contains(&r),
None => entry.as_dict().is_some_and(|d| [d.reference(b"IRT"), d.reference(b"Parent")].into_iter().flatten().any(|r| going.contains(&r))),
None => entry.as_dict().is_some_and(|d| hangs_off(doc, d).into_iter().flatten().any(|r| going.contains(&r))),
}
}
/// What the annotation `d` goes with when it is deleted: what it replies to (`/IRT`) and what it
/// pops up for (`/Parent`). A group subordinate (`/RT /Group`) is deleted on its own
/// ([`own_deletion`]), so its `/IRT` doesn't count.
fn hangs_off(doc: &Document, d: &Dict) -> [Option<ObjRef>; 2] {
[d.reference(b"IRT").filter(|_| !own_deletion(doc, d)), d.reference(b"Parent")]
}
/// Whether the annotation `d` stays when its group's primary is deleted: a group subordinate
/// (`/RT /Group`) does, as Acrobat deletes only the member clicked, unless it is half of Replace
/// Text, which Acrobat deletes as one comment: the strikeout (`/IT /StrikeOutTextEdit`) under its
/// caret, or a caret under such a strikeout, as earlier PdfCraft builds wrote the pair.
fn own_deletion(doc: &Document, d: &Dict) -> bool {
let edit = |d: &Dict| d.name(b"IT") == Some(b"StrikeOutTextEdit".as_slice());
let under_edit =
|| d.name(b"Subtype") == Some(b"Caret".as_slice()) && d.reference(b"IRT").is_some_and(|r| doc.get(r).as_dict().is_some_and(edit));
is_group_subordinate(d) && !edit(d) && !under_edit()
}
/// `doomed` and what goes with it, as Acrobat deletes a comment: the entries of `list` (one or
/// more pages' `/Annots`) that reply to (`/IRT`) or pop up for (`/Parent`) anything going, their
/// own replies, and the pop-ups going annotations name (`/Popup`). An inline reply has no
/// own replies, and the pop-ups going annotations name (`/Popup`). A group subordinate stays
/// ([`own_deletion`]). An inline reply has no
/// reference to collect, but the pop-up it names is. Each annotation is visited once, so a long or
/// cyclic chain costs no more than its length. Filter a list with [`goes_with`], which also
/// matches inline entries.
@@ -142,7 +271,7 @@ pub fn with_replies_and_popups(doc: &Document, list: &[Object], doomed: impl Int
// What goes with the annotation this entry hangs off: the entry, or for an inline one the
// pop-up it names.
let Some(goes) = e.as_ref().or_else(|| d.reference(b"Popup")) else { continue };
for on in [d.reference(b"IRT"), d.reference(b"Parent")].into_iter().flatten() {
for on in hangs_off(doc, d).into_iter().flatten() {
hanging.entry(on).or_default().push(goes);
}
}
@@ -163,14 +292,17 @@ pub fn with_replies_and_popups(doc: &Document, list: &[Object], doomed: impl Int
/// around its text stays snug around the new text, centred where it was.
pub fn set_contents(doc: &mut Document, page: usize, index: usize, text: &str, meta: &Meta) -> Result<(), AnnotError> {
let (_, r) = annot_ref(doc, page, index)?;
let d = annot_dict(doc, r);
let free_text = d.name(b"Subtype") == Some(b"FreeText");
let snug = free_text && is_snug(&d);
let before = annot_dict(doc, r);
let free_text = before.name(b"Subtype") == Some(b"FreeText");
refuse_undrawable_thai(free_text, text)?;
let snug = free_text && is_snug(&before);
// The text keeps the look it is drawn with (`/DA`, or `/DS` where it sets one).
let style = text_style::text_style(&before, text_style::dr_face(doc, &before));
doc.update_dict(r, |d| {
d.set(b"Contents".to_vec(), PdfString::text(text));
// The rich-text version follows the new plain text (text boxes), or would contradict it.
if free_text {
rich_text(d);
rich_text_in(d, style);
} else {
d.remove(b"RC");
}
@@ -203,7 +335,9 @@ pub fn set_contents(doc: &mut Document, page: usize, index: usize, text: &str, m
fn fitted_box(doc: &Document, r: ObjRef, text: &str) -> Option<[f64; 4]> {
let d = annot_dict(doc, r);
let nums = |key: &[u8]| -> Option<Vec<f64>> { d.get(key)?.as_array()?.iter().map(|o| o.as_f64()).collect() };
let (_, size) = appearance::parse_da(&d);
// Measured in the face and size the text is drawn in (`/DA`, or `/DS` where it sets one).
let TextStyle { face, size, .. } = text_style::text_style(&d, text_style::dr_face(doc, &d));
let measure = |s: &str, size: f64| appearance::text_width_in(face, s, size);
let pad = 2.0 + border_width_of(&d);
let rect = nums(b"Rect").filter(|v| v.len() == 4 && v.iter().all(|x| x.is_finite()))?;
let rect = [rect[0], rect[1], rect[2], rect[3]];
@@ -221,11 +355,11 @@ fn fitted_box(doc: &Document, r: ObjRef, text: &str) -> Option<[f64; 4]> {
w
} else {
// No usable old width: fall back to the creation-time width (at most 300 pt).
let longest = text.lines().map(|l| appearance::text_width(l, size)).fold(0.0, f64::max);
let longest = text.lines().map(|l| measure(l, size)).fold(0.0, f64::max);
(longest + 2.0 * pad + 4.0).clamp(40.0, 300.0)
};
let lines = appearance::wrap(text, size, (w - 2.0 * pad).max(1.0)).len().max(1) as f64;
Some([x0, top - lines * size * 1.2 - 2.0 * pad - 2.0, x0 + w, top])
let height = appearance::text_lines_height(&pdfcraft_fonts::wrap_with(text, size, (w - 2.0 * pad).max(1.0), measure), size);
Some([x0, top - height - 2.0 * pad - 2.0, x0 + w, top])
}
/// Reply to a comment; returns the reply's index in the page's `/Annots`.
@@ -237,8 +371,9 @@ pub fn add_reply(doc: &mut Document, page: usize, index: usize, text: &str, auth
reply(doc, page, index, text, author, meta, None)
}
/// Set a comment's review status (Acrobat: "Set status ▸ Accepted"…). Like Acrobat this adds a
/// state reply (`/State`, `/StateModel /Review`) by `author`; the latest one wins.
/// Set a comment's review status ("Set status ▸ Accepted"…). This adds a hidden state reply
/// (`/State`, `/StateModel /Review`) by `author`, in reply to `author`'s previous one when there is
/// one; each reviewer's latest is their status.
pub fn set_review_state(doc: &mut Document, page: usize, index: usize, state: ReviewState, author: &str, meta: &Meta) -> Result<usize, AnnotError> {
let text = format!("{} set by {}", state.name(), if author.is_empty() { "unknown" } else { author });
reply(doc, page, index, &text, author, meta, Some(state))
@@ -271,6 +406,11 @@ fn reply(
d.set(b"State".to_vec(), PdfString::text(s.name()));
d.set(b"StateModel".to_vec(), PdfString::text("Review"));
d.set(b"Subj".to_vec(), PdfString::text("Status"));
// Hidden (Table 167, bit 2): the state "is not specified in the annotation itself but in a
// separate text annotation" (ISO 32000-2 §12.5.6.3), so this reply is a state record, not
// a note, and is neither drawn nor listed as one.
d.set(b"F".to_vec(), Object::Int(FLAG_HIDDEN | FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE));
d.set(b"IRT".to_vec(), Object::Ref(last_state_reply(doc, p, parent, author, "Review").unwrap_or(parent)));
}
// An empty form: nothing is drawn for the reply itself.
let mut fd = Dict::new();
@@ -298,6 +438,83 @@ pub(crate) fn unlocked(doc: &Document, r: ObjRef) -> Result<(), AnnotError> {
if flags(doc, r) & FLAG_LOCKED != 0 { Err(AnnotError::Invalid("the comment is locked".into())) } else { Ok(()) }
}
/// Open, close or move a comment's pop-up window (Acrobat: double-click a comment to open it,
/// drag its title bar to move it). `rect` is the pop-up's new `/Rect` in user space; `open` its
/// `/Open`. A comment without a `/Popup` gets one, placed to the right of the comment as new
/// notes get theirs. A text box, a Fill & Sign mark or a reply has no pop-up of its own (a reply
/// shows in its parent's). The lock does not apply: the window's place and state are not the
/// comment. The modification date is left alone: nothing of the comment changed.
pub fn set_popup(doc: &mut Document, page: usize, index: usize, rect: Option<[f64; 4]>, open: Option<bool>, meta: &Meta) -> Result<(), AnnotError> {
let (p, r) = annot_ref(doc, page, index)?;
let d = annot_dict(doc, r);
let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
if !is_comment_subtype(&subtype) || subtype == "FreeText" || is_fill_sign(doc, &d) || d.contains(b"IRT") {
return Err(AnnotError::Invalid(format!("{subtype} comments have no pop-up of their own")));
}
let rect = match rect {
Some(rect) => {
let rect = normalize(rect);
if !finite(&rect) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 {
return Err(AnnotError::Invalid("invalid pop-up rectangle".into()));
}
Some(rect)
}
None => None,
};
let _ = meta;
// Where a new pop-up goes: to the right of the comment, as new notes' do.
let own: Vec<f64> = d
.get(b"Rect")
.map(|o| doc.resolve(o))
.and_then(|o| o.as_array().cloned())
.unwrap_or_default()
.iter()
.filter_map(|v| doc.resolve(v).as_f64())
.collect();
let beside = match own[..] {
[_, _, x1, y1] if finite(&own) => [x1 + 10.0, y1 - 120.0, x1 + 210.0, y1],
_ => [0.0, 0.0, 200.0, 120.0],
};
// Only a pop-up that is this comment's: `/Popup` naming another annotation (or a pop-up
// whose `/Parent` is another comment) would move or open that one, so it gets its own.
match own_popup(doc, Some(r), &d) {
Some(pr) => {
// One without a `/Rect` of four numbers reads as no pop-up (`summaries`), so it gets
// the new pop-up's place: opening it then shows it.
let rect = rect.or_else(|| popup_info(doc, Some(r), &d).is_none().then_some(beside));
doc.update_dict(pr, |p| {
if let Some(rect) = rect {
p.set(b"Rect".to_vec(), num_array(&rect));
}
if let Some(open) = open {
p.set(b"Open".to_vec(), Object::Bool(open));
}
})?
}
None => {
let mut pd = Dict::new();
pd.set(b"Type".to_vec(), Object::name("Annot"));
pd.set(b"Subtype".to_vec(), Object::name("Popup"));
pd.set(b"Rect".to_vec(), num_array(&rect.unwrap_or(beside)));
pd.set(b"Parent".to_vec(), Object::Ref(r));
pd.set(b"Open".to_vec(), Object::Bool(open.unwrap_or(false)));
pd.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE));
let pref = doc.add(Object::Dict(pd));
doc.update_dict(r, |d| d.set(b"Popup".to_vec(), Object::Ref(pref)))?;
let mut list = annots(doc, p);
list.push(Object::Ref(pref));
set_annots(doc, p, list)?;
}
}
// A note's own `/Open` says the same thing to viewers that read it there (§12.5.6.4).
if let Some(open) = open
&& (subtype == "Text" || d.contains(b"Open"))
{
doc.update_dict(r, |d| d.set(b"Open".to_vec(), Object::Bool(open)))?;
}
Ok(())
}
/// Lock or unlock a comment (the Locked flag). A locked comment can't be moved, resized,
/// restyled or deleted; its text and replies stay editable, as in Acrobat.
pub fn set_locked(doc: &mut Document, page: usize, index: usize, locked: bool) -> Result<(), AnnotError> {
@@ -307,8 +524,9 @@ pub fn set_locked(doc: &mut Document, page: usize, index: usize, locked: bool) -
Ok(())
}
/// Mark or unmark a comment with a checkmark. Like Acrobat this adds a hidden state reply with
/// `/StateModel /Marked` by `author`; the latest one wins. It's private bookkeeping, not a status.
/// Mark or unmark a comment with a checkmark. This adds a hidden state reply with `/StateModel
/// /Marked` by `author`, in reply to `author`'s previous one when there is one; their latest one
/// wins. It's each reviewer's private bookkeeping, not a status.
pub fn set_marked(doc: &mut Document, page: usize, index: usize, marked: bool, author: &str, meta: &Meta) -> Result<usize, AnnotError> {
let (p, parent) = annot_ref(doc, page, index)?;
let pd = annot_dict(doc, parent);
@@ -319,7 +537,7 @@ pub fn set_marked(doc: &mut Document, page: usize, index: usize, marked: bool, a
let text = format!("{state} set by {}", if author.is_empty() { "unknown" } else { author });
let mut d = base_dict("Text", [0.0; 4], p, &text, author, meta);
d.set(b"Rect".to_vec(), pd.get(b"Rect").cloned().unwrap_or_else(|| num_array(&[0.0; 4])));
d.set(b"IRT".to_vec(), Object::Ref(parent));
d.set(b"IRT".to_vec(), Object::Ref(last_state_reply(doc, p, parent, author, "Marked").unwrap_or(parent)));
d.set(b"F".to_vec(), Object::Int(FLAG_HIDDEN | FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE));
d.set(b"State".to_vec(), PdfString::text(state));
d.set(b"StateModel".to_vec(), PdfString::text("Marked"));
@@ -331,6 +549,56 @@ pub fn set_marked(doc: &mut Document, page: usize, index: usize, marked: bool, a
Ok(i)
}
/// `author`'s latest `model` state reply about `target` on page `p`, for the next change to reply
/// to: ISO 32000-2 §12.5.6.3, "Additional state changes shall be made by adding text annotations
/// in reply to the previous reply for a given user". A state reply is about the
/// annotation its `/IRT` chain reaches past other state replies; a loop reaches nothing. Each
/// reply's answer is kept, so a long history is walked once.
fn last_state_reply(doc: &Document, p: ObjRef, target: ObjRef, author: &str, model: &str) -> Option<ObjRef> {
use std::collections::{HashMap, HashSet};
// As the readers see it: a readable `/State` (not null, empty or another type), or a known
// `/StateModel` whose default state applies.
let is_state = |r: ObjRef| {
doc.get(r).as_dict().is_some_and(|d| {
text_value(doc, d, b"State").is_some() || matches!(text_value(doc, d, b"StateModel").as_deref(), Some("Marked" | "Review"))
})
};
let irt = |r: ObjRef| doc.get(r).as_dict().and_then(|d| d.get(b"IRT").and_then(Object::as_ref));
let mut reaches: HashMap<ObjRef, Option<ObjRef>> = HashMap::new();
let mut mine = Vec::new();
for r in annots(doc, p).iter().filter_map(Object::as_ref).filter(|&r| r != target) {
let d = annot_dict(doc, r);
if text_value(doc, &d, b"StateModel").as_deref() != Some(model) || text_value(doc, &d, b"T").unwrap_or_default() != author.trim() {
continue;
}
let (mut path, mut seen, mut at) = (vec![r], HashSet::from([r]), irt(r));
let about = loop {
let Some(up) = at else { break None };
if let Some(&known) = reaches.get(&up) {
break known;
}
if !is_state(up) {
break Some(up);
}
if !seen.insert(up) {
break None;
}
path.push(up);
at = irt(up);
};
for x in path {
reaches.insert(x, about);
}
if about == Some(target) {
mine.push(r);
}
}
// The end of the chain, whatever order the page lists it in: a reply none of the others
// replies to; among several (changes that don't chain), the last listed.
let answered: HashSet<ObjRef> = mine.iter().filter_map(|&r| irt(r)).collect();
mine.iter().rev().copied().find(|r| !answered.contains(r))
}
/// Move a comment (and its pop-up) by `(dx, dy)` points. The appearance moves with `/Rect`.
pub fn move_annotation(doc: &mut Document, page: usize, index: usize, dx: f64, dy: f64, meta: &Meta) -> Result<(), AnnotError> {
if !finite(&[dx, dy]) {
@@ -459,7 +727,7 @@ fn text_extent(d: &Dict, wrap: f64) -> [f64; 2] {
let (_, size) = appearance::parse_da(d);
let lines = appearance::wrap(&text, size, wrap.max(1.0));
let widest = lines.iter().map(|l| appearance::text_width(l, size)).fold(0.0, f64::max);
[widest, lines.len().max(1) as f64 * size * 1.2]
[widest, appearance::text_lines_height(&lines, size)]
}
/// The width and height of FreeText `d`'s text box `tb` as displayed: swapped on a page turned by
@@ -531,10 +799,12 @@ pub fn set_border_shape(doc: &mut Document, page: usize, index: usize, shape: Bo
/// At most this many points reshape a comment (a drawn polygon has a handful).
const MAX_POINTS: usize = 100_000;
/// Reshape a line, connected lines, a polygon or a cloud: its points in user space (a line's two
/// ends, the others' corners), as dragging the comment's handles sets them. `/Rect` follows the
/// points with room for the line's width and endings, as when the comment was drawn, and the
/// appearance is redrawn. A measurement keeps the points it measured.
/// Reshape a line, connected lines, a polygon, a cloud or a callout: its points in user space (a
/// line's two ends, the others' corners; a callout's arrow point and, if it has one, its knee), as
/// dragging the comment's handles sets them. `/Rect` follows the points with room for the line's
/// width and endings, as when the comment was drawn, and the appearance is redrawn. A callout's
/// text box stays where it is and its leader line re-attaches. A measurement keeps the points it
/// measured.
pub fn set_points(doc: &mut Document, page: usize, index: usize, points: &[[f64; 2]], meta: &Meta) -> Result<(), AnnotError> {
let (_, r) = annot_ref(doc, page, index)?;
unlocked(doc, r)?;
@@ -545,22 +815,30 @@ pub fn set_points(doc: &mut Document, page: usize, index: usize, points: &[[f64;
if points.len() > MAX_POINTS {
return Err(AnnotError::Invalid(format!("at most {MAX_POINTS} points")));
}
let style = Style { width: border_width_of(&d), ..Style::default() };
if d.name(b"Subtype") == Some(b"FreeText") && callout_line(doc, &d).is_some() {
// A callout's points: its arrow point, then optionally its knee (a four-number `/CL`
// gains one; ISO 32000-2 Table 177 allows both forms). The arrow point alone keeps a knee.
if !matches!(points.len(), 1 | 2) || !finite(&points.concat()) {
return Err(AnnotError::Invalid("a callout has an arrow point and, optionally, a knee".into()));
}
let text_box = callout_text_box(doc, &d).ok_or_else(|| AnnotError::Invalid("the callout's text box (/RD) can't be read".into()))?;
return place_text_box(doc, r, text_box, Some(points), meta, false);
}
// The width the appearance draws with (`/BS`, else `/Border`), so the room is the drawn stroke's.
let style = Style { width: appearance::border_width(&d), ..Style::default() };
let ends = line_endings_of(&d).unwrap_or_default();
let (start, end) = (ends.first().copied().unwrap_or(LineEnding::None), ends.get(1).copied().unwrap_or(LineEnding::None));
let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
let (shape, key): (Shape, &[u8]) = match (subtype.as_str(), points) {
("Line", [from, to]) => (Shape::Line { from: *from, to: *to, start, end }, b"L"),
// Each checks the points too: finite, and far enough apart to draw.
let (rect, key): ([f64; 4], &[u8]) = match (subtype.as_str(), points) {
("Line", [from, to]) => (rect_for(&Shape::Line { from: *from, to: *to, start, end }, &style)?, b"L"),
("Line", _) => return Err(AnnotError::Invalid("a line has two ends".into())),
("PolyLine", _) => (Shape::PolyLine { vertices: points.to_vec(), start, end }, b"Vertices"),
("Polygon", _) => {
let cloud = d.get(b"BE").and_then(|b| b.as_dict()).is_some_and(|b| b.name(b"S") == Some(b"C"));
(Shape::Polygon { vertices: points.to_vec(), cloud }, b"Vertices")
}
("PolyLine", _) => (rect_for(&Shape::PolyLine { vertices: points.to_vec(), start, end }, &style)?, b"Vertices"),
// Room for the cloud as the appearance draws it: `/BE /S /C` at its own intensity
// (Table 169), which may reach further than a new cloud's intensity 1.
("Polygon", _) => (polygon_rect(points, style.width, appearance::cloud_intensity(&d))?, b"Vertices"),
(other, _) => return Err(AnnotError::Invalid(format!("{other} comments have no points to move"))),
};
// Checks the points too: finite, and far enough apart to draw.
let rect = rect_for(&shape, &style)?;
let before = d;
doc.update_dict(r, |d| {
d.set(key.to_vec(), num_array(&points.concat()));
@@ -620,6 +898,43 @@ fn resize_ink(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta) -> Res
/// leader line re-attaches and `/Rect` grows to hold it, and the appearance is redrawn
/// (`new_text`: for changed contents, see [`set_appearance`] and `draw_appearance`).
fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, new_text: bool) -> Result<(), AnnotError> {
place_text_box(doc, r, rect, None, meta, new_text)
}
/// A callout's leader line (`/CL`, ISO 32000-2 Table 177): four numbers (start, end) or six
/// (start, knee, end), as points in user space. `None` when the annotation has no usable `/CL`.
pub(crate) fn callout_line(doc: &Document, d: &Dict) -> Option<Vec<[f64; 2]>> {
// All or nothing: a non-number would shift every later number into the wrong point.
let l: Vec<f64> = doc.resolve(d.get(b"CL")?).as_array()?.iter().map(|x| doc.resolve(x).as_f64()).collect::<Option<_>>()?;
(matches!(l.len(), 4 | 6) && finite(&l)).then(|| l.as_chunks::<2>().0.to_vec())
}
/// A callout's text box: `/Rect` inset by `/RD` (ISO 32000-2 Table 177). `None` without both.
pub(crate) fn callout_text_box(doc: &Document, d: &Dict) -> Option<[f64; 4]> {
let nums = |key: &[u8]| -> Option<Vec<f64>> { doc.resolve(d.get(key)?).as_array()?.iter().map(|o| doc.resolve(o).as_f64()).collect() };
let r = nums(b"Rect").filter(|v| v.len() == 4 && finite(v))?;
let rd = nums(b"RD").filter(|v| v.len() == 4 && finite(v) && v.iter().all(|x| *x >= 0.0))?;
let r = normalize([r[0], r[1], r[2], r[3]]);
let tb = [r[0] + rd[0], r[1] + rd[1], r[2] - rd[2], r[3] - rd[3]];
(tb[2] - tb[0] >= 1.0 && tb[3] - tb[1] >= 1.0).then_some(tb)
}
/// As [`apply_text_box`]. For a callout, `lead` (its arrow point and optional knee, dragged by
/// their handles) replaces the leader line's own points. The arrow point never moves with the box
/// ("The text box moves around a stationary anchor point—the arrow on the end-point line",
/// Acrobat DC Help, 11/28/2019, p. 357), and the line follows the box as Acrobat Pro DC (a 2021
/// Continuous release) was seen to:
/// - the box moved without changing size: the knee and the end move with it (also seen in
/// Acrobat Pro 2026.002.22031);
/// - the box resized with the side the line meets left where it was (the end still on it): the
/// line doesn't change;
/// - otherwise (that side moved): the line meets the middle of that side, the knee
/// [`CALLOUT_KNEE_LINE`] out from it ([`callout_knee_on`]). Acrobat also moves the box next to
/// the arrow point then; PdfCraft leaves the box where it was put.
///
/// With `lead`, the end stays where it is while it is on the side facing the knee (dragging the
/// knee or the arrow point leaves it in Acrobat), and otherwise meets the middle of that side.
fn place_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], lead: Option<&[[f64; 2]]>, meta: &Meta, new_text: bool) -> Result<(), AnnotError> {
let rect = normalize(rect);
if !finite(&rect) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 {
return Err(AnnotError::Invalid("invalid rectangle (too small)".into()));
@@ -628,17 +943,62 @@ fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, ne
if !new_text {
refuse_foreign_text(doc, &d)?;
}
// A callout's rectangle is its text box: the leader line re-attaches and `/Rect` grows to hold it.
let callout = d.get(b"CL").and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect::<Vec<f64>>()).filter(|l| l.len() == 6);
let (outer, cl) = match callout {
Some(mut l) => {
let attach = callout_attach(rect, [l[2], l[3]]);
(l[4], l[5]) = (attach[0], attach[1]);
let pad = border_width_of(&d) / 2.0 + appearance::arrow_size(border_width_of(&d)) + 1.0;
let lead = grow(bounds([[l[0], l[1]], [l[2], l[3]]].into_iter()).unwrap_or_default(), pad);
([rect[0].min(lead[0]), rect[1].min(lead[1]), rect[2].max(lead[2]), rect[3].max(lead[3])], Some(l))
// A callout's rectangle is its text box: `/Rect` grows to hold the leader line. A four-number
// `/CL` (no knee) stays four numbers unless `lead` adds a knee.
let old_line = callout_line(doc, &d);
let old_box = callout_text_box(doc, &d);
let old_end = old_line.as_deref().and_then(<[[f64; 2]]>::last).copied();
// An arrow point alone moves the arrow: a knee the line has stays where it is.
let kept: [[f64; 2]; 2];
let lead = match (lead, old_line.as_deref()) {
(Some([point]), Some([_, knee, _])) => {
kept = [*point, *knee];
Some(&kept[..])
}
None => (rect, None),
_ => lead,
};
let line: Option<Vec<[f64; 2]>> = match (lead, old_line.as_deref()) {
(Some(lead @ [.., toward]), _) => {
let side = callout_side(rect, *toward);
let end = old_end.filter(|e| on_side(rect, side, *e)).unwrap_or_else(|| callout_attach(rect, *toward));
Some(lead.iter().copied().chain([end]).collect())
}
(Some(_), _) => None,
(None, Some(old @ [start, .., end])) => {
let (start, end) = (*start, *end);
let knee = old.get(1).filter(|_| old.len() == 3).copied();
let toward = knee.unwrap_or(start);
Some(match old_box {
// Moved, not resized: the knee and the end come along.
Some(b)
if ((rect[2] - rect[0]) - (b[2] - b[0])).abs() < CALLOUT_SLACK && ((rect[3] - rect[1]) - (b[3] - b[1])).abs() < CALLOUT_SLACK =>
{
let (dx, dy) = (rect[0] - b[0], rect[1] - b[1]);
let shift = |p: [f64; 2]| [p[0] + dx, p[1] + dy];
[start].into_iter().chain(knee.map(shift)).chain([shift(end)]).collect()
}
// Resized, the side the line meets still under its end: nothing changes.
Some(b) if on_side(rect, callout_side(b, toward), end) => old.to_vec(),
// That side moved: laid out again as a new callout is, from the side facing the
// arrow point (the box may now be on the knee's other side).
Some(_) => {
let new_knee = callout_knee(rect, start);
let end = callout_attach(rect, new_knee);
[start].into_iter().chain(knee.map(|_| new_knee)).chain([end]).collect()
}
None => [start].into_iter().chain(knee).chain([callout_attach(rect, toward)]).collect(),
})
}
(None, _) => None,
};
let (outer, cl) = match line.as_deref() {
Some(line @ [lead @ .., _]) => {
// The end is on the box, so only the arrow point and the knee need room around them.
let pad = border_width_of(&d) / 2.0 + appearance::arrow_size(border_width_of(&d)) + 1.0;
let lead_box = grow(bounds(lead.iter().copied()).unwrap_or_default(), pad);
([rect[0].min(lead_box[0]), rect[1].min(lead_box[1]), rect[2].max(lead_box[2]), rect[3].max(lead_box[3])], Some(line.concat()))
}
_ => (rect, None),
};
let (shape, width) = (BorderShape::of(&d), border_width_of(&d));
let free_text = d.name(b"Subtype") == Some(b"FreeText");
@@ -669,8 +1029,9 @@ fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, ne
}
}
/// The border width as the appearance draws it: `/BS /W`, else the third number of `/Border`
/// (another app's), else 1.
/// The width a comment's border is drawn with ([`appearance::border_width`]: `/BS /W`, else
/// `/Border`'s third number, 1 when neither gives a usable one), so what is sized around it
/// leaves room for what is drawn.
fn border_width_of(d: &Dict) -> f64 {
appearance::border_width(d)
}
@@ -703,14 +1064,66 @@ fn ink_rect_for_width(d: &Dict, old_w: f64, w: f64) -> Option<[f64; 4]> {
/// Change a comment's colour, opacity (of the whole comment), line width and/or line endings, and
/// redraw it.
///
/// A Replace Text proposal is one look: colour and opacity are the group's (`/C` is read from
/// the primary, ISO 32000-2 §12.5.6.2), so restyling either the caret or its strikeout restyles
/// and redraws the other too, in the same edit. The strikeout takes no line width (a text markup
/// annotation has no border) and neither part has line endings. All or nothing: if one member
/// can't be restyled, none is.
pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleChange, meta: &Meta) -> Result<(), AnnotError> {
let StyleChange { color, opacity, width, endings } = change;
let (_, r) = annot_ref(doc, page, index)?;
let others = replace_text_partners(doc, page, r)?;
// Check every partner before changing anything.
for &o in &others {
unlocked(doc, o)?;
}
let before: Vec<(ObjRef, Dict)> = others.iter().map(|&o| (o, annot_dict(doc, o))).collect();
let mut done: Vec<(ObjRef, Dict)> = vec![(r, annot_dict(doc, r))];
style_one(doc, r, change.clone(), meta)?;
for (o, d) in before {
let caret = d.name(b"Subtype") == Some(b"Caret");
let part = StyleChange { color: change.color, opacity: change.opacity, width: change.width.filter(|_| caret), endings: None };
let unchanged = part.color.is_none() && part.opacity.is_none() && part.width.is_none();
if unchanged {
continue;
}
if let Err(e) = style_one(doc, o, part, meta) {
// Put the members already restyled back, the target included.
for (o, d) in done.into_iter().rev() {
doc.set(o, Object::Dict(d));
}
return Err(e);
}
done.push((o, d));
}
Ok(())
}
/// The other members of the Replace Text proposal `r` belongs to, on `page`: for its caret, the
/// strikeouts grouped under it; for a strikeout, its caret and any other strikeout under it.
/// Empty for anything else.
fn replace_text_partners(doc: &Document, page: usize, r: ObjRef) -> Result<Vec<ObjRef>, AnnotError> {
let own = Object::Ref(r);
let caret = match replaced_by(doc, &own) {
Some(c) => c,
None if annot_dict(doc, r).name(b"Subtype") == Some(b"Caret") => r,
None => return Ok(Vec::new()),
};
let list = annots(doc, page_ref(doc, page)?);
let strikes = list.iter().filter(|e| replaced_by(doc, e) == Some(caret)).filter_map(Object::as_ref);
// `/Annots` may list one annotation twice: restyle each once.
let mut seen: HashSet<ObjRef> = HashSet::from([r]);
Ok(std::iter::once(caret).chain(strikes).filter(|&o| seen.insert(o)).collect())
}
/// [`set_style`] on one annotation.
fn style_one(doc: &mut Document, r: ObjRef, change: StyleChange, meta: &Meta) -> Result<(), AnnotError> {
let StyleChange { color, opacity, width, endings } = change;
unlocked(doc, r)?;
let d = annot_dict(doc, r);
let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
// Check before changing anything: a stale appearance would contradict the new style.
if appearance::build(doc, &d).is_none() {
if !crate::draw::can_draw(doc, &d) {
return Err(AnnotError::Unsupported(subtype.clone()));
}
if color.is_some_and(|c| !finite(&c)) || opacity.is_some_and(|o| !o.is_finite()) || width.is_some_and(|w| !w.is_finite()) {
@@ -725,13 +1138,19 @@ pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleCha
}
refuse_foreign_text(doc, &d)?;
let free_text = subtype == "FreeText";
// The face and size the text is drawn in, which the new colour keeps.
let drawn = text_style::text_style(&d, text_style::dr_face(doc, &d));
// Typewriter text has no box of its own: a border goes around its text, so the box grows
// (or shrinks) by the change on every side and the text keeps its place and its lines.
// So does a text box inside an oval or a circle, which goes around it again.
let shape = if free_text { BorderShape::of(&d) } else { BorderShape::Rectangle };
let typewriter = free_text && (d.name(b"IT") == Some(b"FreeTextTypeWriter") || shape != BorderShape::Rectangle) && !d.contains(b"CL");
// A border style given by reference (`/BS 5 0 R`, ISO 32000-2 §7.3.10): its width and other
// entries, which a new width keeps.
let direct = crate::draw::with_direct_values(doc, &d);
let (old_width, old_bs) = (border_width_of(&direct), direct.get(b"BS").and_then(Object::as_dict).cloned());
let regrow = width.filter(|_| typewriter).and_then(|w| {
let by = w.max(0.0) - if d.contains(b"BS") || d.contains(b"Border") { border_width_of(&d) } else { 0.0 };
let by = w.max(0.0) - if d.contains(b"BS") || d.contains(b"Border") { old_width } else { 0.0 };
let rect = grow(text_box_of(&d)?, by);
(by != 0.0 && finite(&rect) && rect[2] - rect[0] >= 1.0 && rect[3] - rect[1] >= 1.0).then_some((rect, w.max(0.0)))
});
@@ -742,9 +1161,7 @@ pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleCha
}
if let Some(c) = color {
if free_text {
let (_, size) = appearance::parse_da(d);
d.set(b"DA".to_vec(), free_text_da(c, appearance::da_border(d), size));
rich_text(d);
restyle_text(d, TextStyle { color: c, ..drawn });
} else {
d.set(b"C".to_vec(), rgb(c));
}
@@ -755,9 +1172,9 @@ pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleCha
set_part_keys(d, appearance::PartOpacity { line: o, fill: o, text: o });
}
if let Some(w) = width {
let old_w = border_width_of(d);
let old_w = old_width;
let w = w.max(0.0);
let mut bs = d.get(b"BS").and_then(|b| b.as_dict()).cloned().unwrap_or_default();
let mut bs = old_bs.unwrap_or_default();
bs.set(b"W".to_vec(), Object::Real(w));
d.set(b"BS".to_vec(), Object::Dict(bs));
d.remove(b"Border");
@@ -803,11 +1220,11 @@ pub fn set_border_color(doc: &mut Document, page: usize, index: usize, color: Op
return Err(AnnotError::Invalid("invalid colour".into()));
}
refuse_foreign_text(doc, &d)?;
// The face, size and colour the text is drawn in, which the border colour keeps.
let drawn = text_style::text_style(&d, text_style::dr_face(doc, &d));
let before = d;
doc.update_dict(r, |d| {
let (text, size) = appearance::parse_da(d);
d.set(b"DA".to_vec(), free_text_da(text, color, size));
rich_text(d);
restyle_text_bordered(d, drawn, color);
touch(d, meta);
})?;
redraw_or_restore(doc, r, before)
@@ -828,11 +1245,11 @@ pub fn set_font_size(doc: &mut Document, page: usize, index: usize, size: f64, m
}
refuse_foreign_text(doc, &d)?;
let snug = is_snug(&d);
// The face and colour the text is drawn in, which the new size keeps.
let drawn = text_style::text_style(&d, text_style::dr_face(doc, &d));
let before = d;
doc.update_dict(r, |d| {
let (col, _) = appearance::parse_da(d);
d.set(b"DA".to_vec(), free_text_da(col, appearance::da_border(d), size));
rich_text(d);
restyle_text(d, TextStyle { size, ..drawn });
touch(d, meta);
})?;
let text = text_value(doc, &annot_dict(doc, r), b"Contents").unwrap_or_default();
@@ -871,7 +1288,7 @@ pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option<Rgb>
return Err(AnnotError::Invalid("only rectangles, ovals, polygons and text boxes can be filled".into()));
}
// Check before changing anything: a stale appearance would contradict the new fill.
if appearance::build(doc, &d).is_none() {
if !crate::draw::can_draw(doc, &d) {
return Err(AnnotError::Unsupported(subtype));
}
if fill.is_some_and(|c| !finite(&c)) {
@@ -912,7 +1329,7 @@ pub fn set_part_opacity(doc: &mut Document, page: usize, index: usize, fill: Opt
return Err(AnnotError::Invalid("invalid opacity".into()));
}
// Check before changing anything: a stale appearance would contradict the new opacity.
if appearance::build(doc, &d).is_none() {
if !crate::draw::can_draw(doc, &d) {
return Err(AnnotError::Unsupported(subtype));
}
refuse_foreign_text(doc, &d)?;
@@ -984,11 +1401,17 @@ fn pair_points(d: &Dict, key: &[u8]) -> Option<Vec<[f64; 2]>> {
}
/// Replace Text (Acrobat's proposal): strike out `quads` and add a caret at the end of the
/// struck text holding `replacement`, grouped with the strikeout (`/IRT` + `/RT /Group`) so they
/// move, list and delete as one. Returns the strikeout's index.
/// struck text holding `replacement`, grouped (ISO 32000-2 §12.5.6.2) so they list, select and
/// delete as one. Returns the caret's index, the group's comment.
///
/// The caret is the group's primary: it holds `replacement` in `/Contents` and has `/IT
/// /Replace`. The strikeout is its subordinate (`/IRT` to the caret, `/RT /Group`, `/IT
/// /StrikeOutTextEdit`) with no `/Contents`, since a reader ignores a subordinate's text.
/// Groups written before this the other way round (the strikeout primary, the text only on
/// the caret) still list their text: see `pdfcraft_render::fill_group_text`.
///
/// The two annotations need their own `/NM`: XFDF names a reply by the `/NM` of what it replies
/// to, so one id for both writes `inreplyto` equal to the caret's own `name`, and a reader that
/// to, so one id for both writes `inreplyto` equal to the strikeout's own `name`, and a reader that
/// resolves names (Acrobat, and our own importer) loses the pair. `caret_meta` is a second id
/// from the caller, so the edit stays deterministic.
pub fn add_text_replacement(doc: &mut Document, replacement: TextReplacement<'_>) -> Result<usize, AnnotError> {
@@ -1006,7 +1429,6 @@ pub fn add_text_replacement(doc: &mut Document, replacement: TextReplacement<'_>
meta,
)?;
let (_, sr) = annot_ref(doc, page, s)?;
doc.update_dict(sr, |d| d.set(b"IT".to_vec(), Object::name("StrikeOutTextEdit")))?;
// The caret sits on the baseline at the end of the last line, its point at the text.
let xs = [last[0], last[2], last[4], last[6]];
let ys = [last[1], last[3], last[5], last[7]];
@@ -1021,11 +1443,16 @@ pub fn add_text_replacement(doc: &mut Document, replacement: TextReplacement<'_>
)?;
let (_, cr) = annot_ref(doc, page, c)?;
doc.update_dict(cr, |d| {
d.set(b"IRT".to_vec(), Object::Ref(sr));
d.set(b"RT".to_vec(), Object::name("Group"));
d.set(b"IT".to_vec(), Object::name("Replace"));
d.set(b"Subj".to_vec(), PdfString::text("Replace Text"));
})?;
Ok(s)
doc.update_dict(sr, |d| {
d.set(b"IT".to_vec(), Object::name("StrikeOutTextEdit"));
d.set(b"IRT".to_vec(), Object::Ref(cr));
d.set(b"RT".to_vec(), Object::name("Group"));
d.remove(b"Contents");
})?;
Ok(c)
}
/// The Eraser (Draw tools): remove the parts of drawing `(page, index)` within `radius` points
+2663 -21
View File
File diff suppressed because it is too large. Load diff
+480
View File
@@ -0,0 +1,480 @@
//! The face, size and colour a text box's text is drawn in when PdfCraft draws its appearance:
//! `/DA` (ISO 32000-2 Table 177; the font named there is looked up in the interactive form's
//! `/DR` font resources, §12.7.4.3) and `/DS`, the default style string (Table 177), which is
//! read for its CSS `font`, `font-family`, `font-size`, `font-weight`, `font-style` and `color`.
//!
//! Only the twelve standard text faces are drawn (Helvetica, Times and Courier, each regular,
//! bold, italic and bold italic, in WinAnsiEncoding): a font that isn't one of them is drawn in
//! Helvetica, as before. Symbol and ZapfDingbats are not text faces for this purpose.
use pdfcraft_cos::{Dict, Document, Object};
use pdfcraft_fonts::{Std14, Std14Match as Match};
use crate::Rgb;
use crate::appearance::parse_da;
/// `/DS` past this many bytes is read only this far (a style string is a line or two).
const MAX_DS: usize = 4096;
/// Font sizes a text box is drawn at, as for `/DA`.
const MAX_SIZE: f64 = 400.0;
/// How a text box's text is drawn.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TextStyle {
pub face: Std14,
pub size: f64,
pub color: Rgb,
}
/// The style of `d`'s text: `/DA`, with what `/DS` sets taking its place. `dr_face` is the face
/// the `/DA` font resource names in the form's `/DR` ([`dr_face`]); `None` when it isn't there,
/// and the common resource names (`/Helv`, `/Cour`, `/TiRo`, …) are read instead.
pub fn text_style(d: &Dict, dr_face: Option<Std14>) -> TextStyle {
let (da_color, da_size) = parse_da(d);
let da_face = dr_face.or_else(|| da_font_name(d).as_deref().and_then(resource_face)).unwrap_or(Std14::Helvetica);
let mut style = TextStyle { face: da_face, size: da_size, color: da_color };
let Some(ds) = d.get(b"DS").and_then(Object::as_string).map(|s| s.to_text()) else { return style };
let ds = parse_ds(&ds);
if let Some(size) = ds.size {
style.size = size;
}
// `/DS` colours are 8-bit (`#RRGGBB`): one that rounds to the same as `/DA`'s keeps `/DA`'s.
if let Some(color) = ds.color
&& bytes(color) != bytes(da_color)
{
style.color = color;
}
let base = ds.face.unwrap_or(style.face);
style.face = styled(base, ds.bold.unwrap_or(is_bold(base)), ds.italic.unwrap_or(is_italic(base)));
style
}
/// The face the `/DA` font of `d` names in the document's `/AcroForm /DR /Font` (§12.7.4.3):
/// `Some` when that resource is there — the standard text face its `/BaseFont` names, or
/// Helvetica for any other font — and `None` when it isn't.
pub fn dr_face(doc: &Document, d: &Dict) -> Option<Std14> {
let name = da_font_name(d)?;
let root = doc.trailer().get(b"Root").map(|r| doc.resolve(r))?;
let form = root.as_dict()?.get(b"AcroForm").map(|f| doc.resolve(f))?;
let dr = form.as_dict()?.get(b"DR").map(|r| doc.resolve(r))?;
let fonts = dr.as_dict()?.get(b"Font").map(|f| doc.resolve(f))?;
let font = fonts.as_dict()?.get(name.as_bytes()).map(|f| doc.resolve(f))?;
let font = font.as_dict()?;
let base = font.name(b"BaseFont").map(String::from_utf8_lossy);
Some(base.as_deref().and_then(base_font_face).unwrap_or(Std14::Helvetica))
}
/// The font resource name of the last `Tf` in `/DA` (without the slash). `Tf` takes a name and
/// a size; a name given without its size (`/TiRo Tf`) is still read, and the size then falls back
/// as [`parse_da`] says.
pub fn da_font_name(d: &Dict) -> Option<String> {
let da = d.get(b"DA").and_then(Object::as_string)?;
let da = String::from_utf8_lossy(&da.bytes);
let toks: Vec<&str> = da.split_whitespace().collect();
let tf = toks.iter().rposition(|t| *t == "Tf")?;
let name = match toks.get(..tf)? {
[.., name, size] if !size.starts_with('/') => name,
[.., name] => name,
[] => return None,
};
let name = name.strip_prefix('/')?;
(!name.is_empty()).then(|| name.to_string())
}
/// The face a font resource name stands for: the names PDF writers commonly give the standard
/// fonts in `/DR` (`Helv`, `HeBo`, `Cour`, `TiRo`, …), or a standard face's own name.
pub fn resource_face(name: &str) -> Option<Std14> {
Some(match name {
"Helv" => Std14::Helvetica,
"HeBo" => Std14::HelveticaBold,
"HeOb" | "HeIt" => Std14::HelveticaOblique,
"HeBO" | "HeBI" => Std14::HelveticaBoldOblique,
"Cour" => Std14::Courier,
"CoBo" => Std14::CourierBold,
"CoOb" | "CoIt" => Std14::CourierOblique,
"CoBO" | "CoBI" => Std14::CourierBoldOblique,
"TiRo" => Std14::TimesRoman,
"TiBo" => Std14::TimesBold,
"TiIt" => Std14::TimesItalic,
"TiBI" => Std14::TimesBoldItalic,
_ => return base_font_face(name),
})
}
/// The standard text face a `/BaseFont` (or family) name asks for: the spec's names and their
/// usual aliases (Arial, Times New Roman, Courier New), not a guess from a family hint.
fn base_font_face(name: &str) -> Option<Std14> {
match Std14::recognise(name)? {
(Std14::Symbol | Std14::ZapfDingbats, _) | (_, Match::FamilyHint) => None,
(face, Match::Alias) => Some(face),
}
}
/// `CSS` family names: the generic ones, else a standard face's name or alias.
fn family_face(name: &str) -> Option<Std14> {
let name = name.trim().trim_matches(|c| c == '"' || c == '\'').trim();
match name.to_ascii_lowercase().as_str() {
"serif" => Some(Std14::TimesRoman),
"monospace" => Some(Std14::Courier),
"sans-serif" => Some(Std14::Helvetica),
_ => base_font_face(name),
}
}
fn is_bold(f: Std14) -> bool {
matches!(
f,
Std14::HelveticaBold
| Std14::HelveticaBoldOblique
| Std14::TimesBold
| Std14::TimesBoldItalic
| Std14::CourierBold
| Std14::CourierBoldOblique
)
}
fn is_italic(f: Std14) -> bool {
matches!(
f,
Std14::HelveticaOblique
| Std14::HelveticaBoldOblique
| Std14::TimesItalic
| Std14::TimesBoldItalic
| Std14::CourierOblique
| Std14::CourierBoldOblique
)
}
/// `base`'s family in the given weight and slant.
fn styled(base: Std14, bold: bool, italic: bool) -> Std14 {
use Std14::*;
match (base, bold, italic) {
(TimesRoman | TimesBold | TimesItalic | TimesBoldItalic, false, false) => TimesRoman,
(TimesRoman | TimesBold | TimesItalic | TimesBoldItalic, true, false) => TimesBold,
(TimesRoman | TimesBold | TimesItalic | TimesBoldItalic, false, true) => TimesItalic,
(TimesRoman | TimesBold | TimesItalic | TimesBoldItalic, true, true) => TimesBoldItalic,
(Courier | CourierBold | CourierOblique | CourierBoldOblique, false, false) => Courier,
(Courier | CourierBold | CourierOblique | CourierBoldOblique, true, false) => CourierBold,
(Courier | CourierBold | CourierOblique | CourierBoldOblique, false, true) => CourierOblique,
(Courier | CourierBold | CourierOblique | CourierBoldOblique, true, true) => CourierBoldOblique,
(_, false, false) => Helvetica,
(_, true, false) => HelveticaBold,
(_, false, true) => HelveticaOblique,
(_, true, true) => HelveticaBoldOblique,
}
}
/// The resource name an appearance stream gives `face` (the usual `/DR` names).
pub fn resource_name(face: Std14) -> &'static str {
match face {
Std14::HelveticaBold => "HeBo",
Std14::HelveticaOblique => "HeOb",
Std14::HelveticaBoldOblique => "HeBO",
Std14::TimesRoman => "TiRo",
Std14::TimesBold => "TiBo",
Std14::TimesItalic => "TiIt",
Std14::TimesBoldItalic => "TiBI",
Std14::Courier => "Cour",
Std14::CourierBold => "CoBo",
Std14::CourierOblique => "CoOb",
Std14::CourierBoldOblique => "CoBO",
_ => "Helv",
}
}
/// `face`'s `CSS` family, weight and style, for writing `/DS` and `/RC`.
pub fn css(face: Std14) -> (&'static str, &'static str, &'static str) {
let family = match styled(face, false, false) {
Std14::TimesRoman => "Times",
Std14::Courier => "Courier",
_ => "Helvetica",
};
(family, if is_bold(face) { "bold" } else { "normal" }, if is_italic(face) { "italic" } else { "normal" })
}
fn bytes(c: Rgb) -> [u8; 3] {
// Clamped to 0..=1 first, so the product is 0..=255 and the cast exact.
c.map(|x| (x.clamp(0.0, 1.0) * 255.0).round() as u8)
}
/// What a `/DS` string sets.
#[derive(Debug, Default, PartialEq)]
struct Ds {
face: Option<Std14>,
bold: Option<bool>,
italic: Option<bool>,
size: Option<f64>,
color: Option<Rgb>,
}
/// Read a default style string: `;`-separated `CSS` declarations. Unknown or malformed ones are
/// skipped; later ones win.
fn parse_ds(ds: &str) -> Ds {
let end = ds.char_indices().map(|(i, _)| i).find(|i| *i >= MAX_DS).unwrap_or(ds.len());
let ds = ds.get(..end).unwrap_or_default();
let mut out = Ds::default();
for decl in ds.split(';') {
let Some((prop, value)) = decl.split_once(':') else { continue };
let value = value.trim();
match prop.trim().to_ascii_lowercase().as_str() {
"font" => font_shorthand(value, &mut out),
"font-family" => {
if let Some(face) = value.split(',').find_map(family_face) {
out.face = Some(face);
}
}
"font-size" => {
if let Some(size) = font_size(value, true) {
out.size = Some(size);
}
}
"font-weight" => {
if let Some(bold) = weight(value) {
out.bold = Some(bold);
}
}
"font-style" => {
if let Some(italic) = slant(value) {
out.italic = Some(italic);
}
}
"color" => {
if let Some(c) = css_color(value) {
out.color = Some(c);
}
}
_ => {}
}
}
out
}
/// `font: [style] [weight] size[/line-height] family[, family…]`, read leniently: the size is
/// the token with a unit, keywords set weight and slant, and the rest is the family list (some
/// writers put the family first).
fn font_shorthand(value: &str, out: &mut Ds) {
let mut family = Vec::new();
for tok in value.split_whitespace() {
if let Some(size) = font_size(tok, false) {
out.size = Some(size);
} else if let Some(bold) = weight(tok).filter(|_| !tok.eq_ignore_ascii_case("normal")) {
out.bold = Some(bold);
} else if let Some(italic) = slant(tok).filter(|_| !tok.eq_ignore_ascii_case("normal")) {
out.italic = Some(italic);
} else if !matches!(tok.to_ascii_lowercase().as_str(), "normal" | "small-caps") {
family.push(tok);
}
}
if let Some(face) = family.join(" ").split(',').find_map(family_face) {
out.face = Some(face);
}
}
/// A size in points: `14pt` (with a line height, `14pt/16pt`), or a bare number where
/// `unitless` allows it. Positive and finite only; capped as `/DA` sizes are.
fn font_size(tok: &str, unitless: bool) -> Option<f64> {
let tok = tok.split('/').next()?.trim();
let num = match tok.strip_suffix("pt").or_else(|| tok.strip_suffix("PT")) {
Some(n) => n,
None if unitless => tok,
None => return None,
};
num.parse::<f64>().ok().filter(|s| s.is_finite() && *s > 0.0).map(|s| s.min(MAX_SIZE))
}
fn weight(v: &str) -> Option<bool> {
match v.to_ascii_lowercase().as_str() {
"bold" | "bolder" | "600" | "700" | "800" | "900" => Some(true),
"normal" | "lighter" | "100" | "200" | "300" | "400" | "500" => Some(false),
_ => None,
}
}
fn slant(v: &str) -> Option<bool> {
match v.to_ascii_lowercase().as_str() {
"italic" | "oblique" => Some(true),
"normal" => Some(false),
_ => None,
}
}
/// `#RRGGBB`, `#RGB` or `rgb(r, g, b)` (0–255 or percentages).
fn css_color(v: &str) -> Option<Rgb> {
let v = v.trim();
if let Some(hex) = v.strip_prefix('#') {
let digit = |c: char| c.to_digit(16).map(f64::from);
let ds: Vec<f64> = hex.chars().map(digit).collect::<Option<_>>()?;
return match ds.as_slice() {
[r, g, b] => Some([r * 17.0 / 255.0, g * 17.0 / 255.0, b * 17.0 / 255.0]),
[r1, r0, g1, g0, b1, b0] => Some([(r1 * 16.0 + r0) / 255.0, (g1 * 16.0 + g0) / 255.0, (b1 * 16.0 + b0) / 255.0]),
_ => None,
};
}
let lower = v.to_ascii_lowercase();
let inner = lower.strip_prefix("rgb(")?.strip_suffix(')')?;
let parts: Vec<f64> = inner
.split(',')
.map(|p| {
let p = p.trim();
let x = match p.strip_suffix('%') {
Some(pc) => pc.trim().parse::<f64>().ok()? / 100.0,
None => p.parse::<f64>().ok()? / 255.0,
};
x.is_finite().then(|| x.clamp(0.0, 1.0))
})
.collect::<Option<_>>()?;
match parts.as_slice() {
[r, g, b] => Some([*r, *g, *b]),
_ => None,
}
}
#[cfg(test)]
mod tests {
use pdfcraft_cos::PdfString;
use super::*;
fn dict(da: &str, ds: Option<&str>) -> Dict {
let mut d = Dict::new();
d.set(b"DA".to_vec(), PdfString::literal(da.as_bytes().to_vec()));
if let Some(ds) = ds {
d.set(b"DS".to_vec(), PdfString::text(ds));
}
d
}
#[test]
fn da_resource_names_pick_the_standard_faces() {
assert_eq!(text_style(&dict("0 g /Cour 12 Tf", None), None).face, Std14::Courier);
assert_eq!(text_style(&dict("1 0 0 rg /TiRo 16 Tf", None), None).face, Std14::TimesRoman);
assert_eq!(text_style(&dict("/HeBo 9 Tf 0 g", None), None).face, Std14::HelveticaBold);
assert_eq!(text_style(&dict("/Courier-Bold 9 Tf", None), None).face, Std14::CourierBold);
// Unknown, symbolic and family-hint-only names keep Helvetica.
for da in ["/F1 12 Tf", "/ZaDb 12 Tf", "/Symbol 12 Tf", "/MySerif 12 Tf", "12 Tf", "", "/ Tf"] {
assert_eq!(text_style(&dict(da, None), None).face, Std14::Helvetica, "{da:?}");
}
// A `/DR` lookup outranks the name.
assert_eq!(text_style(&dict("/Cour 12 Tf", None), Some(Std14::TimesRoman)).face, Std14::TimesRoman);
}
#[test]
fn ds_font_size_and_colour_win_over_da() {
let s = text_style(&dict("0 g /Helv 12 Tf", Some("font: Times 14pt; color: #008000")), None);
assert_eq!((s.face, s.size), (Std14::TimesRoman, 14.0));
assert!((s.color[1] - 128.0 / 255.0).abs() < 1e-9 && s.color[0] == 0.0 && s.color[2] == 0.0, "{:?}", s.color);
let s = text_style(
&dict(
"1 0 0 rg /Cour 9 Tf",
Some("font-family: 'Helvetica', sans-serif; font-size: 20; font-weight: bold; font-style: italic; color: rgb(0, 0, 255)"),
),
None,
);
assert_eq!((s.face, s.size, s.color), (Std14::HelveticaBoldOblique, 20.0, [0.0, 0.0, 1.0]));
// CSS order, a line height, a #RGB colour and a generic family.
let s = text_style(&dict("0 g /Helv 12 Tf", Some("font: italic bold 10pt/12pt serif; color:#f00")), None);
assert_eq!((s.face, s.size, s.color), (Std14::TimesBoldItalic, 10.0, [1.0, 0.0, 0.0]));
// Bold from the style keeps the DA family.
assert_eq!(text_style(&dict("/Cour 12 Tf", Some("font-weight:700")), None).face, Std14::CourierBold);
}
/// PdfCraft writes `/DS` from `/DA` (8-bit colour): `/DA`'s exact colour, size and face stay.
#[test]
fn pdfcraft_written_ds_matches_da() {
let s = text_style(&dict("0.1 0.2 0.3 rg /Helv 12.5 Tf", Some("font: Helvetica 12.5pt; text-align:left; color:#1A334D")), None);
assert_eq!(s, TextStyle { face: Std14::Helvetica, size: 12.5, color: [0.1, 0.2, 0.3] });
}
#[test]
fn malformed_ds_falls_back_to_da() {
let da = TextStyle { face: Std14::Courier, size: 9.0, color: [1.0, 0.0, 0.0] };
let huge = format!("color: #00ff00;{}", "x".repeat(1 << 20));
let past_cap = format!("{}; font-size: 30pt", " ".repeat(MAX_DS));
for ds in [
"font-size: NaN",
"font-size: inf",
"font-size: -3pt",
"font-size: 0",
"font: NaNpt Zzz",
"font: 1e999pt",
"color: #12",
"color: #zzzzzz",
"color: rgb(1, 2)",
"color: rgb(NaN, 0, 0)",
"color: #ff0000é",
":::;;;",
"font-family: Wingdings, 'Comic Sans'",
"é: ü; ;",
past_cap.as_str(),
] {
assert_eq!(text_style(&dict("1 0 0 rg /Cour 9 Tf", Some(ds)), None), da, "{ds:?}");
}
// Read up to the cap: what comes first still counts, and nothing past it is scanned.
assert_eq!(text_style(&dict("1 0 0 rg /Cour 9 Tf", Some(&huge)), None).color, [0.0, 1.0, 0.0]);
// Sizes are capped as /DA's are.
assert_eq!(text_style(&dict("/Helv 9 Tf", Some("font-size: 1e300pt")), None).size, MAX_SIZE);
}
/// `/DA` strings as files give them: a `Tf` without a size, a zero size (auto-size in a form
/// field, which a text box doesn't have), and hostile operands. None panics, each keeps what it
/// can read, and nothing that isn't a finite number reaches the style.
#[test]
fn hostile_da_strings_keep_what_they_can() {
let style = |da: &str| text_style(&dict(da, None), None);
let black12 = |face| TextStyle { face, size: 12.0, color: [0.0; 3] };
for (da, want) in [
// A missing size keeps the named face at the default size.
("/TiRo Tf", black12(Std14::TimesRoman)),
("0 g /Cour Tf", black12(Std14::Courier)),
// Zero, negative, NaN, infinite or not a number: the default size.
("/Cour 0 Tf", black12(Std14::Courier)),
("/Cour -0 Tf", black12(Std14::Courier)),
("/Cour -12 Tf", black12(Std14::Courier)),
("/Cour NaN Tf", black12(Std14::Courier)),
("/Cour inf Tf", black12(Std14::Courier)),
("/Cour -inf Tf", black12(Std14::Courier)),
("/Cour 1e999 Tf", black12(Std14::Courier)),
("/Cour twelve Tf", black12(Std14::Courier)),
// No Tf at all, or one with nothing before it.
("1 0 0 rg", TextStyle { color: [1.0, 0.0, 0.0], ..black12(Std14::Helvetica) }),
("Tf", black12(Std14::Helvetica)),
("Tf Tf Tf", black12(Std14::Helvetica)),
("", black12(Std14::Helvetica)),
// Colour operands that aren't finite numbers leave the colour black.
("NaN NaN NaN rg /Helv 9 Tf", TextStyle { size: 9.0, ..black12(Std14::Helvetica) }),
("NaN g /Helv 9 Tf", TextStyle { size: 9.0, ..black12(Std14::Helvetica) }),
("inf 0 0 rg /Helv 9 Tf", TextStyle { size: 9.0, ..black12(Std14::Helvetica) }),
("rg g /Helv 9 Tf", TextStyle { size: 9.0, ..black12(Std14::Helvetica) }),
// Out-of-range colour components are clamped.
("2 -1 0.5 rg /Helv 9 Tf", TextStyle { size: 9.0, color: [1.0, 0.0, 0.5], ..black12(Std14::Helvetica) }),
// Huge sizes are capped.
("/Helv 1e300 Tf", TextStyle { size: MAX_SIZE, ..black12(Std14::Helvetica) }),
("/Helv 100000 Tf", TextStyle { size: MAX_SIZE, ..black12(Std14::Helvetica) }),
// Garbage around a valid Tf: the last Tf is read.
("\u{0} ] [ << /Helv 9 Tf >> /Cour 14 Tf )", TextStyle { size: 14.0, ..black12(Std14::Courier) }),
] {
assert_eq!(style(da), want, "{da:?}");
let s = style(da);
assert!(s.size.is_finite() && s.size > 0.0 && s.color.iter().all(|c| (0.0..=1.0).contains(c)), "{da:?}: {s:?}");
}
// A font name that is no standard face and that no `/DR` lists keeps Helvetica.
assert_eq!(style("0 g /F9 11 Tf"), TextStyle { size: 11.0, ..black12(Std14::Helvetica) });
// Binary bytes that aren't UTF-8 don't panic.
let mut d = Dict::new();
d.set(b"DA".to_vec(), PdfString::literal(vec![0xFF, 0xFE, b' ', b'/', 0xC3, b' ', b'9', b' ', b'T', b'f']));
assert_eq!(text_style(&d, None).size, 9.0);
assert_eq!(da_font_name(&dict("/TiRo Tf", None)).as_deref(), Some("TiRo"));
assert_eq!(da_font_name(&dict("12 Tf", None)), None);
assert_eq!(da_font_name(&dict("0 g Tf", None)), None);
}
#[test]
fn css_round_trips_through_the_parser() {
for face in [Std14::Helvetica, Std14::TimesBold, Std14::CourierOblique, Std14::HelveticaBoldOblique, Std14::TimesBoldItalic] {
let (family, weight, style) = css(face);
let ds = format!("font-family:{family};font-weight:{weight};font-style:{style}");
assert_eq!(text_style(&dict("/Helv 9 Tf", Some(&ds)), None).face, face);
}
}
}
+5 -1
View File
@@ -417,13 +417,17 @@ pub enum Shape {
contours: Vec<Vec<[f64; 2]>>,
},
/// A custom stamp: a picture already in the document (an image XObject, or a form XObject
/// whose `/Matrix` maps it to `size` points) filling `rect`, named `name`.
/// whose `/Matrix` maps it to `size` points) filling `rect`, named `name`. With `line`, a
/// line of text under the picture (#1741): a dynamic stamp's name, date and time, filled in
/// when it was placed; the picture then fills the rectangle above it
/// ([`CUSTOM_LINE_BAND`](crate::CUSTOM_LINE_BAND)).
CustomStamp {
rect: [f64; 4],
name: String,
picture: ObjRef,
image: bool,
size: (f64, f64),
line: Option<String>,
},
/// A redaction mark (§12.5.6.23) over quadrilaterals (text) or one rectangle as a quad
/// (areas, pages). `overlay` is the text shown on the box once applied, drawn with `look`.
+1 -1
View File
@@ -3,7 +3,7 @@
Layer L4 (may use `edit`): verification tooling, the faux-redaction audit.
```rust
let findings: Vec<Finding> = audit_redactions(&doc)?; // page, kind, rect, covered_text
let findings: Vec<AuditFinding> = audit_redactions(&doc)?; // page, kind, rect, covered_text
```
It reports two kinds of finding:
+24 -3
View File
@@ -61,7 +61,8 @@ do not keep an old PDF byte buffer alive.
This bounds retained text-cache data, not total process memory: active extraction batches,
renderers, document buffers and the requested output have separate lifetimes. Search still reads
growing batches (8 to 128 pages), with one lookahead match to determine continuation.
growing batches (8 to 128 pages), with one lookahead match to determine continuation, unless
`stop_at_limit` is set (below), which starts at one page and looks no further than its limit.
`text_find` keeps matches from readable pages when another reached page fails extraction. It
returns `warnings` with 1-based `page`, `error` and `error_truncated`, plus `warning_count` and
@@ -75,6 +76,16 @@ that reaches the end with warnings therefore has `complete: false` **without** `
should follow `next` when present and separately inspect warnings. A capped search can return
both. The existing `query`, `count`, `matches` and rectangle semantics are unchanged.
That lookahead means a search with exactly `limit` matches reads on to the end of the document to
learn that no further match exists. `stop_at_limit: true` skips it: the search ends at the
`limit`th match. It reads one page first, then batches of 2, 4, … up to 128 pages, so nothing past
the batch holding that match is read or can add warnings (a match on the first page reads only that
page). The answer carries
`stopped_at_limit`. When that is `true`, `complete` is `false` and `next` is only the position just
past the last match, not a promise of another one; following it may return no matches and
`complete: true`. When it is `false`, the search ran out of pages first and `complete` means what
it always does.
`text_extract`, comment placement and redaction keep strict all-requested-pages-must-succeed
semantics; they never silently act on partial text.
@@ -146,7 +157,10 @@ claude mcp add pdfcraft -- pdfcraft-cli mcp --root <dir>
`object_list {doc, page}` inventories recognised paragraphs, Image/Form artwork and added items
with generation-scoped kind/index references. `object_move {doc, page, objects, offset, generation?}`
moves 1–1000 references atomically in displayed points (right/down), preserving source glyph bytes
and fonts. A refusal changes nothing; success is one undo step. See
and fonts. A refusal changes nothing; success is one undo step. `all: true` takes every object on the
page instead, as ⌘A does. `object_copy {doc, page, objects | all, cut?}` copies them with every
resource they use and `object_paste {doc, page}` pastes them as page content that draws the same,
into the same or another open document, stepped as the app steps a paste. See
[the selection and movement guide](../../docs/editing-multiple-objects.md) for limits and examples.
## Accessibility review record
@@ -285,7 +299,14 @@ Example: `form_set_props doc=1 field=agree export_value=Accepted widget=1`.
reference) to a PNG, without the viewer. `model` picks the page's Nth 3D model (default 1);
`width` and `height` are 1 to 4096 (default 512); `direction` is the view direction from the eye
(default `[-1, 1, -1]`, with Z up unless the view looks along Z); `perspective` defaults to true.
The model is framed to fill the image and shaded by a headlight on white. U3D models are refused
The model is framed to fill the image and shaded by a headlight on white. `orbit`, instead of
`direction`, frames it as a viewer's orbit control would: `{view, up, front, turn, zoom,
zoom_at, pan}`, all optional. `view` is `iso` (default), `front`, `right`, `top` or `back`
about the `up` axis (default `+z`) and the `front` axis (perpendicular to `up`); `turn` is
`[yaw, pitch]` in degrees, pitch down; `zoom` is a positive factor, the result held within 0.05 to 50, at `zoom_at` `[right, up]`
in picture heights from the centre, where the point under it stays still; `pan` `[right, up]`
moves the target, in model radii, so the model moves the other way. Every view frames the model
at the same size, whatever its direction. U3D models are refused
with an error, and the annotation's own views, lighting and render modes are not applied yet.
## Viewer navigation history
+55 -16
View File
@@ -6,10 +6,10 @@ use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::collections::BTreeSet;
use crate::{Args, Automation, Result, ToolError, child, failed, unused, write_atomic};
use crate::{Args, Automation, Result, ToolError, child, failed, write_atomic};
mod documents;
mod form_tools;
pub(crate) mod form_tools;
mod ocr;
fn status_id(s: Status) -> &'static str {
@@ -213,25 +213,25 @@ 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;
self.session.apply(id, pdfcraft_engine::Edit::AutoTagText).map_err(failed)?;
let warnings = self.apply_with_warnings(id, pdfcraft_engine::Edit::AutoTagText)?;
let tags = self.accessibility_tags(a)?;
Ok(json!({
let out = json!({
"tags": tags["tags"], "count": tags["count"],
"note": "Text objects were tagged as paragraphs. Review heading roles, reading order and remaining accessibility findings manually.",
}))
});
Ok(with_warnings(out, warnings))
}
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 warnings = self.apply_with_warnings(id, pdfcraft_engine::Edit::AutoTagFormFields)?;
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." }),
)
let out = 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." });
Ok(with_warnings(out, warnings))
}
pub(crate) fn accessibility_autotag_preview(&self, a: &Args) -> Result<Value> {
@@ -432,10 +432,11 @@ impl Automation {
let rule = Rule::from_id(id).ok_or_else(|| ToolError::InvalidArgs(format!("unknown rule {id:?}")))?;
let edit = self.doc(a)?.accessibility_fix(rule, a.opt_str("value")?).map_err(failed)?;
let doc = self.doc(a)?.id;
self.session.apply(doc, edit).map_err(failed)?;
let warnings = self.apply_with_warnings(doc, edit)?;
let r =
self.doc(a)?.accessibility_check(&Options { rules: [rule].into(), pages: None }).ok_or_else(|| failed("the document can't be read"))?;
Ok(json!({ "rule": id, "status": status_id(r.results.iter().find(|x| x.rule == rule).map_or(Status::Skipped, |x| x.status)) }))
let out = json!({ "rule": id, "status": status_id(r.results.iter().find(|x| x.rule == rule).map_or(Status::Skipped, |x| x.status)) });
Ok(with_warnings(out, warnings))
}
pub(crate) fn accessibility_figures(&self, a: &Args) -> Result<Value> {
@@ -466,8 +467,8 @@ impl Automation {
pdfcraft_engine::Edit::SetAltText { figure, alt: a.opt_str("alt")?.map(str::to_owned) }
};
let id = self.doc(a)?.id;
self.session.apply(id, edit).map_err(failed)?;
self.accessibility_figures(a)
let warnings = self.apply_with_warnings(id, edit)?;
Ok(with_warnings(self.accessibility_figures(a)?, warnings))
}
/// The structure tags, one page at a time (`offset`, `limit`), optionally only some types, pages
@@ -551,7 +552,22 @@ impl Automation {
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)?;
let warnings =
self.apply_with_warnings(id, pdfcraft_engine::Edit::MarkTagArtifact { object: pdfcraft_cos::ObjRef::new(object, generation) })?;
Ok(with_warnings(self.accessibility_tags(a)?, warnings))
}
pub(crate) fn accessibility_layout_table_to_blocks(&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::LayoutTableToBlocks { object: pdfcraft_cos::ObjRef::new(object, generation) })
.map_err(failed)?;
self.accessibility_tags(a)
}
@@ -573,9 +589,32 @@ impl Automation {
other => return Err(ToolError::InvalidArgs(format!("placement must be before or after, not {other:?}"))),
};
let id = self.doc(a)?.id;
self.session.apply(id, pdfcraft_engine::Edit::MoveTag { object, anchor, placement }).map_err(failed)?;
self.accessibility_tags(a)
let warnings = self.apply_with_warnings(id, pdfcraft_engine::Edit::MoveTag { object, anchor, placement })?;
Ok(with_warnings(self.accessibility_tags(a)?, warnings))
}
/// Apply `edit` to document `id` and take out the errors it added to the document's script
/// output, such as the values of an XFA form that could not be written to its data.
fn apply_with_warnings(&mut self, id: DocId, edit: pdfcraft_engine::Edit) -> Result<Vec<String>> {
let ((), out) = self.session.with_own_js_output(id, |s| s.apply(id, edit)).map_err(failed)?;
Ok(out.errors)
}
}
/// `out` with `warnings` added to its `warnings` list (made when it has none).
fn with_warnings(mut out: Value, warnings: Vec<String>) -> Value {
if warnings.is_empty() {
return out;
}
if let Some(map) = out.as_object_mut() {
match map.get_mut("warnings").and_then(Value::as_array_mut) {
Some(list) => list.extend(warnings.into_iter().map(Value::from)),
None => {
map.insert("warnings".into(), json!(warnings));
}
}
}
out
}
#[cfg(test)]
+6 -17
View File
@@ -1,4 +1,4 @@
use super::{Args, Automation, Result, ToolError, Value, child, failed, json, unused, write_atomic};
use super::{Args, Automation, Result, ToolError, Value, child, failed, json, with_warnings, write_atomic};
impl Automation {
pub(crate) fn doc_export_office(&self, a: &Args) -> Result<Value> {
@@ -103,8 +103,8 @@ impl Automation {
pub(crate) fn pdfa_convert(&mut self, a: &Args) -> Result<Value> {
let level = self.pdfa_level(a)?;
let id = self.doc(a)?.id;
self.session.apply(id, pdfcraft_engine::Edit::ConvertPdfA { level }).map_err(failed)?;
self.pdfa_verify(a)
let warnings = self.apply_with_warnings(id, pdfcraft_engine::Edit::ConvertPdfA { level })?;
Ok(with_warnings(self.pdfa_verify(a)?, warnings))
}
pub(crate) fn action_list(&self) -> Result<Value> {
@@ -148,18 +148,7 @@ impl Automation {
.and_then(|bytes| run_on(&action, &name, std::sync::Arc::new(bytes), |_, _| {}));
out.push(match result {
Ok(r) => {
let target = child(&folder, &name);
let target = if target.exists() {
let stem =
std::path::Path::new(&name).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_else(|| "document".into());
unused(&folder, &stem)
} else {
target
};
if target.exists() {
return Err(failed(format!("{}: no unused output name available", target.display())));
}
write_atomic(&target, &r.bytes)?;
let target = crate::batch::publish(&child(&folder, &name), &r.bytes)?;
json!({ "path": p, "output": target.to_string_lossy(), "log": r.log })
}
Err(e) => json!({ "path": p, "error": e }),
@@ -220,7 +209,7 @@ impl Automation {
pub(crate) fn doc_compare_mark(&mut self, a: &Args) -> Result<Value> {
let (old, new) = self.compare_ids(a)?;
let n = self.session.mark_differences(old, new).map_err(failed)?;
Ok(json!({ "comments": n }))
let (n, out) = self.session.with_own_js_output(new, |s| s.mark_differences(old, new)).map_err(failed)?;
Ok(with_warnings(json!({ "comments": n }), out.errors))
}
}
+57 -12
View File
@@ -1,4 +1,4 @@
use super::{Args, Automation, Result, ToolError, Value, failed, json, write_atomic};
use super::{Args, Automation, Result, ToolError, Value, failed, json, with_warnings, write_atomic};
/// A script's request to the viewer as the JSON that running a script reports; a side panel
/// goes by its fixed id.
@@ -12,6 +12,7 @@ fn request_json(r: &pdfcraft_engine::js::Request) -> Value {
R::Submit(u) => json!({ "submit": u }),
R::Focus(f) => json!({ "focus": f }),
R::Beep => json!({ "beep": true }),
R::Highlight(on) => json!({ "highlight": on }),
R::SaveAs => json!({ "save_as": true }),
R::ShowPanel(p) => json!({ "panel": p.id() }),
}
@@ -19,7 +20,7 @@ fn request_json(r: &pdfcraft_engine::js::Request) -> Value {
/// A message a script showed, as `js_run` reports it: its text, title (null when it gave none),
/// icon and buttons.
fn alert_json(m: &pdfcraft_engine::js::Message) -> Value {
pub(crate) fn alert_json(m: &pdfcraft_engine::js::Message) -> Value {
json!({ "message": m.text, "title": m.title, "icon": m.icon.name(), "buttons": m.buttons.name() })
}
@@ -28,10 +29,18 @@ impl Automation {
/// messages, console lines, requests, error, the failing document scripts' errors and result.
pub(crate) fn js_run(&mut self, a: &Args) -> Result<Value> {
let id = self.doc(a)?.id;
let o = self.session.run_javascript(id, a.str("script")?, a.opt_str("field")?).map_err(failed)?;
let (script, field) = (a.str("script")?, a.opt_str("field")?);
// Applying the script's field changes runs the field scripts again; their output is reported with the script's.
let (mut o, edit) = self.session.with_own_js_output(id, |s| s.run_javascript(id, script, field)).map_err(failed)?;
o.alerts.extend(edit.alerts);
o.console.extend(edit.console);
o.requests.extend(edit.requests);
let reqs: Vec<Value> = o.requests.iter().map(request_json).collect();
let alerts: Vec<Value> = o.alerts.iter().map(alert_json).collect();
Ok(json!({ "alerts": alerts, "console": o.console, "requests": reqs, "error": o.error, "document_errors": o.doc_errors, "result": o.result }))
Ok(with_warnings(
json!({ "alerts": alerts, "console": o.console, "requests": reqs, "error": o.error, "document_errors": o.doc_errors, "result": o.result }),
edit.errors,
))
}
pub(crate) fn js_document_scripts(&self, a: &Args) -> Result<Value> {
@@ -39,13 +48,17 @@ impl Automation {
Ok(json!({ "scripts": list }))
}
/// `js_set_document_script`: set the document-level script `name` to `script`, or remove it
/// when `script` is left out, as one undoable edit; returns the document's scripts.
pub(crate) fn js_set_document_script(&mut self, a: &Args) -> Result<Value> {
let edit = pdfcraft_engine::Edit::SetDocumentScript { name: a.str("name")?.into(), script: a.opt_str("script")?.map(str::to_string) };
let id = self.doc(a)?.id;
self.session.apply(id, edit).map_err(failed)?;
self.js_document_scripts(a)
let warnings = self.apply_with_warnings(id, edit)?;
Ok(with_warnings(self.js_document_scripts(a)?, warnings))
}
/// `form_set_script`: set field `field`'s JavaScript for `event` to `script`, or remove it when
/// `script` is left out, as one undoable edit; returns the console output and errors.
pub(crate) fn form_set_script(&mut self, a: &Args) -> Result<Value> {
let edit = pdfcraft_engine::Edit::SetFieldScript {
name: a.str("field")?.into(),
@@ -53,11 +66,41 @@ impl Automation {
script: a.opt_str("script")?.map(str::to_string),
};
let id = self.doc(a)?.id;
self.session.apply(id, edit).map_err(failed)?;
let out = self.session.take_js_output(id);
let ((), out) = self.session.with_own_js_output(id, |s| s.apply(id, edit)).map_err(failed)?;
Ok(json!({ "field": a.str("field")?, "event": a.str("event")?, "console": out.console, "errors": out.errors }))
}
/// `form_focus`: field `field` (its 1-based `widget`) gains (`enter`) or loses (`exit`)
/// focus, running its XFA scripts for that event; returns whether the form changed and what
/// the scripts showed or asked for.
pub(crate) fn form_focus(&mut self, a: &Args) -> Result<Value> {
use pdfcraft_engine::js::FieldFocus;
let focus = match a.str("event")? {
"enter" => FieldFocus::Enter,
"exit" => FieldFocus::Exit,
other => return Err(ToolError::InvalidArgs(format!("event must be enter or exit, not {other:?}"))),
};
let name = a.str("field")?;
let doc = self.doc(a)?;
let field = doc.form.iter().find(|f| f.name == name).ok_or_else(|| failed(format!("there is no field named {name:?} (see form_fields)")))?;
// A field with no widget (one only scripts or calculations use) has nothing to focus.
let widgets = field.widgets.len();
let widget = a.opt_int("widget")?.unwrap_or(1);
let widget = usize::try_from(widget).ok().and_then(|w| w.checked_sub(1)).filter(|w| *w < widgets).ok_or_else(|| match widgets {
0 => ToolError::InvalidArgs(format!("{name:?} has no widget to focus")),
n => ToolError::InvalidArgs(format!("widget must be 1 to {n} for {name:?}")),
})?;
let id = doc.id;
let changed = self.session.field_focus(id, name, widget, focus).map_err(failed)?;
let out = self.session.take_js_output(id);
let reqs: Vec<Value> = out.requests.iter().map(request_json).collect();
Ok(
json!({ "changed": changed, "alerts": out.alerts.iter().map(alert_json).collect::<Vec<_>>(), "console": out.console, "requests": reqs, "errors": out.errors }),
)
}
/// `form_merge_data`: merge the form data files at `paths` into one CSV at `path`, a row per
/// file; returns the path and the row and column counts.
pub(crate) fn form_merge_data(&mut self, a: &Args) -> Result<Value> {
let mut files = Vec::new();
for p in a.strs("paths")? {
@@ -90,10 +133,12 @@ impl Automation {
v
})
.collect();
let mut warnings = Vec::new();
if a.opt_bool("add")?.unwrap_or(true) && !found.is_empty() {
self.session.auto_detect_fields(id, &pages).map_err(failed)?;
let (_, out) = self.session.with_own_js_output(id, |s| s.auto_detect_fields(id, &pages)).map_err(failed)?;
warnings = out.errors;
}
Ok(json!({ "fields": list }))
Ok(with_warnings(json!({ "fields": list }), warnings))
}
pub(crate) fn form_actions(&self, a: &Args) -> Result<Value> {
@@ -157,8 +202,8 @@ impl Automation {
let edit =
pdfcraft_engine::Edit::SetFieldProps { name, props: Box::new(pdfcraft_engine::FieldProps { actions: Some(acts), ..Default::default() }) };
let id = self.doc(a)?.id;
self.session.apply(id, edit).map_err(failed)?;
self.form_actions(a)
let warnings = self.apply_with_warnings(id, edit)?;
Ok(with_warnings(self.form_actions(a)?, warnings))
}
pub(crate) fn js_enabled(&mut self, a: &Args) -> Result<Value> {
+4 -3
View File
@@ -1,4 +1,4 @@
use super::{Args, Automation, Result, ToolError, Value, child, failed, json, write_atomic};
use super::{Args, Automation, Result, ToolError, Value, child, failed, json, with_warnings, write_atomic};
impl Automation {
/// The settings every OCR tool shares: dpi, language, the engine/strategy choice, the four
@@ -258,9 +258,10 @@ impl Automation {
let recognizers = pdfcraft_engine::ocr::recognizers(&settings).map_err(failed)?;
match out {
None => {
let found = self.session.recognize_text(id, &pages, settings).map_err(failed)?;
let (found, out) = self.session.with_own_js_output(id, |s| s.recognize_text(id, &pages, settings)).map_err(failed)?;
let warnings = out.errors;
let words = found.iter().map(|p| p.words.len()).sum::<usize>();
Ok(json!({ "words": words, "pages": Self::ocr_pages_json(&found, copy) }))
Ok(with_warnings(json!({ "words": words, "pages": Self::ocr_pages_json(&found, copy) }), warnings))
}
Some(out) => {
let job = self.session.ocr_job(id, &pages, settings).ok_or_else(|| failed("the document could not be read"))?;
+3 -2
View File
@@ -36,8 +36,9 @@ impl Automation {
}
(None, None) => return Err(ToolError::InvalidArgs("text needs `text` or `path`".into())),
};
// The page font is Helvetica/WinAnsi: text it would turn into `?` is refused (with
// its line) by `pdfcraft_create::from_text`, as for every other caller.
// The page font is Helvetica/WinAnsi, or the bundled Inter face for Latin, Greek and
// Cyrillic letters WinAnsi lacks: text neither can draw is refused (with its line)
// by `pdfcraft_create::from_text`, as for every other caller.
let bytes = self.session.create_from_text(&title, &text).map_err(|e| match e {
pdfcraft_engine::EditError::Create(pdfcraft_engine::CreateError::Invalid(m)) => ToolError::InvalidArgs(m),
e => failed(e),
+1 -1
View File
@@ -281,7 +281,7 @@ impl Automation {
}
/// Publish atomically without replacing any existing name, even if another writer wins a race.
fn publish(target: &Path, bytes: &[u8]) -> Result<PathBuf> {
pub(crate) fn publish(target: &Path, bytes: &[u8]) -> Result<PathBuf> {
let dir = target.parent().ok_or_else(|| failed("output has no parent"))?;
std::fs::create_dir_all(dir).map_err(failed)?;
let mut temp = tempfile::NamedTempFile::new_in(dir).map_err(failed)?;
+243 -25
View File
@@ -8,6 +8,8 @@ use super::json::Args;
use super::paths::read_input;
use super::{Arc, Automation, Path, PathBuf};
const MAX_BATES_BYTES: u64 = 512 * 1024 * 1024;
struct FilePlan {
input: String,
source: PathBuf,
@@ -26,19 +28,23 @@ impl Automation {
}
let start = match args.0.get("start_number") {
None | Some(Value::Null) => 1,
Some(value) => value
.as_u64()
.filter(|n| *n >= 1)
.ok_or_else(|| ToolError::InvalidArgs("start_number must be a whole number of at least 1".into()))?,
Some(value) => {
value.as_u64().filter(|n| *n >= 1).ok_or_else(|| ToolError::InvalidArgs("start_number must be an integer of at least 1".into()))?
}
};
let digits = args.opt_int("digits")?.unwrap_or(6);
let digits =
u8::try_from(digits).ok().filter(|d| (1..=12).contains(d)).ok_or_else(|| ToolError::InvalidArgs("digits must be from 1 to 12".into()))?;
let prefix = args.opt_str("prefix")?.unwrap_or("");
let suffix = args.opt_str("suffix")?.unwrap_or("");
// `#` separates the token's fields and `>>` ends it: either would cut the token short.
if prefix.len() > 128 || suffix.len() > 128 || [prefix, suffix].iter().any(|s| s.contains('#') || s.contains(">>")) {
return Err(ToolError::InvalidArgs("prefix and suffix must be at most 128 bytes and cannot contain # or >>".into()));
if prefix.chars().count() > 128
|| suffix.chars().count() > 128
|| prefix.contains('#')
|| suffix.contains('#')
|| prefix.contains(">>")
|| suffix.contains(">>")
{
return Err(ToolError::InvalidArgs("prefix and suffix must be at most 128 characters and cannot contain # or >>".into()));
}
let dry_run = args.opt_bool("dry_run")?.unwrap_or(false);
@@ -70,16 +76,7 @@ impl Automation {
targets.push((output.to_string(), target));
passwords.push(entry.get("password").and_then(Value::as_str).map(str::to_owned));
}
let input_paths: std::collections::HashSet<_> = sources.iter().map(|(_, path)| path.clone()).collect();
let mut output_paths = std::collections::HashSet::new();
for (name, path) in &targets {
if input_paths.contains(path) {
return Err(failed(format!("output {name} names an input file; choose a separate output path")));
}
if !output_paths.insert(path.clone()) {
return Err(failed(format!("two files use the same output path: {name}")));
}
}
check_unique_outputs(&targets, dry_run)?;
let mut plans = Vec::with_capacity(files.len());
let mut next = start;
@@ -88,13 +85,13 @@ impl Automation {
let size = std::fs::metadata(input_path).map_err(failed)?.len();
input_bytes = input_bytes
.checked_add(size)
.filter(|total| *total <= 512 * 1024 * 1024)
.filter(|total| *total <= MAX_BATES_BYTES)
.ok_or_else(|| failed("combined Bates input exceeds the 512 MiB safety limit"))?;
let bytes = read_input(input_path)?;
input_bytes = input_bytes
.saturating_sub(size)
.checked_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX))
.filter(|total| *total <= 512 * 1024 * 1024)
.filter(|total| *total <= MAX_BATES_BYTES)
.ok_or_else(|| failed("combined Bates input exceeds the 512 MiB safety limit"))?;
let display_name = input_path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let password = passwords.get(index).and_then(Option::as_deref);
@@ -106,7 +103,8 @@ impl Automation {
return Err(failed(format!("{input_name} has no pages to number")));
}
let count = u64::try_from(pages).map_err(|_| failed(format!("page count is too large for {input_name}")))?;
let last = next.checked_add(count.saturating_sub(1)).ok_or_else(|| failed(format!("Bates number range overflows for {input_name}")))?;
let last_offset = count.checked_sub(1).ok_or_else(|| failed(format!("{input_name} has no pages to number")))?;
let last = next.checked_add(last_offset).ok_or_else(|| failed(format!("Bates number range overflows for {input_name}")))?;
plans.push(FilePlan {
input: input_name.clone(),
source: input_path.clone(),
@@ -116,7 +114,7 @@ impl Automation {
first: next,
last,
});
if index + 1 < sources.len() {
if index + 1 < files.len() {
next = last.checked_add(1).ok_or_else(|| failed(format!("Bates sequence overflows after {input_name}")))?;
}
}
@@ -163,12 +161,14 @@ impl Automation {
let result = applied.and_then(|()| self.session.save_full_bytes(id));
match result {
Ok(bytes) => {
let total = output_bytes.saturating_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX));
if total > 512 * 1024 * 1024 {
results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":"combined Bates output exceeds the 512 MiB safety limit"}));
} else {
let candidate = output_bytes
.checked_add(u64::try_from(bytes.len()).unwrap_or(u64::MAX))
.filter(|total| *total <= MAX_BATES_BYTES);
if let Some(total) = candidate {
output_bytes = total;
staged.push((plan.output.clone(), bytes));
} else {
results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":"combined Bates output exceeds the 512 MiB safety limit"}));
}
}
Err(error) => results.push(json!({"input":plan.input,"output":plan.output,"status":"error","pages":plan.pages,"first":plan.first,"last":plan.last,"error":error.to_string()})),
@@ -198,6 +198,123 @@ impl Automation {
}
}
fn check_unique_outputs(targets: &[(String, PathBuf)], dry_run: bool) -> Result<()> {
if targets.len() < 2 {
return Ok(());
}
// Probe exact basenames in a directory under each canonical parent, so the filesystem applies its own name rules.
let mut groups: std::collections::HashMap<PathBuf, (PathBuf, Vec<(String, std::ffi::OsString)>)> = std::collections::HashMap::new();
for (name, target) in targets {
let parent = target.parent().ok_or_else(|| failed(format!("output has no parent directory: {name}")))?;
let identity = parent.canonicalize().map_err(failed)?;
let probe_name = output_probe_name(target).map_err(failed)?;
groups.entry(identity.clone()).or_insert_with(|| (identity, Vec::new())).1.push((name.clone(), probe_name));
}
if dry_run {
// Dry runs cannot probe the volume: Windows uses a conservative case-insensitive comparison,
// while Unix preserves case, so unusual case-sensitive Windows or case-insensitive Unix volumes can differ.
for (_, (_, names)) in groups {
for (index, (name, candidate)) in names.iter().enumerate() {
if names.iter().take(index).any(|(_, prior)| same_output_name(prior, candidate)) {
return Err(failed(format!("two files use the same output path: {name}")));
}
}
}
return Ok(());
}
for (_, (parent, names)) in groups {
match tempfile::tempdir_in(&parent) {
Ok(probes) => {
for (name, probe_name) in names {
let probe = probes.path().join(probe_name);
match std::fs::OpenOptions::new().write(true).create_new(true).open(&probe) {
Ok(file) => drop(file),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
return Err(failed(format!("two files use the same output path: {name}")));
}
Err(error) => return Err(failed(format!("could not validate output path {name}: {error}"))),
}
}
}
#[cfg(windows)]
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => probe_files_for_resolved_outputs(&parent, names)?,
Err(error) => return Err(failed(error)),
}
}
Ok(())
}
#[cfg(windows)]
fn probe_files_for_resolved_outputs(parent: &Path, names: Vec<(String, std::ffi::OsString)>) -> Result<()> {
use std::os::windows::fs::OpenOptionsExt;
const FILE_FLAG_DELETE_ON_CLOSE: u32 = 0x0400_0000;
let mut probes = Vec::with_capacity(names.len());
for (name, probe_name) in names {
let probe = parent.join(probe_name);
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true).share_mode(0).custom_flags(FILE_FLAG_DELETE_ON_CLOSE);
match options.open(&probe) {
Ok(file) => probes.push(file),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
return Err(failed(format!("two files use the same output path: {name}")));
}
Err(error) => return Err(failed(format!("could not validate output path {name}: {error}"))),
}
}
drop(probes);
Ok(())
}
fn same_output_name(left: &std::ffi::OsStr, right: &std::ffi::OsStr) -> bool {
#[cfg(windows)]
{
use std::os::windows::ffi::OsStrExt;
fn key(name: &std::ffi::OsStr) -> Vec<u32> {
std::char::decode_utf16(name.encode_wide())
.map(|decoded| match decoded {
Ok(character) => {
let mut uppercase = character.to_uppercase();
match (uppercase.next(), uppercase.next()) {
(Some(single), None) => single as u32,
_ => character as u32,
}
}
Err(error) => 0x11_0000 + u32::from(error.unpaired_surrogate()),
})
.collect()
}
key(left) == key(right)
}
#[cfg(not(windows))]
{
left == right
}
}
fn output_probe_name(path: &Path) -> std::io::Result<std::ffi::OsString> {
let name = path.file_name().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "output has no file name"))?;
#[cfg(windows)]
{
use std::os::windows::ffi::{OsStrExt, OsStringExt};
let is_verbatim = path.components().any(|component| matches!(component, std::path::Component::Prefix(prefix) if matches!(prefix.kind(), std::path::Prefix::Verbatim(_) | std::path::Prefix::VerbatimUNC(_, _) | std::path::Prefix::VerbatimDisk(_))));
let mut wide: Vec<u16> = name.encode_wide().collect();
if !is_verbatim {
while wide.last().is_some_and(|unit| *unit == u16::from(b'.') || *unit == u16::from(b' ')) {
wide.pop();
}
}
Ok(std::ffi::OsString::from_wide(&wide))
}
#[cfg(not(windows))]
{
Ok(name.to_os_string())
}
}
/// Publish a PDF atomically while refusing a target created by another process after preflight.
pub(super) fn publish_new(path: &Path, bytes: &[u8]) -> Result<()> {
let parent = path.parent().ok_or_else(|| failed("output has no parent directory"))?;
@@ -212,3 +329,104 @@ pub(super) fn publish_new(path: &Path, bytes: &[u8]) -> Result<()> {
}
})
}
#[cfg(test)]
mod tests {
use super::check_unique_outputs;
#[test]
fn dry_run_path_check_creates_no_probe_files() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("same.pdf");
let targets = vec![("first.pdf".into(), path.clone()), ("second.pdf".into(), path)];
assert!(check_unique_outputs(&targets, true).is_err());
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
}
#[cfg(unix)]
#[test]
fn dry_run_detects_output_parent_aliases_without_writing() {
let dir = tempfile::tempdir().unwrap();
let alias = dir.path().join("alias");
std::os::unix::fs::symlink(dir.path(), &alias).unwrap();
let first = dir.path().join("same.pdf");
let second = alias.join("same.pdf");
let targets = vec![("first.pdf".into(), first), ("second.pdf".into(), second)];
assert!(check_unique_outputs(&targets, true).is_err());
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
}
#[test]
fn long_output_names_fit_in_probe_directory() {
let dir = tempfile::tempdir().unwrap();
let first = dir.path().join(format!("{}.pdf", "a".repeat(220)));
let second = dir.path().join(format!("{}.pdf", "b".repeat(220)));
let targets = vec![("first.pdf".into(), first), ("second.pdf".into(), second)];
assert!(check_unique_outputs(&targets, false).is_ok());
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
}
#[cfg(unix)]
#[test]
fn output_probe_paths_preserve_non_utf8_names() {
use std::os::unix::ffi::OsStringExt;
let dir = tempfile::tempdir().unwrap();
let first = dir.path().join(std::ffi::OsString::from_vec(vec![b'o', 0xff]));
let second = dir.path().join(std::ffi::OsString::from_vec(vec![b'o', 0xfe]));
let targets = vec![("first.pdf".into(), first), ("second.pdf".into(), second)];
assert!(check_unique_outputs(&targets, false).is_ok());
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
}
#[cfg(windows)]
#[test]
fn windows_probe_names_trim_only_non_verbatim_paths() {
use std::ffi::OsString;
use std::path::Path;
let normal = Path::new(r"C:\root\report.pdf. ");
assert_eq!(super::output_probe_name(normal).unwrap(), OsString::from("report.pdf"));
let verbatim = Path::new(r"\\?\C:\root\report.pdf. ");
assert_eq!(super::output_probe_name(verbatim).unwrap(), OsString::from("report.pdf. "));
}
#[cfg(windows)]
#[test]
fn dry_run_detects_case_only_output_collisions_without_writing() {
let dir = tempfile::tempdir().unwrap();
let first = dir.path().join("Case.pdf");
let second = dir.path().join("CASE.pdf");
let targets = vec![("first.pdf".into(), first), ("second.pdf".into(), second)];
assert!(check_unique_outputs(&targets, true).is_err());
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
}
#[cfg(windows)]
#[test]
fn permission_denied_child_directory_uses_delete_on_close_file_probes() {
use std::process::Command;
let dir = tempfile::tempdir().unwrap();
let identity = Command::new("whoami.exe").args(["/user", "/fo", "csv", "/nh"]).output().unwrap();
assert!(identity.status.success());
let identity = String::from_utf8(identity.stdout).unwrap();
let sid = identity.split('"').nth(3).unwrap();
let acl = Command::new("icacls.exe").arg(dir.path()).arg("/deny").arg(format!("*{sid}:(AD)")).status().unwrap();
assert!(acl.success());
// Where the deny entry does not bind the test's own account (it did not on CI's Windows image),
// there is no denied folder to probe.
if !matches!(tempfile::tempdir_in(dir.path()), Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied) {
return;
}
let direct_probe = dir.path().join("direct-probe");
let file = std::fs::OpenOptions::new().write(true).create_new(true).open(&direct_probe).unwrap();
drop(file);
std::fs::remove_file(&direct_probe).unwrap();
let targets = vec![("first.pdf".into(), dir.path().join("first.pdf")), ("second.pdf".into(), dir.path().join("second.pdf"))];
assert!(check_unique_outputs(&targets, false).is_ok());
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
}
}
+69 -30
View File
@@ -179,7 +179,21 @@ impl Automation {
let doc = self.doc(a)?;
let only = a.opt_int("page")?.map(|p| (p.max(1) - 1) as usize);
let all = self.comments(a)?;
// A group (`/RT /Group`, ISO 32000-2 §12.5.6.2) lists as its primary, with the primary's
// attributes: the subordinates are neither comments nor replies, only its `group`.
let mut members: HashMap<(usize, usize), Vec<&Annotation>> = HashMap::new();
for (m, p) in all.iter().zip(pdfcraft_render::group_primaries(&all)) {
if let Some(p) = p.and_then(|p| all.get(p)) {
members.entry((p.page, p.index)).or_default().push(m);
}
}
let view = |c: &Annotation| -> Value {
let group: Vec<Value> = members
.get(&(c.page, c.index))
.into_iter()
.flatten()
.map(|m| json!({ "id": m.name, "page": m.page + 1, "index": m.index + 1, "type": m.subtype }))
.collect();
let mut v = json!({
"id": c.name,
"page": c.page + 1,
@@ -191,6 +205,9 @@ impl Automation {
"modified": c.modified,
"rect": crate::view_rect(&doc.info, c.page, c.shown_rect(doc.info.pages.get(c.page).map_or(0, |p| p.rotation))),
"color": c.color.map(hex),
"group": group,
// Unchanged on a page the viewer could not read, as `rect` is.
"popup": c.popup.map(|w| json!({ "rect": crate::view_rect(&doc.info, c.page, w.rect), "open": w.open })),
});
// A text box's or typed text's oval or circle (a rectangle is not reported).
if c.subtype == "FreeText"
@@ -211,8 +228,8 @@ impl Automation {
v["text_opacity"] = json!(t);
}
}
// A line's ends, or the corners of connected lines, a polygon or a cloud, as
// comment_edit's `points` takes them.
// A line's ends, the corners of connected lines, a polygon or a cloud, or a callout's
// arrow point and knee, as comment_edit's `points` takes them.
// Unchanged on a page the viewer could not read, as `rect` is.
if !c.points.is_empty() {
let round = |v: f32| (v * 100.0).round() / 100.0;
@@ -220,28 +237,36 @@ impl Automation {
v["points"] =
json!(c.points.iter().map(|q| page.map_or([q[0], q[1]], |p| p.user_to_view(q[0], q[1])).map(round)).collect::<Vec<_>>());
}
// A callout's text box, as comment_edit's `rect` takes it (`rect` also holds the line).
if let Some(tb) = c.text_box {
v["text_box"] = json!(crate::view_rect(&doc.info, c.page, tb));
}
v
};
// Every reply under the comment its thread starts from, at any depth, in document order:
// a reply to a reply keeps its `/IRT` on the direct parent, so following one level would
// drop it. Parents are found by reference, as `/NM` need not be unique. A status or
// checkmark belongs to the annotation its `/IRT` names (ISO 32000-2 §12.5.6.2), as in the
// Comments panel: one set on a reply is not the comment's.
// drop it. Parents are found by reference, as `/NM` need not be unique. Status and
// checkmark replies are read through `review` instead. A group subordinate is in no
// thread, and a reply to one is the group's, its primary's.
let tops = pdfcraft_render::thread_tops(&all);
let mut threads: HashMap<(usize, usize), Vec<&Annotation>> = HashMap::new();
for r in &all {
for r in all.iter().filter(|r| r.state.is_none() && !r.is_group_member()) {
let Some(&top) = tops.get(&(r.page, r.index)) else { continue };
let key = match r.parent {
Some(parent) if r.state.is_some() => parent,
_ => top,
};
threads.entry(key).or_default().push(r);
threads.entry(top).or_default().push(r);
}
// An annotation's review status and checkmark: the latest of each set on it.
// An annotation's review states. A state reply's `/IRT` refers to the annotation whose
// state it sets, and a reviewer's later change replies to their previous one (ISO 32000-2
// §12.5.6.3), so a status on a reply is that reply's, as in the Comments panel. `status`
// is the latest set by anyone; `statuses` has each reviewer's latest (a state is "for the
// specified user"). Checkmarks are each reviewer's own too: `marked_by` names who has one
// on, and `marked` whether anyone has.
let review = pdfcraft_render::ReviewStates::new(&all);
let states = |c: &Annotation, v: &mut Value| {
let set = threads.get(&(c.page, c.index)).map(Vec::as_slice).unwrap_or(&[]);
v["status"] = json!(set.iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.clone()));
v["marked"] = json!(set.iter().rev().find(|r| r.is_mark()).is_some_and(|r| r.state.as_deref() == Some("Marked")));
v["status"] = json!(review.latest_status(c));
v["statuses"] = json!(review.statuses(c).iter().map(|(who, state)| json!({ "author": who, "status": state })).collect::<Vec<_>>());
let marked_by = review.marked(c);
v["marked"] = json!(!marked_by.is_empty());
v["marked_by"] = json!(marked_by);
};
let roots: Vec<Value> = all
.iter()
@@ -253,7 +278,6 @@ impl Automation {
let thread = threads.get(&(c.page, c.index)).map(Vec::as_slice).unwrap_or(&[]);
v["replies"] = thread
.iter()
.filter(|r| r.state.is_none())
.map(|r| {
let mut reply = view(r);
states(r, &mut reply);
@@ -406,18 +430,8 @@ impl Automation {
let point = to_user(&info, t[0], t[1]);
let knee = match a.nums::<2>("knee")? {
Some(k) => to_user(&info, k[0], k[1]),
// Halfway between the point and the box, level with the box's middle.
None => {
let mid = (rect[1] + rect[3]) / 2.0;
let side = if point[0] < rect[0] {
rect[0]
} else if point[0] > rect[2] {
rect[2]
} else {
(rect[0] + rect[2]) / 2.0
};
[(point[0] + side) / 2.0, mid]
}
// Acrobat's: 12 pt out from the middle of the side facing the point.
None => pdfcraft_engine::callout_knee(rect, point),
};
Shape::Callout {
rect,
@@ -490,8 +504,12 @@ impl Automation {
if contents.trim().is_empty() {
return Err(ToolError::InvalidArgs("replace needs `contents`: the replacement text".into()));
}
// `/C` is a group attribute, taken from the primary (ISO 32000-2 §12.5.6.2), so the
// caret carries the colour too. Only the colour: opacity and the rest are the
// caret's own.
let mut caret = Style::default_for(&Shape::Caret { rect: [0.0; 4] });
caret.color = style.color;
let strike = style;
let caret = Style::default_for(&Shape::Caret { rect: [0.0; 4] });
let mut out = self.apply(a, Edit::ReplaceText { page, quads, text: contents, author, strike, caret })?;
out["comment"] = json!({ "page": page + 1, "type": "replace" });
return Ok(out);
@@ -694,6 +712,17 @@ impl Automation {
self.apply(a, Edit::MarkAnnotation { page, index, marked, author })
}
pub(crate) fn comment_popup(&mut self, a: &Args) -> Result<Value> {
let (page, index) = self.comment_target(a)?;
let info = self.doc(a)?.info.pages[page].clone();
let open = a.opt_bool("open")?;
let rect = a.nums::<4>("rect")?.map(|r| rect_to_user(&info, r));
if open.is_none() && rect.is_none() {
return Err(ToolError::InvalidArgs("nothing to change: pass open and/or rect".into()));
}
self.apply(a, Edit::SetAnnotationPopup { page, index, rect, open })
}
pub(crate) fn comment_lock(&mut self, a: &Args) -> Result<Value> {
let (page, index) = self.comment_target(a)?;
let locked = a.opt_bool("locked")?.unwrap_or(true);
@@ -867,6 +896,7 @@ impl Automation {
let bytes = crate::read_input(&path)?;
let file = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let name = a.opt_str("name")?.map(str::to_owned).unwrap_or_else(|| file.rsplit_once('.').map_or(file.clone(), |(s, _)| s.to_owned()));
let author = a.opt_str("author")?.unwrap_or(DEFAULT_AUTHOR).to_string();
let edit = Edit::AddCustomStamp {
page,
rect,
@@ -876,7 +906,16 @@ impl Automation {
bytes: std::sync::Arc::new(bytes),
page: a.opt_int("file_page")?.unwrap_or(1).max(1) as usize - 1,
},
author: a.opt_str("author")?.unwrap_or(DEFAULT_AUTHOR).to_string(),
line: None,
author: author.clone(),
};
// A dynamic line (#1741): `{name}`, `{date}` and `{time}` filled in now.
let edit = match (edit, a.opt_str("line")?) {
(Edit::AddCustomStamp { page, rect, name, file, author, .. }, Some(t)) if !t.trim().is_empty() => {
let line = Some(self.session().stamp_line(t, &author));
Edit::AddCustomStamp { page, rect, name, file, author, line }
}
(edit, _) => edit,
};
self.apply(a, edit)
}
+64 -11
View File
@@ -354,14 +354,32 @@ impl Automation {
Ok(json!({"page":page+1,"generation":doc.edit_generation(),"count":items.len(),"objects":items}))
}
pub(crate) fn object_move(&mut self, a: &Args) -> Result<Value> {
/// The references `objects` names (from object_list), or with `all` every object on `page`,
/// as ⌘A selects them in Edit a PDF; refused when `generation` is not the document's.
fn object_targets(&self, a: &Args, page: usize) -> Result<Vec<pdfcraft_engine::ObjectTarget>> {
use pdfcraft_engine::{MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget};
let page = self.page(a)?;
let doc = self.doc(a)?;
if let Some(g) = a.get("generation") {
let g = g.as_u64().ok_or_else(|| bad("generation must be a non-negative integer from object_list"))?;
if g != doc.edit_generation() {
return Err(failed("the document changed; call object_list again"));
}
}
if a.opt_bool("all")?.unwrap_or(false) {
if a.get("objects").is_some() {
return Err(bad("give objects or all, not both"));
}
let all: Vec<_> = doc.editable_objects(page).map_err(failed)?.into_iter().map(|o| o.target).collect();
if all.is_empty() || all.len() > MAX_MOVE_OBJECTS {
return Err(failed(format!("page {} has {} objects; select 1–1000", page + 1, all.len())));
}
return Ok(all);
}
let rows = a
.get("objects")
.and_then(Value::as_array)
.filter(|r| !r.is_empty() && r.len() <= MAX_MOVE_OBJECTS)
.ok_or_else(|| bad("objects must contain 1–1000 references from object_list"))?;
.ok_or_else(|| bad("objects must contain 1–1000 references from object_list (or pass all: true)"))?;
let mut targets = Vec::with_capacity(rows.len());
for row in rows {
let row = row.as_object().ok_or_else(|| bad("each object must contain kind and index"))?;
@@ -382,6 +400,12 @@ impl Automation {
.ok_or_else(|| bad("object index must be a positive integer from object_list"))?;
targets.push(ObjectTarget { kind, index });
}
Ok(targets)
}
pub(crate) fn object_move(&mut self, a: &Args) -> Result<Value> {
let page = self.page(a)?;
let targets = self.object_targets(a, page)?;
let delta = a
.get("offset")
.and_then(Value::as_array)
@@ -389,17 +413,46 @@ impl Automation {
.and_then(|v| Some([v.first()?.as_f64()?, v.get(1)?.as_f64()?]))
.filter(|v| v.iter().all(|n| n.is_finite() && n.abs() <= 1e9))
.ok_or_else(|| bad("offset must contain two finite numbers [dx, dy] in displayed points"))?;
let doc = self.doc(a)?;
if let Some(g) = a.get("generation") {
let g = g.as_u64().ok_or_else(|| bad("generation must be a non-negative integer from object_list"))?;
if g != doc.edit_generation() {
return Err(failed("the document changed; call object_list again before moving"));
}
}
let offset = doc.object_move_offset(page, delta).map_err(failed)?;
let offset = self.doc(a)?.object_move_offset(page, delta).map_err(failed)?;
let count = targets.len();
let mut result = self.apply(a, Edit::MoveObjects { page, objects: targets, offset })?;
result["moved"] = json!(count);
Ok(result)
}
/// Edit ▸ Copy (or with `cut`, Cut) of objects selected together, kept for object_paste into
/// any open document. A cut then deletes them as one undo step.
pub(crate) fn object_copy(&mut self, a: &Args) -> Result<Value> {
let page = self.page(a)?;
let targets = self.object_targets(a, page)?;
let cut = a.opt_bool("cut")?.unwrap_or(false);
let doc = self.doc(a)?;
if cut && !doc.allows_modification() {
return Err(failed(pdfcraft_engine::EditError::NotPermitted("changes")));
}
let clip = doc.copy_objects(page, &targets).map_err(failed)?;
let out = json!({ "copied": clip.objects().len(), "text": clip.text() });
self.objects_clipboard = Some(pdfcraft_engine::ObjectClipboard::new(clip));
if !cut {
return Ok(out);
}
let delete = Edit::delete_objects(page, targets).ok_or_else(|| failed("nothing to cut"))?;
let mut result = self.apply(a, delete)?;
result["copied"] = out["copied"].clone();
result["text"] = out["text"].clone();
Ok(result)
}
/// Edit ▸ Paste of the objects object_copy copied: onto `page`, where they were, or on the
/// page they came from (and on each further paste onto a page) 18 points down and right.
pub(crate) fn object_paste(&mut self, a: &Args) -> Result<Value> {
let page = self.page(a)?;
let mut clipboard = self.objects_clipboard.take().ok_or_else(|| failed("copy objects first (object_copy)"))?;
let count = clipboard.clip().objects().len();
let edit = self.doc(a).and_then(|doc| clipboard.paste(doc, page).map_err(failed));
self.objects_clipboard = Some(clipboard);
let mut result = self.apply(a, edit?)?;
result["pasted"] = json!(count);
Ok(result)
}
}
+50 -1
View File
@@ -700,7 +700,7 @@ impl Automation {
summary(self.session.get(new).ok_or_else(|| failed("the document vanished"))?)
}
"doc_export_images" | "doc_export_text" | "doc_export_all_images" => self.export(name, &a)?,
"doc_header_footer" | "doc_watermark" | "doc_background" | "doc_remove_marks" => self.marks(name, &a)?,
"doc_header_footer" | "doc_watermark" | "doc_background" | "doc_printer_marks" | "doc_remove_marks" => self.marks(name, &a)?,
"doc_backdrops" => self.backdrops(&a)?,
"doc_unprotect" => {
let mut out = self.apply(&a, Edit::RemoveProtection)?;
@@ -709,6 +709,7 @@ impl Automation {
}
"form_fields" => self.form_fields(&a)?,
"form_fill" => self.form_fill(&a)?,
"form_focus" => self.form_focus(&a)?,
"form_set_image" => {
let path = self.resolve(a.str("path")?, false)?;
let bytes = read_input(&path)?;
@@ -738,6 +739,8 @@ impl Automation {
"links_remove" => self.links_remove(&a)?,
"object_list" => self.object_list(&a)?,
"object_move" => self.object_move(&a)?,
"object_copy" => self.object_copy(&a)?,
"object_paste" => self.object_paste(&a)?,
"content_list" => self.content_list(&a)?,
"page_add_text" => self.page_add_text(&a)?,
"page_add_image" => self.page_add_image(&a)?,
@@ -745,6 +748,7 @@ impl Automation {
"content_delete" => self.content_delete(&a)?,
"content_copy" => self.content_copy(&a)?,
"doc_print" => self.doc_print(&a)?,
"doc_fix_hairlines" => self.fix_hairlines(&a)?,
"attachment_add" => {
let path = self.resolve(a.str("path")?, false)?;
let bytes = read_input(&path)?;
@@ -764,6 +768,49 @@ impl Automation {
let edit = Edit::DeleteAttachment { key: a.str("name")?.into() };
self.apply(&a, edit)?
}
"doc_convert_colours" => self.convert_colours(&a)?,
"attachment_edit" => {
let key = a.opt_str("key")?;
let name = a.opt_str("name")?;
if key.is_some() == name.is_some() {
return Err(ToolError::InvalidArgs("provide exactly one of key or name".into()));
}
let keys: Vec<String> = self
.doc(&a)?
.info
.attachments
.iter()
.filter_map(|attachment| match &attachment.source {
pdfcraft_render::AttachmentSource::Document { key: attachment_key }
if key.is_some_and(|key| key == attachment_key) || name.is_some_and(|name| name == attachment.name) =>
{
Some(attachment_key.clone())
}
pdfcraft_render::AttachmentSource::Document { .. } => None,
pdfcraft_render::AttachmentSource::Annotation { .. } => None,
})
.collect();
let key = match keys.as_slice() {
[key] => key.clone(),
[] if key.is_some() => {
return Err(ToolError::InvalidArgs(format!("there is no document attachment with key {:?}", key.unwrap_or_default())));
}
[] => {
return Err(ToolError::InvalidArgs(format!(
"there is no document attachment named {:?}; use a name from doc_info",
name.unwrap_or_default()
)));
}
_ => {
return Err(ToolError::InvalidArgs(format!(
"more than one document attachment is named {:?}; provide its key from doc_info",
name.unwrap_or_default()
)));
}
};
let edit = Edit::EditAttachmentDescription { key, description: Some(a.str("description")?.into()) };
self.apply(&a, edit)?
}
"doc_remove_hidden" => self.doc_remove_hidden(&a)?,
"doc_audit_redactions" => self.doc_audit_redactions(&a)?,
"fill_sign_add" => self.fill_sign_add(&a)?,
@@ -832,6 +879,7 @@ impl Automation {
"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_layout_table_to_blocks" => self.accessibility_layout_table_to_blocks(&a)?,
"accessibility_move_tag" => self.accessibility_move_tag(&a)?,
"sign_id_create" => self.sign_id_create(&a)?,
"sign_document" => self.sign_document(&a)?,
@@ -869,6 +917,7 @@ impl Automation {
"sign_trust" => self.sign_trust(&a)?,
"comment_mark" => self.comment_mark(&a)?,
"comment_lock" => self.comment_lock(&a)?,
"comment_popup" => self.comment_popup(&a)?,
"comments_hide" => self.comments_hide(&a)?,
"layer_set" => self.layer_set(&a)?,
"view_reading_colors" => self.view_reading_colors(&a)?,
+37 -7
View File
@@ -1,7 +1,7 @@
use super::error::{Result, ToolError, failed};
use super::json::{Args, nest_rows, security, summary};
use super::paths::{child, read_input, write_atomic};
use super::{Arc, Automation, BlockKey, DocId, Document, Edit, Path, PathBuf, TextBlock, Value, comments, file_access, json};
use super::{Arc, Automation, BlockKey, DocId, Document, Edit, Path, TextBlock, Value, comments, json};
/// How far a watermark may be moved from the page centre, in points (a page is at most 14,400).
const WATERMARK_MAX_OFFSET: f64 = 20_000.0;
@@ -135,18 +135,18 @@ impl Automation {
pub(crate) fn doc_save_with(&mut self, a: &Args, write: impl FnOnce(&Path, &[u8]) -> Result<()>) -> Result<Value> {
let doc = self.doc(a)?;
let id = doc.id;
let target = match a.opt_str("path")? {
let explicit = a.opt_str("path")?;
let target = match explicit {
Some(p) => self.resolve(p, true)?,
None => {
// Saving in place writes where the file was opened from, which the read root
// allows and the write root may not.
// allows and the write root may not. It also goes through --root again: a
// folder on the remembered path may have become a link out since (#1791).
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
self.resolve(&own, true)?
}
};
let same_file = doc.path.as_deref().is_some_and(|p| Path::new(p) == target);
let same_file = explicit.is_none() || 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.
let full = a.opt_bool("full")?.unwrap_or(!same_file);
let flatten_fill_sign = a.opt_bool("flatten_fill_sign")?.unwrap_or(false);
@@ -360,12 +360,15 @@ impl Automation {
("discard_thumbnails", &mut settings.discard_thumbnails),
("discard_alternate_images", &mut settings.discard_alternate_images),
("discard_tags", &mut settings.discard_tags),
("discard_search_index", &mut settings.discard_search_index),
("discard_print_settings", &mut settings.discard_print_settings),
("discard_form_actions", &mut settings.discard_form_actions),
("flate_unencoded", &mut settings.flate_unencoded),
("object_streams", &mut settings.object_streams),
("lzw_to_flate", &mut settings.lzw_to_flate),
("remove_invalid_links", &mut settings.remove_invalid_links),
("remove_unreferenced_dests", &mut settings.remove_unreferenced_dests),
("discard_external_refs", &mut settings.discard_external_refs),
] {
if let Some(b) = a.opt_bool(key)? {
*flag = b;
@@ -470,12 +473,17 @@ impl Automation {
"image_bytes_after": o.image_bytes_after,
"thumbnails": o.thumbnails,
"alternate_images": o.alternate_images,
"search_indexes": o.search_indexes,
"tags_removed": o.tags_removed,
"print_settings": o.print_settings,
"form_actions": o.form_actions,
"streams_compressed": o.streams_compressed,
"lzw_streams": o.lzw_streams,
"invalid_links": o.invalid_links,
"invalid_bookmarks": o.invalid_bookmarks,
"unreferenced_dests": o.unreferenced_dests,
"external_links": o.external_links,
"external_bookmarks": o.external_bookmarks,
"unused_xobjects": o.unused_xobjects,
"merged_objects": r.merged,
"discarded": r.discarded.iter().map(|(h, n)| json!({ "category": h.id(), "count": n })).collect::<Vec<_>>(),
@@ -623,11 +631,33 @@ impl Automation {
replace,
file: self.mark_file(a)?,
},
"doc_printer_marks" => {
use pdfcraft_engine::PrinterMarks;
let flag = |k: &str, default: bool| -> Result<bool> { Ok(a.opt_bool(k)?.unwrap_or(default)) };
let (y, m, d) = self.session.today();
let settings = PrinterMarks {
trim: flag("trim", true)?,
bleed: flag("bleed", true)?,
registration: flag("registration", true)?,
color_bars: flag("color_bars", true)?,
page_info: flag("page_info", true)?,
line_weight: a.opt_num("line_weight")?.unwrap_or(0.25),
make_room: match a.opt_num("make_room")? {
Some(r) if r < 0.0 => None,
Some(r) => Some(r),
None => Some(36.0),
},
label: a.opt_str("label")?.map(str::to_string).unwrap_or_else(|| self.doc(a).map(|d| d.name.clone()).unwrap_or_default()),
date: a.opt_str("date")?.map(str::to_string).unwrap_or_else(|| format!("{y:04}-{m:02}-{d:02}")),
};
Edit::AddPrinterMarks { pages, settings, replace: a.opt_bool("replace")?.unwrap_or(true) }
}
_ => {
let kind = match a.str("kind")? {
"header_footer" => MarkKind::HeaderFooter,
"watermark" => MarkKind::Watermark,
"background" => MarkKind::Background,
"printer_marks" => MarkKind::PrinterMarks,
other => return Err(ToolError::InvalidArgs(format!("unknown kind {other:?}"))),
};
Edit::RemoveMarks { kind }
+168 -52
View File
@@ -5,22 +5,24 @@
//! 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 path is made absolute against the working directory, then followed the way the file
//! system reads it, one name at a time from the top, its links followed. A name is looked up
//! only while the folder it is in is the root, inside it, or one of the root's own parent
//! folders: a path that leaves those is refused before anything beyond is touched (looking up
//! `/net/<host>` mounts it), and every such refusal reads the same, so it can't tell what exists
//! out there. Inside the root, a name that doesn't exist yet ends the walk and the rest is taken
//! as plain names (`..` below a missing folder is refused, and so is a link that leads nowhere).
//! - A path on another network share or in a device namespace (`\\host\share`, `\\.\pipe\x`)
//! than the root's is refused by name, before anything is resolved: resolving it would contact
//! the host.
//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside
//! `/work`), without regard to case on Windows.
//! - The result must lie inside the root, compared by whole canonical components (`/work2` isn't
//! inside `/work`), with the drive letter compared 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;
@@ -100,11 +102,10 @@ impl AutomationRoots {
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {shown}")),
};
};
let absolute = absolute(shown, target)?;
// Another network share or a device is refused by name, as `--root` refuses it: even
// resolving the path would contact the host (#4003).
if !foreign_share(&absolute, folder) && inside(&resolve(shown, &absolute)?, folder) {
return Ok(());
match follow(shown, &absolute(shown, target)?, folder) {
Toward::Inside => return Ok(()),
Toward::Refused(why) => return Err(why),
Toward::Outside => {}
}
let root = root_shown.display();
match access {
@@ -141,60 +142,85 @@ fn absolute(shown: &str, path: &Path) -> Result<PathBuf, String> {
std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {shown}: {e}"))
}
/// `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, absolute: &Path) -> Result<PathBuf, String> {
// The names below the nearest existing folder, last first.
let mut missing: Vec<OsString> = vec![];
let mut at = absolute;
loop {
match std::fs::canonicalize(at) {
Ok(mut found) => {
found.extend(missing.iter().rev());
return Ok(found);
/// Where a followed path ends up.
pub(crate) enum Toward {
/// Inside the root, with its links followed (a missing tail taken as names).
Inside,
/// Anywhere else. Said the same way whatever is out there.
Outside,
/// Inside the root, but it can't be followed, and why.
Refused(String),
}
/// Follow the absolute `path` the way the file system reads it, one name at a time from the top,
/// looking a name up only while the folder it is in is `root`, inside it, or one of `root`'s own
/// parent folders (all with their links followed already, so `..` means what it means to the file
/// system).
pub(crate) fn follow(shown: &str, path: &Path, root: &Path) -> Toward {
// Another network share or a device is refused by name, as `--root` refuses it: even looking
// it up would contact the host (#4003).
if foreign_share(path, root) {
return Toward::Outside;
}
let near = |at: &Path| inside(at, root) || inside(root, at);
let mut parts = path.components().peekable();
let top: PathBuf = std::iter::from_fn(|| parts.next_if(|c| matches!(c, Component::Prefix(_) | Component::RootDir))).collect();
let mut at = match std::fs::canonicalize(&top) {
Ok(at) if near(&at) => at,
_ => return Toward::Outside,
};
while let Some(part) = parts.next() {
let name = match part {
Component::Normal(name) => name,
Component::CurDir => continue,
Component::ParentDir => {
at.pop();
continue;
}
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}"));
Component::Prefix(_) | Component::RootDir => return Toward::Outside,
};
let next = at.join(name);
match std::fs::canonicalize(&next) {
Ok(found) if near(&found) => at = found,
Ok(_) => return Toward::Outside,
// Not even there yet: only inside the root does it matter why.
Err(_) if !inside(&at, root) => return Toward::Outside,
Err(e) => {
return match std::fs::symlink_metadata(&next) {
Ok(m) if m.file_type().is_symlink() => Toward::Refused(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(_) => Toward::Refused(format!("automation path rejected: cannot resolve {shown}: {e}")),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
if parts.any(|part| !matches!(part, Component::Normal(_) | Component::CurDir)) {
return Toward::Refused(format!("automation path rejected: parent traversal below a folder that doesn't exist: {shown}"));
}
Toward::Inside
}
Err(e) => Toward::Refused(format!("automation path rejected: cannot resolve {shown}: {e}")),
};
}
}
}
if inside(&at, root) { Toward::Inside } else { Toward::Outside }
}
/// Is `path` (resolved) `root` or inside it, component by component (without regard to case on
/// Windows, whose file systems ignore it)?
/// Is `path` (resolved) `root` or inside it, component by component (the drive letter without
/// regard to case on Windows)?
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 {
use std::path::Prefix;
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(),
match (a, b) {
(Component::Prefix(a), Component::Prefix(b)) => match (a.kind(), b.kind()) {
(Prefix::Disk(a) | Prefix::VerbatimDisk(a), Prefix::Disk(b) | Prefix::VerbatimDisk(b)) => a.eq_ignore_ascii_case(&b),
_ => false,
},
_ => false,
}
}
@@ -306,6 +332,62 @@ mod tests {
let _ = std::fs::remove_dir_all(base);
}
/// Outside the root, the refusal comes before anything out there is looked up, so it can't
/// tell whether a folder out there exists.
#[test]
fn paths_out_of_the_root_are_refused_alike_without_looking() {
let (base, roots) = folders("follow");
let sep = std::path::MAIN_SEPARATOR;
let root = s(base.join("in"));
let refusal = |p: &str| roots.check(p, Access::Read).unwrap_err().replace(p, "<path>");
// Through a folder outside that exists, and through one that doesn't.
let there = format!("{root}{sep}..{sep}outside{sep}..{sep}x.pdf");
let missing = format!("{root}{sep}..{sep}nowhere{sep}..{sep}x.pdf");
assert_eq!(refusal(&there), refusal(&missing));
assert!(refusal(&missing).starts_with("automation path rejected: outside the read root"), "{}", refusal(&missing));
// Out through a missing folder and back in. (Windows folds `..` by name when it makes a path
// absolute, as it does when it opens one: that path is `in\a.pdf` there.)
#[cfg(not(windows))]
assert!(refusal(&format!("{root}/../nowhere/../in/a.pdf")).contains("outside the read root"));
// A `\\?\` path is taken as written, `..` and all, so there the walk itself refuses it.
#[cfg(windows)]
{
let verbatim = |rest: &str| format!(r"\\?\{root}\{rest}");
assert_eq!(refusal(&verbatim(r"..\outside\..\x.pdf")), refusal(&verbatim(r"..\nowhere\..\x.pdf")));
assert!(
refusal(&verbatim(r"..\nowhere\x.pdf")).starts_with("automation path rejected: outside the read root"),
"{}",
refusal(&verbatim(r"..\nowhere\x.pdf"))
);
}
let _ = std::fs::remove_dir_all(base);
}
/// The root can be named as it was given or as it resolves (`/var/…` and `/private/var/…` on
/// macOS, where the temporary folder is reached through a link).
#[test]
fn a_root_may_be_named_as_given_or_as_resolved() {
let (base, roots) = folders("given");
let resolved = std::fs::canonicalize(base.join("in")).unwrap();
assert_eq!(roots.check(&s(base.join("in/a.pdf")), Access::Read), Ok(()));
assert_eq!(roots.check(&s(resolved.join("a.pdf")), Access::Read), Ok(()));
let _ = std::fs::remove_dir_all(base);
}
/// A link beside the root, in one of its parent folders, is followed: to the root it leads in,
/// anywhere else it leads out.
#[cfg(unix)]
#[test]
fn links_beside_the_root_are_followed() {
let (base, roots) = folders("beside");
std::os::unix::fs::symlink(base.join("in"), base.join("to-in")).unwrap();
std::os::unix::fs::symlink(base.join("outside"), base.join("to-outside")).unwrap();
assert_eq!(roots.check(&s(base.join("to-in/a.pdf")), Access::Read), Ok(()));
assert_eq!(roots.check(&s(base.join("to-in/sub/new.pdf")), Access::Read), Ok(()));
assert!(roots.check(&s(base.join("to-outside/secret.pdf")), Access::Read).unwrap_err().contains("outside the read root"));
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn relative_paths_resolve_against_the_working_directory() {
let (base, roots) = folders("relative");
@@ -385,6 +467,37 @@ mod tests {
let _ = std::fs::remove_dir_all(base);
}
/// #4003: canonical path components are compared as the file system gave them, with only the
/// Windows drive letter matched ASCII case-insensitively, so a sibling whose name `to_lowercase`
/// folds onto the root's (`K` U+212A → `k`) or a differently-cased canonical component is not
/// taken as inside the root.
#[test]
fn canonical_components_are_compared_exactly() {
let (base, _) = folders("canonical-exact");
std::fs::create_dir_all(base.join("ink")).unwrap();
let kelvin = base.join("in\u{212A}");
std::fs::create_dir_all(&kelvin).unwrap();
std::fs::write(kelvin.join("secret.pdf"), "x").unwrap();
// U+212A folds to K. A case-insensitive volume stores both names as one directory, so
// `check` would be asked to reject a path inside its own root. The component comparison
// itself does not need that volume.
let distinct = base.join("ink").canonicalize().ok() != kelvin.canonicalize().ok();
if distinct {
let roots = AutomationRoots::new(Some(&base.join("ink")), None).unwrap().unwrap();
let e = roots.check(&s(kelvin.join("secret.pdf")), Access::Read).unwrap_err();
assert!(e.starts_with("automation path rejected: outside the read root"), "{e}");
}
assert!(!inside(Path::new("/work/in\u{212A}/secret.pdf"), Path::new("/work/ink")));
assert!(!inside(Path::new(r"\\?\C:\work\IN\a.pdf"), Path::new(r"\\?\C:\work\in")));
#[cfg(windows)]
{
assert!(inside(Path::new(r"\\?\c:\work\in\a.pdf"), Path::new(r"\\?\C:\work\in")));
assert!(inside(Path::new(r"c:\work\in\a.pdf"), Path::new(r"\\?\C:\work\in")));
assert!(!inside(Path::new(r"\\?\D:\work\in\a.pdf"), Path::new(r"\\?\C:\work\in")));
}
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
@@ -393,7 +506,8 @@ mod tests {
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.
// discovered file again before `process` reads it or `publish` writes it. `doc_bates_multi`
// resolves its paths before checking output collisions and using the probe helpers.
const RESOLVED_BY_CALLER: &[&str] = &[
"write_atomic",
"write_atomic_with",
@@ -405,6 +519,8 @@ mod tests {
"linked",
"walk",
"publish",
"check_unique_outputs",
"probe_files_for_resolved_outputs",
];
let mut dirs = vec![Path::new(env!("CARGO_MANIFEST_DIR")).join("src")];
let mut files = vec![];
+78 -1
View File
@@ -208,13 +208,58 @@ fn kind_name(k: FormFieldKind) -> &'static str {
}
}
/// A JSON number as the radio option it names: its integer spelling when it is a whole number
/// (`1`, `1.0` and `1e0` are all `1`; `-0` is `0`), otherwise JSON's own spelling (`1.5`), which
/// names only an option exported as exactly that text. A whole number at or past 2^53, where a
/// float no longer holds every integer, keeps JSON's spelling too (`1e300`), so it names no option
/// by accident.
fn number_option(n: &serde_json::Number) -> String {
// Integers as parsed (u64, i64) print exactly.
if n.is_u64() || n.is_i64() {
return n.to_string();
}
const EXACT: f64 = 9_007_199_254_740_992.0; // 2^53
match n.as_f64() {
// Finite, whole and below 2^53 in magnitude, so the cast is exact and in range.
Some(x) if x.is_finite() && x.fract() == 0.0 && x.abs() < EXACT => (x as i64).to_string(),
_ => n.to_string(),
}
}
/// A JSON value → the value for this field (booleans for check boxes, arrays for lists…).
fn value_for(f: &FormField, v: &Value) -> Result<FieldValue> {
let wrong = |what: &str| ToolError::InvalidArgs(format!("{}: {what}", f.name));
Ok(match (f.kind, v) {
(FormFieldKind::CheckBox, Value::Bool(b)) => FieldValue::Check(*b),
// {"widget": n} turns on button n (1-based, as form_fields numbers them) and never turns it
// off: radio buttons that share an on state stay exclusive (ISO 32000-2 Table 229).
(FormFieldKind::CheckBox | FormFieldKind::Radio, Value::Object(o)) => {
let n = o.get("widget").and_then(Value::as_u64).filter(|&n| n >= 1).ok_or_else(|| wrong("use {\"widget\": n} with n from 1"))?;
let i = usize::try_from(n - 1).map_err(|_| wrong("there is no such widget"))?;
if f.widgets.get(i).and_then(|w| w.on_state.as_ref()).is_none() {
return Err(wrong(&format!("there is no button {n} with an on state (the field has {})", f.widgets.len())));
}
FieldValue::Widget(i)
}
(FormFieldKind::Radio, Value::Null) => FieldValue::Radio(None),
(FormFieldKind::Radio, Value::String(s)) => FieldValue::Radio(Some(s.clone())),
// An option as `form_fields` lists it (an export value) names its button's on state. The
// export values are matched first and the on-state names after them
// (`Field::state_for_export`): with `/Opt [(1) (2)]` and states `/0` `/1`, `1` is the first
// button, not the second one's state. A value matching neither is refused when written,
// with the options listed.
(FormFieldKind::Radio, Value::String(s)) => FieldValue::Radio(Some(f.state_for_export(s).unwrap_or(s).to_owned())),
// A number is the same option written without quotes (`1` for the export value `1`).
// Below 2^53 it is compared as a number with the export values first, so `1.0` names the button
// exported as `1.0` (`/Opt [(1.0) (2.0)]`, states `/0 /1`) and not the one whose state is
// `/1`; then as text, as a string would be.
(FormFieldKind::Radio, Value::Number(n)) => {
if let Some(state) = n.as_f64().and_then(|x| f.state_for_number(x)) {
FieldValue::Radio(Some(state.to_owned()))
} else {
let s = number_option(n);
FieldValue::Radio(Some(f.state_for_export(&s).map_or_else(|| s.clone(), str::to_owned)))
}
}
(FormFieldKind::Combo | FormFieldKind::List, Value::Array(a)) => FieldValue::Choice(
a.iter().map(|x| x.as_str().map(str::to_owned).ok_or_else(|| wrong("list values must be strings"))).collect::<Result<_>>()?,
),
@@ -294,9 +339,41 @@ impl Automation {
}
FormFieldKind::Radio => {
o.insert("options".into(), json!((0..f.widgets.len()).filter_map(|i| f.export_of(i)).collect::<Vec<_>>()));
// Buttons sharing an on state have the same value: which is on (1-based).
o.insert(
"on_widgets".into(),
json!((0..f.widgets.len()).filter(|&i| f.widget_on(i)).map(|i| i.saturating_add(1)).collect::<Vec<_>>()),
);
}
// A check box whose kids have different on states (ISO 32000-2 §12.7.5.2.3):
// which one is ticked, and the names form_fill takes to tick each. One option
// per distinct on state: kids sharing a state tick together (EXAMPLE 2), so
// their `/Opt` exports are not separate choices. Exports name the options
// unless two states share one (Table 230 allows it) or an export would tick
// another state (form_fill reads a name as an on state first); then the
// states do.
FormFieldKind::CheckBox => {
let mut seen = std::collections::HashSet::new();
let states: Vec<&str> = f.widgets.iter().filter_map(|w| w.on_state.as_deref()).filter(|s| seen.insert(*s)).collect();
if states.len() > 1 {
let mut exports = std::collections::HashSet::new();
let by_export = states.iter().all(|s| {
let e = f.export_for_state(s);
exports.insert(e) && f.state_for(e) == Some(*s)
});
let name = |s: &str| if by_export { f.export_for_state(s).to_owned() } else { s.to_owned() };
// The ticked kid is the one whose `/AS` is its on state: §12.7.5.2.3
// makes `/AS` decide where it and `/V` differ.
let on = (0..f.widgets.len()).find(|&i| f.widget_on(i)).and_then(|i| f.widgets.get(i)?.on_state.as_deref());
o.insert("options".into(), json!(states.iter().map(|s| name(s)).collect::<Vec<_>>()));
o.insert("checked".into(), json!(on.map(name)));
}
}
FormFieldKind::Combo | FormFieldKind::List => {
o.insert("options".into(), f.options.iter().map(|(e, d)| json!({ "value": e, "label": d })).collect());
// The selected options as 1-based positions in `options`: two options can share
// an export value, and `value` alone does not tell them apart.
o.insert("selected".into(), json!(f.selected.iter().map(|i| i.saturating_add(1)).collect::<Vec<_>>()));
o.insert("multi_select".into(), json!(f.has(field_flags::MULTI_SELECT)));
o.insert("editable".into(), json!(f.has(field_flags::EDIT)));
}
+40
View File
@@ -0,0 +1,40 @@
//! Print production ▸ Fix hairlines as a tool: strokes narrower than a threshold on the page get a
//! width (`doc_fix_hairlines`).
use pdfcraft_engine::{Edit, Hairlines};
use serde_json::{Value, json};
use crate::{Args, Automation, Result, ToolError};
impl Automation {
/// Strokes thinner than `narrower_than` points on the page (after every transformation;
/// zero-width lines always) are given `width` points; Type 3 glyphs and tiling patterns on
/// request. Nothing to fix is not an error: `fixed` is 0 and no edit is made. `elsewhere`
/// counts the glyphs and patterns changed that something outside `pages` also uses.
pub(crate) fn fix_hairlines(&mut self, a: &Args) -> Result<Value> {
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 settings = Hairlines {
narrower_than: a.opt_num("narrower_than")?.unwrap_or(0.003 * 72.0),
width: a.opt_num("width")?.unwrap_or(0.01 * 72.0),
type3: a.opt_bool("type3")?.unwrap_or(false),
patterns: a.opt_bool("patterns")?.unwrap_or(false),
};
if !settings.in_range() {
return Err(ToolError::InvalidArgs(format!(
"narrower_than must be 0 or more and width above 0, both at most {} (points)",
Hairlines::MAX_POINTS
)));
}
let id = self.doc(a)?.id;
let found = self.session.count_hairlines(id, &pages, &settings);
if found.lines == 0 {
return Ok(json!({ "fixed": 0, "pages": 0, "elsewhere": 0 }));
}
let r = self.apply(a, Edit::FixHairlines { pages, settings })?;
Ok(json!({ "fixed": found.lines, "pages": found.pages, "elsewhere": found.elsewhere, "undo": r["undo"] }))
}
}
+62 -30
View File
@@ -279,6 +279,8 @@ pub(crate) fn summary(d: &Document) -> Value {
})
}
/// What `doc_info` returns for an open document: its metadata, security, pages, bookmarks,
/// comments, fields, links, layers, transitions, attachments, fonts, warnings and repairs.
pub(crate) fn info(d: &Document) -> Value {
let i = &d.info;
let page1 = |p: usize| p + 1;
@@ -313,7 +315,7 @@ pub(crate) fn info(d: &Document) -> Value {
"pages": i.pages.iter().enumerate().map(|(n, p)| json!({
"page": n + 1, "label": p.label, "width": p.width, "height": p.height, "rotation": p.rotation,
})).collect::<Vec<_>>(),
"outline": outline(&i.outline),
"outline": outline(i, &i.outline),
"outline_more": i.outline_more,
// Rectangles use the tools' convention (top-left of the displayed page, like
// comment_list and link_list), not raw PDF user space, so they can be fed back to
@@ -329,34 +331,15 @@ pub(crate) fn info(d: &Document) -> Value {
})).collect::<Vec<_>>(),
"links": i.links.iter().map(|l| json!({
"page": page1(l.page), "rect": view_rect(i, l.page, l.rect),
"target": match &l.target {
pdfcraft_render::LinkTarget::Page(p, _) => json!({ "page": page1(*p) }),
pdfcraft_render::LinkTarget::Uri(u) => json!({ "uri": u }),
pdfcraft_render::LinkTarget::SetLayers { changes, preserve_rb } => json!({
"layers": changes.iter().map(|(op, ocg)| json!({
"layer": i.layers.iter().find(|l| l.id == *ocg).map(|l| l.name.as_str()),
"state": match op {
pdfcraft_render::LayerOp::On => "on",
pdfcraft_render::LayerOp::Off => "off",
pdfcraft_render::LayerOp::Toggle => "toggle",
},
})).collect::<Vec<_>>(),
"preserve_rb": preserve_rb,
}),
pdfcraft_render::LinkTarget::RemoteGoTo { launch, file, destination, new_window } => json!({
"action": if *launch { "Launch" } else { "GoToR" },
"remote_document": file,
"destination": match destination {
pdfcraft_render::RemoteDestination::Default => json!({ "kind": "default" }),
pdfcraft_render::RemoteDestination::Page { page, view } => json!({ "kind": "page", "page": page1(*page), "view": format!("{view:?}") }),
pdfcraft_render::RemoteDestination::Named(name) => json!({ "kind": "named", "name": name }),
},
"new_window": new_window,
}),
pdfcraft_render::LinkTarget::Other(o) => json!({ "other": o }),
},
"target": link_target(i, &l.target),
})).collect::<Vec<_>>(),
"layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible, "initial": l.initial })).collect::<Vec<_>>(),
// The Layers panel's rows, the default configuration's /Order (ISO 32000-2 Table 99): a
// layer by its 1-based position in `layers`, or a label; `depth` 0 at the top.
"layer_order": i.layer_order.iter().map(|r| match &r.item {
pdfcraft_render::LayerItem::Layer(n) => json!({ "depth": r.depth, "layer": n.saturating_add(1), "name": i.layers.get(*n).map(|l| l.name.as_str()) }),
pdfcraft_render::LayerItem::Label(s) => json!({ "depth": r.depth, "label": s }),
}).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!({
@@ -364,7 +347,12 @@ pub(crate) fn info(d: &Document) -> Value {
"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<_>>(),
"attachments": i.attachments.iter().map(|a| json!({ "name": a.name, "key": match &a.source { pdfcraft_render::AttachmentSource::Document { key } => Some(key), pdfcraft_render::AttachmentSource::Annotation { .. } => None }, "description": a.description, "size": a.size })).collect::<Vec<_>>(),
// A PDF portfolio (`/Collection`, ISO 32000-2 §7.11.6): its pages are a cover sheet and its
// documents are `attachments`; `initial` names the one presented first, null for the cover.
"portfolio": i.portfolio.as_ref().map(|p| json!({
"initial": p.initial.and_then(|n| i.attachments.get(n)).map(|a| &a.name), "view": p.view.name(),
})),
// `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
// `substitute` with a non-null `substitution` means no face was available at all, so
@@ -389,6 +377,50 @@ pub fn view_rect(i: &pdfcraft_render::DocInfo, page: usize, rect: [f32; 4]) -> [
i.pages.get(page).map_or(rect, |p| comments::rect_to_view(p, rect))
}
fn outline(items: &[pdfcraft_render::OutlineItem]) -> Value {
Value::Array(items.iter().map(|o| json!({ "title": o.title, "page": o.page.map(|p| p + 1), "children": outline(&o.children) })).collect())
/// Bookmarks with the page each goes to, or the action it runs instead (as a link's `target`).
fn outline(i: &pdfcraft_render::DocInfo, items: &[pdfcraft_render::OutlineItem]) -> Value {
Value::Array(
items
.iter()
.map(|o| {
json!({
"title": o.title, "page": o.page.map(|p| p + 1),
"action": o.action.as_ref().map(|a| link_target(i, a)), "children": outline(i, &o.children),
})
})
.collect(),
)
}
/// Where a link goes, or what a bookmark's action does: a 1-based page, a web address, layer
/// changes, another PDF, or the name of an action that isn't run.
fn link_target(i: &pdfcraft_render::DocInfo, target: &pdfcraft_render::LinkTarget) -> Value {
// A remote page number is the file's, however large.
let page1 = |p: usize| p.saturating_add(1);
match target {
pdfcraft_render::LinkTarget::Page(p, _) => json!({ "page": page1(*p) }),
pdfcraft_render::LinkTarget::Uri(u) => json!({ "uri": u }),
pdfcraft_render::LinkTarget::SetLayers { changes, preserve_rb } => json!({
"layers": changes.iter().map(|(op, ocg)| json!({
"layer": i.layers.iter().find(|l| l.id == *ocg).map(|l| l.name.as_str()),
"state": match op {
pdfcraft_render::LayerOp::On => "on",
pdfcraft_render::LayerOp::Off => "off",
pdfcraft_render::LayerOp::Toggle => "toggle",
},
})).collect::<Vec<_>>(),
"preserve_rb": preserve_rb,
}),
pdfcraft_render::LinkTarget::RemoteGoTo { launch, file, destination, new_window } => json!({
"action": if *launch { "Launch" } else { "GoToR" },
"remote_document": file,
"destination": match destination {
pdfcraft_render::RemoteDestination::Default => json!({ "kind": "default" }),
pdfcraft_render::RemoteDestination::Page { page, view } => json!({ "kind": "page", "page": page1(*page), "view": format!("{view:?}") }),
pdfcraft_render::RemoteDestination::Named(name) => json!({ "kind": "named", "name": name }),
},
"new_window": new_window,
}),
pdfcraft_render::LinkTarget::Other(o) => json!({ "other": o }),
}
}
+5 -2
View File
@@ -22,6 +22,7 @@ mod content;
mod conventions;
pub mod file_access;
mod forms;
mod hairlines;
mod layers;
mod links;
#[cfg(feature = "mcp")]
@@ -48,7 +49,6 @@ use serde_json::{Value, json};
pub use pdfcraft_render::Pixels;
pub use tools::{ToolDef, tools};
use file_access::foreign_share;
use text_cache::TextCache;
mod assemble;
@@ -71,7 +71,7 @@ use pages::{DEFAULT_DPI, MAX_DPI, encode_png, rendered_content};
use pages::{FIND_FIRST_BATCH, FIND_MAX_WARNING_BYTES, FIND_MAX_WARNINGS, extract_parallel};
#[cfg(test)]
pub(crate) use paths::MAX_INPUT;
use paths::{child, lexical, unused, write_atomic};
use paths::{child, lexical, write_atomic};
pub(crate) use paths::{read_input, read_input_bytes};
#[cfg(test)]
use paths::{write_atomic_synced, write_atomic_with};
@@ -101,6 +101,8 @@ pub struct Automation {
/// Review sessions held for `ocr_words` (a guard and the pages read), by document.
ocr_reviews: HashMap<DocId, OcrReview>,
next_ocr_review: u64,
/// What object_copy copied, for object_paste into any open document.
objects_clipboard: Option<pdfcraft_engine::ObjectClipboard>,
}
impl Default for Automation {
@@ -121,6 +123,7 @@ impl Automation {
evicted: HashSet::new(),
ocr_reviews: HashMap::new(),
next_ocr_review: 0,
objects_clipboard: None,
}
}
+139 -17
View File
@@ -2,7 +2,7 @@
//! all links. Rectangles are points from the top-left of the displayed page.
use pdfcraft_engine::{
Edit, LinkAction, LinkHighlight, LinkStyle, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path,
Edit, LinkAction, LinkHighlight, LinkStyle, LinkZoom, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path,
};
use pdfcraft_render::{DestView, LinkTarget, RemoteDestination};
use serde_json::{Value, json};
@@ -25,18 +25,30 @@ impl Automation {
Ok(Some([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]))
}
fn link_action(&self, a: &Args) -> Result<Option<LinkAction>> {
/// The action `url`, `to_page` and `zoom` ask for, if any. `current` is the link's action now
/// (link_edit): a new page keeps the link's zoom, and a zoom alone keeps its page.
fn link_action(&self, a: &Args, current: Option<&LinkAction>) -> Result<Option<LinkAction>> {
let zoom = link_zoom(a)?;
let (current_page, current_zoom) = match current {
Some(LinkAction::Page { page, zoom }) => (Some(*page), Some(*zoom)),
_ => (None, None),
};
match (a.opt_str("url")?, a.opt_int("to_page")?) {
(Some(_), Some(_)) => Err(bad("pass url or to_page, not both")),
(Some(_), None) if zoom.is_some() => Err(bad("zoom goes with to_page, not url")),
(Some(u), None) => Ok(Some(LinkAction::Uri(u.to_string()))),
(None, Some(p)) => {
let n = self.doc(a)?.info.pages.len() as i64;
if p < 1 || p > n {
return Err(bad(format!("to_page must be 1–{n}")));
}
Ok(Some(LinkAction::Page(p as usize - 1)))
Ok(Some(LinkAction::Page { page: p as usize - 1, zoom: zoom.or(current_zoom).unwrap_or(LinkZoom::FitPage) }))
}
(None, None) => Ok(None),
(None, None) => match (zoom, current_page) {
(None, _) => Ok(None),
(Some(zoom), Some(page)) => Ok(Some(LinkAction::Page { page, zoom })),
(Some(_), None) => Err(bad("zoom needs to_page: the link doesn't go to a page")),
},
}
}
@@ -65,7 +77,8 @@ 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 viewed: std::collections::HashMap<(usize, usize), &pdfcraft_render::Link> =
doc.info.links.iter().map(|t| ((t.page, t.index), t)).collect();
let items: Vec<Value> = doc
.links()
.iter()
@@ -73,30 +86,104 @@ impl Automation {
// The link's page may be one the viewer could not read (#2660): rect unchanged then.
let rect = crate::view_rect(&doc.info, l.page, l.rect.map(|x| x as f32));
let mut v = json!({ "page": l.page + 1, "index": l.index + 1, "rect": rect, "visible": l.style.visible });
let link = viewed.get(&(l.page, l.index));
// Where the link is active when that isn't its whole rect: its /QuadPoints
// (ISO 32000-2 Table 176), as the bounding box of each quadrilateral.
if let Some(link) = link.filter(|k| !k.quads.is_empty()) {
let areas: Vec<Value> = link
.quads
.iter()
.map(|q| {
let (xs, ys) = ([q[0], q[2], q[4], q[6]], [q[1], q[3], q[5], q[7]]);
let fold = |v: [f32; 4], f: fn(f32, f32) -> f32, start: f32| v.into_iter().fold(start, f);
let box_ = [
fold(xs, f32::min, f32::MAX),
fold(ys, f32::min, f32::MAX),
fold(xs, f32::max, f32::MIN),
fold(ys, f32::max, f32::MIN),
];
json!(crate::view_rect(&doc.info, l.page, box_))
})
.collect();
v["active_areas"] = json!(areas);
}
// 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 {
let target = link.and_then(|t| match &t.target {
LinkTarget::Page(page, view) => Some((*page, view)),
_ => None,
});
match (&l.action, target) {
(LinkAction::Uri(u), _) => v["url"] = json!(u),
(LinkAction::Uri(u), _) => {
v["url"] = json!(u);
// A relative address, resolved against the catalog's /URI /Base
// (ISO 32000-2 Table 211): what the viewer offers to open. That is the
// address inspection kept, which stays raw once its text budget is spent;
// resolve afresh only for a link inspection did not list.
let opened = match link.map(|t| &t.target) {
Some(LinkTarget::Uri(kept)) => Some(kept.clone()),
Some(_) => None,
None => pdfcraft_render::resolve_uri(doc.info.uri_base.as_deref(), u),
};
if let Some(opened) = opened.filter(|o| o != u) {
v["resolved_url"] = json!(opened);
}
}
(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);
match action {
LinkAction::Page { zoom, .. } => v["zoom"] = zoom_value(*zoom),
LinkAction::Other(_) => v["named"] = json!(true),
LinkAction::Uri(_) => {}
}
}
(LinkAction::Page(p), None) => v["to_page"] = json!(p + 1),
(LinkAction::Page { page, zoom }, None) => {
v["to_page"] = json!(page + 1);
v["zoom"] = zoom_value(*zoom);
}
(LinkAction::Other(o), None) => v["other"] = json!(o),
}
v
})
.collect();
Ok(json!({ "count": items.len(), "links": items }))
let mut out = json!({ "count": items.len(), "links": items });
// Addresses written as plain page text, which the viewer makes clickable (#4207). Reading
// the pages' text costs more than the list above, so only on request.
if a.opt_bool("text_links")?.unwrap_or(false) {
let id = doc.id;
// One share of the pages at a time (`MAX_TEXT_LINK_PAGES_PER_SCAN`), from the 1-based
// `text_links_from`; `text_links_next_page` says where the next share starts.
let pages = doc.info.pages.len().max(1);
let from = match a.opt_int("text_links_from")? {
None => 0,
Some(p) => usize::try_from(p)
.ok()
.filter(|p| (1..=pages).contains(p))
.map(|p| p - 1)
.ok_or_else(|| bad(format!("text_links_from must be a page from 1 to {pages}")))?,
};
let scan = self.session.text_links(id, from);
if let Some(next) = scan.next_page {
out["text_links_next_page"] = json!(next.saturating_add(1));
}
let found: Vec<Value> = scan
.links
.into_iter()
.map(|(page, l)| {
let mut v = json!({ "page": page + 1, "rects": l.rects, "url": l.uri });
// What the viewer does on a click: the same check every document link gets.
if let Err(e) = pdfcraft_engine::links::document_url(&l.uri) {
v["blocked"] = json!(e.to_string());
}
v
})
.collect();
out["text_links"] = json!(found);
}
Ok(out)
}
pub(crate) fn link_open(&mut self, a: &Args) -> Result<Value> {
@@ -168,12 +255,13 @@ impl Automation {
pub(crate) fn link_add(&mut self, a: &Args) -> Result<Value> {
let page = self.page(a)?;
let rect = self.view_rect(a, page)?.ok_or_else(|| bad("link_add needs rect"))?;
let action = self.link_action(a)?.ok_or_else(|| bad("pass url or to_page"))?;
let action = self.link_action(a, None)?.ok_or_else(|| bad("pass url or to_page"))?;
let style = self.link_style(a, LinkStyle::default())?;
self.apply(a, Edit::AddLink { page, rect, action, style })
}
fn link_index(&self, a: &Args) -> Result<(usize, usize, LinkStyle)> {
/// The link `page` and `index` name: its page, index in `/Annots`, appearance and action.
fn link_index(&self, a: &Args) -> Result<(usize, usize, LinkStyle, LinkAction)> {
let page = self.page(a)?;
let index = a.int("index")?;
let doc = self.doc(a)?;
@@ -182,19 +270,19 @@ impl Automation {
.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)))?;
Ok((page, l.index, l.style))
Ok((page, l.index, l.style, l.action.clone()))
}
pub(crate) fn link_edit(&mut self, a: &Args) -> Result<Value> {
let (page, index, style) = self.link_index(a)?;
let (page, index, style, current) = self.link_index(a)?;
let rect = self.view_rect(a, page)?;
let action = self.link_action(a)?;
let action = self.link_action(a, Some(&current))?;
let new_style = self.link_style(a, style)?;
self.apply(a, Edit::SetLink { page, index, rect, action, style: (new_style != style).then_some(new_style) })
}
pub(crate) fn link_delete(&mut self, a: &Args) -> Result<Value> {
let (page, index, _) = self.link_index(a)?;
let (page, index, ..) = self.link_index(a)?;
self.apply(a, Edit::DeleteLink { page, index })
}
@@ -225,6 +313,40 @@ impl Automation {
}
}
/// The zoom a `zoom` argument names: fit_page, fit_width, fit_height, fit_visible, actual
/// (100%), inherit (keep the reader's zoom), or a percentage, kept within 1–6400.
fn link_zoom(a: &Args) -> Result<Option<LinkZoom>> {
match a.get("zoom") {
None | Some(Value::Null) => Ok(None),
Some(Value::Number(p)) => match p.as_f64() {
Some(p) if p.is_finite() && p > 0.0 => Ok(Some(LinkZoom::Percent(p.clamp(1.0, 6400.0)))),
_ => Err(bad("zoom must be a positive percentage")),
},
Some(Value::String(z)) => Ok(Some(match z.as_str() {
"fit_page" => LinkZoom::FitPage,
"fit_width" => LinkZoom::FitWidth,
"fit_height" => LinkZoom::FitHeight,
"fit_visible" => LinkZoom::FitVisible,
"actual" => LinkZoom::Percent(100.0),
"inherit" => LinkZoom::Inherit,
o => return Err(bad(format!("unknown zoom {o:?}"))),
})),
Some(_) => Err(bad("zoom is a name or a percentage")),
}
}
/// How `link_list` reports a link's zoom: the names `link_zoom` reads, a percentage as a number.
fn zoom_value(zoom: LinkZoom) -> Value {
match zoom {
LinkZoom::FitPage => json!("fit_page"),
LinkZoom::FitWidth => json!("fit_width"),
LinkZoom::FitHeight => json!("fit_height"),
LinkZoom::FitVisible => json!("fit_visible"),
LinkZoom::Inherit => json!("inherit"),
LinkZoom::Percent(p) => json!(p),
}
}
fn dest_view_value(view: DestView) -> Value {
match view {
DestView::Top => json!({ "kind": "top" }),
+1 -1
View File
@@ -147,7 +147,7 @@ impl Automation {
Ok(json!({"point":point,"kind":s.kind,"distance":measure::distance(at,point)}))
})
.transpose()?;
Ok(json!({"snap":snap,"truncated":geometry.truncated,"intersection_limited":intersection_limited,"segments":geometry.segments.len()}))
Ok(json!({"snap":snap,"truncated":geometry.truncated,"intersection_limited":intersection_limited,"segments":geometry.segment_count()}))
}
pub(crate) fn measurement_export(&self, a: &Args) -> Result<Value> {
let all = self.doc(a)?.measurements().map_err(failed)?;
+70 -3
View File
@@ -2,7 +2,9 @@
use pdfcraft_engine::model3d::Model3dKind;
use pdfcraft_prc::PrcFile;
use pdfcraft_prc_render::{Camera, RenderOptions, assemble, render};
use pdfcraft_prc_render::nav::{Axis, NamedView, Orbit, Upright, reach};
use pdfcraft_prc_render::{Bounds, Camera, RenderOptions, WorldMesh, assemble, render};
use serde_json::Value;
use super::Automation;
use super::dispatch::Content;
@@ -22,7 +24,6 @@ impl Automation {
let page = self.page(a)?;
let width = side(a, "width")?;
let height = side(a, "height")?;
let direction = direction(a)?;
let perspective = a.opt_bool("perspective")?.unwrap_or(true);
let doc = self.doc(a)?;
let found = doc.model_3d_annotations(page).map_err(failed)?;
@@ -42,7 +43,14 @@ impl Automation {
let prc = PrcFile::parse(&model.data).map_err(|e| failed(format!("the 3D model could not be read: {e}")))?;
let meshes = assemble(&prc).map_err(failed)?;
let aspect = f64::from(width) / f64::from(height);
let camera = Camera::fit_meshes(&meshes, direction, up_for(direction), perspective, aspect).map_err(failed)?;
let camera = match a.get("orbit") {
Some(_) if a.get("direction").is_some() => return Err(Args::wrong("orbit", "left out when direction is given")),
Some(moves) => orbit_camera(moves, &meshes, perspective, aspect)?,
None => {
let direction = direction(a)?;
Camera::fit_meshes(&meshes, direction, up_for(direction), perspective, aspect).map_err(failed)?
}
};
let image = render(&meshes, &camera, &RenderOptions { width, height, ..RenderOptions::default() }).map_err(failed)?;
let data = encode_png(image.width, image.height, &premultiplied(image.rgba))?;
Ok(vec![Content::Png { data, width: image.width, height: image.height }])
@@ -71,6 +79,65 @@ fn direction(a: &Args) -> Result<[f64; 3]> {
Ok(out)
}
/// The keys `orbit` takes.
const ORBIT_KEYS: [&str; 7] = ["view", "up", "front", "turn", "zoom", "zoom_at", "pan"];
/// The camera `orbit` names: a named view about an up axis, then a turn, a zoom and a pan, as a
/// viewer's orbit control would apply them.
fn orbit_camera(moves: &Value, meshes: &[WorldMesh], perspective: bool, aspect: f64) -> Result<Camera> {
let wrong = |what: &str| Args::wrong("orbit", what);
let moves = moves.as_object().ok_or_else(|| wrong("an object"))?;
if let Some(key) = moves.keys().find(|k| !ORBIT_KEYS.contains(&k.as_str())) {
return Err(wrong(&format!("an object without {key}; its keys are {}", ORBIT_KEYS.join(", "))));
}
let token = |key: &str| moves.get(key).map(|v| v.as_str().ok_or_else(|| wrong(&format!("{key} as a string"))));
let axis = |key: &str| token(key).map(|t| Axis::from_token(t?).ok_or_else(|| wrong(&format!("{key} as one of +x, +y, +z, -x, -y, -z"))));
let mut upright = Upright::default();
if let Some(up) = axis("up") {
upright = upright.with_up(up?);
}
if let Some(front) = axis("front") {
let front = front?;
if !upright.up().perpendicular(front) {
return Err(wrong("front perpendicular to up"));
}
upright = upright.with_front(front);
}
let view = match token("view") {
None => NamedView::Isometric,
Some(t) => NamedView::from_token(t?).ok_or_else(|| wrong("view as one of iso, front, right, top, back"))?,
};
let mut orbit = Orbit::named(view, perspective, upright);
let bounds = Bounds::of(meshes).ok_or_else(|| failed("the model has no vertex"))?;
let reach = reach(meshes, &bounds);
if let Some(v) = moves.get("turn") {
let [yaw, pitch] = pair(v).ok_or_else(|| wrong("turn as [yaw, pitch] in degrees"))?;
orbit.turn(yaw.to_radians(), pitch.to_radians());
}
if let Some(v) = moves.get("zoom") {
let factor = v.as_f64().filter(|f| f.is_finite() && *f > 0.0).ok_or_else(|| wrong("zoom as a positive number"))?;
let at = match moves.get("zoom_at") {
None => [0.0, 0.0],
Some(v) => pair(v).ok_or_else(|| wrong("zoom_at as [right, up] in picture heights"))?,
};
orbit.zoom_at(&bounds, reach, aspect, factor, at);
} else if moves.contains_key("zoom_at") {
return Err(wrong("an object with zoom when it has zoom_at"));
}
if let Some(v) = moves.get("pan") {
orbit.pan_by(pair(v).ok_or_else(|| wrong("pan as [right, up] in model radii"))?);
}
orbit.camera(&bounds, reach, aspect).map_err(failed)
}
/// Two finite numbers.
fn pair(v: &Value) -> Option<[f64; 2]> {
match v.as_array()?.as_slice() {
[a, b] => Some([a.as_f64().filter(|f| f.is_finite())?, b.as_f64().filter(|f| f.is_finite())?]),
_ => None,
}
}
/// Z is up, as CAD models are drawn, unless the view looks along it.
fn up_for([x, y, z]: [f64; 3]) -> [f64; 3] {
if x.hypot(y) <= z.abs() * 1e-6 { [0.0, 1.0, 0.0] } else { [0.0, 0.0, 1.0] }
+27 -4
View File
@@ -235,6 +235,10 @@ impl Automation {
let (mut html_left, mut spent, mut cut) = (html_budget, false, false);
for (&p, t) in pages.iter().zip(texts) {
let mut page = json!({ "page": p + 1, "text": text(&t) });
// Content cut at the renderer's limits (#623): the text is incomplete, and said so.
if t.truncated {
page["truncated"] = json!(true);
}
if html {
// Copy with formatting: the same glyphs as an HTML fragment, within one budget for
// the whole call; a page past it gets none and the call says so.
@@ -270,6 +274,8 @@ impl Automation {
/// `limit` is reached, so a bounded search doesn't extract (and cache) the whole document
/// (#971). One match past the limit supplies `next`. Reached extraction failures are reported
/// as bounded warnings; `complete` requires both no continuation and no failed pages.
/// `stop_at_limit` drops that lookahead: the search ends at the `limit`th match, so a lone match
/// never sends it to the end of the document, and `next` is only where to resume (#5328).
pub(crate) fn text_find(&mut self, a: &Args) -> Result<Value> {
self.text_find_with(a, &mut extract_parallel)
}
@@ -289,11 +295,15 @@ impl Automation {
Some(p) => return Err(ToolError::InvalidArgs(format!("start_page {p} is out of range: the document has {n} pages"))),
};
let skip = usize::try_from(a.opt_int("skip")?.unwrap_or(0).max(0)).unwrap_or(usize::MAX);
let stop_at_limit = a.opt_bool("stop_at_limit")?.unwrap_or(false);
let mut matches = Vec::new();
let mut next = None;
let mut stopped = false;
let mut warnings = Vec::new();
let mut warning_count = 0usize;
let (mut first, mut batch) = (start, FIND_FIRST_BATCH);
// A search that stops at its limit starts with one page, so a match on it reads nothing
// more; the batches still double for a match further in.
let (mut first, mut batch) = (start, if stop_at_limit { 1 } else { FIND_FIRST_BATCH });
'search: while first < n {
let end = first.saturating_add(batch).min(n);
let pages: Vec<usize> = (first..end).collect();
@@ -315,13 +325,19 @@ impl Automation {
let skip_here = if p == start { skip } else { 0 };
// One extra match proves that the caller needs a continuation. Include skipped
// matches on the starting page when bounding the temporary per-page result.
let needed = limit.saturating_sub(matches.len()).saturating_add(skip_here).saturating_add(1);
let needed = limit.saturating_sub(matches.len()).saturating_add(skip_here).saturating_add(usize::from(!stop_at_limit));
for (k, r) in text.find_opts_limited(query, false, false, needed).into_iter().enumerate().skip(skip_here) {
if matches.len() == limit {
next = Some(json!({ "start_page": p + 1, "skip": k }));
break 'search;
}
matches.push(json!({ "page": p + 1, "text": text.text_of(r.clone()), "rects": text.line_rects(r) }));
if stop_at_limit && matches.len() == limit {
// Resume just past this match; whether another exists isn't looked at.
next = Some(json!({ "start_page": p + 1, "skip": k.saturating_add(1) }));
stopped = true;
break 'search;
}
}
}
first = end;
@@ -329,8 +345,13 @@ impl Automation {
}
let mut out = json!({ "query": query, "count": matches.len(), "complete": next.is_none() && warning_count == 0, "matches": matches,
"warnings_truncated": warning_count > warnings.len(), "warning_count": warning_count, "warnings": warnings });
if let (Some(next), Some(o)) = (next, out.as_object_mut()) {
o.insert("next".into(), next);
if let Some(o) = out.as_object_mut() {
if let Some(next) = next {
o.insert("next".into(), next);
}
if stop_at_limit {
o.insert("stopped_at_limit".into(), stopped.into());
}
}
Ok(out)
}
@@ -393,6 +414,8 @@ fn render_warning_text(warning: pdfcraft_render::RenderWarning) -> &'static str
match warning {
RenderWarning::ContentTruncated => "content past the page's safety budget was skipped",
RenderWarning::ColourFallback => "a colour that could not be computed was painted in a fallback colour",
RenderWarning::MalformedIndexedImage => "an image with a malformed Indexed colour table may be drawn wrongly or not at all",
RenderWarning::Type3WidthsMissing => "a Type 3 font has no /Widths; its glyphs were spaced by the widths their glyph procedures give",
}
}
+8 -2
View File
@@ -1,5 +1,5 @@
use super::error::{Result, failed};
use super::{Automation, Path, PathBuf, StagingName, file_access, foreign_share, staging_suffixes};
use super::{Automation, Path, PathBuf, StagingName, file_access, staging_suffixes};
impl Automation {
// ---- paths -------------------------------------------------------------------------------
@@ -39,7 +39,13 @@ impl Automation {
let Some(root) = &self.root else { return Ok(p.to_path_buf()) };
let outside = || failed(format!("{path} is outside the allowed directory {}", root.display()));
let joined = lexical(&root.join(p)).ok_or_else(outside)?;
if foreign_share(&joined, root) {
// Followed one name at a time first: nothing outside the root or its own parent folders is
// looked up, not even to see whether it exists. What can't be followed inside the root is
// said below. Made absolute the way Windows reads it first: it drops a folder name's
// trailing dot (`in.` is `in`), but `follow` looks names up below `\\?\` folders, where it
// doesn't.
let as_read = std::path::absolute(&joined).map_err(|_| outside())?;
if matches!(file_access::follow(path, &as_read, root), file_access::Toward::Outside) {
return Err(outside());
}
let mut existing = joined.as_path();
+94 -22
View File
@@ -1,7 +1,7 @@
//! Printing tools: list printers, and print (or save the print-ready PDF) with Acrobat's Print
//! dialog options.
use pdfcraft_engine::print::{self, Binding, BookletSubset, Content, Layout, Orientation, PAPERS, PageOrder, SizeMode, Subset, spool};
use pdfcraft_engine::print::{self, Binding, BookletSubset, Layout, Orientation, PAPERS, PageOrder, SizeMode, Subset, spool};
use serde_json::{Value, json};
use crate::{Args, Automation, Result, ToolError, failed, write_atomic};
@@ -60,8 +60,10 @@ fn paper_named(name: Option<&str>) -> Result<(usize, (f64, f64))> {
/// The spooler job for `doc_print` to `printer`. It feeds the paper the PDF was laid out on
/// (`PAPERS[paper]`, as `media` after the driver's `options`) rather than the queue's default
/// paper, which a job naming none got (A4 under Letter sheets on the queue in #3752).
fn spool_job(a: &Args, printer: &str, title: String, duplex: Option<spool::Duplex>, paper: usize) -> Result<spool::Job> {
/// paper, which a job naming none got (A4 under Letter sheets on the queue in #3752), and tells
/// CUPS which way the laid-out `sheets` run, as the Print dialog does (#2713): an orientation the
/// caller chose in `options` stands.
fn spool_job(a: &Args, printer: &str, title: String, duplex: Option<spool::Duplex>, paper: usize, sheets: &[print::Sheet]) -> Result<spool::Job> {
let options = match a.get("options") {
None => Vec::new(),
Some(Value::Object(m)) => m
@@ -81,11 +83,54 @@ fn spool_job(a: &Args, printer: &str, title: String, duplex: Option<spool::Duple
scaling: spool::Scaling::Exact,
grayscale: a.opt_bool("grayscale")?.unwrap_or(false),
title,
options: options.into_iter().chain(print::media_option(paper)).collect(),
options: {
let mut options: Vec<(String, String)> = options.into_iter().chain(print::media_option(paper)).collect();
print::add_cups_orientation(&mut options, sheets);
options
},
})
}
impl Automation {
/// Print production ▸ Convert colours: every colour on the pages in gray, rgb or cmyk.
pub(crate) fn convert_colours(&mut self, a: &Args) -> Result<Value> {
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 target = match a.opt_str("to")?.unwrap_or("gray") {
"gray" | "grayscale" => pdfcraft_engine::ColourTarget::Gray,
"rgb" => pdfcraft_engine::ColourTarget::Rgb,
"cmyk" => pdfcraft_engine::ColourTarget::Cmyk,
other => return Err(ToolError::InvalidArgs(format!("unknown target {other:?} (gray, rgb, cmyk)"))),
};
let settings = pdfcraft_engine::ConvertColours {
target,
preserve_black: a.opt_bool("preserve_black")?.unwrap_or(true),
images: a.opt_bool("images")?.unwrap_or(true),
annotations: a.opt_bool("annotations")?.unwrap_or(false),
};
let id = self.doc(a)?.id;
let found = self.session.count_colours(id, &pages, &settings);
if found.colours == 0 && found.images == 0 {
return Ok(json!({ "colours": 0, "images": 0, "images_left": found.images_left, "pages": 0, "loses_conformance": [] }));
}
// PDF/X has no declaration of its own here: it always carries an output intent.
let declared = self.doc(a)?.standards();
let pdfa = declared.pdfa.map(|(part, level)| format!("PDF/A-{part}{}", level.to_lowercase()));
let loses: Vec<String> = pdfa.into_iter().chain(declared.output_intents.iter().map(|i| format!("output intent {i}"))).collect();
let r = self.apply(a, pdfcraft_engine::Edit::ConvertColours { pages, settings })?;
Ok(json!({
"colours": found.colours,
"images": found.images,
"images_left": found.images_left,
"pages": found.pages,
"loses_conformance": loses,
"undo": r["undo"],
}))
}
/// `printers`: the system's printers, the default marked, and each one's two-sided support
/// where the spooler reports it.
pub(crate) fn printers(&self) -> Result<Value> {
@@ -129,11 +174,20 @@ impl Automation {
let pages = print::select_pages(count, a.opt_str("pages")?, &labels, subset, a.opt_bool("reverse")?.unwrap_or(false))
.map_err(|e| bad(e.to_string()))?;
let layout = match a.opt_str("layout")?.unwrap_or("fit") {
"fit" => Layout::Size(SizeMode::Fit),
"actual" => Layout::Size(SizeMode::Actual),
"shrink" => Layout::Size(SizeMode::Shrink),
"custom" => Layout::Size(SizeMode::Custom(a.opt_num("scale")?.ok_or_else(|| bad("custom needs scale (percent)"))?)),
"multiple" => {
// Custom pages per sheet: `cols` across by `rows` down, both or neither.
let grid = match (a.opt_int("cols")?, a.opt_int("rows")?) {
(None, None) => None,
(Some(cols), Some(rows)) => {
if a.opt_int("per_sheet")?.is_some() {
return Err(bad("give per_sheet, or cols and rows, not both"));
}
let grid = usize::try_from(cols).ok().zip(usize::try_from(rows).ok()).and_then(|(cols, rows)| Layout::grid(cols, rows));
Some(grid.ok_or_else(|| bad(format!("cols and rows must each be 1 to {}", print::MAX_GRID)))?)
}
_ => return Err(bad("a custom grid needs both cols and rows")),
};
let per = a.opt_int("per_sheet")?.unwrap_or(2).clamp(1, 256) as usize;
let order = match a.opt_str("order")?.unwrap_or("horizontal") {
"horizontal" => PageOrder::Horizontal,
@@ -143,14 +197,15 @@ impl Automation {
"cut-stack" => PageOrder::CutStack,
o => return Err(bad(format!("unknown order {o:?}"))),
};
match Layout::multiple(per) {
Layout::Multiple { cols, rows, per_sheet, .. } => Layout::Multiple {
match grid.unwrap_or_else(|| Layout::multiple(per)) {
Layout::Multiple { cols, rows, per_sheet, custom, .. } => Layout::Multiple {
cols,
rows,
per_sheet,
order,
border: a.opt_bool("border")?.unwrap_or(false),
auto_rotate: a.opt_bool("auto_rotate")?.unwrap_or(true),
custom,
},
l => l,
}
@@ -169,9 +224,12 @@ impl Automation {
overlap: a.opt_num("overlap")?.unwrap_or(18.0),
cut_marks: a.opt_bool("cut_marks")?.unwrap_or(true),
},
l => return Err(bad(format!("unknown layout {l:?}"))),
l => Layout::Size(print::names::parse_size_mode(l, 100.0).ok_or_else(|| bad(format!("unknown layout {l:?}")))?),
};
let duplex = job_duplex(a.opt_str("duplex")?.unwrap_or("off"), &layout)?;
if (a.opt_int("cols")?.is_some() || a.opt_int("rows")?.is_some()) && !matches!(layout, Layout::Multiple { .. }) {
return Err(bad("cols and rows need layout multiple"));
}
if a.opt_str("order")? == Some("cut-stack") && !matches!(layout, Layout::Multiple { .. }) {
return Err(bad("cut-stack order needs layout multiple"));
}
@@ -181,13 +239,8 @@ impl Automation {
"landscape" => Orientation::Landscape,
o => return Err(bad(format!("unknown orientation {o:?}"))),
};
let content = match a.opt_str("comments_forms")?.unwrap_or("document-and-markups") {
"document" => Content::Document,
"document-and-markups" => Content::DocumentAndMarkups,
"document-and-stamps" => Content::DocumentAndStamps,
"form-fields-only" => Content::FormFieldsOnly,
c => return Err(bad(format!("unknown comments_forms {c:?}"))),
};
let content = a.opt_str("comments_forms")?.unwrap_or("document-and-markups");
let content = print::names::parse_content(content).ok_or_else(|| bad(format!("unknown comments_forms {content:?}")))?;
let (paper_index, paper) = paper_named(a.opt_str("paper")?)?;
// Print as image (Acrobat's Advanced Print Setup): pictures at `dpi`, grey when
// `grayscale` is also asked for.
@@ -202,7 +255,8 @@ impl Automation {
};
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 laid_out = print::layout(&sizes, &settings).map_err(|e| bad(e.to_string()))?;
let sheets = laid_out.len();
let bytes = self.session.print_pdf(id, &settings).map_err(failed)?;
let mut out = json!({ "sheets": sheets, "pages": settings.pages.len(), "bytes": bytes.len() });
match (a.opt_str("path")?, a.opt_str("printer")?) {
@@ -212,7 +266,7 @@ impl Automation {
out["path"] = json!(path.to_string_lossy());
}
(None, Some(printer)) => {
let job = spool_job(a, printer, self.doc(a)?.name.clone(), duplex, paper_index)?;
let job = spool_job(a, printer, self.doc(a)?.name.clone(), duplex, paper_index, &laid_out)?;
out["job"] = json!(spool::submit(&bytes, &job).map_err(|e| failed(e.to_string()))?);
}
_ => return Err(bad("pass either path (save the print-ready PDF) or printer (a name from printers, or \"default\")")),
@@ -251,13 +305,31 @@ mod tests {
assert!(duplex("sideways", false).is_err());
}
/// #2713, #3739: `doc_print` to a printer tells CUPS when every sheet runs landscape, after
/// the paper, so a landscape sheet is turned onto the portrait paper rather than printed
/// unrotated and cut off. Portrait and mixed jobs say nothing, and an orientation passed in
/// `options` stands and isn't sent twice.
#[test]
fn printing_landscape_sheets_to_a_printer_asks_cups_to_turn_them() {
let sheets = |sizes: &[(f64, f64)]| sizes.iter().map(|&size| print::Sheet { size, ..Default::default() }).collect::<Vec<_>>();
let job = |args: Value, s: &[print::Sheet]| spool_job(&Args(&args), "Office", "a.pdf".into(), None, 0, s).unwrap().options;
let media = ("media".to_string(), "Letter".to_string());
let landscape = sheets(&[(792.0, 612.0), (792.0, 612.0)]);
let turned = if cfg!(windows) { vec![media.clone()] } else { vec![media.clone(), ("orientation-requested".to_string(), "4".to_string())] };
assert_eq!(job(json!({}), &landscape), turned);
assert_eq!(job(json!({}), &sheets(&[(612.0, 792.0)])), vec![media.clone()], "portrait sheets");
assert_eq!(job(json!({}), &sheets(&[(792.0, 612.0), (612.0, 792.0)])), vec![media.clone()], "a job that mixes them");
let chosen = ("orientation-requested".to_string(), "5".to_string());
assert_eq!(job(json!({ "options": { "orientation-requested": "5" } }), &landscape), [chosen, media], "the caller's own choice, once");
}
/// #3752: `doc_print` to a printer sent no paper, so the queue's default paper stood in for
/// the one the sheets were laid out on (A4 under Letter sheets on the report's queue).
#[test]
fn printing_to_a_printer_feeds_the_laid_out_paper() {
let job = |args: Value, paper_name: Option<&str>| {
let (index, _) = paper_named(paper_name).unwrap();
spool_job(&Args(&args), "Office", "a.pdf".into(), None, index).unwrap()
spool_job(&Args(&args), "Office", "a.pdf".into(), None, index, &[]).unwrap()
};
let media = |m: &str| ("media".to_string(), m.to_string());
// US Letter when no paper is asked for, after the driver's own options.
@@ -273,7 +345,7 @@ mod tests {
assert_eq!(job(json!({}), Some("letter")).options, [media("Letter")]);
assert_eq!(job(json!({}), Some("ARCH D")).options, [media("na_arch-d_24x36in")]);
assert!(matches!(paper_named(Some("Quarto")), Err(ToolError::InvalidArgs(m)) if m.contains("US Letter")));
assert_eq!(spool_job(&Args(&json!({})), "default", String::new(), None, 0).ok().map(|j| j.printer), Some(None));
assert!(spool_job(&Args(&json!({ "options": { "InputSlot": 3 } })), "Office", String::new(), None, 0).is_err());
assert_eq!(spool_job(&Args(&json!({})), "default", String::new(), None, 0, &[]).ok().map(|j| j.printer), Some(None));
assert!(spool_job(&Args(&json!({ "options": { "InputSlot": 3 } })), "Office", String::new(), None, 0, &[]).is_err());
}
}
+31 -5
View File
@@ -2,8 +2,8 @@
//! (removing what they cover for good) or clear them.
use pdfcraft_engine::{
Edit, Hidden, NewAnnotation, REDACT_MAX_WORDS, REDACT_VALIDATORS, REDACTION_CODE_SETS, RedactPattern, RedactRegex, RedactValidator,
RedactionCodeSet, Shape, Style, find_pattern, rect_quad, redact_word_list,
Edit, HIDDEN, Hidden, NewAnnotation, REDACT_MAX_WORDS, REDACT_VALIDATORS, REDACTION_CODE_SETS, RedactPattern, RedactRegex, RedactValidator,
RedactionCodeSet, Shape, Style, find_pattern, rect_quad, redact_word_list, redacted_file_name,
};
use serde_json::{Value, json};
@@ -200,6 +200,26 @@ impl Automation {
let after = self.doc(a)?.redaction_marks();
out["applied"] = json!(before - after);
out["marks_pending"] = json!(after);
// Where the removed text is still held that applying leaves as it is.
let left = self.doc(a)?.redaction_elsewhere().unwrap_or_default();
out["still_holding"] = json!({
"bookmarks": left.bookmarks,
"attachments": left.attachments,
"comments": left.comments,
"xmp": left.xmp,
"total": left.total(),
"unsearched": left.unsearched,
});
// What became of a PDF/A or PDF/UA claim in the document XMP the scrub removed.
let claim = |c: pdfcraft_engine::RedactionClaim| match c {
pdfcraft_engine::RedactionClaim::None => "none",
pdfcraft_engine::RedactionClaim::Kept => "kept",
pdfcraft_engine::RedactionClaim::Lost => "lost",
};
let id = self.doc(a)?.redaction_identification().unwrap_or_default();
out["identification"] = json!({ "pdfa": claim(id.pdfa), "pdfua": claim(id.pdfua) });
// The name to save the redacted copy under, as Save does in the app.
out["save_as"] = json!(redacted_file_name(&self.doc(a)?.name));
Ok(out)
}
@@ -235,6 +255,9 @@ impl Automation {
Ok(json!({ "findings": items, "count": items.len() }))
}
/// `doc_remove_hidden`: remove the listed categories of hidden information, or all of them
/// (Sanitize Document) when none are listed, and report how many items the listed
/// categories lost as `removed`.
pub(crate) fn doc_remove_hidden(&mut self, a: &Args) -> Result<Value> {
let which = match a.get("categories") {
None => None,
@@ -253,8 +276,11 @@ impl Automation {
// One scan, for this check and for what was removed. The removal refuses when it counts
// every listed category as 0 (an AcroForm with no fields counts 1), with an error about
// redaction marks; this reads the same scan, so it refuses exactly then. A document that
// can't be edited has no scan: `apply` says why.
let info = self.doc(a)?.hidden_info();
// can't be edited has no scan: `apply` says why. Only the listed categories are counted,
// here and after: the others would read every page's content and every object for
// nothing (#5873).
let listed = which.clone().unwrap_or_else(|| HIDDEN.to_vec());
let info = self.doc(a)?.hidden_info_of(&listed);
let edit = match which {
None => Edit::Sanitize,
Some(which) => {
@@ -266,7 +292,7 @@ impl Automation {
};
let before: usize = info.iter().map(|c| c.1).sum();
let mut out = self.apply(a, edit)?;
let after: usize = self.doc(a)?.hidden_info().iter().map(|c| c.1).sum();
let after: usize = self.doc(a)?.hidden_info_of(&listed).iter().map(|c| c.1).sum();
out["removed"] = json!(before.saturating_sub(after));
Ok(out)
}
+88 -16
View File
@@ -2,6 +2,7 @@
//! document, and manage trusted certificates. Rectangles are points from the top-left of the
//! displayed page.
use pdfcraft_engine::js::SignEvent;
use pdfcraft_engine::sign::{self, Certificate, DigitalId, Modification, Name, PrivateKey};
use pdfcraft_engine::{SignOptions, SignatureInfo, SignatureStatus};
use serde_json::{Value, json};
@@ -175,6 +176,8 @@ impl Automation {
Ok(json!({ "path": path.to_string_lossy(), "certificate": cert_json(&id.certificate) }))
}
/// The digital ID `id` names: a .p12/.pfx file opened with `password`, or an OS key store
/// identity (`keychain:` on macOS, `windows:` on Windows).
fn open_id(&self, a: &Args) -> Result<DigitalId> {
// A macOS Keychain identity: "keychain:<fingerprint>" or "keychain:<common name>".
if let Some(r) = a.str("id")?.strip_prefix("keychain:") {
@@ -197,6 +200,8 @@ impl Automation {
})
}
/// `sign_document`: sign the document with a digital ID into `out`, visibly (in a field or a
/// rectangle) or not, running a dynamic form's signing scripts around it.
pub(crate) fn sign_document(&mut self, a: &Args) -> Result<Value> {
let id = self.open_id(a)?;
let doc = self.doc(a)?;
@@ -246,30 +251,94 @@ impl Automation {
Some(v) => labels_arg(v)?,
};
let out = self.resolve(a.str("out")?, true)?;
let opts = SignOptions {
field,
page,
rect,
reason: a.opt_str("reason")?.map(str::to_string),
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()
// A dynamic form's signing scripts run only when asked: what preSign changes is signed,
// and the caller hasn't seen it. What they changed is listed.
let run_scripts = a.opt_bool("run_form_scripts")?.unwrap_or(false);
// Every argument is read before the preSign scripts change anything, so a bad one is
// refused with the document as it was.
let reason = a.opt_str("reason")?.map(str::to_string);
let location = a.opt_str("location")?.map(str::to_string);
let contact = a.opt_str("contact")?.map(str::to_string);
let signing = field.clone();
let scripted = signing.as_deref().is_some_and(|f| {
self.session.sign_event_scripted(doc_id, f, SignEvent::Before) || self.session.sign_event_scripted(doc_id, f, SignEvent::After)
});
// Each field's value and whether it is read-only, to tell what the preSign scripts changed.
let values = |me: &Automation| -> std::collections::BTreeMap<String, (Vec<String>, bool)> {
me.session.get(doc_id).map(|d| d.form.iter().map(|f| (f.name.clone(), (f.value.clone(), f.read_only()))).collect()).unwrap_or_default()
};
let was = values(self);
let before = match &signing {
Some(f) if run_scripts => self.session.run_sign_event(doc_id, f, SignEvent::Before).map_err(failed)?,
_ => false,
};
let changed: Vec<Value> = if before {
values(self)
.into_iter()
.filter(|(name, now)| was.get(name) != Some(now))
.map(|(name, (value, read_only))| {
let (old, old_ro) = was.get(&name).cloned().unwrap_or_default();
json!({ "field": name, "before": old, "after": value, "read_only_before": old_ro, "read_only": read_only })
})
.collect()
} else {
Vec::new()
};
let opts =
SignOptions { field, page, rect, reason, location, contact, certify, appearance, appearance_labels, graphic, ..SignOptions::default() };
let signed = self.session.sign(doc_id, &id, opts).map_err(|e| match e {
// A graphic the signer refuses is the caller's to fix.
pdfcraft_engine::EditError::Sign(m) if m.starts_with("the signature graphic") => bad(m),
e => failed(e),
})?;
write_atomic(&out, &signed)?;
});
let signed = match signed {
Ok(s) => s,
Err(e) => {
// Not signed: what the preSign scripts did is taken back.
if before {
let _ = self.session.undo(doc_id);
let _ = self.session.take_js_output(doc_id);
}
return Err(e);
}
};
if let Err(e) = write_atomic(&out, &signed) {
// Not saved, so not signed: what the preSign scripts did is taken back too.
if before {
let _ = self.session.undo(doc_id);
let _ = self.session.take_js_output(doc_id);
}
return Err(e);
}
let path = out.to_string_lossy().into_owned();
self.session.mark_signed(doc_id, signed.clone(), Some(path.clone())).map_err(failed)?;
// Then its postSign scripts; what they change is a change after signing, not saved yet.
// The file is signed and saved by now: a postSign step that fails is reported, not an error.
let (after, after_error) = match &signing {
Some(f) if run_scripts => match self.session.run_sign_event(doc_id, f, SignEvent::After) {
Ok(ran) => (ran, None),
Err(e) => (true, Some(format!("the postSign step couldn't finish: {e}"))),
},
_ => (false, None),
};
let mut scripts = self.session.take_js_output(doc_id);
scripts.errors.extend(after_error);
let doc = self.doc(a)?;
let newest = doc.signatures.iter().filter(|s| s.signed).max_by_key(|s| s.revision).cloned();
Ok(json!({ "path": path, "bytes": signed.len(), "signature": newest.map(|s| self.sig_json(a, &s)).transpose()? }))
let mut result = json!({ "path": path, "bytes": signed.len(), "signature": newest.map(|s| self.sig_json(a, &s)).transpose()? });
if before || after {
result["form_scripts"] = json!({
"before_signing": before,
"after_signing": after,
"changed": changed,
"alerts": scripts.alerts.iter().map(crate::a11y::form_tools::alert_json).collect::<Vec<_>>(),
"errors": scripts.errors,
"unsaved_changes": self.doc(a)?.dirty,
});
} else if scripted {
result["form_scripts"] = json!({ "skipped": true, "why": "the field has signing scripts; pass run_form_scripts: true to run them" });
}
Ok(result)
}
/// `{"image": path}` (PNG or JPEG), `{"text": name}` (typed in the script font) or
@@ -310,6 +379,9 @@ impl Automation {
pub(crate) fn sign_trust(&mut self, a: &Args) -> Result<Value> {
let mut certs: Vec<Certificate> =
if a.opt_bool("clear")?.unwrap_or(false) { Vec::new() } else { self.session.trusted_certificates().to_vec() };
// Which certificates are already in, by their bytes: a set, so a bundle of many distinct
// certificates isn't compared against every one before it.
let mut known: std::collections::HashSet<Vec<u8>> = certs.iter().map(|c| c.raw.clone()).collect();
let paths: Vec<String> = match a.get("paths") {
None => Vec::new(),
Some(v) => v
@@ -330,7 +402,7 @@ impl Automation {
sign::x509::load_certificates(&bytes).map_err(failed)?
};
for c in found {
if !certs.iter().any(|t| t.raw == c.raw) {
if known.insert(c.raw.clone()) {
certs.push(c);
}
}
+125
View File
@@ -146,6 +146,84 @@ fn search_bounds_warning_volume_and_does_not_reach_distant_failures() {
assert!(warnings.iter().all(|w| w["error"].as_str().unwrap().len() <= FIND_MAX_WARNING_BYTES && w["error_truncated"] == true));
}
#[test]
fn stop_at_limit_ends_at_a_lone_match_without_reading_the_distant_tail() {
let mut a = Automation::new();
let id = search_document(&mut a, 200);
// #5328: "Page 1" is only on page 1 (every other readable page reads "Page 2"), and the last
// page fails, so only a search that runs on to the end hears about it.
let requested = std::cell::RefCell::new(Vec::new());
let mut extract = |bytes: &Arc<Vec<u8>>, password: Option<Arc<str>>, pages: &[usize]| {
requested.borrow_mut().extend_from_slice(pages);
let filler = extract_parallel(bytes, password.clone(), &[1]).remove(0).1;
let first = extract_parallel(bytes, password, &[0]).remove(0).1;
pages
.iter()
.map(|&p| {
(
p,
match p {
0 => first.clone(),
199 => Err("synthetic page 200 failure".into()),
_ => filler.clone(),
},
)
})
.collect::<Vec<_>>()
};
let looked_ahead = a.text_find_with(&Args(&json!({ "doc": id.0, "query": "Page 1", "limit": 1 })), &mut extract).unwrap();
assert_eq!((looked_ahead["count"].clone(), looked_ahead["warning_count"].clone()), (json!(1), json!(1)), "{looked_ahead}");
assert!(looked_ahead.get("next").is_none() && looked_ahead.get("stopped_at_limit").is_none());
a.texts.remove(&id);
requested.borrow_mut().clear();
let args = json!({ "doc": id.0, "query": "Page 1", "limit": 1, "stop_at_limit": true });
let stopped = a.text_find_with(&Args(&args), &mut extract).unwrap();
assert_eq!(stopped["matches"][0]["page"], 1);
assert_eq!((stopped["count"].clone(), stopped["warning_count"].clone()), (json!(1), json!(0)), "{stopped}");
assert_eq!((stopped["complete"].clone(), stopped["stopped_at_limit"].clone()), (json!(false), json!(true)));
assert_eq!(stopped["next"], json!({ "start_page": 1, "skip": 1 }));
assert_eq!(*requested.borrow(), [0], "a match on the first page reads only that page");
// Resuming finds nothing more and reaches the failing page, which it now reports.
let mut resume = args.clone();
resume["start_page"] = stopped["next"]["start_page"].clone();
resume["skip"] = stopped["next"]["skip"].clone();
let rest = a.text_find_with(&Args(&resume), &mut extract).unwrap();
assert_eq!((rest["count"].clone(), rest["stopped_at_limit"].clone(), rest["complete"].clone()), (json!(0), json!(false), json!(false)));
assert_eq!(rest["warnings"][0]["page"], 200);
assert!(rest.get("next").is_none());
}
#[test]
fn stop_at_limit_continuations_return_every_match_once_and_end_complete() {
let mut a = Automation::new();
let id = search_document(&mut a, 120);
let all = json(&mut a, "text_find", json!({ "doc": id.0, "query": "1", "limit": 10_000 }));
assert_eq!(all["complete"], true);
// "1" appears twice on pages like 11 and 111, so resuming has to skip within a page.
for limit in [1, 2, 3] {
let mut args = json!({ "doc": id.0, "query": "1", "limit": limit, "stop_at_limit": true });
let mut pieced = Vec::new();
for _ in 0..=all["count"].as_u64().unwrap() {
let part = json(&mut a, "text_find", args.clone());
assert_eq!(part["warning_count"], 0);
pieced.extend(part["matches"].as_array().unwrap().iter().cloned());
if part["stopped_at_limit"] == false {
assert_eq!(part["complete"], true);
assert!(part.get("next").is_none());
break;
}
assert_eq!((part["count"].clone(), part["complete"].clone()), (json!(limit), json!(false)));
args["start_page"] = part["next"]["start_page"].clone();
args["skip"] = part["next"]["skip"].clone();
}
assert_eq!(pieced, *all["matches"].as_array().unwrap(), "limit {limit}: no match lost or repeated");
}
let none = json(&mut a, "text_find", json!({ "doc": id.0, "query": "absent", "limit": 1, "stop_at_limit": true }));
assert_eq!((none["count"].clone(), none["complete"].clone(), none["stopped_at_limit"].clone()), (json!(0), json!(true), json!(false)));
}
#[test]
fn a_bounded_text_find_reads_only_the_pages_it_needs_and_says_it_stopped() {
let dir = std::env::temp_dir().join(format!("pdfcraft-automation-find-{}", std::process::id()));
@@ -192,6 +270,30 @@ fn a_bounded_text_find_reads_only_the_pages_it_needs_and_says_it_stopped() {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_save_without_a_path_stays_inside_the_root() {
let base = std::env::temp_dir().join(format!("pdfcraft-automation-save-root-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let root = base.join("root");
let outside = base.join("outside");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&outside).unwrap();
let mut a = Automation::new().with_root(&root).unwrap();
let bytes = Arc::new(numbered_pages(1));
// Remembered path inside the root: still saves.
let inside = root.canonicalize().unwrap().join("in.pdf");
let id = a.session.open("in.pdf", Some(inside.to_string_lossy().into_owned()), bytes.clone(), None).unwrap();
json(&mut a, "doc_save", json!({ "doc": id.0 }));
assert!(inside.exists());
// Remembered path outside the root: refused, and nothing is written there.
let escaped = outside.join("out.pdf");
let id = a.session.open("out.pdf", Some(escaped.to_string_lossy().into_owned()), bytes, None).unwrap();
let error = a.call("doc_save", &json!({ "doc": id.0 })).unwrap_err();
assert!(error.to_string().contains("outside the allowed directory"), "{error}");
assert!(!escaped.exists());
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn dot_dot_resolves_by_name_and_never_climbs_above_the_start() {
let base = std::env::temp_dir();
@@ -219,6 +321,7 @@ fn names_from_documents_become_one_plain_file_name() {
#[cfg(windows)]
#[test]
fn other_shares_and_device_paths_are_refused_by_name() {
use crate::file_access::foreign_share;
let local = Path::new(r"\\?\C:\work\root");
for p in [
r"\\host\share\x.pdf",
@@ -242,6 +345,28 @@ fn other_shares_and_device_paths_are_refused_by_name() {
assert!(foreign_share(Path::new(r"\\attacker\docs\x.pdf"), shared));
}
/// Windows drops a trailing dot from a folder name when it opens a path (`root.` is `root`), so
/// with a root, a path that names the root or a folder above it that way still reaches the file
/// inside. A dotted name outside stays outside.
#[cfg(windows)]
#[test]
fn a_trailing_dot_on_the_root_or_above_it_names_the_folder_windows_opens() {
let base = std::env::temp_dir().join(format!("pdfcraft-automation-dot-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join("root")).unwrap();
std::fs::create_dir_all(base.join("outside")).unwrap();
std::fs::write(base.join("root").join("a.pdf"), b"x").unwrap();
std::fs::write(base.join("outside").join("b.pdf"), b"x").unwrap();
let a = Automation::new().with_root(base.join("root")).unwrap();
let shown = base.to_string_lossy().into_owned();
for p in [format!(r"{shown}\root.\a.pdf"), format!(r"{shown}.\root\a.pdf")] {
let resolved = a.resolve(&p, false);
assert!(resolved.is_ok(), "{p}: {resolved:?}");
}
assert!(a.resolve(&format!(r"{shown}\outside.\b.pdf"), false).is_err());
let _ = std::fs::remove_dir_all(&base);
}
/// A fresh, empty folder for one staging test.
fn staging_dir(test: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("pdfcraft-staging-{}-{test}", std::process::id()));
+129 -39
View File
@@ -140,8 +140,8 @@ pub fn tools() -> Vec<ToolDef> {
.with(schema(json!({
"files": {"type":"array","minItems":1,"maxItems":1000,"description":"PDFs in Bates sequence order.","items":{"type":"object","properties":{"input":{"type":"string","minLength":1,"description":"Input PDF path."},"output":{"type":"string","minLength":1,"description":"New output PDF path; must not exist."},"password":{"type":"string","description":"Open or permissions password, if needed."}},"required":["input","output"],"additionalProperties":false}},
"start_number": {"type":"integer","minimum":1,"description":"First number in the sequence (default 1)."}, "digits": {"type":"integer","minimum":1,"maximum":12,"description":"Zero-padded number width (default 6)."},
"prefix": {"type":"string","maxLength":128,"description":"Text before the number: at most 128 UTF-8 bytes, without # or >>."}, "suffix": {"type":"string","maxLength":128,"description":"Text after the number: at most 128 UTF-8 bytes, without # or >>."},
"dry_run": {"type":"boolean","description":"Return each file's inclusive number interval without writing (default false)."}
"prefix": {"type":"string","maxLength":128,"description":"Bates prefix, up to 128 characters; cannot contain # or >>."}, "suffix": {"type":"string","maxLength":128,"description":"Bates suffix, up to 128 characters; cannot contain # or >>."},
"dry_run": {"type":"boolean","description":"Return each file's inclusive number interval without writing (default false). Output collisions are checked conservatively: Windows compares case-insensitively, while Unix preserves case, so case-sensitive Windows and case-insensitive Unix volumes can differ from normal runs."}
}), &["files"])),
t("doc_batch", "Process files and folders", "Run convert, ocr and/or optimize over files and folders, one file at a time. recursive defaults to false. Preserve paths relative to each selected folder inside out_dir; duplicate inputs run once. Skip unsupported files, links and the output subtree. Existing outputs receive numbered names and are never replaced. Continue after per-file errors unless stop_on_error is true. Return completed/failed/skipped counts and per-file results. Discovery is bounded to 10000 entries and 64 folder levels. Convert must be first; each step can appear once. OCR uses installed models. Optimize uses Reduce File Size settings, optionally overriding target ppi (threshold 1.5 times ppi; black-and-white images go to the larger of ppi and 300) and JPEG quality; a larger candidate keeps the original bytes. Sources and open documents are unchanged.")
.with(schema(json!({
@@ -154,17 +154,40 @@ pub fn tools() -> Vec<ToolDef> {
"dpi": {"type":"number","minimum":72,"maximum":600},
"language": {"type":"string"}, "skip_text_pages": {"type":"boolean"}
}), &["paths","out_dir","steps"])),
t("doc_fix_hairlines", "Fix hairlines", "Print production: strokes narrower than narrower_than points on the page (after every transformation; zero-width lines always) are given width points, in page content and form XObjects; Type 3 glyphs and tiling patterns only when asked, changed in place wherever they are used. Both widths at most 10. Returns fixed (strokes), pages, and elsewhere (glyphs and patterns changed that something outside pages also uses); nothing to fix gives 0 and no edit.")
.cmd("prepress.hairlines")
.with(schema(
json!({
"doc": doc(), "pages": pages("to fix (default all)"),
"narrower_than": { "type": "number", "minimum": 0, "description": "Points on the page (default 0.216)." },
"width": { "type": "number", "exclusiveMinimum": 0, "description": "Points on the page (default 0.72)." },
"type3": { "type": "boolean" }, "patterns": { "type": "boolean" },
}),
&["doc"],
)),
t("attachment_add", "Attach a file", "Attach the file at path to the document (document level, the Attachments panel; not a paper-clip comment on a page). name defaults to the file's name; the attachment's key, unique in the document, is returned. Undoable.")
.cmd("edit.attach")
.with(schema(json!({"doc": doc(), "path": {"type": "string"}, "name": {"type": "string"}, "description": {"type": "string"}}), &["doc", "path"])),
t("attachment_delete", "Delete an attachment", "Remove the document-level attachment with this name (as doc_info lists it). Undoable.")
.destructive()
.with(schema(json!({"doc": doc(), "name": {"type": "string"}}), &["doc", "name"])),
t("doc_convert_colours", "Convert colours", "Print production: every colour operator in the pages' content and the artwork it draws rewritten in the target space (to: gray, rgb or cmyk) by the standard device formulas (device spaces, ICC by component count, Lab, Indexed, Separation and DeviceN tints; patterns, shadings and inline images stay). Not a prepress separation: no ICC profile and no output intent is used. Spot colours are converted, not kept: exact through a Type 2 tint, otherwise a gray of their darkness; /None stays. preserve_black (default true) keeps pure black as 100% K / gray 0; images (default true) re-encodes 8-bit gray and RGB pictures (JPEG stays JPEG, compressed again at quality 80; CMYK written lossless; soft masks kept), and leaves indexed, CMYK, masked and other pictures as they are; annotations also converts the pages' comment and field appearances. Returns how many colours and pictures changed on how many pages, images_left (pictures not in the target that stayed), and loses_conformance (the PDF/A level and output intents the file declared: it may no longer meet them, and still declares them; standards_remove_id takes that off). Undoable.")
.cmd("prepress.convert_colors")
.with(schema(
json!({
"doc": doc(), "pages": pages("to convert (default all)"),
"to": { "type": "string", "enum": ["gray", "rgb", "cmyk"] },
"preserve_black": { "type": "boolean" }, "images": { "type": "boolean" }, "annotations": { "type": "boolean" },
}),
&["doc"],
)),
t("attachment_edit", "Edit an attachment description", "Change the description of a document attachment by its exact key from doc_info, or by a unique displayed name. An empty description clears it. The file and other attachment data are preserved. Undoable.")
.with(schema(json!({"doc": doc(), "key": {"type": "string"}, "name": {"type": "string"}, "description": {"type": "string"}}), &["doc", "description"])),
t("doc_open", "Open a PDF", "Open a PDF file and return its document id, page count and whether it can be edited.")
.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, page transitions and display times, the destination it opens at, attachments, fonts, security, repair notes and the objects that could not be read.")
t("doc_info", "Inspect a document", "Metadata, page sizes and labels, bookmarks, annotations, form fields, links, layers and the Layers panel's tree (layer_order), page transitions and display times, the destination it opens at, attachments (and, for a PDF portfolio, the one it presents first), fonts, security, repair notes and the objects that could not be read.")
.ro()
.cmd("file.properties")
.with(schema(json!({ "doc": doc() }), &["doc"])),
@@ -187,7 +210,7 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc"],
)),
t("doc_set_info", "Set document metadata", "Set a document information entry such as Title, Author, Subject or Keywords, or a custom property under any other name (no spaces, delimiters, / or #; at most 127 bytes; doc_info lists them under custom); an empty value removes it. When the file has XMP metadata, the matching XMP property (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) is changed too, so viewers that read XMP first show the new value. Undoable.")
t("doc_set_info", "Set document metadata", "Set a document information entry such as Title, Author, Subject or Keywords, or a custom property under any other name (no spaces, delimiters, / or #; at most 127 bytes; doc_info lists them under custom); an empty value removes it. When the file has XMP metadata, the matching XMP property (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) is changed too, so viewers that read XMP first show the new value, and a custom property is kept there as pdfx:<name> when its name can be an XML name (in a PDF/A file only one already there is changed). Undoable.")
.with(schema(json!({ "doc": doc(), "key": { "type": "string" }, "value": { "type": "string" } }), &["doc", "key", "value"])),
t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600). When the picture is incomplete or approximate (content cut at the page's safety budget, a colour painted in a fallback colour), a JSON part follows the image: { page, warnings: [sentence, ...] }.")
.ro()
@@ -195,28 +218,47 @@ pub fn tools() -> Vec<ToolDef> {
"hide_text_block": { "type": "integer", "minimum": 1, "description": "Render an inline editor background without this paragraph (number from text_paragraphs, resolved as text_edit does: refused once an edit merged that paragraph into a neighbour). 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("model_render", "Render a 3D model", "Draw a page's 3D model (a 3D annotation's PRC stream) to a PNG, framed to fill the image, with headlight shading on a white background. model is its 1-based position among the page's 3D models (default 1). direction is the view direction [x, y, z] from the eye towards the model (default [-1, 1, -1]); Z is up unless the view looks along Z. perspective defaults to true. U3D models can't be drawn yet and give an error. Does not modify the document.")
t("model_render", "Render a 3D model", "Draw a page's 3D model (a 3D annotation's PRC stream) to a PNG, framed to fill the image, with headlight shading on a white background. model is its 1-based position among the page's 3D models (default 1). direction is the view direction [x, y, z] from the eye towards the model (default [-1, 1, -1]); Z is up unless the view looks along Z. orbit, in place of direction, frames the model as a viewer's orbit control does (the same size from every side) and applies, in order: view (iso, front, right, top or back; default iso) about up (+x, +y, +z, -x, -y or -z; default +z) with the Front view looking along front (an axis perpendicular to up; default +y, or -z when y is up), then turn [yaw, pitch] in degrees (pitch looks down and stops just short of the up axis), then zoom (a factor, the result held within 0.05 to 50) about the picture point zoom_at [right, up] in picture heights from the centre (default the centre), then pan [right, up] in model radii (it moves the target, so the model moves the other way). perspective defaults to true. U3D models can't be drawn yet and give an error. Does not modify the document.")
.ro()
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "model": { "type": "integer", "minimum": 1 },
"width": { "type": "integer", "minimum": 1, "maximum": 4096 }, "height": { "type": "integer", "minimum": 1, "maximum": 4096 },
"direction": { "type": "array", "items": { "type": "number" }, "minItems": 3, "maxItems": 3 },
"perspective": { "type": "boolean" }
"direction": { "type": "array", "items": { "type": "number" }, "minItems": 3, "maxItems": 3, "description": "The view direction from the eye towards the model (default [-1, 1, -1]). Mutually exclusive with orbit." },
"perspective": { "type": "boolean" },
"orbit": { "type": "object", "description": "A viewer's orbit moves, applied in the order of these keys; all optional. Mutually exclusive with direction.", "properties": {
"view": { "type": "string", "enum": ["iso", "front", "right", "top", "back"], "description": "The view to start from (default iso)." },
"up": { "type": "string", "enum": ["+x", "+y", "+z", "-x", "-y", "-z"], "description": "The model axis that appears upward (default +z)." },
"front": { "type": "string", "enum": ["+x", "+y", "+z", "-x", "-y", "-z"], "description": "The axis the Front view looks along, perpendicular to up (default +y, or -z when y is up)." },
"turn": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2, "description": "[yaw, pitch] in degrees, pitch looking down (default [0, 0])." },
"zoom": { "type": "number", "exclusiveMinimum": 0, "description": "A factor on the fitted view (default 1); the result is held within 0.05 to 50." },
"zoom_at": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2, "description": "The picture point the zoom keeps still, [right, up] in picture heights from the centre (default [0, 0]). Requires zoom." },
"pan": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2, "description": "Moves the target [right, up] in model radii, so the model moves the other way (default [0, 0]); each held within 1000." }
}, "additionalProperties": false }
}), &["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"])),
t("object_move", "Move objects together", "Atomically translate 1–1000 mixed references from object_list by [dx, dy] in displayed points (right/down). Preserves glyph codes, fonts, image data and relative spacing; success is one undo step. Text used as a clipping path and vertical text are refused. A bad/stale reference refuses the entire move.")
t("object_move", "Move objects together", "Atomically translate 1–1000 mixed references from object_list (or with all: true every object on the page) by [dx, dy] in displayed points (right/down). Preserves glyph codes, fonts, image data and relative spacing; success is one undo step. Text used as a clipping path and vertical text are refused. A bad/stale reference refuses the entire move.")
.with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},
"objects":{"type":"array","minItems":1,"maxItems":1000,"uniqueItems":true,"items":{
"type":"object","properties":{"kind":{"type":"string","enum":["added","text","image"]},"index":{"type":"integer","minimum":1}},"required":["kind","index"],"additionalProperties":false}},
"all":{"type":"boolean","description":"Every object on the page, instead of objects."},
"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"])),
"generation":{"type":"integer","minimum":0,"description":"Use generation returned by object_list to reject a stale selection."}}), &["doc","page","offset"])),
t("object_copy", "Copy or cut objects together", "Edit ▸ Copy (cut: Cut) of 1–1000 mixed references from object_list, or with all: true every object on the page, as ⌘A then ⌘C select and copy them in Edit a PDF. The copy keeps their content operators and every resource they use (fonts, images, ExtGStates, colour spaces, patterns, shadings) and is kept for object_paste into any open document, also after this one changes or closes. cut then deletes them as one undo step. Refused when the document doesn't allow copying (cut: or changes), or when text used as a clipping path is drawn before them. Returns the count copied and their text.")
.with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},
"objects":{"type":"array","minItems":1,"maxItems":1000,"uniqueItems":true,"items":{
"type":"object","properties":{"kind":{"type":"string","enum":["added","text","image"]},"index":{"type":"integer","minimum":1}},"required":["kind","index"],"additionalProperties":false}},
"all":{"type":"boolean","description":"Every object on the page, instead of objects."},
"cut":{"type":"boolean","description":"Delete them after copying (one undo step)."},
"generation":{"type":"integer","minimum":0,"description":"Use generation returned by object_list to reject a stale selection."}}), &["doc","page"])),
t("object_paste", "Paste copied objects", "Edit ▸ Paste of what object_copy copied, onto page of doc as page content that draws the same: where it was, or on the page it came from 18 points down and to the right, and 18 more for each further paste onto the same page; centred when that would leave it off the page. Resources join the page's under names that don't clash; into another document the fonts, images and other objects they use are copied (each once, sharing what is there; refused past 20,000 objects or 256 MB). Pasted content is untagged and in no layer. One undo step.")
.with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}}), &["doc","page"])),
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. With format \"html\", each page also carries html, the same text as Copy with formatting puts on the clipboard (paragraphs, bold, italic, size, colour and font family), and styles_incomplete when some styles had to be left out. With rect, the html is that text in line order, not split into the column's rows and cells. The HTML of one call is capped at 32 MiB in total; pages past the cap carry empty html with styles_incomplete. html_truncated is set when that cap is reached or when a page's own copy was cut at its span or size cap.")
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. With format \"html\", each page also carries html, the same text as Copy with formatting puts on the clipboard (paragraphs, bold, italic, size, colour and font family), and styles_incomplete when some styles had to be left out. With rect, the html is that text in line order, not split into the column's rows and cells. A page whose content was cut at the renderer's limits carries truncated: true; its text is incomplete. The HTML of one call is capped at 32 MiB in total; pages past the cap carry empty html with styles_incomplete. html_truncated is set when that cap is reached or when a page's own copy was cut at its span or size cap.")
.ro()
.cmd("edit.column_select")
.with(schema(
@@ -227,12 +269,13 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc"],
)),
t("text_find", "Find text", "Find a phrase (case-insensitive, whitespace-normalised) and return matches in page order with page and line rectangles in points (origin top-left). Reads growing batches, looking one match past the limit for a continuation. Failed pages do not discard readable matches: warnings contains up to 100 {page, error, error_truncated} entries (errors capped at 1024 UTF-8 bytes), warning_count counts failed pages, and warnings_truncated reports a capped list. complete is false after a page failure or when more matches exist; next is present only for a match-limit continuation.")
t("text_find", "Find text", "Find a phrase (case-insensitive, whitespace-normalised) and return matches in page order with page and line rectangles in points (origin top-left). Reads growing batches, looking one match past the limit for a continuation. Failed pages do not discard readable matches: warnings contains up to 100 {page, error, error_truncated} entries (errors capped at 1024 UTF-8 bytes), warning_count counts failed pages, and warnings_truncated reports a capped list. complete is false after a page failure or when more matches exist; next is present only for a match-limit continuation. With stop_at_limit the search ends at the limit-th match instead of looking for one more, so a lone early match doesn't read the rest of the document.")
.ro()
.cmd("edit.find")
.with(schema(json!({ "doc": doc(), "query": { "type": "string", "minLength": 1 }, "limit": { "type": "integer", "minimum": 1, "description": "Maximum matches (default 500). When another match exists, next holds start_page and skip to continue. complete is false for either additional matches or failed pages; an exhausted search with warnings has no next." },
"start_page": { "type": "integer", "minimum": 1, "description": "Page to start searching from (default 1)." },
"skip": { "type": "integer", "minimum": 0, "description": "Matches to pass over on start_page before returning any (default 0)." } }), &["doc", "query"])),
"skip": { "type": "integer", "minimum": 0, "description": "Matches to pass over on start_page before returning any (default 0)." },
"stop_at_limit": { "type": "boolean", "description": "Stop at the limit-th match without looking for another (default false). The answer then has stopped_at_limit; when true, complete is false and next is only where to resume, so following it may find nothing more." } }), &["doc", "query"])),
t("page_rotate", "Rotate pages", "Rotate pages by a multiple of 90 degrees (positive is clockwise): the listed pages (default all), filtered like Acrobat's Rotate Pages by subset (all, even, odd page numbers) and orientation (all, landscape, portrait). Undoable.")
.cmd("page.rotate")
.with(schema(
@@ -477,15 +520,29 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc", "from", "to"],
)),
t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags. A calculated field includes calculate: {op, fields} or {notation} as form_set_props takes them, or {script} for a custom Calculate script (set with form_set_script). Push buttons include caption (first widget's normal label) and captions [{widget, caption}] for all widgets (1-based).")
t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags. A calculated field includes calculate: {op, fields} or {notation} as form_set_props takes them, or {script} for a custom Calculate script (set with form_set_script). Push buttons include caption (first widget's normal label) and captions [{widget, caption}] for all widgets (1-based). Radio groups include on_widgets, the 1-based numbers of the buttons that are on (buttons sharing an option tell apart only there). A check box whose widgets have different on states also lists them as options, and which one is checked (null when none). Combo and list boxes include selected: the 1-based positions in options of the selected ones, which tells apart options that share an export value.")
.ro()
.with(schema(json!({ "doc": doc() }), &["doc"])),
t(
"form_fill",
"Fill in form fields",
"Set several fields at once (one undo step). values maps field names (from form_fields) to: a string for text fields, combo boxes and radio groups (an option), true/false for check boxes, an array of strings for multi-select lists. Appearances are regenerated so every viewer shows the values. A value whose characters the field's font can't draw (Chinese in a Helvetica field) is still saved; `warnings` in the result names the field and character: PdfCraft draws `?` there and asks other viewers to redraw the field from its value.",
"Set several fields at once (one undo step). values maps field names (from form_fields) to: a string for text fields, combo boxes and radio groups (an option), true/false for check boxes (or one of a check box's options, to check that widget and clear the others), an array of strings for multi-select lists; {\"widget\": n} turns on button n (1-based) of a check box or radio group and never turns it off (to clear, use null for a radio group that allows it, false for a check box); of radio buttons sharing an option only that one is on unless the group has RadiosInUnison. A radio option is an export value as form_fields lists it in options; where one button's on-state name equals another button's export value, the export value wins, and an on-state name that no export value matches still selects its button. A radio option may also be a JSON number: a whole number names the option spelled as that integer (1, 1.0 and 1e0 all name \"1\"), any other number only an option spelled exactly as JSON prints it (1.5). A value that names no option is refused with the options listed. Appearances are regenerated so every viewer shows the values. Thai the field's font can't hold is drawn from PdfCraft's built-in Thai face (a build without one refuses it). Any other value whose characters the field's font can't draw (Chinese in a Helvetica field) is still saved; `warnings` in the result names the field and character: PdfCraft draws `?` there and asks other viewers to redraw the field from its value.",
)
.with(schema(json!({ "doc": doc(), "values": { "type": "object", "description": "Field name → value." } }), &["doc", "values"])),
t(
"form_focus",
"Move the focus into or out of a field",
"A field gains (event enter) or loses (event exit) focus, as when someone clicks or tabs into or out of it, and the dynamic XFA form's enter or exit scripts for it run (other forms have none, and nothing happens). widget picks the radio button or copy of the field (1-based, default 1; see form_fields). What the scripts change is one undoable edit, and none when they change nothing. Changing a field's value (form_fill) runs its exit scripts already, so the next exit of that field doesn't run them again. Returns changed, and the scripts' alerts, console output, requests and errors.",
)
.with(schema(
json!({
"doc": doc(),
"field": { "type": "string", "description": "Field name (from form_fields)." },
"event": { "type": "string", "enum": ["enter", "exit"] },
"widget": { "type": "integer", "minimum": 1, "description": "Widget of the field (1-based, default 1): a radio button of the group, or a copy of the field." },
}),
&["doc", "field", "event"],
)),
t("form_reset", "Clear form", "Reset fields to their default values: all of them, or only those listed. Undoable.")
.destructive()
.with(schema(json!({ "doc": doc(), "fields": { "type": "array", "items": { "type": "string" } } }), &["doc"])),
@@ -532,7 +589,7 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc", "page", "rect", "rows"],
)),
t("stamp_custom", "Add a custom stamp", "Stamp a picture: a PDF page (file_page, default 1, kept as vector content in a form XObject) or an image file. Either centred at at [x, y] (points from the top-left of the displayed page) at its natural size, at most 200 pt, or fitted inside rect [x0, y0, x1, y1] (the same coordinates; each side 1–14400 pt), scaled to keep its shape and centred in the box. Like at, rect is not clipped to the page. Pass at or rect, not both. name labels it (default: the file name). Undoable.")
t("stamp_custom", "Add a custom stamp", "Stamp a picture: a PDF page (file_page, default 1, kept as vector content in a form XObject) or an image file. Either centred at at [x, y] (points from the top-left of the displayed page) at its natural size, at most 200 pt, or fitted inside rect [x0, y0, x1, y1] (the same coordinates; each side 1–14400 pt), scaled to keep its shape and centred in the box. Like at, rect is not clipped to the page. Pass at or rect, not both. name labels it (default: the file name). line adds a text line under the picture with {name} (the author), {date} and {time} filled in now, as a dynamic stamp. Undoable.")
.cmd("comment.stamp")
.with(schema(
json!({
@@ -544,6 +601,7 @@ pub fn tools() -> Vec<ToolDef> {
"file_page": { "type": "integer", "minimum": 1 },
"name": { "type": "string" },
"author": { "type": "string" },
"line": { "type": "string", "maxLength": 200 },
}),
&["doc", "page", "path"],
)),
@@ -566,7 +624,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"form_set_props",
"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: a dotted name such as MASTER.first is the full name and moves the field under those parent fields; a plain name renames it within its parent), rect (position), rotation and caption require a single field. A rename also renames the field in the calculations that read it (calculate field lists and notation, and getField calls naming it in a string literal; names built at run time are not rewritten), in the same Undo step, and formulas_updated lists the fields whose calculation changed. caption changes one push-button normal label (widget: 1-based index, default 1); an empty string clears it. Other widgets and icon-only layouts are preserved. 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.",
"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: a dotted name such as MASTER.first is the full name and moves the field under those parent fields; a plain name renames it within its parent), rect (position), rotation and caption require a single field. A rename also renames the field in the calculations that read it (calculate field lists and notation, and getField calls naming it in a string literal; names built at run time are not rewritten), in the same Undo step, and formulas_updated lists the fields whose calculation changed. caption changes one push-button normal label (widget: 1-based index, default 1); an empty string clears it. Other widgets and icon-only layouts are preserved. 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; the field keeps its rectangle and its content turns inside it. Undoable.",
)
.with(schema(
json!({
@@ -590,7 +648,7 @@ pub fn tools() -> Vec<ToolDef> {
"flags": { "type": "object", "additionalProperties": { "type": "boolean" } },
"font_size": { "type": "number", "minimum": 0 },
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "Move/resize: points from the top-left of the displayed page." },
"rotation": { "type": "integer", "enum": [0, 90, 180, 270], "description": "Widget rotation in degrees, counterclockwise. Changing between horizontal and vertical swaps the rectangle around its center." },
"rotation": { "type": "integer", "enum": [0, 90, 180, 270], "description": "Widget rotation in degrees, counterclockwise. The field keeps its rectangle; its content turns inside it." },
"appearance": { "type": "object", "description": "Appearance tab: border, fill, text_color (#RRGGBB, a name or \"none\"), width (0–12 points; 1 thin, 2 medium, 3 thick), style (solid, dashed, beveled, inset, underline), font (helvetica, times, courier)." },
"format": { "description": "Format tab: {\"type\": \"number\", \"decimals\": 2, \"currency\": \"$\", \"separator\": 0-4, \"negative\": 0-3}, {\"type\": \"percent\"}, {\"type\": \"date\"|\"time\", \"pattern\": \"mm/dd/yyyy\"}, {\"type\": \"zip\"|\"zip4\"|\"phone\"|\"ssn\"}, {\"type\": \"mask\", \"mask\": \"AA-9999\"}, or \"none\"." },
"validate": { "description": "Validate tab: {\"min\": 0, \"max\": 100} (either may be left out, or null) or \"none\"." },
@@ -647,7 +705,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"redact_apply",
"Apply redactions",
"Apply the redaction marks (all, or those on pages): text, images, vectors, comments and form fields under them are removed for good and boxes are drawn in their place. A verification pass fails the operation if anything readable remains. Undoable until saved; the saved file no longer contains the content.",
"Apply the redaction marks (all, or those on pages): text, images, vectors, comments and form fields under them are removed for good and boxes are drawn in their place. A verification pass fails the operation if anything readable remains. Document metadata (Info entries, the document's and pages' XMP) that repeats the removed text is removed too; when the document XMP goes, identification says what became of its PDF/A and PDF/UA claims (pdfa, pdfua: none, kept when written back in a packet holding only the identification, or lost). still_holding counts the places applying leaves as they are that still hold the removed text: bookmarks (titles), attachments (names, file names, descriptions), comments (text, rich text, subject or author of those that remain) and xmp (XMP on images, fonts, forms, annotations and other objects), with their total; doc_remove_hidden removes them. unsearched: true means the removed text couldn't be searched for, so every such place holding any text is counted. Undoable until saved; the saved file no longer contains the content. The result's save_as is the document's name with _Redacted before the extension (contract.pdf gives contract_Redacted.pdf; a name that already ends in _Redacted is unchanged); pass it to doc_save as path to save under that name.",
)
.destructive()
.with(schema(json!({ "doc": doc(), "pages": pages("whose marks to apply (default: all)") }), &["doc"])),
@@ -688,7 +746,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 (by page number, as Acrobat counts them: pages 2, 7-10 with even prints 2, 8 and 10, not every second page of the range); 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; a booklet sets two-sided itself, as Acrobat does: both flips on the short edge, front or back prints one-sided), 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, A0/A1/A2/A3/A4/A5/A6, or US #10 Envelope (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, \"(n)\" for the nth page whatever its label, unless a page is labelled exactly \"(n)\", since labels are looked up first; default all); subset odd/even (by page number, as Acrobat counts them: pages 2, 7-10 with even prints 2, 8 and 10, not every second page of the range); reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, which turns the sheet to suit the grid, or a custom grid of cols across by rows down, each 1-99, on the sheet as oriented (auto prints portrait); 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; a booklet sets two-sided itself, as Acrobat does: both flips on the short edge, front or back prints one-sided), 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, A0/A1/A2/A3/A4/A5/A6, or US #10 Envelope (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!({
@@ -699,6 +757,8 @@ pub fn tools() -> Vec<ToolDef> {
"layout": { "type": "string", "enum": ["fit", "actual", "shrink", "custom", "multiple", "booklet", "poster"] },
"scale": { "type": "number", "exclusiveMinimum": 0 },
"per_sheet": { "type": "integer", "minimum": 1, "maximum": 256 },
"cols": { "type": "integer", "minimum": 1, "maximum": 99, "description": "Custom pages per sheet: columns across the sheet; give rows too, and no per_sheet." },
"rows": { "type": "integer", "minimum": 1, "maximum": 99, "description": "Custom pages per sheet: rows down the sheet; give cols too, and no per_sheet." },
"order": { "type": "string", "enum": ["horizontal", "horizontal-reversed", "vertical", "vertical-reversed", "cut-stack"] },
"border": { "type": "boolean" },
"auto_rotate": { "type": "boolean" },
@@ -791,30 +851,32 @@ pub fn tools() -> Vec<ToolDef> {
t("content_delete", "Delete added content", "Delete an added item (page, index from content_list). Undoable.")
.destructive()
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 } }), &["doc", "page", "index"])),
t("link_list", "List links", "Every link: page, 1-based index (for link_edit/link_delete), rect (points from the top-left of the page), and where it goes (url or to_page).")
t("link_list", "List links", "Every link: page, 1-based index (for link_edit/link_delete), rect (points from the top-left of the page), and where it goes (url, or to_page with its zoom). A relative url also has resolved_url, the address resolved against the document's base URI (catalog /URI /Base); a link active only in its /QuadPoints has active_areas, one rect per quadrilateral. With text_links: true, also the web and email addresses written as plain page text with no link over them, which the viewer opens on a click: page, rects (one per line), url, and blocked (why it won't open) when the link check refuses it. Only http, https and mailto addresses are found. At most 100 pages are read per call, from text_links_from (default 1); text_links_next_page then gives the page to continue from.")
.ro()
.with(schema(json!({ "doc": doc() }), &["doc"])),
.with(schema(json!({ "doc": doc(), "text_links": { "type": "boolean" }, "text_links_from": { "type": "integer", "minimum": 1 } }), &["doc"])),
t("link_open", "Open a linked PDF", "Follow a local GoToR link in PdfCraft: open the referenced PDF as a new document, respecting --root. page and index use link_list numbering. Named destinations are reported but not applied. Absolute file specifications require allow_absolute: true after reviewing the target; relative links need no opt-in.")
.with(schema(
json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 }, "password": { "type": "string", "description": "Optional password for the linked PDF." }, "allow_absolute": { "type": "boolean", "description": "Explicitly allow an absolute local GoToR target (default false). Existing validation and --root confinement still apply." } }),
&["doc", "page", "index"],
)),
t("link_add", "Add a link", "Add a link over rect on page that opens url or goes to to_page. Appearance: visible (rectangle), color, width 1-3, highlight none/invert/outline/inset. Undoable.")
t("link_add", "Add a link", "Add a link over rect on page that opens url or goes to to_page, shown at zoom: fit_page (default), fit_width, fit_height, fit_visible, actual, inherit (keep the reader's zoom) or a percentage. Appearance: visible (rectangle), color, width 1-3, highlight none/invert/outline/inset. Undoable.")
.cmd("edit.link")
.with(schema(
json!({
"doc": doc(), "page": { "type": "integer", "minimum": 1 }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
"url": { "type": "string" }, "to_page": { "type": "integer", "minimum": 1 },
"zoom": { "description": "fit_page, fit_width, fit_height, fit_visible, actual, inherit, or a percentage" },
"visible": { "type": "boolean" }, "color": { "type": "string" }, "width": { "type": "number" }, "highlight": { "type": "string", "enum": ["none", "invert", "outline", "inset"] },
}),
&["doc", "page", "rect"],
)),
t("link_edit", "Edit a link", "Change a link's rect, destination (url or to_page) or appearance. Undoable.")
t("link_edit", "Edit a link", "Change a link's rect, destination (url, or to_page and zoom as in link_add; a new to_page keeps the link's zoom, a zoom alone keeps its page) or appearance. Undoable.")
.with(schema(
json!({
"doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 },
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
"url": { "type": "string" }, "to_page": { "type": "integer", "minimum": 1 },
"zoom": { "description": "fit_page, fit_width, fit_height, fit_visible, actual, inherit, or a percentage" },
"visible": { "type": "boolean" }, "color": { "type": "string" }, "width": { "type": "number" }, "highlight": { "type": "string", "enum": ["none", "invert", "outline", "inset"] },
}),
&["doc", "page", "index"],
@@ -822,7 +884,7 @@ pub fn tools() -> Vec<ToolDef> {
t("link_delete", "Delete a link", "Delete one link (page, index from link_list). Undoable.")
.destructive()
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 } }), &["doc", "page", "index"])),
t("links_from_urls", "Create links from URLs", "Find web addresses (http://, https://, www.) in the text of every page and make them clickable links. Returns the URLs. Undoable.")
t("links_from_urls", "Create links from URLs", "Find web addresses (http://, https://, www.) in the text of every page and make them clickable links, leaving out text on a layer the viewer hides (layer_set). Returns the URLs. Undoable.")
.cmd("edit.links_from_urls")
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("links_remove", "Remove all links", "Remove every link in the document (or on the given pages). Undoable.")
@@ -845,7 +907,7 @@ pub fn tools() -> Vec<ToolDef> {
.ro().cmd("measure.snap").with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},"at":point(),"tolerance":{"type":"number","minimum":0,"maximum":10000},"endpoints":{"type":"boolean"},"midpoints":{"type":"boolean"},"intersections":{"type":"boolean"},"paths":{"type":"boolean"}}), &["doc","page","at"])),
t("measure_export", "Export measurements as CSV", "Atomically write saved measurement values, labels, authors and scale ratios as spreadsheet-safe CSV. Returns how many unsupported measurements were left out.")
.cmd("measure.export").with(schema(json!({"doc":doc(),"out":path_arg()}), &["doc","out"])),
t("comment_list", "List comments", "Every comment (annotation other than links, form widgets and pop-ups) with its page, index, id, type, author, text, date, rectangle, colour, review status, checkmark and replies (each with its own status and checkmark).")
t("comment_list", "List comments", "Every comment (annotation other than links, form widgets and pop-ups) with its page, index, id, type, author, text, date, rectangle, colour, review status, checkmark and replies (each with its own status and checkmark); the points that shape a line, polygon or callout, and a callout's text box. Annotations grouped with a comment (/RT /Group) are listed once, as that comment with its attributes, and named in its `group`. comment_delete deletes just the member named, as Acrobat does (Replace Text's strikeout and caret go together).")
.ro()
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1, "description": "Only this page." } }), &["doc"])),
t(
@@ -854,7 +916,7 @@ pub fn tools() -> Vec<ToolDef> {
"Add a comment as Acrobat's commenting tools do. Geometry is in points with the origin at the top-left of the displayed page, y down (as in page_render images at 72 dpi and text_find rects). \
note: `at` [x, y] (icon top-left). stamp: `at` [x, y] (its centre) and `stamp`: approved, completed, confidential, draft, final, for comment, for public release, information only, not approved, not for public release, preliminary results, void, accepted, initial here, rejected, sign here, witness; dynamic: true for the dynamic approved/confidential/received/reviewed/revised stamps with a By … at … line. highlight/underline/strikeout/squiggly: `find` (text on the page to mark; every match with all: true) or `quads`. \
rectangle/oval/textbox: `rect` [x0, y0, x1, y1]. line/arrow: `from`, `to`. Optional `endings` (two names: start, end) for a line, arrow or polyline, or one name for a callout: None, Square, Circle, Diamond, OpenArrow, ClosedArrow, Butt, ROpenArrow, RClosedArrow, Slash. An arrow without endings is None then OpenArrow. ink: `strokes` [[[x, y], …], …]. \
polygon/cloud/polyline (connected lines): `points` [[x, y], …]. callout: `rect` (its text box), `to` (the point the arrow touches), optional `knee`. caret (inserted text): `at`, the insertion point on the baseline. replace (Replace Text): `find` or `quads` like highlight, `contents` the replacement. attachment: `path` (the file), `at`, optional `icon` (PushPin, Paperclip, Graph, Tag). Undoable. Text a text box, callout or stamp draws in the standard fonts that they can't draw (Chinese, Greek) is saved as typed and drawn as `?`; `warnings` in the result says so.",
polygon/cloud/polyline (connected lines): `points` [[x, y], …]. callout: `rect` (its text box), `to` (the point the arrow touches), optional `knee`. caret (inserted text): `at`, the insertion point on the baseline. replace (Replace Text): `find` or `quads` like highlight, `contents` the replacement. attachment: `path` (the file), `at`, optional `icon` (PushPin, Paperclip, Graph, Tag). Undoable. Thai in a text box or callout is drawn from PdfCraft's built-in Thai face (a build without one refuses it). Other text a text box, callout or stamp draws in the standard fonts that they can't draw (Chinese, Greek) is saved as typed and drawn as `?`; `warnings` in the result says so.",
)
.with(schema(
json!({
@@ -923,7 +985,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. 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).")
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). On a dynamic (XFA) form, run_form_scripts: true runs the field's own signing scripts: preSign just before signing, so what it changes is signed too (form_scripts.changed lists each field it changed, with the values before and after), and postSign after, left unsaved. Without it they don't run, and form_scripts.skipped says the field has them.")
.cmd("sign.digital")
.with(schema(
json!({
@@ -974,6 +1036,7 @@ pub fn tools() -> Vec<ToolDef> {
"additionalProperties": false,
},
"out": { "type": "string", "description": "Where to save the signed document." },
"run_form_scripts": { "type": "boolean", "description": "Run a dynamic form's preSign and postSign scripts for the field (default false)." },
}),
&["doc", "id", "out"],
)),
@@ -1016,7 +1079,7 @@ 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 (the whole comment's, or its fill's and text's own), line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), border colour (`border_color`, text box/typewriter/callout; \"text\" gives it the text's colour again), rectangle (rectangle/oval/text box/stamp), position (`move` [dx, dy] in points) or shape (`points`: a line's two ends, or the corners of connected lines, a polygon or a cloud). 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 (the whole comment's, or its fill's and text's own), line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), border colour (`border_color`, text box/typewriter/callout; \"text\" gives it the text's colour again), rectangle (rectangle/oval/text box/stamp, or a callout's text box: its arrow point stays and its leader line re-attaches), position (`move` [dx, dy] in points) or shape (`points`: a line's two ends, the corners of connected lines, a polygon or a cloud, or a callout's arrow point and knee, as comment_list gives them). Stamps keep their original appearance when resized. One undo step.").with(schema(
comment_ref(json!({
"contents": { "type": "string" },
"color": color(),
@@ -1030,13 +1093,18 @@ pub fn tools() -> Vec<ToolDef> {
"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(),
"points": { "type": "array", "items": point(), "minItems": 2, "description": "A line's two ends, or every corner of connected lines, a polygon or a cloud, in page coordinates." },
"points": { "type": "array", "items": point(), "minItems": 1, "description": "A line's two ends, every corner of connected lines, a polygon or a cloud, or a callout's arrow point and (optionally) knee, in page coordinates. A callout given its arrow point alone keeps the knee it has." },
})),
&["doc"],
)),
t("comment_image_preview", "Preview image signature layers", "Return a PNG of the page without the selected image signature/initials (layer=background, default), or its embedded image with alpha (layer=image). Includes the displayed rectangle, document rotation, image_rotation (how far clockwise the image is shown turned) and annotation opacity. Cache these layers for live placement/resizing; commit once with comment_edit. Does not change the document or undo history.")
.ro().with(schema(comment_ref(json!({ "layer": { "type": "string", "enum": ["background", "image"], "default": "background" }, "dpi": { "type": "number", "minimum": 1, "maximum": 600, "default": 96 } })), &["doc"])),
t("comment_delete", "Delete a comment", "Delete a comment with its pop-up and replies, or one reply (its id, or page and index, from comment_list) with its pop-up and the replies to it; the comment and its other replies stay. Undoable.").destructive().with(schema(comment_ref(json!({})), &["doc"])),
t("comment_popup", "Open, close or move a comment's pop-up", "Open or close a comment's pop-up window (open), or move it (rect, in page view coordinates as comment_list reports). A comment without a pop-up gets one beside it. Text boxes, Fill & Sign marks and replies have none of their own. Undoable.")
.with(schema(comment_ref(json!({
"open": { "type": "boolean" },
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
})), &["doc"])),
t(
"comments_copy",
"Copy comments",
@@ -1125,7 +1193,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"doc_header_footer",
"Add header and footer",
"Add a header and/or footer to pages. Text boxes: header_left/center/right, footer_left/center/right. Tokens: <<1>> page number, <<n>> page count, <<1 of n>>, <<Page 1 of n>>, <<1/n>>, dates <<m/d/yyyy>> <<yyyy-mm-dd>> <<mmmm d, yyyy>> <<dd.mm.yyyy>> (and more), Bates <<Bates Number#6#1#PREFIX#SUFFIX>>. shrink: true scales the page content down evenly into the room the header and footer leave free, so they don't cover it. font: Helvetica (default), Times or Courier. replace: true swaps out existing ones (Update). Undoable.",
"Add a header and/or footer to pages. Text boxes: header_left/center/right, footer_left/center/right. Tokens: <<1>> page number, <<n>> page count, <<1 of n>>, <<Page 1 of n>>, <<1/n>>, dates <<m/d/yyyy>> <<yyyy-mm-dd>> <<mmmm d, yyyy>> <<dd.mm.yyyy>> (and more), Bates <<Bates Number#6#1#PREFIX#SUFFIX>>. shrink: true scales the page content down evenly into the room the header and footer leave free, so they don't cover it. font: Helvetica (default), Times or Courier; Thai is drawn from PdfCraft's built-in Thai face beside it (a build without one refuses it). replace: true swaps out existing ones (Update). Undoable.",
)
.cmd("edit.header_footer")
.with(schema(
@@ -1144,7 +1212,7 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc"],
)),
t("doc_watermark", "Add watermark", "Add a watermark to pages: text, or a picture from `file` (an image, or page `file_page` of a PDF) at `scale` of the page (default 0.5); rotated (degrees counter-clockwise, default 45), semi-transparent (opacity 0–1, default 0.5), text fitted to the page unless font_size is given, on top unless behind: true; moved from the page centre by `offset_x` (right) and `offset_y` (up) points (default 0). replace: true swaps out existing ones. Undoable.")
t("doc_watermark", "Add watermark", "Add a watermark to pages: text, or a picture from `file` (an image, or page `file_page` of a PDF) at `scale` of the page (default 0.5); rotated (degrees counter-clockwise, default 45), semi-transparent (opacity 0–1, default 0.5), text fitted to the page unless font_size is given (Thai drawn from PdfCraft's built-in Thai face; a build without one refuses it), on top unless behind: true; moved from the page centre by `offset_x` (right) and `offset_y` (up) points (default 0). replace: true swaps out existing ones. Undoable.")
.cmd("edit.watermark")
.with(schema(
json!({
@@ -1171,13 +1239,27 @@ pub fn tools() -> Vec<ToolDef> {
json!({ "doc": doc(), "pages": pages("to fill (default: all)"), "color": { "type": "string" }, "file": { "type": "string" }, "file_page": { "type": "integer", "minimum": 1 }, "scale": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }, "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, "replace": { "type": "boolean" }, "replace_backdrop": { "type": "boolean", "description": "Replace a white backdrop the page paints itself, which would hide the background. Default true." } }),
&["doc"],
)),
t("doc_printer_marks", "Add printer marks", "Print production: trim marks, bleed marks (dashed, at the bleed box), registration targets, colour bars and a page-information line (label, page n of N, date) drawn outside the trim box as a removable page mark, in registration colour (Separation All, so on every plate) except the process-ink colour bars; the page is widened by make_room points a side when the marks would fall outside it (the trim box stays). replace (default true) replaces marks added before. Undoable; doc_remove_marks kind printer_marks takes them off.")
.cmd("prepress.marks")
.with(schema(
json!({
"doc": doc(), "pages": pages("the pages to mark (default all)"),
"trim": { "type": "boolean" }, "bleed": { "type": "boolean" }, "registration": { "type": "boolean" }, "color_bars": { "type": "boolean" }, "page_info": { "type": "boolean" },
"line_weight": { "type": "number", "minimum": 0.1, "maximum": 4, "description": "Points (Acrobat offers 0.25, 0.5 and 1)." },
"make_room": { "type": "number", "maximum": 288, "description": "Points to widen the page by on each side when needed (default 36); negative leaves the page as it is." },
"label": { "type": "string", "description": "The page-information line's label (default: the file's name)." },
"date": { "type": "string", "description": "The page-information line's date (default: today)." },
"replace": { "type": "boolean" },
}),
&["doc"],
)),
t("doc_backdrops", "List page backdrops", "Pages that paint their own backdrop, which hides a background added behind the content: `fill` (an opaque white fill doc_background can replace) or `image` (a page-sized picture, e.g. a scan, that still hides it). Pages are 1-based; pages without one are left out.")
.ro()
.cmd("edit.background")
.with(schema(json!({ "doc": doc(), "pages": pages("to check (default: all)") }), &["doc"])),
t("doc_remove_marks", "Remove header & footer, watermark or background", "Remove every header and footer, watermark or background PdfCraft (or a compatible tool) added. Undoable.")
t("doc_remove_marks", "Remove header & footer, watermark, background or printer marks", "Remove every header and footer, watermark, background or printer marks PdfCraft (or a compatible tool) added. Undoable.")
.destructive()
.with(schema(json!({ "doc": doc(), "kind": { "type": "string", "enum": ["header_footer", "watermark", "background"] } }), &["doc", "kind"])),
.with(schema(json!({ "doc": doc(), "kind": { "type": "string", "enum": ["header_footer", "watermark", "background", "printer_marks"] } }), &["doc", "kind"])),
t("doc_export_images", "Export pages as images", "Write pages as PNG, JPEG or TIFF files (`<name>_page_<n>.png|jpg|tif`) into a folder, at a resolution (default 150 dpi). JPEG and TIFF are flattened onto white paper. A page too large for the renderer at that resolution is drawn at the most it allows, listed in `lower_dpi` ({page, dpi}). Includes unsaved edits.")
.cmd("export.image")
.with(schema(
@@ -1297,6 +1379,11 @@ pub fn tools() -> Vec<ToolDef> {
"doc": doc(), "object": { "type": "integer", "minimum": 1 },
"generation": { "type": "integer", "minimum": 0, "maximum": 65535 },
}), &["doc", "object", "generation"])),
t("accessibility_layout_table_to_blocks", "Change layout table to blocks", "Turn a table used only for layout (label and value pairs, a letterhead grid) into plain blocks in one step: the Table tag, its THead/TBody/TFoot and TR tags become Div, its TH, TD and Caption tags P (Div when they hold tags of their own). Content, reading order and the tags inside cells stay; a table nested in a cell stays a table; Table attributes (Summary, Scope, spans) are removed, other attributes kept. The tag's type must be Table after the role map; use object and generation from accessibility_tags. Undoable. Returns the tags as accessibility_tags does.")
.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 },
@@ -1308,7 +1395,7 @@ pub fn tools() -> Vec<ToolDef> {
t("form_merge_data", "Merge data files into spreadsheet", "Collect the field values of form data files (FDF, XFDF) or filled-in PDF forms into one CSV file at path: a column per field name, a row per file. Returns the row and column counts.")
.cmd("form.merge_data")
.with(schema(json!({ "paths": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, "path": { "type": "string" } }), &["paths", "path"])),
t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts (except the PDF container scripts when XFA layout is active; they remain available through js_document_scripts). Field changes and resetForm are applied as one undoable step; returns the script's alerts (each with its message, title, icon and buttons, in order; every question is still answered OK), console output, requests (print, page, url, submit), error, and the errors of document-level scripts that failed (the rest still ran).")
t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts (except the PDF container scripts when XFA layout is active; they remain available through js_document_scripts). Field changes and resetForm are applied as one undoable step; returns the alerts (each with its message, title, icon and buttons, in order; every question is still answered OK), console output and requests (print, page, url, submit) of the script and of the field scripts its changes ran, the script's error, the errors of document-level scripts that failed (the rest still ran), and in warnings the errors of applying the changes, such as values of an XFA form that could not be written to its data.")
.cmd("tools.js_console")
.with(schema(
json!({ "doc": doc(), "script": { "type": "string" }, "field": { "type": "string", "description": "Run as this button's Mouse Up event." } }),
@@ -1337,10 +1424,10 @@ pub fn tools() -> Vec<ToolDef> {
t("print_readiness", "Print readiness", "Verification only. Reports effective image resolution where each image is drawn (not against the page size), page boxes and bleed past the trim, output intents, fonts that are not embedded, and the PDF/A-2b structural result. Does not modify the document, embed fonts, invent a press profile, or check PDF/X.")
.ro()
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("pdfa_convert", "Save as PDF/A", "Standards ▸ Save as PDF/A: fix what can be fixed for PDF/A-2b or 3b (XMP identification and metadata, an sRGB output intent, forbidden actions and JavaScript, annotation print flags, image interpolation, encryption). Returns what was fixed and what remains (e.g. fonts that aren't embedded). Undoable; save the document to keep it.")
t("pdfa_convert", "Save as PDF/A", "Standards ▸ Save as PDF/A: fix what can be fixed for PDF/A-2b or 3b (XMP identification and metadata, an sRGB output intent, forbidden actions and JavaScript, annotation print flags, image interpolation, encryption, the file header, the file identifier). Returns what was fixed and what remains (e.g. fonts that aren't embedded). Undoable; save the document to keep it.")
.cmd("standards.pdfa")
.with(schema(json!({ "doc": doc(), "level": { "type": "string", "enum": ["2b", "3b"] } }), &["doc"])),
t("standards_remove_id", "Remove PDF/A or PDF/X identification", "Remove what identifies the document as PDF/A (its pdfaid: XMP properties) or PDF/X (GTS_PDFXVersion and GTS_PDFXConformance, in the document information and as pdfxid: XMP properties), e.g. after edits it no longer meets. Output intents and other metadata stay. Fails when the document declares nothing of the kind. Undoable.")
t("standards_remove_id", "Remove PDF/A or PDF/X identification", "Remove what identifies the document as PDF/A (its pdfaid: XMP properties) or PDF/X (GTS_PDFXVersion and GTS_PDFXConformance, in the document information and as pdfxid: or pdfx: XMP properties), e.g. after edits it no longer meets. Output intents and other metadata stay. Fails when the document declares nothing of the kind. Undoable.")
.with(schema(json!({ "doc": doc(), "standard": { "type": "string", "enum": ["pdfa", "pdfx"] } }), &["doc", "standard"])),
t("action_list", "List actions", "Action Wizard: the built-in actions (name, description, steps) and every step an action can use (id, label, whether it takes a text argument).")
.ro()
@@ -1479,7 +1566,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"fill_sign_add",
"Fill & Sign: type text or place a mark",
"Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, default 10 pt; `font_size` 4–300 pt; optional `color` as #RRGGBB for text/date, default black; `border` 0–12 pt around it, default none, in its colour or `border_color`), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`; `font_size` also applies), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 32 megapixels, scaled down to 4 megapixels, preserving transparency; with `scan: true`, `path` is instead a photo of a signature on paper, within the same limits: the signature is found, and the paper, shadows and table around it are removed, with `sensitivity` 0–100, default 50, picking up fainter ink when higher). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations. Typed text the standard fonts can't draw is saved and drawn as `?` (`warnings` in the result says so); a typed signature neither the script font nor its fallback can draw is refused (pass `path` instead).",
"Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, default 10 pt; `font_size` 4–300 pt; optional `color` as #RRGGBB for text/date, default black; `border` 0–12 pt around it, default none, in its colour or `border_color`), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`; `font_size` also applies), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 32 megapixels, scaled down to 4 megapixels, preserving transparency; with `scan: true`, `path` is instead a photo of a signature on paper, within the same limits: the signature is found, and the paper, shadows and table around it are removed, with `sensitivity` 0–100, default 50, picking up fainter ink when higher). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations. Typed Thai is drawn from PdfCraft's built-in Thai face (a build without one refuses it); other typed text the standard fonts can't draw is saved and drawn as `?` (`warnings` in the result says so); a typed signature neither the script font nor its fallback can draw is refused (pass `path` instead).",
)
.cmd("sign.fill.text")
.with(schema(
@@ -1508,7 +1595,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"doc_create",
"Create a PDF",
"Create a new, unsaved document and return it like doc_open: `blank` (pages, width, height in points; default 1 US Letter page), `images` (paths of PNG, JPEG, TIFF (every page), GIF or BMP files, one page each at the image's resolution) or `text` (a .txt path, or `text` directly; Western European (WinAnsi) characters only, others are refused). Save it with doc_save and a path.",
"Create a new, unsaved document and return it like doc_open: `blank` (pages, width, height in points; default 1 US Letter page), `images` (paths of PNG, JPEG, TIFF (every page), GIF or BMP files, one page each at the image's resolution) or `text` (a .txt path, or `text` directly; set in Helvetica, or in the bundled Inter face when it has Latin, Greek or Cyrillic letters outside Western European (WinAnsi); other characters are refused). Save it with doc_save and a path.",
)
.with(schema(
json!({
@@ -1638,7 +1725,7 @@ pub fn tools() -> Vec<ToolDef> {
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.")
.with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])),
t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. 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). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, up to 16 JBIG2 images sharing one symbol dictionary (/JBIG2Globals) where that is smaller still (image masks and DeviceGray images named directly, of at most 64 Mi pixels, which redaction can store again without it), Group 3 fax images, JBIG2 images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT, a JBIG2 image JBIG2 and any other becoming Flate). Images past what one run may decode stay as they are, counted in images_refused; streams it only stores with Flate past that stay LZW, uncounted. 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); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); 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. target_bytes (optional): if the copy is larger, retry with up to 5 progressively stronger image settings (downsample to at most 110, 96, 72, 54, 36 ppi, applied to colour and grey images; images set to JPEG also get JPEG quality at most 45, 35, 28, 20, 12, while zip and retain images keep their encoding and only get the lower ppi) and stop at the first copy that fits; reports target_reached, attempts and target_settings, and writes the smallest copy with target_reached false when none fits. Without it nothing changes. 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. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. 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). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, up to 16 JBIG2 images sharing one symbol dictionary (/JBIG2Globals) where that is smaller still (image masks and DeviceGray images named directly, of at most 64 Mi pixels, which redaction can store again without it), Group 3 fax images, JBIG2 images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT, a JBIG2 image JBIG2 and any other becoming Flate). Images past what one run may decode stay as they are, counted in images_refused; streams it only stores with Flate past that stay LZW, uncounted. Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, search_index (removes the catalog's /PieceInfo entries whose application name contains \"index\" in any letter case; search_indexes), print_settings (entries removed: print_settings), form_actions (submit, import and reset actions removed: form_actions; the fields and links stay, and the actions chained after one still run); flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); discard_external_refs takes GoToR, GoToE, Launch and ImportData actions, and Thread actions with /F, out of links and bookmarks, removes a link left with nothing to do, and keeps links within the document and web links (external_links, external_bookmarks); 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. target_bytes (optional): if the copy is larger, retry with up to 5 progressively stronger image settings (downsample to at most 110, 96, 72, 54, 36 ppi, applied to colour and grey images; images set to JPEG also get JPEG quality at most 45, 35, 28, 20, 12, while zip and retain images keep their encoding and only get the lower ppi) and stop at the first copy that fits; reports target_reached, attempts and target_settings, and writes the smallest copy with target_reached false when none fits. Without it nothing changes. The open document is unchanged.")
.cmd("optimize.advanced")
.with(schema(
json!({
@@ -1654,12 +1741,15 @@ pub fn tools() -> Vec<ToolDef> {
"discard_thumbnails": { "type": "boolean" },
"discard_alternate_images": { "type": "boolean" },
"discard_tags": { "type": "boolean" },
"discard_search_index": { "type": "boolean" },
"discard_print_settings": { "type": "boolean" },
"discard_form_actions": { "type": "boolean" },
"flate_unencoded": { "type": "boolean" },
"object_streams": { "type": "boolean" },
"lzw_to_flate": { "type": "boolean" },
"remove_invalid_links": { "type": "boolean" },
"remove_unreferenced_dests": { "type": "boolean" },
"discard_external_refs": { "type": "boolean" },
"discard": { "type": "array", "items": { "type": "string" } },
}),
&["doc", "path"],
File diff suppressed because it is too large. Load diff
+84
View File
@@ -0,0 +1,84 @@
//! `text_find` on a ligature glyph named in the encoding and on a word divided by a hyphen at a
//! line end.
use std::path::PathBuf;
use pdfcraft_automation::{Automation, Content};
use serde_json::{Value, json};
/// A fresh directory for one test.
fn tmp(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("pdfcraft-find-text-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn ok(a: &mut Automation, tool: &str, args: Value) -> Value {
match a.call(tool, &args) {
Ok(mut c) => match c.remove(0) {
Content::Json(v) => v,
other => panic!("{tool}: expected JSON, got {other:?}"),
},
Err(e) => panic!("{tool} {args}: {e}"),
}
}
/// One page: `of<fi>ce` in a Helvetica whose code 128 is named `/fi`, and `exam-` at the end of
/// one line with `ple` starting the next.
fn find_pdf() -> Vec<u8> {
let body = "BT /F2 14 Tf 90 700 Td (of\\200ce) Tj ET\nBT /F1 14 Tf 90 650 Td (exam-) Tj 0 -16 Td (ple) Tj ET\n";
let objs = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R >> >> >>".to_string(),
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding << /Type /Encoding /BaseEncoding /WinAnsiEncoding /Differences [128 /fi] >> >>"
.to_string(),
];
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
}
/// "office" finds the ligature, and "example" both halves of the divided word, highlighted line by
/// line; the hyphenated form is still found as written.
#[test]
fn text_find_matches_ligatures_and_words_divided_at_a_line_end() {
let dir = tmp("forms");
std::fs::write(dir.join("find.pdf"), find_pdf()).unwrap();
let mut a = Automation::new().with_root(&dir).unwrap();
let doc = ok(&mut a, "doc_open", json!({ "path": "find.pdf" }))["doc"].as_u64().unwrap();
let find = |a: &mut Automation, q: &str| ok(a, "text_find", json!({ "doc": doc, "query": q }));
let office = find(&mut a, "office");
assert_eq!(office["count"], 1, "{office}");
let rects = office["matches"][0]["rects"].as_array().unwrap();
assert_eq!(rects.len(), 1);
assert!((rects[0][0].as_f64().unwrap() - 90.0).abs() < 0.5, "from the left edge of the o: {rects:?}");
let example = find(&mut a, "example");
assert_eq!(example["count"], 1, "{example}");
assert_eq!(example["matches"][0]["text"], "exam-\nple");
let rects = example["matches"][0]["rects"].as_array().unwrap();
assert_eq!(rects.len(), 2, "one highlight on each line: {rects:?}");
let top = |k: usize| rects[k][1].as_f64().unwrap();
assert!(top(1) > top(0) + 10.0, "the second on the next line down: {rects:?}");
for q in ["exam-ple", "Exam- ple", "exam-"] {
assert_eq!(find(&mut a, q)["count"], 1, "{q}");
}
assert_eq!(find(&mut a, "examples")["count"], 0);
let _ = std::fs::remove_dir_all(&dir);
}
+52 -20
View File
@@ -11,9 +11,10 @@
//!
//! 1. **Nothing panics.** A panic in a tool is a crashed MCP server or CLI (AGENTS.md §4), so
//! every call must come back as `Ok` or as a `ToolError` the agent can read.
//! 2. **Nothing escapes `--root`.** Every path argument is tried with `..` traversal, absolute
//! paths, Windows drive and UNC paths, and a planted symbolic link. The call must fail, and
//! afterwards nothing outside the root may have been created.
//! 2. **Nothing escapes `--root` or the automation roots.** Every path argument is tried with `..`
//! traversal, absolute paths, Windows drive and UNC paths, and a planted symbolic link, under
//! both `--root` and `--automation-read-root`/`--automation-write-root` (#4003). The call must
//! fail, and afterwards nothing outside the root may have been created.
//!
//! The second is the mechanized form of the D3 audit. Reading the code says every handler resolves
//! its own paths and that no tool dispatches another internally; this keeps that true as tools are
@@ -22,6 +23,7 @@
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::path::{Path, PathBuf};
use pdfcraft_automation::file_access::AutomationRoots;
use pdfcraft_automation::{Automation, ToolError, tools};
use serde_json::{Value, json};
@@ -84,6 +86,7 @@ fn zoo() -> Vec<Value> {
/// bug can only touch what the test made. `linked`: the root holds `link-out`, a symbolic link
/// to `base`.
fn escapes(base: &std::path::Path, linked: bool) -> Vec<String> {
let root = base.join("root");
let mut out: Vec<String> = vec![
"../outside.pdf".into(),
"../../outside.pdf".into(),
@@ -93,13 +96,16 @@ fn escapes(base: &std::path::Path, linked: bool) -> Vec<String> {
base.join("outside.pdf").to_string_lossy().into_owned(),
base.join("new-outside.pdf").to_string_lossy().into_owned(),
base.join("sub").join("new-outside.pdf").to_string_lossy().into_owned(),
root.join("..").join("outside.pdf").to_string_lossy().into_owned(),
];
if linked {
out.push("link-out/outside.pdf".into());
out.push(root.join("link-out").join("outside.pdf").to_string_lossy().into_owned());
}
// Backslashes separate only on Windows; elsewhere this is an ordinary file name in the root.
if cfg!(windows) {
out.push(r"..\..\outside.pdf".into());
out.push(r"\\.\pipe\pdfcraft-hostile-escape.pdf".into());
}
out
}
@@ -220,6 +226,20 @@ fn empty_dir(dir: &Path) {
}
}
/// `action_run` keeps going when one input path is refused and reports that refusal on the file.
/// That is still a confinement refusal: every file has an error naming the boundary, and none
/// has an output.
fn per_file_confinement_refusal(content: &[pdfcraft_automation::Content]) -> bool {
let Some(files) = content.iter().find_map(|c| match c {
pdfcraft_automation::Content::Json(v) => v.get("files").and_then(Value::as_array),
pdfcraft_automation::Content::Png { .. } => None,
}) else {
return false;
};
!files.is_empty()
&& files.iter().all(|file| file.get("output").is_none() && file.get("error").and_then(Value::as_str).is_some_and(|e| e.contains("outside")))
}
/// Real values for the arguments a tool checks before the others, so a hostile value reaches the
/// argument it is meant for instead of failing on a placeholder. `sign_document` checks its
/// digital ID first and its document second: this ID opens with its password, and each call gets
@@ -234,6 +254,12 @@ fn fixture(a: &mut Automation, root: &Path, tool: &str) -> serde_json::Map<Strin
let args = json!({ "doc": doc, "id": "id.p12", "password": "test", "out": "signed.pdf" });
args.as_object().cloned().unwrap_or_default()
}
// Without a step, action_run returns "give action or steps" before it resolves folder
// or paths, and that error would be counted as a confinement refusal.
"action_run" => {
let args = json!({ "steps": [{ "step": "set_title", "arg": "Confined" }] });
args.as_object().cloned().unwrap_or_default()
}
_ => serde_json::Map::new(),
}
}
@@ -313,28 +339,34 @@ fn no_tool_escapes_the_root() {
}
};
let mut a = Automation::new().with_root(&root).expect("root");
// A real document, so tools reach their path arguments instead of failing on `doc` first.
let doc = blank_doc(&mut a);
let roots = AutomationRoots::new(Some(&root), Some(&root)).expect("roots");
let modes = [("--root", Automation::new().with_root(&root).expect("root")), ("automation roots", Automation::new().with_automation_roots(roots))];
let mut leaked = Vec::new();
let mut tried = 0usize;
let mut refused = 0usize;
for def in tools() {
for (prop, is_list) in path_props(&def) {
for escape in escapes(&base, linked) {
let mut obj = plausible_with(&def, Some(doc));
obj.insert(prop.clone(), if is_list { json!([escape]) } else { json!(escape) });
let args = Value::Object(obj);
tried += 1;
match catch_unwind(AssertUnwindSafe(|| a.call(def.name, &args))) {
// A path that leaves the root must be refused. Succeeding means the tool
// either used it or ignored an argument it declares — both are faults.
Ok(Ok(_)) => leaked.push(format!("{} accepted {prop}={escape:?}", def.name)),
Ok(Err(_)) => refused += 1,
Err(_) => leaked.push(format!("{} panicked on {prop}={escape:?}", def.name)),
for (mode, mut a) in modes {
// A real document, so tools reach their path arguments instead of failing on `doc` first.
let doc = blank_doc(&mut a);
for def in tools() {
for (prop, is_list) in path_props(&def) {
for escape in escapes(&base, linked) {
let mut obj = plausible_with(&def, Some(doc));
obj.insert(prop.clone(), if is_list { json!([escape]) } else { json!(escape) });
let args = Value::Object(obj);
tried += 1;
match catch_unwind(AssertUnwindSafe(|| a.call(def.name, &args))) {
// A path that leaves the root must be refused. Succeeding means the tool
// either used it or ignored an argument it declares — both are faults.
// action_run reports a refused input inside an otherwise successful
// result, one error per file, and writes nothing for that file.
Ok(Ok(content)) if per_file_confinement_refusal(&content) => refused += 1,
Ok(Ok(_)) => leaked.push(format!("{mode}: {} accepted {prop}={escape:?}", def.name)),
Ok(Err(_)) => refused += 1,
Err(_) => leaked.push(format!("{mode}: {} panicked on {prop}={escape:?}", def.name)),
}
}
}
}
@@ -0,0 +1,81 @@
//! Which options a list box has selected, through the tool table (ISO 32000-2 Table 234, `/I`).
use pdfcraft_automation::{Automation, Content};
use serde_json::{Value, json};
fn tool(a: &mut Automation, name: &str, args: Value) -> Value {
match a.call(name, &args).unwrap().remove(0) {
Content::Json(value) => value,
other => panic!("expected JSON: {other:?}"),
}
}
/// A PDF of the given objects (numbered from 1).
fn pdf_of(objs: &[&str]) -> Vec<u8> {
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
}
fn open(test: &str) -> (Automation, u64, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!("pdfcraft-list-selection-{test}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let bytes = pdf_of(&[
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R] /NeedAppearances true /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 400] >>",
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R] >>",
"<< /Type /Annot /Subtype /Widget /FT /Ch /T (D_dup) /Opt [[(1) (One-A)] [(1) (One-B)] [(2) (Two)]] /V (1) /I [1] /Rect [50 250 200 320] /P 3 0 R /F 4 >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
"<< /Type /Annot /Subtype /Widget /FT /Ch /T (H_twovals) /Opt [[(TW) (Taiwan)] [(JP) (Japan)] [(US) (United States)]] /V [(TW) (JP)] /Rect [50 150 200 220] /P 3 0 R /F 4 >>",
]);
std::fs::write(dir.join("choice.pdf"), bytes).unwrap();
let mut a = Automation::new().with_root(&dir).unwrap();
let id = tool(&mut a, "doc_open", json!({ "path": "choice.pdf" }))["doc"].as_u64().unwrap();
(a, id, dir)
}
fn field(a: &mut Automation, doc: u64, name: &str) -> Value {
let all = tool(a, "form_fields", json!({ "doc": doc }));
all["fields"].as_array().unwrap().iter().find(|f| f["name"] == name).unwrap().clone()
}
/// Two options share the export value `1`: `selected` says which one `/I` picks, and filling the
/// field with an option's label picks that option.
#[test]
fn form_fields_reports_which_of_two_options_with_one_export_value_is_selected() {
let (mut a, doc, dir) = open("dup");
let d = field(&mut a, doc, "D_dup");
assert_eq!(d["value"], "1");
assert_eq!(d["selected"], json!([2]), "One-B, the second option: {d}");
tool(&mut a, "form_fill", json!({ "doc": doc, "values": { "D_dup": "One-A" } }));
let d = field(&mut a, doc, "D_dup");
assert_eq!((d["value"].clone(), d["selected"].clone()), (json!("1"), json!([1])), "{d}");
tool(&mut a, "form_fill", json!({ "doc": doc, "values": { "D_dup": "One-B" } }));
let d = field(&mut a, doc, "D_dup");
assert_eq!(d["selected"], json!([2]));
let _ = std::fs::remove_dir_all(&dir);
}
/// A list box without MultiSelect given `/V [(TW) (JP)]` reports Taiwan alone (ISO 32000-2
/// Table 233: "at most one item shall be selected"), and filling it writes one value.
#[test]
fn form_fields_reports_one_value_for_a_single_select_list_given_two() {
let (mut a, doc, dir) = open("twovals");
let h = field(&mut a, doc, "H_twovals");
assert_eq!((h["value"].clone(), h["selected"].clone()), (json!("TW"), json!([1])), "{h}");
tool(&mut a, "form_fill", json!({ "doc": doc, "values": { "H_twovals": "United States" } }));
let h = field(&mut a, doc, "H_twovals");
assert_eq!((h["value"].clone(), h["selected"].clone()), (json!("US"), json!([3])), "{h}");
let _ = std::fs::remove_dir_all(&dir);
}
+4
View File
@@ -9,12 +9,16 @@ mod batch;
mod batch_failures;
mod batch_secrets;
mod button_caption;
mod find_text;
mod form_bulk;
mod hostile_args;
mod list_selection;
mod model_3d;
mod model_render;
mod optimize_content_boundaries;
mod portfolio_info;
mod redact_after_stray_q_form;
mod redact_text_after_q;
mod render_warnings;
mod text_copy;
mod unreadable_objects;
+168
View File
@@ -110,3 +110,171 @@ fn a_model_outside_the_root_is_never_opened_to_be_drawn() {
let doc = open(&mut a, "prc.pdf");
assert!(a.call("model_render", &json!({ "doc": doc, "page": 1 })).is_ok());
}
#[test]
fn an_orbit_frames_the_model_from_a_named_view_and_its_moves() {
let (_dir, mut a) = setup("orbit");
let doc = open(&mut a, "prc.pdf");
let draw = |a: &mut Automation, orbit: Value| orbit_picture(a, doc, false, &orbit);
// The triangle lies in z = 0: the Top view looks down on its face, the Front view along its edge.
let top = draw(&mut a, json!({ "view": "top" }));
assert_eq!(rgb(&top, 32, 24), [190, 192, 200], "the top view sees the face head on");
let front = draw(&mut a, json!({ "view": "front" }));
assert!((0..48).all(|y| rgb(&front, 32, y) == [255, 255, 255]), "the front view sees only its edge");
let tipped = draw(&mut a, json!({ "view": "front", "turn": [0, 30] }));
assert_ne!(rgb(&tipped, 32, 24), [255, 255, 255], "tipped down from the front, the face is in the middle");
// With y up the Front view looks along -z, onto the face.
let y_up = draw(&mut a, json!({ "view": "front", "up": "+y" }));
assert_eq!(rgb(&y_up, 32, 24), [190, 192, 200]);
// Turning the front view down by 90° stops just short of the top view and sees the face.
let turned = draw(&mut a, json!({ "view": "front", "turn": [0, 90] }));
assert_ne!(rgb(&turned, 32, 24), [255, 255, 255]);
// A pan moves the target right, so the model moves left; three radii take it out of the picture.
let (left, right) = extent(&top, 24);
let (moved_left, moved_right) = extent(&draw(&mut a, json!({ "view": "top", "pan": [0.5, 0] })), 24);
assert!(moved_left < left && moved_right < right, "{left}..{right} moved to {moved_left}..{moved_right}");
let panned = draw(&mut a, json!({ "view": "top", "pan": [3, 0] }));
assert!((0..64).all(|x| rgb(&panned, x, 24) == [255, 255, 255]), "the model has left the picture");
// Zooming in about the centre keeps the face under it and leaves no background at the corner.
let zoomed = draw(&mut a, json!({ "view": "top", "zoom": 8 }));
assert_eq!(rgb(&zoomed, 32, 24), [190, 192, 200]);
assert_ne!(rgb(&zoomed, 0, 0), [255, 255, 255], "zoomed in, the face fills the corner too");
}
/// The first and last columns on row `y` that are not background.
fn extent(data: &[u8], y: usize) -> (usize, usize) {
let drawn: Vec<usize> = (0..64).filter(|&x| rgb(data, x, y) != [255, 255, 255]).collect();
(*drawn.first().expect("the model is on the row"), *drawn.last().expect("the model is on the row"))
}
/// The first and last rows on column `x` that are not background.
fn rows(data: &[u8], x: usize) -> (usize, usize) {
let drawn: Vec<usize> = (0..48).filter(|&y| rgb(data, x, y) != [255, 255, 255]).collect();
(*drawn.first().expect("the model is on the column"), *drawn.last().expect("the model is on the column"))
}
/// A 64 by 48 picture of the model through `orbit`.
fn orbit_picture(a: &mut Automation, doc: u64, perspective: bool, orbit: &Value) -> Vec<u8> {
let out =
a.call("model_render", &json!({ "doc": doc, "page": 1, "width": 64, "height": 48, "perspective": perspective, "orbit": orbit })).unwrap();
let Content::Png { data, .. } = &out[0] else { panic!("expected a PNG") };
data.clone()
}
#[test]
fn an_empty_orbit_frames_the_isometric_view_in_perspective() {
let (_dir, mut a) = setup("orbit-default");
let doc = open(&mut a, "prc.pdf");
let default = orbit_picture(&mut a, doc, false, &json!({}));
assert_ne!(rgb(&default, 32, 24), [255, 255, 255], "the model is centred");
let (left, right) = extent(&default, 24);
let (high, low) = rows(&default, 32);
assert!(left > 0 && right < 63 && high > 0 && low < 47, "inside the picture: {left}..{right}, {high}..{low}");
assert!(right - left > 16, "and fills it: {left}..{right}");
assert_eq!(default, orbit_picture(&mut a, doc, false, &json!({ "view": "iso", "up": "+z", "front": "+y" })), "the defaults");
let unset = a.call("model_render", &json!({ "doc": doc, "page": 1, "width": 64, "height": 48, "orbit": {} })).unwrap();
let Content::Png { data: unset, .. } = &unset[0] else { panic!("expected a PNG") };
assert_eq!(*unset, orbit_picture(&mut a, doc, true, &json!({})), "perspective is the default");
assert_ne!(*unset, default, "and shows");
}
#[test]
fn zoom_at_keeps_the_point_under_it_still() {
let (_dir, mut a) = setup("orbit-zoom-at");
let doc = open(&mut a, "prc.pdf");
let (left, right) = extent(&orbit_picture(&mut a, doc, false, &json!({ "view": "top", "zoom": 2 })), 24);
// A point 0.2 picture heights off centre stays there, so what was at the centre moves
// 0.2 x (2 - 1) x 48 = 9.6 pixels the other way.
for (at, sign) in [(0.2, -1.0), (-0.2, 1.0)] {
let (l, r) = extent(&orbit_picture(&mut a, doc, false, &json!({ "view": "top", "zoom": 2, "zoom_at": [at, 0] })), 24);
for (was, now) in [(left, l), (right, r)] {
let moved = now as f64 - was as f64;
assert!((moved - sign * 9.6).abs() <= 1.5, "at {at}: {was} moved to {now}");
}
}
}
#[test]
fn the_zoom_is_held_within_its_range() {
let (_dir, mut a) = setup("orbit-zoom-range");
let doc = open(&mut a, "prc.pdf");
let far = orbit_picture(&mut a, doc, false, &json!({ "view": "top", "zoom": 0.05 }));
assert_eq!(far, orbit_picture(&mut a, doc, false, &json!({ "view": "top", "zoom": 0.001 })), "the far end");
assert_ne!(far, orbit_picture(&mut a, doc, false, &json!({ "view": "top", "zoom": 0.2 })));
// Panned to just inside the near edge, so a nearer view would move the edge in the picture.
let close = |z: f64| json!({ "view": "top", "zoom": z, "pan": [0, -0.7] });
let near = orbit_picture(&mut a, doc, false, &close(50.0));
assert_eq!(near, orbit_picture(&mut a, doc, false, &close(1000.0)), "the near end");
assert_ne!(near, orbit_picture(&mut a, doc, false, &close(25.0)));
}
#[test]
fn an_orbit_draws_in_perspective_at_the_same_scale() {
let (_dir, mut a) = setup("orbit-perspective");
let doc = open(&mut a, "prc.pdf");
let tipped = json!({ "view": "front", "turn": [0, 30] });
let deep = orbit_picture(&mut a, doc, true, &tipped);
assert_ne!(rgb(&deep, 32, 24), [255, 255, 255]);
assert_ne!(deep, orbit_picture(&mut a, doc, false, &tipped), "foreshortened");
let top = json!({ "view": "top" });
let (left, right) = extent(&orbit_picture(&mut a, doc, true, &top), 24);
let (flat_left, flat_right) = extent(&orbit_picture(&mut a, doc, false, &top), 24);
assert!(left.abs_diff(flat_left) <= 2 && right.abs_diff(flat_right) <= 2, "{left}..{right} against {flat_left}..{flat_right}");
}
#[test]
fn an_orbit_front_yaw_and_upward_pan_each_move_the_picture() {
let (_dir, mut a) = setup("orbit-moves");
let doc = open(&mut a, "prc.pdf");
let mut draw = |orbit: Value| orbit_picture(&mut a, doc, false, &orbit);
// Yawing the Front view by 90 degrees is the Right view, which looks along -x; +x is its opposite.
let right_view = draw(json!({ "view": "right", "turn": [0, 30] }));
assert_eq!(right_view, draw(json!({ "view": "front", "turn": [90, 30] })), "yaw");
assert_eq!(right_view, draw(json!({ "view": "front", "front": "-x", "turn": [0, 30] })), "front -x");
assert_ne!(right_view, draw(json!({ "view": "front", "turn": [0, 30] })));
assert_eq!(draw(json!({ "view": "right", "turn": [180, 30] })), draw(json!({ "view": "front", "front": "+x", "turn": [0, 30] })), "front +x");
// From above, the triangle's apex is up the picture; a quarter yaw lays it along the row,
// twice as wide there.
let top = draw(json!({ "view": "top" }));
let (left, right) = extent(&top, 24);
let (yaw_left, yaw_right) = extent(&draw(json!({ "view": "top", "turn": [90, 0] })), 24);
assert!(yaw_right - yaw_left > (right - left) * 3 / 2, "{left}..{right} yawed to {yaw_left}..{yaw_right}");
// A pan up moves the target up, so the model moves down.
let (high, low) = rows(&top, 32);
let (moved_high, moved_low) = rows(&draw(json!({ "view": "top", "pan": [0, 0.25] })), 32);
assert!(moved_high > high && moved_low >= low, "{high}..{low} moved to {moved_high}..{moved_low}");
}
#[test]
fn a_wrong_orbit_is_a_clear_error() {
let (_dir, mut a) = setup("orbit-bad");
let doc = open(&mut a, "prc.pdf");
for (bad, reason) in [
(json!({ "orbit": { "view": "side" } }), "view as one of"),
(json!({ "orbit": { "view": 1 } }), "view as a string"),
(json!({ "orbit": { "up": "z" } }), "up as one of"),
(json!({ "orbit": { "up": 1 } }), "up as a string"),
(json!({ "orbit": { "front": "y" } }), "front as one of"),
(json!({ "orbit": { "up": "+z", "front": "-z" } }), "front perpendicular to up"),
(json!({ "orbit": { "turn": [1] } }), "turn as [yaw, pitch]"),
(json!({ "orbit": { "turn": [1, 2, 3] } }), "turn as [yaw, pitch]"),
(json!({ "orbit": { "turn": 1 } }), "turn as [yaw, pitch]"),
(json!({ "orbit": { "zoom": 0 } }), "zoom as a positive number"),
(json!({ "orbit": { "zoom": -2 } }), "zoom as a positive number"),
(json!({ "orbit": { "zoom": "a" } }), "zoom as a positive number"),
(json!({ "orbit": { "zoom_at": [0.1, 0] } }), "with zoom when it has zoom_at"),
(json!({ "orbit": { "zoom": 2, "zoom_at": [1] } }), "zoom_at as [right, up]"),
(json!({ "orbit": { "zoom": 2, "zoom_at": [1, 2, 3] } }), "zoom_at as [right, up]"),
(json!({ "orbit": { "pan": [1, "a"] } }), "pan as [right, up]"),
(json!({ "orbit": { "pan": [1] } }), "pan as [right, up]"),
(json!({ "orbit": { "pan": [1, 2, 3] } }), "pan as [right, up]"),
(json!({ "orbit": { "spin": 1 } }), "without spin"),
(json!({ "orbit": 1 }), "an object"),
(json!({ "orbit": {}, "direction": [0, 0, -1] }), "left out when direction is given"),
] {
let mut args = json!({ "doc": doc, "page": 1, "width": 16, "height": 16 });
args.as_object_mut().unwrap().extend(bad.as_object().unwrap().clone());
let err = a.call("model_render", &args).unwrap_err().to_string();
assert!(err.contains("orbit must be") && err.contains(reason), "{bad}: {err}");
}
}
@@ -0,0 +1,54 @@
//! `doc_info` reports a PDF portfolio (`/Collection`, ISO 32000-2 §7.11.6): the attachment it
//! presents first and how its file list first shows.
use pdfcraft_automation::{Automation, Content};
use serde_json::{Value, json};
/// Call tool `name` and return its JSON result.
fn tool(a: &mut Automation, name: &str, args: Value) -> Value {
match a.call(name, &args).unwrap().remove(0) {
Content::Json(value) => value,
other => panic!("expected JSON: {other:?}"),
}
}
/// A one-page portfolio of `cover.txt` and `form.pdf` with `collection` as its `/Collection`
/// entries, or none at all.
fn portfolio(collection: Option<&str>) -> Vec<u8> {
let collection = collection.map(|c| format!("/Collection << /Type /Collection {c} >>")).unwrap_or_default();
format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R {collection} /Names << /EmbeddedFiles << /Names [(cover.txt) 5 0 R (form.pdf) 7 0 R] >> >> >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >> endobj
4 0 obj << /Type /EmbeddedFile /Length 5 >> stream
hello
endstream endobj
5 0 obj << /Type /Filespec /F (cover.txt) /UF (cover.txt) /EF << /F 4 0 R >> >> endobj
6 0 obj << /Type /EmbeddedFile /Length 8 >> stream
%PDF-1.7
endstream endobj
7 0 obj << /Type /Filespec /F (form.pdf) /UF (form.pdf) /EF << /F 6 0 R >> >> endobj
trailer << /Root 1 0 R >>
%%EOF"
)
.into_bytes()
}
/// `doc_info` names a portfolio's initial document and view, and reports none for a plain file.
#[test]
fn doc_info_names_the_document_a_portfolio_presents_first() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("tile.pdf"), portfolio(Some("/D (form.pdf) /View /T"))).unwrap();
std::fs::write(dir.path().join("cover.pdf"), portfolio(Some(""))).unwrap();
std::fs::write(dir.path().join("plain.pdf"), portfolio(None)).unwrap();
let mut auto = Automation::new().with_root(dir.path()).unwrap();
let mut info = |path: &str| {
let doc = tool(&mut auto, "doc_open", json!({ "path": path }))["doc"].as_u64().unwrap();
tool(&mut auto, "doc_info", json!({ "doc": doc }))["portfolio"].clone()
};
assert_eq!(info("tile.pdf"), json!({ "initial": "form.pdf", "view": "tile" }));
// Without `/D` the cover sheet is the initial document.
assert_eq!(info("cover.pdf"), json!({ "initial": null, "view": "details" }));
assert_eq!(info("plain.pdf"), Value::Null);
}
@@ -96,6 +96,33 @@ fn a_clean_page_renders_to_the_png_alone() {
let _ = std::fs::remove_dir_all(dir);
}
/// An image whose Indexed lookup table is too short (ISO 32000-2 §8.6.6.3) is drawn recovered, and
/// `page_render` says so in the words `pdfcraft-cli render` prints.
#[test]
fn page_render_reports_a_recovered_malformed_indexed_image() {
let dir = std::env::temp_dir().join(format!("pdfcraft-render-warnings-indexed-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let content = "q 200 0 0 200 0 0 cm /Im Do Q";
// hival 7 over DeviceRGB needs 24 bytes; the table holds 6.
let pdf = format!(
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /XObject << /Im 5 0 R >> >> /Contents 4 0 R >> endobj\n\
4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
5 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 1 /BitsPerComponent 8 /ColorSpace [/Indexed /DeviceRGB 7 <FF0000 00FF00>] /Filter /ASCIIHexDecode /Length 5 >> stream\n0001>\nendstream endobj\n\
trailer << /Root 1 0 R >>\n%%EOF",
content.len()
);
std::fs::write(dir.join("indexed.pdf"), pdf).unwrap();
let mut a = Automation::new().with_root(&dir).unwrap();
let doc = open(&mut a, "indexed.pdf");
let out = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap();
let Some(Content::Json(report)) = out.get(1) else { panic!("expected the warnings as JSON: {out:?}") };
assert_eq!(report["warnings"], json!(["an image with a malformed Indexed colour table may be drawn wrongly or not at all"]));
let _ = std::fs::remove_dir_all(dir);
}
/// Over MCP the warnings are a text block after the image and the call's `structuredContent`.
#[cfg(feature = "mcp")]
#[test]
+106
View File
@@ -0,0 +1,106 @@
//! `text_extract` and `text_find` on a page of the cases text copy has to get right: words drawn out
//! of order, runs of different sizes on one baseline, a ligature glyph named in the encoding and a
//! line turned upwards in a row of text.
use std::path::PathBuf;
use pdfcraft_automation::{Automation, Content};
use serde_json::{Value, json};
/// A fresh directory for one test.
fn tmp(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("pdfcraft-text-copy-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn ok(a: &mut Automation, tool: &str, args: Value) -> Value {
match a.call(tool, &args) {
Ok(mut c) => match c.remove(0) {
Content::Json(v) => v,
other => panic!("{tool}: expected JSON, got {other:?}"),
},
Err(e) => panic!("{tool} {args}: {e}"),
}
}
/// One page of labelled rows (`/F1` Helvetica, `/F2` Helvetica with code 128 named `/fi`):
/// 01 `of<fi>ce`; 06 `exam-` over `ple`; 07 two columns of two lines; 08 "second" drawn before
/// "first", to its right; 11 a 14 pt invisible word and a 9 pt note on one baseline; 12 letter
/// spacing; 13 "up" set upwards; 14 two words placed by `Td`.
fn copy_pdf() -> Vec<u8> {
let rows = [
("01", "BT /F2 14 Tf 90 {Y} Td (of\\200ce) Tj ET"),
("06", "BT /F1 14 Tf 90 {Y} Td (exam-) Tj 0 -16 Td (ple) Tj ET"),
("07", "BT /F1 12 Tf 90 {Y} Td (left one) Tj 0 -14 Td (left two) Tj ET BT /F1 12 Tf 300 {Y} Td (right one) Tj 0 -14 Td (right two) Tj ET"),
("08", "BT /F1 14 Tf 160 {Y} Td (second) Tj ET BT /F1 14 Tf 90 {Y} Td (first) Tj ET"),
("11", "BT /F1 14 Tf 3 Tr 90 {Y} Td (hidden) Tj ET BT /F1 9 Tf 0.5 g 160 {Y} Td (<- invisible word here) Tj ET"),
("12", "BT /F1 14 Tf 8 Tc 90 {Y} Td (SPACED) Tj ET"),
("13", "BT /F1 14 Tf 0 1 -1 0 110 {Y} Tm (up) Tj ET"),
("14", "BT /F1 14 Tf 90 {Y} Td (split) Tj 40 0 Td (words) Tj ET"),
];
let mut body = String::new();
let mut y = 740;
for (tag, row) in rows {
body += &format!("BT /F1 9 Tf 0.4 g 40 {y} Td ({tag}) Tj ET 0 g\nq {} Q\n", row.replace("{Y}", &y.to_string()));
y -= match tag {
"06" | "13" => 48,
"07" => 44,
_ => 38,
};
}
let objs = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R >> >> >>".to_string(),
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding << /Type /Encoding /BaseEncoding /WinAnsiEncoding /Differences [128 /fi] >> >>"
.to_string(),
];
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
}
/// Copy keeps a baseline on one line in left-to-right order, columns column by column, the
/// ligature as its letters and the turned line in its row; Find matches what Copy reads, and its
/// rectangles cover the glyphs it names.
#[test]
fn text_extract_and_find_read_one_line_per_baseline() {
let dir = tmp("baseline");
std::fs::write(dir.join("copy.pdf"), copy_pdf()).unwrap();
let mut a = Automation::new().with_root(&dir).unwrap();
let doc = ok(&mut a, "doc_open", json!({ "path": "copy.pdf" }))["doc"].as_u64().unwrap();
let text = ok(&mut a, "text_extract", json!({ "doc": doc }));
assert_eq!(
text["pages"][0]["text"],
"01 office\n06 exam-\nple\n07 left one\nleft two\nright one\nright two\n08 first second\n11 hidden <- invisible word here\n12 SPACED\n13\nup\n14 split words"
);
let find = |a: &mut Automation, q: &str| ok(a, "text_find", json!({ "doc": doc, "query": q }));
let first_second = find(&mut a, "first second");
assert_eq!(first_second["count"], 1);
assert_eq!(first_second["matches"][0]["text"], "first second");
// One rectangle across both words, from the left edge of "first" (x = 90) past the right edge of "second".
let rects = first_second["matches"][0]["rects"].as_array().unwrap();
assert_eq!(rects.len(), 1, "{rects:?}");
let x = |k: usize| rects[0][k].as_f64().unwrap();
assert!((x(0) - 90.0).abs() < 0.5 && x(2) > 200.0, "{rects:?}");
let office = find(&mut a, "office");
assert_eq!(office["count"], 1);
assert_eq!(office["matches"][0]["text"], "office");
assert_eq!(find(&mut a, "hidden <- invisible")["count"], 1);
assert_eq!(find(&mut a, "left one right one")["count"], 0, "the columns stay apart");
let _ = std::fs::remove_dir_all(&dir);
}
+38 -3
View File
@@ -411,6 +411,12 @@ mod tests {
}
}
/// Byte offset of pixel (`x`, `y`) in a `w`-wide RGBA buffer, in wide integers so an image past 4 GiB
/// (its `y * w` or `* 4` would wrap a `u32`) still indexes the right pixel; past `usize` it is out of range.
fn rgba_index(x: u32, y: u32, w: u32) -> usize {
usize::try_from((u128::from(y) * u128::from(w) + u128::from(x)) * 4).unwrap_or(usize::MAX)
}
/// Visual compare: the regions where two renderings of a page differ, as pixel boxes
/// `[x0, y0, x1, y1]` (y down) in `a`'s image. Both images are RGBA; `b` is sampled at the
/// same relative position when sizes differ. Pixels count as different when a channel differs
@@ -421,6 +427,11 @@ pub fn visual_regions(a: (&[u8], u32, u32), b: (&[u8], u32, u32), tolerance: u8)
if wa == 0 || ha == 0 || wb == 0 || hb == 0 {
return Vec::new();
}
// Declared sizes the buffers cannot hold (a damaged render) would overflow the u32 index maths.
let holds = |px: &[u8], w: u32, h: u32| u128::from(w) * u128::from(h) * 4 <= px.len() as u128;
if !holds(pa, wa, ha) || !holds(pb, wb, hb) {
return Vec::new();
}
const CELL: u32 = 8;
let (gw, gh) = (wa.div_ceil(CELL), ha.div_ceil(CELL));
let mut grid = vec![false; (gw * gh) as usize];
@@ -428,9 +439,8 @@ pub fn visual_regions(a: (&[u8], u32, u32), b: (&[u8], u32, u32), tolerance: u8)
let yb = (y as u64 * hb as u64 / ha as u64) as u32;
for x in 0..wa {
let xb = (x as u64 * wb as u64 / wa as u64) as u32;
let ia = ((y * wa + x) * 4) as usize;
let ib = ((yb * wb + xb) * 4) as usize;
let (Some(ca), Some(cb)) = (pa.get(ia..ia + 3), pb.get(ib..ib + 3)) else { continue };
let (ia, ib) = (rgba_index(x, y, wa), rgba_index(xb, yb, wb));
let (Some(ca), Some(cb)) = (pa.get(ia..ia.saturating_add(3)), pb.get(ib..ib.saturating_add(3))) else { continue };
if ca.iter().zip(cb).any(|(p, q)| p.abs_diff(*q) > tolerance) {
grid[((y / CELL) * gw + x / CELL) as usize] = true;
}
@@ -500,3 +510,28 @@ mod visual_tests {
assert_eq!(visual_regions((&a, 200, 200), (&c, 200, 200), 16).len(), 2);
}
}
#[cfg(test)]
mod visual_hostile_tests {
use super::*;
#[test]
fn absurd_dimensions_with_short_buffers_return_quickly() {
let px = [0u8; 16];
let r = visual_regions((&px, u32::MAX, u32::MAX), (&px, 2, 2), 0);
assert!(r.is_empty());
}
#[test]
fn pixel_offsets_past_four_gib_do_not_wrap() {
// A 32768 x 32769 RGBA image is 4,295,098,368 bytes, so a buffer that large passes the size check;
// its last pixel sits past u32::MAX and must not wrap back onto an early pixel.
let (w, h) = (32768u32, 32769u32);
let last = rgba_index(w - 1, h - 1, w);
assert_eq!(last as u64, (u64::from(h - 1) * u64::from(w) + u64::from(w - 1)) * 4);
assert!(last as u64 > u64::from(u32::MAX));
assert_eq!(rgba_index(0, 0, w), 0);
// Out of range for any buffer: saturates instead of overflowing.
assert_eq!(rgba_index(u32::MAX, u32::MAX, u32::MAX), usize::MAX);
}
}
+10 -6
View File
@@ -67,8 +67,9 @@ pub struct Parsed {
/// Bytes that could not be read as operands or operators.
pub skipped: usize,
/// Inline images the renderer's tokenizer reads no image for (it stops, or finds no `EI` it
/// accepts). Their end here is a guess, so the operators after them may not be the ones the
/// page draws.
/// accepts), so their end here is a guess and the operators after them may not be the ones
/// the page draws; and images it reads whose parameters this parser can't, which are missing
/// from `ops` although the page draws them.
pub ambiguous_images: usize,
/// For [`Pieces::parse`]: per piece, whether a comment runs into it from the piece before
/// and whether one runs out of it into the next. Only boundaries are recorded, so the size
@@ -139,8 +140,8 @@ impl<'a> Ops<'a> {
self.skipped
}
/// Inline images read so far whose end is this parser's guess (as in
/// [`Parsed::ambiguous_images`]).
/// Inline images read so far that are ambiguous (as in [`Parsed::ambiguous_images`]): either
/// their end is this parser's guess, or the renderer reads them and this parser cannot.
pub fn ambiguous_images(&self) -> usize {
self.ambiguous_images
}
@@ -291,7 +292,8 @@ struct Inline {
image: Option<(Dict, Vec<u8>)>,
/// The position after its `EI` (or the end of the stream).
end: usize,
/// The renderer's tokenizer reads no image here, so `end` is this parser's guess.
/// The renderer's tokenizer reads no image here, so `end` is this parser's guess; or it reads
/// one whose parameters this parser can't, so `image` is `None` for an image the page draws.
ambiguous: bool,
}
@@ -339,7 +341,9 @@ fn inline_image(data: &[u8], bi: usize, pos: usize) -> Inline {
{
samples = rest;
}
return Inline { image: readable.then(|| (dict, samples.to_vec())), end, ambiguous: false };
// The renderer draws an image whose parameters this parser can't read: it is not
// represented here, so a redaction can't inspect or remove it and must not trust the page.
return Inline { image: readable.then(|| (dict, samples.to_vec())), end, ambiguous: !readable };
}
if !readable {
// Without an `EI` after it, the image runs to the end of the stream.
+10
View File
@@ -209,6 +209,16 @@ fn filtered_unknown_and_damaged_inline_images_end_where_the_renderer_ends_them()
}
}
/// The renderer reads an image whose parameters this parser can't (a stray operand before `ID`):
/// the page draws an image `ops` doesn't hold, so it is counted as ambiguous rather than dropped
/// silently.
#[test]
fn an_inline_image_read_by_the_renderer_but_not_here_is_ambiguous() {
let parsed = parse(b"q BI /W 1 /H 1 /BPC 8 /CS /G 7 ID \x80 EI Q");
assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::<Vec<_>>(), [b"q".as_slice(), b"Q"]);
assert_eq!(parsed.ambiguous_images, 1);
}
/// A zlib stream (one stored block) for 64 DeviceGray samples whose bytes hold " EI\n" at offset 41,
/// the way a stored deflate block copies incompressible samples verbatim (#3579).
const FLATE_WITH_EI: &str = "789c014000bfffed746159d50788f7ea604984cd08e00b4c8983207f56fe82c77ef8dfb8b9a3a586382045490a3120\
+2 -2
View File
@@ -20,8 +20,8 @@ let doc = Document::open(bytes)?; // Arc<Vec<u8>>
let doc = Document::open_with_password(bytes, Some("owner"))?; // encrypted files
let root = doc.root(); // Option<ObjRef> — the catalog
let obj = doc.get(ObjRef { num: 12, gen: 0 }); // load (and cache): Arc<Object>
let real = doc.resolve(&obj); // follow one indirect reference
let obj = doc.get(ObjRef::new(12, 0)); // load (and cache): Arc<Object>
let real = doc.resolve(&obj); // follow a bounded reference chain
let d = doc.dict(&obj); // Option<Dict>, resolving first
let data = stream.decoded()?; // filters applied on demand
let data = doc.with_direct_parms(&stream).decoded()?; // /DecodeParms given by reference too
+37
View File
@@ -209,6 +209,11 @@ impl ImageBudget {
self.refused
}
/// Charges `work` steps paid for out of another allowance, by a thread working on its own.
pub fn charge(&mut self, work: u64) {
self.work = self.work.saturating_sub(work);
}
/// The steps of decoding still allowed.
pub fn work_left(&self) -> u64 {
self.work
@@ -748,6 +753,38 @@ mod image_tests {
assert_eq!(budget.work_left(), u64::MAX);
}
/// PDFBOX-3932: an image and soft mask declared `/Predictor 15` that their writer never
/// applied, so their rows carry no PNG filter-type byte. Exported, they were "shorter than
/// their size says"; they are read as the samples they are, and tagged rows as before.
#[test]
fn an_image_whose_rows_carry_no_png_tags_decodes_to_its_samples() {
let doc = Document::new_empty();
let samples: Vec<u8> = (0..36u8).map(|i| 200 - i).collect();
let image = |height: Option<i64>, raw: Vec<u8>| {
let mut parms = Dict::new();
parms.set(b"Predictor".to_vec(), Object::Int(15));
parms.set(b"Colors".to_vec(), Object::Int(3));
parms.set(b"Columns".to_vec(), Object::Int(3));
let mut dict = Dict::new();
dict.set(b"Width".to_vec(), Object::Int(3));
if let Some(h) = height {
dict.set(b"Height".to_vec(), Object::Int(h));
}
dict.set(b"Filter".to_vec(), Object::name("FlateDecode"));
dict.set(b"DecodeParms".to_vec(), Object::Dict(parms));
Stream::from_raw(dict, raw)
};
let untagged = pdfcraft_filters::encode_flate(&samples);
let mut budget = ImageBudget::with_work(u64::MAX);
assert_eq!(budget.decode(&doc, &image(Some(4), untagged.clone()), 1 << 20).unwrap(), samples);
// Without a height to tell, the predictor is undone, as before.
assert_ne!(budget.decode(&doc, &image(None, untagged.clone()), 1 << 20).ok(), Some(samples.clone()));
assert_ne!(budget.decode(&doc, &image(Some(5), untagged), 1 << 20).ok(), Some(samples.clone()));
let p = pdfcraft_filters::Params { predictor: 15, colors: 3, columns: 3, ..pdfcraft_filters::Params::default() };
let tagged = pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, &p, &samples).unwrap();
assert_eq!(budget.decode(&doc, &image(Some(4), tagged), 1 << 20).unwrap(), samples);
}
/// The images of a file share its globals, so an image refused doesn't read them: this one's
/// can't be read, which is how to tell.
#[test]
+2
View File
@@ -48,6 +48,8 @@ pub struct Document {
/// `fill_xref_gaps`.
located: Arc<BTreeMap<u32, (u64, u16)>>,
repair_log: Arc<Vec<String>>,
/// See [`Self::rebuilt_unlinked_update`].
unlinked_update: bool,
/// What loading each file object gave, a failure as well: a damaged object is otherwise
/// loaded again on every read, and a member of an object stream that can't be decoded
/// decodes the stream again (`Document::load`).
+48 -20
View File
@@ -2,7 +2,8 @@ use std::collections::HashSet;
use super::reader::find;
use super::state::{
LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadState, Slot, SourceIdentity, loaded_bytes, note_overlay_read, objstm_member, overlay_reads,
LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadState, ScanLengths, Slot, SourceIdentity, loaded_bytes, loading, note_overlay_read, objstm_member,
overlay_reads,
};
use super::xref::{Revision, XrefEntry, XrefIndex};
use super::{
@@ -90,6 +91,13 @@ impl Document {
&self.repair_log
}
/// Whether the file was rebuilt from its object headers because its last update has no `/Prev`
/// and so cut its page tree off the cross-reference chain (ISO 32000-2 §7.5.6, §7.5.8.2). The
/// renderer rebuilds such a file under the same conditions, so the two read the same objects.
pub fn rebuilt_unlinked_update(&self) -> bool {
self.unlinked_update
}
/// Objects of the file that could not be read, lowest number first, as lines such as
/// "object 11 0 could not be read: syntax error at byte 512: ...". [`Self::get`] reads each
/// as null (ISO 32000-2 §7.3.10), so a document with one opens, but what it held is lost:
@@ -479,7 +487,14 @@ impl Document {
let data = if *work == 0 { Cow::Borrowed(&[][..]) } else { undo(before, work)? };
return pdfcraft_filters::decode_jbig2_within(&data, globals.as_deref().map(Vec::as_slice), columns, height, max, work).map_err(filter);
}
let Some(p) = self.fax_params(s) else { return s.decoded_spending(max, work) };
let Some(p) = self.fax_params(s) else {
// The last filter gives the image's rows: rows without the tags of the PNG predictor
// they declare are read as they are (`pdfcraft_filters::decode_image_tolerant_within`).
return match height {
Some(rows) => s.decoded_image_rows_spending(max, rows, work),
None => s.decoded_spending(max, work),
};
};
// As many rows as the image has: a /Rows below /Height would cut the picture short, and
// rows past /Height aren't drawn.
let p = pdfcraft_filters::CcittParams { rows: 0, ..p };
@@ -691,23 +706,7 @@ impl Document {
match self.entries.lookup(num, &self.data) {
None | Some(XrefEntry::Free { .. }) => Ok(Object::Null),
Some(XrefEntry::InFile { offset, .. }) => {
let resolve = |r: ObjRef| {
if self.overlay.contains_key(&r.num) {
note_overlay_read();
}
// A length resolved through this document's edits counts as an edit read
// on every reuse.
if let Some((known, edits)) = LENGTHS.with(|m| m.borrow().get(&r).copied()) {
if edits {
note_overlay_read();
}
return known;
}
let edits_read = overlay_reads();
let length = self.try_get_reference(r).ok().and_then(|o| o.as_int());
LENGTHS.with(|m| m.borrow_mut().insert(r, (length, overlay_reads() != edits_read)));
length
};
let resolve = |r: ObjRef| self.stream_length(r);
let off = offset as usize;
let at = |off: usize| match parse_indirect_shared(&self.data, off, &resolve, &self.endstreams) {
Ok((id, o)) if id.num == num => Some(self.decrypted(id, o)),
@@ -781,6 +780,29 @@ impl Document {
Ok(stm)
}
/// An indirect stream `/Length`, read through the loader's guard against cycles. Kept in
/// [`LENGTHS`] only during a load, which clears it when it ends: a scan of the file from
/// [`Self::objstm`] can come here without one.
fn stream_length(&self, r: ObjRef) -> Option<i64> {
if self.overlay.contains_key(&r.num) {
note_overlay_read();
}
let cached = loading();
// A length resolved through this document's edits counts as an edit read on every reuse.
if cached && let Some((known, edits)) = LENGTHS.with(|m| m.borrow().get(&r).copied()) {
if edits {
note_overlay_read();
}
return known;
}
let edits_read = overlay_reads();
let length = self.try_get_reference(r).ok().and_then(|o| o.as_int());
if cached {
LENGTHS.with(|m| m.borrow_mut().insert(r, (length, overlay_reads() != edits_read)));
}
length
}
/// Last-resort lookup: scan the file for `num G obj`. What isn't there is remembered: each miss
/// reads the whole file, and a file can refer to such an object from everywhere.
fn scan_for(&self, num: u32) -> Option<(ObjRef, Object)> {
@@ -791,6 +813,8 @@ impl Document {
super::tests::FILE_SCANS.with(|scans| scans.set(scans.get() + 1));
let needle = format!("{num} ");
let data = &self.data;
let budget = ScanLengths::enter();
let resolve = |r: ObjRef| if budget.take() { self.stream_length(r) } else { None };
let mut found = None;
let mut i = 0;
while let Some(p) = find(data, needle.as_bytes(), i, data.len()) {
@@ -798,7 +822,11 @@ impl Document {
if p > 0 && !is_whitespace(data[p - 1]) {
continue;
}
if let Ok((id, o)) = parse_indirect_shared(data, p, &|_| None, &self.endstreams)
// An indirect `/Length` is resolved, as at the object's offset, or a stream that ends
// in a misspelt `endstream` is searched on into a later object's. A header that parses
// as far as the `/Length` starts with `num` itself, after white space. A budget shared
// with the scans this leads to bounds how often the file is read again ([`ScanLengths`]).
if let Ok((id, o)) = parse_indirect_shared(data, p, &resolve, &self.endstreams)
&& id.num == num
{
found = Some((id, o)); // keep the last (newest) definition
+101 -11
View File
@@ -21,6 +21,7 @@ impl Document {
xref_oddities: Arc::default(),
located: Arc::default(),
repair_log: Arc::new(Vec::new()),
unlinked_update: false,
cache: Arc::default(),
objstms: Arc::default(),
unreadable: Arc::default(),
@@ -109,6 +110,7 @@ impl Document {
xref_oddities: Arc::default(),
located: Arc::default(),
repair_log: Arc::new(Vec::new()),
unlinked_update: false,
cache: Arc::default(),
objstms: Arc::default(),
unreadable: Arc::default(),
@@ -134,6 +136,8 @@ impl Document {
}
let parsed = doc.read_xref_chain(&mut log);
let mut authenticated = false;
// The newest section cut the page tree off from the chain (see `update_cuts_off_the_pages`).
let mut cut_off = false;
let ok = match parsed {
Ok((entries, trailer, revisions, oddities)) => {
doc.entries = Arc::new(entries);
@@ -147,17 +151,22 @@ impl Document {
doc.authenticate(&enc, &credentials)?;
authenticated = true;
}
doc.root_is_catalog()
let catalog = doc.root_is_catalog();
cut_off = catalog && doc.update_cuts_off_the_pages();
catalog && !cut_off
}
Err(e) => {
log.push(format!("cross-reference data unreadable ({e}); rebuilding from object headers"));
false
}
};
if cut_off {
log.push(UNLINKED_UPDATE.into());
doc.unlinked_update = true;
} else if !ok && !doc.entries.is_empty() {
log.push("cross-reference data does not lead to a valid catalog; rebuilding from object headers".into());
}
if !ok {
if !doc.entries.is_empty() {
log.push("cross-reference data does not lead to a valid catalog; rebuilding from object headers".into());
}
doc.reconstruct(&mut log)?;
}
// Header generations are plaintext, including in encrypted files. Inspect only the
@@ -182,6 +191,24 @@ impl Document {
Ok(doc)
}
/// Whether the chain of cross-reference sections ends at the newest one (no `/Prev`, or one
/// that isn't an offset) although the file holds earlier sections, and the catalog's `/Pages`
/// is not among the objects that one section reaches. ISO 32000-2 §7.5.6 and §7.5.8.2 require
/// the `/Prev`; without it the earlier revisions, page tree and all, are cut off, so the file
/// is rebuilt from its object headers as a damaged one is (the newest definition of each
/// object wins), and the repair log says why. (A file whose `/Root` itself is missing is
/// rebuilt anyway.)
fn update_cuts_off_the_pages(&self) -> bool {
if self.revisions.len() != 1 {
return false;
}
let Some(root) = self.root() else { return false };
let Ok(catalog) = self.try_get_reference(root) else { return false };
let Object::Dict(catalog) = &*catalog else { return false };
// The page tree first: the scan for an earlier `startxref` reads the whole file.
!catalog.get(b"Pages").is_some_and(|pages| matches!(&*self.resolve(pages), Object::Dict(_))) && earlier_sections(&self.data)
}
fn authenticate(&mut self, enc: &Object, credentials: &Credentials<'_>) -> Result<(), CosError> {
// Authentication can change how the same input is decoded.
self.source_identity = Arc::new(());
@@ -203,13 +230,24 @@ impl Document {
// Read here, while nothing is decrypted yet.
let members = self.encryption_members(enc);
let handler = match credentials {
Credentials::Password(password) => Arc::new(pdfcraft_crypt::SecurityHandler::open(params, &id0, *password).map_err(|e| match e {
pdfcraft_crypt::CryptError::WrongPassword if password.is_none() => CosError::NeedsPassword,
pdfcraft_crypt::CryptError::WrongPassword => CosError::WrongPassword,
other => CosError::Security(other.to_string()),
})?),
// The same encryption as the document whose key this is: that key opens it.
Credentials::Handler { handler, id0: known } if *handler.dict() == params && *known == id0 => Arc::clone(handler),
Credentials::Password(password) => match pdfcraft_crypt::SecurityHandler::open(params.clone(), &id0, *password) {
Ok(handler) => Arc::new(handler),
// No password, or the empty one: what only the embedded files need isn't asked.
Err(pdfcraft_crypt::CryptError::WrongPassword) => match self.without_password(&dict, params) {
Some(handler) if password.is_none_or(str::is_empty) => Arc::new(handler),
_ if password.is_none() => return Err(CosError::NeedsPassword),
_ => return Err(CosError::WrongPassword),
},
Err(other) => return Err(CosError::Security(other.to_string())),
},
// The same encryption as the document whose key this is: that key opens it. A handler
// without the key opens only what it would open by itself.
Credentials::Handler { handler, id0: known } if *handler.dict() == params && *known == id0 => {
if !handler.has_key() && !crate::security::efopen_only(self, &dict) {
return Err(CosError::NeedsPassword);
}
Arc::clone(handler)
}
Credentials::Handler { .. } => return Err(CosError::NeedsPassword),
};
self.security = Some(handler);
@@ -222,6 +260,46 @@ impl Document {
Ok(())
}
/// The handler for a document opened without a password that only its embedded files need
/// (see [`crate::security::efopen_only`]). Not a repair: ISO 32000-2 §7.6.6 has such a
/// document open so; [`Document::embedded_files_locked`] tells it.
fn without_password(&self, dict: &Dict, params: pdfcraft_crypt::EncryptDict) -> Option<pdfcraft_crypt::SecurityHandler> {
crate::security::efopen_only(self, dict).then(|| pdfcraft_crypt::SecurityHandler::without_key(params)).flatten()
}
/// A document opened without the password its embedded files need takes either password
/// later: with the file key its embedded files decrypt, so what was read as stored is read
/// again. Refused while an edited stream holds data read as stored, which the key would
/// encrypt a second time.
fn unlock_embedded_files(&mut self, password: &str) -> Result<(), CosError> {
let Some(current) = self.security.as_deref() else { return Ok(()) };
if self.overlay_entries().any(|(_, _, o)| matches!(o.as_deref(), Some(Object::Stream(s)) if self.reads_as_stored(s))) {
return Err(CosError::Security("an embedded file was edited before the password was given: save or undo that edit first".into()));
}
let handler = pdfcraft_crypt::SecurityHandler::open(current.dict().clone(), &self.first_id(), Some(password)).map_err(|e| match e {
pdfcraft_crypt::CryptError::WrongPassword => CosError::WrongPassword,
other => CosError::Security(other.to_string()),
})?;
self.security = Some(Arc::new(handler));
self.source_identity = Arc::new(());
self.cache = Arc::default();
self.objstms = Arc::default();
Ok(())
}
/// Whether the document opened without the password its embedded files need (`/AuthEvent
/// /EFOpen`): their data is held, and saved, as stored, and isn't to be read as the files.
pub fn embedded_files_locked(&self) -> bool {
self.security.as_deref().is_some_and(|h| !h.has_key())
}
/// Whether `s`, a stream of this document, holds encrypted data that reads as stored because the
/// document opened without the password its embedded files need (see
/// [`Document::embedded_files_locked`]). Such data isn't the file it encrypts.
pub fn reads_as_stored(&self, s: &crate::Stream) -> bool {
self.security.as_deref().is_some_and(|h| crate::security::stream_reads_as_stored(self, h, s))
}
/// The first file identifier, part of the encryption key (empty when there is none).
fn first_id(&self) -> Vec<u8> {
let id = self.trailer.get(b"ID").map(|id| self.resolve(id));
@@ -268,6 +346,9 @@ impl Document {
/// Both passwords unlock the same file key, so nothing read so far is decoded again.
pub fn unlock(&mut self, password: &str) -> Result<(), CosError> {
let Some(current) = self.security.as_deref() else { return Ok(()) };
if !current.has_key() {
return self.unlock_embedded_files(password);
}
if current.auth() == pdfcraft_crypt::Auth::Owner {
return Ok(());
}
@@ -411,3 +492,12 @@ impl Document {
}
}
}
/// The repair-log line for a file rebuilt because its last update has no `/Prev`.
const UNLINKED_UPDATE: &str = "the file's last update has no /Prev link to its earlier revisions, so their objects (the page tree among them) are unreachable through the cross-reference chain; rebuilding from object headers";
/// Whether `data` has more than one `startxref`: an update was appended to it, or it is
/// linearized (whose first-page section links to the main one).
fn earlier_sections(data: &[u8]) -> bool {
find(data, b"startxref", 0, data.len()).and_then(|at| find(data, b"startxref", at.saturating_add(9), data.len())).is_some()
}
+52
View File
@@ -11,6 +11,52 @@ thread_local! {
pub(super) static LENGTHS: std::cell::RefCell<HashMap<ObjRef, (Option<i64>, bool)>> = std::cell::RefCell::new(HashMap::new());
/// Unsaved edits read on this thread (`Document::try_get`, and a `/Length` in `Document::load`).
static OVERLAY_READS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
/// How deep `Document::scan_for` is nested on this thread, and how many indirect `/Length`
/// values the outermost scan and those inside it have resolved ([`ScanLengths`]).
static SCAN_LENGTHS: std::cell::Cell<(u32, u32)> = const { std::cell::Cell::new((0, 0)) };
}
/// The indirect `/Length` values a scan of the file for an object may resolve, shared with the
/// scans it leads to. Each header in the file that starts with the object's number is parsed, and
/// a `/Length` that names an object the cross-reference table cannot place scans the file for that
/// one: a damaged file with many such headers, each naming another missing object, would read the
/// whole file once per header. Past the budget a `/Length` is left unresolved, and the stream is
/// read as before resolution, by its `endstream`.
pub(super) struct ScanLengths;
impl ScanLengths {
/// A real object is defined once per revision, and its `/Length` once per definition.
pub(super) const MAX: u32 = 32;
/// Marks a scan as running; the outermost one starts the budget afresh.
pub(super) fn enter() -> Self {
SCAN_LENGTHS.with(|c| {
let (depth, used) = c.get();
c.set((depth.saturating_add(1), if depth == 0 { 0 } else { used }));
});
ScanLengths
}
/// Whether one more `/Length` may be resolved, counting it if so.
pub(super) fn take(&self) -> bool {
SCAN_LENGTHS.with(|c| {
let (depth, used) = c.get();
if used >= Self::MAX {
return false;
}
c.set((depth, used.saturating_add(1)));
true
})
}
}
impl Drop for ScanLengths {
fn drop(&mut self) {
SCAN_LENGTHS.with(|c| {
let (depth, used) = c.get();
c.set((depth.saturating_sub(1), used));
});
}
}
/// Unsaved edits read so far on this thread: a load during which this changes depended on one.
@@ -42,6 +88,12 @@ impl LoadGuard {
}
}
/// Whether an object is being loaded on this thread: [`LENGTHS`] holds only while one is, since
/// it is cleared when the outermost load ends.
pub(super) fn loading() -> bool {
LOADING.with(|l| !l.borrow().is_empty())
}
/// What loading a file object gave, kept in the document's cache either way.
pub(super) type Loaded = Result<Arc<Object>, CosError>;
+128
View File
@@ -30,6 +30,67 @@ fn what_a_scan_of_the_file_did_not_find_is_remembered() {
assert_eq!(FILE_SCANS.with(std::cell::Cell::get), 1, "a clone has the same file");
}
/// An object found by a scan of the file, because its cross-reference offset is wrong, has its
/// indirect `/Length` resolved as at the right offset. Unresolved, a stream ending in a misspelt
/// `endstream` (`endstrea`) was searched on into a later object's `endstream`, and took that
/// object's header and data as its own.
#[test]
fn a_scanned_stream_resolves_its_indirect_length() {
let mut out = build(
&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [] /Count 0 >>",
"<< /Length 4 0 R >>\nstream\nHELLO\nendstrea",
"5",
"<< /Length 5 >>\nstream\nWORLD\nendstream",
],
"/Root 1 0 R",
);
// Point object 3's entry at the catalog, so only the scan finds it.
let at = |needle: &[u8], out: &[u8]| out.windows(needle.len()).position(|w| w == needle).unwrap();
let catalog = format!("{:010} 00000 n ", at(b"1 0 obj", &out));
let entry = at(format!("{:010} 00000 n ", at(b"3 0 obj", &out)).as_bytes(), &out);
out.splice(entry..entry + catalog.len(), catalog.bytes());
let doc = Document::open(Arc::new(out)).unwrap();
let stream = doc.try_get(3).unwrap();
let Object::Stream(s) = &*stream else { panic!("stream expected: {stream:?}") };
assert_eq!(&s.raw[..], b"HELLO");
}
/// A scan of the file resolves only so many indirect `/Length` values. Each header that starts
/// with the sought number is parsed, and a `/Length` naming an object the table cannot place scans
/// the file for that one: with a hundred such headers, each naming another misplaced object, one
/// lookup read the whole file a hundred and one times.
#[test]
fn a_scan_of_the_file_resolves_a_bounded_number_of_lengths() {
const HEADERS: u32 = 100;
let mut out = b"%PDF-1.7\n".to_vec();
let catalog = out.len();
out.extend_from_slice(b"1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n");
let pages = out.len();
out.extend_from_slice(b"2 0 obj << /Type /Pages /Kids [] /Count 0 >> endobj\n");
// Object 3 is defined only by headers inside this string, each with its own misplaced /Length.
out.extend_from_slice(b"4 0 obj (\n");
for k in 0..HEADERS {
out.extend_from_slice(format!("3 0 obj << /Length {} 0 R >> stream\nX\nendstream endobj\n", 10 + k).as_bytes());
}
out.extend_from_slice(b") endobj\n");
let size = 10 + HEADERS;
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f \n").as_bytes());
for num in 1..size {
let at = if num == 2 { pages } else { catalog };
out.extend_from_slice(format!("{at:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer << /Size {size} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
let doc = Document::open(Arc::new(out)).unwrap();
FILE_SCANS.with(|scans| scans.set(0));
let three = doc.try_get(3).unwrap();
assert!(matches!(&*three, Object::Stream(_)), "{three:?}");
let scans = FILE_SCANS.with(std::cell::Cell::get);
assert!(scans <= 1 + super::state::ScanLengths::MAX as usize, "{scans} scans of the file");
}
/// An object can be nothing but a reference to another. A chain of them is followed, and one that
/// goes round ends in what isn't a dictionary or a name, without hanging.
#[test]
@@ -276,6 +337,10 @@ fn fax_images_decode_with_their_parameters() {
let mut understated = parms.clone();
understated.set(b"Rows".to_vec(), Object::Int(1));
assert_eq!(image(&doc, Some(Object::Dict(understated.clone())), 10, 3).unwrap(), bits, "/Height rows, whatever /Rows says");
// More /Rows than /Height (PDFBOX-771: 1,084,975,104), as the renderer reads it too.
let mut overstated = parms.clone();
overstated.set(b"Rows".to_vec(), Object::Int(1_000_000_000));
assert_eq!(image(&doc, Some(Object::Dict(overstated)), 10, 3).unwrap(), bits, "/Height rows, not /Rows past it");
// The parameters as the file gives them, /Rows included, and Table 11's defaults.
let mut d = Dict::new();
d.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode"));
@@ -2190,6 +2255,69 @@ fn the_editor_cache_charges_a_dictionary_for_the_values_it_holds() {
assert_eq!(cache.owned(), sum, "the kept total is the cache's own");
}
/// The width (`/MediaBox` x1) of each of `pages`, in order.
fn page_widths(pages: &[Dict]) -> Vec<Option<i64>> {
pages.iter().map(|p| p.get(b"MediaBox").and_then(Object::as_array).and_then(|b| b.get(2)).and_then(Object::as_int)).collect()
}
/// The pages of `doc` in page-tree order, as the dictionaries the document holds.
fn page_tree_pages(doc: &Document) -> Vec<Dict> {
let catalog = doc.get(doc.root().expect("root"));
let pages = doc.resolve(catalog.as_dict().and_then(|c| c.get(b"Pages")).expect("/Pages"));
let kids = pages.as_dict().and_then(|p| p.get(b"Kids")).and_then(Object::as_array).cloned().unwrap_or_default();
kids.iter().filter_map(|k| doc.resolve(k).as_dict().cloned()).collect()
}
/// ISO 32000-2 §7.5.6 and §7.5.8.2: an update reaches the revisions before it through `/Prev`.
/// One without it cuts them off, page tree and all. The file is rebuilt from its object headers
/// as a damaged one is, so its page tree is found, the newest definition of each object wins, and
/// the repair log says why; a save rewrites it in full, with every page.
#[test]
fn an_update_without_prev_is_rebuilt_with_its_page_tree_and_a_repair_note() {
let doc = Document::open(Arc::new(pdfcraft_testkit::update_without_prev(false))).expect("the unlinked file opens");
let pages = page_tree_pages(&doc);
assert_eq!(pages.len(), 3);
assert!(pages.first().is_some_and(|p| p.contains(b"Annots")), "the update's page wins: {pages:?}");
assert!(doc.reconstructed() && doc.rebuilt_unlinked_update());
assert!(doc.repair_log().iter().any(|l| l.contains("no /Prev")), "{:?}", doc.repair_log());
let catalog = doc.get(doc.root().expect("root"));
assert!(catalog.as_dict().is_some_and(|c| c.contains(b"Lang")), "the update's catalog wins");
let saved = crate::write_incremental(&doc, &crate::SaveOptions::default()).expect("saves");
let reopened = Document::open(Arc::new(saved)).expect("the saved file opens");
let pages = page_tree_pages(&reopened);
assert_eq!(page_widths(&pages), [Some(200), Some(300), Some(400)], "every page is saved, in order");
assert!(pages.first().is_some_and(|p| p.contains(b"Annots")), "the update's page is saved: {pages:?}");
let catalog = reopened.get(reopened.root().expect("root"));
assert!(catalog.as_dict().is_some_and(|c| c.contains(b"Lang")), "the update's catalog is saved");
assert!(reopened.repair_log().is_empty(), "{:?}", reopened.repair_log());
// The same file with `/Prev` reads through its chain, as before.
let linked = Document::open(Arc::new(pdfcraft_testkit::update_without_prev(true))).expect("the linked file opens");
assert_eq!(page_tree_pages(&linked).len(), 3);
assert!(!linked.reconstructed() && !linked.rebuilt_unlinked_update());
assert_eq!(linked.revisions().len(), 2);
assert!(linked.repair_log().is_empty(), "{:?}", linked.repair_log());
}
/// A hybrid update (ISO 32000-2 §7.5.8.4) without `/Prev`: its `/XRefStm` stream belongs to the
/// update, so the earlier revision is cut off just the same, and the file is rebuilt.
#[test]
fn a_hybrid_update_without_prev_is_rebuilt_too() {
let doc = Document::open(Arc::new(pdfcraft_testkit::hybrid_update_without_prev(false))).expect("the unlinked file opens");
let pages = page_tree_pages(&doc);
assert_eq!(pages.len(), 3);
assert!(pages.first().is_some_and(|p| p.contains(b"Annots")), "the update's page wins: {pages:?}");
assert!(doc.rebuilt_unlinked_update(), "{:?}", doc.repair_log());
assert!(doc.repair_log().iter().any(|l| l.contains("no /Prev")), "{:?}", doc.repair_log());
let catalog = doc.get(doc.root().expect("root"));
assert!(catalog.as_dict().is_some_and(|c| c.contains(b"Lang")), "the update's catalog wins");
let linked = Document::open(Arc::new(pdfcraft_testkit::hybrid_update_without_prev(true))).expect("the linked file opens");
assert_eq!(page_tree_pages(&linked).len(), 3);
assert!(!linked.rebuilt_unlinked_update() && linked.repair_log().is_empty(), "{:?}", linked.repair_log());
assert_eq!(linked.revisions().len(), 2);
}
#[test]
fn find_agrees_with_a_plain_window_search() {
let naive = |hay: &[u8], needle: &[u8], from: usize, to: usize| {
+2 -2
View File
@@ -25,12 +25,12 @@ mod writer;
pub use budget::{DecodeBudget, ImageBudget, MAX_PAGE_DECODED, MAX_PAGE_FORMS, MAX_REDACTED_IMAGE_PIXELS};
pub use bytes::Bytes;
pub use document::{Document, ReadState, Revision, SourceIdentity, XrefEntry};
pub use object::{Dict, MAX_DECODED, MAX_JBIG2_GLOBALS, Name, ObjRef, Object, PdfString, Stream};
pub use object::{Dict, MAX_DECODED, MAX_JBIG2_GLOBALS, Name, ObjRef, Object, PdfString, Stream, TextChars};
pub use parser::{Lexer, is_whitespace, parse_indirect};
pub use pdfcraft_crypt::{
Algorithm, Auth, Method as CryptMethod, NewEncryption, Permissions, SecurityHandler, effective_password, pdfdoc_byte, pdfdoc_char,
};
pub use writer::{SaveOptions, pdf_date, reachable_objects, serialize, write_full, write_incremental};
pub use writer::{SaveOptions, civil_from_days, new_file_id, pdf_date, reachable_objects, serialize, write_full, write_incremental};
#[derive(Debug, thiserror::Error, Clone, PartialEq)]
pub enum CosError {
+124 -9
View File
@@ -57,19 +57,90 @@ impl PdfString {
/// non-ASCII bytes (written by some producers) is read as UTF-8 rather than as
/// PDFDocEncoding mojibake, and stray leading U+FEFF (doubled BOMs) are removed.
pub fn to_text(&self) -> String {
let s = match self.bytes.as_slice() {
self.text_chars().collect()
}
/// The characters [`PdfString::to_text`] decodes, one at a time, without building a string.
pub fn text_chars(&self) -> TextChars<'_> {
let decode = match self.bytes.as_slice() {
[0xFE, 0xFF, rest @ ..] => {
let units: Vec<u16> = rest.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect();
String::from_utf16_lossy(&units)
let units: fn(&[u8; 2]) -> u16 = |c| u16::from_be_bytes(*c);
Decode::Utf16(char::decode_utf16(rest.as_chunks::<2>().0.iter().map(units)))
}
[0xEF, 0xBB, 0xBF, rest @ ..] => String::from_utf8_lossy(rest).into_owned(),
[0xEF, 0xBB, 0xBF, rest @ ..] => Decode::Utf8Lossy { chunks: rest.utf8_chunks(), valid: "".chars(), replace: false },
b => match std::str::from_utf8(b) {
// Pure ASCII is identical either way; only non-ASCII UTF-8 needs the switch.
Ok(utf8) if !b.is_ascii() => utf8.to_owned(),
_ => b.iter().map(|&c| pdfcraft_crypt::pdfdoc_char(c)).collect(),
Ok(utf8) if !b.is_ascii() => Decode::Utf8(utf8.chars()),
_ => Decode::PdfDoc(b.iter()),
},
};
if s.starts_with('\u{FEFF}') { s.trim_start_matches('\u{FEFF}').to_owned() } else { s }
TextChars { decode, leading: true }
}
/// Whether the string decodes to `s` ([`PdfString::to_text`]), compared without allocating.
pub fn text_eq(&self, s: &str) -> bool {
self.text_chars().eq(s.chars())
}
}
/// The characters of a text string: [`PdfString::text_chars`].
pub struct TextChars<'a> {
decode: Decode<'a>,
/// Still at the start, where stray U+FEFF (doubled BOMs) are dropped.
leading: bool,
}
/// The UTF-16BE code units after a BOM (an odd last byte is left out).
type Utf16Units<'a> = std::iter::Map<std::slice::Iter<'a, [u8; 2]>, fn(&[u8; 2]) -> u16>;
enum Decode<'a> {
Utf16(std::char::DecodeUtf16<Utf16Units<'a>>),
/// After a UTF-8 BOM: each invalid sequence is one U+FFFD, as `String::from_utf8_lossy` has it.
Utf8Lossy {
chunks: std::str::Utf8Chunks<'a>,
valid: std::str::Chars<'a>,
replace: bool,
},
Utf8(std::str::Chars<'a>),
PdfDoc(std::slice::Iter<'a, u8>),
}
impl Iterator for Decode<'_> {
type Item = char;
fn next(&mut self) -> Option<char> {
match self {
Decode::Utf16(units) => units.next().map(|u| u.unwrap_or(char::REPLACEMENT_CHARACTER)),
Decode::Utf8Lossy { chunks, valid, replace } => loop {
if let Some(c) = valid.next() {
return Some(c);
}
if *replace {
*replace = false;
return Some(char::REPLACEMENT_CHARACTER);
}
let chunk = chunks.next()?;
*valid = chunk.valid().chars();
*replace = !chunk.invalid().is_empty();
},
Decode::Utf8(chars) => chars.next(),
Decode::PdfDoc(bytes) => bytes.next().map(|&c| pdfcraft_crypt::pdfdoc_char(c)),
}
}
}
impl Iterator for TextChars<'_> {
type Item = char;
fn next(&mut self) -> Option<char> {
loop {
let c = self.decode.next()?;
if self.leading && c == '\u{FEFF}' {
continue;
}
self.leading = false;
return Some(c);
}
}
}
@@ -280,10 +351,12 @@ impl Stream {
}
/// Decoded data, tolerating truncated or corrupt encodings the way viewers do (the data
/// decoded before the damage is returned). Streams with image codecs fail with
/// decoded before the damage is returned). Decoding past
/// [`crate::budget::MAX_PAGE_DECODED`] (256 MiB), the most the renderer reads from one
/// stream, is an error. Streams with image codecs fail with
/// `CosError::Filter` (keep them encoded). Use `decoded_strict` to detect damage.
pub fn decoded(&self) -> Result<Vec<u8>, CosError> {
self.decoded_within(MAX_DECODED)
self.decoded_within(crate::budget::MAX_PAGE_DECODED)
}
/// Like [`Stream::decoded`], for a stream whose decoded size has a tighter bound than
@@ -319,6 +392,14 @@ impl Stream {
pdfcraft_filters::decode_tolerant_within(&chain, &self.raw, max.min(MAX_DECODED), work).map_err(filter)
}
/// [`Stream::decoded_spending`] for the samples of an image `rows` high (its `/Height`):
/// [`pdfcraft_filters::decode_image_tolerant_within`].
pub(crate) fn decoded_image_rows_spending(&self, max: usize, rows: u32, work: &mut u64) -> Result<Vec<u8>, CosError> {
let filter = |e: pdfcraft_filters::FilterError| CosError::Filter(e.to_string());
let chain = self.readable_filters().map_err(filter)?;
pdfcraft_filters::decode_image_tolerant_within(&chain, &self.raw, max.min(MAX_DECODED), rows, work).map_err(filter)
}
/// The stream decoded through its filters by `decode`, or copied when it has none, within
/// `max` and [`MAX_DECODED`].
fn decode_chain(
@@ -594,6 +675,33 @@ mod tests {
assert_eq!(PdfString::literal(Vec::new()).to_text(), "");
}
#[test]
fn text_eq_compares_what_to_text_decodes() {
let utf16 = |s: &str| [0xFE, 0xFF].into_iter().chain(s.encode_utf16().flat_map(u16::to_be_bytes)).collect::<Vec<u8>>();
// Each invalid UTF-8 sequence is one U+FFFD, as String::from_utf8_lossy has it.
let lossy = [0xEF, 0xBB, 0xBF, 0xC3, 0x28, b'x', 0xF0, 0x9F, 0x92, 0xFF];
assert_eq!(PdfString::literal(lossy.to_vec()).to_text(), String::from_utf8_lossy(&lossy[3..]));
let cases: Vec<Vec<u8>> = vec![
Vec::new(),
b"Plain".to_vec(),
vec![b'C', b'a', b'f', 0xE9, 0x18],
"目录".as_bytes().to_vec(),
utf16("第一章"),
vec![0xFE, 0xFF, 0xD8, 0x00, 0x00, b'A'],
vec![0xFE, 0xFF, 0xFE, 0xFF, 0x00, b'A', 0xFE, 0xFF],
lossy.to_vec(),
];
for bytes in cases {
let s = PdfString::literal(bytes);
let text = s.to_text();
assert!(s.text_eq(&text), "{text:?}");
assert!(!s.text_eq(&format!("{text}x")), "{text:?}");
assert!(text.is_empty() || !s.text_eq(&text[..text.len() - text.chars().last().map_or(0, char::len_utf8)]), "{text:?}");
}
// A U+FEFF after the first character is text and stays.
assert!(PdfString::literal(utf16("A\u{FEFF}")).text_eq("A\u{FEFF}"));
}
#[test]
fn pdfdoc_encoding_high_range() {
assert_eq!(PdfString::literal(vec![0x84, 0x92, 0xA0, b'A']).to_text(), "—™€A");
@@ -674,6 +782,13 @@ mod tests {
assert_eq!(stream.decoded_strict().unwrap(), b"A");
}
#[test]
fn decoded_stops_at_renderer_cap_of_256_mib() {
let data = vec![0u8; (256 << 20) + 1];
let stream = Stream::flate(Dict::new(), &data);
assert!(matches!(stream.decoded(), Err(CosError::Filter(_))));
}
#[test]
fn first_filter_decodes_only_the_outer_wrapper() {
let data = "0 0 m 100 100 l S\n".repeat(64).into_bytes();
+183 -20
View File
@@ -437,7 +437,7 @@ pub(crate) type EndstreamIndex = std::sync::OnceLock<Vec<usize>>;
fn endstream_offsets(data: &[u8]) -> Vec<usize> {
#[cfg(test)]
ENDSTREAM_SCAN.with(|c| c.set(c.get() + data.len()));
data.windows(9).enumerate().filter(|(_, w)| *w == b"endstream").map(|(i, _)| i).collect()
data.windows(9).enumerate().filter(|(i, w)| *w == b"endstream" && endstream_at(data, *i)).map(|(i, _)| i).collect()
}
fn parse_indirect_in(
@@ -491,25 +491,28 @@ fn parse_indirect_in(
Some(Object::Ref(r)) => resolve_length(*r),
_ => None,
};
let valid = |len: i64| -> bool {
if len < 0 {
return false;
}
let end = start.saturating_add(len as usize);
if end > data.len() {
return false;
}
let mut after = Lexer::new(data, end);
after.eat_keyword(b"endstream")
// A /Length that fits in the file (not negative, and no wrap on a 32-bit target).
let fits = declared.and_then(|l| usize::try_from(l).ok()).filter(|l| start.checked_add(*l).is_some_and(|end| end <= data.len()));
// What follows the /Length bytes, white space aside.
let after = |len: usize| {
let mut after = Lexer::new(data, start.saturating_add(len));
after.skip_ws();
data.get(after.pos..).unwrap_or_default()
};
let len = match declared {
Some(l) if valid(l) => l as usize,
// Wrong or missing /Length: search for "endstream" (§7.3.8.1 recovery).
_ => {
find_endstream(data, start, endstreams)
.ok_or_else(|| CosError::Syntax { offset: start, detail: "stream without endstream".into() })?
- start
}
let len = match fits {
// The /Length ends at `endstream`, or where it would be: misspelt (`endstrea`), or
// left out before `endobj` or the end of the file. The stream is the bytes its /Length
// gives, "how many bytes of the PDF file are used for the stream's data" (ISO 32000-2
// §7.3.8.2), as other readers take it. A producer that wrote `endstrea` after every
// stream lost every font and every page's content, and searching on would run into a
// later object's `endstream`.
Some(l) if at_stream_end(after(l)) => l,
// Wrong or missing /Length: search for "endstream" (§7.3.8.1 recovery). With none
// left in the file, a /Length that fits is still the stream's extent.
_ => match find_endstream(data, start, endstreams) {
Some(end) => end - start,
None => fits.ok_or_else(|| CosError::Syntax { offset: start, detail: "stream without endstream".into() })?,
},
};
let range = start..start + len;
let raw = match shared {
@@ -524,6 +527,45 @@ fn parse_indirect_in(
Ok((id, obj))
}
/// Whether `rest`, what follows a stream's /Length bytes with white space skipped, is where the
/// stream ends. A whole `endstream` token is: a token runs on through regular characters (ISO
/// 32000-2 §7.2.3), so payload such as `endstreamer` is not one. A misspelt `endstream`
/// (`endstrea`), an `endobj` with the `endstream` left out, or both, end it only where the object
/// ends as well, before the end of the data, another object's `N G obj` header, or `xref`,
/// `trailer` or `startxref`: payload such as `endobj more` or `endobject`, where a wrong /Length
/// stops, is not, and the search for `endstream` runs instead. So does anything else but the end
/// of the data.
fn at_stream_end(rest: &[u8]) -> bool {
let mut lx = Lexer::new(rest, 0);
if lx.eat_keyword(b"endstream") {
return true;
}
let misspelt = lx.eat_keyword(b"endstrea");
let endobj = lx.eat_keyword(b"endobj");
lx.skip_ws();
if lx.pos >= rest.len() {
return true;
}
if !(misspelt || endobj) {
return false;
}
if [&b"xref"[..], b"trailer", b"startxref"].iter().any(|kw| Lexer::new(rest, lx.pos).eat_keyword(kw)) {
return true;
}
let number = |lx: &mut Lexer| {
lx.skip_ws();
let t = lx.token();
!t.is_empty() && t.iter().all(u8::is_ascii_digit)
};
number(&mut lx) && number(&mut lx) && lx.eat_keyword(b"obj")
}
/// Whether a whole `endstream` token starts at `at` in `data`: one followed by a regular
/// character (`endstreamer`) is payload (ISO 32000-2 §7.2.3).
fn endstream_at(data: &[u8], at: usize) -> bool {
data.get(at..).is_some_and(|d| d.starts_with(b"endstream")) && !data.get(at.saturating_add(9)).copied().is_some_and(is_regular)
}
/// Offset where stream data ends (before the EOL preceding "endstream").
fn find_endstream(data: &[u8], from: usize, endstreams: Option<&EndstreamIndex>) -> Option<usize> {
let at = match endstreams {
@@ -533,7 +575,7 @@ fn find_endstream(data: &[u8], from: usize, endstreams: Option<&EndstreamIndex>)
}
None => {
let hay = data.get(from..)?;
let found = hay.windows(9).position(|w| w == b"endstream");
let found = hay.windows(9).enumerate().position(|(i, w)| w == b"endstream" && endstream_at(hay, i));
#[cfg(test)]
ENDSTREAM_SCAN.with(|c| c.set(c.get() + found.map_or(hay.len(), |p| p + 9)));
found? + from
@@ -660,6 +702,127 @@ mod tests {
assert_eq!(s.dict.int(b"Length"), Some(5));
}
/// With no `endstream` left in the file, as a producer that writes `endstrea` leaves every
/// stream, a stream is the bytes its /Length gives, "how many bytes of the PDF file are used
/// for the stream's data" (ISO 32000-2 §7.3.8.2): it was an error, and the object null, so
/// such a file lost every font and page content. A /Length past the file, or none, is still
/// no stream.
#[test]
fn indirect_stream_without_endstream_is_its_length() {
for end in [&b"\r\nendstrea\rendobj\r\n"[..], b"\nendobj\n", b""] {
let mut data = b"7 0 obj\n<< /Length 5 >>\nstream\r\nHELLO".to_vec();
data.extend_from_slice(end);
let (_, o) = parse_indirect(&data, 0, &|_| None).unwrap();
let Object::Stream(s) = o else { panic!("stream expected") };
assert_eq!(&s.raw[..], b"HELLO", "{end:?}");
}
assert!(parse_indirect(b"7 0 obj\n<< /Length 6 >>\nstream\nHELLO", 0, &|_| None).is_err(), "past the file");
assert!(parse_indirect(b"7 0 obj\n<< >>\nstream\nHELLO\nendstrea\nendobj\n", 0, &|_| None).is_err(), "no /Length");
}
/// A stream ending in `endstrea` is its /Length even when a later object does have an
/// `endstream`: the search for one would otherwise run on into that object and take its
/// header and data as this stream's.
#[test]
fn a_stream_without_endstream_is_not_read_into_a_later_one() {
let data = b"7 0 obj\n<< /Length 5 >>\nstream\r\nHELLO\r\nendstrea\rendobj\r\n8 0 obj\n<< /Length 5 >>\nstream\nWORLD\nendstream\nendobj\n";
let second = data.windows(7).position(|w| w == b"8 0 obj").unwrap();
for (offset, want) in [(0, &b"HELLO"[..]), (second, b"WORLD")] {
let (_, o) = parse_indirect(data, offset, &|_| None).unwrap();
let Object::Stream(s) = o else { panic!("stream expected at {offset}") };
assert_eq!(&s.raw[..], want, "at {offset}");
}
// Through the loader's shared index of `endstream` offsets as well.
let index = EndstreamIndex::new();
let (_, o) = parse_indirect_in(data, None, 0, &|_| None, Some(&index)).unwrap();
let Object::Stream(s) = o else { panic!("stream expected") };
assert_eq!(&s.raw[..], b"HELLO");
}
/// The same for a stream whose `endstream` is left out before `endobj`: the later object's
/// `endstream` is not this stream's end.
#[test]
fn a_stream_ending_at_endobj_is_not_read_into_a_later_one() {
let data = b"7 0 obj\n<< /Length 5 >>\nstream\nHELLO\nendobj\n8 0 obj\n<< /Length 5 >>\nstream\nWORLD\nendstream\nendobj\n";
let (_, o) = parse_indirect(data, 0, &|_| None).unwrap();
let Object::Stream(s) = o else { panic!("stream expected") };
assert_eq!(&s.raw[..], b"HELLO");
}
/// A wrong /Length that stops before payload bytes beginning like an end marker (`endobject`,
/// `endstreak`, `endstreamer`) is not taken for the stream's end: a token runs on through
/// regular characters (ISO 32000-2 §7.2.3), so the search for `endstream` finds the whole
/// payload, and skips `endstreamer` too, by itself and through the loader's shared index.
#[test]
fn a_length_ending_inside_a_longer_word_is_not_an_end_marker() {
for word in ["endobject", "endstreak", "endstreamer"] {
let pdf = format!("7 0 obj\n<< /Length 5 >>\nstream\nHELLO {word} more\nendstream\nendobj\n");
let (_, o) = parse_indirect(pdf.as_bytes(), 0, &|_| None).unwrap();
let Object::Stream(s) = o else { panic!("stream expected") };
assert_eq!(s.raw[..], *format!("HELLO {word} more").as_bytes(), "{word}");
let index = EndstreamIndex::new();
let (_, o) = parse_indirect_in(pdf.as_bytes(), None, 0, &|_| None, Some(&index)).unwrap();
let Object::Stream(s) = o else { panic!("stream expected") };
assert_eq!(s.raw[..], *format!("HELLO {word} more").as_bytes(), "{word}, indexed");
}
// Whole markers still end it, followed by white space, a delimiter or the end of the data.
for tail in ["endobj\n", "endstrea%", "endobj"] {
let pdf = format!("7 0 obj\n<< /Length 5 >>\nstream\nHELLO\n{tail}");
let (_, o) = parse_indirect(pdf.as_bytes(), 0, &|_| None).unwrap();
let Object::Stream(s) = o else { panic!("stream expected") };
assert_eq!(&s.raw[..], b"HELLO", "{tail:?}");
}
}
/// A whole `endobj` or `endstrea` token where a wrong /Length stops is payload, not the
/// stream's end, unless the object ends there too: what follows is the end of the data,
/// another object's header, or `xref`, `trailer` or `startxref`. Otherwise the search for
/// `endstream` finds the whole payload.
#[test]
fn an_end_marker_inside_the_payload_does_not_end_the_stream() {
for payload in ["endobj more", "endstrea more", "endstrea endobj more", "endobj 12 more"] {
let pdf = format!("7 0 obj\n<< /Length 5 >>\nstream\nHELLO {payload}\nendstream\nendobj\n");
let (_, o) = parse_indirect(pdf.as_bytes(), 0, &|_| None).unwrap();
let Object::Stream(s) = o else { panic!("stream expected") };
assert_eq!(s.raw[..], *format!("HELLO {payload}").as_bytes(), "{payload}");
}
// Where the object does end, the /Length is the stream, also with an `endstream` later.
for next in ["8 0 obj\n<< >>", "%c\n12 0 obj", "xref", "trailer", "startxref"] {
for marker in ["endobj", "endstrea\nendobj", "endstrea"] {
let pdf = format!("7 0 obj\n<< /Length 5 >>\nstream\nHELLO\n{marker}\n{next}\nstream\nWORLD\nendstream\nendobj\n");
let (_, o) = parse_indirect(pdf.as_bytes(), 0, &|_| None).unwrap();
let Object::Stream(s) = o else { panic!("stream expected") };
assert_eq!(&s.raw[..], b"HELLO", "{marker:?} then {next:?}");
}
}
}
/// Without an `endstream`, the /Length is trusted as written when it fits, also when it is
/// wrong, and an indirect one is resolved first; one that doesn't fit, a negative one and one
/// too large for any file are errors rather than wrapped into a short read.
#[test]
fn a_stream_without_endstream_takes_its_length_only_when_it_fits() {
let stream = |length: &str, tail: &str| format!("7 0 obj\n<< /Length {length} >>\nstream\nHELLO{tail}");
let raw = |pdf: &str, lookup: &dyn Fn(ObjRef) -> Option<i64>| match parse_indirect(pdf.as_bytes(), 0, lookup) {
Ok((_, Object::Stream(s))) => Ok(s.raw.to_vec()),
Ok((_, other)) => panic!("{pdf:?} is {other:?}"),
Err(e) => Err(e),
};
let none = |_: ObjRef| None;
// Wrong but fitting: the bytes it gives, not the bytes up to `endstrea`.
assert_eq!(raw(&stream("3", "\nendstrea\nendobj\n"), &none).unwrap(), b"HEL");
// Exactly to the end of the file, and nothing at all.
assert_eq!(raw(&stream("5", ""), &none).unwrap(), b"HELLO");
assert_eq!(raw(&stream("0", "\nendstrea\nendobj\n"), &none).unwrap(), b"");
// Indirect, resolved through the lookup.
let lookup = |r: ObjRef| (r == ObjRef::new(9, 0)).then_some(4);
assert_eq!(raw(&stream("9 0 R", "\nendstrea\nendobj\n"), &lookup).unwrap(), b"HELL");
assert!(raw(&stream("9 0 R", "\nendstrea\nendobj\n"), &none).is_err(), "an unresolved /Length");
// One past the end, negative, the largest integer and one beyond it.
for length in ["6", "-1", "-9223372036854775808", "9223372036854775807", "18446744073709551621"] {
assert!(raw(&stream(length, ""), &none).is_err(), "/Length {length}");
}
}
#[test]
fn indirect_stream_with_a_space_before_the_line_break() {
// `stream \r\n` as Microsoft Reporting Services writes it: the data starts after the
+40
View File
@@ -128,6 +128,46 @@ pub(crate) fn encrypt_dict(doc: &Document, d: &Dict) -> EncryptDict {
}
}
/// Whether `d`, an `/Encrypt` dictionary, has crypt filters that encrypt (`/CFM` `V2`, `AESV2` or
/// `AESV3`), each authorised only when an embedded file is opened (`/AuthEvent /EFOpen`, ISO
/// 32000-2 §7.6.6, Table 25): what a document asks for its embedded files alone, which it opens
/// without (§7.6.6: "EFOpen Authorization shall be required when accessing embedded files"). The
/// files use them through `/EFF` or through a `/Crypt` filter of their own. Its strings and streams
/// have to be in the clear too, which [`SecurityHandler::without_key`] checks.
pub(crate) fn efopen_only(doc: &Document, d: &Dict) -> bool {
let name = |d: &Dict, k: &[u8]| d.get(k).and_then(|v| doc.resolve(v).as_name().map(<[u8]>::to_vec));
let Some(cf) = d.get(b"CF").map(|c| doc.resolve(c)).and_then(|c| c.as_dict().cloned()) else { return false };
let mut encrypting = Vec::new();
for (k, v) in cf.iter() {
let Some(f) = doc.resolve(v).as_dict().cloned() else { continue };
if !matches!(name(&f, b"CFM").as_deref(), Some(b"V2" | b"AESV2" | b"AESV3")) {
continue;
}
if name(&f, b"AuthEvent").as_deref() != Some(b"EFOpen") {
return false;
}
encrypting.push(k.clone());
}
!encrypting.is_empty()
}
/// Whether `s`, read from a document whose handler has no file key, holds data that handler would
/// decrypt, which therefore reads as stored: still encrypted.
pub(crate) fn stream_reads_as_stored(doc: &Document, h: &SecurityHandler, s: &Stream) -> bool {
if h.has_key() {
return false;
}
let _scope = FollowScope::enter();
let named = named_crypt_filter(doc, &s.dict);
let ty = s.dict.get(b"Type").map(|t| followed(doc, t));
let kind = match (&named, ty.as_deref().and_then(Object::as_name)) {
(Some(n), _) => StreamKind::Named(n),
(None, Some(b"EmbeddedFile")) => StreamKind::EmbeddedFile,
_ => StreamKind::Normal,
};
h.decrypts(kind)
}
/// Whether `f`, an entry of a `/Filter` chain or its only filter, is `/Crypt`.
fn is_crypt(doc: &Document, f: &Object) -> bool {
followed(doc, f).as_name() == Some(b"Crypt")
+51 -10
View File
@@ -166,6 +166,24 @@ fn write_stream(s: &Stream, out: &mut Vec<u8>) {
out.extend_from_slice(b"\nendstream");
}
/// A document opened without the password its embedded files need holds their data as stored and
/// can't encrypt: a save keeps its `/Encrypt` as it is, and writes no edited or added stream that
/// would have to be encrypted (an attached file put in, say), which would be written in the clear.
fn keyless_check(doc: &Document) -> Result<(), CosError> {
if !doc.embedded_files_locked() {
return Ok(());
}
let refuse =
|what: &str| Err(CosError::Security(format!("{what} without the password of the document's embedded files: open it with the password")));
if doc.encryption_changed() || doc.trailer().get(b"Encrypt").is_none() {
return refuse("the document's security can't change");
}
if doc.overlay_entries().any(|(_, _, o)| matches!(o.as_deref(), Some(Object::Stream(s)) if doc.reads_as_stored(s))) {
return refuse("an edited or added embedded file can't be encrypted");
}
Ok(())
}
/// The object as written: encrypted with its (output) number when the document is encrypted,
/// except the `/Encrypt` dictionary itself (`source_num` is its number in the source document).
/// `o` has the document's numbers in its references ([`crate::security::transform`]).
@@ -242,6 +260,7 @@ pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>,
if doc.stream_limited() {
return Err(CosError::ReadOnlyLimit);
}
keyless_check(doc)?;
// Reconstructed files have no chain to append to; added or removed encryption must
// rewrite every object; redaction must not leave the old revision behind. All need a full save.
if doc.reconstructed() || doc.encryption_changed() || doc.full_save_required() {
@@ -384,6 +403,14 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>, CosErro
if doc.stream_limited() {
return Err(CosError::ReadOnlyLimit);
}
keyless_check(doc)?;
// Renumbering breaks a key that mixes in the object number.
if doc.security().is_some_and(|h| !h.has_key() && !h.survives_renumbering()) {
return Err(CosError::Security(
"the embedded files are encrypted and the document was opened without their password: open it with the password to save it in full"
.into(),
));
}
// A full traversal must not fill the editor's shared caches (which undo snapshots also
// own). Parsed objects and decoded object streams are temporary working data here.
let mut reader = doc.object_reader();
@@ -762,13 +789,21 @@ fn stamp_mod_date(doc: &mut Document, opts: &SaveOptions) {
}
}
/// Keep an existing `/ID`, or create one (two equal 16-byte strings, §14.4).
/// Keep any existing two-element `/ID` array as it is, even a malformed one such as `[<> <>]` or `[1 2]` (an ordinary
/// save preserves what it doesn't understand; Save as PDF/A replaces or completes it), or create one with
/// [`new_file_id`] when there is none or it isn't a two-element array.
fn ensure_id(trailer: &mut Dict, opts: &SaveOptions, content: &[u8]) {
if matches!(trailer.get(b"ID"), Some(Object::Array(a)) if a.len() == 2) {
return;
}
trailer.set(b"ID".to_vec(), new_file_id(content, opts.id_seed));
}
/// A new file identifier for a file whose bytes are `content` (ISO 32000-1 14.4): an array of
/// two equal 16-byte strings, the same for the same content and `seed`.
pub fn new_file_id(content: &[u8], seed: u64) -> Object {
// FNV-1a over a sample of the content and the seed: stable, dependency-free.
let mut h: u64 = 0xcbf2_9ce4_8422_2325 ^ opts.id_seed;
let mut h: u64 = 0xcbf2_9ce4_8422_2325 ^ seed;
for b in content.iter().step_by((content.len() / 4096).max(1)).chain(content.iter().rev().take(64)) {
h ^= *b as u64;
h = h.wrapping_mul(0x0100_0000_01b3);
@@ -777,7 +812,7 @@ fn ensure_id(trailer: &mut Dict, opts: &SaveOptions, content: &[u8]) {
id.extend_from_slice(&h.to_be_bytes());
id.extend_from_slice(&h.rotate_left(29).wrapping_mul(0x9E37_79B9_7F4A_7C15).to_be_bytes());
let s = Object::String(PdfString { bytes: id, hex: true });
trailer.set(b"ID".to_vec(), Object::Array(vec![s.clone(), s]));
Object::Array(vec![s.clone(), s])
}
/// Give a rewritten file a new identifier (§14.4), so that a redacted or sanitized copy does not
@@ -919,14 +954,14 @@ pub fn pdf_date(unix_secs: i64) -> String {
format!("D:{y:04}{m:02}{d:02}{:02}{:02}{:02}Z", secs / 3600, secs / 60 % 60, secs % 60)
}
/// Days since 1970-01-01 → proleptic Gregorian (year, month, day).
/// Days since 1970-01-01 → proleptic Gregorian (year, month, day). Total for any `i64` count:
/// dates past `NaiveDate`'s ±262143-year range keep their proleptic Gregorian year instead of
/// falling back to the epoch.
///
/// A `from_timestamp` epoch fallback would change dates past `NaiveDate`'s ±262143-year range,
/// which the old hand-rolled converter formatted directly. The Gregorian calendar repeats every
/// 400 years (146_097 days), so chrono converts the day within one cycle and whole cycles go back
/// into the year (an `i128` so the extremes can't overflow), keeping chrono as the only calendar
/// logic and reproducing the old result exactly. 1970-01-01 is `num_days_from_ce` 719_163.
fn civil_from_days(days: i64) -> (i64, u32, u32) {
/// The Gregorian calendar repeats every 400 years (146_097 days), so chrono converts the day
/// within one cycle and whole cycles go back into the year (an `i128` so the extremes can't
/// overflow), keeping chrono as the only calendar logic. 1970-01-01 is `num_days_from_ce` 719_163.
pub fn civil_from_days(days: i64) -> (i64, u32, u32) {
use chrono::Datelike;
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
// `rest + 719_163` is 719_163..865_259, always a valid CE day.
@@ -1110,6 +1145,12 @@ mod tests {
// its sign, exactly as before).
assert_eq!(pdf_date(10_000_000_000_000), "D:3188570520174640Z");
assert_eq!(pdf_date(-10_000_000_000_000), "D:-3149180813061320Z");
assert_eq!(crate::civil_from_days(0), (1970, 1, 1));
assert_eq!(crate::civil_from_days(-1), (1969, 12, 31));
assert_eq!(crate::civil_from_days(11_016), (2000, 2, 29));
assert_eq!(crate::civil_from_days(19_782), (2024, 2, 29));
assert_eq!(crate::civil_from_days(i64::MAX), (25_252_734_927_768_524, 7, 27));
assert_eq!(crate::civil_from_days(i64::MIN), (-25_252_734_927_764_585, 6, 7));
}
#[test]
+224
View File
@@ -0,0 +1,224 @@
//! A document that encrypts only its embedded files, authorised when one is opened (`/AuthEvent
//! /EFOpen`, ISO 32000-2 §7.6.6, Table 25): it opens without a password, its pages read as stored,
//! and its embedded files keep their encrypted bytes, flagged as such, through every save.
use std::sync::Arc;
use pdfcraft_cos::{Algorithm, CosError, Document, NewEncryption, ObjRef, Object, SaveOptions, write_full, write_incremental};
use pdfcraft_crypt::StreamKind;
const PAGE: &str = "BT /F1 12 Tf 20 100 Td (Visible page) Tj ET";
const ATTACHED: &[u8] = b"the attached file's own words";
const PASSWORD: &str = "attach pw";
const EMBEDDED: ObjRef = ObjRef { num: 6, generation: 0 };
const CONTENT: ObjRef = ObjRef { num: 4, generation: 0 };
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02X}")).collect()
}
/// One page in the clear and one attachment encrypted with `/StdCF`, whose `/AuthEvent` is
/// `auth_event`: AES-256 (V5, R6) or AES-128 (V4, R4), `/StmF` and `/StrF` `/Identity`.
fn fixture(algorithm: Algorithm, auth_event: &str) -> Vec<u8> {
fixture_with(algorithm, auth_event, false)
}
/// [`fixture`], the attachment naming `/StdCF` in a `/Crypt` filter of its own when `named`, with
/// no `/EFF` (ISO 32000-2 §7.4.10).
fn fixture_with(algorithm: Algorithm, auth_event: &str, named: bool) -> Vec<u8> {
let id0 = [7u8; 16];
let params =
NewEncryption { algorithm, user_password: PASSWORD, owner_password: "owner pw", permissions: -4, encrypt_metadata: true, seed: [3; 32] };
let handler = pdfcraft_crypt::create(&params, &id0).unwrap();
let d = handler.dict();
let kind = if named { StreamKind::Named(b"StdCF") } else { StreamKind::EmbeddedFile };
let cipher = handler.encrypt_stream(EMBEDDED.num, 0, ATTACHED, kind);
let (eff, crypt) = if named { ("", " /Filter [/Crypt] /DecodeParms [<< /Name /StdCF >>]") } else { (" /EFF /StdCF", "") };
let (version, cfm, r6) = match algorithm {
Algorithm::Aes256 => ("/V 5 /R 6", "/AESV3 /Length 32", format!("/OE <{}> /UE <{}> /Perms <{}>", hex(&d.oe), hex(&d.ue), hex(&d.perms))),
_ => ("/V 4 /R 4 /Length 128", "/AESV2 /Length 16", String::new()),
};
let encrypt = format!(
"<< /Filter /Standard {version} /O <{}> /U <{}> {r6} /P {} /CF << /StdCF << /AuthEvent /{auth_event} /CFM {cfm} >> >> /StmF /Identity /StrF /Identity{eff} >>",
hex(&d.o),
hex(&d.u),
d.p
);
let mut objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [(note.txt) 5 0 R] >> >> >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>".to_vec(),
format!("<< /Length {} >>\nstream\n{PAGE}\nendstream", PAGE.len()).into_bytes(),
b"<< /Type /Filespec /F (note.txt) /UF (note.txt) /EF << /F 6 0 R >> >>".to_vec(),
];
let mut stream = format!("<< /Type /EmbeddedFile{crypt} /Length {} >>\nstream\n", cipher.len()).into_bytes();
stream.extend_from_slice(&cipher);
stream.extend_from_slice(b"\nendstream");
objs.push(stream);
objs.push(encrypt.into_bytes());
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
let trailer = format!(
"trailer\n<< /Size {} /Root 1 0 R /Encrypt 7 0 R /ID [<{}> <{}>] >>\nstartxref\n{xref}\n%%EOF\n",
objs.len() + 1,
hex(&id0),
hex(&id0)
);
out.extend_from_slice(trailer.as_bytes());
out
}
fn stream(doc: &Document, r: ObjRef) -> pdfcraft_cos::Stream {
match &*doc.get(r) {
Object::Stream(s) => s.clone(),
other => panic!("object {} isn't a stream: {other:?}", r.num),
}
}
/// The attachment as the password reads it from `bytes`.
fn attachment_with_password(bytes: Vec<u8>) -> Vec<u8> {
let doc = Document::open_with_password(Arc::new(bytes), Some(PASSWORD)).unwrap();
assert!(!doc.embedded_files_locked());
let embedded = doc.root().and_then(|r| doc.get(r).as_dict().cloned()).unwrap();
assert!(embedded.get(b"Names").is_some());
stream(&doc, EMBEDDED).decoded().unwrap()
}
#[test]
fn a_document_encrypting_only_its_embedded_files_opens_without_a_password() {
let bytes = fixture(Algorithm::Aes256, "EFOpen");
let doc = Document::open(Arc::new(bytes.clone())).unwrap();
assert!(doc.embedded_files_locked());
// Not a repair: the specification has it open so.
assert!(doc.repair_log().is_empty(), "{:?}", doc.repair_log());
assert!(doc.trailer().get(b"Encrypt").is_some());
// The page reads as stored; the attachment keeps its encrypted bytes and says so.
let content = stream(&doc, CONTENT);
assert_eq!(content.decoded().unwrap(), PAGE.as_bytes());
assert!(!doc.reads_as_stored(&content));
let embedded = stream(&doc, EMBEDDED);
assert!(doc.reads_as_stored(&embedded));
assert_ne!(embedded.decoded().unwrap(), ATTACHED);
// The password still opens it, and reads the attachment.
assert_eq!(attachment_with_password(bytes.clone()), ATTACHED);
// A wrong password is still refused.
assert!(matches!(Document::open_with_password(Arc::new(bytes), Some("nope")), Err(CosError::WrongPassword)));
}
#[test]
fn a_crypt_filter_authorised_when_the_document_opens_still_needs_the_password() {
let bytes = fixture(Algorithm::Aes256, "DocOpen");
assert!(matches!(Document::open(Arc::new(bytes.clone())), Err(CosError::NeedsPassword)));
assert_eq!(attachment_with_password(bytes), ATTACHED);
}
#[test]
fn saves_keep_the_encrypted_embedded_files_as_stored() {
for algorithm in [Algorithm::Aes256, Algorithm::Aes128] {
let mut doc = Document::open(Arc::new(fixture(algorithm, "EFOpen"))).unwrap();
assert!(doc.embedded_files_locked(), "{algorithm:?}");
let root = doc.root().unwrap();
doc.update_dict(root, |c| c.set(b"PageMode".to_vec(), Object::name("UseAttachments"))).unwrap();
// An incremental save keeps /Encrypt, and the attachment decrypts with the password.
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
assert_eq!(attachment_with_password(saved), ATTACHED, "{algorithm:?}");
// A full save renumbers: AES-256 keys don't depend on the number, AES-128 keys do.
match algorithm {
Algorithm::Aes256 => {
let full = write_full(&doc, &SaveOptions::default()).unwrap();
let reopened = Document::open(Arc::new(full.clone())).unwrap();
assert!(reopened.embedded_files_locked());
let doc = Document::open_with_password(Arc::new(full), Some(PASSWORD)).unwrap();
let names = doc.root().and_then(|r| doc.get(r).as_dict().cloned()).unwrap();
let tree = doc.resolve(names.get(b"Names").unwrap()).as_dict().cloned().unwrap();
let files = doc.resolve(tree.get(b"EmbeddedFiles").unwrap()).as_dict().cloned().unwrap();
let list = doc.resolve(files.get(b"Names").unwrap()).as_array().cloned().unwrap();
let spec = doc.resolve(&list[1]).as_dict().cloned().unwrap();
let ef = doc.resolve(spec.get(b"EF").unwrap()).as_dict().cloned().unwrap();
let file = match &*doc.resolve(ef.get(b"F").unwrap()) {
Object::Stream(s) => s.decoded().unwrap(),
other => panic!("{other:?}"),
};
assert_eq!(file, ATTACHED);
}
_ => assert!(matches!(write_full(&doc, &SaveOptions::default()), Err(CosError::Security(_))), "{algorithm:?}"),
}
}
}
#[test]
fn security_cannot_change_without_the_embedded_files_password() {
let mut doc = Document::open(Arc::new(fixture(Algorithm::Aes256, "EFOpen"))).unwrap();
doc.remove_encryption();
assert!(matches!(write_full(&doc, &SaveOptions::default()), Err(CosError::Security(_))));
assert!(matches!(write_incremental(&doc, &SaveOptions::default()), Err(CosError::Security(_))));
}
/// From the review of the EFOpen change: the empty password opens it too, and the credentials of a
/// document opened without the key open no other.
#[test]
fn the_empty_password_opens_it_and_keyless_credentials_open_nothing_more() {
let bytes = fixture(Algorithm::Aes256, "EFOpen");
let doc = Document::open_with_password(Arc::new(bytes.clone()), Some("")).unwrap();
assert!(doc.embedded_files_locked());
assert!(doc.open_with_same_credentials(Arc::new(bytes)).unwrap().embedded_files_locked());
// The same /Encrypt values and /ID, authorised when the document opens.
let doc_open = fixture(Algorithm::Aes256, "DocOpen");
assert!(matches!(doc.open_with_same_credentials(Arc::new(doc_open.clone())), Err(CosError::NeedsPassword)));
assert!(matches!(Document::open_with_password(Arc::new(doc_open), Some("")), Err(CosError::WrongPassword)));
}
/// An embedded file that names an EFOpen crypt filter of its own, with no `/EFF`, opens the same way.
#[test]
fn an_embedded_file_naming_its_own_crypt_filter_opens_without_a_password() {
let bytes = fixture_with(Algorithm::Aes256, "EFOpen", true);
let doc = Document::open(Arc::new(bytes.clone())).unwrap();
assert!(doc.embedded_files_locked());
assert!(doc.reads_as_stored(&stream(&doc, EMBEDDED)));
assert_eq!(stream(&doc, CONTENT).decoded().unwrap(), PAGE.as_bytes());
assert_eq!(attachment_with_password(bytes), ATTACHED);
}
/// A save can't encrypt without the key: removing `/Encrypt` through the trailer, or putting in an
/// embedded file, is refused rather than written in the clear.
#[test]
fn saves_refuse_what_would_need_the_key() {
let open = || Document::open(Arc::new(fixture(Algorithm::Aes256, "EFOpen"))).unwrap();
let mut doc = open();
doc.trailer_mut().remove(b"Encrypt");
assert!(matches!(write_incremental(&doc, &SaveOptions::default()), Err(CosError::Security(_))));
assert!(matches!(write_full(&doc, &SaveOptions::default()), Err(CosError::Security(_))));
let mut doc = open();
let mut replaced = stream(&doc, EMBEDDED);
replaced.raw = b"new words".to_vec().into();
doc.set(EMBEDDED, Object::Stream(replaced));
assert!(matches!(write_incremental(&doc, &SaveOptions::default()), Err(CosError::Security(_))));
}
/// The password given after opening unlocks the embedded files; a wrong one doesn't.
#[test]
fn the_password_given_later_unlocks_the_embedded_files() {
let mut doc = Document::open(Arc::new(fixture(Algorithm::Aes256, "EFOpen"))).unwrap();
assert!(matches!(doc.unlock("nope"), Err(CosError::WrongPassword)));
assert!(doc.embedded_files_locked());
doc.unlock(PASSWORD).unwrap();
assert!(!doc.embedded_files_locked());
assert_eq!(stream(&doc, EMBEDDED).decoded().unwrap(), ATTACHED);
assert_eq!(stream(&doc, CONTENT).decoded().unwrap(), PAGE.as_bytes());
// Not while an embedded file read as stored has been edited.
let mut doc = Document::open(Arc::new(fixture(Algorithm::Aes256, "EFOpen"))).unwrap();
let edited = stream(&doc, EMBEDDED);
doc.set(EMBEDDED, Object::Stream(edited));
assert!(matches!(doc.unlock(PASSWORD), Err(CosError::Security(_))));
}
Loaded 100 of 654 files, more files were not shown because too many files have changed in this diff. Show more