Merge branch 'land/451' into land/351

This commit is contained in:
Brandon Thomas 2026-10-09 13:56:07 -04:00
commit 36c946eb24
447 changed files with 99678 additions and 1497 deletions

View File

@ -23,12 +23,14 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
# No macOS on PRs, main or nightly: the org's 5 macOS slots are kept for release builds,
# and release.yml builds macOS (a failing release is fixed and rebuilt).
os: [ubuntu-latest, windows-latest]
steps:
- uses: actions/checkout@v4
# The optional craft-fonts build input (Japanese fonts), so the Japanese font tests run on
# Linux; macOS and Windows test the build without it. See craftrules standards/fonts.md.
# Linux; Windows tests the build without it. See craftrules standards/fonts.md.
- name: Check out craft-fonts (Linux)
if: runner.os == 'Linux'
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0

View File

@ -4,7 +4,7 @@
Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).
## In this repository (249)
## In this repository (257)
| Asset | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|
@ -14,7 +14,13 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/es.tsv` | Spanish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/fr.tsv` | French interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/de.tsv` | German interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/te.tsv` | Telugu interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/ru.tsv` | Russian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/ja.tsv` | Japanese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/align-center.svg` | Lucide icon "align-center" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
@ -257,6 +263,8 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `crates/sign/tests/data/openssl-signed.pdf` | One-page PDF signed with OpenSSL CMS (adbe.pkcs7.detached) by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl cms -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/pdf.rs): a signature PdfCraft did not make |
| `assets/icons/clipboard-paste.svg` | Lucide icon "clipboard-paste" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clipboard-paste.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/fonts/DancingScript.ttf` | Dancing Script (variable) | The Dancing Script Project Authors (Pablo Impallari) | OFL-1.1 | https://github.com/google/fonts/blob/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/dancingscript/DancingScript%5Bwght%5D.ttf | Fill & Sign typed signatures and initials: the typed name's glyph outlines, drawn as filled paths (the font file itself is not embedded in PDFs) |
| `packaging/macos/dmg/background.svg` | PdfCraft macOS DMG window background | @XusBadia | MIT OR Apache-2.0 | Original work for this repository, derived from the PdfCraft app icon (assets/app-icon/pdfcraft.svg, referenced via an SVG image element, not copied) | Finder window of the macOS DMG; rendered to background.tiff by packaging/macos/dmg/generate.py |
| `packaging/macos/dmg/background.tiff` | PdfCraft macOS DMG window background (rendered) | @XusBadia | MIT OR Apache-2.0 | Rendered from packaging/macos/dmg/background.svg by packaging/macos/dmg/generate.py | 1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packaging/macos/package.sh; includes the app icon and the free LittleCMS built-in sRGB profile |
## Compiled in through dependencies (6)

View File

@ -19,7 +19,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "5da2aa2944fcad4720972062f0f97800f73b8bfa5dd0fff6c28f4ec0f8ee19f9"
sha256 = "2d56f8464f3c25efe9737b204db34cef012ea3716f4d78209d9abeaf0ea9393b"
[[asset]]
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "39f83ab539f6380df995a855c1c8e8c036bcd060e06b9d1b027771e1e7d6e7ef"
sha256 = "b0ca648560c45e554447aae8dba37c1c9c636c86b277d00fceeb2a21b474c8f7"
[[asset]]
path = "crates/ui-egui/src/i18n/cs.tsv"
@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "accf181cff1f0ea86bb33528f6158d1aec18c9c3ff54de62dc758536a24cb677"
sha256 = "8bab483a3f5ba3a724365c2a5a892291a10f41fdc443eaceb66f49c8a20cae20"
[[asset]]
path = "crates/ui-egui/src/i18n/pt-br.tsv"
@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "0ee4d2f630623ef7e06413a7c34a4a0269a2600fba682f06988a1346be4d07a5"
sha256 = "2cd5dcc6cf7e94914b955ee0f2f5c3e8951b16e26ab49fcb7111246a6e31ee2a"
[[asset]]
path = "crates/ui-egui/src/i18n/es.tsv"
@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "4c409434bd3385b56b561c36ff819e19e574bda8979d16cd53232cb5bea3c65f"
sha256 = "e9a9bad1252afdcb42c36b24ca85411f8c63df7debada94d4423bd48761ecb05"
[[asset]]
path = "crates/ui-egui/src/i18n/fr.tsv"
@ -74,7 +74,62 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "d322ac956218961d22828726a7fe0752b0a68603679c2dcf17c5aa840995e124"
sha256 = "0351382a91d2df51da3d45e25a09af87a081bc88e3496c3512ad89f5d4460a3a"
[[asset]]
path = "crates/ui-egui/src/i18n/de.tsv"
title = "German interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "57f52e3671a322938a160ac11b73b1f55fb6311140f20d89ae12c9a91a0500d6"
[[asset]]
path = "crates/ui-egui/src/i18n/te.tsv"
title = "Telugu interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "d3630f989e9725c22444dc27765142674b66f72436502ca969b2851fc38d703d"
[[asset]]
path = "crates/ui-egui/src/i18n/ru.tsv"
title = "Russian interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "e505c00bcc8b8af6aec27abf84bebac7a1925c632130411000a6c57241816193"
[[asset]]
path = "crates/ui-egui/src/i18n/hu.tsv"
title = "Hungarian interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "c198f1de831d7f9c853baa6977894ccc34d94e6b36c772c2e53fc5b0e0fe708b"
[[asset]]
path = "crates/ui-egui/src/i18n/bg.tsv"
title = "Bulgarian interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "9682b2c3c28449dc63637cb5962b309df3ad7db7e8d96bf8bb0f0bc386c9c611"
[[asset]]
path = "crates/ui-egui/src/i18n/ja.tsv"
@ -85,7 +140,18 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "4b14f85bc0890823c30bff37723ecb05bc5ea5a8583334644168b08043ab6bde"
sha256 = "319b032d2c50097ec14e2c7eab92e8696d33e198aeac3585ff46e98c61dec90f"
[[asset]]
path = "crates/ui-egui/src/i18n/uk.tsv"
title = "Ukrainian interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "f3a03fc466c1f956cbd44a5cad101c53db5ffc80b3b7551a273ac41f43263dd5"
[[asset]]
path = "assets/icons/accessibility.svg"
@ -3083,6 +3149,29 @@ kind = "font"
usage = "Fill & Sign typed signatures and initials: the typed name's glyph outlines, drawn as filled paths (the font file itself is not embedded in PDFs)"
sha256 = "21808625578fe8d8cd10cb684be546dca077b27cd03a53a2f1ec11dc743c924c"
[[asset]]
path = "packaging/macos/dmg/background.svg"
title = "PdfCraft macOS DMG window background"
author = "@XusBadia"
source = "Original work for this repository, derived from the PdfCraft app icon (assets/app-icon/pdfcraft.svg, referenced via an SVG image element, not copied)"
licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "image"
usage = "Finder window of the macOS DMG; rendered to background.tiff by packaging/macos/dmg/generate.py"
sha256 = "e8b9ea9d5e64b7b741c977543c1693825e77d5c4c579eeb674bf5dcd3764cd8a"
[[asset]]
path = "packaging/macos/dmg/background.tiff"
title = "PdfCraft macOS DMG window background (rendered)"
author = "@XusBadia"
source = "Rendered from packaging/macos/dmg/background.svg by packaging/macos/dmg/generate.py"
licence = "MIT OR Apache-2.0"
licence_file = "assets/app-icon/LICENSE.txt"
kind = "image"
usage = "1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packaging/macos/package.sh; includes the app icon and the free LittleCMS built-in sRGB profile"
sha256 = "f46881724a6621f3686a612a79dbdce4f7dbe9e2025610e44c9a094341334c2c"
[[fetched]]
file = "text-detection.rten"
url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-detection.rten"

27
Cargo.lock generated
View File

@ -3477,6 +3477,7 @@ dependencies = [
"hayro-syntax",
"pdfcraft-cos",
"pdfcraft-fonts",
"pdfcraft-model",
"thiserror 2.0.21",
]
@ -3486,8 +3487,11 @@ version = "0.4.0"
dependencies = [
"base64 0.22.1",
"image",
"pdfcraft-cos",
"pdfcraft-engine",
"pdfcraft-model",
"pdfcraft-organize",
"pdfcraft-platform",
"pdfcraft-render",
"pdfcraft-xfa",
"png 0.18.1",
@ -3566,6 +3570,7 @@ dependencies = [
"pdfcraft-fonts",
"pdfcraft-model",
"thiserror 2.0.21",
"unicode-bidi",
]
[[package]]
@ -3573,6 +3578,8 @@ name = "pdfcraft-engine"
version = "0.4.0"
dependencies = [
"chrono",
"icu_properties",
"idna",
"image",
"pdfcraft-a11y",
"pdfcraft-annot",
@ -3598,6 +3605,7 @@ dependencies = [
"pdfcraft-xfdf",
"png 0.18.1",
"thiserror 2.0.21",
"url",
]
[[package]]
@ -3621,6 +3629,7 @@ dependencies = [
name = "pdfcraft-fonts"
version = "0.4.0"
dependencies = [
"harfrust",
"pdfcraft-cos",
"skrifa 0.44.0",
]
@ -3634,6 +3643,7 @@ dependencies = [
"pdfcraft-cos",
"pdfcraft-fonts",
"pdfcraft-model",
"pdfcraft-render",
"thiserror 2.0.21",
]
@ -3700,6 +3710,10 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "pdfcraft-platform"
version = "0.4.0"
[[package]]
name = "pdfcraft-preflight"
version = "0.4.0"
@ -3736,12 +3750,15 @@ dependencies = [
name = "pdfcraft-render"
version = "0.4.0"
dependencies = [
"bytemuck",
"getrandom 0.4.3",
"hayro",
"hayro-syntax",
"kurbo 0.13.1",
"log",
"lopdf",
"pdfcraft-fonts",
"skrifa 0.44.0",
"thiserror 2.0.21",
]
@ -3777,6 +3794,7 @@ version = "0.4.0"
dependencies = [
"arboard",
"base64 0.22.1",
"bytemuck",
"eframe",
"egui",
"egui_extras",
@ -3785,10 +3803,14 @@ dependencies = [
"image",
"js-sys",
"log",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"pdfcraft-cos",
"pdfcraft-engine",
"pdfcraft-fonts",
"pdfcraft-model",
"pdfcraft-platform",
"pdfcraft-render",
"pdfcraft-xfa",
"png 0.18.1",
@ -3822,9 +3844,12 @@ name = "pdfcraft-xfa"
version = "0.4.0"
dependencies = [
"base64 0.22.1",
"image",
"pdfcraft-cos",
"pdfcraft-fonts",
"png 0.18.1",
"roxmltree",
"sha2",
"thiserror 2.0.21",
]
@ -6571,8 +6596,6 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winit"
version = "0.30.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d"
dependencies = [
"ahash",
"android-activity",

View File

@ -40,6 +40,7 @@ pdfcraft-forms = { path = "crates/forms", version = "0.4.0" }
pdfcraft-xfa = { path = "crates/xfa", version = "0.4.0" }
pdfcraft-render = { path = "crates/render", version = "0.4.0" }
pdfcraft-engine = { path = "crates/engine", version = "0.4.0" }
pdfcraft-platform = { path = "crates/platform", version = "0.4.0" }
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.4.0" }
pdfcraft-automation = { path = "crates/automation", version = "0.4.0", default-features = false }
@ -74,8 +75,14 @@ unsafe_code = "forbid"
dbg_macro = "warn"
todo = "warn"
# `"*"` matches every non-workspace package: crates.io dependencies and the
# vendored crates under `vendor/` (excluded from the workspace). Our own crates
# keep full debug info. Dependencies keep only line tables, so backtraces still
# show file and line, but debug info for the whole dependency graph stays out of
# every test binary (each UI test binary had a 500-600 MB PDB on Windows, #454).
[profile.dev.package."*"]
opt-level = 2
debug = "line-tables-only"
[profile.dev]
opt-level = 1
@ -91,3 +98,4 @@ hayro = { path = "vendor/hayro" }
hayro-syntax = { path = "vendor/hayro-syntax" }
hayro-jbig2 = { path = "vendor/hayro-jbig2" }
lopdf = { path = "vendor/lopdf" }
winit = { path = "vendor/winit" }

View File

@ -133,7 +133,7 @@ Search the whole document as you type, step through matches with <kbd>⌘G</kbd>
## Navigate long documents
Bookmarks, page thumbnails and the document's own page labels (i, ii, 1, 2…) keep you oriented in long documents.
Bookmarks, page thumbnails and the document's own page labels (i, ii, 1, 2…) keep you oriented in long documents. Search bookmark titles in the Bookmarks panel to find nested entries even when their parents are collapsed. Matches keep their ancestors for context; Clear restores the unfiltered tree without changing its expansion state.
<table>
<tr>
@ -172,6 +172,8 @@ Open **Organize pages** to see every page at once:
- *Atomic:* the file is written to a temporary copy, then swapped in.
- *Verified:* independently checked with qpdf.
When open documents exceed the window width, scroll over the tab strip with the mouse wheel or trackpad, or use its horizontal scrollbar. Opening or switching to a document brings its tab into view.
Unsaved documents carry a dot on their tab, and closing or quitting asks before anything is lost. Changes are autosaved every minute. If PdfCraft ever quits unexpectedly, it offers to recover your work the next time it opens. Encrypted documents stay encrypted on disk.
</td>
@ -291,6 +293,8 @@ Every engine feature is reachable without the GUI, through one table of JSON-Sch
{ "mcpServers": { "pdfcraft": { "command": "pdfcraft-cli", "args": ["mcp", "--root", "/path/to/your/pdfs"] } } }
```
`--compact` shrinks the tool list the agent has to read: `tools/list` returns about ten core tools plus `tool_search` and `tool_call`, which find and run every other tool, so the list costs far fewer tokens. Every tool still works.
`--root` confines every file the agent can read or write to one directory. Builds that should not include the server at all can use `cargo build -p pdfcraft-cli --no-default-features`.
- **The Rust API** (`pdfcraft_automation::Automation::call`), for embedding.
@ -418,6 +422,10 @@ The honest assessment by area, what's lacking and where we're going are in **[RO
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.
The MSI installs for all users and requires administrator privileges. For unattended deployment
without a desktop shortcut, run from an elevated terminal:

View File

@ -79,7 +79,8 @@ The gaps that matter most, in priority order. Agents: prefer these over adding P
3. **Measure fidelity against Acrobat.** Build a side-by-side harness (render, text, form behaviour) over synthetic fixtures (`plan/acrobat/`, clean-room rules apply). Until then, parity numbers count features, not quality.
4. **Editing existing content (M7).** Fonts, subsets, reflow and CJK. The most visible gap for Pro users.
5. **Pro workflows.** Signatures with timestamps and LTV (M9), OCR beyond Latin (M10), Office import/export (M10), preflight and PDF/A/X/UA (M11), XFA (M12).
6. **1.0 polish (M14).** Code-signed installers, localization (the catalog system is in place and French, Spanish, Japanese and both Chinese catalogs cover the UI; Czech and Brazilian Portuguese still cover menus only), keyboard-only operation, a full screen-reader audit.
6. **1.0 polish (M14).** Code-signed installers, localization (the catalog system is in place and French, German, Spanish, Japanese, Russian and both Chinese catalogs cover the UI; Czech and Brazilian Portuguese still cover menus only), keyboard-only operation, a full screen-reader audit.
6. **1.0 polish (M14).** Code-signed installers, localization (the catalog system is in place and French, Spanish, Japanese, Russian, Ukrainian and both Chinese catalogs cover the UI; Czech and Brazilian Portuguese still cover menus only), keyboard-only operation, a full screen-reader audit.
Decided against for now (owner, 2026-10-05): self-installing updates and an update check at start. Help ▸ Check for updates is manual only.
@ -90,20 +91,21 @@ Hours are for a single agent (low–high). "Done" is the estimated fraction of t
| M | Milestone | Est. hours | Done | Remaining (h) | Notes |
|---|---|---|---|---|---|
| M0 | Skeleton: workspace, xtask gates, CI | 15–30 | 92% | 2–4 | GitHub workflow, `deny.toml` (licence audit of every dependency), parity checklist (826 features, `xtask parity`) done. Missing: remaining crate stubs, testkit/oracle crates |
| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 78% | 25–45 | Done:<br>- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;<br>- cos: parse and repair (including invalid xref object-number ranges), decrypt on load, re-encrypt on save, incremental and full writing.<br>Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.<br>Full saves now pack objects into compressed object streams. Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs |
| M2 | Model, render, text | 200–350 | 15% | 170–300 | hayro bootstrap renderer (vendored patches). Text extraction reaches word-F1 0.98 against pdftotext. Missing: model crate, fonts, DisplayList, renderer independent of hayro |
| M2 | Model, render, text | 200–350 | 15% | 170–300 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input. Text extraction reaches word-F1 0.98 against pdftotext. Missing: model crate, fonts, DisplayList, renderer independent of hayro |
| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 78% | 25–45 | Done:<br>- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;<br>- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.<br>Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.<br>Full saves now pack objects into compressed object streams. Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs |
| M3 | Viewer app (native + web) | 80–150 | 88% | 10–19 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel. |
| M4 | Engine, history, save, organize | 100–180 | 95% | 9–17 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize. Missing: Replace pages, page transitions, recovery on the web |
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 15–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export; flatten; Fill & Sign; agent tools. Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters. Missing: custom stamps, replace-text proposals, summary layouts with the page |
| M4 | Engine, history, save, organize | 100–180 | 95% | 9–17 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize. Missing: Replace pages, page transitions, recovery on the web |
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 15–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export; flatten; Fill & Sign; agent tools. Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page |
| M6 | Forms + JavaScript | 160–320 | 45% | 95–185 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done. Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA |
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 17% | 210–420 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
| M8 | Security + redaction | 100–180 | 66% | 35–65 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize. Missing: certificate security, redaction codes and pattern locales, DCT re-encoding |
| M9 | Signatures (PAdES, validation) | 160–280 | 55% | 70–125 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL. Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 |
| M10 | OCR, create, export, print | 200–350 | 33% | 130–235 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, CUPS spooler, print-ready PDF). Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print. Missing: OCR languages beyond Latin, editable-text OCR output, Office export/import, Windows/web printing |
| M10 | OCR, create, export, print | 200–350 | 33% | 130–235 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, CUPS spooler, print-ready PDF). Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; Create PDF from multiple files (PDFs, images and text, edited as pages in the grid). Missing: OCR languages beyond Latin, editable-text OCR output, Office export/import, Windows/web printing |
| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 165–290 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA |
| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 23% | 155–295 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export. Missing: autotag, Tags/Order/Content panels, Reading Order tool, alt-text workflow, compare, geospatial/3D measurement, search index, XFA |
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 33–66 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 5% | 115–240 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`; Japanese, Traditional Chinese, Simplified Chinese, Spanish and French catalogs have ≈1,800 entries each; Simplified Chinese and French coverage is checked against the UI source; Czech and Brazilian Portuguese for menus. French locales (`fr`, `fr_FR`, `fr_CA`) follow the French catalog. Missing: more catalogs filled in, installers, performance budgets |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 5% | 115–240 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`; Japanese, Traditional Chinese, Simplified Chinese, Russian, German, Spanish and French catalogs have ≈1,800 entries each; Simplified Chinese, French and German coverage is checked against the UI source; Czech and Brazilian Portuguese for menus. French locales (`fr`, `fr_FR`, `fr_CA`) follow the French catalog. Missing: more catalogs filled in, installers, performance budgets |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 5% | 115–240 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`; Japanese, Traditional Chinese, Simplified Chinese, Russian, Spanish, French and Ukrainian catalogs have ≈1,800–2,000 entries each; Simplified Chinese, French and Ukrainian coverage is checked against the UI source; Czech and Brazilian Portuguese for menus. French locales (`fr`, `fr_FR`, `fr_CA`) follow the French catalog; Ukrainian locales (`uk`, `uk_UA`) follow the Ukrainian one. Missing: more catalogs filled in, installers, performance budgets |
| | **Total (original plan sizing)** | **2,085–3,840** | **≈ 35%** | **≈ 1,350–2,500 at the planned rate; ≈ 600–1,100 at the measured rate** | |
**Overall progress: about 30–35% of the effort (51.0% of features shipped).** The viewer and the core are far ahead of the editing features, because the viewer was built first so progress could be seen; rendering itself is still borrowed from `hayro`. See [Honest assessment](#honest-assessment-2026-10-05).
@ -125,14 +127,48 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run
## Log
- **2026-10-09 (M5, line endings):** Lines, connected lines and callouts draw all ten standard line-ending styles. `comment_add` accepts `endings`, and Comment properties can change them in one undo step. An unknown name is left undrawn, and a non-finite direction is rejected. Cloudy borders remain polygon-only, so `comment.line-endings` stays partial. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M6, field rotation):** Regenerated form appearances honour `/MK /R` (0, 90, 180, 270). Filling or restyling a rotated field keeps the text, check mark and button caption turned, and Field Properties ▸ Position and `form_set_props` `rotation` set it, swapping width and height around the field's center when the axis changes. `form.appearance-regeneration` stays partial (Helvetica metrics are still approximate). Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M5.7, #434 partial):** Natural-size image stamps placed at a click or through `stamp_custom` remain upright on rotated pages, with a displayed-space-sized rectangle and a counterrotated standard appearance matrix. Move, resize, restyle and undo/redo preserve that orientation. Native and external Poppler save/reopen rendering cover all four quarter-turns; UI tests verify the displayed bounds. Explicit rectangle placement, image signatures, PDF-page stamps and image-file orientation metadata remain outside this repair. #434 stays open; milestone table and effort estimates remain unchanged.
- **2026-10-09 (M1, raw-stream decoding limits):** COS checks unfiltered decoded data against the caller and per-stream limits before copying it, including empty filter arrays and streams whose Crypt filter was applied on load. Strict decoding uses the same raw-data guard. Tiny synthetic regressions cover exceeded, exact and zero-byte limits while retaining the filtered decoder behavior. Milestone and overall effort estimates unchanged (about 30–35%).
- **2026-10-09 (M7, grouped artwork):** Edit text/images selects page-drawn Form XObjects as whole figures, including nested raster/vector/text content. Their BBox and optional Matrix determine the selection; move/resize/rotate/flip/delete rewrite only the selected page placement, preserving shared Forms and other placements. Engine, automation and UI core regressions cover geometry, rendered movement, save/reopen, undo and selection/drag/Delete. Bitmap replacement/extraction remain raster-only; inline images and individual elements inside a Form are not editable through this tool. Overall estimate unchanged (about 30–35%).
- **2026-10-08 (M5.7, signature resize proportions):** Image signature corner handles preserve the embedded image's original aspect ratio and anchor the opposite corner; edge midpoint handles stretch width or height independently. The live preview and committed edit share the constrained geometry, including page/view rotation and reopened PDFs whose rectangles were stretched previously. Synthetic UI tests cover all corners, edge resizing, undo and redo. Milestone and effort estimates unchanged.
- **2026-10-08 (M5.7, live signature adjustment):** Embedded image signatures resize and move with every pointer frame. Movement hides the frame and handles, which return on release; Escape cancels without committing. A cached background preserves the page beneath the image, and only the completed gesture enters undo history. The read-only engine preview and `comment_image_preview` tool share bounded image decoding and render layers. Synthetic pixel tests cover live geometry, alpha, coloured page content, rotation, cancellation and encrypted save/reopen. Milestone and effort estimates unchanged.
- **2026-10-08 (M10/M4, create from multiple files):** Create a PDF ▸ Multiple files is live. The picked PDFs, images and `.txt` files open as one unsaved document in the Organize page grid, where pages are removed and reordered and Save pages writes what is shown. The grid gained a "+" at every gap that inserts files right there, and on desktop files dropped on the grid go to the gap under the pointer; Insert from file now takes several files in order, converts images and text, and selects what it inserted. File-type detection moved from the shell into `pdfcraft-create` (`source_kind`), with one engine entry point (`Session::convert_to_pdf`) shared by Open, Insert, Create and the new `doc_create_multiple` tool (which can also write one PDF per file). Not yet: Office/HTML/Markdown inputs, PDF Portfolios, progress for big batches, dropping onto the grid in the web build. Overall estimate unchanged (about 30–35%).
- **2026-10-08 (M5.7, signature placement):** Image signatures and initials preview at the pointer at their actual page size, including zoom and document/view rotation. A successful placement switches to Select with the new annotation selected; stamp resize handles use the existing engine edit, automation tool and undo history while preserving the appearance. Synthetic rendered UI and automation tests verify positioning, one placement per selection, resizing, undo/redo and save/reopen. Milestone and overall effort estimates unchanged.
- **2026-10-08 (M5.7, image signatures):** Fill & Sign imports local PNG/JPEG signatures and initials through the Image tab, with previews, local persistence and undoable placement. The shared engine validates bounded images and preserves transparency and orientation; `fill_sign_add` accepts an image path. Synthetic UI and automation tests cover cancellation, invalid input, settings, undo/redo and save/reopen rendering. Overall effort estimate unchanged (about 30–35%).
- **2026-10-08 (M9, #179):** Windows Current User Personal certificate store identities join file-based digital IDs, signing through CNG without exporting keys. RSA-2048 and ECDSA P-256 round trips use temporary certificates with cleanup guards; enumeration, missing identities, automation errors and the password-free UI have regression coverage. Smart cards and PKCS #11 remain planned; overall effort estimate unchanged.
Newest first. One line per session: the date, what moved, and the new overall percentage.
- **2026-10-09 (M14, German interface follow-up):** Follow the merged German catalog with shorter search/action labels, corrected callout/accessibility wording and a distinct certificate Subject context. Localized tool names retain their capitalization and comment composer titles go through the catalog. Search hints reserve shortcut space and Home cards bound text with full hover text; layout, context and German Preferences/search control regression tests cover these changes. Overall effort estimate remains about 30–35%.
- **2026-10-09 (community, destinations):** Bookmarks and links go to their destination's position, not just its page (ISO 32000-2 §12.3.2.2): `/XYZ` puts (left, top) at the window's top-left and applies a non-zero zoom; `/Fit`, `/FitH` and `/FitV` switch to fit page, width and height with `top` or `left` at the window's edge; `/FitR` zooms to fit the rectangle, centred. Named destinations resolve the same way; crop-box origin, `/Rotate` and view rotation are accounted for; malformed or non-finite operands fall back to the top of the page. Not yet: the `/FitB*` content box (read as the page). Overall estimate unchanged (about 30–35%).
- **2026-10-09 (community, M14, Hungarian):** Magyar (`hu`) is a complete interface catalog (2,036 entries) covering every command, All tools entry, dialog, panel and notice, selected automatically for `hu` and `hu_HU`, and from Preferences. Informal "te"; commands are nouns (Mentés, Megnyitás). After a numeral the noun stays singular, so both plural forms match. Coverage tests check commands, All tools labels and every `tl!` literal; history labels keep captured names; the bundled Latin faces cover Hungarian diacritics including ő and ű. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M14, Windows Auto):** Neutral launcher locales (`C`, `C.UTF-8`, `POSIX`) no longer select English ahead of the Windows preferred display-language list. Explicit supported locale overrides retain their priority. Regression tests use existing catalogs and cover neutral/mixed overrides, unsupported first display tags and multiple preferred Windows languages. Catalogs and other platforms are unchanged; overall effort estimate remains about 30–35%.
- **2026-10-09 (community, M14, German):** Deutsch (`de`) is a complete interface catalog (2,035 entries) covering every command, All tools entry, dialog, panel and notice, selected automatically for `de`, `de_DE`, `de_AT`, `de_CH` and other German locales, and from Preferences. Informal "du", infinitive for commands. Coverage tests check commands, All tools labels and every `tl!` literal; history labels keep captured names; the bundled Latin faces cover umlauts, ß and German quotes. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (community, M7, Arabic added text):** Add text writes Arabic into the PDF instead of `?`. The text is shaped with the craft-fonts `Arab` face through harfrust (already in the tree via egui): joining forms, ligatures and mark positions. Each line is laid out in display order by the Unicode bidirectional algorithm, so Arabic mixed with Latin words, European or Arabic-Indic digits and brackets reads correctly; justified right-to-left paragraphs end at the right edge. Each cluster (a letter with its dots or marks) becomes one glyph of a Type 3 font built from the face's outlines, with a ToUnicode entry, so `text_extract`, copy and search get every letter once and no font file is embedded. Wrapping measures shaped words. Each item has its own fonts under names free on the page (a full save renumbers objects but not resource names); updating or deleting the item removes them. Without the face, adding Arabic text returns a clear error and changes nothing, while an existing item holding Arabic still moves and retypes, drawn as `?` as before. Tests cover shaping, display order, ToUnicode, font names and clean-up on one page, and odd input (empty lines, direction controls, lone marks, 18,000 characters). Not yet: bold/italic Arabic, Hebrew, right-to-left typing in the box (misc.rtl-ui), editing existing Arabic text (edit.rtl-cjk-editing). The craft-fonts revision pinned for releases predates its Arabic face, so release builds report the missing font until the pin moves. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M2, predictor hardening):** Empty PNG predictor frames return without row buffers. Packed TIFF Predictor 2 rows reject overflowing dimensions and size per-component scratch to the samples present in the input, aligned with upstream lopdf#589. Synthetic regressions cover malformed dimensions, partial rows, 1/2/4-bit decoding and padding. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (community, document inspection):** Annotation dates preserve non-date Unicode text instead of panicking while opening; failed auxiliary structure inspection keeps renderer page geometry and reports a warning. Synthetic UTF16BE annotation, UTF8-boundary and independent panic regressions cover the fix. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (community, M14, #503):** Ukrainian (Українська, `uk`) adds 2,047 original catalog entries covering commands, tools, dialogs, panels, notices, history and contributor controls. Auto resolves `uk`/`uk_UA`; the manual preference persists. Integer plurals distinguish one/few/many, including 11–14 and large counts; fixed-count templates use count-neutral wording. Calendar dates use genitive month names; Home cards fit wrapped text and the search hint keeps clear of its shortcut. Coverage checks scan UI literals, and font checks use bundled Inter and JetBrains Mono, including ґ/є/і/ї. Control tests check translated Preferences, command search, error framing and language switching with unsaved documents. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (community, Home pinned folders):** Home gets a Folders section: File ▸ Pin folder to Home… (or "Pin a folder…" on Home) pins up to 8 folders, and each lists its newest PDFs (name, how long ago, size; 8 at first, up to 100 with Show more), one click to open, Unpin to remove. Folders are read on a worker thread and listed again every few seconds while Home shows, so new scans appear without reopening anything; a folder that can't be read (moved, offline synced drive) stays pinned and says so. Each folder is described by where it is ("iCloud Drive", "OneDrive · Contoso", "Documents › Work") rather than its full path, so two folders called "Scans" can be told apart. Cloud drives work through their local synced folder; nothing is read through a provider's API. Settings keep only absolute paths, without duplicates. Command label translated in every catalog; Home strings in French, Spanish, Japanese and both Chinese catalogs. Not yet: subfolders, search inside a pinned folder, the web build. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (community, M14, Telugu):** Telugu (తెలుగు, `te`) as a complete catalog of 1,984 entries covering every command, All tools entry, dialog, panel and notice; `te` and `te_IN` locales follow it. Telugu text is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, storytold/craft-fonts#10), kept in the web build too, after the CJK and Arabic faces; egui shapes its conjuncts. Commands use the plain imperative and sentences the polite form. The craft-fonts revision pinned for releases has no Telugu face yet, so release builds show Telugu as replacement boxes until the pin moves. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M14, Simplified Chinese About):** The About tabs, contributor controls and statistics, and model table now have Simplified Chinese translations, along with multiline link-privacy and cut-and-stack guidance. Coverage checks include direct `i18n::t` calls, multiline literals and dynamic credit labels; UI control checks exercise the translated About tabs and tables. Chinese font delivery remains incomplete; overall ≈ 30–35% (unchanged).
- **2026-10-09 (M14, #305):** Windows MSI rejects per-user installation overrides while allowing removal of legacy per-user installs and keeps its progress message visible. README documents unattended deployment without a desktop shortcut. Compiled x64 MSI checks pass; ARM64 install regressions added for CI. Overall estimate unchanged (about 30–35%).
- **2026-10-08 (M4, Combine files):** Combine files is now its own tab instead of a dialog: a table of the files (name, pages, size, modified, warnings) whose columns sort, resize and move, with the layout kept in the settings. Files, folders (optionally with subfolders) and open documents can be added, or dropped on the tab; rows select like a file manager (Ctrl/Shift), move as a block by toolbar, Alt+arrows or drag, and every change undoes with ⌘Z. Problems show before Combine: password-protected files, security settings that withhold page assembly, bad page ranges (checked as typed), duplicates; protected files unlock with their open or permissions password, also headlessly (`doc_combine` `passwords`). The result is never encrypted, and the list says so. `organize.combine-sort` shipped; `organize.combine-options` partial (open files). Still missing: protecting the result in the same step, file-size and PDF/A options. Overall estimate unchanged (about 30–35%).
- **2026-10-08 (M14, French):** Français (`fr`) is a complete interface catalog (about 1,880 entries), selected automatically for `fr`, `fr_FR`, `fr_CA` and other French locales, and from Preferences. Coverage tests check commands, All tools labels and every `tl!` literal; history labels keep captured names. Latin UI faces cover the catalog. Localization remains partial for Czech and Brazilian Portuguese; overall ≈ 30–35% (unchanged).
- **2026-10-08 (M14, right-to-left file names):** A tab with an Arabic or Hebrew file name showed replacement boxes. The interface now takes an `Arab` face from craft-fonts when the build input has one, and on desktop falls back to one font installed on the machine for characters no embedded face has (`PDFCRAFT_SYSTEM_FONTS=0` turns that off; README screenshots do). File names and paths in the tab strip, the recent-files list and Properties are put in display order, since egui joins the letters but does not reorder runs. Still missing: multi-line text (bookmarks, comments, search results), text input and a mirrored layout, so `misc.rtl-ui` stays planned; the craft-fonts revision pinned for releases predates its Arabic face, so release builds rely on the installed font until the pin moves. Overall estimate unchanged (about 30–35%).
@ -238,3 +274,5 @@ Newest first. One line per session: the date, what moved, and the new overall pe
- Robustness sweep (963 of 983 files open, 0 crashes), text layer, find and select, tiles, web build, polish.
- Overall ≈ 3–4%.
- **2026-09-30 (session 1):** planning complete; viewer vertical slice.
- M13: common command, batch, inspection and bounded preview tools share the existing confined automation path. MCP tools carry complete hints and reject unknown top-level keys as invalid parameters.

View File

@ -1,22 +1,6 @@
//! pdfcraft-cli — headless PdfCraft.
//!
//! ```text
//! pdfcraft-cli info <file.pdf> [--password PW] document summary as JSON
//! pdfcraft-cli render <file.pdf> --page N [--dpi 96] --out x.png (.png, .jpg, .tif or .pam)
//! pdfcraft-cli text <file.pdf> [--page N] extracted text (pages separated by form feeds)
//! pdfcraft-cli edit <in.pdf> --out out.pdf [--rotate 1,3:90] [--delete 2,4] [--move 5:1]
//! [--insert-blank 1] [--title T] [--author A] [--full]
//! pdfcraft-cli combine <a.pdf> <b.pdf> … --out combined.pdf
//! pdfcraft-cli extract <in.pdf> --pages 1,3,5 --out out.pdf
//! pdfcraft-cli split <in.pdf> (--every N | --before 3,7) [--out-dir DIR]
//! pdfcraft-cli check <files or dirs…> [--timeout 20] [--dpi 36] [--json out.json]
//! pdfcraft-cli tools automation tools and their JSON Schemas
//! pdfcraft-cli run <tool> [key=value …] [--root DIR] [--out image.png]
//! pdfcraft-cli run --script steps.json [--root DIR] [{"tool": "doc_open", "args": {…}}, …]
//! pdfcraft-cli mcp [--root DIR] MCP server on stdin/stdout (opt-in)
//! pdfcraft-cli ui --control FILE <method> [key=value …] [--out shot.png]
//! drive a running app started with --control FILE
//! ```
//! Usage: `pdfcraft-cli --help`, which prints `USAGE` below.
//!
//! `run` and `mcp` drive the same tool table (`pdfcraft-automation`). In `run`, values parse as
//! JSON when they can (`pages=[1,3]`, `degrees=90`) and are strings otherwise. A script runs its
@ -42,28 +26,75 @@ use std::time::{Duration, Instant};
use pdfcraft_engine::export::ImageFormat;
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind, inspect};
/// The `mcp` lines of [`USAGE`]: only a build with the `mcp` feature has the command.
#[cfg(feature = "mcp")]
macro_rules! mcp_usage {
() => {
"\
pdfcraft-cli mcp [--root DIR] [--compact] MCP server on stdin/stdout (opt-in)
--compact lists a core set of tools plus tool_search and tool_call
"
};
}
#[cfg(not(feature = "mcp"))]
macro_rules! mcp_usage {
() => {
""
};
}
/// The full usage, printed by `--help` (also after a command, e.g. `render --help`).
const USAGE: &str = concat!(
"\
pdfcraft-cli info <file.pdf> [--password PW] document summary as JSON
pdfcraft-cli render <file.pdf> --page N [--dpi 96] --out x.png (.png, .jpg, .tif or .pam)
pdfcraft-cli text <file.pdf> [--page N] extracted text (pages separated by form feeds)
pdfcraft-cli edit <in.pdf> --out out.pdf [--rotate 1,3:90] [--delete 2,4] [--move 5:1]
[--insert-blank 1] [--title T] [--author A] [--full]
pdfcraft-cli combine <a.pdf> <b.pdf> … --out combined.pdf
pdfcraft-cli extract <in.pdf> --pages 1,3,5 --out out.pdf
pdfcraft-cli split <in.pdf> (--every N | --before 3,7) [--out-dir DIR]
pdfcraft-cli check <files or dirs…> [--timeout 20] [--dpi 36] [--json out.json]
pdfcraft-cli tools automation tools and their JSON Schemas
pdfcraft-cli run <tool> [key=value …] [--root DIR] [--out image.png]
pdfcraft-cli run --script steps.json [--root DIR] [{\"tool\": \"doc_open\", \"args\": {…}}, …]
",
mcp_usage!(),
"\
pdfcraft-cli ui --control FILE <method> [key=value …] [--out shot.png]
drive a running app started with --control FILE
help and feedback: https://discord.gg/artcraft"
);
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let result =
match args.first().map(String::as_str) {
Some("info") => info(&args[1..]),
Some("render") => render(&args[1..]),
Some("text") => text(&args[1..]),
Some("edit") => edit(&args[1..]),
Some("combine") => combine(&args[1..]),
Some("extract") => extract(&args[1..]),
Some("split") => split(&args[1..]),
Some("check") => check(&args[1..]),
Some("check-one") => check_one(&args[1..]),
Some("tools") => tools(),
Some("run") => run(&args[1..]),
Some("ui") => ui(&args[1..]),
#[cfg(feature = "mcp")]
Some("mcp") => mcp(&args[1..]),
Some("--version") => version(),
_ => Err("usage: pdfcraft-cli <info|render|text|edit|combine|extract|split|check|tools|run|mcp|ui> … (see source header for options)\nhelp and feedback: https://discord.gg/artcraft"
.into()),
};
// `<command> --help` too; only right after the command, so a later `-h` (a value) still reaches it.
let command = match args.get(1).map(String::as_str) {
Some("--help" | "-h") => Some("--help"),
_ => args.first().map(String::as_str),
};
let result = match command {
Some("--help" | "-h" | "help") => stdout_line(format_args!("{USAGE}")),
None => Err(format!("usage:\n{USAGE}").into()),
Some("info") => info(&args[1..]),
Some("render") => render(&args[1..]),
Some("text") => text(&args[1..]),
Some("edit") => edit(&args[1..]),
Some("combine") => combine(&args[1..]),
Some("extract") => extract(&args[1..]),
Some("split") => split(&args[1..]),
Some("check") => check(&args[1..]),
Some("check-one") => check_one(&args[1..]),
Some("tools") => tools(),
Some("run") => run(&args[1..]),
Some("ui") => ui(&args[1..]),
#[cfg(feature = "mcp")]
Some("mcp") => mcp(&args[1..]),
#[cfg(not(feature = "mcp"))]
Some("mcp") => Err("mcp: this build leaves out the MCP server (built without the `mcp` feature)".into()),
Some("--version") => version(),
Some(other) => Err(format!("unknown command '{other}'; see pdfcraft-cli --help").into()),
};
match result {
Ok(()) => ExitCode::SUCCESS,
Err(CliError::Stdout(e)) if e.kind() == std::io::ErrorKind::BrokenPipe => ExitCode::SUCCESS,
@ -175,6 +206,22 @@ fn info(args: &[String]) -> Result<(), CliError> {
fn text(args: &[String]) -> Result<(), CliError> {
let path = *positional(args).first().ok_or("text: missing file")?;
let mut selected_page = None;
// Validate every supplied value before reading, retaining the first valid selection.
let mut options = args.iter();
while let Some(option) = options.next() {
if option == "--page" {
let page = options
.next()
.and_then(|value| value.parse::<usize>().ok())
.filter(|page| *page > 0)
.ok_or("bad --page: expected a positive page number")?;
selected_page.get_or_insert(page - 1);
} else if option.starts_with("--") {
// Match positional(): an option's operand is not itself another option.
options.next();
}
}
let password = flag(args, "--password");
let bytes = read(path)?;
let mut r = PageRenderer::new(bytes.clone(), RenderConfig { password: password.map(Arc::from), ..Default::default() });
@ -187,15 +234,15 @@ fn text(args: &[String]) -> Result<(), CliError> {
return Err("text: the document could not be parsed".into());
}
}
let pages: Vec<usize> = match flag(args, "--page") {
Some(p) => vec![p.parse::<usize>().map_err(|_| "bad --page")?.saturating_sub(1)],
let pages: Vec<usize> = match selected_page {
Some(page) => vec![page],
None => (0..r.page_count()).collect(),
};
let mut failed: Vec<usize> = Vec::new();
for (n, p) in pages.iter().enumerate() {
let out = r.render(RenderRequest { page: *p, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0 });
if let Some(e) = out.error {
eprintln!("page {}: {e}", p + 1);
let _ = writeln!(std::io::stderr().lock(), "page {}: {e}", p + 1);
failed.push(p + 1);
continue;
}
@ -262,11 +309,11 @@ fn edit(args: &[String]) -> Result<(), CliError> {
"--move" => {
let (pages, to) = value.split_once(':').ok_or("--move PAGES:TO")?;
let to: usize = to.parse().map_err(|_| "bad target")?;
edits.push(Edit::MovePages { pages: page_list(pages)?, to: to.saturating_sub(1) });
edits.push(Edit::MovePages { pages: page_list(pages)?, to: to.checked_sub(1).ok_or("--move target must be at least 1")? });
}
"--insert-blank" => {
let at: usize = value.parse().map_err(|_| "bad position")?;
edits.push(Edit::InsertBlankPage { at: at.saturating_sub(1), width: 612.0, height: 792.0 });
edits.push(Edit::InsertBlankPage { at: at.checked_sub(1).ok_or("--insert-blank must be at least 1")?, width: 612.0, height: 792.0 });
}
"--title" => edits.push(Edit::SetInfo { key: "Title".into(), value: value.into() }),
"--author" => edits.push(Edit::SetInfo { key: "Author".into(), value: value.into() }),
@ -337,6 +384,7 @@ fn split(args: &[String]) -> Result<(), CliError> {
fn render(args: &[String]) -> Result<(), CliError> {
let path = *positional(args).first().ok_or("render: missing file")?;
let page: usize = flag(args, "--page").unwrap_or("1").parse().map_err(|_| "bad --page")?;
let page_index = page.checked_sub(1).ok_or("--page must be at least 1")?;
let dpi: f32 = flag(args, "--dpi").unwrap_or("96").parse().map_err(|_| "bad --dpi")?;
let out = flag(args, "--out").ok_or("render: missing --out (.png, .jpg, .tif or .pam)")?;
// The file is what its name says (#248): a `.png` used to get a netpbm PAM stream.
@ -348,7 +396,7 @@ fn render(args: &[String]) -> Result<(), CliError> {
_ => return Err(format!("render: --out {out}: use a .png, .jpg, .tif or .pam name").into()),
};
let mut r = PageRenderer::new(read(path)?, RenderConfig { password: flag(args, "--password").map(Arc::from), ..Default::default() });
let p = r.render(RenderRequest { page: page.saturating_sub(1), kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0 });
let p = r.render(RenderRequest { page: page_index, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0 });
if let Some(e) = p.error {
return Err(e.into());
}
@ -362,7 +410,7 @@ fn render(args: &[String]) -> Result<(), CliError> {
}
};
std::fs::write(out, bytes).map_err(|e| format!("{out}: {e}"))?;
eprintln!("rendered page {page} at {dpi} dpi: {}×{} px in {} ms", p.width, p.height, p.millis);
let _ = writeln!(std::io::stderr().lock(), "rendered page {page} at {dpi} dpi: {}×{} px in {} ms", p.width, p.height, p.millis);
Ok(())
}
@ -579,8 +627,14 @@ fn run(args: &[String]) -> Result<(), CliError> {
#[cfg(feature = "mcp")]
fn mcp(args: &[String]) -> Result<(), CliError> {
let mut server = pdfcraft_automation::mcp::McpServer::new(automation(args)?);
eprintln!("pdfcraft-cli: MCP server on stdio (protocol {}); close stdin to stop", pdfcraft_automation::mcp::PROTOCOL_VERSIONS[0]);
let compact = args.iter().any(|a| a == "--compact");
let mut server = pdfcraft_automation::mcp::McpServer::new(automation(args)?).with_compact(compact);
let _ = writeln!(
std::io::stderr().lock(),
"pdfcraft-cli: MCP server on stdio (protocol {}{}); close stdin to stop",
pdfcraft_automation::mcp::PROTOCOL_VERSIONS[0],
if compact { ", compact tool list" } else { "" }
);
server.serve(std::io::stdin().lock(), std::io::stdout().lock()).map_err(|e| CliError::Message(e.to_string()))
}

View File

@ -91,3 +91,91 @@ fn the_documented_options_still_edit_and_save() {
assert_eq!(info.title.as_deref(), Some("Rotated"), "{name}");
}
}
mod one_based_arguments {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
struct TestDir(PathBuf);
impl TestDir {
fn new() -> Self {
static NEXT: AtomicU64 = AtomicU64::new(0);
for _ in 0..128 {
let path =
std::env::temp_dir().join(format!("pdfcraft-cli-one-based-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
match std::fs::create_dir(&path) {
Ok(()) => return Self(path),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => panic!("cannot create test directory: {e}"),
}
}
panic!("cannot reserve test directory");
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn zero_render_page_and_edit_positions_fail_before_io() {
let dir = TestDir::new();
let source = fixture();
std::fs::write(dir.0.join("input.pdf"), &source).unwrap();
for input in ["input.pdf", "missing.pdf"] {
for destination in ["output.pam", "new.pam", "input.pdf"] {
std::fs::write(dir.0.join("output.pam"), b"keep destination").unwrap();
for (command, option, value, diagnostic) in [
("render", "--page", "0", "--page must be at least 1"),
("edit", "--move", "1:0", "--move target must be at least 1"),
("edit", "--insert-blank", "0", "--insert-blank must be at least 1"),
] {
let out = Command::new(BIN).current_dir(&dir.0).args([command, input, "--out", destination, option, value]).output().unwrap();
assert_eq!(out.status.code(), Some(1), "{command} {input} {option} {value}");
assert_eq!(out.stderr, format!("pdfcraft-cli: {diagnostic}\n").as_bytes());
assert!(out.stdout.is_empty());
assert_eq!(std::fs::read(dir.0.join("input.pdf")).unwrap(), source);
assert_eq!(std::fs::read(dir.0.join("output.pam")).unwrap(), b"keep destination");
assert!(!dir.0.join("new.pam").exists());
assert!(!dir.0.join("missing.pdf").exists());
}
}
}
assert_eq!(std::fs::read_dir(&dir.0).unwrap().count(), 2);
}
#[test]
fn first_render_page_move_target_and_insertion_position_remain_usable() {
let dir = TestDir::new();
let source = fixture();
std::fs::write(dir.0.join("input.pdf"), &source).unwrap();
for output in ["page.pam", "page.png"] {
let out =
Command::new(BIN).current_dir(&dir.0).args(["render", "input.pdf", "--page", "1", "--dpi", "18", "--out", output]).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let bytes = std::fs::read(dir.0.join(output)).unwrap();
if output.ends_with(".pam") {
let header = b"P7\nWIDTH 50\nHEIGHT 75\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n";
assert!(bytes.starts_with(header));
assert_eq!(bytes.len(), header.len() + 50 * 75 * 4);
} else {
assert!(bytes.starts_with(b"\x89PNG\r\n\x1a\n"));
assert_eq!(u32::from_be_bytes(bytes[16..20].try_into().unwrap()), 50);
assert_eq!(u32::from_be_bytes(bytes[20..24].try_into().unwrap()), 75);
}
}
let out = Command::new(BIN)
.current_dir(&dir.0)
.args(["edit", "input.pdf", "--out", "edited.pdf", "--move", "1:1", "--rotate", "1:90", "--insert-blank", "1"])
.output()
.unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
assert!(out.stdout.is_empty() && out.stderr.is_empty());
let info = pdfcraft_render::inspect(std::sync::Arc::new(std::fs::read(dir.0.join("edited.pdf")).unwrap()), None).unwrap();
assert_eq!(info.pages.len(), 2);
assert_eq!((info.pages[0].width, info.pages[0].height, info.pages[0].rotation), (612.0, 792.0, 0));
assert_eq!((info.pages[1].width, info.pages[1].height, info.pages[1].rotation), (300.0, 200.0, 90));
assert_eq!(std::fs::read(dir.0.join("input.pdf")).unwrap(), source);
}
}

View File

@ -0,0 +1,65 @@
//! `--help` prints the full usage to stdout and succeeds; a missing or unknown command fails with
//! the usage (or a pointer to `--help`) on stderr.
use std::process::{Command, Output};
const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
fn run(args: &[&str]) -> Output {
Command::new(BIN).args(args).output().unwrap()
}
fn text(bytes: &[u8]) -> String {
String::from_utf8_lossy(bytes).into_owned()
}
#[test]
fn help_flags_print_full_usage_to_stdout() {
for args in [&["--help"][..], &["-h"], &["help"], &["render", "--help"], &["check", "-h"]] {
let out = run(args);
assert!(out.status.success(), "{args:?} exited {:?}", out.status);
let stdout = text(&out.stdout);
// Per-command options, not just the command list.
assert!(stdout.contains("pdfcraft-cli render <file.pdf> --page N"), "{args:?}: {stdout}");
assert!(stdout.contains("--every N"), "{args:?}: {stdout}");
assert!(out.stderr.is_empty(), "{args:?}: {}", text(&out.stderr));
}
}
#[test]
fn no_command_fails_with_usage_on_stderr() {
let out = run(&[]);
assert!(!out.status.success());
assert!(out.stdout.is_empty());
assert!(text(&out.stderr).contains("pdfcraft-cli render <file.pdf> --page N"));
}
/// `mcp` is listed only when the build has it (`--no-default-features` leaves it out).
#[test]
fn usage_lists_mcp_only_when_built_with_it() {
let out = run(&["--help"]);
let stdout = text(&out.stdout);
assert_eq!(stdout.contains("pdfcraft-cli mcp"), cfg!(feature = "mcp"), "{stdout}");
assert_eq!(stdout.contains("--compact"), cfg!(feature = "mcp"), "{stdout}");
// The lines around it are still there.
assert!(stdout.contains("pdfcraft-cli run --script steps.json"), "{stdout}");
assert!(stdout.contains("pdfcraft-cli ui --control FILE"), "{stdout}");
}
#[cfg(not(feature = "mcp"))]
#[test]
fn mcp_without_the_feature_says_it_was_left_out() {
let out = run(&["mcp"]);
assert!(!out.status.success());
let stderr = text(&out.stderr);
assert!(stderr.contains("without the `mcp` feature"), "{stderr}");
}
#[test]
fn unknown_command_names_it_and_points_to_help() {
let out = run(&["frobnicate"]);
assert!(!out.status.success());
let stderr = text(&out.stderr);
assert!(stderr.contains("unknown command 'frobnicate'"), "{stderr}");
assert!(stderr.contains("pdfcraft-cli --help"), "{stderr}");
}

View File

@ -0,0 +1,76 @@
//! `pdfcraft-cli mcp --compact`: the flag reaches the server, `--root` is still honoured, and plain `mcp` still lists every tool.
#![cfg(feature = "mcp")]
use std::io::Write as _;
use std::process::{Command, Stdio};
use serde_json::{Value, json};
/// Run `pdfcraft-cli mcp <flags>` over an initialize request plus `requests` and return the replies to `requests`.
fn session(flags: &[&str], requests: &[Value]) -> Vec<Value> {
let mut child = Command::new(env!("CARGO_BIN_EXE_pdfcraft-cli"))
.arg("mcp")
.args(flags)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
let mut input = String::from("{\"jsonrpc\":\"2.0\",\"id\":0,\"method\":\"initialize\",\"params\":{}}\n");
for r in requests {
input.push_str(&r.to_string());
input.push('\n');
}
child.stdin.take().unwrap().write_all(input.as_bytes()).unwrap();
let out = child.wait_with_output().unwrap();
assert!(out.status.success());
String::from_utf8(out.stdout).unwrap().lines().skip(1).map(|l| serde_json::from_str(l).unwrap()).collect()
}
fn request(id: u64, method: &str, params: Value) -> Value {
json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })
}
/// The `tools/list` tool names of `pdfcraft-cli mcp <flags>`.
fn listed_tools(flags: &[&str]) -> Vec<String> {
let reply = session(flags, &[request(1, "tools/list", json!({}))]).remove(0);
reply["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap().to_string()).collect()
}
#[test]
fn compact_flag_shortens_the_tool_list() {
let full = listed_tools(&[]);
let compact = listed_tools(&["--compact"]);
assert_eq!(compact.len(), 17, "{compact:?}");
assert!(compact.iter().any(|n| n == "tool_search") && compact.iter().any(|n| n == "tool_call"));
assert!(compact.iter().all(|n| full.contains(n) || n == "tool_search" || n == "tool_call"));
assert!(full.len() > 100 && !full.iter().any(|n| n == "tool_search"), "{} tools", full.len());
// The flag can sit on either side of --root.
assert_eq!(listed_tools(&["--root", ".", "--compact"]), compact);
assert_eq!(listed_tools(&["--compact", "--root", "."]), compact);
}
#[test]
fn compact_flag_keeps_the_root() {
let root = std::env::temp_dir().join(format!("pdfcraft-cli-compact-root-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
let outside = std::env::temp_dir().join(format!("pdfcraft-cli-compact-outside-{}.pdf", std::process::id()));
let outside = outside.to_string_lossy().into_owned();
let root_arg = root.to_string_lossy().into_owned();
let open_outside =
request(1, "tools/call", json!({ "name": "tool_call", "arguments": { "name": "doc_open", "arguments": { "path": outside } } }));
let text = |reply: &Value| reply["result"]["content"][0]["text"].as_str().unwrap_or_default().to_string();
// With the root, in either flag order, a path outside it is refused with the root's own message.
for flags in [vec!["--root", root_arg.as_str(), "--compact"], vec!["--compact", "--root", root_arg.as_str()]] {
let reply = session(&flags, std::slice::from_ref(&open_outside)).remove(0);
assert_eq!(reply["result"]["isError"], true, "{flags:?}: {reply}");
assert!(text(&reply).contains("is outside the allowed directory"), "{flags:?}: {}", text(&reply));
}
// Without a root the same call gets past that check (the file does not exist, so it fails for that reason instead).
let reply = session(&["--compact"], std::slice::from_ref(&open_outside)).remove(0);
assert_eq!(reply["result"]["isError"], true, "{reply}");
assert!(!text(&reply).contains("is outside the allowed directory"), "{}", text(&reply));
let _ = std::fs::remove_dir_all(&root);
}

View File

@ -61,11 +61,19 @@ fn script_image_output_is_confined_to_the_root() {
assert!(!base.join("outside/escaped.png").exists() && !base.join("outside/new").exists());
assert_eq!(std::fs::read_to_string(base.join("outside/existing.txt")).unwrap(), "SENTINEL\n");
// "." is the root itself: refused, without staging a temporary file beside the root.
// "." is the root itself: refused, without staging a temporary file beside the root. The
// file at the old fixed staging name and the listing are canaries; staging names are random.
std::fs::write(base.join(".root.pdfcraft-tmp"), "SENTINEL\n").unwrap();
let listing = || {
let mut names: Vec<_> = std::fs::read_dir(&base).unwrap().map(|e| e.unwrap().file_name()).collect();
names.sort();
names
};
let before = listing();
let o = render_to(&base, &base, Some(&root), ".");
assert!(!o.status.success() && String::from_utf8_lossy(&o.stderr).contains("is a folder"), "{}", String::from_utf8_lossy(&o.stderr));
assert_eq!(std::fs::read_to_string(base.join(".root.pdfcraft-tmp")).unwrap(), "SENTINEL\n");
assert_eq!(listing(), before, "nothing was left beside the root");
// A relative path resolves inside the root, not in the working directory.
let o = render_to(&base, &base, Some(&root), "relative.png");

View File

@ -0,0 +1,132 @@
//! Diagnostic stderr failures must not replace a command's real result.
#![cfg(target_os = "linux")]
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Output, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
struct Scratch(PathBuf);
impl Scratch {
fn new() -> Self {
static NEXT: AtomicU64 = AtomicU64::new(0);
for _ in 0..128 {
let path = std::env::temp_dir().join(format!("pdfcraft-stderr-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
match std::fs::create_dir(&path) {
Ok(()) => return Self(path),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(error) => panic!("creating stderr test directory: {error}"),
}
}
panic!("no unused stderr test directory after 128 attempts");
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
// Only this test's newly spawned child is ever terminated, including assertion unwinding.
struct OwnedChild(Option<Child>);
impl Drop for OwnedChild {
fn drop(&mut self) {
if let Some(child) = &mut self.0 {
let _ = child.kill();
let _ = child.wait();
}
}
}
fn run(command: &mut Command, input: Option<&str>) -> Output {
command.stdin(if input.is_some() { Stdio::piped() } else { Stdio::null() });
let mut owned = OwnedChild(Some(command.spawn().unwrap()));
if let Some(input) = input {
owned.0.as_mut().unwrap().stdin.take().unwrap().write_all(input.as_bytes()).unwrap();
}
// Every fixture emits only a few short lines, below the pipe capacity.
let deadline = Instant::now() + Duration::from_secs(30);
while owned.0.as_mut().unwrap().try_wait().unwrap().is_none() {
assert!(Instant::now() < deadline, "owned CLI child exceeded thirty seconds");
std::thread::sleep(Duration::from_millis(10));
}
owned.0.take().unwrap().wait_with_output().unwrap()
}
fn full() -> Stdio {
std::fs::OpenOptions::new().write(true).open("/dev/full").unwrap().into()
}
fn render(pdf: &Path, out: &Path, stderr: Stdio) -> Output {
run(Command::new(BIN).arg("render").arg(pdf).args(["--page", "1", "--dpi", "18", "--out"]).arg(out).stdout(Stdio::piped()).stderr(stderr), None)
}
#[test]
fn failing_diagnostics_preserve_render_success_and_real_text_or_file_errors() {
let dir = Scratch::new();
let bytes = pdfcraft_engine::Session::new().create_from_text("Diagnostic test", "Ordinary diagnostic text").unwrap();
let pdf = dir.0.join("input.pdf");
std::fs::write(&pdf, bytes.as_slice()).unwrap();
let image_path = dir.0.join("page.pam");
let failed_sink = render(&pdf, &image_path, full());
assert!(failed_sink.status.success(), "completed render must survive failed diagnostics: {}", failed_sink.status);
assert!(failed_sink.stdout.is_empty());
let image = std::fs::read(&image_path).unwrap();
assert!(image.starts_with(b"P7\nWIDTH ") && image.windows(7).any(|part| part == b"ENDHDR\n"));
let ordinary = render(&pdf, &image_path, Stdio::piped());
assert!(ordinary.status.success());
assert!(String::from_utf8_lossy(&ordinary.stderr).contains("rendered page 1 at 18 dpi"));
assert_eq!(std::fs::read(&image_path).unwrap(), image, "diagnostic destination must not change rendered bytes");
let invalid = run(Command::new(BIN).arg("text").arg(&pdf).args(["--page", "2"]).stdout(Stdio::piped()).stderr(full()), None);
assert_eq!(invalid.status.code(), Some(1), "page errors remain normal failures rather than stderr panics");
assert!(invalid.stdout.is_empty());
let valid = run(Command::new(BIN).arg("text").arg(&pdf).stdout(Stdio::piped()).stderr(full()), None);
assert!(valid.status.success());
assert!(String::from_utf8_lossy(&valid.stdout).contains("Ordinary diagnostic text"));
// An existing directory with a supported extension reaches the real file writer.
// Using /dev/full as the render path would only test upstream's extension check.
let folder = dir.0.join("folder.pam");
std::fs::create_dir(&folder).unwrap();
std::fs::write(folder.join("keep"), b"sentinel").unwrap();
assert_eq!(render(&pdf, &folder, full()).status.code(), Some(1));
assert_eq!(std::fs::read(folder.join("keep")).unwrap(), b"sentinel");
assert_eq!(std::fs::read(&image_path).unwrap(), image);
assert_eq!(std::fs::read(&pdf).unwrap(), bytes.as_slice());
}
#[cfg(feature = "mcp")]
#[test]
fn failing_startup_diagnostics_preserve_mcp_replies_and_protocol_output_errors() {
let requests = [
serde_json::json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-06-18" } }),
serde_json::json!({ "jsonrpc": "2.0", "id": 2, "method": "ping" }),
serde_json::json!({ "jsonrpc": "2.0", "id": 3, "method": "ordinary-unknown-method" }),
];
let input = requests.iter().map(|request| format!("{request}\n")).collect::<String>();
let output = run(Command::new(BIN).arg("mcp").stdout(Stdio::piped()).stderr(full()), Some(&input));
assert!(output.status.success(), "failed startup diagnostics must not stop stdio: {}", output.status);
let replies: Vec<serde_json::Value> = String::from_utf8(output.stdout).unwrap().lines().map(|line| serde_json::from_str(line).unwrap()).collect();
assert_eq!(replies.len(), 3);
assert_eq!(replies[0]["id"], 1);
assert_eq!(replies[0]["result"]["protocolVersion"], "2025-06-18");
assert_eq!(replies[1]["id"], 2);
assert_eq!(replies[1]["result"], serde_json::json!({}));
assert_eq!(replies[2]["id"], 3);
assert_eq!(replies[2]["error"]["code"], -32601);
// Upstream's compact mode still reaches stdio even when its expanded diagnostic fails.
let ping = "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"ping\"}\n";
for (stderr, diagnostic_expected) in [(full(), false), (Stdio::piped(), true)] {
let output = run(Command::new(BIN).args(["mcp", "--compact"]).stdout(Stdio::piped()).stderr(stderr), Some(ping));
assert!(output.status.success());
let reply: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(reply["id"], 4);
assert_eq!(reply["result"], serde_json::json!({}));
if diagnostic_expected {
assert!(String::from_utf8_lossy(&output.stderr).contains(", compact tool list); close stdin to stop"));
}
}
let output = run(Command::new(BIN).arg("mcp").stdout(full()).stderr(full()), Some("{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"ping\"}\n"));
assert_eq!(output.status.code(), Some(1), "actual protocol output failure must still fail");
}

View File

@ -95,3 +95,117 @@ fn text_of_a_protected_document_with_the_password_succeeds() {
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("Page 1") && stdout.contains("Page 3"), "{stdout}");
}
mod page_argument_tests {
use super::*;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
struct PageDir(PathBuf);
impl PageDir {
fn new() -> Self {
static NEXT: AtomicU64 = AtomicU64::new(0);
for _ in 0..128 {
let serial = NEXT.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!("pdfcraft-page-{}-{serial}", std::process::id()));
match std::fs::create_dir(&path) {
Ok(()) => return Self(path),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(error) => panic!("creating page test directory: {error}"),
}
}
panic!("no unused page test directory after 128 attempts");
}
}
impl Drop for PageDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn malformed_page_values_fail_before_reading_or_printing_document_text() {
let dir = PageDir::new();
let path = dir.0.join("three.pdf");
let bytes = fixture(3);
std::fs::write(&path, &bytes).unwrap();
let missing = dir.0.join("missing.pdf");
let invalid = [
vec!["--page", "0"],
vec!["--page", ""],
vec!["--page", "bad"],
vec!["--page", "-1"],
vec!["--page", "1.5"],
vec!["--page", "18446744073709551616"],
vec!["--page"],
vec!["--page", "--password", "unused"],
vec!["--page", "2", "--page"],
];
for input in [&path, &missing] {
for args in &invalid {
let out = Command::new(BIN).arg("text").arg(input).args(args).output().unwrap();
assert_eq!(out.status.code(), Some(1), "{args:?}: {out:?}");
assert!(out.stdout.is_empty(), "{args:?}: no text may be printed");
assert_eq!(String::from_utf8(out.stderr).unwrap().trim(), "pdfcraft-cli: bad --page: expected a positive page number");
assert_eq!(std::fs::read(&path).unwrap(), bytes);
assert!(!missing.exists());
}
}
}
#[test]
fn valid_page_values_preserve_all_page_selection_and_first_duplicate_semantics() {
let dir = PageDir::new();
let path = dir.0.join("three.pdf");
let bytes = fixture(3);
std::fs::write(&path, &bytes).unwrap();
for (args, expected) in [
(vec![], vec!["Page 1", "Page 2", "Page 3"]),
(vec!["--page", "1"], vec!["Page 1"]),
(vec!["--page", "2"], vec!["Page 2"]),
(vec!["--page", "3"], vec!["Page 3"]),
(vec!["--page", "2", "--page", "1"], vec!["Page 2"]),
] {
let out = Command::new(BIN).arg("text").arg(&path).args(args).output().unwrap();
assert!(out.status.success(), "{out:?}");
assert!(out.stderr.is_empty());
let text = String::from_utf8(out.stdout).unwrap();
assert_eq!(text.split(' ').map(str::trim).collect::<Vec<_>>(), expected);
assert_eq!(std::fs::read(&path).unwrap(), bytes);
}
}
#[test]
fn an_option_shaped_password_is_not_parsed_as_a_page_flag() {
let dir = PageDir::new();
let source = dir.0.join("three.pdf");
let bytes = fixture(3);
std::fs::write(&source, &bytes).unwrap();
let protected = dir.0.join("protected.pdf");
let script = dir.0.join("protect.json");
let steps = serde_json::json!([
{ "tool": "doc_open", "args": { "path": source } },
{ "tool": "doc_protect", "args": { "doc": 1, "open_password": "--page" } },
{ "tool": "doc_save", "args": { "doc": 1, "path": protected } },
]);
std::fs::write(&script, steps.to_string()).unwrap();
let out = Command::new(BIN).args(["run", "--script"]).arg(&script).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let protected_bytes = std::fs::read(&protected).unwrap();
for (args, expected) in [
(vec!["--password", "--page"], vec!["Page 1", "Page 2", "Page 3"]),
(vec!["--page", "2", "--password", "--page"], vec!["Page 2"]),
(vec!["--password", "--page", "--page", "2"], vec!["Page 2"]),
] {
let out = Command::new(BIN).arg("text").arg(&protected).args(args).output().unwrap();
assert!(out.status.success(), "{out:?}");
assert!(out.stderr.is_empty());
let text = String::from_utf8(out.stdout).unwrap();
assert_eq!(text.split(' ').map(str::trim).collect::<Vec<_>>(), expected);
assert_eq!(std::fs::read(&protected).unwrap(), protected_bytes);
assert_eq!(std::fs::read(&source).unwrap(), bytes);
}
}
}

View File

@ -14,7 +14,9 @@ workspace = true
ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "_ring"] }
rustls-native-certs = "0.8"
pdfcraft-ui-egui = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "persistence", "links"] }
# 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"] }
egui = { workspace = true }
serde_json = { workspace = true }
# Records from every crate go to standard error and the log file (src/logging.rs).

View File

@ -19,6 +19,9 @@
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::cell::Cell;
use std::rc::Rc;
use pdfcraft_ui_egui::PdfCraftApp;
#[cfg(target_os = "macos")]
@ -39,16 +42,24 @@ const APP_ICON_PNG: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256
/// The app was called PrintCraft before; settings saved then are under this key.
const LEGACY_STORAGE_KEY: &str = "printcraft";
/// The settings folder: `app.ron` and the `logs` folder (docs/development.md). eframe would
/// otherwise derive it from the app id; keep it under "PdfCraft".
/// The settings folder: `app.ron` and the `logs` folder (docs/development.md). In portable mode
/// it is `PdfCraftData` beside the executable (#157). eframe would otherwise derive it from the
/// app id; keep it under "PdfCraft".
fn settings_dir() -> Option<std::path::PathBuf> {
if let Some(dir) = pdfcraft_ui_egui::portable::data_dir() {
return Some(dir.to_path_buf());
}
eframe::storage_dir("PdfCraft")
}
/// Move the settings and crash-recovery folders of the app's former name, PrintCraft, to the new
/// name once, so an upgrade keeps recent files, preferences and unsaved work. Best effort: a
/// folder is left alone when the new one already exists or the move fails.
/// folder is left alone when the new one already exists or the move fails. A portable copy leaves
/// the per-user folders alone.
fn migrate_legacy_folders() {
if pdfcraft_ui_egui::portable::data_dir().is_some() {
return;
}
let mut moves = vec![(eframe::storage_dir("PrintCraft"), settings_dir())];
// Recovery lives in the settings folder except on Windows, where it is under %LOCALAPPDATA%.
if cfg!(windows) {
@ -70,6 +81,37 @@ fn migrate_legacy_folders() {
}
}
/// Desktop launchers (GNOME Files, KDE Dolphin…) only recognise an app as the default handler for a
/// mime type if its `Exec` takes URIs (`%u`/`%U`), not just paths (`%F`); the packaged `.desktop`
/// file uses `%U` accordingly (packaging/linux/ai.storyteller.pdfcraft.desktop). Decode a local
/// `file://` argument (`file:///path` or `file://localhost/path`) to a plain path here so the rest
/// of the app, which only ever opens paths, is unaffected. Other schemes (`http://`, `mailto:`…),
/// URIs naming another host, and plain paths pass through untouched.
fn path_from_arg(arg: String) -> String {
let Some(rest) = arg.strip_prefix("file://") else { return arg };
let rest = rest.strip_prefix("localhost").unwrap_or(rest);
if !rest.starts_with('/') {
return arg;
}
// `file:///C:/x.pdf` on Windows names `C:/x.pdf`. `rest` starts with the one-byte '/', so
// byte 1 is a char boundary.
let rest = if cfg!(windows) && rest.as_bytes().get(2) == Some(&b':') { &rest[1..] } else { rest };
let mut out = Vec::with_capacity(rest.len());
let mut bytes = rest.bytes();
while let Some(b) = bytes.next() {
if b == b'%' {
let hex = bytes.clone().take(2).collect::<Vec<u8>>();
if let Some(byte) = std::str::from_utf8(&hex).ok().filter(|h| h.len() == 2).and_then(|h| u8::from_str_radix(h, 16).ok()) {
out.push(byte);
bytes.nth(1);
continue;
}
}
out.push(b);
}
String::from_utf8(out).unwrap_or(arg)
}
fn main() -> eframe::Result {
// First, so the panic hook and every start-up warning are recorded (`logging`).
let logger = logging::install();
@ -107,10 +149,89 @@ fn main() -> eframe::Result {
let value = args.next().unwrap_or_default();
options.push((flag.trim_start_matches("--").to_string(), value));
}
_ => files.push(a),
_ => files.push(path_from_arg(a)),
}
}
let integrated = cfg!(target_os = "macos");
migrate_legacy_folders();
// The log file lives in the settings folder; opened after the arguments (so `--version` leaves
// no file behind) and after the PrintCraft migration (which a fresh folder would block).
// Records logged until now are written to it first.
if let (Some(logger), Some(dir)) = (logger, settings_dir()) {
match logger.attach_dir(&dir.join("logs")) {
Ok(path) => log::info!("PdfCraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()),
// Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics.
Err(e) => log::warn!("no log file: {e}"),
}
}
let choice = renderer_choice(std::env::var("PDFCRAFT_RENDERER").ok().as_deref());
let launch = Launch { files, options, control_file, create_images, integrated };
// 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")]
let apple_events = apple_events::AppleEvents::install();
// Set once the app is created, which is after the renderer has started.
let started = Rc::new(Cell::new(false));
let first = if choice == RendererChoice::Gl { eframe::Renderer::Glow } else { eframe::Renderer::Wgpu };
let result = eframe::run_native(
"PdfCraft",
native_options(integrated, first),
app_creator(
launch.clone(),
Rc::clone(&started),
#[cfg(target_os = "macos")]
&apple_events,
),
);
match result {
// Only a renderer that couldn't start: without a window or display at all, OpenGL can't
// help either, and winit's own error says more.
Err(e @ eframe::Error::Wgpu(_)) if retry_with_gl(choice, started.get()) => {
// Old or unusual GPUs and drivers (#461, #435, #392) can't give wgpu a device; OpenGL
// usually still works there, so that's better than quitting.
log::error!("the GPU renderer (wgpu) didn't start: {e}. Starting with OpenGL instead; set PDFCRAFT_RENDERER=gl to skip wgpu.");
eframe::run_native(
"PdfCraft",
native_options(integrated, eframe::Renderer::Glow),
app_creator(
launch,
started,
#[cfg(target_os = "macos")]
&apple_events,
),
)
}
other => other,
}
}
/// Which renderer to start with, from `PDFCRAFT_RENDERER`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum RendererChoice {
/// wgpu, then OpenGL if wgpu can't start (unset, or anything unrecognised).
Auto,
/// wgpu only (`wgpu`): a failure is reported, not worked around.
Wgpu,
/// OpenGL only (`gl`, `opengl` or `glow`): for drivers where wgpu starts but misbehaves.
Gl,
}
fn renderer_choice(value: Option<&str>) -> RendererChoice {
match value.map(|v| v.trim().to_ascii_lowercase()).as_deref() {
Some("wgpu") => RendererChoice::Wgpu,
Some("gl" | "opengl" | "glow") => RendererChoice::Gl,
_ => RendererChoice::Auto,
}
}
/// Whether a failed run should be retried with OpenGL: only when wgpu was tried first by choice of
/// nobody, and it failed before the app was created (so while starting the renderer, not later).
fn retry_with_gl(choice: RendererChoice, app_started: bool) -> bool {
choice == RendererChoice::Auto && !app_started
}
/// The window and renderer settings for one run.
fn native_options(integrated: bool, renderer: eframe::Renderer) -> eframe::NativeOptions {
let mut viewport = egui::ViewportBuilder::default()
.with_title("PdfCraft")
.with_inner_size([1440.0, 920.0])
@ -126,90 +247,130 @@ fn main() -> eframe::Result {
if integrated {
viewport = viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false);
}
migrate_legacy_folders();
// The log file lives in the settings folder; opened after the arguments (so `--version` leaves
// no file behind) and after the PrintCraft migration (which a fresh folder would block).
// Records logged until now are written to it first.
if let (Some(logger), Some(dir)) = (logger, settings_dir()) {
match logger.attach_dir(&dir.join("logs")) {
Ok(path) => log::info!("PdfCraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()),
// Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics.
Err(e) => log::warn!("no log file: {e}"),
}
}
let persistence_path = settings_dir().map(|d| d.join("app.ron"));
let mut native = eframe::NativeOptions { viewport, persistence_path, ..Default::default() };
configure_gpu(&mut native);
// 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")]
let apple_events = apple_events::AppleEvents::install();
#[cfg(target_os = "macos")]
let apple_events = &apple_events;
eframe::run_native(
"PdfCraft",
native,
Box::new(move |cc| {
let mut app = PdfCraftApp::new();
if let Some(json) = cc.storage.and_then(|s| s.get_string("pdfcraft").or_else(|| s.get_string(LEGACY_STORAGE_KEY))) {
app.restore(&json);
// Set explicitly, so the renderer never depends on which one eframe defaults to.
let mut native = eframe::NativeOptions { viewport, persistence_path, renderer, ..Default::default() };
if renderer == eframe::Renderer::Wgpu {
configure_gpu(&mut native);
}
native
}
/// What the command line asked for, kept so a second run (with OpenGL) can start the same way.
#[derive(Clone)]
struct Launch {
files: Vec<String>,
options: Vec<(String, String)>,
control_file: Option<String>,
create_images: bool,
integrated: bool,
}
fn app_creator<'a>(
launch: Launch,
started: Rc<Cell<bool>>,
#[cfg(target_os = "macos")] apple_events: &'a apple_events::AppleEvents,
) -> eframe::AppCreator<'a> {
let Launch { files, options, control_file, create_images, integrated } = launch;
Box::new(move |cc| {
started.set(true);
let mut app = PdfCraftApp::new();
if let Some(json) = cc.storage.and_then(|s| s.get_string("pdfcraft").or_else(|| s.get_string(LEGACY_STORAGE_KEY))) {
app.restore(&json);
}
app.integrated_titlebar = integrated;
app.update_source = Some(std::sync::Arc::new(updates::latest_release));
app.os_key_store_ids = cfg!(any(target_os = "macos", target_os = "windows"));
#[cfg(target_os = "macos")]
{
app.os_events = Some(apple_events.connect(&cc.egui_ctx));
}
if let Some(file) = &control_file {
let client = app.attach_control(&cc.egui_ctx);
match pdfcraft_ui_egui::control::serve(client).and_then(|ep| write_control_file(file, ep.port, &ep.token).map(|()| ep.port)) {
// Never the token (AGENTS.md §3): it stays in the owner-only file.
Ok(port) => log::info!("UI control channel on 127.0.0.1:{port} (connection details in {file})"),
Err(e) => log::error!("--control {file}: {e}"),
}
app.integrated_titlebar = integrated;
app.update_source = Some(std::sync::Arc::new(updates::latest_release));
app.os_key_store_ids = cfg!(any(target_os = "macos", target_os = "windows"));
#[cfg(target_os = "macos")]
{
app.os_events = Some(apple_events.connect(&cc.egui_ctx));
}
// Autosave unsaved changes; offer to recover documents a crashed session left behind.
if let Some(dir) = pdfcraft_ui_egui::RecoveryStore::default_dir() {
app.enable_recovery(pdfcraft_ui_egui::RecoveryStore::new(dir));
}
// A portable marker whose data folder can't be written (#157): say where settings went.
if let Some(w) = &pdfcraft_ui_egui::portable::current().unwritable {
app.notify_fmt(
"Portable mode is off: {folder} can't be written ({error}). Settings are kept in your user folder instead.",
&[("folder", &w.folder.display().to_string()), ("error", &w.error)],
);
}
if create_images {
if let Err(e) = app.begin_image_import_paths(&files) {
app.notify(e);
}
if let Some(file) = &control_file {
let client = app.attach_control(&cc.egui_ctx);
match pdfcraft_ui_egui::control::serve(client).and_then(|ep| write_control_file(file, ep.port, &ep.token).map(|()| ep.port)) {
// Never the token (AGENTS.md §3): it stays in the owner-only file.
Ok(port) => log::info!("UI control channel on 127.0.0.1:{port} (connection details in {file})"),
Err(e) => log::error!("--control {file}: {e}"),
}
} else {
// With the preference on, last session's files come back first; files named on
// the command line open after them, in front (#442).
app.reopen_last_files(&files);
for f in files {
app.open_path(&f);
}
// Autosave unsaved changes; offer to recover documents a crashed session left behind.
if let Some(dir) = pdfcraft_ui_egui::RecoveryStore::default_dir() {
app.enable_recovery(pdfcraft_ui_egui::RecoveryStore::new(dir));
}
for (k, v) in options {
if let Err(e) = app.set_option(&k, &v) {
log::warn!("--{k} {v}: {e}");
}
if create_images {
if let Err(e) = app.begin_image_import_paths(&files) {
app.notify(e);
}
} else {
for f in files {
app.open_path(&f);
}
}
for (k, v) in options {
if let Err(e) = app.set_option(&k, &v) {
log::warn!("--{k} {v}: {e}");
}
}
Ok(Box::new(app))
}),
)
}
Ok(Box::new(app))
})
}
/// 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.
fn write_control_file(path: &str, port: u16, token: &str) -> std::io::Result<()> {
use std::io::{ErrorKind, Write};
let json = serde_json::json!({ "port": port, "token": token, "pid": std::process::id() }).to_string();
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
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"))?;
// 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();
for i in 0..16u32 {
let mut staged = std::ffi::OsString::from(".");
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),
};
// 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);
}
return done;
}
use std::io::Write;
let mut f = opts.open(path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
f.set_permissions(std::fs::Permissions::from_mode(0o600))?;
}
f.write_all(json.as_bytes())
Err(std::io::Error::new(ErrorKind::AlreadyExists, "no free name for the control file's temporary copy"))
}
/// How wgpu finds a GPU. Each choice yields to its wgpu environment variable.
@ -220,7 +381,9 @@ fn write_control_file(path: &str, port: u16, token: &str) -> std::io::Result<()>
/// It also saves battery. Machines with one GPU are unaffected.
/// - On Linux, draw on a GPU that a monitor is plugged into. On a desktop whose monitors all hang
/// off the discrete GPU, drawing on the integrated one leaves the window black under Wayland
/// compositors on NVIDIA. Among the GPUs that drive a display, the integrated one still wins.
/// compositors on NVIDIA. When several GPUs drive a display, the one driving a built-in panel
/// wins (a hybrid laptop, issue #8); without a built-in panel (a desktop with a monitor on each
/// GPU, issue #445) the discrete one wins, as the integrated one may fail to present there.
/// - On Windows, use Direct3D 12, falling back to OpenGL, and never load Vulkan drivers unless
/// `WGPU_BACKEND` asks for them. Creating a Vulkan instance loads every installed Vulkan driver
/// into the process, and a faulty one (an Intel driver in issue #37) crashed PdfCraft before
@ -256,58 +419,125 @@ fn configure_gpu(native: &mut eframe::NativeOptions) {
}
}
/// PCI `(vendor, device)` ids of the GPUs with a connected monitor, read from the DRM connectors
/// under `drm` (`card1-DP-3/status` is `connected`, `card1/device/{vendor,device}` hold `0x10de`).
/// A GPU with a connected monitor.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
struct DisplayGpu {
/// PCI `(vendor, device)` ids.
pci: (u32, u32),
/// Whether one of its connected connectors is a built-in panel (`eDP`, `LVDS` or `DSI`).
internal_panel: bool,
}
/// Whether a DRM connector name (`eDP-1`, `LVDS-1`, `DSI-1`) is a laptop's built-in panel.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn is_internal_panel(connector: &str) -> bool {
["eDP", "LVDS", "DSI"].iter().any(|kind| connector.starts_with(kind))
}
/// The GPUs with a connected monitor, read from the DRM connectors under `drm` (`card1-DP-3/status`
/// is `connected`, `card1/device/{vendor,device}` hold `0x10de`).
#[cfg(target_os = "linux")]
fn linux_display_gpus(drm: &std::path::Path) -> Vec<(u32, u32)> {
fn linux_display_gpus(drm: &std::path::Path) -> Vec<DisplayGpu> {
let read_hex = |p: std::path::PathBuf| -> Option<u32> {
let s = std::fs::read_to_string(p).ok()?;
u32::from_str_radix(s.trim().trim_start_matches("0x"), 16).ok()
};
let mut gpus = Vec::new();
let mut gpus: Vec<DisplayGpu> = Vec::new();
let Ok(entries) = std::fs::read_dir(drm) else { return gpus };
// A machine has a handful of connectors; the cap only bounds a pathological sysfs.
for entry in entries.flatten().take(256) {
let name = entry.file_name();
let Some((card, _connector)) = name.to_str().and_then(|n| n.split_once('-')) else { continue };
let Some((card, connector)) = name.to_str().and_then(|n| n.split_once('-')) else { continue };
let connected = std::fs::read_to_string(entry.path().join("status")).is_ok_and(|s| s.trim() == "connected");
if !connected {
continue;
}
let device = drm.join(card).join("device");
if let (Some(v), Some(d)) = (read_hex(device.join("vendor")), read_hex(device.join("device")))
&& !gpus.contains(&(v, d))
{
gpus.push((v, d));
let (Some(v), Some(d)) = (read_hex(device.join("vendor")), read_hex(device.join("device"))) else { continue };
let internal_panel = is_internal_panel(connector);
match gpus.iter_mut().find(|g| g.pci == (v, d)) {
Some(gpu) => gpu.internal_panel |= internal_panel,
None => gpus.push(DisplayGpu { pci: (v, d), internal_panel }),
}
}
gpus
}
/// Index of the adapter to draw with: one that drives a display (by PCI ids) first, then the most
/// frugal kind — integrated, discrete, other, virtual, software.
/// Index of the adapter to draw with: one that drives a display (by PCI ids) first. Among several
/// of those, the one driving a built-in panel (a hybrid laptop, #8), or else, with no built-in
/// panel anywhere (a desktop with a monitor on each GPU, #445), the discrete one. Otherwise the
/// most frugal kind: integrated, discrete, other, virtual, software.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn pick_adapter(adapters: &[(u32, u32, eframe::wgpu::DeviceType)], displays: &[(u32, u32)]) -> Option<usize> {
fn pick_adapter(adapters: &[(u32, u32, eframe::wgpu::DeviceType)], displays: &[DisplayGpu]) -> Option<usize> {
use eframe::wgpu::DeviceType;
let display = |pci: (u32, u32)| displays.iter().find(|g| g.pci == pci);
let multi_gpu_desktop = displays.len() > 1 && !displays.iter().any(|g| g.internal_panel);
adapters
.iter()
.enumerate()
.min_by_key(|(_, (vendor, device, kind))| {
let drives_display = displays.contains(&(*vendor, *device));
let frugality = match kind {
DeviceType::IntegratedGpu => 0,
DeviceType::DiscreteGpu => 1,
DeviceType::Other => 2,
DeviceType::VirtualGpu => 3,
DeviceType::Cpu => 4,
let shown = display((*vendor, *device));
let drives_display = shown.is_some();
let drives_panel = shown.is_some_and(|g| g.internal_panel);
let rank = match kind {
// On a desktop with monitors on both, the integrated GPU can accept the window and
// still fail to present to it (#445).
DeviceType::DiscreteGpu if drives_display && multi_gpu_desktop => 0,
DeviceType::IntegratedGpu => 1,
DeviceType::DiscreteGpu => 2,
DeviceType::Other => 3,
DeviceType::VirtualGpu => 4,
DeviceType::Cpu => 5,
};
(!drives_display, frugality)
(!drives_display, !drives_panel, rank)
})
.map(|(i, _)| i)
}
#[cfg(test)]
mod tests {
#[test]
fn path_from_arg_decodes_file_uris() {
assert_eq!(super::path_from_arg("file:///home/alice/report.pdf".to_string()), "/home/alice/report.pdf");
assert_eq!(super::path_from_arg("file:///home/alice/my%20report.pdf".to_string()), "/home/alice/my report.pdf");
assert_eq!(super::path_from_arg("file://localhost/tmp/a%C3%A9.pdf".to_string()), "/tmp/aé.pdf");
// Malformed or truncated escapes are kept as written; bytes that aren't UTF-8 keep the URI.
assert_eq!(super::path_from_arg("file:///tmp/100%.pdf".to_string()), "/tmp/100%.pdf");
assert_eq!(super::path_from_arg("file:///tmp/a%2".to_string()), "/tmp/a%2");
assert_eq!(super::path_from_arg("file:///tmp/%zz%".to_string()), "/tmp/%zz%");
assert_eq!(super::path_from_arg("file:///tmp/%FF.pdf".to_string()), "file:///tmp/%FF.pdf");
}
#[test]
fn path_from_arg_leaves_plain_paths_and_other_schemes_alone() {
assert_eq!(super::path_from_arg("report.pdf".to_string()), "report.pdf");
assert_eq!(super::path_from_arg("/home/alice/report.pdf".to_string()), "/home/alice/report.pdf");
assert_eq!(super::path_from_arg("https://example.com/report.pdf".to_string()), "https://example.com/report.pdf");
assert_eq!(super::path_from_arg("file://server/share/a.pdf".to_string()), "file://server/share/a.pdf");
assert_eq!(super::path_from_arg("file://".to_string()), "file://");
}
#[test]
fn opengl_is_the_fallback_unless_a_renderer_was_chosen() {
use super::{RendererChoice, native_options, renderer_choice, retry_with_gl};
assert_eq!(renderer_choice(None), RendererChoice::Auto);
assert_eq!(renderer_choice(Some("")), RendererChoice::Auto);
assert_eq!(renderer_choice(Some("vulkan")), RendererChoice::Auto);
assert_eq!(renderer_choice(Some(" WGPU ")), RendererChoice::Wgpu);
for gl in ["gl", "OpenGL", "glow"] {
assert_eq!(renderer_choice(Some(gl)), RendererChoice::Gl);
}
// Retried with OpenGL only when wgpu failed to start by default, never after the app ran.
assert!(retry_with_gl(RendererChoice::Auto, false));
assert!(!retry_with_gl(RendererChoice::Auto, true));
assert!(!retry_with_gl(RendererChoice::Wgpu, false));
assert!(!retry_with_gl(RendererChoice::Gl, false));
// Each run states its renderer rather than relying on eframe's default.
assert_eq!(native_options(false, eframe::Renderer::Wgpu).renderer, eframe::Renderer::Wgpu);
assert_eq!(native_options(false, eframe::Renderer::Glow).renderer, eframe::Renderer::Glow);
}
#[test]
fn gpu_backends_avoid_vulkan_on_windows_and_prefer_low_power() {
let mut native = eframe::NativeOptions::default();
@ -325,6 +555,23 @@ mod tests {
}
}
#[test]
fn winit_carries_the_windows_11_monitor_scale_fix() {
// Issue #324: winit 0.30.13 as released nudges a window dragged onto a monitor with another
// scale factor back onto the one it is leaving, so on Windows 11 it ends up on the wrong
// monitor, at the wrong size and scale. vendor/winit carries the fix from winit master. A
// dependency bump that resolves winit from crates.io again, or a re-vendored copy without
// the patch, would silently bring the bug back: re-apply the patch, or drop the copy once a
// winit 0.30 release has the fix (vendor/README.md).
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let lock = std::fs::read_to_string(root.join("Cargo.lock")).unwrap();
let winit = lock.split("[[package]]").find(|p| p.contains("\nname = \"winit\"\n")).expect("winit is in Cargo.lock");
assert!(!winit.contains("\nsource = "), "winit must resolve to vendor/winit, not:{winit}");
let dpi_changed = std::fs::read_to_string(root.join("vendor/winit/src/platform_impl/windows/event_loop.rs")).unwrap();
let patch = "if !WIN10_BUILD_VERSION.is_some_and(|build| build < 22000) {\n new_outer_rect = suggested_rect;";
assert!(dpi_changed.contains(patch), "vendor/winit lost its WM_DPICHANGED patch");
}
const NVIDIA: (u32, u32) = (0x10de, 0x2684);
const AMD_IGPU: (u32, u32) = (0x1002, 0x164e);
@ -333,29 +580,65 @@ mod tests {
vec![(NVIDIA.0, NVIDIA.1, DeviceType::DiscreteGpu), (AMD_IGPU.0, AMD_IGPU.1, DeviceType::IntegratedGpu), (0, 0, DeviceType::Cpu)]
}
const INTEL_IGPU: (u32, u32) = (0x8086, 0xa780);
fn monitor(pci: (u32, u32)) -> super::DisplayGpu {
super::DisplayGpu { pci, internal_panel: false }
}
fn panel(pci: (u32, u32)) -> super::DisplayGpu {
super::DisplayGpu { pci, internal_panel: true }
}
#[test]
fn pick_adapter_prefers_the_gpu_driving_the_monitors() {
// A desktop whose monitors are all on the discrete GPU: the integrated one shows black.
assert_eq!(super::pick_adapter(&adapters(), &[NVIDIA]), Some(0));
assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA)]), Some(0));
}
#[test]
fn pick_adapter_keeps_the_integrated_gpu_on_hybrid_laptops() {
// Issue #8: the panel is on the integrated GPU, an external monitor on the discrete one.
assert_eq!(super::pick_adapter(&adapters(), &[NVIDIA, AMD_IGPU]), Some(1));
assert_eq!(super::pick_adapter(&adapters(), &[AMD_IGPU]), Some(1));
assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA), panel(AMD_IGPU)]), Some(1));
assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)]), Some(1));
assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)]), Some(1));
assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU)]), Some(1));
}
#[test]
fn pick_adapter_prefers_the_discrete_gpu_on_desktops_with_a_monitor_on_each() {
// Issue #445: one monitor on the Intel iGPU, one on the NVIDIA card, no built-in panel.
use eframe::wgpu::DeviceType;
let desktop =
[(INTEL_IGPU.0, INTEL_IGPU.1, DeviceType::IntegratedGpu), (NVIDIA.0, NVIDIA.1, DeviceType::DiscreteGpu), (0, 0, DeviceType::Cpu)];
assert_eq!(super::pick_adapter(&desktop, &[monitor(INTEL_IGPU), monitor(NVIDIA)]), Some(1));
assert_eq!(super::pick_adapter(&desktop, &[monitor(NVIDIA), monitor(INTEL_IGPU)]), Some(1));
// A GPU driving no display still loses to one that does.
assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), monitor(INTEL_IGPU)]), Some(1));
}
#[test]
fn pick_adapter_falls_back_to_low_power_without_a_match() {
assert_eq!(super::pick_adapter(&adapters(), &[(0x8086, 0x1234)]), Some(1));
assert_eq!(super::pick_adapter(&[], &[NVIDIA]), None);
assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234))]), Some(1));
assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234)), monitor((0x8086, 0x5678))]), Some(1));
assert_eq!(super::pick_adapter(&[], &[monitor(NVIDIA)]), None);
}
#[test]
fn internal_panels_are_edp_lvds_and_dsi_connectors() {
for name in ["eDP-1", "LVDS-1", "DSI-1"] {
assert!(super::is_internal_panel(name), "{name}");
}
for name in ["DP-3", "HDMI-A-1", "DVI-D-1", "VGA-1", "Writeback-1", ""] {
assert!(!super::is_internal_panel(name), "{name}");
}
}
#[cfg(target_os = "linux")]
#[test]
fn linux_display_gpus_reads_connected_connectors() -> std::io::Result<()> {
let dir = std::env::temp_dir().join(format!("pdfcraft-drm-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let card = |name: &str, (vendor, device): (u32, u32)| -> std::io::Result<()> {
std::fs::create_dir_all(dir.join(name).join("device"))?;
std::fs::write(dir.join(name).join("device/vendor"), format!("{vendor:#06x}\n"))?;
@ -371,10 +654,92 @@ mod tests {
connector("card1-DP-4", "connected")?;
connector("card2-HDMI-A-1", "disconnected")?;
connector("card2-Writeback-1", "unknown")?;
let gpus = super::linux_display_gpus(&dir);
let desktop = super::linux_display_gpus(&dir);
// Issue #445: a monitor on the integrated GPU too, still no built-in panel.
connector("card2-HDMI-A-1", "connected")?;
let mut both = super::linux_display_gpus(&dir);
// A hybrid laptop: the integrated GPU drives the built-in panel as well.
connector("card2-eDP-1", "connected")?;
let mut laptop = super::linux_display_gpus(&dir);
std::fs::remove_dir_all(&dir)?;
assert_eq!(gpus, vec![NVIDIA]);
assert_eq!(desktop, vec![monitor(NVIDIA)]);
both.sort_by_key(|g| g.pci);
assert_eq!(both, vec![monitor(AMD_IGPU), monitor(NVIDIA)]);
laptop.sort_by_key(|g| g.pci);
assert_eq!(laptop, vec![panel(AMD_IGPU), monitor(NVIDIA)]);
assert!(super::linux_display_gpus(std::path::Path::new("/nonexistent/drm")).is_empty());
Ok(())
}
/// A fresh folder per call: tests run in parallel.
fn scratch() -> std::path::PathBuf {
static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let d = std::env::temp_dir().join(format!("pdfcraft-control-file-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// The control file's port and token, and every name in its folder.
fn written(dir: &std::path::Path) -> (serde_json::Value, Vec<String>) {
let json = serde_json::from_str(&std::fs::read_to_string(dir.join("ctl.json")).unwrap()).unwrap();
let mut names: Vec<String> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
names.sort();
(json, names)
}
#[test]
fn the_control_file_replaces_a_hard_link_instead_of_writing_through_it() {
// The file used to be opened with create + truncate, so a link planted at the path had its
// target truncated and overwritten with the token.
let dir = scratch();
std::fs::write(dir.join("victim.txt"), "keep me").unwrap();
std::fs::hard_link(dir.join("victim.txt"), dir.join("ctl.json")).unwrap();
super::write_control_file(&dir.join("ctl.json").to_string_lossy(), 4242, "t0ken").unwrap();
assert_eq!(std::fs::read_to_string(dir.join("victim.txt")).unwrap(), "keep me");
let (json, names) = written(&dir);
assert_eq!((json["port"].as_u64(), json["token"].as_str()), (Some(4242), Some("t0ken")));
assert_eq!(names, ["ctl.json", "victim.txt"], "no temporary file left behind");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_control_file_replaces_a_symlink_instead_of_writing_through_it() {
let dir = scratch();
let (link, target) = (dir.join("ctl.json"), dir.join("victim.txt"));
std::fs::write(&target, "keep me").unwrap();
#[cfg(unix)]
let made = std::os::unix::fs::symlink(&target, &link);
#[cfg(windows)]
let made = std::os::windows::fs::symlink_file(&target, &link);
if let Err(e) = made {
// Windows needs Developer Mode (or admin) for symlinks.
eprintln!("skipped: can't create a symlink here: {e}");
let _ = std::fs::remove_dir_all(&dir);
return;
}
super::write_control_file(&link.to_string_lossy(), 4242, "t0ken").unwrap();
assert_eq!(std::fs::read_to_string(&target).unwrap(), "keep me");
assert!(!std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink());
assert_eq!(written(&dir).0["token"].as_str(), Some("t0ken"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_control_file_replaces_the_one_a_previous_run_left() {
let dir = scratch();
std::fs::write(dir.join("ctl.json"), r#"{"port":1,"token":"old"}"#).unwrap();
super::write_control_file(&dir.join("ctl.json").to_string_lossy(), 4242, "t0ken").unwrap();
let (json, names) = written(&dir);
assert_eq!(json["token"].as_str(), Some("t0ken"));
assert_eq!(names, ["ctl.json"]);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(dir.join("ctl.json")).unwrap().permissions().mode();
assert_eq!(mode & 0o077, 0, "only the owner can read the token: {mode:o}");
}
let _ = std::fs::remove_dir_all(&dir);
}
}

View File

@ -22,6 +22,11 @@ real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
sync_github_name = false
[people.jcl-bit]
real_name = "James Locke"
display_name = "James Locke"
sync_github_name = false
[people.switch87]
real_name = "Gert Pellin"
display_name = "Gert Pellin"
@ -36,3 +41,26 @@ sync_github_name = false
real_name = "Hal Goff"
display_name = "Hal Goff, HG Studio"
sync_github_name = false
[people.mlkonrad]
real_name = "Marlon Konrad"
display_name = "Marlon Konrad"
sync_github_name = false
[people.chiranjeevi-max]
real_name = "Chiranjeevi Uddanti"
display_name = "Chiranjeevi U"
sync_github_name = false
[people.cyfung1031]
real_name = "Chester Fung"
display_name = "Chester Fung"
sync_github_name = true
[people.amitabhthakur]
sync_github_name = true
[people.chent11]
real_name = "Tao Chen"
display_name = "Tao Chen"
sync_github_name = false

View File

@ -144,10 +144,8 @@ fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet<i64>) -> Option<[f64;
let top = ctm.last().copied().unwrap_or(Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]));
match op.op.as_slice() {
b"q" => ctm.push(top),
b"Q" => {
if ctm.len() > 1 {
ctm.pop();
}
b"Q" if ctm.len() > 1 => {
ctm.pop();
}
b"cm" => {
if let Some(m) = Matrix::from_operands(&op.operands)

View File

@ -11,6 +11,7 @@ workspace = true
[dependencies]
pdfcraft-cos = { workspace = true }
pdfcraft-fonts = { workspace = true }
pdfcraft-model = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]

View File

@ -1,6 +1,6 @@
# pdfcraft-annot
Comments (annotations), ISO 32000-2 §12.5. Layer L3, depends only on `pdfcraft-cos`.
Comments (annotations), ISO 32000-2 §12.5. Layer L3, depends on `pdfcraft-cos`, `pdfcraft-fonts` and `pdfcraft-model`.
## What it does
@ -23,6 +23,7 @@ set_review_state(&mut doc, page, index, ReviewState::Accepted, "Ada", &meta)?;
move_annotation(&mut doc, page, index, dx, dy, &meta)?;
set_style(&mut doc, page, index, Some(rgb), Some(0.5), Some(2.0), &meta)?;
delete_annotation(&mut doc, page, index)?;
signature_image(&doc, page, index)?; // embedded image XObject of Fill & Sign signature/initials
```
A comment is addressed by `(page, index in /Annots)`, the same pair
@ -37,6 +38,12 @@ deterministic; the engine supplies them.
cloudy borders or callouts, 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;
viewers scale that appearance into the new rectangle. Locked stamps refuse the edit.
- Natural-size image stamps placed by the engine counterrotate their appearance on rotated
pages. Image restyling retains that appearance matrix. Explicit rectangles, image signatures
and PDF-page stamps still use their existing placement behavior; page-rotation support for
those paths is not complete.
## Not yet

View File

@ -26,23 +26,98 @@ pub fn cloud_radius(w: f64) -> f64 {
6.0 + 1.5 * w.max(0.0)
}
/// Draw a line ending (`/LE`) at `tip`, for a line arriving from `from`. Open and closed arrows only.
fn line_end(c: &mut String, kind: &[u8], tip: (f64, f64), from: (f64, f64), w: f64) {
/// One of the ten `/LE` names (ISO 32000-2 Table 217). Unknown names are not drawn.
pub(crate) fn known_ending(kind: &[u8]) -> bool {
std::str::from_utf8(kind).ok().and_then(crate::LineEnding::parse).is_some()
}
/// Draw a line ending (`/LE`) at `tip`, for a line arriving from `from`.
///
/// Returns false for an unknown style or a non-finite direction, so the caller keeps the
/// previous appearance instead of writing `NaN`. `None` and a zero-length direction draw nothing.
fn line_end(c: &mut String, kind: &[u8], tip: (f64, f64), from: (f64, f64), w: f64) -> bool {
if kind == b"None" {
return;
return true;
}
if !known_ending(kind) {
return false;
}
let (dx, dy) = (from.0 - tip.0, from.1 - tip.1);
let len = dx.hypot(dy);
if len == 0.0 {
return;
if !dx.is_finite() || !dy.is_finite() {
return false;
}
let len = dx.hypot(dy);
if !len.is_finite() {
return false;
}
if len == 0.0 {
return true;
}
let (ux, uy) = (dx / len, dy / len);
let s = arrow_size(w);
let (cos, sin) = (30f64.to_radians().cos(), 30f64.to_radians().sin());
let a = (tip.0 + s * (ux * cos - uy * sin), tip.1 + s * (ux * sin + uy * cos));
let b = (tip.0 + s * (ux * cos + uy * sin), tip.1 + s * (-ux * sin + uy * cos));
let op = if kind == b"ClosedArrow" { "h B" } else { "S" };
c.push_str(&format!("{} {} m {} {} l {} {} l {op}\n", n(a.0), n(a.1), n(tip.0), n(tip.1), n(b.0), n(b.1)));
if !s.is_finite() {
return false;
}
let (ix, iy) = (dx / len, dy / len);
match kind {
b"OpenArrow" | b"ClosedArrow" | b"ROpenArrow" | b"RClosedArrow" => {
// Open and closed arrows keep their original geometry. A reverse arrow uses the same
// head, pointing back along the line.
let (ux, uy) = if kind.starts_with(b"R") { (-ix, -iy) } else { (ix, iy) };
let (cos, sin) = (30f64.to_radians().cos(), 30f64.to_radians().sin());
let a = (tip.0 + s * (ux * cos - uy * sin), tip.1 + s * (ux * sin + uy * cos));
let b = (tip.0 + s * (ux * cos + uy * sin), tip.1 + s * (-ux * sin + uy * cos));
let op = if kind.ends_with(b"ClosedArrow") { "h B" } else { "S" };
c.push_str(&format!("{} {} m {} {} l {} {} l {op}\n", n(a.0), n(a.1), n(tip.0), n(tip.1), n(b.0), n(b.1)));
}
b"Square" | b"Circle" | b"Diamond" => {
let h = s / 2.0;
if kind == b"Square" {
c.push_str(&format!("{} {} {} {} re h B\n", n(tip.0 - h), n(tip.1 - h), n(s), n(s)));
} else if kind == b"Circle" {
c.push_str(&ellipse(tip.0 - h, tip.1 - h, tip.0 + h, tip.1 + h));
c.push_str("B\n");
} else {
let (px, py) = (-iy, ix);
let v = |along: f64, across: f64| (tip.0 + ix * along + px * across, tip.1 + iy * along + py * across);
let (a, b, d, e) = (v(h, 0.0), v(0.0, h), v(-h, 0.0), v(0.0, -h));
c.push_str(&format!("{} {} m {} {} l {} {} l {} {} l h B\n", n(a.0), n(a.1), n(b.0), n(b.1), n(d.0), n(d.1), n(e.0), n(e.1)));
}
}
_ => {
// Butt is perpendicular to the line. Slash is that stroke turned 45 degrees.
let (px, py) = (-iy, ix);
let (sx, sy) = if kind == b"Slash" {
let q = std::f64::consts::FRAC_1_SQRT_2;
(px * q - py * q, px * q + py * q)
} else {
(px, py)
};
let h = s / 2.0;
c.push_str(&format!("{} {} m {} {} l S\n", n(tip.0 - sx * h), n(tip.1 - sy * h), n(tip.0 + sx * h), n(tip.1 + sy * h)));
}
}
true
}
/// The cubic Bézier segments (first control point, second control point, end) of a smooth curve
/// through every point of `pts`, from the first: a Catmull-Rom spline, so the curve leaves each
/// point parallel to the chord between its neighbours (an end uses itself as the missing
/// neighbour). Ink is drawn so, as Acrobat draws it, rather than as straight segments. The curve
/// stays within its points and these control points.
pub(crate) fn smooth_segments(pts: &[(f64, f64)]) -> Vec<[(f64, f64); 3]> {
let Some(&last) = pts.last() else { return Vec::new() };
let at = |i: usize| pts.get(i).copied().unwrap_or(last);
(0..pts.len().saturating_sub(1))
.map(|i| {
let (p0, p1, p2, p3) = (at(i.saturating_sub(1)), at(i), at(i + 1), at(i + 2));
[(p1.0 + (p2.0 - p0.0) / 6.0, p1.1 + (p2.1 - p0.1) / 6.0), (p2.0 - (p3.0 - p1.0) / 6.0, p2.1 - (p3.1 - p1.1) / 6.0), p2]
})
.collect()
}
/// `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()
}
/// A closed cloudy outline through `pts` (§12.5.4 `/BE /S /C`): each edge becomes a row of
@ -179,6 +254,12 @@ fn form(bbox: [f64; 4], content: &[u8], resources: Dict) -> Stream {
Stream::flate(d, content)
}
/// The page `/Rotate` an image signature's picture is turned back by (`/PCPictureRotate`, written
/// when the signature is added to a turned page): 0, 90, 180 or 270.
pub(crate) fn picture_turn(d: &Dict) -> i64 {
d.get(b"PCPictureRotate").and_then(Object::as_int).unwrap_or(0).rem_euclid(360) / 90 * 90
}
/// Draw the normal appearance of an annotation, or `None` if this subtype/variant isn't supported.
pub fn build(d: &Dict) -> Option<Stream> {
// Imported measurement appearances may include leaders, captions and formatting that
@ -313,32 +394,45 @@ pub fn build(d: &Dict) -> Option<Stream> {
});
}
b"Line" => {
let l = nums(d, b"L").filter(|l| l.len() == 4)?;
let l = nums(d, b"L").filter(|l| l.len() == 4 && l.iter().all(|x| x.is_finite()))?;
let col = stroke?;
let ends: Vec<Vec<u8>> = match d.get(b"LE") {
None => vec![b"None".to_vec(), b"None".to_vec()],
Some(o) => o.as_array()?.iter().map(|e| e.as_name().map(<[u8]>::to_vec)).collect::<Option<_>>()?,
};
if ends.len() != 2 || ends.iter().any(|e| !matches!(e.as_slice(), b"None" | b"OpenArrow" | b"ClosedArrow")) {
if ends.len() != 2 || ends.iter().any(|e| !known_ending(e)) {
return None;
}
let fill = color(d, b"IC")?.unwrap_or(col);
c.push_str(&format!("{}{}{} w 1 J 1 j\n{}", rg_stroke(col), rg(fill), n(w), dash(d)));
c.push_str(&format!("{} {} m {} {} l S\n[] 0 d\n", n(l[0]), n(l[1]), n(l[2]), n(l[3])));
for (end, (tip, from)) in ends.iter().zip([((l[0], l[1]), (l[2], l[3])), ((l[2], l[3]), (l[0], l[1]))]) {
line_end(&mut c, end, tip, from, w);
if !line_end(&mut c, end, tip, from, w) {
return None;
}
}
}
b"Polygon" | b"PolyLine" => {
let v = nums(d, b"Vertices").filter(|v| v.len() >= 4 && v.len() % 2 == 0)?;
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();
let col = stroke;
let closed = subtype == b"Polygon";
let fill = if closed { color(d, b"IC")? } else { None };
if subtype == b"PolyLine" && d.get(b"LE").is_some_and(|e| e.as_array().is_none_or(|a| a.iter().any(|x| x.as_name() != Some(b"None")))) {
// Line endings on connected lines aren't drawn yet.
return None;
}
let ends = if subtype == b"PolyLine" {
match d.get(b"LE") {
None => None,
Some(o) => {
let names: Vec<Vec<u8>> =
o.as_array().filter(|a| a.len() == 2)?.iter().map(|e| e.as_name().map(<[u8]>::to_vec)).collect::<Option<_>>()?;
if names.iter().any(|e| !known_ending(e)) {
return None;
}
Some(names)
}
}
} else {
None
};
if let Some(f) = fill {
c.push_str(&rg(f));
}
@ -362,6 +456,17 @@ pub fn build(d: &Dict) -> Option<Stream> {
(false, true) => "S\n",
(false, false) => "n\n",
});
if let Some(ends) = ends {
let col = col?;
let (Some(start), Some(end)) = (ends.first(), ends.get(1)) else { return None };
let (Some(&tip0), Some(&from0)) = (pts.first(), pts.get(1)) else { return None };
let (Some(&tip1), Some(&from1)) = (pts.last(), pts.get(pts.len().saturating_sub(2))) else { return None };
let fill = color(d, b"IC")?.unwrap_or(col);
c.push_str(&format!("[] 0 d\n{}", rg(fill)));
if !line_end(&mut c, start, tip0, from0, w) || !line_end(&mut c, end, tip1, from1, w) {
return None;
}
}
}
b"Caret" => {
// A filled caret: two curved flanks meeting at the top centre.
@ -396,11 +501,10 @@ pub fn build(d: &Dict) -> Option<Stream> {
let pts: Vec<(f64, f64)> = pts.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect();
let Some(first) = pts.first() else { continue };
c.push_str(&format!("{} {} m\n", n(first.0), n(first.1)));
if pts.len() == 1 {
c.push_str(&format!("{} {} l\n", n(first.0 + 0.01), n(first.1)));
}
for p in &pts[1..] {
c.push_str(&format!("{} {} l\n", n(p.0), n(p.1)));
match pts.as_slice() {
[_] => c.push_str(&format!("{} {} l\n", n(first.0 + 0.01), n(first.1))),
[_, end] => c.push_str(&format!("{} {} l\n", n(end.0), n(end.1))),
_ => c.push_str(&smooth_curve(&pts)),
}
c.push_str("S\n");
}
@ -424,11 +528,14 @@ pub fn build(d: &Dict) -> Option<Stream> {
return Some(form(rect, c.as_bytes(), res));
}
// A custom stamp: its picture (an image, or a form mapped to /PCPictureSize) fills
// the rectangle.
// the rectangle. An image signature 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) {
let [x0, y0, x1, y1] = rect;
let (w, h) = (x1 - x0, y1 - y0);
let place = if matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))) {
let image = matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true)));
let place = if image {
format!("{} 0 0 {} {} {} cm", n(w), n(h), n(x0), n(y0))
} else {
let size = nums(d, b"PCPictureSize").filter(|s| s.len() == 2 && s[0] > 0.0 && s[1] > 0.0)?;
@ -438,7 +545,15 @@ pub fn build(d: &Dict) -> Option<Stream> {
let mut xo = Dict::new();
xo.set(b"Pic".to_vec(), Object::Ref(pic));
res.set(b"XObject".to_vec(), Object::Dict(xo));
return Some(form(rect, c.as_bytes(), res));
let mut stream = form(rect, c.as_bytes(), res);
let turn = picture_turn(d);
if image && 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];
stream.dict.set(b"Matrix".to_vec(), Object::Array(matrix.iter().map(|x| Object::Real(*x)).collect()));
}
return Some(stream);
}
// Only PdfCraft's own Fill & Sign marks are drawn here.
let name = d.name(b"Name")?;
@ -516,17 +631,22 @@ pub fn build(d: &Dict) -> Option<Stream> {
None => b"None".to_vec(),
Some(o) => o.as_name()?.to_vec(),
};
if !matches!(end.as_slice(), b"None" | b"OpenArrow" | b"ClosedArrow") {
if !known_ending(&end) {
return None;
}
let lw = bw.max(0.5);
let pts: Vec<(f64, f64)> = cl.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect();
if pts.len() < 2 || pts.iter().any(|p| !p.0.is_finite() || !p.1.is_finite()) {
return None;
}
c.push_str(&format!("{}{}{} w 1 J 1 j\n", rg_stroke(text_color), rg(bg.unwrap_or([1.0; 3])), n(lw)));
for (i, p) in pts.iter().enumerate() {
c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" }));
}
c.push_str("S\n");
line_end(&mut c, &end, pts[0], pts[1], lw);
if !line_end(&mut c, &end, pts[0], pts[1], lw) {
return None;
}
}
if let Some(bg) = bg {
c.push_str(&format!("{}{} {} {} {} re f\n", rg(bg), n(rect[0]), n(rect[1]), n(rect[2] - rect[0]), n(rect[3] - rect[1])));

View File

@ -274,6 +274,55 @@ impl StampKind {
}
}
/// A line ending (`/LE`): the ten styles in ISO 32000-2 Table 217.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LineEnding {
None,
Square,
Circle,
Diamond,
OpenArrow,
ClosedArrow,
Butt,
ROpenArrow,
RClosedArrow,
Slash,
}
impl LineEnding {
pub const ALL: [Self; 10] = [
Self::None,
Self::Square,
Self::Circle,
Self::Diamond,
Self::OpenArrow,
Self::ClosedArrow,
Self::Butt,
Self::ROpenArrow,
Self::RClosedArrow,
Self::Slash,
];
pub fn name(self) -> &'static str {
match self {
Self::None => "None",
Self::Square => "Square",
Self::Circle => "Circle",
Self::Diamond => "Diamond",
Self::OpenArrow => "OpenArrow",
Self::ClosedArrow => "ClosedArrow",
Self::Butt => "Butt",
Self::ROpenArrow => "ROpenArrow",
Self::RClosedArrow => "RClosedArrow",
Self::Slash => "Slash",
}
}
pub fn parse(name: &str) -> Option<Self> {
Self::ALL.into_iter().find(|ending| ending.name() == name)
}
}
/// Geometry of a new comment, in PDF user space of its page.
#[derive(Clone, Debug, PartialEq)]
pub enum Shape {
@ -294,11 +343,12 @@ pub enum Shape {
Oval {
rect: [f64; 4],
},
/// A line, with an open arrowhead at `to` when `arrow` is set.
/// A line. An open arrow at `to` is `start: None`, `end: OpenArrow` (the Arrow tool).
Line {
from: [f64; 2],
to: [f64; 2],
arrow: bool,
start: LineEnding,
end: LineEnding,
},
/// Freehand strokes (Draw tool).
Ink {
@ -357,9 +407,11 @@ pub enum Shape {
vertices: Vec<[f64; 2]>,
cloud: bool,
},
/// Connected lines (Polygonal Line tool).
/// Connected lines (Polygonal Line tool). `start` is the first vertex, `end` the last.
PolyLine {
vertices: Vec<[f64; 2]>,
start: LineEnding,
end: LineEnding,
},
/// A text callout: a text box at `rect` with a leader line from `point` (arrowhead) via
/// `knee` to the box (FreeText, `/IT /FreeTextCallout`, `/CL`).
@ -368,6 +420,7 @@ pub enum Shape {
knee: [f64; 2],
point: [f64; 2],
font_size: f64,
ending: LineEnding,
},
/// Insert text: a caret in `rect` (its point at the top centre).
Caret {
@ -662,6 +715,31 @@ fn annot_dict(doc: &Document, r: ObjRef) -> Dict {
doc.get(r).as_dict().cloned().unwrap_or_default()
}
/// The embedded image of a Fill & Sign image signature or initials (0-based target).
/// Other stamps have appearances that can't be represented by this image alone.
pub fn signature_image(doc: &Document, page: usize, index: usize) -> Result<Option<ObjRef>, AnnotError> {
let p = page_ref(doc, page)?;
let list = annots(doc, p);
let entry = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?;
let obj = doc.resolve(entry);
let Some(d) = obj.as_dict() else { return Ok(None) };
Ok((d.name(b"Subtype") == Some(b"Stamp")
&& matches!(d.name(b"Name"), Some(b"PCCustomSignature" | b"PCCustomInitials"))
&& matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))))
.then(|| d.reference(b"PCPicture"))
.flatten())
}
/// The page `/Rotate` the picture of the image signature at `(page, index)` is drawn turned back
/// by, so it reads upright on a page shown that way: 0 for one added to an unturned page, or by
/// another app. The picture appears turned by the page's current rotation less this.
pub fn picture_rotation(doc: &Document, page: usize, index: usize) -> Result<i64, AnnotError> {
let p = page_ref(doc, page)?;
let list = annots(doc, p);
let entry = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?;
Ok(doc.resolve(entry).as_dict().map_or(0, appearance::picture_turn))
}
// ── building ────────────────────────────────────────────────────────────────────────────────
/// Annotation flags (§12.5.3).
@ -701,6 +779,14 @@ fn grow(r: [f64; 4], by: f64) -> [f64; 4] {
[r[0] - by, r[1] - by, r[2] + by, r[3] + by]
}
fn ending_draws(ending: LineEnding) -> bool {
ending != LineEnding::None
}
fn ending_pair(start: LineEnding, end: LineEnding) -> Object {
Object::Array(vec![Object::name(start.name()), Object::name(end.name())])
}
/// Validate and compute `/Rect` for a new comment.
fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotError> {
let bad = |what: &str| AnnotError::Invalid(format!("invalid {what}"));
@ -724,7 +810,6 @@ fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotError> {
| Shape::TextBox { rect, .. }
| Shape::Typewriter { rect, .. }
| Shape::Stamp { rect, .. }
| Shape::CustomStamp { rect, .. }
| Shape::TypedSignature { rect, .. }
| Shape::Mark { rect, .. } => {
let r = normalize(*rect);
@ -733,18 +818,32 @@ fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotError> {
}
r
}
Shape::Line { from, to, arrow } => {
Shape::CustomStamp { rect, .. } => {
let r = normalize(*rect);
// Image signatures may be very thin; a positive PDF appearance box still works.
if !finite(rect) || r[2] <= r[0] || r[3] <= r[1] {
return Err(bad("rectangle (empty)"));
}
r
}
Shape::Line { from, to, start, end } => {
if !finite(from) || !finite(to) || (from[0] - to[0]).hypot(from[1] - to[1]) < 1.0 {
return Err(bad("line (too short)"));
}
let pad = half + if *arrow { appearance::arrow_size(style.width) } else { 0.0 };
let pad = half + if ending_draws(*start) || ending_draws(*end) { appearance::arrow_size(style.width) } else { 0.0 };
grow(bounds([*from, *to].into_iter()).unwrap_or_default(), pad + 1.0)
}
Shape::Ink { strokes } | Shape::Signature { strokes } => {
if strokes.iter().all(|s| s.is_empty()) || !strokes.iter().flatten().all(|p| finite(p)) {
return Err(bad("drawing (no points)"));
}
grow(bounds(strokes.iter().flatten().copied()).unwrap_or_default(), half + 1.0)
// Strokes of three or more points are drawn as curves, which stay within their
// points and control points.
let controls = strokes.iter().filter(|s| s.len() > 2).flat_map(|s| {
let pts: Vec<(f64, f64)> = s.iter().map(|p| (p[0], p[1])).collect();
appearance::smooth_segments(&pts).into_iter().flat_map(|[a, b, _]| [[a.0, a.1], [b.0, b.1]])
});
grow(bounds(strokes.iter().flatten().copied().chain(controls)).unwrap_or_default(), half + 1.0)
}
Shape::Polygon { vertices, cloud } => {
let b = bounds(vertices.iter().copied())
@ -752,10 +851,11 @@ fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotError> {
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 })
}
Shape::PolyLine { vertices } => {
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);
grow(b.ok_or_else(|| bad("connected lines (they need two points)"))?, half + 1.0)
let extra = if ending_draws(*start) || ending_draws(*end) { appearance::arrow_size(style.width) } else { 0.0 };
grow(b.ok_or_else(|| bad("connected lines (they need two points)"))?, half + 1.0 + extra)
}
Shape::Callout { rect, knee, point, .. } => {
let r = normalize(*rect);
@ -808,7 +908,7 @@ fn subject(shape: &Shape) -> &'static str {
Shape::TextMarkup { kind: Markup::Squiggly, .. } => "Squiggly",
Shape::Rectangle { .. } => "Rectangle",
Shape::Oval { .. } => "Oval",
Shape::Line { arrow: true, .. } => "Arrow",
Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "Arrow",
Shape::Line { .. } => "Line",
Shape::Ink { .. } => "Pencil",
Shape::TextBox { .. } => "Text Box",
@ -905,11 +1005,11 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
}
border(&mut d);
}
Shape::Line { from, to, arrow } => {
Shape::Line { from, to, start, end } => {
d.set(b"C".to_vec(), rgb(style.color));
d.set(b"L".to_vec(), num_array(&[from[0], from[1], to[0], to[1]]));
if *arrow {
d.set(b"LE".to_vec(), Object::Array(vec![Object::name("None"), Object::name("OpenArrow")]));
if ending_draws(*start) || ending_draws(*end) {
d.set(b"LE".to_vec(), ending_pair(*start, *end));
}
border(&mut d);
}
@ -940,6 +1040,14 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
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]));
// 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") {
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));
}
}
}
Shape::Ink { strokes } | Shape::Signature { strokes } => {
d.set(b"C".to_vec(), rgb(style.color));
@ -962,9 +1070,12 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
}
border(&mut d);
}
Shape::PolyLine { vertices } => {
Shape::PolyLine { vertices, start, end } => {
d.set(b"C".to_vec(), rgb(style.color));
d.set(b"Vertices".to_vec(), num_array(&vertices.concat()));
if ending_draws(*start) || ending_draws(*end) {
d.set(b"LE".to_vec(), ending_pair(*start, *end));
}
border(&mut d);
}
Shape::Caret { .. } => {
@ -1001,12 +1112,12 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
d.set(b"Contents".to_vec(), PdfString::text(file.trim()));
}
}
Shape::Callout { rect: tb, knee, point, .. } => {
Shape::Callout { rect: tb, knee, point, ending, .. } => {
let tb = normalize(*tb);
let attach = callout_attach(tb, *knee);
d.set(b"IT".to_vec(), Object::name("FreeTextCallout"));
d.set(b"CL".to_vec(), num_array(&[point[0], point[1], knee[0], knee[1], attach[0], attach[1]]));
d.set(b"LE".to_vec(), Object::name("OpenArrow"));
d.set(b"LE".to_vec(), Object::name(ending.name()));
// The text box inside /Rect (§12.5.6.6 /RD).
d.set(b"RD".to_vec(), num_array(&[tb[0] - rect[0], tb[1] - rect[1], rect[2] - tb[2], rect[3] - tb[3]]));
}
@ -1050,14 +1161,57 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
Ok(index)
}
/// Counterrotate a newly placed image stamp's appearance into displayed-page axes.
/// The normal appearance's transformed bounding box is fitted to `/Rect` by PDF viewers.
pub fn orient_image_stamp(doc: &mut Document, page: usize, index: usize, rotation: i64) -> Result<(), AnnotError> {
let matrix = match rotation {
90 => [0.0, 1.0, -1.0, 0.0, 0.0, 0.0],
180 => [-1.0, 0.0, 0.0, -1.0, 0.0, 0.0],
270 => [0.0, -1.0, 1.0, 0.0, 0.0, 0.0],
_ => return Ok(()),
};
let (_, r) = annot_ref(doc, page, index)?;
let d = annot_dict(doc, r);
if d.name(b"Subtype") != Some(b"Stamp") || !matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))) {
return Err(AnnotError::Invalid("only an image stamp can be oriented".into()));
}
let normal = d
.get(b"AP")
.map(|ap| doc.resolve(ap))
.and_then(|ap| ap.as_dict().and_then(|ap| ap.reference(b"N")))
.ok_or_else(|| AnnotError::Invalid("the stamp has no normal appearance".into()))?;
let Object::Stream(mut stream) = doc.get(normal).as_ref().clone() else {
return Err(AnnotError::Invalid("the stamp's normal appearance is not a stream".into()));
};
stream.dict.set(b"Matrix".to_vec(), num_array(&matrix));
doc.set(normal, Object::Stream(stream));
Ok(())
}
/// (Re)generate `/AP /N` for the annotation `r` from its dictionary.
/// Regenerate an annotation's normal appearance from its dictionary.
pub fn set_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) = appearance::build(&d) else { return Err(AnnotError::Unsupported(subtype)) };
let Some(mut stream) = appearance::build(&d) else { return Err(AnnotError::Unsupported(subtype)) };
// Image stamp restyling must retain the placement's page-axis correction.
if matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))) {
let matrix = d.get(b"AP").and_then(|ap| {
let ap = doc.resolve(ap);
let normal = doc.resolve(ap.as_dict()?.get(b"N")?);
let Object::Stream(normal) = normal.as_ref() else { return None };
normal.dict.get(b"Matrix").cloned()
});
if let Some(matrix) = matrix {
stream.dict.set(b"Matrix".to_vec(), matrix);
}
}
let ap = doc.add(Object::Stream(stream));
let mut apd = Dict::new();
// 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.
let mut apd = d.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).unwrap_or_default();
apd.remove(b"D");
apd.remove(b"R");
apd.set(b"N".to_vec(), Object::Ref(ap));
doc.update_dict(r, |d| {
d.set(b"AP".to_vec(), Object::Dict(apd));
@ -1066,6 +1220,30 @@ pub fn set_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> {
Ok(())
}
/// A copy of `doc` in which every annotation without a normal appearance (`/AP /N`) has the one
/// [`appearance::build`] draws from its dictionary, for displaying the document (`None` when no
/// annotation needs one). Many files carry comments without appearances (FreeText, Ink, notes,
/// stamps, …) that viewers draw from the dictionary. `doc` is not changed: saving writes the file
/// as it was. Links, form fields and pop-ups keep their own handling.
pub fn with_missing_appearances(doc: &Document) -> Option<Document> {
let mut copy: Option<Document> = None;
for page in page_refs(doc).ok()? {
for r in annots(doc, page).iter().filter_map(Object::as_ref) {
let d = annot_dict(doc, r);
if matches!(d.name(b"Subtype"), None | Some(b"Link" | b"Widget" | b"Popup")) {
continue;
}
let has_normal = d.get(b"AP").is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N")));
if has_normal || appearance::build(&d).is_none() {
continue;
}
// Fails only when `r` isn't a dictionary, which `build` above has just ruled out.
set_appearance(copy.get_or_insert_with(|| doc.clone()), r).ok();
}
}
copy
}
/// 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 });
@ -1353,12 +1531,24 @@ pub fn move_annotation(doc: &mut Document, page: usize, index: usize, dx: f64, d
Ok(())
}
/// Resize a rectangle, oval or text box to `rect`; its appearance is redrawn.
/// Resize a rectangle, oval, text box or stamp to `rect`. Stamps keep their appearance,
/// which PDF viewers scale from its bounding box into the new rectangle.
pub fn set_rect(doc: &mut Document, page: usize, index: usize, rect: [f64; 4], meta: &Meta) -> Result<(), AnnotError> {
let (_, r) = annot_ref(doc, page, index)?;
unlocked(doc, r)?;
let d = annot_dict(doc, r);
let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
if subtype == "Stamp" {
let rect = normalize(rect);
if !finite(&rect) || rect[2] <= rect[0] || rect[3] <= rect[1] {
return Err(AnnotError::Invalid("invalid rectangle (too small)".into()));
}
doc.update_dict(r, |d| {
d.set(b"Rect".to_vec(), num_array(&rect));
touch(d, meta);
})?;
return Ok(());
}
if !matches!(subtype.as_str(), "Square" | "Circle" | "FreeText") {
return Err(AnnotError::Invalid(format!("{subtype} comments can't be resized")));
}
@ -1405,7 +1595,8 @@ fn border_width_of(d: &Dict) -> f64 {
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).max(0.0)
}
/// Change a comment's colour, opacity and/or line width, and redraw it.
/// Change a comment's colour, opacity, line width and/or line endings, and redraw it.
#[allow(clippy::too_many_arguments)]
pub fn set_style(
doc: &mut Document,
page: usize,
@ -1413,6 +1604,7 @@ pub fn set_style(
color: Option<Rgb>,
opacity: Option<f64>,
width: Option<f64>,
endings: Option<&[LineEnding]>,
meta: &Meta,
) -> Result<(), AnnotError> {
let (_, r) = annot_ref(doc, page, index)?;
@ -1421,11 +1613,18 @@ pub fn set_style(
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(&d).is_none() {
return Err(AnnotError::Unsupported(subtype));
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()) {
return Err(AnnotError::Invalid("invalid style".into()));
}
if let Some(ends) = endings {
let callout = subtype == "FreeText" && d.contains(b"CL");
let pair = matches!(subtype.as_str(), "Line" | "PolyLine");
if (callout && ends.len() != 1) || (pair && ends.len() != 2) || (!callout && !pair) {
return Err(AnnotError::Invalid("this comment has no line endings to change".into()));
}
}
let free_text = subtype == "FreeText";
doc.update_dict(r, |d| {
if let Some(c) = color {
@ -1452,11 +1651,55 @@ pub fn set_style(
d.set(b"BS".to_vec(), Object::Dict(bs));
d.remove(b"Border");
}
if let Some(ends) = endings {
if ends.len() == 1 {
if let Some(ending) = ends.first() {
d.set(b"LE".to_vec(), Object::name(ending.name()));
}
} else if let (Some(start), Some(end)) = (ends.first(), ends.get(1)) {
d.set(b"LE".to_vec(), ending_pair(*start, *end));
}
}
ensure_ending_room(d);
touch(d, meta);
})?;
set_appearance(doc, r)
}
/// Grow a line or polyline's rectangle so a non-`None` ending is not clipped. Idempotent.
fn ensure_ending_room(d: &mut Dict) {
let subtype = d.name(b"Subtype").unwrap_or_default();
if !matches!(subtype, b"Line" | b"PolyLine") {
return;
}
if !line_endings_of(d).is_some_and(|ends| ends.iter().any(|e| ending_draws(*e))) {
return;
}
let key: &[u8] = if subtype == b"Line" { b"L" } else { b"Vertices" };
let Some(pts) = pair_points(d, key) else { return };
let Some(bounds) = bounds(pts.iter().copied()) else { return };
let w = border_width_of(d);
let need = grow(bounds, w / 2.0 + appearance::arrow_size(w) + 1.0);
let vals: Vec<f64> = d.get(b"Rect").and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect()).unwrap_or_default();
let Some(rect) = four(&vals) else { return };
let union = [rect[0].min(need[0]), rect[1].min(need[1]), rect[2].max(need[2]), rect[3].max(need[3])];
if union != rect {
d.set(b"Rect".to_vec(), num_array(&union));
}
}
fn four(v: &[f64]) -> Option<[f64; 4]> {
Some([*v.first()?, *v.get(1)?, *v.get(2)?, *v.get(3)?])
}
fn pair_points(d: &Dict, key: &[u8]) -> Option<Vec<[f64; 2]>> {
let v: Vec<f64> = d.get(key)?.as_array()?.iter().filter_map(|o| o.as_f64()).collect();
if v.len() < 4 || !v.len().is_multiple_of(2) || v.iter().any(|x| !x.is_finite()) {
return None;
}
Some(v.as_chunks::<2>().0.to_vec())
}
// ── reading ─────────────────────────────────────────────────────────────────────────────────
/// One comment as the viewer lists it (the same fields and rules as
@ -1602,6 +1845,8 @@ pub struct Props {
pub restylable: bool,
/// The Locked flag.
pub locked: bool,
/// `/LE`: two names for a line or polyline (`None` when unset), one for a callout.
pub endings: Option<Vec<LineEnding>>,
}
/// The current properties of the comment at `(page, index)`.
@ -1626,9 +1871,23 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
restylable: appearance::build(d).is_some(),
locked: d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0) & FLAG_LOCKED != 0,
subtype,
endings: line_endings_of(d),
})
}
/// `/LE` for a line, polyline or callout. Unknown names yield `None` so the control stays hidden.
fn line_endings_of(d: &Dict) -> Option<Vec<LineEnding>> {
let parse = |name: &[u8]| std::str::from_utf8(name).ok().and_then(LineEnding::parse);
match d.name(b"Subtype")? {
b"Line" | b"PolyLine" => match d.get(b"LE") {
None => Some(vec![LineEnding::None, LineEnding::None]),
Some(o) => o.as_array().filter(|a| a.len() == 2)?.iter().map(|e| parse(e.as_name()?)).collect(),
},
b"FreeText" if d.contains(b"CL") => Some(vec![parse(d.name(b"LE").unwrap_or(b"None"))?]),
_ => None,
}
}
/// 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.

View File

@ -60,7 +60,7 @@ fn list(doc: &Document, page: usize) -> Vec<Dict> {
}
fn ap_content(doc: &Document, d: &Dict) -> String {
let n = d.get(b"AP").and_then(|a| a.as_dict()).and_then(|a| a.reference(b"N")).expect("has /AP /N");
let n = d.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().and_then(|a| a.reference(b"N"))).expect("has /AP /N");
let obj = doc.get(n);
let Object::Stream(s) = &*obj else { panic!("AP is not a stream") };
String::from_utf8_lossy(&s.decoded().unwrap()).into_owned()
@ -84,7 +84,7 @@ fn every_tool_creates_a_drawable_comment() {
Shape::TextMarkup { kind: Markup::Squiggly, quads: vec![[72.0, 412.0, 200.0, 412.0, 72.0, 400.0, 200.0, 400.0]] },
Shape::Rectangle { rect: [300.0, 300.0, 200.0, 200.0] },
Shape::Oval { rect: [300.0, 300.0, 400.0, 350.0] },
Shape::Line { from: [50.0, 50.0], to: [150.0, 100.0], arrow: true },
Shape::Line { from: [50.0, 50.0], to: [150.0, 100.0], start: LineEnding::None, end: LineEnding::OpenArrow },
Shape::Ink { strokes: vec![vec![[10.0, 10.0], [20.0, 30.0], [30.0, 10.0]], vec![[40.0, 40.0]]] },
Shape::TextBox { rect: [300.0, 600.0, 500.0, 650.0], font_size: 14.0 },
];
@ -141,6 +141,28 @@ fn every_tool_creates_a_drawable_comment() {
assert_eq!(all[1].reference(b"Popup"), entries[2].as_ref());
}
/// #260 (page 7): Acrobat draws an Ink annotation as a smooth curve through its `/InkList`
/// points, where PdfCraft drew straight segments between them. Three or more points now make a
/// Catmull-Rom spline through every point (as cubic Béziers); two points stay a line, one a dot.
#[test]
fn ink_strokes_are_smooth_curves_through_their_points() {
let mut doc = fixture();
let strokes = vec![vec![[0.0, 0.0], [10.0, 10.0], [20.0, 0.0]], vec![[30.0, 0.0], [40.0, 10.0]], vec![[50.0, 50.0]]];
let i = add_annotation(&mut doc, &new(0, Shape::Ink { strokes }), &meta("ink")).unwrap();
let ap = ap_content(&doc, &list(&doc, 0)[i]);
// Tangents at each point run parallel to the chord between its neighbours (the ends use
// their own point): (0,0)→(10,10) leaves along (10,10) and arrives along (20,0).
assert!(ap.contains("0 0 m\n1.667 1.667 6.667 10 10 10 c\n13.333 10 18.333 1.667 20 0 c\nS\n"), "{ap}");
assert!(ap.contains("30 0 m\n40 10 l\nS\n"), "{ap}");
assert!(ap.contains("50 50 m\n50.01 50 l\nS\n"), "{ap}");
// /Rect holds the whole curve: rising to (20, 20) and dropping to (21, 0), it swings above
// y 20 (its control point is at y 21.667), beyond the points' own bounds plus the margin.
let rise = Shape::Ink { strokes: vec![vec![[0.0, 0.0], [10.0, 10.0], [20.0, 20.0], [21.0, 0.0]]] };
let j = add_annotation(&mut doc, &new(0, rise), &meta("rise")).unwrap();
let top = rect(&list(&doc, 0)[j])[3];
assert!(top >= 21.667 + 2.0 - 0.01, "/Rect top {top}");
}
#[test]
fn indirect_annots_array_is_updated_in_place() {
let mut doc = fixture();
@ -157,7 +179,7 @@ fn invalid_geometry_is_rejected() {
let bad = [
Shape::TextMarkup { kind: Markup::Highlight, quads: vec![] },
Shape::Rectangle { rect: [0.0, 0.0, 0.5, 10.0] },
Shape::Line { from: [1.0, 1.0], to: [1.0, 1.0], arrow: false },
Shape::Line { from: [1.0, 1.0], to: [1.0, 1.0], start: LineEnding::None, end: LineEnding::None },
Shape::Ink { strokes: vec![vec![]] },
Shape::Note { at: [f64::NAN, 0.0], icon: NoteIcon::Note },
];
@ -239,7 +261,7 @@ fn move_shifts_all_geometry_and_the_popup() {
fn restyle_and_resize_redraw_the_appearance() {
let mut doc = fixture();
let r = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [10.0, 10.0, 110.0, 60.0] }), &meta("r")).unwrap();
set_style(&mut doc, 0, r, Some([1.0, 0.0, 0.0]), Some(0.5), Some(4.0), &meta("")).unwrap();
set_style(&mut doc, 0, r, Some([1.0, 0.0, 0.0]), Some(0.5), Some(4.0), None, &meta("")).unwrap();
set_rect(&mut doc, 0, r, [0.0, 0.0, 200.0, 100.0], &meta("")).unwrap();
let doc2 = reopen(&doc);
let d = &list(&doc2, 0)[r];
@ -249,10 +271,15 @@ fn restyle_and_resize_redraw_the_appearance() {
assert_eq!(d.get(b"CA").and_then(|o| o.as_f64()), Some(0.5));
// A stamp's appearance can't be regenerated, so restyling it changes nothing.
let before = list(&doc, 1)[0].clone();
assert_eq!(set_style(&mut doc, 1, 0, Some([0.0; 3]), None, None, &meta("")), Err(AnnotError::Unsupported("Stamp".into())));
assert_eq!(set_style(&mut doc, 1, 0, Some([0.0; 3]), None, None, None, &meta("")), Err(AnnotError::Unsupported("Stamp".into())));
assert_eq!(list(&doc, 1)[0], before);
// Lines can't be resized as rectangles.
let l = add_annotation(&mut doc, &new(0, Shape::Line { from: [0.0, 0.0], to: [10.0, 10.0], arrow: false }), &meta("l")).unwrap();
let l = add_annotation(
&mut doc,
&new(0, Shape::Line { from: [0.0, 0.0], to: [10.0, 10.0], start: LineEnding::None, end: LineEnding::None }),
&meta("l"),
)
.unwrap();
assert!(matches!(set_rect(&mut doc, 0, l, [0.0, 0.0, 5.0, 5.0], &meta("")), Err(AnnotError::Invalid(_))));
}
@ -261,7 +288,7 @@ fn text_box_text_and_colour_changes_are_drawn() {
let mut doc = fixture();
let t = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [0.0, 0.0, 300.0, 100.0], font_size: 12.0 }), &meta("t")).unwrap();
set_contents(&mut doc, 0, t, "Caf\u{e9} (draft) \u{2014} 100%", &meta("")).unwrap();
set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, &meta("")).unwrap();
set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap();
let doc = reopen(&doc);
let d = &list(&doc, 0)[t];
assert_eq!(text(d, b"Contents"), "Caf\u{e9} (draft) \u{2014} 100%");
@ -315,7 +342,16 @@ fn editing_a_callout_refits_the_box_and_keeps_the_leader() {
let mut doc = fixture();
let c = add_annotation(
&mut doc,
&new(0, Shape::Callout { rect: [300.0, 600.0, 500.0, 612.0], knee: [260.0, 560.0], point: [200.0, 520.0], font_size: 12.0 }),
&new(
0,
Shape::Callout {
rect: [300.0, 600.0, 500.0, 612.0],
knee: [260.0, 560.0],
point: [200.0, 520.0],
font_size: 12.0,
ending: LineEnding::OpenArrow,
},
),
&meta("c"),
)
.unwrap();
@ -407,8 +443,8 @@ fn fill_and_sign_items_are_drawn() {
assert_eq!(text(&all[s], b"Subj"), "Signature");
// Other stamps still can't be restyled; ours can.
let mut doc = doc;
set_style(&mut doc, 1, marks[0], Some([0.0, 0.0, 1.0]), None, None, &meta("")).unwrap();
assert!(set_style(&mut doc, 1, 0, Some([0.0; 3]), None, None, &meta("")).is_err());
set_style(&mut doc, 1, marks[0], Some([0.0, 0.0, 1.0]), None, None, None, &meta("")).unwrap();
assert!(set_style(&mut doc, 1, 0, Some([0.0; 3]), None, None, None, &meta("")).is_err());
}
#[test]
@ -493,11 +529,24 @@ fn polygons_clouds_connected_lines_callouts_and_carets_are_drawn() {
let tri = vec![[100.0, 100.0], [200.0, 100.0], [150.0, 180.0]];
let i = add_annotation(&mut doc, &new(0, Shape::Polygon { vertices: tri.clone(), cloud: false }), &meta("p")).unwrap();
let c = add_annotation(&mut doc, &new(0, Shape::Polygon { vertices: tri.clone(), cloud: true }), &meta("c")).unwrap();
let l =
add_annotation(&mut doc, &new(0, Shape::PolyLine { vertices: vec![[300.0, 300.0], [350.0, 320.0], [400.0, 300.0]] }), &meta("l")).unwrap();
let l = add_annotation(
&mut doc,
&new(0, Shape::PolyLine { vertices: vec![[300.0, 300.0], [350.0, 320.0], [400.0, 300.0]], start: LineEnding::None, end: LineEnding::None }),
&meta("l"),
)
.unwrap();
let co = add_annotation(
&mut doc,
&new(0, Shape::Callout { rect: [300.0, 500.0, 420.0, 540.0], knee: [260.0, 520.0], point: [220.0, 460.0], font_size: 10.0 }),
&new(
0,
Shape::Callout {
rect: [300.0, 500.0, 420.0, 540.0],
knee: [260.0, 520.0],
point: [220.0, 460.0],
font_size: 10.0,
ending: LineEnding::OpenArrow,
},
),
&meta("co"),
)
.unwrap();
@ -554,7 +603,7 @@ fn polygons_clouds_connected_lines_callouts_and_carets_are_drawn() {
for bad in [
Shape::Polygon { vertices: vec![[0.0, 0.0], [10.0, 10.0]], cloud: false },
Shape::PolyLine { vertices: vec![[0.0, 0.0]] },
Shape::PolyLine { vertices: vec![[0.0, 0.0]], start: LineEnding::None, end: LineEnding::None },
Shape::Caret { rect: [0.0, 0.0, 0.0, 10.0] },
] {
assert!(add_annotation(&mut doc, &new(0, bad), &meta("b")).is_err());
@ -657,3 +706,216 @@ fn the_eraser_cuts_strokes_and_removes_empty_drawings() {
erase_ink(&mut doc, 0, i, &all[21..], 8.0, &meta("e")).unwrap();
assert!(!list(&doc, 0).iter().any(|d| d.name(b"Subtype") == Some(b"Ink")));
}
/// A one-page document from object bodies (object 1 is the catalog, 3 the page).
fn document_of(objs: &[&[u8]]) -> Document {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
Document::open(Arc::new(out)).expect("opens")
}
#[test]
fn comments_without_appearances_get_one_for_display_only() {
let doc = document_of(&[
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>",
b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R] >>",
b"<< /Type /Annot /Subtype /FreeText /Rect [48 582 285 632] /Contents (FreeText annotation with valid /DA string.) /DA (/Helv 10 Tf 0 0 1 rg) /Border [0 0 1] /C [.85 .47 .02] >>",
b"<< /Type /Annot /Subtype /Ink /Rect [70 392 275 472] /InkList [[80 402 120 452 160 412 210 462 260 417]] /Border [0 0 2] /C [.49 .18 .07] >>",
b"<< /Type /Annot /Subtype /Text /Rect [400 717 420 737] /C [1 1 0] /Contents (A note) >>",
b"<< /Type /Annot /Subtype /Square /Rect [10 10 50 50] /C [1 0 0] /AP << /N 11 0 R >> >>",
b"<< /Type /Annot /Subtype /Link /Rect [48 717 308 741] /Border [0 0 1] /C [0 0 1] >>",
b"<< /Type /Annot /Subtype /Widget /FT /Tx /T (f) /Rect [10 100 100 120] >>",
b"<< /Type /Annot /Subtype /Sound /Rect [10 200 30 220] >>",
b"<< /Type /XObject /Subtype /Form /BBox [10 10 50 50] /Length 0 >>\nstream\n\nendstream",
]);
let display = with_missing_appearances(&doc).expect("three comments need an appearance");
let has_ap = |doc: &Document| list(doc, 0).iter().map(|d| d.get(b"AP").is_some()).collect::<Vec<_>>();
assert_eq!(has_ap(&doc), [false, false, false, true, false, false, false], "the document itself is untouched");
assert_eq!(has_ap(&display), [true, true, true, true, false, false, false], "FreeText, Ink and Text get one; the rest don't");
let shown = list(&display, 0);
let free_text = ap_content(&display, &shown[0]);
assert!(free_text.contains("re f") && free_text.contains("Tj"), "background box and text: {free_text}");
assert_eq!(shown[3].get(b"AP"), list(&doc, 0)[3].get(b"AP"), "an existing appearance is kept");
// The display copy survives a save and reopen (what the renderer reads).
assert_eq!(has_ap(&reopen(&display)), [true, true, true, true, false, false, false]);
// Nothing to add: no copy.
let complete = document_of(&[
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>",
b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>",
b"<< /Type /Annot /Subtype /Link /Rect [48 717 308 741] >>",
b"<< /Type /Annot /Subtype /Square /Rect [10 10 50 50] /C [1 0 0] /AP << /N 6 0 R >> >>",
b"<< /Type /XObject /Subtype /Form /BBox [10 10 50 50] /Length 0 >>\nstream\n\nendstream",
]);
assert!(with_missing_appearances(&complete).is_none());
}
fn line_dict(coords: [f64; 4], ending: &str) -> pdfcraft_cos::Dict {
let mut d = pdfcraft_cos::Dict::new();
d.set(b"Subtype".to_vec(), Object::name("Line"));
d.set(b"Rect".to_vec(), Object::Array([0.0, 0.0, 300.0, 300.0].into_iter().map(Object::Real).collect()));
d.set(b"C".to_vec(), Object::Array([1.0, 0.0, 0.0].into_iter().map(Object::Real).collect()));
d.set(b"L".to_vec(), Object::Array(coords.into_iter().map(Object::Real).collect()));
d.set(b"LE".to_vec(), Object::Array(vec![Object::name("None"), Object::name(ending)]));
d
}
#[test]
fn every_line_ending_is_drawn() {
let filled = [LineEnding::Square, LineEnding::Circle, LineEnding::Diamond, LineEnding::ClosedArrow, LineEnding::RClosedArrow];
let (mut open, mut reverse) = (String::new(), String::new());
for ending in LineEnding::ALL {
let mut doc = fixture();
add_annotation(
&mut doc,
&new(0, Shape::Line { from: [80.0, 100.0], to: [200.0, 160.0], start: LineEnding::None, end: ending }),
&meta("ending"),
)
.unwrap();
let doc = reopen(&doc);
let line = list(&doc, 0).into_iter().find(|d| d.name(b"Subtype") == Some(b"Line")).expect("line");
let content = ap_content(&doc, &line);
assert!(!content.is_empty(), "{ending:?}");
let paints = content.contains("h B") || content.contains("B\n");
assert_eq!(paints, filled.contains(&ending), "{ending:?} fill: {content}");
if ending != LineEnding::None {
let r = rect(&line);
assert!(r[0] < 70.0 && r[2] > 210.0, "{ending:?} rectangle includes the ending: {r:?}");
}
if ending == LineEnding::OpenArrow {
open = content.clone();
}
if ending == LineEnding::ROpenArrow {
reverse = content;
}
}
assert_ne!(open, reverse, "a reverse arrow points the other way");
let mut doc = fixture();
let index = add_annotation(
&mut doc,
&new(0, Shape::Line { from: [40.0, 40.0], to: [140.0, 40.0], start: LineEnding::None, end: LineEnding::None }),
&meta("plain"),
)
.unwrap();
let before = rect(&list(&doc, 0).into_iter().find(|d| d.name(b"Subtype") == Some(b"Line")).expect("line"));
set_style(&mut doc, 0, index, None, None, None, Some(&[LineEnding::None, LineEnding::Square]), &meta("style")).unwrap();
let mut doc = reopen(&doc);
let line = list(&doc, 0).into_iter().find(|d| d.name(b"Subtype") == Some(b"Line")).expect("line");
let after = rect(&line);
assert!(after[1] < before[1] - 5.0 && after[3] > before[3] + 5.0, "the square is not clipped: {before:?} → {after:?}");
assert!(ap_content(&doc, &line).contains("re h B"), "square ending");
add_annotation(
&mut doc,
&new(0, Shape::PolyLine { vertices: vec![[40.0, 200.0], [90.0, 260.0], [150.0, 200.0]], start: LineEnding::Circle, end: LineEnding::Slash }),
&meta("poly"),
)
.unwrap();
add_annotation(
&mut doc,
&new(
0,
Shape::Callout {
rect: [300.0, 500.0, 420.0, 540.0],
knee: [260.0, 520.0],
point: [220.0, 460.0],
font_size: 10.0,
ending: LineEnding::Diamond,
},
),
&meta("call"),
)
.unwrap();
let doc = reopen(&doc);
let all = list(&doc, 0);
let poly = all.iter().find(|d| d.name(b"Subtype") == Some(b"PolyLine")).expect("polyline");
let poly_ap = ap_content(&doc, poly);
assert!(poly_ap.contains("B\n") && poly_ap.contains(" l S"), "circle fills and the slash only strokes: {poly_ap}");
let call = all.iter().find(|d| d.name(b"IT") == Some(b"FreeTextCallout")).expect("callout");
assert_eq!(call.name(b"LE"), Some(b"Diamond".as_slice()));
assert!(ap_content(&doc, call).contains("h B"), "diamond callout");
assert!(appearance::build(&line_dict([f64::NAN, 0.0, 10.0, 0.0], "OpenArrow")).is_none(), "a non-finite endpoint is rejected");
assert!(appearance::build(&line_dict([0.0, 0.0, 10.0, 0.0], "Foo")).is_none(), "an unknown ending is not replaced");
}
#[test]
fn redraw_keeps_shared_annotation_appearances_and_drops_stale_alternates() {
let alternates: [(&[u8], &[u8]); 2] = [(b"R", b"0 0 20 10 re f\n"), (b"D", b"1 1 18 8 re S\n")];
for indirect in [false, true] {
let mut doc = fixture();
let first = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [10.0, 10.0, 110.0, 60.0] }), &meta("first")).unwrap();
let other = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [120.0, 10.0, 220.0, 60.0] }), &meta("other")).unwrap();
let (_, first_ref) = annot_ref(&mut doc, 0, first).unwrap();
let (_, other_ref) = annot_ref(&mut doc, 0, other).unwrap();
let mut entries = annot_dict(&doc, first_ref).get(b"AP").unwrap().as_dict().unwrap().clone();
for (key, bytes) in alternates {
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("XObject"));
d.set(b"Subtype".to_vec(), Object::name("Form"));
d.set(b"BBox".to_vec(), num_array(&[0.0, 0.0, 20.0, 10.0]));
let r = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(d, bytes.to_vec())));
entries.set(key.to_vec(), Object::Ref(r));
}
entries.set(b"VendorState".to_vec(), Object::name("Retained"));
let shared = if indirect { Object::Ref(doc.add(Object::Dict(entries))) } else { Object::Dict(entries) };
for r in [first_ref, other_ref] {
doc.update_dict(r, |d| d.set(b"AP".to_vec(), shared.clone())).unwrap();
}
let mut doc = reopen(&doc);
let other_dict = list(&doc, 0)[other].clone();
let source = other_dict.get(b"AP").unwrap();
let before = doc.resolve(source);
set_style(&mut doc, 0, first, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap();
assert_eq!(doc.resolve(source), before, "a shared AP must not change");
for full in [false, true] {
let bytes = if full {
pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap()
} else {
assert!(!doc.revisions().is_empty(), "incremental save needs an existing revision");
assert!(!doc.encryption_changed() && !doc.full_save_required(), "this edit must not require a full rewrite");
let original = doc.bytes();
let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap();
assert!(bytes.len() > original.len(), "incremental save appends the changed appearance");
assert!(bytes.starts_with(original.as_slice()), "incremental save preserves the complete original prefix");
bytes
};
hayro_syntax::Pdf::new(bytes.clone()).unwrap();
let doc = Document::open(Arc::new(bytes)).unwrap();
let all = list(&doc, 0);
let first_ap = doc.resolve(all[first].get(b"AP").unwrap());
let other_ap = doc.resolve(all[other].get(b"AP").unwrap());
let first_ap = first_ap.as_dict().unwrap();
let other_ap = other_ap.as_dict().unwrap();
assert_ne!(first_ap.get(b"N"), other_ap.get(b"N"));
// The restyled annotation keeps unknown entries, but not down/rollover looks in its
// old colour.
assert_eq!(first_ap.len(), 2);
assert_eq!(other_ap.len(), 4);
for (key, bytes) in alternates {
assert!(first_ap.get(key).is_none(), "stale /{}", String::from_utf8_lossy(key));
let object = doc.resolve(other_ap.get(key).unwrap());
let Object::Stream(stream) = &*object else { panic!("alternate appearance") };
assert_eq!(stream.decoded().unwrap(), bytes);
}
assert_eq!(first_ap.name(b"VendorState"), Some(&b"Retained"[..]));
assert_eq!(other_ap.name(b"VendorState"), Some(&b"Retained"[..]));
assert!(ap_content(&doc, &all[first]).contains("1 0 0 RG"));
assert!(ap_content(&doc, &all[other]).contains("0 0.4 1 RG"));
}
}
}

View File

@ -12,6 +12,7 @@ workspace = true
pdfcraft-engine = { workspace = true }
pdfcraft-render = { workspace = true }
pdfcraft-organize = { workspace = true }
pdfcraft-platform = { workspace = true }
serde_json = { workspace = true }
png = "0.18"
base64 = { version = "0.22", optional = true }
@ -23,5 +24,7 @@ default = ["mcp"]
mcp = ["dep:base64"]
[dev-dependencies]
pdfcraft-cos = { workspace = true }
pdfcraft-model = { workspace = true }
pdfcraft-xfa = { workspace = true }
image = { version = "0.25", default-features = false, features = ["jpeg"] }

View File

@ -23,6 +23,8 @@ mcp::McpServer::new(a).serve(stdin, stdout)? // newline-delimited JS
- Pages and positions are **1-based**. Rectangles are PDF points with the origin at the top-left of the displayed page.
- Unknown arguments are rejected, so typos fail loudly.
- Tools that change a document return its summary (`doc`, `pages`, `dirty`, `undo`, `redo`, …).
- `comment_image_preview` returns JSON and a PNG: `layer: "background"` (the default) gives the page without the selected image signature/initials; `layer: "image"` gives its embedded image with alpha. The metadata supplies its displayed rectangle, document rotation, `image_rotation` (how far clockwise the image is shown turned: 0 for one placed by PdfCraft, which keeps it upright as displayed) and annotation opacity. Each result can be saved with the CLI's `--out`. Cache the background and transform the image for live movement/resizing; `comment_edit` commits the final geometry in one undo step. Previewing never changes the document.
- For proportional image signature resizing, use the embedded image layer's width/height ratio rather than a previously stretched annotation rectangle. The GUI preserves this ratio at corners with the opposite corner anchored; edge midpoint handles change only their own axis. `comment_edit` accepts the resulting rectangle.
- `doc_close` refuses to drop unsaved changes unless `discard_changes: true`. `doc_save` writes atomically, incrementally in place, and in full for a new path.
- With a root set, every read and write path must resolve inside it (symlinks and `..` included). Relative paths resolve inside the root, and `..` is resolved by name before the check. Every path outside the root gets the same refusal (`<path> is outside the allowed directory <root>`), whether or not it exists, so a confined agent can't probe the rest of the disk; on Windows another network share or device path (`\\host\share`, `\\?\UNC\…`, `\\.\…`) is refused without being contacted. "Not found" and other filesystem errors are reported only for paths inside the root. Tools that write several files into a folder name them after the document or input file, with separators, colons and control characters replaced by `_`, so a name can't lead them out of the folder. The root itself must be a folder.
@ -30,6 +32,8 @@ mcp::McpServer::new(a).serve(stdin, stdout)? // newline-delimited JS
**Opt-in only.** Nothing starts it automatically, and it opens no port. It runs while `pdfcraft-cli mcp [--root DIR]` runs, normally launched by an agent from its MCP configuration. It stops when stdin closes. The CLI's `mcp` Cargo feature (on by default) compiles it out entirely when disabled.
`pdfcraft-cli mcp --compact` (or `McpServer::with_compact(true)`) keeps `tools/list` short for agents with small context budgets. It lists only the core tools (`mcp::COMPACT_CORE_TOOLS`: open, info, save, close, render, text extract and find, combine, split, undo) plus the common command/inspection/preview tools and two meta tools: `tool_search` (optional `query` and `category`, the name prefix; or `name` for one tool's full `input_schema`) and `tool_call` (`name` and `arguments`, run through the same `Automation::call`). `tools/call` by name keeps working for every tool, and the server instructions mention the meta tools. Without the flag nothing changes.
Implemented: `initialize` (protocol 2025-06-18, 2025-03-26, 2024-11-05), `ping`, `tools/list` (with `readOnlyHint`/`destructiveHint` annotations), `tools/call` (JSON results also returned as `structuredContent`; images as `image/png`), and `resources/list`, `resources/templates/list` and `resources/read`. The resources expose the open documents read-only: `pdfcraft://doc/{doc}/info` (JSON), `…/text`, `…/page/{page}/text` and `…/page/{page}/image{?dpi}` (PNG, 1–600 dpi). Tool failures come back as `isError: true` results, so the agent can read and recover from them.
## Adding a tool
@ -38,3 +42,29 @@ Implemented: `initialize` (protocol 2025-06-18, 2025-03-26, 2024-11-05), `ping`,
2. Add a `ToolDef` in `src/tools.rs`: a precise description, the schema, `ro()`/`destructive()`, and `cmd()` if a registry command exists.
3. Handle it in `Automation::call`, validating pages and positions with the existing helpers.
4. Add an end-to-end test in `tests/automation.rs`. `tool_table_is_well_formed` checks names, schemas and command links.
## Common command tools
Following FilmCraft's MCP conventions, `command_list {doc?, filter?, enabled_only?}` keeps its
`{commands: [...]}` result and adds each mapped tool's parameter schema. `command_run {id, params?}`
runs that command through its existing headless tool: include `doc` and other tool arguments in
`params`. Commands without a headless mapping return a tool error. Unknown parameter keys are
ignored with a `warnings` array; required values and paths still go through the original validation.
`command_batch {steps: [{id, params?}], stop_on_error?}` returns `completed`, `failed`, and per-step
`{ok, result?, error?}` entries. The default is to stop at the first error; earlier edits remain.
MCP marks a batch with any failed steps as `isError`, including through compact `tool_call`.
`doc_inspect {doc?}` returns one document's info, or `{documents: [...]}` for all open documents.
`render_preview {doc?, page?, max_side?}` returns a PNG (page defaults to 1, maximum side defaults
to 1024, allowed 1–4096). Omit `doc` only when exactly one document is open.
All task tools remain available; compact mode includes the five common tools. Every MCP tool has
all four annotation hints. Unknown top-level argument keys return JSON-RPC `-32602` with the
accepted keys; failures in the invoked tool remain `isError` results. An escaped tool panic is
reported as an internal tool error, and the server continues serving.
Exports complete synchronously. `progressToken` and cancellation notifications are harmlessly
ignored; no background-job or cancellation behavior is added.
Session resources `pdfcraft://document` and `pdfcraft://commands`, and the refusal of unknown argument keys, are described in [docs/mcp.md](../../docs/mcp.md).

View File

@ -3,11 +3,13 @@
//! Geometry follows the automation convention: points from the top-left of the displayed page,
//! y down. It is converted to PDF user space (crop box, `/Rotate`) here.
use pdfcraft_engine::{Edit, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, SummarySort};
use pdfcraft_engine::{
Edit, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, SummarySort,
};
use pdfcraft_render::{Annotation, PageInfo};
use serde_json::{Value, json};
use crate::{Args, Automation, Result, ToolError, failed};
use crate::{Args, Automation, Content, DEFAULT_DPI, MAX_DPI, Result, ToolError, encode_png, failed};
/// Author used when a tool call names none.
pub(crate) const DEFAULT_AUTHOR: &str = "PdfCraft";
@ -34,6 +36,33 @@ pub(crate) fn parse_color(s: &str) -> Result<Rgb> {
Ok([c(0), c(2), c(4)])
}
const ENDING_NAMES: &str = "None, Square, Circle, Diamond, OpenArrow, ClosedArrow, Butt, ROpenArrow, RClosedArrow, Slash";
fn line_endings(a: &Args) -> Result<Option<Vec<LineEnding>>> {
let Some(v) = a.get("endings") else { return Ok(None) };
let arr = v.as_array().ok_or_else(|| ToolError::InvalidArgs("endings must be an array of line-ending names".into()))?;
if arr.is_empty() || arr.len() > 2 {
return Err(ToolError::InvalidArgs("endings takes one name for a callout, or two for a line or polyline".into()));
}
arr.iter()
.map(|item| {
let name = item.as_str().ok_or_else(|| ToolError::InvalidArgs("endings must be line-ending names".into()))?;
LineEnding::parse(name).ok_or_else(|| ToolError::InvalidArgs(format!("unknown line ending {name:?} ({ENDING_NAMES})")))
})
.collect::<Result<Vec<_>>>()
.map(Some)
}
/// `[start, end]` for a line, arrow or polyline. An arrow with no `endings` stays `[None, OpenArrow]`.
fn ending_pair(kind: &str, endings: Option<&[LineEnding]>) -> Result<[LineEnding; 2]> {
match endings {
None if kind == "arrow" => Ok([LineEnding::None, LineEnding::OpenArrow]),
None => Ok([LineEnding::None, LineEnding::None]),
Some(v) if v.len() == 2 => Ok([v.first().copied().unwrap_or(LineEnding::None), v.get(1).copied().unwrap_or(LineEnding::None)]),
Some(_) => Err(ToolError::InvalidArgs("a line or polyline needs two endings, the start and the end".into())),
}
}
fn hex(c: [f32; 3]) -> String {
let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
format!("#{:02X}{:02X}{:02X}", b(c[0]), b(c[1]), b(c[2]))
@ -86,6 +115,35 @@ impl Args<'_> {
}
impl Automation {
/// One of the read-only layers the GUI uses to drag/resize an embedded image signature.
pub(crate) fn comment_image_preview(&self, a: &Args) -> Result<Vec<Content>> {
let (page, index) = self.comment_target(a)?;
let dpi = a.opt_num("dpi")?.unwrap_or(DEFAULT_DPI);
if !(1.0..=MAX_DPI).contains(&dpi) {
return Err(ToolError::InvalidArgs(format!("dpi must be between 1 and {MAX_DPI}")));
}
let doc = self.doc(a)?;
let preview = doc.image_signature_preview(page, index).map_err(failed)?.ok_or_else(|| failed("choose an image signature or initials"))?;
let annotation = doc.info.annotations.iter().find(|c| c.page == page && c.index == index).ok_or_else(|| failed("no such comment"))?;
let info = doc.info.pages.get(page).ok_or_else(|| failed("no such page"))?;
let [w, h] = preview.image.size();
let layer = a.opt_str("layer")?.unwrap_or("background");
let image = match layer {
"image" => Content::Png { data: preview.image.bytes().as_ref().clone(), width: w as u32, height: h as u32 },
"background" => {
let out = preview.render_background((dpi / 72.0) as f32).map_err(failed)?;
Content::Png { data: encode_png(out.width, out.height, &out.rgba)?, width: out.width, height: out.height }
}
_ => return Err(ToolError::InvalidArgs("layer must be background or image".into())),
};
Ok(vec![
Content::Json(json!({ "page": page + 1, "index": index + 1, "rect": rect_to_view(info, annotation.rect), "rotation": info.rotation,
"image_rotation": (i64::from(info.rotation) - preview.turn).rem_euclid(360),
"layer": layer, "opacity": preview.opacity, "dpi": dpi })),
image,
])
}
/// Comments of a document, optionally only of one 0-based page.
fn comments(&self, a: &Args) -> Result<Vec<Annotation>> {
let doc = self.doc(a)?;
@ -152,6 +210,10 @@ impl Automation {
let page = self.page(a)?;
let info = self.doc(a)?.info.pages[page].clone();
let kind = a.str("type")?;
let endings = line_endings(a)?;
if endings.is_some() && !matches!(kind, "line" | "arrow" | "polyline" | "callout") {
return Err(ToolError::InvalidArgs("endings apply to a line, arrow, polyline or callout".into()));
}
let markup = match kind {
"highlight" => Some(Markup::Highlight),
"underline" => Some(Markup::Underline),
@ -225,7 +287,8 @@ impl Automation {
}
"line" | "arrow" => {
let (f, t) = (a.need::<2>("from", "a line")?, a.need::<2>("to", "a line")?);
Shape::Line { from: to_user(&info, f[0], f[1]), to: to_user(&info, t[0], t[1]), arrow: kind == "arrow" }
let [start, end] = ending_pair(kind, endings.as_deref())?;
Shape::Line { from: to_user(&info, f[0], f[1]), to: to_user(&info, t[0], t[1]), start, end }
}
"ink" => {
let wrong = || ToolError::InvalidArgs("strokes must be an array of arrays of [x, y] points".into());
@ -258,7 +321,10 @@ impl Automation {
})
.collect::<Result<Vec<_>>>()?;
match kind {
"polyline" => Shape::PolyLine { vertices },
"polyline" => {
let [start, end] = ending_pair(kind, endings.as_deref())?;
Shape::PolyLine { vertices, start, end }
}
_ => Shape::Polygon { vertices, cloud: kind == "cloud" },
}
}
@ -281,7 +347,17 @@ impl Automation {
[(point[0] + side) / 2.0, mid]
}
};
Shape::Callout { rect, knee, point, font_size: a.opt_num("font_size")?.unwrap_or(10.0) }
Shape::Callout {
rect,
knee,
point,
font_size: a.opt_num("font_size")?.unwrap_or(10.0),
ending: match endings.as_deref() {
None => LineEnding::OpenArrow,
Some([ending]) => *ending,
Some(_) => return Err(ToolError::InvalidArgs("a callout needs one ending".into())),
},
}
}
"attachment" => {
let [x, y] = a.need::<2>("at", "an attachment (its icon's top-left)")?;
@ -350,13 +426,24 @@ impl Automation {
let [x, y] = a.need::<2>("at", "Fill & Sign")?;
let at = to_user(&info, x, y);
let author = a.opt_str("author")?.unwrap_or(DEFAULT_AUTHOR).to_string();
let kind = a.str("type")?;
if let Some(path) = a.opt_str("path")? {
if !matches!(kind, "signature" | "initials") || a.opt_str("text")?.is_some() {
return Err(ToolError::InvalidArgs("path is only for an image signature or initials; pass either path or text".into()));
}
let path = self.resolve(path, false)?;
let file = std::fs::File::open(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
let image = pdfcraft_engine::SignatureImage::read(file).map_err(|e| ToolError::InvalidArgs(e.to_string()))?;
let edit = image.edit(page, &info, at, kind == "initials", &author).ok_or_else(|| ToolError::InvalidArgs("at must be finite".into()))?;
return self.apply(a, edit);
}
let size = 10.0;
let text_at = |t: &str| {
let w = (pdfcraft_engine::annot_text::text_width(t, size) + 8.0).clamp(20.0, 600.0);
let h = size * 1.2 + 6.0;
Shape::Typewriter { rect: [at[0], at[1] - h, at[0] + w, at[1]], font_size: size }
};
let (shape, contents) = match a.str("type")? {
let (shape, contents) = match kind {
"text" => {
let t = a.str("text")?.to_string();
(text_at(&t), t)
@ -366,12 +453,12 @@ impl Automation {
let t = format!("{m}/{d}/{yy}");
(text_at(&t), t)
}
// A typed signature or initials in the script font, left edge at `at`.
// A typed signature or initials in the script font, left edge at `at`, upright as displayed.
kind @ ("signature" | "initials") => {
let t = a.str("text")?;
let h = if kind == "initials" { 24.0 } else { 32.0 };
let shape =
pdfcraft_engine::typed_signature_shape(at, t, h).ok_or_else(|| ToolError::InvalidArgs("text has nothing to draw".into()))?;
let shape = pdfcraft_engine::typed_signature_shape(at, t, h, i64::from(info.rotation))
.ok_or_else(|| ToolError::InvalidArgs("text has nothing to draw".into()))?;
(shape, String::new())
}
kind => {
@ -444,7 +531,7 @@ impl Automation {
}
let (color, opacity, width) = (a.color("color")?, a.opt_num("opacity")?, a.opt_num("width")?);
if color.is_some() || opacity.is_some() || width.is_some() {
edits.push(Edit::StyleAnnotation { page, index, color, opacity, width });
edits.push(Edit::StyleAnnotation { page, index, color, opacity, width, endings: None });
}
if let Some(r) = a.nums::<4>("rect")? {
edits.push(Edit::ResizeAnnotation { page, index, rect: rect_to_user(&info, r) });

View File

@ -0,0 +1,124 @@
//! Shared command/inspection/preview entry points, using the existing confined tool path.
use serde_json::{Value, json};
use crate::{Args, Automation, Content, Result, ToolError, encode_png, failed, info, tools};
use pdfcraft_render::{RenderRequest, RequestKind};
/// The most steps one `command_batch` runs.
pub(crate) const MAX_BATCH_STEPS: usize = 1000;
impl Automation {
pub(crate) fn command_run(&mut self, args: &Args) -> Result<Vec<Content>> {
let id = args.str("id")?;
let tool = tools::tool_for_command(id).ok_or_else(|| failed(format!("command {id:?} has no headless tool; see command_list")))?;
let def = tools::find(tool).ok_or_else(|| failed("command tool is unavailable"))?;
let mut params = match args.get("params") {
None | Some(Value::Null) => serde_json::Map::new(),
Some(Value::Object(params)) => params.clone(),
Some(_) => return Err(ToolError::InvalidArgs("params must be an object".into())),
};
let props = def.input_schema.get("properties").and_then(Value::as_object).ok_or_else(|| failed("tool has no parameter schema"))?;
let accepted = props.keys().map(String::as_str).collect::<Vec<_>>().join(", ");
let mut warnings = Vec::new();
params.retain(|key, _| {
if props.contains_key(key) {
return true;
}
warnings.push(format!("unknown param {key:?} for {id} (accepted: {accepted})"));
false
});
// This preserves the task tool's validation, atomic writes and --root confinement.
let mut content = self.call(tool, &Value::Object(params))?;
if !warnings.is_empty() {
if let Some(object) = content.iter_mut().find_map(|c| match c {
Content::Json(v) => v.as_object_mut(),
_ => None,
}) {
let mut all = match object.remove("warnings") {
Some(Value::Array(old)) => old,
Some(old) => vec![old],
None => Vec::new(),
};
all.extend(warnings.into_iter().map(Value::String));
object.insert("warnings".into(), Value::Array(all));
} else {
content.push(Content::Json(json!({"warnings":warnings})));
}
}
Ok(content)
}
pub(crate) fn command_batch(&mut self, args: &Args) -> Result<Value> {
let steps = args.get("steps").and_then(Value::as_array).ok_or_else(|| ToolError::InvalidArgs("steps must be an array".into()))?;
if steps.len() > MAX_BATCH_STEPS {
return Err(ToolError::InvalidArgs(format!("a batch has at most {MAX_BATCH_STEPS} steps ({} given); split it", steps.len())));
}
// Check every envelope before applying any edit.
for step in steps {
let a = Args(step);
a.str("id")?;
if a.get("params").is_some_and(|p| !p.is_object() && !p.is_null()) {
return Err(ToolError::InvalidArgs("step params must be an object".into()));
}
}
let stop = args.opt_bool("stop_on_error")?.unwrap_or(true);
let (mut completed, mut failed, mut results) = (0usize, 0usize, Vec::new());
for step in steps {
match self.command_run(&Args(step)) {
Ok(content) => {
completed += 1;
let result = content.into_iter().find_map(|c| match c {
Content::Json(v) => Some(v),
_ => None,
});
results.push(json!({"ok":true,"result":result}));
}
Err(e) => {
failed += 1;
results.push(json!({"ok":false,"error":e.to_string()}));
if stop {
break;
}
}
}
}
Ok(json!({"completed":completed,"failed":failed,"results":results}))
}
pub(crate) fn doc_inspect(&self, args: &Args) -> Result<Value> {
if args.opt_int("doc")?.is_some() {
return Ok(info(self.doc(args)?));
}
Ok(json!({"documents":self.session.docs().iter().map(info).collect::<Vec<_>>()}))
}
pub(crate) fn render_preview(&mut self, args: &Args) -> Result<Content> {
let id = if args.opt_int("doc")?.is_some() {
self.doc(args)?.id
} else {
match self.session.docs() {
[doc] => doc.id,
_ => return Err(failed("give doc from doc_list (a single open document is selected automatically)")),
}
};
let page = args.opt_int("page")?.unwrap_or(1);
let max = args.opt_int("max_side")?.unwrap_or(1024);
if !(1..=4096).contains(&max) {
return Err(ToolError::InvalidArgs("max_side must be between 1 and 4096".into()));
}
let page = usize::try_from(page).ok().and_then(|p| p.checked_sub(1)).ok_or_else(|| failed("page must be at least 1"))?;
let doc = self.session.get(id).ok_or_else(|| failed("no such document"))?;
let size = doc.info.pages.get(page).ok_or_else(|| failed("page is out of range"))?;
let side = size.width.max(size.height);
if !side.is_finite() || side <= 0.0 {
return Err(failed("page has invalid dimensions"));
}
let scale = (max as f32 / side).min(1.0);
let out = self.renderer(id)?.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale, tag: 0 });
if let Some(error) = out.error {
return Err(failed(error));
}
let data = encode_png(out.width, out.height, &out.rgba)?;
Ok(Content::Png { data, width: out.width, height: out.height })
}
}

View File

@ -189,10 +189,12 @@ impl Automation {
"value": match f.kind {
FormFieldKind::CheckBox => json!(!f.value.is_empty()),
FormFieldKind::List if f.has(field_flags::MULTI_SELECT) => json!(f.value),
FormFieldKind::Radio => json!(f.value.first().map(|v| f.export_for_state(v))),
_ => json!(f.value.first()),
},
"page": w.and_then(|w| w.page).map(|p| p + 1),
"rect": rect,
"rotation": w.map(|w| w.rotation).unwrap_or(0),
"read_only": f.read_only(),
"required": f.has(field_flags::REQUIRED),
});
@ -225,7 +227,7 @@ impl Automation {
}
match f.kind {
FormFieldKind::Radio => {
o.insert("options".into(), json!(f.widgets.iter().filter_map(|w| w.on_state.clone()).collect::<Vec<_>>()));
o.insert("options".into(), json!((0..f.widgets.len()).filter_map(|i| f.export_of(i)).collect::<Vec<_>>()));
}
FormFieldKind::Combo | FormFieldKind::List => {
o.insert("options".into(), f.options.iter().map(|(e, d)| json!({ "value": e, "label": d })).collect());
@ -393,6 +395,11 @@ impl Automation {
out
}
},
rotation: match a.opt_int("rotation")? {
None => None,
Some(r @ (0 | 90 | 180 | 270)) => Some((0, r)),
Some(r) => return Err(ToolError::InvalidArgs(format!("rotation must be 0, 90, 180 or 270, not {r}"))),
},
actions: None,
check_style: match a.opt_str("check_style")? {
None => None,

View File

@ -14,6 +14,7 @@
mod a11y;
mod comments;
mod content;
mod conventions;
mod forms;
mod links;
#[cfg(feature = "mcp")]
@ -29,6 +30,7 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use pdfcraft_engine::{DocId, Document, Edit, Session, commands};
use pdfcraft_platform::staging::{StagingName, create_staging, staging_suffixes};
use pdfcraft_render::{PageRenderer, PageText, RenderConfig, RenderRequest, RequestKind};
use serde_json::{Value, json};
@ -154,6 +156,7 @@ impl Automation {
self.apply(&a, edit)?
}
"page_render" => return self.page_render(&a).map(|c| vec![c]),
"comment_image_preview" => return self.comment_image_preview(&a),
"text_extract" => self.text_extract(&a)?,
"text_find" => self.text_find(&a)?,
"page_rotate" => {
@ -200,6 +203,7 @@ impl Automation {
"page_insert_file" => self.insert_file(&a)?,
"page_extract" => self.page_extract(&a)?,
"doc_combine" => self.doc_combine(&a)?,
"doc_create_multiple" => self.doc_create_multiple(&a)?,
"doc_split" => self.doc_split(&a)?,
"edit_undo" => {
let id = self.doc(&a)?.id;
@ -212,6 +216,10 @@ impl Automation {
json!({ "redone": label, "document": summary(self.doc(&a)?) })
}
"command_list" => self.command_list(&a)?,
"command_run" => return self.command_run(&a),
"command_batch" => self.command_batch(&a)?,
"doc_inspect" => self.doc_inspect(&a)?,
"render_preview" => return self.render_preview(&a).map(|c| vec![c]),
"page_number" => {
use pdfcraft_organize::LabelStyle as L;
let n = self.doc(&a)?.info.pages.len();
@ -329,7 +337,7 @@ impl Automation {
.enumerate()
.map(|(i, im)| {
let (u, v) = (info.user_to_view(im.rect[0] as f32, im.rect[1] as f32), info.user_to_view(im.rect[2] as f32, im.rect[3] as f32));
json!({ "image": i + 1, "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], "pixels": [im.width, im.height], "name": im.name })
json!({ "image": i + 1, "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], "pixels": [im.width, im.height], "name": im.name, "kind": if im.is_form { "form" } else { "image" } })
})
.collect();
json!({ "page": page + 1, "count": list.len(), "images": list })
@ -417,6 +425,7 @@ impl Automation {
let block = self.doc(&a)?.text_blocks(page)[k as usize - 1].clone();
let text = a.opt_str("text")?.map(str::to_owned).unwrap_or(block.text);
let mut style = pdfcraft_engine::BlockStyle {
bold: a.opt_bool("bold")?,
size: a.opt_num("size")?,
underline: a.opt_bool("underline")?,
line_spacing: a.opt_num("line_spacing")?,
@ -1140,6 +1149,63 @@ impl Automation {
Ok(result)
}
fn doc_create_multiple(&mut self, a: &Args) -> Result<Value> {
let paths = a.strs("paths")?;
if paths.is_empty() || paths.len() > pdfcraft_engine::MAX_CREATE_FILES {
return Err(ToolError::InvalidArgs(format!("paths must list 1 to {} files", pdfcraft_engine::MAX_CREATE_FILES)));
}
let separate = match a.opt_str("mode")? {
None | Some("combine") => false,
Some("separate") => true,
Some(m) => return Err(ToolError::InvalidArgs(format!("mode must be \"combine\" or \"separate\", not {m:?}"))),
};
if separate {
return self.create_separate(a, &paths);
}
let ranges: Vec<Option<String>> = match a.get("pages") {
None | Some(Value::Null) => vec![None; paths.len()],
Some(Value::Array(v)) if v.len() == paths.len() => v.iter().map(|x| x.as_str().map(str::to_owned)).collect(),
Some(_) => return Err(ToolError::InvalidArgs("pages must list a range (or null) for each path".into())),
};
let mut sources = Vec::new();
for (p, range) in paths.into_iter().zip(ranges) {
let path = self.resolve(p, false)?;
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let (_, pdf) = self.session.convert_to_pdf(&name, &Arc::new(bytes)).map_err(failed)?;
let title = path.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
sources.push((title, pdf, range));
}
let bytes = self.session.combine_ranges(&sources).map_err(failed)?;
self.deliver(a, "Combined", bytes)
}
/// One PDF per file, written into `out_dir`; a file that fails doesn't stop the others.
fn create_separate(&mut self, a: &Args, paths: &[&str]) -> Result<Value> {
let dir = self.resolve(a.str("out_dir").map_err(|_| ToolError::InvalidArgs("mode \"separate\" needs out_dir".into()))?, true)?;
std::fs::create_dir_all(&dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?;
let mut out = Vec::new();
for &p in paths {
let converted = self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| {
let bytes = std::fs::read(&src).map_err(|e| format!("{}: {e}", src.display()))?;
let name = src.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let stem = src.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document".into());
let (kind, pdf) = self.session.convert_to_pdf(&name, &Arc::new(bytes)).map_err(|e| e.to_string())?;
Ok((kind, stem, pdf))
});
out.push(match converted {
Ok((pdfcraft_engine::SourceKind::Pdf, ..)) => json!({ "path": p, "skipped": "already a PDF" }),
Ok((_, stem, pdf)) => {
let target = unused(&dir, &stem);
write_atomic(&target, &pdf)?;
json!({ "path": p, "output": target.to_string_lossy(), "bytes": pdf.len() })
}
Err(e) => json!({ "path": p, "error": e }),
});
}
Ok(json!({ "files": out }))
}
fn page_extract(&mut self, a: &Args) -> Result<Value> {
let doc = self.doc(a)?;
let (id, name) = (doc.id, format!("{} (extract)", doc.name));
@ -1175,9 +1241,24 @@ impl Automation {
}
let ranges: Vec<Option<String>> = match a.get("pages") {
None | Some(Value::Null) => vec![None; paths.len()],
Some(Value::Array(v)) if v.len() == paths.len() => v.iter().map(|x| x.as_str().map(str::to_owned)).collect(),
Some(Value::Array(v)) if v.len() == paths.len() => v
.iter()
.enumerate()
.map(|(index, value)| match value {
Value::Null => Ok(None),
Value::String(range) => Ok(Some(range.clone())),
_ => Err(ToolError::InvalidArgs(format!("pages[{index}] must be a range string or null"))),
})
.collect::<Result<_>>()?,
Some(_) => return Err(ToolError::InvalidArgs("pages must list a range (or null) for each path".into())),
};
let passwords: Vec<Option<String>> = match a.get("passwords") {
None | Some(Value::Null) => vec![None; paths.len()],
Some(Value::Array(v)) if v.len() == paths.len() && v.iter().all(|x| x.is_string() || x.is_null()) => {
v.iter().map(|x| x.as_str().map(str::to_owned)).collect()
}
Some(_) => return Err(ToolError::InvalidArgs("passwords must list a password (or null) for each path".into())),
};
let mut sources = Vec::new();
for (p, range) in paths.into_iter().zip(ranges) {
let path = self.resolve(p, false)?;
@ -1185,7 +1266,8 @@ impl Automation {
let name = path.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
sources.push((name, Arc::new(bytes), range));
}
let bytes = self.session.combine_ranges(&sources).map_err(failed)?;
let passwords: Vec<Option<&str>> = passwords.iter().map(Option::as_deref).collect();
let bytes = self.session.combine_unlocked(&sources, &passwords).map_err(failed)?;
self.deliver(a, "Combined", bytes)
}
@ -1267,10 +1349,10 @@ impl Automation {
let doc = self.session.get(id).ok_or_else(|| failed("no such document"))?;
let fresh = || {
let config = RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() };
(doc.bytes.clone(), PageRenderer::new(doc.bytes.clone(), config))
(doc.display.clone(), PageRenderer::new(doc.display.clone(), config))
};
let entry = self.renderers.entry(id).or_insert_with(fresh);
if !Arc::ptr_eq(&entry.0, &doc.bytes) {
if !Arc::ptr_eq(&entry.0, &doc.display) {
*entry = fresh();
}
Ok(&mut entry.1)
@ -1356,6 +1438,8 @@ impl Automation {
Some(_) => Some(self.doc(a)?.id),
None => None,
};
let filter = a.opt_str("filter")?.unwrap_or("").to_lowercase();
let enabled_only = a.opt_bool("enabled_only")?.unwrap_or(false);
let list: Vec<Value> = commands::COMMANDS
.iter()
.map(|c| {
@ -1366,9 +1450,18 @@ impl Automation {
"shortcut": c.shortcut.map(|s| s.label(cfg!(target_os = "macos"))),
"enabled": commands::is_enabled(c, &self.session, active),
"tool": tools::tool_for_command(c.id),
"params": tools::tool_for_command(c.id).and_then(tools::find).map(|t| &t.input_schema),
})
})
.collect();
let list: Vec<Value> = list
.into_iter()
.filter(|c| {
(!enabled_only || c.get("enabled").and_then(Value::as_bool) == Some(true))
&& (filter.is_empty()
|| ["id", "label", "menu"].iter().any(|k| c.get(k).is_some_and(|v| v.to_string().to_lowercase().contains(&filter))))
})
.collect();
Ok(json!({ "commands": list }))
}
@ -1613,7 +1706,7 @@ fn info(d: &Document) -> Value {
"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::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!({
@ -1718,8 +1811,23 @@ fn child(dir: &Path, name: &str) -> PathBuf {
dir.join(safe)
}
/// `<stem>.pdf` in `dir`, or `<stem> (2).pdf` and so on when that name is taken.
fn unused(dir: &Path, stem: &str) -> PathBuf {
let first = child(dir, &format!("{stem}.pdf"));
if !first.exists() {
return first;
}
(2..10_000u32).map(|n| child(dir, &format!("{stem} ({n}).pdf"))).find(|p| !p.exists()).unwrap_or(first)
}
/// Write via a temporary file in the same directory, then rename over the target.
fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
write_atomic_with(path, bytes, staging_suffixes())
}
/// [`write_atomic`], trying the staging names that `suffixes` give.
fn write_atomic_with(path: &Path, bytes: &[u8], suffixes: impl IntoIterator<Item = u64>) -> Result<()> {
use std::io::Write;
// A folder can't be replaced, and staging beside it could land outside the root: "." names
// the root itself, whose parent isn't ours to write in.
if path.is_dir() {
@ -1728,8 +1836,17 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
let dir = path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."));
let name = path.file_name().ok_or_else(|| failed(format!("{}: not a file path", path.display())))?;
std::fs::create_dir_all(dir).map_err(|e| failed(format!("{}: {e}", dir.display())))?;
let tmp = dir.join(format!(".{}.pdfcraft-tmp", name.to_string_lossy()));
std::fs::write(&tmp, bytes).map_err(|e| failed(format!("{}: {e}", tmp.display())))?;
let (tmp, file) =
create_staging(dir, &name.to_string_lossy(), StagingName::SuffixThenTag, suffixes).map_err(|e| failed(format!("{}: {e}", dir.display())))?;
// Closed at the end of the block, before the rename.
let written = {
let mut file = file;
file.write_all(bytes)
};
if let Err(e) = written {
let _ = std::fs::remove_file(&tmp);
return Err(failed(format!("{}: {e}", tmp.display())));
}
std::fs::rename(&tmp, path).map_err(|e| {
let _ = std::fs::remove_file(&tmp);
failed(format!("{}: {e}", path.display()))
@ -1739,6 +1856,7 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
#[cfg(test)]
mod tests {
use super::*;
use pdfcraft_platform::staging::STAGING_ATTEMPTS;
#[test]
fn dot_dot_resolves_by_name_and_never_climbs_above_the_start() {
@ -1790,4 +1908,158 @@ mod tests {
assert!(foreign_share(Path::new(r"\\server\other\x.pdf"), shared));
assert!(foreign_share(Path::new(r"\\attacker\docs\x.pdf"), shared));
}
/// 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()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn staged(dir: &Path, suffix: u64) -> PathBuf {
dir.join(format!(".out.pdf.{suffix:016x}.pdfcraft-tmp"))
}
fn read(p: &Path) -> String {
std::fs::read_to_string(p).unwrap()
}
/// A symbolic link to a file, where the system allows one (Windows needs Developer Mode or an
/// administrator for it).
fn file_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
#[cfg(unix)]
{
std::os::unix::fs::symlink(target, link)
}
#[cfg(windows)]
{
std::os::windows::fs::symlink_file(target, link)
}
#[cfg(not(any(unix, windows)))]
{
Err(std::io::Error::other(format!("no symbolic links here: {} {}", target.display(), link.display())))
}
}
#[test]
fn staging_never_writes_through_a_file_planted_at_its_name() {
let dir = staging_dir("planted");
// A file elsewhere that a planted link points at.
let outside = dir.join("outside.txt");
std::fs::write(&outside, "PRECIOUS").unwrap();
let target = dir.join("out.pdf");
// A hard link needs no privileges on any system, and writing to it writes to `outside`.
std::fs::hard_link(&outside, staged(&dir, 1)).unwrap();
std::fs::write(staged(&dir, 2), "PLANTED").unwrap();
write_atomic_with(&target, b"NEW", [1, 2, 3]).unwrap();
assert_eq!(read(&target), "NEW");
assert_eq!(read(&outside), "PRECIOUS");
assert_eq!(read(&staged(&dir, 1)), "PRECIOUS");
assert_eq!(read(&staged(&dir, 2)), "PLANTED");
assert!(!staged(&dir, 3).exists(), "the staging file was renamed into place");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn staging_never_writes_through_a_symbolic_link_planted_at_its_name() {
let dir = staging_dir("symlink");
let outside = dir.join("outside.txt");
std::fs::write(&outside, "PRECIOUS").unwrap();
let target = dir.join("out.pdf");
if let Err(e) = file_symlink(&outside, &staged(&dir, 1)) {
eprintln!("symbolic links not checked: {e}");
return;
}
write_atomic_with(&target, b"NEW", [1, 2]).unwrap();
assert_eq!(read(&target), "NEW");
assert_eq!(read(&outside), "PRECIOUS");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn staging_never_creates_a_file_through_a_dangling_link_at_its_name() {
let dir = staging_dir("dangling");
let unborn = dir.join("created-through-a-link.txt");
let target = dir.join("out.pdf");
if let Err(e) = file_symlink(&unborn, &staged(&dir, 1)) {
eprintln!("symbolic links not checked: {e}");
return;
}
write_atomic_with(&target, b"NEW", [1, 2]).unwrap();
assert_eq!(read(&target), "NEW");
assert!(!unborn.exists(), "nothing was created through the dangling link");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn staging_gives_up_rather_than_reuse_a_taken_name() {
let dir = staging_dir("taken");
let target = dir.join("out.pdf");
std::fs::write(&target, "OLD").unwrap();
for s in 1..=STAGING_ATTEMPTS as u64 {
std::fs::write(staged(&dir, s), "PLANTED").unwrap();
}
assert!(write_atomic_with(&target, b"NEW", 1..).is_err());
assert_eq!(read(&target), "OLD");
for s in 1..=STAGING_ATTEMPTS as u64 {
assert_eq!(read(&staged(&dir, s)), "PLANTED");
}
let _ = std::fs::remove_dir_all(&dir);
}
/// The names in a folder: what a test can see was left behind.
fn listing(dir: &Path) -> Vec<String> {
let mut names: Vec<_> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
names.sort();
names
}
#[test]
fn staging_names_differ_between_saves_and_fit_long_names() {
let suffixes: std::collections::HashSet<u64> = staging_suffixes().take(64).collect();
assert_eq!(suffixes.len(), 64);
assert_ne!(staging_suffixes().next(), staging_suffixes().next(), "each save draws new names");
// 60 four-byte characters: a 244-byte name, within every system's limit. Its staging name
// must be too (on Linux the whole name in it would be 275 bytes).
let dir = staging_dir("long");
let name = format!("{}.pdf", "\u{1F600}".repeat(60));
write_atomic(&dir.join(&name), b"NEW").unwrap();
assert_eq!(read(&dir.join(&name)), "NEW");
assert_eq!(listing(&dir), [name], "no staging file is left behind");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_folder_at_the_staging_name_is_left_alone() {
let dir = staging_dir("folder");
std::fs::create_dir(staged(&dir, 1)).unwrap();
std::fs::write(staged(&dir, 1).join("inside.txt"), "PLANTED").unwrap();
let target = dir.join("out.pdf");
write_atomic_with(&target, b"NEW", [1, 2]).unwrap();
assert_eq!(read(&target), "NEW");
assert_eq!(read(&staged(&dir, 1).join("inside.txt")), "PLANTED");
let _ = std::fs::remove_dir_all(&dir);
}
/// Windows refuses to replace a read-only file, so the rename fails: the staging file must not
/// be left behind.
#[cfg(windows)]
#[test]
fn a_failed_rename_removes_the_staging_file() {
let dir = staging_dir("readonly");
let target = dir.join("out.pdf");
std::fs::write(&target, "OLD").unwrap();
let mut perms = std::fs::metadata(&target).unwrap().permissions();
perms.set_readonly(true);
std::fs::set_permissions(&target, perms.clone()).unwrap();
let e = write_atomic_with(&target, b"NEW", [7]).unwrap_err();
assert!(e.to_string().contains(&target.display().to_string()), "the rename failed, not the staging: {e}");
assert_eq!(read(&target), "OLD");
assert_eq!(listing(&dir), ["out.pdf"], "the staging file was removed");
#[allow(clippy::permissions_set_readonly_false)]
perms.set_readonly(false);
std::fs::set_permissions(&target, perms).unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
}

View File

@ -29,15 +29,53 @@ const INVALID_PARAMS: i64 = -32602;
const INSTRUCTIONS: &str = "PdfCraft edits PDFs. Open a file with doc_open to get a document id, then inspect \
(doc_info, text_extract, text_find, page_render) or edit it (page_*, doc_set_info). Edits are undoable \
(edit_undo) and stay in memory until doc_save. Page numbers are 1-based. Open documents are also \
resources: pdfcraft://doc/{doc}/info, /text, /page/{page}/text and /page/{page}/image.";
resources: pdfcraft://doc/{doc}/info, /text, /page/{page}/text and /page/{page}/image. \
command_list discovers command ids and tool schemas; command_run executes one, command_batch runs several. \
doc_inspect returns open document state; render_preview returns a bounded PNG.";
/// Appended to the instructions in compact mode.
const COMPACT_INSTRUCTIONS: &str = " Only a core set of tools is listed. Every other tool is still available: find it with \
tool_search (optional query and category, or name for its full input schema) and run it with tool_call.";
/// The tools `tools/list` returns in compact mode, besides the two meta tools.
pub const COMPACT_CORE_TOOLS: &[&str] = &[
"doc_open",
"doc_info",
"doc_save",
"doc_close",
"page_render",
"text_extract",
"text_find",
"doc_combine",
"doc_split",
"edit_undo",
"command_list",
"command_run",
"command_batch",
"doc_inspect",
"render_preview",
];
/// Meta tools that exist only in compact mode.
const TOOL_SEARCH: &str = "tool_search";
const TOOL_CALL: &str = "tool_call";
pub struct McpServer {
automation: Automation,
compact: bool,
}
impl McpServer {
pub fn new(automation: Automation) -> Self {
Self { automation }
Self { automation, compact: false }
}
/// In compact mode `tools/list` returns only [`COMPACT_CORE_TOOLS`] plus `tool_search` and
/// `tool_call`, which find and run any other tool. `tools/call` accepts every tool by name
/// either way. Off by default.
pub fn with_compact(mut self, on: bool) -> Self {
self.compact = on;
self
}
pub fn automation(&self) -> &Automation {
@ -78,7 +116,8 @@ impl McpServer {
};
let params = obj.get("params").cloned().unwrap_or(Value::Null);
let id = id?; // notifications/initialized, notifications/cancelled, …: nothing to answer
Some(match self.dispatch(method, &params) {
let result = if method == "tools/call" { guarded_tool(|| self.dispatch(method, &params)) } else { self.dispatch(method, &params) };
Some(match result {
Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
Err((code, message)) => error(id, code, &message),
})
@ -93,18 +132,46 @@ impl McpServer {
"protocolVersion": version,
"capabilities": { "tools": { "listChanged": false }, "resources": { "listChanged": false, "subscribe": false } },
"serverInfo": { "name": "pdfcraft", "title": "PdfCraft", "version": env!("CARGO_PKG_VERSION"), "websiteUrl": pdfcraft_engine::links::APP_PAGE },
"instructions": INSTRUCTIONS,
"instructions": if self.compact { format!("{INSTRUCTIONS}{COMPACT_INSTRUCTIONS}") } else { INSTRUCTIONS.to_string() },
}))
}
"ping" => Ok(json!({})),
"tools/list" => Ok(json!({ "tools": crate::tools().iter().map(tool_json).collect::<Vec<_>>() })),
"tools/list" => Ok(json!({ "tools": self.tool_list() })),
"tools/call" => {
let name = params.get("name").and_then(Value::as_str).ok_or((INVALID_PARAMS, "tools/call needs a tool name".to_string()))?;
let args = params.get("arguments").cloned().unwrap_or(Value::Null);
if self.compact && name == TOOL_SEARCH {
check_keys(name, &args, &["query", "category", "name"]).map_err(|e| (INVALID_PARAMS, e))?;
return Ok(match search_tools(&args) {
Ok(found) => call_result(vec![Content::Json(found)]),
Err(message) => tool_error(&message),
});
}
if self.compact && name == TOOL_CALL {
check_keys(name, &args, &["name", "arguments"]).map_err(|e| (INVALID_PARAMS, e))?;
let (inner, inner_args) = match unwrap_tool_call(&args) {
Ok(pair) => pair,
Err(message) => return Ok(tool_error(&message)),
};
return Ok(match self.automation.call(&inner, &inner_args) {
Ok(content) => tool_content(&inner, content),
Err(ToolError::UnknownTool(t)) => tool_error(&format!("unknown tool {t:?}; use tool_search to find the right name")),
Err(e) => tool_error(&e.to_string()),
});
}
if let Some(def) = crate::tools::find(name) {
let keys: Vec<&str> = def
.input_schema
.get("properties")
.and_then(Value::as_object)
.map(|p| p.keys().map(String::as_str).collect())
.unwrap_or_default();
check_keys(name, &args, &keys).map_err(|e| (INVALID_PARAMS, e))?;
}
match self.automation.call(name, &args) {
Ok(content) => Ok(call_result(content)),
Ok(content) => Ok(tool_content(name, content)),
Err(ToolError::UnknownTool(t)) => Err((INVALID_PARAMS, format!("unknown tool {t:?}"))),
Err(e) => Ok(json!({ "content": [{ "type": "text", "text": e.to_string() }], "isError": true })),
Err(e) => Ok(tool_error(&e.to_string())),
}
}
"resources/list" => Ok(json!({ "resources": self.resource_list() })),
@ -118,6 +185,167 @@ impl McpServer {
}
}
impl McpServer {
/// What `tools/list` returns: every tool, or in compact mode the core set and the meta tools.
fn tool_list(&self) -> Vec<Value> {
let all = crate::tools();
if !self.compact {
return all.iter().map(tool_json).collect();
}
let mut out: Vec<Value> = COMPACT_CORE_TOOLS.iter().filter_map(|n| all.iter().find(|t| t.name == *n)).map(tool_json).collect();
out.extend(meta_tools());
out
}
}
/// The two tools only compact mode lists.
fn meta_tools() -> [Value; 2] {
[
json!({
"name": TOOL_SEARCH,
"title": "Find PdfCraft tools",
"description": "Search and list all PdfCraft automation tools, including the core tools, with a one-sentence description each. Filter by query \
(words matched against name, title and description) and/or category (the name prefix: doc, page, text, form, …). \
With name, return that one tool in full, including its input_schema. Run a tool with tool_call.",
"inputSchema": {
"type": "object",
"properties": {
"query": { "type": "string", "description": "Words to look for, all of which must match (case-insensitive)." },
"category": { "type": "string", "description": "Tool name prefix, e.g. page, text, form, redact." },
"name": { "type": "string", "description": "Exact tool name: return its description and input_schema." },
},
"required": [],
"additionalProperties": false,
},
"annotations": { "title": "Find PdfCraft tools", "readOnlyHint": true, "destructiveHint": false, "idempotentHint": true, "openWorldHint": false },
}),
json!({
"name": TOOL_CALL,
"title": "Run any PdfCraft tool",
"description": "Run any PdfCraft tool by name with its arguments, exactly as if it were called directly. \
Find names and argument schemas with tool_search. The result is the tool's own result.",
"inputSchema": {
"type": "object",
"properties": {
"name": { "type": "string", "description": "The tool to run, e.g. page_rotate." },
"arguments": { "type": "object", "description": "The tool's arguments (see its input_schema from tool_search)." },
},
"required": ["name"],
"additionalProperties": false,
},
"annotations": { "title": "Run any PdfCraft tool", "readOnlyHint": false, "destructiveHint": true, "idempotentHint": false, "openWorldHint": false },
}),
]
}
/// The category of a tool: its name up to the first underscore (`page_rotate` → `page`).
fn category_of(name: &str) -> &str {
name.split('_').next().unwrap_or(name)
}
/// A description up to and including its first sentence.
fn first_sentence(text: &str) -> &str {
// ASCII lowercasing keeps byte offsets, so positions found in `lower` are valid in `text`.
let lower = text.to_ascii_lowercase();
let mut from = 0;
while let Some(i) = lower.get(from..).and_then(|rest| rest.find(". ")).map(|i| i + from) {
// "e.g. " and "i.e. " are abbreviations, not the end of the sentence.
if !lower.get(..i).is_some_and(|before| before.ends_with("e.g") || before.ends_with("i.e")) {
return text.get(..=i).unwrap_or(text);
}
from = i + 2;
}
text
}
/// Reject keys other than `allowed`, as the tool table does for a tool's own arguments.
fn check_keys(tool: &str, args: &Value, allowed: &[&str]) -> Result<(), String> {
match args.as_object().and_then(|o| o.keys().find(|k| !allowed.contains(&k.as_str()))) {
Some(k) => Err(format!("{tool}: unknown argument {k:?} (expected: {})", allowed.join(", "))),
None => Ok(()),
}
}
/// `tool_search`: filter the tool table by query and category, or describe one tool in full.
fn search_tools(args: &Value) -> Result<Value, String> {
if !(args.is_object() || args.is_null()) {
return Err(format!("{TOOL_SEARCH} takes an object with optional query, category and name"));
}
check_keys(TOOL_SEARCH, args, &["query", "category", "name"])?;
let text = |key: &str| match args.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(s)) => Ok(Some(s.trim())),
Some(_) => Err(format!("{TOOL_SEARCH}: {key} must be a string")),
};
let (query, category, name) = (text("query")?, text("category")?, text("name")?);
let all = crate::tools();
if let Some(name) = name {
let t =
all.iter().find(|t| t.name == name).ok_or_else(|| format!("unknown tool {name:?}; call {TOOL_SEARCH} without name to list the tools"))?;
return Ok(json!({ "tool": {
"name": t.name, "title": t.title, "category": category_of(t.name), "description": t.description,
"read_only": t.read_only, "destructive": t.destructive, "input_schema": t.input_schema,
} }));
}
let category = category.map(|c| c.trim_end_matches('_').to_lowercase()).filter(|c| !c.is_empty());
let words: Vec<String> = query.map(|q| q.to_lowercase().split_whitespace().map(str::to_owned).collect()).unwrap_or_default();
let mut categories: Vec<&str> = all.iter().map(|t| category_of(t.name)).collect();
categories.sort_unstable();
categories.dedup();
let found: Vec<Value> = all
.iter()
.filter(|t| category.as_deref().is_none_or(|c| category_of(t.name) == c))
.filter(|t| {
let haystack = format!("{} {} {}", t.name, t.title, t.description).to_lowercase();
words.iter().all(|w| haystack.contains(w.as_str()))
})
.map(|t| json!({ "name": t.name, "category": category_of(t.name), "description": first_sentence(t.description), "read_only": t.read_only }))
.collect();
Ok(json!({ "count": found.len(), "tools": found, "categories": categories }))
}
/// `tool_call`: the tool name and its arguments from the meta tool's own arguments.
fn unwrap_tool_call(args: &Value) -> Result<(String, Value), String> {
let Some(obj) = args.as_object() else { return Err(format!("{TOOL_CALL} needs an object with name and arguments")) };
check_keys(TOOL_CALL, args, &["name", "arguments"])?;
let name = obj
.get("name")
.and_then(Value::as_str)
.ok_or_else(|| format!("{TOOL_CALL} needs the name of the tool to run; find it with {TOOL_SEARCH}"))?;
if name == TOOL_CALL || name == TOOL_SEARCH {
return Err(format!("{TOOL_SEARCH} and {TOOL_CALL} are called directly, not through {TOOL_CALL}"));
}
let inner = match obj.get("arguments") {
None | Some(Value::Null) => Value::Null,
Some(v @ Value::Object(_)) => v.clone(),
Some(_) => return Err(format!("{TOOL_CALL}: arguments must be an object")),
};
Ok((name.to_owned(), inner))
}
/// Last-resort boundary shared by every tools/call route, including compact dispatch.
fn guarded_tool(f: impl FnOnce() -> Result<Value, (i64, String)>) -> Result<Value, (i64, String)> {
pdfcraft_engine::guard(f).unwrap_or_else(|message| Ok(tool_error(&format!("internal error: {message}"))))
}
fn tool_content(name: &str, content: Vec<Content>) -> Value {
let failed = name == "command_batch"
&& content.iter().any(|c| match c {
Content::Json(v) => v.get("failed").and_then(Value::as_u64).is_some_and(|n| n > 0),
_ => false,
});
let mut result = call_result(content);
if let Some(object) = result.as_object_mut() {
object.insert("isError".into(), json!(failed));
}
result
}
/// A failed tool call, as the agent should read it.
fn tool_error(message: &str) -> Value {
json!({ "content": [{ "type": "text", "text": message }], "isError": true })
}
/// The resource behind a `pdfcraft://` URI.
#[derive(Debug, PartialEq)]
enum Resource {
@ -176,10 +404,29 @@ impl McpServer {
}
}
}
out.extend([
json!({"uri":"pdfcraft://document","name":"Open documents","mimeType":"application/json","description":"Session document state, as doc_inspect without arguments."}),
json!({"uri":"pdfcraft://commands","name":"Command catalog","mimeType":"application/json","description":"Commands and mapped tool schemas, as command_list without arguments."}),
]);
out
}
fn read_resource(&mut self, uri: &str) -> Result<Value, (i64, String)> {
if let Some(tool) = match uri {
"pdfcraft://document" => Some("doc_inspect"),
"pdfcraft://commands" => Some("command_list"),
_ => None,
} {
let content = self.automation.call(tool, &json!({})).map_err(|e| (INVALID_PARAMS, e.to_string()))?;
let value = content
.into_iter()
.find_map(|c| match c {
Content::Json(v) => Some(v),
_ => None,
})
.ok_or((INVALID_PARAMS, "resource returned no JSON".into()))?;
return Ok(json!({"uri":uri,"mimeType":"application/json","text":value.to_string()}));
}
let r = parse_uri(uri).ok_or((INVALID_PARAMS, format!("unknown resource {uri:?}")))?;
let call = |a: &mut Automation, tool: &str, args: Value| a.call(tool, &args).map_err(|e| (INVALID_PARAMS, e.to_string()));
let json_of = |c: Vec<Content>| c.into_iter().find_map(|c| if let Content::Json(v) = c { Some(v) } else { None }).unwrap_or(Value::Null);
@ -232,7 +479,7 @@ fn tool_json(t: &crate::ToolDef) -> Value {
"title": t.title,
"description": t.description,
"inputSchema": t.input_schema,
"annotations": { "title": t.title, "readOnlyHint": t.read_only, "destructiveHint": t.destructive, "openWorldHint": false },
"annotations": { "title": t.title, "readOnlyHint": t.read_only, "destructiveHint": t.destructive, "idempotentHint": t.read_only || matches!(t.name, "doc_save" | "doc_optimize" | "doc_export_data" | "measure_export" | "doc_export_images" | "doc_export_office" | "doc_export_all_images" | "doc_export_text" | "image_save"), "openWorldHint": false },
})
}
@ -262,7 +509,28 @@ fn call_result(content: Vec<Content>) -> Value {
#[cfg(test)]
mod tests {
use super::{Resource, parse_uri};
use super::{Resource, first_sentence, parse_uri};
#[test]
fn conventions_panic_is_a_tool_error() {
let r = super::guarded_tool(|| panic!("synthetic tool failure")).unwrap();
assert_eq!(r["isError"], true);
assert!(r["content"][0]["text"].as_str().unwrap().contains("synthetic tool failure"));
let next = super::guarded_tool(|| Ok(serde_json::json!({"ok":true}))).unwrap();
assert_eq!(next["ok"], true);
}
#[test]
fn first_sentence_skips_abbreviations() {
assert_eq!(first_sentence("Rotate pages. Undoable."), "Rotate pages.");
assert_eq!(first_sentence("Label pages (i.e. roman numerals). Undoable."), "Label pages (i.e. roman numerals).");
assert_eq!(first_sentence("Label pages (I.E. roman numerals, E.G. i, ii). Undoable."), "Label pages (I.E. roman numerals, E.G. i, ii).");
assert_eq!(first_sentence("Use a value, e.g. en-US, or none. Then more."), "Use a value, e.g. en-US, or none.");
assert_eq!(first_sentence("No full stop here"), "No full stop here");
assert_eq!(first_sentence("Only an abbreviation, e.g. this"), "Only an abbreviation, e.g. this");
assert_eq!(first_sentence(""), "");
assert_eq!(first_sentence("Zażółć gęślą, i.e. jaźń. Dalej."), "Zażółć gęślą, i.e. jaźń.");
}
#[test]
fn resource_uris_parse() {

View File

@ -185,17 +185,31 @@ pub fn tools() -> Vec<ToolDef> {
json!({ "doc": doc(), "pages": pages("to extract"), "out": save_out.clone(), "open": open.clone(), "separate": { "type": "boolean" }, "out_dir": { "type": "string" }, "delete": { "type": "boolean" } }),
&["doc", "pages"],
)),
t("doc_combine", "Combine files", "Combine PDFs, in order, into one (bookmarks are kept under one entry per file). pages optionally chooses each file's pages, in step with paths: a range such as \"1-3, 6\" or null for all pages.")
t("doc_combine", "Combine files", "Combine PDFs, in order, into one (bookmarks are kept under one entry per file). pages optionally chooses each file's pages, in step with paths: a range such as \"1-3, 6\" or null for all pages. passwords, also in step with paths, opens encrypted files (the open password, or the permissions password where a file's security doesn't allow copying pages; null for none). The result is not encrypted. Passwords are never echoed back.")
.cmd("page.combine")
.with(schema(
json!({
"paths": { "type": "array", "items": { "type": "string" }, "minItems": 2 },
"pages": { "type": "array", "items": { "type": ["string", "null"] } },
"passwords": { "type": "array", "items": { "type": ["string", "null"] } },
"out": save_out,
"open": open,
}),
&["paths"],
)),
t("doc_create_multiple", "Create PDF from multiple files", "Convert several files (PDFs, PNG/JPEG/JPEG 2000/TIFF/GIF/BMP images, .txt files) to PDF in one run. mode \"combine\" (default) joins them, in order, into one PDF with a bookmark per file; pages optionally chooses each file's pages, in step with paths (a range such as \"1-3, 6\" or null). mode \"separate\" writes one PDF per file into out_dir (files that are already PDFs are skipped; existing files are never overwritten) and reports each file's result.")
.cmd("create.multiple")
.with(schema(
json!({
"paths": { "type": "array", "items": { "type": "string" }, "minItems": 1, "maxItems": pdfcraft_engine::MAX_CREATE_FILES },
"mode": { "type": "string", "enum": ["combine", "separate"] },
"pages": { "type": "array", "items": { "type": ["string", "null"] } },
"out": save_out.clone(),
"open": open.clone(),
"out_dir": { "type": "string", "description": "Folder for mode \"separate\"." },
}),
&["paths"],
)),
t("doc_split", "Split a document", "Split into several files written to out_dir: every N pages, before given pages, at top-level bookmarks (bookmarks: true; files named after them), or by file size (max_mb). Files are <name>-partK.pdf.")
.cmd("page.split")
.with(schema(
@ -231,7 +245,7 @@ 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 and rect (top-left-origin points), read-only and required flags.")
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.")
.ro()
.with(schema(json!({ "doc": doc() }), &["doc"])),
t(
@ -284,7 +298,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"form_set_props",
"Field properties",
"Change a field's properties (General and Options tabs): name (renames it), tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), rect (position), format, validate, calculate (Acrobat's Format/Validate/Calculate tabs, run natively: values are checked, formatted and recalculated as in Acrobat). Options tab: align (left, center, right), default (the value Reset form restores; for check boxes and radio buttons 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 and radio buttons: check, circle, cross, diamond, square, star). Only the given ones change. Undoable.",
"Change a field's properties (General and Options tabs): name (renames it), tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), rect (position), rotation (0, 90, 180 or 270 degrees counterclockwise; changing between horizontal and vertical swaps width and height around the center), format, validate, calculate (Acrobat's Format/Validate/Calculate tabs, run natively: values are checked, formatted and recalculated as in Acrobat). Options tab: align (left, center, right), default (the value Reset form restores; for check boxes and radio buttons 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 and radio buttons: check, circle, cross, diamond, square, star). Only the given ones change. Undoable.",
)
.with(schema(
json!({
@ -304,6 +318,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." },
"appearance": { "type": "object", "description": "Appearance tab: border, fill, text_color (#RRGGBB, a name or \"none\"), width (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 \"none\"." },
@ -513,7 +528,7 @@ pub fn tools() -> Vec<ToolDef> {
"Add a comment",
"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`. ink: `strokes` [[[x, y], …], …]. \
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.",
)
.with(schema(
@ -541,6 +556,7 @@ pub fn tools() -> Vec<ToolDef> {
"fill": color(),
"opacity": { "type": "number", "minimum": 0, "maximum": 1 },
"width": { "type": "number", "minimum": 0, "description": "Line width in points." },
"endings": { "type": "array", "items": { "type": "string", "enum": ["None", "Square", "Circle", "Diamond", "OpenArrow", "ClosedArrow", "Butt", "ROpenArrow", "RClosedArrow", "Slash"] }, "minItems": 1, "maxItems": 2, "description": "Line endings. Two names (start, end) for a line, arrow or polyline; one name for a callout." },
"font_size": { "type": "number", "exclusiveMinimum": 0 },
}),
&["doc", "page", "type"],
@ -617,7 +633,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, line width, rectangle (rectangle/oval/text box) or position (`move` [dx, dy] in points). One undo step.").with(schema(
t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity, line width, rectangle (rectangle/oval/text box/stamp) or position (`move` [dx, dy] in points). Stamps keep their original appearance when resized. One undo step.").with(schema(
comment_ref(json!({
"contents": { "type": "string" },
"color": color(),
@ -628,6 +644,8 @@ pub fn tools() -> Vec<ToolDef> {
})),
&["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. Undoable.").destructive().with(schema(comment_ref(json!({})), &["doc"])),
t(
"doc_protect",
@ -892,7 +910,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`, 10 pt), place a check, cross, dot or line, today's date, or a typed signature or initials (`text` drawn in a script font as filled outlines; at is its left edge, centred vertically), at `at` [x, y] in points from the top-left of the page (the text's top-left; a mark's centre). Creates movable, undoable annotations.",
"Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, 10 pt), place a check, cross, dot or line, today's date, 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 4 megapixels, preserving transparency). `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.",
)
.cmd("sign.fill.text")
.with(schema(
@ -902,6 +920,7 @@ pub fn tools() -> Vec<ToolDef> {
"type": { "type": "string", "enum": ["text", "check", "cross", "dot", "line", "date", "signature", "initials"] },
"at": point(),
"text": { "type": "string", "minLength": 1 },
"path": { "type": "string", "description": "PNG or JPEG for signature/initials; pass either path or text." },
"author": { "type": "string" },
}),
&["doc", "page", "type", "at"],
@ -955,11 +974,11 @@ pub fn tools() -> Vec<ToolDef> {
.ro()
.cmd("edit.edit_text")
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])),
t("page_images", "List page images", "The images a page draws (Edit a PDF): number, box (top-left-origin points), pixel size and resource name.")
t("page_images", "List page images", "The raster images and grouped Form artwork a page draws (Edit a PDF): number, box (top-left-origin points), kind (image/form), pixel size ([0,0] for forms) and resource name. Forms include nested graphics and are edited as a whole. Numbers count forms and images together in drawing order, so a page with forms numbers its images differently than before forms were listed: always take numbers from a fresh page_images call.")
.ro()
.cmd("edit.edit_text")
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])),
t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new box in top-left-origin points), rotate (quarters clockwise, default 1), flip_horizontal, flip_vertical, replace (path: an image file, drawn in the same place) or delete. Undoable.")
t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new box in top-left-origin points), rotate (quarters clockwise, default 1), flip_horizontal, flip_vertical, replace (path: an image file, drawn in the same place) or delete. Undoable. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.")
.cmd("edit.edit_text")
.with(schema(
json!({
@ -973,7 +992,7 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc", "page", "image", "action"],
)),
t("image_save", "Save image as", "Write one of a page's images to path: JPEG images unchanged, others as PNG (the extension is added when missing).")
t("image_save", "Save image as", "Write one of a page's raster images (number from page_images; forms are refused) to path: JPEG images unchanged, others as PNG (the extension is added when missing).")
.destructive()
.cmd("edit.edit_text")
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "image": { "type": "integer", "minimum": 1 }, "path": { "type": "string" } }), &["doc", "page", "image", "path"])),
@ -981,7 +1000,7 @@ pub fn tools() -> Vec<ToolDef> {
.ro()
.cmd("edit.edit_text")
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])),
t("text_edit", "Edit text", "Replace the text of one paragraph (paragraph, from text_paragraphs: rewrapped to the paragraph's width with its line spacing) or one line (line, from text_lines) in place, keeping position, size and colour. For a paragraph, also change its formatting: font (helvetica, times, courier) with bold/italic, size (points), color (#rrggbb), align (left, center, right, justify), underline, line_spacing (× size), char_spacing (points) and scale (horizontal, percent), or move it (dx, dy in points; up is +dy) and rewrap it to a new width (points); text may then be omitted. Its own font is reused when it can show every character; otherwise the line is set in Helvetica (the result shows the font used). Text that no available font can show is refused. Undoable.")
t("text_edit", "Edit text", "Replace the text of one paragraph (paragraph, from text_paragraphs: rewrapped to the paragraph's width with its line spacing) or one line (line, from text_lines) in place, keeping position, size and colour. For a paragraph, also change its formatting: font (helvetica, times, courier) with bold/italic, size (points), color (#rrggbb), align (left, center, right, justify), underline, line_spacing (× size), char_spacing (points) and scale (horizontal, percent), or move it (dx, dy in points; up is +dy) and rewrap it to a new width (points); text may then be omitted. Its own font is reused when it can show every character and no font/weight change is requested; otherwise it uses a standard font matching the source style or, for Japanese and other text no standard font can show (Cyrillic, Greek), real Mincho/Gothic fallback outlines matching the requested/source family and available weight, from a face that has every character (the result shows the font used). Missing Japanese weights use Regular, not synthetic bold; the small web build has only Gothic Regular. Paragraph bold may be set without font to keep the source family. Text that no available font can show is refused. Undoable.")
.cmd("edit.edit_text")
.with(schema(
json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "line": { "type": "integer", "minimum": 1 },
@ -1032,7 +1051,15 @@ pub fn tools() -> Vec<ToolDef> {
t("edit_redo", "Redo", "Redo the last undone edit of a document.").cmd("edit.redo").with(schema(json!({ "doc": doc() }), &["doc"])),
t("command_list", "List commands", "Every registered PdfCraft command with its menu, shortcut, whether it is enabled now, and the tool that automates it.")
.ro()
.with(schema(json!({ "doc": doc() }), &[])),
.with(schema(json!({ "doc": doc(), "filter":{"type":"string"}, "enabled_only":{"type":"boolean"} }), &[])),
t("command_run", "Run a command", "Run a registry command through its headless tool. Pass that tool's arguments in params (see command_list). Unknown params are ignored with warnings; path confinement still applies.")
.destructive().with(schema(json!({"id":{"type":"string"},"params":{"type":"object"}}), &["id"])),
t("command_batch", "Run several commands", "Run steps in order (at most 1000). Returns completed/failed counts and per-step ok/result/error; stop_on_error defaults to true.")
.destructive().with(schema(json!({"steps":{"type":"array","items":schema(json!({"id":{"type":"string"},"params":{"type":"object"}}), &["id"])},"stop_on_error":{"type":"boolean"}}), &["steps"])),
t("doc_inspect", "Inspect open documents", "With doc, return doc_info; otherwise return information for every open document (empty documents array when none are open).")
.ro().with(schema(json!({"doc":doc()}), &[])),
t("render_preview", "Render a preview", "Render a PNG bounded by max_side (default 1024), without changing or saving the document. Page defaults to 1; doc may be omitted when exactly one document is open.")
.ro().with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},"max_side":{"type":"integer","minimum":1,"maximum":4096}}), &[])),
]
}

File diff suppressed because it is too large Load Diff

91
crates/cos/README.md Normal file
View File

@ -0,0 +1,91 @@
# pdfcraft-cos
The PDF object layer (ISO 32000-2 §7): the COS object graph, parsed lazily and tolerantly, edited
through a copy-on-write overlay, and written back incrementally or as a full rewrite.
- **Layer:** L1, standalone. The layering table lets it use only `pdfcraft-filters`,
`pdfcraft-crypt` and `pdfcraft-geom` (`xtask/src/layers.rs::STANDALONE_DEPS`); it currently uses
the first two. That keeps the parser and the crypto independently auditable and reusable.
- `#![forbid(unsafe_code)]`, and the crate denies `unwrap`/`expect`/`panic!`/`todo!`. Builds for
`wasm32-unknown-unknown`.
**The object graph is the document model.** Nothing is lifted into a tidier representation and
written back out, because that is how data gets silently dropped. Typed views live above, in
`pdfcraft-model`.
## API
```rust
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 d = doc.dict(&obj); // Option<Dict>, resolving first
let data = stream.decoded()?; // filters applied on demand
doc.set(r, Object::Dict(d)); // edit: lands in the overlay
let r = doc.add(obj); // append a new object
doc.free(r);
let bytes = write_incremental(&doc, &SaveOptions::default())?; // append a revision
let bytes = write_full(&doc, &SaveOptions::default())?; // garbage-collected rewrite
```
Both writers **return the bytes**; the caller decides where they go, which is what lets the engine
produce a working file in memory after every edit and keep saving atomic.
Useful alongside: `doc.repair_log()` (what was reconstructed), `doc.revisions()` and
`doc.revision_ends()` (the incremental history), `doc.is_edited(num)` and `doc.modified_objects()`,
`doc.permissions()` and `doc.security()`, `doc.set_encryption()` / `doc.remove_encryption()`, and
`doc.require_full_save()` for edits that cannot be expressed incrementally.
`Document` is **cheap to clone**: the original bytes, the xref index and the parse caches are
shared, so a clone is a snapshot. Undo/redo and background jobs hold clones while the UI edits
another. Edits never touch the original bytes.
## Reading
- **Every cross-reference form:** classic tables with their quirky subsections, cross-reference
streams, hybrid-reference files (`/XRefStm`), and object streams.
- **Repair.** A file whose xref is wrong or missing is reconstructed by scanning for `obj`
headers. Repairs are *recorded*, never silent, so the UI can say the file was damaged.
- **Tolerant, never fatal.** The lexer works on a byte slice and a position; it allocates nothing
for skipped data, bounds-checks every index, and caps nesting depth. Malformed input yields
`CosError`, never a panic — this crate is the first thing a hostile file meets.
- **Lazy.** Objects are parsed on first use and cached. (Whole-file buffering is still the case;
`ByteSource` streaming is `core.lazy-loading`, not yet done.)
- **Encryption** is handled on load and save through `pdfcraft-crypt`: every standard-security
revision R2–R6 (RC4, AES-128/256), crypt filters, `/EFF` embedded-file encryption, and
`/EncryptMetadata`. `security.rs` knows what the spec exempts — the `/Encrypt` dictionary, xref
streams, the trailer `/ID`, and objects inside an object stream (the stream is encrypted whole).
## Writing
`write_incremental` appends only the edited objects, a cross-reference section **in the same
style as the file's last one**, and a trailer with `/Prev`. The original bytes are never modified,
which is what keeps a signature over an earlier revision valid.
`write_full` writes only what the trailer can reach, renumbered densely — the Save As and
garbage-collecting path. By default (`SaveOptions::object_streams`) it packs non-stream objects
into compressed object streams with a cross-reference stream (PDF 1.5+); turning that off writes a
classic table any reader accepts.
Dictionaries keep their key order and streams keep their *encoded* bytes, so rewritten objects stay
recognisable in a diff and byte-stable when re-serialized.
## Errors
`CosError`: `NotPdf`, `Syntax { offset, detail }`, `MissingObject`, `NotADictionary`, `Filter`,
`NeedsPassword`, `WrongPassword`, `Security`, `Poisoned`.
## Not done here
- Streaming very large files within a memory budget (`ByteSource`, `core.lazy-loading`); files are
read whole today.
- Linearized output, Fast Web View (`core.linearization`, M11).
- Structural validation against the Arlington model (`core.arlington-validation`), which will live
in the reserved `arlington` crate.
- Image codecs. `pdfcraft-filters` carries the non-image filters; DCT, JPX, JBIG2 and CCITT are
still decoded through the bootstrap renderer (`core.image-filters`, M2.6).

93
crates/cos/src/bytes.rs Normal file
View File

@ -0,0 +1,93 @@
//! Stream data that can share a larger buffer instead of copying out of it.
use std::ops::{Deref, Range};
use std::sync::Arc;
/// A stream's bytes: a buffer of their own, or a range of a larger shared buffer. Streams read
/// from a file point into the file's bytes, which the document keeps anyway, so caching a
/// parsed stream costs its dictionary and not a second copy of its data.
#[derive(Clone, Default)]
pub struct Bytes {
buf: Arc<Vec<u8>>,
range: Range<usize>,
}
impl Bytes {
/// `range` of `buf`, without copying; `None` when the range is not within `buf`.
pub(crate) fn view(buf: &Arc<Vec<u8>>, range: Range<usize>) -> Option<Self> {
(range.start <= range.end && range.end <= buf.len()).then(|| Self { buf: buf.clone(), range })
}
/// Whether these bytes keep alive a larger buffer than themselves that isn't `owner`.
pub(crate) fn borrows_other_than(&self, owner: &Arc<Vec<u8>>) -> bool {
self.range.len() < self.buf.len() && !Arc::ptr_eq(&self.buf, owner)
}
/// The same bytes, in a buffer of their own.
pub(crate) fn detached(&self) -> Self {
Self::from(self.to_vec())
}
}
impl From<Vec<u8>> for Bytes {
fn from(v: Vec<u8>) -> Self {
let range = 0..v.len();
Self { buf: Arc::new(v), range }
}
}
impl From<Arc<Vec<u8>>> for Bytes {
fn from(buf: Arc<Vec<u8>>) -> Self {
let range = 0..buf.len();
Self { buf, range }
}
}
impl Deref for Bytes {
type Target = [u8];
fn deref(&self) -> &[u8] {
// `view` and `From` only build in-bounds ranges; an empty slice keeps this panic-free.
self.buf.get(self.range.clone()).unwrap_or_default()
}
}
impl AsRef<[u8]> for Bytes {
fn as_ref(&self) -> &[u8] {
self
}
}
impl PartialEq for Bytes {
fn eq(&self, other: &Self) -> bool {
(Arc::ptr_eq(&self.buf, &other.buf) && self.range == other.range) || **self == **other
}
}
impl Eq for Bytes {}
impl std::fmt::Debug for Bytes {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
(**self).fmt(f)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn views_share_their_buffer_and_compare_by_content() {
let file = Arc::new(b"0123456789".to_vec());
let view = Bytes::view(&file, 2..5).expect("in range");
assert_eq!(&*view, b"234");
assert_eq!(view.as_ptr(), file[2..].as_ptr(), "no copy");
assert_eq!(view, Bytes::from(b"234".to_vec()));
assert!(Bytes::view(&file, 5..11).is_none() && Bytes::view(&file, Range { start: 6, end: 5 }).is_none());
let other = Arc::new(Vec::new());
assert!(view.borrows_other_than(&other) && !view.borrows_other_than(&file));
let own = view.detached();
assert!(!own.borrows_other_than(&other) && own == view && own.as_ptr() != view.as_ptr());
assert!(!Bytes::from(file.clone()).borrows_other_than(&other), "a whole buffer is its own");
}
}

View File

@ -10,7 +10,7 @@ use std::sync::{Arc, Mutex};
use crate::CosError;
use crate::object::{Dict, ObjRef, Object};
use crate::parser::{Lexer, is_whitespace, parse_indirect};
use crate::parser::{Lexer, is_whitespace, parse_indirect, parse_indirect_shared};
thread_local! {
/// The objects being loaded on this thread, innermost last (see `Document::try_get`).
@ -509,7 +509,7 @@ impl Document {
length
};
let off = *offset as usize;
let at = |off: usize| match parse_indirect(&self.data, off, &resolve) {
let at = |off: usize| match parse_indirect_shared(&self.data, off, &resolve) {
Ok((id, o)) if id.num == num => Some(self.decrypted(id, o)),
_ => None,
};
@ -564,7 +564,7 @@ impl Document {
if p > 0 && !is_whitespace(data[p - 1]) {
continue;
}
if let Ok((id, o)) = parse_indirect(data, p, &|_| None)
if let Ok((id, o)) = parse_indirect_shared(data, p, &|_| None)
&& id.num == num
{
found = Some((id, o)); // keep the last (newest) definition
@ -575,9 +575,22 @@ impl Document {
// ── editing ─────────────────────────────────────────────────────────────────────────────
/// Stream data still pointing into another document's file would keep that whole file
/// alive for as long as this edit exists: give it a buffer of its own (just its bytes).
fn adopt(&self, obj: Object) -> Object {
match obj {
Object::Stream(mut s) if s.raw.borrows_other_than(&self.data) => {
s.raw = s.raw.detached();
Object::Stream(s)
}
other => other,
}
}
/// Replace an object (keeping its generation).
pub fn set(&mut self, r: ObjRef, obj: impl Into<Object>) {
self.overlay.insert(r.num, Slot::Set(r.generation, Arc::new(obj.into())));
let obj = self.adopt(obj.into());
self.overlay.insert(r.num, Slot::Set(r.generation, Arc::new(obj)));
if r.num >= self.next_num {
self.next_num = r.num + 1;
}
@ -587,7 +600,8 @@ impl Document {
pub fn add(&mut self, obj: impl Into<Object>) -> ObjRef {
let r = ObjRef::new(self.next_num, 0);
self.next_num += 1;
self.overlay.insert(r.num, Slot::Set(0, Arc::new(obj.into())));
let obj = self.adopt(obj.into());
self.overlay.insert(r.num, Slot::Set(0, Arc::new(obj)));
r
}
@ -1010,7 +1024,7 @@ mod tests {
// Either recovered via the endstream search or reported — but never a stack overflow.
let o = doc.get(ObjRef::new(3, 0));
if let Object::Stream(s) = o.as_ref() {
assert_eq!(s.raw.as_slice(), b"abc");
assert_eq!(&s.raw[..], b"abc");
}
}

View File

@ -5,18 +5,22 @@
//! untouched) or as a full, garbage-collected rewrite. The PDF object graph *is* PdfCraft's
//! document model (plan/architecture.md §5, ADR-0010).
//!
//! Status (M1 in progress): parsing of all xref forms, object streams, repair by scanning,
//! incremental and full writing. Not yet: encryption (M1.6), object-stream / xref-stream output
//! for full saves, linearization (M11).
//! Done: every cross-reference form (tables, streams, hybrid `/XRefStm`), object streams, repair
//! by scanning, encryption on load and save (R2–R6, crypt filters, `/EFF`), incremental writing,
//! and full saves that pack objects into compressed object streams with a cross-reference stream.
//! Not yet: streaming very large files within a memory budget (`ByteSource`,
//! `core.lazy-loading`), and linearized output (`core.linearization`, M11). See the README.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod bytes;
mod document;
mod object;
mod parser;
mod security;
mod writer;
pub use bytes::Bytes;
pub use document::{Document, Revision, XrefEntry};
pub use object::{Dict, MAX_DECODED, Name, ObjRef, Object, PdfString, Stream};
pub use parser::{Lexer, parse_indirect};

View File

@ -3,11 +3,9 @@
//! Dictionaries keep their key order so rewritten objects stay recognisable in diffs and
//! byte-stable when re-serialized. Streams keep their *encoded* bytes; decoding is on demand.
use std::sync::Arc;
use pdfcraft_filters::{Filter, Params};
use crate::CosError;
use crate::{Bytes, CosError};
/// An indirect reference: object number and generation.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
@ -160,7 +158,7 @@ impl FromIterator<(Name, Object)> for Dict {
#[derive(Clone, Debug, PartialEq)]
pub struct Stream {
pub dict: Dict,
pub raw: Arc<Vec<u8>>,
pub raw: Bytes,
}
/// Upper bound for a single decoded stream (decompression-bomb defence).
@ -169,14 +167,14 @@ pub const MAX_DECODED: usize = 1 << 30;
impl Stream {
/// A new stream from already-encoded bytes (`/Length` is set at write time).
pub fn from_raw(dict: Dict, raw: Vec<u8>) -> Self {
Self { dict, raw: Arc::new(raw) }
Self { dict, raw: raw.into() }
}
/// A new stream holding `data` compressed with Flate.
pub fn flate(mut dict: Dict, data: &[u8]) -> Self {
dict.set(b"Filter".to_vec(), Object::Name(b"FlateDecode".to_vec()));
dict.remove(b"DecodeParms");
Self { dict, raw: Arc::new(pdfcraft_filters::encode_flate(data)) }
Self { dict, raw: pdfcraft_filters::encode_flate(data).into() }
}
/// The filter chain declared in the dictionary.
@ -223,19 +221,29 @@ impl Stream {
pub fn decoded_within(&self, max: usize) -> Result<Vec<u8>, CosError> {
let chain = self.filters();
if chain.is_empty() {
return Ok(self.raw.as_ref().clone());
return self.raw_within(max);
}
pdfcraft_filters::decode_tolerant(&chain, &self.raw, max.min(MAX_DECODED)).map(|(v, _)| v).map_err(|e| CosError::Filter(e.to_string()))
}
/// Decoded data; any corruption is an error.
/// Decoded data, bounded by [`MAX_DECODED`]; any corruption is an error.
pub fn decoded_strict(&self) -> Result<Vec<u8>, CosError> {
let chain = self.filters();
if chain.is_empty() {
return Ok(self.raw.as_ref().clone());
return self.raw_within(MAX_DECODED);
}
pdfcraft_filters::decode(&chain, &self.raw, MAX_DECODED).map_err(|e| CosError::Filter(e.to_string()))
}
fn raw_within(&self, max: usize) -> Result<Vec<u8>, CosError> {
let max = max.min(MAX_DECODED);
// An empty decoder chain still produces an owned output buffer: check its limit
// before cloning, including streams whose /Crypt filter was applied on load.
if self.raw.len() > max {
return Err(CosError::Filter(pdfcraft_filters::FilterError::LimitExceeded(max).to_string()));
}
Ok(self.raw.to_vec())
}
}
#[derive(Clone, Debug, PartialEq)]
@ -391,4 +399,55 @@ mod tests {
assert_eq!(keys, vec![b"B".to_vec(), b"A".to_vec()]);
assert_eq!(d.int(b"B"), Some(3));
}
#[test]
fn raw_streams_refuse_decoding_past_a_caller_limit() {
let stream = Stream::from_raw(Dict::new(), b"abc".to_vec());
for max in [0, 2] {
assert!(matches!(stream.decoded_within(max), Err(CosError::Filter(_))));
}
}
#[test]
fn empty_filter_arrays_refuse_decoding_past_a_caller_limit() {
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::Array(Vec::new()));
let stream = Stream::from_raw(dict, b"abc".to_vec());
assert!(matches!(stream.decoded_within(2), Err(CosError::Filter(_))));
}
#[test]
fn crypt_only_streams_refuse_decoding_past_a_caller_limit() {
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::name("Crypt"));
let mut parms = Dict::new();
parms.set(b"Name".to_vec(), Object::name("Identity"));
dict.set(b"DecodeParms".to_vec(), Object::Dict(parms));
let stream = Stream::from_raw(dict, b"abc".to_vec());
assert!(matches!(stream.decoded_within(2), Err(CosError::Filter(_))));
}
#[test]
fn identity_streams_preserve_bytes_within_a_caller_limit() {
let mut array = Dict::new();
array.set(b"Filter".to_vec(), Object::Array(Vec::new()));
let mut crypt = Dict::new();
crypt.set(b"Filter".to_vec(), Object::name("Crypt"));
for dict in [Dict::new(), array, crypt] {
let stream = Stream::from_raw(dict.clone(), b"abc".to_vec());
assert_eq!(stream.decoded_within(3).unwrap(), b"abc");
assert_eq!(stream.decoded_within(usize::MAX).unwrap(), b"abc");
assert_eq!(stream.decoded().unwrap(), b"abc");
assert_eq!(stream.decoded_strict().unwrap(), b"abc");
assert!(Stream::from_raw(dict, Vec::new()).decoded_within(0).unwrap().is_empty());
}
}
#[test]
fn filtered_streams_preserve_the_caller_limit() {
let stream = Stream::flate(Dict::new(), b"abc");
assert!(matches!(stream.decoded_within(2), Err(CosError::Filter(_))));
assert_eq!(stream.decoded_within(3).unwrap(), b"abc");
assert_eq!(stream.decoded_strict().unwrap(), b"abc");
}
}

View File

@ -4,8 +4,10 @@
//! never panics on malformed input (every index is bounds-checked). Depth is limited to defend
//! against pathological nesting.
use crate::CosError;
use std::sync::Arc;
use crate::object::{Dict, ObjRef, Object, PdfString, Stream};
use crate::{Bytes, CosError};
const MAX_DEPTH: usize = 128;
@ -356,6 +358,25 @@ fn hexval(b: u8) -> Option<u8> {
/// Parse an indirect object at `offset`: `N G obj <object> [stream…endstream] endobj`.
/// `resolve_length` resolves an indirect `/Length`. Returns the object and its (num, gen).
pub fn parse_indirect(data: &[u8], offset: usize, resolve_length: &dyn Fn(ObjRef) -> Option<i64>) -> Result<(ObjRef, Object), CosError> {
parse_indirect_in(data, None, offset, resolve_length)
}
/// [`parse_indirect`] for a document's own bytes: stream data points into `data` instead of
/// being copied out of it.
pub(crate) fn parse_indirect_shared(
data: &Arc<Vec<u8>>,
offset: usize,
resolve_length: &dyn Fn(ObjRef) -> Option<i64>,
) -> Result<(ObjRef, Object), CosError> {
parse_indirect_in(data, Some(data), offset, resolve_length)
}
fn parse_indirect_in(
data: &[u8],
shared: Option<&Arc<Vec<u8>>>,
offset: usize,
resolve_length: &dyn Fn(ObjRef) -> Option<i64>,
) -> Result<(ObjRef, Object), CosError> {
let mut lx = Lexer::new(data, offset);
lx.skip_ws();
let num_tok = lx.token();
@ -407,10 +428,15 @@ pub fn parse_indirect(data: &[u8], offset: usize, resolve_length: &dyn Fn(ObjRef
// Wrong or missing /Length: search for "endstream" (§7.3.8.1 recovery).
_ => find_endstream(data, start).ok_or_else(|| CosError::Syntax { offset: start, detail: "stream without endstream".into() })? - start,
};
let raw = data[start..start + len].to_vec();
let range = start..start + len;
let raw = match shared {
Some(buf) => Bytes::view(buf, range),
None => data.get(range).map(|r| Bytes::from(r.to_vec())),
}
.ok_or_else(|| CosError::Syntax { offset: start, detail: "stream data past the end of the file".into() })?;
let mut dict = dict.clone();
dict.set(b"Length".to_vec(), Object::Int(raw.len() as i64));
return Ok((id, Object::Stream(Stream::from_raw(dict, raw))));
return Ok((id, Object::Stream(Stream { dict, raw })));
}
Ok((id, obj))
}
@ -506,7 +532,7 @@ mod tests {
let (id, o) = parse_indirect(data, 0, &|_| None).unwrap();
assert_eq!(id, ObjRef::new(7, 0));
let Object::Stream(s) = o else { panic!("stream expected") };
assert_eq!(s.raw.as_slice(), b"HELLO");
assert_eq!(&s.raw[..], b"HELLO");
assert_eq!(s.dict.int(b"Length"), Some(5));
}
@ -515,7 +541,7 @@ mod tests {
let data = b"7 0 obj\n<< /Length 8 0 R >>\nstream\r\nAB\r\nendstream\nendobj\n";
let (_, o) = parse_indirect(data, 0, &|r| (r.num == 8).then_some(2)).unwrap();
let Object::Stream(s) = o else { panic!() };
assert_eq!(s.raw.as_slice(), b"AB");
assert_eq!(&s.raw[..], b"AB");
}
#[test]

View File

@ -108,7 +108,7 @@ fn walk(o: &Object, strings: &dyn Fn(&PdfString) -> PdfString, h: &SecurityHandl
_ => StreamKind::Normal,
};
let raw = if decrypt { h.decrypt_stream(num, generation, &s.raw, kind) } else { h.encrypt_stream(num, generation, &s.raw, kind) };
Object::Stream(Stream { dict, raw: std::sync::Arc::new(raw) })
Object::Stream(Stream { dict, raw: raw.into() })
}
other => other.clone(),
}

View File

@ -181,16 +181,17 @@ pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>,
let mut doc = doc.clone();
stamp_mod_date(&mut doc, opts);
let original = doc.bytes().clone();
let mut out = Vec::with_capacity(original.len() + 4096);
out.extend_from_slice(&original);
if !matches!(out.last(), Some(b'\n' | b'\r')) {
out.push(b'\n');
}
// The revision is written on its own and joined to the original at the end, so the result is
// allocated once at its exact size: growing a buffer that holds a large original past a
// guessed capacity would copy the whole file again and keep up to as much again unused.
let newline = !matches!(original.last(), Some(b'\n' | b'\r'));
let base = (original.len() + usize::from(newline)) as u64;
let mut out = Vec::new();
let mut offsets: BTreeMap<u32, (u64, u16, bool)> = BTreeMap::new(); // num → (offset, gen, in use)
for (num, generation, obj) in doc.overlay_entries().collect::<Vec<_>>() {
match obj {
Some(o) => {
offsets.insert(num, (out.len() as u64, generation, true));
offsets.insert(num, (base + out.len() as u64, generation, true));
write_indirect(num, generation, &prepared(&doc, num, num, generation, &o), &mut out);
}
None => {
@ -210,16 +211,22 @@ pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>,
let size = doc.next_num().max(trailer.int(b"Size").unwrap_or(0) as u32);
if as_stream {
let rows = offsets.iter().map(|(n, (o, g, used))| (*n, if *used { Row::InFile(*o, *g) } else { Row::Free(*g) })).collect();
write_xref_stream(&mut out, &mut trailer, rows, size);
write_xref_stream(&mut out, base, &mut trailer, rows, size);
} else {
trailer.set(b"Size".to_vec(), Object::Int(size as i64));
let xref_at = out.len();
let xref_at = base + out.len() as u64;
write_xref_table(&mut out, &offsets, prev.is_none());
out.extend_from_slice(b"trailer\n");
write_dict(&trailer, &mut out);
let _ = write!(out, "\nstartxref\n{xref_at}\n%%EOF\n");
}
Ok(out)
let mut file = Vec::with_capacity(original.len() + usize::from(newline) + out.len());
file.extend_from_slice(&original);
if newline {
file.push(b'\n');
}
file.extend_from_slice(&out);
Ok(file)
}
/// Write only reachable objects, renumbered from 1, with a classic cross-reference table.
@ -317,7 +324,7 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>, CosErro
ensure_id(&mut trailer, opts, doc.bytes());
}
if opts.object_streams {
write_xref_stream(&mut out, &mut trailer, rows, next);
write_xref_stream(&mut out, 0, &mut trailer, rows, next);
} else {
trailer.set(b"Size".to_vec(), Object::Int(next as i64));
let offsets: BTreeMap<u32, (u64, u16, bool)> =
@ -328,6 +335,9 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>, CosErro
write_dict(&trailer, &mut out);
let _ = write!(out, "\nstartxref\n{xref_at}\n%%EOF\n");
}
// The size isn't known until the end, and the buffer grew by doubling. The result is often
// kept as a document's working bytes for as long as it's open: drop the unused capacity.
out.shrink_to_fit();
Ok(out)
}
@ -374,9 +384,10 @@ fn write_xref_table(out: &mut Vec<u8>, offsets: &BTreeMap<u32, (u64, u16, bool)>
}
/// A cross-reference stream section (§7.5.8) that takes object number `num`, ending the file.
/// Rows are Flate-compressed with the PNG Up predictor, and field widths fit the largest value.
fn write_xref_stream(out: &mut Vec<u8>, trailer: &mut Dict, mut rows: BTreeMap<u32, Row>, num: u32) {
let xref_at = out.len() as u64;
/// `out` holds the file from byte offset `base` on. Rows are Flate-compressed with the PNG Up
/// predictor, and field widths fit the largest value.
fn write_xref_stream(out: &mut Vec<u8>, base: u64, trailer: &mut Dict, mut rows: BTreeMap<u32, Row>, num: u32) {
let xref_at = base + out.len() as u64;
rows.insert(num, Row::InFile(xref_at, 0));
if trailer.get(b"Prev").is_none() {
rows.entry(0).or_insert(Row::Free(65535));

View File

@ -0,0 +1,78 @@
//! Stream data read from a file points into the file's bytes instead of being copied: the
//! document's object cache holds parsed dictionaries, not a second copy of every stream.
use std::sync::Arc;
use pdfcraft_cos::{Document, ObjRef, Object, SaveOptions, write_full, write_incremental};
const CONTENT: &[u8] = b"0 0 1 rg 10 10 30 20 re f";
fn fixture() -> Vec<u8> {
let objs = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 50] >>".to_string(),
"<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>".to_string(),
format!("<< /Length {} >>\nstream\n{}\nendstream", CONTENT.len(), String::from_utf8_lossy(CONTENT)),
];
let mut out = b"%PDF-1.4\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
const CONTENTS: ObjRef = ObjRef { num: 4, generation: 0 };
fn stream_bytes(doc: &Document, r: ObjRef) -> (Vec<u8>, *const u8) {
match &*doc.get(r) {
Object::Stream(s) => (s.raw.to_vec(), s.raw.as_ptr()),
other => panic!("{other:?}"),
}
}
#[test]
fn cached_streams_are_the_files_own_bytes() {
let file = Arc::new(fixture());
let doc = Document::open(file.clone()).unwrap();
let (data, at) = stream_bytes(&doc, CONTENTS);
assert_eq!(data, CONTENT);
assert!(file.as_ptr_range().contains(&at), "not a copy");
// A full save reads and caches every object; the cache still holds no copy of stream data.
let full = write_full(&doc, &SaveOptions::default()).unwrap();
assert!(file.as_ptr_range().contains(&stream_bytes(&doc, CONTENTS).1));
// Saves are unchanged: the stream is written as it was read.
let back = Document::open(Arc::new(full)).unwrap();
assert_eq!(stream_bytes(&back, ObjRef::new(4, 0)).0, CONTENT);
}
#[test]
fn edits_within_a_document_keep_pointing_into_its_file() {
let file = Arc::new(fixture());
let mut doc = Document::open(file.clone()).unwrap();
doc.update_dict(CONTENTS, |d| d.set(b"Marked".to_vec(), Object::Bool(true))).unwrap();
assert!(file.as_ptr_range().contains(&stream_bytes(&doc, CONTENTS).1));
let updated = write_incremental(&doc, &SaveOptions::default()).unwrap();
let back = Document::open(Arc::new(updated)).unwrap();
assert_eq!(stream_bytes(&back, CONTENTS).0, CONTENT);
}
#[test]
fn streams_moved_to_another_document_get_their_own_bytes() {
let file = Arc::new(fixture());
let source = Document::open(file.clone()).unwrap();
let moved = (*source.get(CONTENTS)).clone();
let mut target = Document::new_empty();
let r = target.add(moved);
drop(source);
// The target keeps just the stream's bytes, not the whole source file.
assert_eq!(Arc::strong_count(&file), 1);
assert_eq!(stream_bytes(&target, r).0, CONTENT);
}

View File

@ -3,7 +3,7 @@
use std::sync::Arc;
use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, write_full, write_incremental};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full, write_incremental};
/// Four pages, a shared font, document info with a non-ASCII title.
fn fixture() -> Vec<u8> {
@ -87,6 +87,54 @@ fn incremental_saves_stack_on_an_xref_stream_file() {
assert_eq!(hayro_pages(&updated), 4);
}
/// The revision is joined to the original once, at its exact size. A revision of more than a few
/// kilobytes used to outgrow the buffer: the whole original was copied a second time, and the
/// result kept up to as much again unused for as long as it was the document's working bytes.
#[test]
fn incremental_saves_are_allocated_at_their_exact_size() {
let table = Arc::new(fixture());
let stream = Arc::new(write_full(&Document::open(table.clone()).unwrap(), &SaveOptions::default()).unwrap());
let no_newline = Arc::new(fixture().trim_ascii_end().to_vec());
for original in [table, stream, no_newline] {
let mut doc = Document::open(original.clone()).unwrap();
let data: Vec<u8> = (0..100_000u32).map(|i| (i % 251) as u8).collect();
let added = doc.add(Object::Stream(Stream { dict: Dict::new(), raw: data.clone().into() }));
let updated = write_incremental(&doc, &SaveOptions::default()).unwrap();
assert_eq!(updated.capacity(), updated.len());
assert!(updated.starts_with(&original), "an incremental save only appends");
let back = Document::open(Arc::new(updated.clone())).unwrap();
assert!(back.repair_log().is_empty(), "{:?}", back.repair_log());
match &*back.get(added) {
Object::Stream(s) => assert_eq!(*s.raw, data),
other => panic!("{other:?}"),
}
assert_eq!(hayro_pages(&updated), 4);
}
}
/// A full save grows its buffer as it writes, so it used to end with up to as much again unused,
/// kept alive with the document's working bytes. It now ends at its exact size.
#[test]
fn full_saves_are_allocated_at_their_exact_size() {
let mut doc = Document::open(Arc::new(fixture())).unwrap();
let data: Vec<u8> = (0..100_000u32).map(|i| (i % 251) as u8).collect();
let added = doc.add(Object::Stream(Stream { dict: Dict::new(), raw: Arc::new(data.clone()).into() }));
let root = doc.root().unwrap();
doc.update_dict(root, |d| d.set(b"Extra".to_vec(), Object::Ref(added))).unwrap();
for object_streams in [true, false] {
let full = write_full(&doc, &SaveOptions { object_streams, ..SaveOptions::default() }).unwrap();
assert_eq!(full.capacity(), full.len(), "object streams: {object_streams}");
let back = Document::open(Arc::new(full.clone())).unwrap();
assert!(back.repair_log().is_empty(), "{:?}", back.repair_log());
let extra = back.dict(&Object::Ref(back.root().unwrap())).and_then(|d| d.get(b"Extra").cloned()).unwrap();
match &*back.resolve(&extra) {
Object::Stream(s) => assert_eq!(*s.raw, data),
other => panic!("{other:?}"),
}
assert_eq!(hayro_pages(&full), 4);
}
}
#[test]
fn qpdf_accepts_object_stream_output() {
let Ok(out) = std::process::Command::new("qpdf").arg("--version").output() else {

View File

@ -374,6 +374,45 @@ fn tiff_pages(name: &str, bytes: &[u8]) -> Result<Vec<Embedded>, CreateError> {
Ok(out)
}
/// What a file picked for Create is, by its bytes (and, for text, its name).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SourceKind {
Pdf,
Image,
Text,
}
/// File extensions Create converts to PDF (images and plain text).
pub const CONVERTIBLE: [&str; 12] = ["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp", "jp2", "j2k", "jpx", "txt", "text"];
fn is_image(bytes: &[u8]) -> bool {
bytes.starts_with(&[0xFF, 0xD8])
|| bytes.starts_with(b"\x89PNG\r\n\x1a\n")
|| bytes.starts_with(b"II*\0")
|| bytes.starts_with(b"MM\0*")
|| bytes.starts_with(b"GIF8")
// JPEG 2000: a JP2 file or a raw codestream.
|| bytes.starts_with(JP2_SIGNATURE)
|| bytes.starts_with(&[0xFF, 0x4F, 0xFF, 0x51])
// BMP: "BM" and a known header size (so text starting with "BM" stays text).
|| (bytes.starts_with(b"BM")
&& bytes.get(14..18).and_then(|h| <[u8; 4]>::try_from(h).ok()).is_some_and(|h| matches!(u32::from_le_bytes(h), 12 | 40 | 52 | 56 | 108 | 124)))
}
/// Whether `bytes` named `name` is a PDF, an image Create can embed, or plain text (a `.txt` or
/// `.text` file). `None` for anything else.
pub fn source_kind(name: &str, bytes: &[u8]) -> Option<SourceKind> {
let head = bytes.get(..bytes.len().min(1024)).unwrap_or_default();
if head.windows(5).any(|w| w == b"%PDF-") {
return Some(SourceKind::Pdf);
}
if is_image(bytes) {
return Some(SourceKind::Image);
}
let lower = name.to_ascii_lowercase();
(lower.ends_with(".txt") || lower.ends_with(".text")).then_some(SourceKind::Text)
}
/// Detect the image format from its bytes; a TIFF may hold several pages.
fn embed(name: &str, bytes: &[u8]) -> Result<Vec<Embedded>, CreateError> {
if bytes.starts_with(&[0xFF, 0xD8]) {

View File

@ -258,3 +258,18 @@ fn jpeg_2000_images_are_embedded_as_is() {
let doc = reopen(&from_images(&[("raw.j2k".into(), siz)]).unwrap());
assert_eq!(media(&pages(&doc)[0])[2], 64.0);
}
#[test]
fn source_kind_tells_pdfs_images_and_text_apart() {
assert_eq!(source_kind("a.bin", b"%PDF-1.7\n"), Some(SourceKind::Pdf));
assert_eq!(source_kind("a.txt", b"junk before\n%PDF-1.4"), Some(SourceKind::Pdf), "a header after leading junk");
assert_eq!(source_kind("a", &png_bytes(false)), Some(SourceKind::Image));
assert_eq!(source_kind("a.txt", &jpeg_bytes()), Some(SourceKind::Image), "bytes win over the name");
assert_eq!(source_kind("notes.TXT", b"hello"), Some(SourceKind::Text));
assert_eq!(source_kind("notes.text", b""), Some(SourceKind::Text));
// Text that merely starts like a BMP stays text; a truncated BMP header is not an image.
assert_eq!(source_kind("b.txt", b"BMW drivers"), Some(SourceKind::Text));
assert_eq!(source_kind("b.bmp", b"BM"), None);
assert_eq!(source_kind("a.docx", b"PK\x03\x04"), None);
assert_eq!(source_kind("", b""), None);
}

View File

@ -14,6 +14,8 @@ pdfcraft-content = { workspace = true }
pdfcraft-fonts = { workspace = true }
pdfcraft-model = { workspace = true }
thiserror = { workspace = true }
# Display order of right-to-left lines in added text; already in the tree via the interface.
unicode-bidi = "0.3"
[dev-dependencies]
hayro-syntax = { workspace = true }

View File

@ -7,8 +7,11 @@
//! content. Boxes are in display space (origin at the bottom-left of the page as shown, after
//! `/Rotate`), so items stay upright on rotated pages.
use std::collections::HashMap;
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
use pdfcraft_fonts::{helvetica_width, literal, win_ansi};
use pdfcraft_fonts::{GlyphError, GlyphOutline, ShapedCluster, arabic_glyph, helvetica_width, literal, shape_arabic, win_ansi};
use unicode_bidi::{Level, ParagraphBidiInfo};
use crate::{EditError, check, contents, n, page_list, place_tagged};
@ -195,24 +198,167 @@ fn norm(r: [f64; 4]) -> [f64; 4] {
/// The lines of a text item after wrapping to its box width.
pub fn lines(t: &AddedText) -> Vec<String> {
wrapped(t).into_iter().map(|(line, _)| line).collect()
}
/// [`lines`], each with whether its paragraph runs right to left (its first strong character
/// does).
fn wrapped(t: &AddedText) -> Vec<(String, bool)> {
let width = (t.rect[2] - t.rect[0]).max(t.size);
let mut out = Vec::new();
for para in t.text.split('\n') {
let mut line = String::new();
let arabic = para.chars().any(is_arabic);
let rtl = arabic && unicode_bidi::get_base_direction(para) == unicode_bidi::Direction::Rtl;
// Arabic words are shaped one by one (joining stops at spaces), so a line's width is the
// sum of its words' and shaping stays linear in the paragraph's length. The space is the
// paragraph's; a space between Latin words is drawn in the item's font, a few hundredths
// of an em apart.
let measure = |s: &str| if arabic { arabic_width(t, s, rtl) } else { t.family.width(s, t.size, t.bold) };
let space = measure(" ");
let (mut line, mut line_w) = (String::new(), 0.0);
for word in para.split(' ') {
let candidate = if line.is_empty() { word.to_string() } else { format!("{line} {word}") };
if !line.is_empty() && t.family.width(&candidate, t.size, t.bold) > width {
out.push(std::mem::take(&mut line));
let candidate_w = if arabic { if line.is_empty() { measure(word) } else { line_w + space + measure(word) } } else { measure(&candidate) };
if !line.is_empty() && candidate_w > width {
out.push((std::mem::take(&mut line), rtl));
line = word.to_string();
line_w = measure(word);
} else {
line = candidate;
line_w = candidate_w;
}
}
out.push(line);
out.push((line, rtl));
}
out
}
/// Arabic letters, marks, digits and punctuation: an item with any of them is shaped with the
/// craft-fonts Arabic face.
fn is_arabic(c: char) -> bool {
matches!(u32::from(c), 0x0600..=0x06FF | 0x0750..=0x077F | 0x0870..=0x08FF | 0xFB50..=0xFDFF | 0xFE70..=0xFEFC)
}
/// The first character of `t` that would be drawn as `?`: outside WinAnsiEncoding and not drawn
/// with the craft-fonts Arabic face (#125), or Arabic that the face can't draw (or that has no
/// face to draw it). Page-content tools refuse such text; the Add-text editor keeps it open.
pub fn first_undrawable(t: &AddedText) -> Option<char> {
// Arabic that nothing can draw: there's no face, or the face lacks it.
if let Some(c) = t.text.chars().find(|c| shaped_arabic(*c) && !pdfcraft_fonts::arabic_has(*c)) {
return Some(c);
}
if pdfcraft_fonts::document_arabic_font().is_none() || !t.text.chars().any(is_arabic) {
// Drawn line by line in the item's standard font (`\n` splits lines). Without the face
// that is also how `draw` redraws an existing item, so it's checked that way.
return t.text.split('\n').find_map(pdfcraft_fonts::first_non_win_ansi);
}
// What `arabic_layout` leaves to the standard font, exactly as `draw_arabic` splits it.
wrapped(t).into_iter().find_map(|(line, rtl)| {
let (pieces, _) = arabic_layout(t, &line, rtl).ok()?;
pieces.iter().find_map(|p| match p {
Piece::Latin(s) => pdfcraft_fonts::first_non_win_ansi(s),
Piece::Arabic(_) => None,
})
})
}
/// An Arabic character the face shapes and draws (U+061C, a direction mark, is dropped).
fn shaped_arabic(c: char) -> bool {
is_arabic(c) && !is_bidi_control(c)
}
/// Direction marks, embeddings, overrides and isolates: they steer the order and are not drawn.
fn is_bidi_control(c: char) -> bool {
matches!(c, '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}')
}
fn mirrored(c: char) -> char {
match c {
'(' => ')',
')' => '(',
'[' => ']',
']' => '[',
'{' => '}',
'}' => '{',
'<' => '>',
'>' => '<',
'«' => '»',
'»' => '«',
other => other,
}
}
/// A piece of a line with Arabic text, in drawing order.
enum Piece {
/// Shown in the item's own standard font.
Latin(String),
Arabic(Vec<ShapedCluster>),
}
/// A line of an Arabic item in drawing order (the Unicode bidirectional algorithm, UAX #9), and
/// its width at the item's size. `rtl` is its paragraph's direction.
fn arabic_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec<Piece>, f64), EditError> {
// unicode-bidi indexes the first level of a line, so an empty one would panic.
if line.is_empty() {
return Ok((Vec::new(), 0.0));
}
let base = if rtl { Level::rtl() } else { Level::ltr() };
let info = ParagraphBidiInfo::new(line, Some(base));
let (levels, runs) = info.visual_runs(0..line.len());
let mut pieces = Vec::new();
for run in runs {
let run_rtl = levels.get(run.start).is_some_and(Level::is_rtl);
// Runs lie on character boundaries; a missing slice is skipped, never a panic.
let Some(text) = line.get(run) else { continue };
// Arabic letters take the Arabic face; so do spaces and punctuation between them, which the
// shaper then mirrors. Latin text (and what the face lacks) keeps the item's font.
let mut segments: Vec<(bool, String)> = Vec::new();
for c in text.chars().filter(|c| !is_bidi_control(*c)) {
let arabic = is_arabic(c) || (run_rtl && !c.is_alphanumeric() && pdfcraft_fonts::arabic_has(c));
match segments.last_mut() {
Some((kind, s)) if *kind == arabic => s.push(c),
_ => segments.push((arabic, c.to_string())),
}
}
if run_rtl {
segments.reverse();
}
for (arabic, s) in segments {
pieces.push(if arabic {
Piece::Arabic(shape_arabic(&s, run_rtl).map_err(|e| arabic_error(e, &s))?)
} else if run_rtl {
Piece::Latin(s.chars().rev().map(mirrored).collect())
} else {
Piece::Latin(s)
});
}
}
let width = pieces
.iter()
.map(|p| match p {
Piece::Latin(s) => t.family.width(s, t.size, t.bold),
Piece::Arabic(clusters) => clusters.iter().map(|c| c.advance).sum::<f64>() * t.size,
})
.sum();
Ok((pieces, width))
}
/// The width of `s` in an Arabic item; the item's font's estimate if it can't be shaped (drawing
/// then reports why).
fn arabic_width(t: &AddedText, s: &str, rtl: bool) -> f64 {
arabic_layout(t, s, rtl).map(|(_, w)| w).unwrap_or_else(|_| t.family.width(s, t.size, t.bold))
}
fn arabic_error(e: GlyphError, text: &str) -> EditError {
EditError::Invalid(match e {
GlyphError::NoFont => "Arabic text needs PdfCraft's Arabic font, which this build doesn't include \
(to build it in, set CRAFT_FONTS_DIR to a craft-fonts checkout that has an Arab face)"
.into(),
GlyphError::Missing => format!("the Arabic font can't show \"{text}\""),
GlyphError::TooComplex => format!("the Arabic font's glyphs for \"{text}\" are too complex"),
})
}
/// The box a text item occupies (height from its lines).
pub fn text_rect(t: &AddedText) -> [f64; 4] {
let r = norm(t.rect);
@ -224,8 +370,196 @@ fn font_name(base: &str) -> String {
format!("PCF{}", base.replace('-', ""))
}
/// Content and resources for an item; `view` maps display space to user space.
fn draw(doc: &Document, c: &Content, view: [f64; 6]) -> Result<(Vec<u8>, Dict), EditError> {
/// Most glyphs in one Type 3 font: its codes are single bytes, 1–240.
const GLYPHS_PER_FONT: usize = 240;
/// Most Type 3 fonts one item may use, which bounds the objects it adds (ordinary text needs one
/// or two).
const MAX_ARABIC_FONTS: usize = 16;
/// Draw a text item that has Arabic in it. Each line is laid out in display order; Arabic glyphs
/// come from Type 3 fonts drawn from the craft-fonts face's outlines (no font file is embedded),
/// with a ToUnicode map for copy and search. Other text uses the item's standard font `latin`.
fn draw_arabic(doc: &mut Document, t: &AddedText, latin: &str, taken: &Dict, fonts: &mut Dict, out: &mut Vec<u8>) -> Result<(), EditError> {
let r = text_rect(t);
let lines = wrapped(t);
let laid = lines.iter().map(|(line, rtl)| arabic_layout(t, line, *rtl)).collect::<Result<Vec<_>, _>>()?;
// Every distinct cluster (a letter with its dots and the text it stands for) is one glyph of
// one of the fonts, so copy and search get each character exactly once.
let mut keys: Vec<&ShapedCluster> = Vec::new();
let mut index: HashMap<ClusterKey, usize> = HashMap::new();
for c in laid.iter().flat_map(|(pieces, _)| pieces).flat_map(|p| match p {
Piece::Arabic(clusters) => clusters.as_slice(),
Piece::Latin(_) => &[],
}) {
index.entry(cluster_key(c)).or_insert_with(|| {
keys.push(c);
keys.len() - 1
});
}
if keys.len() > GLYPHS_PER_FONT * MAX_ARABIC_FONTS {
return Err(EditError::Invalid("the text uses too many different Arabic glyphs; split it into several boxes".into()));
}
// Every outline is read before anything is written, so a failure leaves the document as it was.
let mut outlines = HashMap::new();
for c in &keys {
for (id, _) in &c.glyphs {
if !outlines.contains_key(id) {
outlines.insert(*id, arabic_glyph(*id).map_err(|e| arabic_error(e, &c.text))?);
}
}
}
let mut names = Vec::new();
for chunk in keys.chunks(GLYPHS_PER_FONT) {
let font = type3_arabic(doc, chunk, &outlines);
// A name no font on the page has, so items never replace each other's fonts (object
// numbers alone don't do: a full save renumbers objects but keeps resource names).
let mut name = format!("PCAr{}", font.num);
let mut suffix = 0usize;
while taken.contains(name.as_bytes()) || fonts.contains(name.as_bytes()) {
suffix = suffix.saturating_add(1);
name = format!("PCAr{}_{suffix}", font.num);
}
fonts.set(name.clone().into_bytes(), Object::Ref(font));
names.push(name);
}
let [cr, cg, cb] = t.color.map(|v| v.clamp(0.0, 1.0));
out.extend(format!("BT {} {} {} rg\n", n(cr), n(cg), n(cb)).bytes());
for (i, ((line, rtl), (pieces, w))) in lines.iter().zip(&laid).enumerate() {
let spaces = line.matches(' ').count();
// Justified: the spaces of every line but the last stretch to fill the box.
let tw = if t.align == Align::Justify && i + 1 < lines.len() && spaces > 0 { ((r[2] - r[0]) - w).max(0.0) / spaces as f64 } else { 0.0 };
let x = match t.align {
// A right-to-left paragraph's unstretched last line ends at the right edge.
Align::Justify if *rtl => r[2] - w - tw * spaces as f64,
Align::Left | Align::Justify => r[0],
Align::Center => r[0] + ((r[2] - r[0]) - w) / 2.0,
Align::Right => r[2] - w,
};
let y = r[3] - (i as f64 * 1.2 + 0.95) * t.size;
let mut pen = x;
let mut current: Option<&str> = None;
for piece in pieces {
match piece {
Piece::Latin(s) => {
out.extend(format!("/{latin} {} Tf {} Tw 1 0 0 1 {} {} Tm ", n(t.size), n(tw), n(pen), n(y)).bytes());
out.extend(literal(&win_ansi(s)));
out.extend_from_slice(b" Tj\n");
current = Some(latin);
pen += t.family.width(s, t.size, t.bold) + tw * s.matches(' ').count() as f64;
}
Piece::Arabic(clusters) => {
for c in clusters {
let Some(&k) = index.get(&cluster_key(c)) else { continue };
let Some(name) = names.get(k / GLYPHS_PER_FONT) else { continue };
// Codes start at 1; k % 240 + 1 is at most 240.
let code = u8::try_from(k % GLYPHS_PER_FONT + 1).unwrap_or(1);
if current != Some(name.as_str()) {
out.extend(format!("/{name} {} Tf ", n(t.size)).bytes());
current = Some(name.as_str());
}
out.extend(format!("1 0 0 1 {} {} Tm ", n(pen), n(y)).bytes());
out.extend(literal(&[code]));
out.extend_from_slice(b" Tj\n");
pen += c.advance * t.size + if c.text == " " { tw } else { 0.0 };
}
}
}
}
}
out.extend_from_slice(b"ET\n");
Ok(())
}
/// What makes two clusters the same Type 3 glyph: text, glyphs, offsets and advance (bit for bit;
/// equal clusters come from the same font units).
type ClusterKey<'a> = (&'a str, Vec<(u32, u64, u64)>, u64);
fn cluster_key(c: &ShapedCluster) -> ClusterKey<'_> {
(c.text.as_str(), c.glyphs.iter().map(|(id, [x, y])| (*id, x.to_bits(), y.to_bits())).collect(), c.advance.to_bits())
}
/// A Type 3 font with `clusters` as codes 1…, each drawn from the Arabic face's glyph `outlines`.
fn type3_arabic(doc: &mut Document, clusters: &[&ShapedCluster], outlines: &HashMap<u32, GlyphOutline>) -> ObjRef {
let mut charprocs = Dict::new();
let mut differences = vec![Object::Int(1)];
let mut widths = Vec::with_capacity(clusters.len());
let mut bbox = [0.0f64; 4];
let mut to_unicode = Vec::new();
for (i, c) in clusters.iter().enumerate() {
let code = i + 1;
// Glyph space is 1000 units per em.
let mut paths = Vec::new();
let mut ink: Option<[f64; 4]> = None;
for (id, [dx, dy]) in &c.glyphs {
let Some(g) = outlines.get(id) else { continue };
for contour in &g.contours {
for (j, p) in contour.iter().enumerate() {
paths.extend(format!("{} {} {}\n", n((p[0] + dx) * 1000.0), n((p[1] + dy) * 1000.0), if j == 0 { "m" } else { "l" }).bytes());
}
paths.extend_from_slice(b"h\n");
let b = [g.bbox[0] + dx, g.bbox[1] + dy, g.bbox[2] + dx, g.bbox[3] + dy];
ink = Some(ink.map_or(b, |a| [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])]));
}
}
// Rounded outwards so no ink is clipped.
let [x0, y0, x1, y1] =
ink.map_or([0.0; 4], |b| [(b[0] * 1000.0).floor(), (b[1] * 1000.0).floor(), (b[2] * 1000.0).ceil(), (b[3] * 1000.0).ceil()]);
bbox = [bbox[0].min(x0), bbox[1].min(y0), bbox[2].max(x1), bbox[3].max(y1)];
let mut proc = format!("{} 0 {} {} {} {} d1\n", n(c.advance * 1000.0), n(x0), n(y0), n(x1), n(y1)).into_bytes();
if !paths.is_empty() {
proc.extend(paths);
proc.extend_from_slice(b"f\n");
}
let glyph = format!("g{code:02X}");
charprocs.set(glyph.clone().into_bytes(), Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &proc)))));
differences.push(Object::name(&glyph));
widths.push(Object::Real((c.advance * 1000.0).round()));
if !c.text.is_empty() {
let hex: String = c.text.encode_utf16().map(|u| format!("{u:04X}")).collect();
to_unicode.push(format!("<{code:02X}> <{hex}>\n"));
}
}
let mut cmap = String::from(
"/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CMapType 2 def\n1 begincodespacerange\n<01> <FF>\nendcodespacerange\n",
);
// A CMap block holds at most 100 entries.
for block in to_unicode.chunks(100) {
cmap.push_str(&format!("{} beginbfchar\n{}endbfchar\n", block.len(), block.concat()));
}
cmap.push_str("endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n");
let cmap = doc.add(Object::Stream(Stream::flate(Dict::new(), cmap.as_bytes())));
let family = pdfcraft_fonts::document_arabic_font().map_or("Arabic", |f| f.family);
let mut descriptor = Dict::new();
descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor"));
descriptor.set(b"FontName".to_vec(), Object::name(&family.replace(' ', "")));
descriptor.set(b"FontFamily".to_vec(), Object::String(PdfString::literal(family.as_bytes().to_vec())));
descriptor.set(b"Flags".to_vec(), Object::Int(4));
descriptor.set(b"ItalicAngle".to_vec(), Object::Int(0));
let mut encoding = Dict::new();
encoding.set(b"Type".to_vec(), Object::name("Encoding"));
encoding.set(b"Differences".to_vec(), Object::Array(differences));
let mut font = Dict::new();
font.set(b"Type".to_vec(), Object::name("Font"));
font.set(b"Subtype".to_vec(), Object::name("Type3"));
font.set(b"FontDescriptor".to_vec(), Object::Ref(doc.add(Object::Dict(descriptor))));
font.set(b"FontBBox".to_vec(), Object::Array(bbox.iter().map(|v| Object::Real(*v)).collect()));
font.set(
b"FontMatrix".to_vec(),
Object::Array(vec![Object::Real(0.001), Object::Int(0), Object::Int(0), Object::Real(0.001), Object::Int(0), Object::Int(0)]),
);
font.set(b"FirstChar".to_vec(), Object::Int(1));
font.set(b"LastChar".to_vec(), Object::Int(clusters.len() as i64));
font.set(b"Widths".to_vec(), Object::Array(widths));
font.set(b"Encoding".to_vec(), Object::Dict(encoding));
font.set(b"CharProcs".to_vec(), Object::Dict(charprocs));
font.set(b"ToUnicode".to_vec(), Object::Ref(cmap));
doc.add(Object::Dict(font))
}
/// Content and resources for an item; `view` maps display space to user space. `taken` holds the
/// page's font names. `existing`: the item is being replaced, so a build without the Arabic face
/// keeps drawing its Arabic as `?`, as before Arabic was supported, rather than refusing to move it.
fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict, existing: bool) -> Result<(Vec<u8>, Dict), EditError> {
let mut res = Dict::new();
let mut out = format!("q {} {} {} {} {} {} cm\n", n(view[0]), n(view[1]), n(view[2]), n(view[3]), n(view[4]), n(view[5])).into_bytes();
match c {
@ -241,6 +575,12 @@ fn draw(doc: &Document, c: &Content, view: [f64; 6]) -> Result<(Vec<u8>, Dict),
}
let mut fonts = Dict::new();
fonts.set(name.clone().into_bytes(), Object::Dict(font));
if t.text.chars().any(is_arabic) && (pdfcraft_fonts::document_arabic_font().is_some() || !existing) {
draw_arabic(doc, t, &name, taken, &mut fonts, &mut out)?;
res.set(b"Font".to_vec(), Object::Dict(fonts));
out.extend_from_slice(b"Q\n");
return Ok((out, res));
}
res.set(b"Font".to_vec(), Object::Dict(fonts));
let r = text_rect(t);
let [cr, cg, cb] = t.color.map(|v| v.clamp(0.0, 1.0));
@ -394,6 +734,17 @@ fn validate(c: &Content) -> Result<(), EditError> {
if (r[2] - r[0]).abs() < 1.0 {
return Err(EditError::Invalid("the text box is too narrow".into()));
}
// Without the Arabic face, `draw` decides for the Arabic letters themselves (#403): a new
// item is refused with what's missing, and one that already holds Arabic stays movable.
// Everything else in it is drawn in the standard font, so it still has to fit.
let undrawable = if t.text.chars().any(shaped_arabic) && pdfcraft_fonts::document_arabic_font().is_none() {
t.text.split('\n').find_map(|line| line.chars().find(|c| !shaped_arabic(*c) && !pdfcraft_fonts::win_ansi_encodable(*c)))
} else {
first_undrawable(t)
};
if let Some(c) = undrawable {
return Err(crate::undrawable(c));
}
}
Content::Image(_) => {
if (r[2] - r[0]).abs() < 1.0 || (r[3] - r[1]).abs() < 1.0 {
@ -410,9 +761,15 @@ fn write(doc: &mut Document, page: usize, c: &Content, obj: Option<ObjRef>) -> R
let all = page_list(doc);
check(&[page], all.len())?;
let p = &all[page];
let (content, res) = draw(doc, c, p.view_matrix(doc))?;
// The page gets its own copy of its resources with the item's fonts and images added.
let view = p.view_matrix(doc);
// The page gets its own copy of its resources with the item's fonts and images added, and
// without the Arabic fonts of the version it replaces.
let mut pres = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
if let Some(r) = obj {
drop_fonts(doc, &mut pres, &own_fonts(doc, r));
}
let taken = pres.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
let (content, res) = draw(doc, c, view, &taken, obj.is_some())?;
for (k, v) in res.iter() {
let mut sub = pres.get(k).map(|s| doc.resolve(s)).and_then(|s| s.as_dict().cloned()).unwrap_or_default();
for (n2, o) in v.as_dict().into_iter().flat_map(|d| d.iter()) {
@ -497,6 +854,40 @@ pub fn delete_content(doc: &mut Document, page: usize, index: usize) -> Result<(
let a = find(doc, page, index)?;
let p = page_list(doc).swap_remove(page);
let list: Vec<Object> = contents(doc, &p)?.into_iter().filter(|o| o.as_ref() != Some(a.obj)).collect();
doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list)))?;
let fonts = own_fonts(doc, a.obj);
// Only the page's own resources carry an item's fonts (`write` gives the page a copy).
let res = match p.dict.get(b"Resources") {
Some(Object::Dict(d)) if !fonts.is_empty() => Some(d.clone()),
_ => None,
};
let res = res.map(|mut r| {
drop_fonts(doc, &mut r, &fonts);
r
});
doc.update_dict(p.obj, |d| {
d.set(b"Contents".to_vec(), Object::Array(list));
if let Some(r) = res {
d.set(b"Resources".to_vec(), Object::Dict(r));
}
})?;
Ok(())
}
/// The Type 3 fonts an Arabic item's stream shows text with. Each item has its own (`draw_arabic`
/// names them so), so they go when the item is replaced or deleted.
fn own_fonts(doc: &Document, obj: ObjRef) -> Vec<Vec<u8>> {
let Object::Stream(s) = &*doc.get(obj) else { return Vec::new() };
let Ok(data) = s.decoded() else { return Vec::new() };
data.split(u8::is_ascii_whitespace).filter_map(|w| w.strip_prefix(b"/")).filter(|w| w.starts_with(b"PCAr")).map(<[u8]>::to_vec).collect()
}
fn drop_fonts(doc: &Document, res: &mut Dict, names: &[Vec<u8>]) {
if names.is_empty() {
return;
}
let Some(mut fonts) = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()) else { return };
for name in names {
fonts.remove(name);
}
res.set(b"Font".to_vec(), Object::Dict(fonts));
}

View File

@ -1,4 +1,4 @@
//! Edit a PDF ▸ edit existing images: the images a page draws (image XObjects painted by `Do`
//! Edit a PDF ▸ edit existing images and grouped artwork (Image/Form XObjects painted by `Do`
//! in its own content streams), and changing one: move/resize/rotate/flip (the `Do` is wrapped in
//! `q … cm … Q` with the extra transform), replace (it draws another XObject in the same place),
//! or delete. Only the stream that draws it is rewritten, as a new object.
@ -13,16 +13,20 @@ use crate::EditError;
pub struct PageImage {
/// Its box in user space.
pub rect: [f64; 4],
/// The placement: the unit square → user space.
/// The placement: image unit square or Form coordinates → user space.
pub matrix: [f64; 6],
/// The XObject resource name and object.
pub name: String,
pub object: Option<ObjRef>,
/// Pixel size.
/// Whether this is grouped Form artwork, edited as a whole (including its nested content).
pub is_form: bool,
/// Pixel size; zero for vector/grouped Form artwork.
pub width: u32,
pub height: u32,
stream: usize,
op: usize,
/// CTM before `Do`, excluding a Form's own Matrix.
draw_matrix: [f64; 6],
}
fn streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec<u8>)> {
@ -55,7 +59,8 @@ fn xobjects(doc: &Document, page: &Dict) -> Dict {
.unwrap_or_default()
}
/// The images page `page` (0-based) draws, in drawing order.
/// The images and Form artwork page `page` (0-based) draws, in drawing order.
/// Forms stay intact: no traversal or rewriting of their internal objects is needed.
pub fn page_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, EditError> {
let p = page_of(doc, page)?;
let xo = xobjects(doc, &p.dict);
@ -75,21 +80,59 @@ pub fn page_images(doc: &Document, page: usize) -> Result<Vec<PageImage>, EditEr
}
b"Do" => {
let Some(name) = op.name(0) else { continue };
let Some(r) = xo.get(name).and_then(Object::as_ref) else { continue };
let obj = doc.get(r);
let Some(resource) = xo.get(name) else { continue };
let obj = doc.resolve(resource);
let Object::Stream(s) = &*obj else { continue };
if s.dict.name(b"Subtype") != Some(b"Image") {
let is_form = s.dict.name(b"Subtype") == Some(b"Form");
let (matrix, bounds) = if is_form {
let Some(bounds) = s.dict.get(b"BBox").map(|v| doc.resolve(v)).and_then(|v| {
v.as_array().and_then(|a| {
if a.len() != 4 {
return None;
}
let mut b = [0.0; 4];
for (n, v) in b.iter_mut().zip(a) {
*n = v.as_f64()?;
}
Some(b)
})
}) else {
continue;
};
let matrix = match s.dict.get(b"Matrix") {
Some(v) => {
let v = doc.resolve(v);
let Some(m) = v.as_array().and_then(|a| Matrix::from_operands(a)) else { continue };
m
}
None => Matrix::IDENTITY,
};
(matrix.then(&ctm), bounds)
} else if s.dict.name(b"Subtype") == Some(b"Image") {
(ctm, [0.0, 0.0, 1.0, 1.0])
} else {
continue;
};
let rect = matrix.bbox(bounds);
if !bounds.iter().chain(matrix.0.iter()).chain(rect.iter()).all(|v| v.is_finite())
|| bounds[2] <= bounds[0]
|| bounds[3] <= bounds[1]
|| rect[2] <= rect[0]
|| rect[3] <= rect[1]
{
continue;
}
out.push(PageImage {
rect: ctm.bbox([0.0, 0.0, 1.0, 1.0]),
matrix: ctm.0,
rect,
matrix: matrix.0,
name: String::from_utf8_lossy(name).into_owned(),
object: Some(r),
width: s.dict.int(b"Width").unwrap_or(0).max(0) as u32,
height: s.dict.int(b"Height").unwrap_or(0).max(0) as u32,
object: resource.as_ref(),
is_form,
width: if is_form { 0 } else { s.dict.int(b"Width").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 },
height: if is_form { 0 } else { s.dict.int(b"Height").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 },
stream: si,
op: i,
draw_matrix: ctm.0,
});
}
_ => {}
@ -137,24 +180,31 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag
let img = images.get(index).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1)))?;
let p = page_of(doc, page)?;
let all = streams(doc, &p.dict);
let (obj, data) = all[img.stream].clone();
let (obj, data) = all.get(img.stream).cloned().ok_or_else(|| EditError::Invalid("the image stream is missing".into()))?;
let mut ops = parse(&data).ops;
let original = ops.get(img.op).cloned().ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?;
let mut resources = None;
let replacement: Vec<Op> = match change {
ImageChange::Delete => Vec::new(),
ImageChange::Transform(t) => {
if !t.iter().all(|v| v.is_finite()) {
return Err(EditError::Invalid("the image transform must be finite".into()));
}
// New CTM = placement × T; as a `cm` before the Do: M = P · T · P⁻¹.
let pm = Matrix(img.matrix);
let pm = Matrix(img.draw_matrix);
let inv = pm.invert().ok_or_else(|| EditError::Invalid("the image has no area".into()))?;
let m = pm.then(&Matrix(*t)).then(&inv);
vec![
Op::new("q", vec![]),
Op::new("cm", m.0.iter().map(|v| pdfcraft_content::num(*v)).collect()),
ops[img.op].clone(),
Op::new("Q", vec![]),
]
if !m.0.iter().all(|v| v.is_finite()) {
return Err(EditError::Invalid("the image transform is too large".into()));
}
vec![Op::new("q", vec![]), Op::new("cm", m.0.iter().map(|v| pdfcraft_content::num(*v)).collect()), original, Op::new("Q", vec![])]
}
ImageChange::Replace(r) => {
if img.is_form {
return Err(EditError::Invalid(
"grouped Form artwork can be moved, resized, rotated, flipped or deleted; replacing it with a bitmap is not supported".into(),
));
}
let name = format!("PCImg{}", r.num);
let mut res = p.dict.get(b"Resources").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
let mut xo = res.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
@ -173,7 +223,7 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag
let new = doc.add(Object::Stream(Stream::flate(dict, &serialize_ops(&ops))));
let contents: Vec<Object> = all.iter().enumerate().map(|(i, (o, _))| if i == img.stream { Object::Ref(new) } else { o.clone() }).collect();
doc.update_dict(p.obj, |d| {
d.set(b"Contents".to_vec(), if contents.len() == 1 { contents[0].clone() } else { Object::Array(contents) });
d.set(b"Contents".to_vec(), if let [one] = contents.as_slice() { one.clone() } else { Object::Array(contents) });
if let Some(res) = resources {
d.set(b"Resources".to_vec(), Object::Dict(res));
}

View File

@ -375,6 +375,24 @@ fn begin(kind: MarkKind, subtype: &str, matrix: [f64; 6]) -> String {
const END: &str = "EMC\nQ\n";
/// Refuses page text the standard-14 fonts (WinAnsiEncoding) can't draw: written anyway, each
/// such character would become a `?` on the page (#125). `lines` must split the text the way the
/// caller draws it, so a `\r` that would be drawn is refused too.
pub(crate) fn drawable<'a>(lines: impl IntoIterator<Item = &'a str>) -> Result<(), EditError> {
match lines.into_iter().find_map(pdfcraft_fonts::first_non_win_ansi) {
Some(c) => Err(undrawable(c)),
None => Ok(()),
}
}
/// The refusal for page text with `c`, which nothing here can draw.
pub(crate) fn undrawable(c: char) -> EditError {
EditError::Invalid(format!(
"the standard fonts can't draw \"{c}\" (U+{:04X}); only Western European characters can be added as text for now",
u32::from(c)
))
}
fn text_op(x: f64, y: f64, text: &str) -> Vec<u8> {
let mut v = format!("1 0 0 1 {} {} Tm ", n(x), n(y)).into_bytes();
v.extend(literal(&win_ansi(text)));
@ -390,6 +408,9 @@ pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter,
if hf.text.iter().all(|t| t.trim().is_empty()) {
return Err(EditError::Invalid("type the header or footer text first".into()));
}
// Tokens only add ASCII (Bates prefixes and suffixes are part of the template), so checking
// the templates, split as `expand`'s output is, covers every page.
hf.text.iter().try_for_each(|t| drawable(t.lines()))?;
if !(hf.font_size.is_finite() && hf.font_size > 0.0 && hf.font_size <= 200.0 && hf.margins.iter().all(|m| m.is_finite() && *m >= 0.0)) {
return Err(EditError::Invalid("invalid font size or margins".into()));
}
@ -452,6 +473,9 @@ pub fn add_watermark(doc: &mut Document, pages: &[usize], wm: &Watermark, replac
if text.is_empty() && wm.source.is_none() {
return Err(EditError::Invalid("type the watermark text first".into()));
}
if wm.source.is_none() {
drawable(text.lines())?;
}
if !(wm.opacity.is_finite() && wm.rotation.is_finite() && wm.font_size.is_finite() && wm.font_size >= 0.0) {
return Err(EditError::Invalid("invalid watermark settings".into()));
}
@ -594,7 +618,7 @@ pub use images::{ImageChange, PageImage, change_image, page_images, rect_to_rect
pub mod text;
pub use text::{BlockStyle, LineEdit, TextBlock, TextLine, replace_block, replace_line, rewrite_block, text_blocks, text_lines};
pub mod added;
pub use added::{Added, AddedImage, AddedText, Align, Content, Family, add_content, delete_content, list_added, update_content};
pub use added::{Added, AddedImage, AddedText, Align, Content, Family, add_content, delete_content, first_undrawable, list_added, update_content};
#[cfg(test)]
mod tests;

View File

@ -227,6 +227,142 @@ fn added_text_and_images_are_page_content_that_stays_editable() {
assert!(marks_present(&doc).is_empty());
}
#[test]
fn added_arabic_text_is_shaped_in_display_order_and_stays_searchable() {
let mut doc = fixture();
let text =
AddedText { rect: [72.0, 600.0, 400.0, 700.0], text: "مرحبا World".into(), size: 14.0, align: Align::Right, ..AddedText::default() };
if pdfcraft_fonts::document_arabic_font().is_none() {
eprintln!("skipping the Arabic drawing checks: built without a craft-fonts Arab face (set CRAFT_FONTS_DIR)");
let err = add_content(&mut doc, 0, &Content::Text(text)).unwrap_err();
assert!(matches!(&err, EditError::Invalid(m) if m.contains("CRAFT_FONTS_DIR")), "{err:?}");
assert!(!doc.is_modified(), "nothing is written when the font is missing");
return;
}
add_content(&mut doc, 0, &Content::Text(text.clone())).unwrap();
// A second Arabic item on the same page keeps its own fonts.
add_content(&mut doc, 0, &Content::Text(AddedText { text: "شكرا".into(), rect: [72.0, 500.0, 400.0, 520.0], ..text.clone() })).unwrap();
let doc = reopen(&doc);
let added = list_added(&doc);
let Content::Text(t) = &added[0].content else { panic!() };
assert_eq!(t.text, "مرحبا World", "the logical text is kept for editing");
let s = streams(&doc, 0);
let item = s.iter().find(|s| s.contains("(World) Tj")).unwrap();
// A right-to-left paragraph: "World" is drawn left of the Arabic word.
let x_before = |s: &str, at: usize| -> f64 {
let tm = s[..at].rfind("1 0 0 1 ").unwrap() + 8;
s[tm..].split(' ').next().unwrap().parse().unwrap()
};
let latin_x = x_before(item, item.find("(World) Tj").unwrap());
let arabic_at = item.find("/PCAr").unwrap();
let arabic_x = x_before(item, item[arabic_at..].find(" Tj").unwrap() + arabic_at);
assert!(latin_x < arabic_x, "{item}");
// The Arabic glyphs come from Type 3 fonts whose ToUnicode maps give back each letter.
let p = &pdfcraft_model::pages(&doc)[0];
let res = doc.resolve(p.dict.get(b"Resources").unwrap());
let fonts = doc.resolve(res.as_dict().unwrap().get(b"Font").unwrap()).as_dict().unwrap().clone();
let arabic: Vec<_> = fonts.iter().filter(|(k, _)| k.starts_with(b"PCAr")).map(|(_, v)| doc.resolve(v).as_dict().unwrap().clone()).collect();
assert_eq!(arabic.len(), 2, "one font per item");
let mut mapped = String::new();
for f in &arabic {
assert_eq!(f.name(b"Subtype"), Some(&b"Type3"[..]));
mapped += &String::from_utf8_lossy(&stream_bytes(&doc, f.get(b"ToUnicode").unwrap()).unwrap());
}
for letter in ["0645", "0631", "062D", "0628", "0627", "0634", "0643"] {
assert!(mapped.contains(&format!("<{letter}>")), "{letter} in {mapped}");
}
}
/// The `PCAr` font names in page `page`'s resources.
fn arabic_font_names(doc: &Document, page: usize) -> Vec<String> {
let p = &pdfcraft_model::pages(doc)[page];
let Some(res) = p.dict.get(b"Resources").map(|r| doc.resolve(r)) else { return vec![] };
let Some(fonts) = res.as_dict().and_then(|r| r.get(b"Font")).map(|f| doc.resolve(f)) else { return vec![] };
fonts.as_dict().unwrap().iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).filter(|k| k.starts_with("PCAr")).collect()
}
#[test]
fn arabic_items_keep_their_own_fonts_through_saves_updates_and_deletes() {
let mut doc = fixture();
let text = AddedText { rect: [72.0, 600.0, 400.0, 700.0], text: "مرحبا".into(), size: 14.0, ..AddedText::default() };
if pdfcraft_fonts::document_arabic_font().is_none() {
// An item that already holds Arabic (stored by an older version, drawn as "?") can still
// be moved and retyped without the face.
add_content(&mut doc, 0, &Content::Text(AddedText { text: "x".into(), ..text.clone() })).unwrap();
update_content(&mut doc, 0, 0, &Content::Text(text)).unwrap();
assert!(streams(&doc, 0).join("").contains("(?????) Tj"));
return;
}
// A full save renumbers objects but keeps resource names, so a font named after its object
// number may meet a name already on the page: here, every name it could take.
let mut taken = Dict::new();
for num in 1..2_000 {
taken.set(format!("PCAr{num}").into_bytes(), Object::name("Taken"));
}
let mut res = Dict::new();
res.set(b"Font".to_vec(), Object::Dict(taken));
let page = pdfcraft_model::pages(&doc)[0].obj;
doc.update_dict(page, |d| d.set(b"Resources".to_vec(), Object::Dict(res))).unwrap();
add_content(&mut doc, 0, &Content::Text(text.clone())).unwrap();
add_content(&mut doc, 0, &Content::Text(AddedText { text: "شكرا".into(), rect: [72.0, 500.0, 400.0, 520.0], ..text.clone() })).unwrap();
let names = arabic_font_names(&doc, 0);
assert_eq!(names.len(), 1_999 + 2, "nothing replaced");
let p = &pdfcraft_model::pages(&doc)[0];
let fonts = doc.resolve(p.dict.get(b"Resources").unwrap()).as_dict().unwrap().get(b"Font").cloned().unwrap();
let fonts = doc.resolve(&fonts).as_dict().unwrap().clone();
assert_eq!(fonts.iter().filter(|(_, v)| v.as_name() == Some(&b"Taken"[..])).count(), 1_999);
let first = own(&doc, 0);
assert!(first.len() == 1 && first[0].contains('_'), "a free name, not a taken one: {first:?}");
// Updating an item swaps its fonts; deleting it removes them.
update_content(&mut doc, 0, 0, &Content::Text(AddedText { text: "أهلا".into(), ..text })).unwrap();
let names = arabic_font_names(&doc, 0);
assert!(names.len() == 2_001 && !names.contains(&first[0]), "the replaced item's font is gone");
delete_content(&mut doc, 0, 0).unwrap();
delete_content(&mut doc, 0, 0).unwrap();
assert_eq!(arabic_font_names(&doc, 0).len(), 1_999, "only the page's own names are left");
reopen(&doc);
}
/// The `PCAr` fonts the stream of added item `index` on page 0 shows text with.
fn own(doc: &Document, index: usize) -> Vec<String> {
let s = String::from_utf8_lossy(&stream_bytes(doc, &Object::Ref(list_added(doc)[index].obj)).unwrap()).into_owned();
let mut names: Vec<String> =
s.split_whitespace().filter_map(|w| w.strip_prefix('/')).filter(|w| w.starts_with("PCAr")).map(String::from).collect();
names.dedup();
names
}
#[test]
fn odd_arabic_text_never_panics() {
let mut doc = fixture();
let long = "بسم الله ".repeat(2_000);
for s in ["\u{202E}ب\u{064B}\u{064B} (]", "\u{064B}", "ا\u{200F}\u{2067}b\u{2069}", "ا\n\nب\tc", "ﷺ ١٢٣ 456", long.as_str()] {
let r = add_content(&mut doc, 0, &Content::Text(AddedText { rect: [0.0, 800.0, 300.0, 780.0], text: s.into(), ..AddedText::default() }));
assert_eq!(r.is_ok(), pdfcraft_fonts::document_arabic_font().is_some(), "{:?}: {r:?}", s.chars().take(12).collect::<String>());
}
// U+2029 and U+0085 (paragraph and line separators) have no WinAnsi code, so the standard font
// would draw them as `?`: refused, by name, rather than written (#125). Laying it out still
// mustn't panic.
let r = add_content(
&mut doc,
0,
&Content::Text(AddedText { rect: [0.0, 800.0, 300.0, 780.0], text: "ا\u{2029}ب\rc\u{85}د\n".into(), ..AddedText::default() }),
);
assert!(r.is_err(), "{r:?}");
if pdfcraft_fonts::document_arabic_font().is_some() {
assert!(matches!(&r, Err(EditError::Invalid(m)) if m.contains("U+2029") || m.contains("U+0085")), "{r:?}");
}
// A character the face lacks is an error that names it, not a box or a crash.
if pdfcraft_fonts::document_arabic_font().is_some() && !pdfcraft_fonts::arabic_has('\u{FDFD}') {
let r = add_content(
&mut doc,
0,
&Content::Text(AddedText { rect: [0.0, 800.0, 300.0, 780.0], text: "ب \u{FDFD}".into(), ..AddedText::default() }),
);
assert!(matches!(&r, Err(EditError::Invalid(m)) if m.contains('\u{FDFD}')), "{r:?}");
}
}
/// One page with Helvetica (WinAnsi) and a subset font that has only the glyphs it uses.
fn text_page(content: &str) -> Document {
let objs: Vec<String> = vec![
@ -296,7 +432,7 @@ fn japanese_line_uses_unicode_type3_fallback() {
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
let replacement = "25362738こんにちは、お元気ですか? 2.3444";
let result = text::replace_line(&mut doc, 0, 0, replacement).unwrap();
assert_eq!(result.substituted.as_deref(), Some("Shippori Mincho Type3"));
assert_eq!(result.substituted.as_deref(), Some("BIZ UDPGothic Type3"));
let bytes = page_content_bytes(&doc, 0);
let content = String::from_utf8_lossy(&bytes);
assert!(content.contains("/PCJp"), "{content}");
@ -304,6 +440,138 @@ fn japanese_line_uses_unicode_type3_fallback() {
assert_eq!(text::text_lines(&reopened, 0).unwrap()[0].text, replacement);
}
fn styled_text_page(base: &str) -> Document {
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Original) Tj ET");
let r = pdfcraft_cos::ObjRef::new(5, 0);
let mut font = doc.get(r).as_dict().unwrap().clone();
font.set(b"BaseFont".to_vec(), pdfcraft_cos::Object::name(base));
doc.set(r, pdfcraft_cos::Object::Dict(font));
doc
}
fn fallback_paths(doc: &Document) -> Vec<Vec<u8>> {
let p = pdfcraft_model::pages(doc).swap_remove(0);
let res = doc.resolve(p.dict.get(b"Resources").unwrap());
let fonts = doc.resolve(res.as_dict().unwrap().get(b"Font").unwrap());
let font = doc.resolve(fonts.as_dict().unwrap().get(b"PCJp").unwrap());
let procs = doc.resolve(font.as_dict().unwrap().get(b"CharProcs").unwrap());
procs
.as_dict()
.unwrap()
.iter()
.map(|(_, object)| match &*doc.resolve(object) {
pdfcraft_cos::Object::Stream(s) => s.decoded().unwrap(),
other => panic!("expected a glyph stream, got {other:?}"),
})
.collect()
}
#[test]
fn japanese_line_fallback_uses_real_source_family_and_weight() {
if without_craft_fonts("japanese_line_fallback_uses_real_source_family_and_weight") {
return;
}
let mut sans = styled_text_page("Helvetica");
let mut bold = descriptor_text_page();
let mut serif = styled_text_page("ABCDEF+ShipporiMincho-Regular");
let replacement = "見本商会 御中";
text::replace_line(&mut sans, 0, 0, replacement).unwrap();
text::replace_line(&mut bold, 0, 0, replacement).unwrap();
text::replace_line(&mut serif, 0, 0, replacement).unwrap();
assert_ne!(fallback_paths(&sans), fallback_paths(&serif), "sans source must not use Mincho outlines");
if pdfcraft_fonts::CRAFT_FONTS.iter().any(|f| f.family == "BIZ UDPGothic" && f.style == "Bold") {
assert_ne!(fallback_paths(&sans), fallback_paths(&bold), "bold must change the actual glyph paths");
}
for doc in [&sans, &bold, &serif] {
assert_eq!(text::text_lines(&reopen(doc), 0).unwrap()[0].text, replacement);
}
}
#[test]
fn japanese_paragraph_fallback_honors_requested_family_and_weight() {
if without_craft_fonts("japanese_paragraph_fallback_honors_requested_family_and_weight") {
return;
}
let replacement = "見本商会 御中";
let mut regular = styled_text_page("Helvetica");
let mut bold = styled_text_page("Helvetica");
let mut serif = styled_text_page("Helvetica");
text::replace_block(&mut regular, 0, 0, replacement).unwrap();
text::rewrite_block(
&mut bold,
0,
0,
Some(replacement),
&text::BlockStyle { family: Some((added::Family::Helvetica, true, false)), ..Default::default() },
)
.unwrap();
text::rewrite_block(
&mut serif,
0,
0,
Some(replacement),
&text::BlockStyle { family: Some((added::Family::Times, false, false)), ..Default::default() },
)
.unwrap();
assert_ne!(fallback_paths(&regular), fallback_paths(&serif), "requested serif must change actual outlines");
if pdfcraft_fonts::CRAFT_FONTS.iter().any(|f| f.family == "BIZ UDPGothic" && f.style == "Bold") {
assert_ne!(fallback_paths(&regular), fallback_paths(&bold), "requested bold must change actual outlines");
let mut source_bold = styled_text_page("Helvetica-Bold");
text::replace_block(&mut source_bold, 0, 0, replacement).unwrap();
assert_eq!(fallback_paths(&bold), fallback_paths(&source_bold));
}
for doc in [&regular, &bold, &serif] {
assert_eq!(text::text_blocks(&reopen(doc), 0).unwrap()[0].text, replacement);
}
}
#[test]
fn japanese_fallback_reports_the_face_after_save_and_reopen() {
if without_craft_fonts("japanese_fallback_reports_the_face_after_save_and_reopen") {
return;
}
// F2 is (a subset of) Arial: a regular sans face, so the fallback is the regular Gothic.
let face = pdfcraft_fonts::document_japanese_font_for_style(false, false).unwrap();
let expected = format!("{}-{}", face.family, face.style).replace(' ', "");
for paragraph in [false, true] {
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
let replacement = "見本商会 御中";
if paragraph {
text::replace_block(&mut doc, 0, 0, replacement).unwrap();
} else {
text::replace_line(&mut doc, 0, 0, replacement).unwrap();
}
for d in [&doc, &reopen(&doc)] {
let lines = text::text_lines(d, 0).unwrap();
assert_eq!(lines[0].text, replacement);
assert_eq!(lines[0].base_font, expected, "report the real fallback face, not an empty BaseFont");
assert_eq!(text::text_blocks(d, 0).unwrap()[0].base_font, expected);
}
}
}
#[test]
fn cyrillic_in_a_serif_line_falls_back_to_a_face_that_has_it() {
if without_craft_fonts("cyrillic_in_a_serif_line_falls_back_to_a_face_that_has_it") {
return;
}
let replacement = "Привет, world";
let has_all = |f: &pdfcraft_fonts::CraftFont| replacement.chars().all(|ch| pdfcraft_fonts::japanese_glyph_from(f, ch).is_ok());
if !pdfcraft_fonts::CRAFT_FONTS.iter().any(|f| f.covers("Jpan") && has_all(f)) {
eprintln!("skipping: no Japanese fallback face has Cyrillic");
return;
}
for paragraph in [false, true] {
let mut doc = styled_text_page("Times-Roman");
if paragraph {
text::replace_block(&mut doc, 0, 0, replacement).unwrap();
} else {
text::replace_line(&mut doc, 0, 0, replacement).unwrap();
}
assert_eq!(text::text_lines(&reopen(&doc), 0).unwrap()[0].text, replacement);
}
}
#[test]
fn japanese_paragraph_uses_unicode_type3_fallback() {
if without_craft_fonts("japanese_paragraph_uses_unicode_type3_fallback") {
@ -316,6 +584,74 @@ fn japanese_paragraph_uses_unicode_type3_fallback() {
assert_eq!(text::text_blocks(&reopened, 0).unwrap()[0].text, replacement);
}
/// Every Type 3 fallback glyph starts with a well-formed `d1`: six operands, the second 0, and a
/// box that encloses every point of the glyph. Acrobat shows a bullet for a glyph whose `d1`
/// has the wrong operand count.
#[test]
fn type3_fallback_glyphs_declare_a_well_formed_d1() {
if without_craft_fonts("type3_fallback_glyphs_declare_a_well_formed_d1") {
return;
}
// Cyrillic is respaced (shifted by its side bearing), and its box must move with it.
let mut glyphs = Vec::new();
for replacement in ["見本商会 御中", "София 2027"] {
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
text::replace_line(&mut doc, 0, 0, replacement).unwrap();
glyphs.extend(fallback_paths(&reopen(&doc)));
}
assert!(!glyphs.is_empty());
for glyph in glyphs {
let text = String::from_utf8(glyph).unwrap();
let mut lines = text.lines();
let d1: Vec<&str> = lines.next().unwrap().split_whitespace().collect();
assert_eq!(d1.len(), 7, "six operands and d1: {d1:?}");
assert_eq!(d1[6], "d1");
let n: Vec<f64> = d1[..6].iter().map(|v| v.parse().unwrap()).collect();
assert_eq!(n[1], 0.0, "wy");
assert!(n[2] <= n[4] && n[3] <= n[5], "box: {n:?}");
for line in lines.filter(|l| l.ends_with(" m") || l.ends_with(" l")) {
let p: Vec<f64> = line.split_whitespace().take(2).map(|v| v.parse().unwrap()).collect();
assert!(p[0] >= n[2] && p[0] <= n[4] && p[1] >= n[3] && p[1] <= n[5], "{line} outside {n:?}");
}
}
}
/// Shippori Mincho, the serif fallback, has no Cyrillic: Bulgarian replacement text in a serif
/// line takes a fallback face that has every letter instead of failing on the first one.
#[test]
fn cyrillic_replacement_in_a_serif_line_uses_a_face_with_the_letters() {
if without_craft_fonts("cyrillic_replacement_in_a_serif_line_uses_a_face_with_the_letters") {
return;
}
let replacement = "София 2027";
for paragraph in [false, true] {
let mut doc = styled_text_page("ABCDEF+Garamond");
if paragraph {
text::replace_block(&mut doc, 0, 0, replacement).unwrap();
} else {
text::replace_line(&mut doc, 0, 0, replacement).unwrap();
}
let lines = text::text_lines(&reopen(&doc), 0).unwrap();
assert_eq!(lines[0].text, replacement);
// Letters are spaced by their shape, not one em each (the faces' full-width Cyrillic).
let widths = fallback_widths(&doc);
assert!(widths.iter().all(|w| *w > 300.0 && *w < 900.0), "{widths:?}");
assert!(widths.windows(2).any(|w| w[0] != w[1]), "proportional: {widths:?}");
}
}
/// The `/Widths` of the page's Type 3 fallback font, for its Cyrillic letters.
fn fallback_widths(doc: &Document) -> Vec<f64> {
let p = pdfcraft_model::pages(doc).swap_remove(0);
let res = doc.resolve(p.dict.get(b"Resources").unwrap());
let fonts = doc.resolve(res.as_dict().unwrap().get(b"Font").unwrap());
let font = doc.resolve(fonts.as_dict().unwrap().get(b"PCJp").unwrap());
let font = font.as_dict().unwrap();
let widths = doc.resolve(font.get(b"Widths").unwrap()).as_array().unwrap().iter().filter_map(|w| w.as_f64()).collect::<Vec<_>>();
// "София 2027": the first five codes are the letters.
widths[..5].to_vec()
}
#[test]
fn japanese_paragraph_keeps_ideographic_spaces() {
if without_craft_fonts("japanese_paragraph_keeps_ideographic_spaces") {
@ -460,6 +796,22 @@ fn editing_text_keeps_the_tokens_a_stream_shares_with_its_neighbours() {
assert_split_tokens_kept(&reopen(&doc), "Rewrapped");
}
#[test]
fn win_ansi_high_glyphs_use_helvetica_not_the_fallback_font() {
// š ž Š Ž Œ Ÿ ƒ † ‰ (WinAnsi 0x80–0x9F) are standard-font glyphs (#347): no Type3 fallback.
let text = "Šumava žaba œuvre Ÿ ƒ † ‰ €";
let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET");
let r = text::replace_line(&mut doc, 0, 0, text).unwrap();
assert_eq!(r.substituted.as_deref(), Some("Helvetica"));
let lines = text::text_lines(&reopen(&doc), 0).unwrap();
assert_eq!((lines[0].text.as_str(), lines[0].base_font.as_str()), (text, "Helvetica"));
let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET");
let r = text::replace_block(&mut doc, 0, 0, text).unwrap();
assert!(r.substituted.as_deref().is_none_or(|s| !s.contains("Type3")), "{:?}", r.substituted);
let lines = text::text_lines(&reopen(&doc), 0).unwrap();
assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>().join(" "), text);
}
#[test]
fn missing_glyphs_substitute_helvetica_and_impossible_text_is_refused() {
let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET");
@ -469,9 +821,9 @@ fn missing_glyphs_substitute_helvetica_and_impossible_text_is_refused() {
let doc2 = reopen(&doc);
let lines = text::text_lines(&doc2, 0).unwrap();
assert_eq!((lines[0].text.as_str(), lines[0].base_font.as_str()), ("abc", "Helvetica"));
// Neither font can show Greek.
// Neither the standard font nor the craft-fonts Japanese faces have this emoji.
let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
assert!(text::replace_line(&mut doc, 0, 0, "Ωmega").is_err());
assert!(text::replace_line(&mut doc, 0, 0, "\u{1f4a9}").is_err());
assert!(text::replace_line(&mut doc, 0, 5, "x").is_err(), "no such line");
}
@ -601,6 +953,56 @@ fn page_images_move_turn_replace_and_delete() {
assert!(images::change_image(&mut doc, 0, 0, &images::ImageChange::Delete).is_err());
}
#[test]
fn form_artwork_edits_only_the_selected_placement() {
let mut doc = fixture(); // three pages share resources; pages 0 and 1 also share contents
let mut form = Dict::new();
form.set(b"Subtype".to_vec(), Object::name("Form"));
form.set(b"BBox".to_vec(), Object::Array([10, 20, 110, 70].map(Object::Int).to_vec()));
form.set(b"Matrix".to_vec(), Object::Array([2, 0, 0, 3, -20, -60].map(Object::Int).to_vec()));
let artwork = doc.add(Object::Stream(Stream::flate(form, b"1 0 0 rg 10 20 100 50 re f")));
doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| {
let mut xo = Dict::new();
xo.set(b"Figure".to_vec(), Object::Ref(artwork));
d.set(b"XObject".to_vec(), Object::Dict(xo));
})
.unwrap();
let content = b"q 1 0 0 1 40 80 cm /Figure Do Q q 1 0 0 1 300 400 cm /Figure Do Q BT /F1 9 Tf (Caption) Tj ET";
doc.set(pdfcraft_cos::ObjRef::new(7, 0), Object::Stream(Stream::flate(Dict::new(), content)));
let mut doc = reopen(&doc);
let original = doc.get(artwork);
let imgs = images::page_images(&doc, 0).unwrap();
assert_eq!(imgs.len(), 2);
assert!(imgs[0].is_form);
assert_eq!((imgs[0].width, imgs[0].height), (0, 0));
assert!(close(imgs[0].rect, [40.0, 80.0, 240.0, 230.0]));
let target = [60.0, 100.0, 160.0, 175.0];
images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(images::rect_to_rect(imgs[0].rect, target))).unwrap();
let mut doc = reopen(&doc);
let moved = images::page_images(&doc, 0).unwrap();
assert!(close(moved[0].rect, target));
assert_eq!(moved[1].rect, imgs[1].rect);
assert_eq!(images::page_images(&doc, 1).unwrap()[0].rect, imgs[0].rect);
assert_eq!(*doc.get(artwork), *original, "the shared Form and its unknown data are untouched");
assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "Caption");
let t = images::turn_about_centre(target, 1, false, false);
images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(t)).unwrap();
assert!(close(images::page_images(&doc, 0).unwrap()[0].rect, [72.5, 87.5, 147.5, 187.5]));
assert!(images::change_image(&mut doc, 0, 0, &images::ImageChange::Replace(artwork)).is_err());
images::change_image(&mut doc, 0, 0, &images::ImageChange::Delete).unwrap();
let doc = reopen(&doc);
assert_eq!(images::page_images(&doc, 0).unwrap().len(), 1);
assert_eq!(images::page_images(&doc, 1).unwrap().len(), 2);
// Invalid bounds are skipped without interpreting the Form stream.
let mut doc = doc;
let mut broken = (*doc.get(artwork)).clone();
if let Object::Stream(s) = &mut broken {
s.dict.set(b"BBox".to_vec(), Object::Array(vec![Object::Int(0)]));
}
doc.set(artwork, broken);
assert!(images::page_images(&doc, 0).unwrap().is_empty());
}
#[test]
fn paragraphs_take_new_formatting() {
let mut doc = text_page("BT /F1 10 Tf 12 TL 100 700 Td (One two three four five six seven) Tj T* (eight nine ten eleven twelve) Tj ET");
@ -852,3 +1254,67 @@ fn the_graphics_state_carries_over_between_content_streams() {
let l = &text::text_lines(&doc, 0).unwrap()[0];
assert!(close(l.rect[0], 100.0) && close(l.size, 10.0), "{l:?}");
}
/// #125: page-content tools refuse text the standard fonts can't draw instead of writing `?`,
/// and leave the document untouched.
#[test]
fn page_text_the_standard_fonts_cant_draw_is_refused() {
let mut doc = fixture();
let before: Vec<Vec<String>> = (0..3).map(|p| streams(&reopen(&doc), p)).collect();
let text = AddedText { rect: [72.0, 600.0, 300.0, 700.0], text: "日本語のテキスト".into(), ..AddedText::default() };
let err = add_content(&mut doc, 0, &Content::Text(text)).unwrap_err().to_string();
assert!(err.contains("\"日\" (U+65E5)"), "{err}");
let hf = HeaderFooter {
text: ["Ελληνικά".into(), String::new(), String::new(), String::new(), String::new(), String::new()],
..HeaderFooter::default()
};
assert!(add_header_footer(&mut doc, &[0, 1], &hf, true, &cx()).unwrap_err().to_string().contains("U+0395"));
let wm = Watermark { text: "机密".into(), ..Watermark::default() };
assert!(add_watermark(&mut doc, &[0], &wm, true).unwrap_err().to_string().contains("U+673A"));
let after: Vec<Vec<String>> = (0..3).map(|p| streams(&reopen(&doc), p)).collect();
assert_eq!(before, after, "a refused edit changes nothing");
// Western European text, including what only WinAnsi (not Latin-1) has, still works.
let text = AddedText { rect: [72.0, 600.0, 300.0, 700.0], text: "Café — 5€ ™".into(), ..AddedText::default() };
add_content(&mut doc, 0, &Content::Text(text.clone())).unwrap();
// Retyping existing text is refused the same way, and the whole document is left as it was.
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
let retyped = AddedText { text: "Café 中".into(), ..text.clone() };
assert!(update_content(&mut doc, 0, 0, &Content::Text(retyped)).unwrap_err().to_string().contains("U+4E2D"));
assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), saved, "a refused update changes nothing");
// A carriage return is checked where it would be drawn: added text splits on LF only, so
// CRLF would leave a `?`; headers and watermarks split CRLF into lines, but not a lone CR.
let crlf = AddedText { text: "A\r\nB".into(), ..text };
assert!(add_content(&mut doc, 0, &Content::Text(crlf)).unwrap_err().to_string().contains("U+000D"));
let hf = |t: &str| HeaderFooter {
text: [t.into(), String::new(), String::new(), String::new(), String::new(), String::new()],
..HeaderFooter::default()
};
add_header_footer(&mut doc, &[0], &hf("Left\r\n<<Bates Number#6#1#ACME-#>>"), true, &cx()).unwrap();
assert!(add_header_footer(&mut doc, &[0], &hf("A\rB"), true, &cx()).unwrap_err().to_string().contains("U+000D"));
// Arabic is shaped with the craft-fonts Arabic face (#403): drawable when that face is built
// in, refused (never written as `?`) when it isn't. Text left to the standard font still counts.
let arabic = |text: &str| AddedText { rect: [72.0, 600.0, 300.0, 700.0], text: text.into(), ..AddedText::default() };
// U+061C is in the Arabic block but is a direction mark that's dropped, not shaped: it never
// makes text drawable, nor (with the face) undrawable.
assert!(add_content(&mut doc, 0, &Content::Text(arabic("\u{061C}中"))).is_err());
if pdfcraft_fonts::arabic_has('م') {
add_content(&mut doc, 0, &Content::Text(arabic("مرحبا Hello"))).unwrap();
add_content(&mut doc, 0, &Content::Text(arabic("\u{061C}Hello"))).unwrap();
for text in ["مرحبا 中", "\u{061C}中"] {
let err = add_content(&mut doc, 0, &Content::Text(arabic(text))).unwrap_err().to_string();
assert!(err.contains("U+4E2D"), "{text:?}: {err}");
}
} else {
// #403's own refusal says what's missing; the Add-text editor still keeps the draft.
let err = add_content(&mut doc, 0, &Content::Text(arabic("مرحبا Hello"))).unwrap_err().to_string();
assert!(err.contains("CRAFT_FONTS_DIR"), "{err}");
assert_eq!(first_undrawable(&arabic("مرحبا Hello")), Some('م'));
// An existing item may still take Arabic (#403 keeps it movable), but nothing else that
// the standard font would write as `?`.
let n = list_added(&doc).iter().filter(|a| a.page == 0).count();
add_content(&mut doc, 0, &Content::Text(arabic("x"))).unwrap();
let err = update_content(&mut doc, 0, n, &Content::Text(arabic("ب中"))).unwrap_err().to_string();
assert!(err.contains("U+4E2D"), "{err}");
update_content(&mut doc, 0, n, &Content::Text(arabic("ب"))).unwrap();
}
}

View File

@ -16,7 +16,7 @@ use std::rc::Rc;
use pdfcraft_content::{Matrix, Op, parse, serialize_ops};
use pdfcraft_cos::{Dict, Document, Object, PdfString, Stream};
use pdfcraft_fonts::pdf::Metrics;
use pdfcraft_fonts::{GlyphError, japanese_glyph};
use pdfcraft_fonts::{CraftFont, GlyphError, japanese_glyph_from};
use crate::EditError;
@ -27,7 +27,7 @@ pub struct TextLine {
pub text: String,
/// Its box in user space.
pub rect: [f64; 4],
/// The font's resource name and `/BaseFont`, and its size in text space.
/// The font's resource name and `/BaseFont` (or Type 3 descriptor's `/FontName`), and its size in text space.
pub font: String,
pub base_font: String,
pub size: f64,
@ -499,7 +499,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
type Encoder = Box<dyn Fn(&str) -> Option<Vec<u8>>>;
fn is_win_ansi_char(c: char) -> bool {
matches!(c, '\u{20}'..='\u{7e}' | '\u{a0}'..='\u{ff}' | '€' | '‚' | '„' | '…' | '‘' | '’' | '“' | '”' | '•' | '–' | '—' | '™' | '\t')
c == '\t' || pdfcraft_fonts::win_ansi_byte(c).is_some()
}
fn needs_type3(text: &str) -> bool {
@ -510,7 +510,9 @@ fn source_family(base_font: &str) -> crate::added::Family {
let name = base_font.to_ascii_lowercase();
if ["courier", "mono", "consolas", "menlo", "monaco", "lucida console"].iter().any(|s| name.contains(s)) {
crate::added::Family::Courier
} else if !name.contains("sans") && ["times", "serif", "roman", "cambria", "georgia", "palatino", "garamond"].iter().any(|s| name.contains(s)) {
} else if !name.contains("sans")
&& ["times", "serif", "roman", "mincho", "cambria", "georgia", "palatino", "garamond"].iter().any(|s| name.contains(s))
{
crate::added::Family::Times
} else {
crate::added::Family::Helvetica
@ -533,24 +535,52 @@ fn pdf_num(v: f64) -> String {
if v.fract() == 0.0 { format!("{v:.0}") } else { format!("{v:.4}").trim_end_matches('0').trim_end_matches('.').to_string() }
}
fn type3_path(ch: char) -> Result<(Vec<u8>, f64), EditError> {
let glyph = japanese_glyph(ch).map_err(|e| match e {
fn type3_path(face: &CraftFont, ch: char) -> Result<(Vec<u8>, f64), EditError> {
let glyph = japanese_glyph_from(face, ch).map_err(|e| match e {
GlyphError::NoFont => no_japanese_font(),
GlyphError::Missing => EditError::Invalid(format!("Japanese fallback font has no glyph for U+{:04X}", ch as u32)),
GlyphError::Missing => EditError::Invalid(format!("no fallback font has a glyph for U+{:04X}", ch as u32)),
GlyphError::TooComplex => EditError::Invalid(format!("Japanese fallback glyph U+{:04X} is too complex", ch as u32)),
})?;
let (dx, width) = proportional(ch, &glyph);
let scale = 1000.0;
let mut out = format!("{} 0 0 0 0 1000 1000 d1\n", pdf_num(glyph.width * scale)).into_bytes();
// d1 is `wx wy llx lly urx ury` (ISO 32000-2 §9.6.4) with a box enclosing the glyph;
// Acrobat draws a bullet in place of a glyph whose d1 is malformed.
let b = if glyph.contours.is_empty() { [0.0; 4] } else { glyph.bbox };
let mut out = format!(
"{} 0 {} {} {} {} d1\n",
pdf_num(width * scale),
pdf_num(((b[0] + dx) * scale).floor()),
pdf_num((b[1] * scale).floor()),
pdf_num(((b[2] + dx) * scale).ceil()),
pdf_num((b[3] * scale).ceil())
)
.into_bytes();
for contour in glyph.contours {
let Some(first) = contour.first() else { continue };
out.extend_from_slice(format!("{} {} m\n", pdf_num(first[0] * scale), pdf_num(first[1] * scale)).as_bytes());
out.extend_from_slice(format!("{} {} m\n", pdf_num((first[0] + dx) * scale), pdf_num(first[1] * scale)).as_bytes());
for p in contour.iter().skip(1) {
out.extend_from_slice(format!("{} {} l\n", pdf_num(p[0] * scale), pdf_num(p[1] * scale)).as_bytes());
out.extend_from_slice(format!("{} {} l\n", pdf_num((p[0] + dx) * scale), pdf_num(p[1] * scale)).as_bytes());
}
out.extend_from_slice(b"h\n");
}
out.extend_from_slice(b"f\n");
Ok((out, glyph.width))
Ok((out, width))
}
/// Side bearing (em) given to a respaced glyph on each side.
const SIDE_BEARING: f64 = 0.05;
/// Shift and advance for a fallback glyph. Japanese faces draw Cyrillic and Greek full-width
/// (one em each, as in JIS X 0208) and have no proportional (`palt`) metrics for them, so a
/// word set that way reads as letter-spaced. Those letters get their ink width plus a side
/// bearing instead; everything else keeps the face's own advance.
fn proportional(ch: char, glyph: &pdfcraft_fonts::GlyphOutline) -> (f64, f64) {
let respace = matches!(ch, '\u{0370}'..='\u{03FF}' | '\u{0400}'..='\u{052F}' | '\u{1F00}'..='\u{1FFF}');
let ink = glyph.bbox[2] - glyph.bbox[0];
if !respace || glyph.contours.is_empty() || !(ink > 0.0 && ink < glyph.width) {
return (0.0, glyph.width);
}
(SIDE_BEARING - glyph.bbox[0], ink + 2.0 * SIDE_BEARING)
}
fn unicode_hex(ch: char) -> String {
@ -568,8 +598,13 @@ fn no_japanese_font() -> EditError {
)
}
fn type3_font(doc: &mut Document, fonts_res: &mut Dict, text: &str) -> Result<Type3Fallback, EditError> {
let family = pdfcraft_fonts::document_japanese_font().ok_or_else(no_japanese_font)?.family;
/// The first of `faces` (best match first) that has a glyph for every one of `chars`, else the first.
fn fallback_face<'a>(faces: &[&'a CraftFont], chars: &[char], has_glyph: impl Fn(&CraftFont, char) -> bool) -> Option<&'a CraftFont> {
faces.iter().copied().find(|face| chars.iter().all(|ch| has_glyph(face, *ch))).or_else(|| faces.first().copied())
}
fn type3_font(doc: &mut Document, fonts_res: &mut Dict, text: &str, family: crate::added::Family, bold: bool) -> Result<Type3Fallback, EditError> {
let faces = pdfcraft_fonts::document_japanese_fonts_for_style(family == crate::added::Family::Times, bold);
let mut chars = Vec::new();
for ch in text.chars() {
if !chars.contains(&ch) {
@ -582,6 +617,10 @@ fn type3_font(doc: &mut Document, fonts_res: &mut Dict, text: &str) -> Result<Ty
if chars.is_empty() {
return Err(EditError::Invalid("replacement text is empty".into()));
}
// The faces differ in coverage (e.g. of Cyrillic), so use the best face that has every
// character. When none has them all, the best face reports the character it lacks.
let face = fallback_face(&faces, &chars, |face, ch| japanese_glyph_from(face, ch).is_ok()).ok_or_else(no_japanese_font)?;
let family = face.family;
let mut codes = Vec::with_capacity(chars.len());
let mut charprocs = Dict::new();
let mut widths = Vec::with_capacity(chars.len());
@ -593,7 +632,7 @@ fn type3_font(doc: &mut Document, fonts_res: &mut Dict, text: &str) -> Result<Ty
for (i, ch) in chars.into_iter().enumerate() {
let code = u8::try_from(i + 1).map_err(|_| EditError::Invalid("Japanese replacement has too many unique characters".into()))?;
let glyph_name = format!("g{code:02X}");
let (path, width) = type3_path(ch)?;
let (path, width) = type3_path(face, ch)?;
let mut pd = Dict::new();
pd.set(b"Length".to_vec(), path.len() as i64);
let proc_ref = doc.add(Object::Stream(Stream::from_raw(pd, path)));
@ -614,6 +653,14 @@ fn type3_font(doc: &mut Document, fonts_res: &mut Dict, text: &str) -> Result<Ty
font.set(b"Type".to_vec(), Object::name("Font"));
font.set(b"Subtype".to_vec(), Object::name("Type3"));
font.set(b"Name".to_vec(), Object::name("PCJapanese"));
let mut descriptor = Dict::new();
descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor"));
descriptor.set(b"FontName".to_vec(), Object::name(&format!("{}-{}", face.family, face.style).replace(' ', "")));
descriptor.set(b"FontFamily".to_vec(), Object::String(PdfString::literal(face.family.as_bytes().to_vec())));
descriptor.set(b"Flags".to_vec(), Object::Int(if face.family.contains("Mincho") { 6 } else { 4 }));
descriptor.set(b"ItalicAngle".to_vec(), Object::Int(0));
// PDF 1.7 tables 5.9 and 5.19: a Type 3 descriptor is indirect; Ascent/Descent may be omitted.
font.set(b"FontDescriptor".to_vec(), Object::Ref(doc.add(Object::Dict(descriptor))));
font.set(b"FontBBox".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(-300), Object::Int(1000), Object::Int(1000)]));
font.set(
b"FontMatrix".to_vec(),
@ -670,7 +717,7 @@ pub fn replace_line(doc: &mut Document, page: usize, line: usize, text: &str) ->
Some(bytes) => replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])),
None => {
if needs_type3(&text) {
let fallback = type3_font(doc, &mut fonts_res, &text)?;
let fallback = type3_font(doc, &mut fonts_res, &text, source_family(&target.base_font), target.bold)?;
let bytes = type3_encode(&fallback, &text)
.ok_or_else(|| EditError::Invalid(format!("\"{text}\" can't be shown by the Japanese fallback")))?;
let size = font_size_before(&ops, first).unwrap_or(target.size);
@ -857,6 +904,8 @@ pub fn replace_block(doc: &mut Document, page: usize, block: usize, text: &str)
pub struct BlockStyle {
/// A standard font family, bold, italic.
pub family: Option<(crate::added::Family, bool, bool)>,
/// Override weight without choosing a different family (`None` keeps the source weight).
pub bold: Option<bool>,
/// Font size in points (user space).
pub size: Option<f64>,
/// Fill colour (RGB 0–1).
@ -904,10 +953,11 @@ pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option
}
let o_state = ts_state.clone();
let metrics = fonts_res.get(font_name.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned()).map(|d| Metrics::from_dict(doc, &d));
let reuse = style.family.is_none() && metrics.as_ref().is_some_and(|m| m.encode(&text).is_some());
let type3 = if !reuse && needs_type3(&text) { Some(type3_font(doc, &mut fonts_res, &text)?) } else { None };
// The standard font used when the paragraph's own can't be (chosen, or substituted).
let reuse = style.family.is_none() && style.bold.is_none() && metrics.as_ref().is_some_and(|m| m.encode(&text).is_some());
// The chosen style also determines the real Japanese fallback outlines and advances.
let (family, bold, italic) = style.family.unwrap_or((source_family(&b.base_font), b.bold, b.italic));
let bold = style.bold.unwrap_or(bold);
let type3 = if !reuse && needs_type3(&text) { Some(type3_font(doc, &mut fonts_res, &text, family, bold)?) } else { None };
let std_width = move |s: &str, size: f64| -> f64 {
match family {
crate::added::Family::Courier => s.chars().count() as f64 * 0.6 * size,
@ -1124,4 +1174,20 @@ mod tests {
assert_eq!(wrap("A\u{2028}B C", 3.0, width), ["A B", "C"]);
assert_eq!(wrap(" \tA B\r\nC ", 3.0, width), ["A B", "C"]);
}
#[test]
fn fallback_face_moves_on_when_the_best_face_lacks_a_character() {
static JPAN: &[&str] = &["Jpan"];
let mincho = CraftFont { family: "Mincho", style: "Regular", scripts: JPAN, bytes: b"" };
let gothic = CraftFont { family: "Gothic", style: "Regular", scripts: JPAN, bytes: b"" };
let faces = [&mincho, &gothic];
// Only the Gothic face has the Cyrillic letter.
let has = |face: &CraftFont, ch: char| ch.is_ascii() || face.family == "Gothic";
assert_eq!(fallback_face(&faces, &['a', 'ф'], has).map(|f| f.family), Some("Gothic"));
// Characters the best face has keep it.
assert_eq!(fallback_face(&faces, &['a', 'b'], has).map(|f| f.family), Some("Mincho"));
// No face has them all: the best face stays, and names the missing glyph.
assert_eq!(fallback_face(&faces, &['a', 'ф'], |_, ch| ch.is_ascii()).map(|f| f.family), Some("Mincho"));
assert!(fallback_face(&[], &['a'], has).is_none());
}
}

View File

@ -10,7 +10,7 @@ workspace = true
[dependencies]
pdfcraft-measure = { workspace = true }
image = { version = "0.25", default-features = false, features = ["jpeg", "tiff"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "tiff"] }
pdfcraft-render = { workspace = true }
pdfcraft-cos = { workspace = true }
pdfcraft-organize = { workspace = true }
@ -34,6 +34,11 @@ pdfcraft-create = { workspace = true }
pdfcraft-model = { workspace = true }
thiserror = { workspace = true }
png = "0.18"
# Already in the graph (url and idna through webbrowser, icu_properties through boa): they show
# the host of a document's link the way the browser reads it (links::display_host).
url = "2.5"
idna = "1.1"
icu_properties = "2.3"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
# Already in the graph through lopdf; used only for the local UTC offset ("today").

80
crates/engine/README.md Normal file
View File

@ -0,0 +1,80 @@
# pdfcraft-engine
The façade every frontend talks to. It holds the open documents, their edit history and the tool
catalogue; frontends never reach past it into the parsing, rendering or editing crates.
- **Layer:** L6. It depends on most of L1–L4 and on nothing above itself. **No UI toolkit** —
`egui`, `winit`, `eframe` and `rfd` are forbidden below L7, and `xtask layers` enforces it.
- The crate denies `unwrap`/`expect`/`panic!`/`todo!`. Entry points that run untrusted-document
code keep a last-resort `catch_unwind` guard so an escaped panic becomes an error and the user
keeps their document.
Two frontends exist on top of it today: `pdfcraft-ui-egui` (desktop and web) and
`pdfcraft-automation` (headless tools, the CLI and MCP). Anything one can do, the other must be
able to do — see AGENTS.md §3.
## The editing model
Each open document holds a `pdfcraft_cos::Document`: the object graph plus a copy-on-write overlay.
1. An edit runs **on a clone**. On success the previous state is pushed onto the undo stack. Clones
share all unchanged data, so a snapshot is cheap — this is what makes 100 levels of undo
affordable on a 200 MB file.
2. After every edit the **working file** is produced by an incremental write: the original bytes
plus one appended revision.
3. The view is refreshed from those bytes, so **what you see is exactly what Save will write**.
4. Saving rebases onto the written bytes, so the next save appends only new edits.
Consequences worth preserving: the original bytes of an opened file are never rewritten in place; a
full rewrite happens only where it must (redaction, PDF/A conversion, print imposition, a changed
password); and unknown data survives a round trip because nothing is reconstructed that wasn't
understood.
## API
```rust
let mut s = Session::new();
let id = s.open("report.pdf", Some(path), bytes, None)?; // -> DocId
s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 })?;
let label = s.undo(id)?; // "Rotate pages"
s.redo(id)?;
let doc = s.get(id).ok_or("gone")?; // &Document: queries, never edits
doc.can_undo(); doc.editable(); doc.allows_printing();
doc.text_blocks(page); doc.page_boxes(page); doc.security_summary();
let bytes = s.save_bytes(id)?; // incremental: keeps signatures over earlier revisions valid
let bytes = s.save_full_bytes(id)?; // full rewrite, garbage-collected
s.mark_saved(id, bytes.clone(), path)?;
```
`Edit` is one enum of 75 variants — every undoable change in the product, from
`RotatePages` to `AddAnnotation`, `SetFieldValue`, `ApplyRedactions` and `AddWatermark`. Adding a
feature means adding a variant and handling it in `apply`, which is what keeps undo, the dirty
flag, autosave and the automation tools consistent for free.
`Session` also owns the operations that produce a *new* document rather than editing one:
`create_blank`, `create_from_images`, `create_from_text`, `combine`, `extract`, `split`,
`split_by_size`, `reduced_bytes`, `optimized_bytes`, `print_pdf`, `export_data`, and the signing
family (`sign`, `sign_with_timestamp`, `timestamp_document`, `embed_ltv`).
## Modules
| Module | What it holds |
|---|---|
| `commands` | The command registry: id, label, menu, shortcut, and a `Needs` precondition encoding document security. Menus, the ⌘K palette, shortcuts and the control channel all read it, so every surface agrees on what exists and when it is enabled |
| `catalog` | Acrobat's "All tools" information architecture as data — groups, sections, icons, hues, and an `Availability` of `Ready`, `Planned(milestone)` or `Provider`. The UI renders what is *not* built yet, honestly |
| `actions` | PDF action dispatch (go-to, URI, named, set-layer-state, JavaScript) |
| `links` | Link creation, properties, and finding URLs in page text |
| `js` | The bridge to `pdfcraft-js`. `forms` never depends on `js` directly; the engine injects it, so a build without JavaScript is possible |
| `xfa`, `ocr`, `compare`, `export` | Façades over the L3/L4 crates of the same names |
## Not done here
- **Rendering is still borrowed.** `pdfcraft-render` wraps `hayro` and inspects through `lopdf`
(ADR-0004). The engine only sees `inspect() -> DocInfo`, `RenderPool` and `text::PageText`, and
that API stays frozen while M2 replaces what is behind it.
- Performance budgets (`misc.performance-budgets`) are not set or enforced.
- Some shipped features are still reachable only from the UI, not through an `automation` tool;
`cargo xtask parity` lists them; closing that gap is tracked in the execution plan (D4).

View File

@ -149,7 +149,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
title: "Create from",
items: &[
item("Single file", "file-input", "create.file", Ready),
item("Multiple files", "files", "create.multiple", Planned("M10")),
item("Multiple files", "files", "create.multiple", Ready),
item("Images", "image", "create.images", Ready),
item("Clipboard", "copy-plus", "create.clipboard", Ready),
item("Blank page", "file-plus-2", "create.blank", Ready),

View File

@ -149,6 +149,10 @@ const HELP: Option<&str> = Some("Help");
/// Every command, in menu order.
pub const COMMANDS: &[CommandSpec] = &[
c("file.open", "Open…", FILE, Some(Shortcut::cmd("O")), Nothing, "folder-open"),
c("file.open_recent", "Open Recent", FILE, None, Nothing, "clock"),
// Shown at the foot of File ▸ Open Recent and on Home, not as its own File menu item.
c("file.clear_recent", "Clear Recent Files", None, None, Nothing, "trash-2"),
c("file.pin_folder", "Pin folder to Home…", FILE, None, Nothing, "folder-plus"),
c("create.blank", "New blank PDF", FILE, None, Nothing, "file-plus-2"),
c("measure.distance", "Measure distance", None, None, Annotate, "ruler"),
c("measure.perimeter", "Measure perimeter", None, None, Annotate, "ruler"),
@ -161,6 +165,7 @@ pub const COMMANDS: &[CommandSpec] = &[
c("page.cut", "Cut pages", None, None, Assembly, "scissors"),
c("page.paste", "Paste pages", None, None, Assembly, "clipboard-paste"),
c("create.file", "Create PDF from file…", FILE, None, Nothing, "file-input"),
c("create.multiple", "Create PDF from multiple files…", FILE, None, Nothing, "files"),
c("create.images", "Create PDF from images…", FILE, None, Nothing, "image"),
c("create.clipboard", "Create PDF from clipboard", FILE, None, Nothing, "copy-plus"),
c("page.combine", "Combine files…", FILE, None, Nothing, "files"),
@ -190,6 +195,7 @@ pub const COMMANDS: &[CommandSpec] = &[
c("edit.snapshot", "Take a snapshot", EDIT, None, Document, "camera"),
c("view.full_screen", "Full screen mode", VIEW, Some(Shortcut::cmd("L")), Document, "maximize"),
c("view.read_mode", "Read mode", VIEW, Some(Shortcut { command: true, shift: false, mac_ctrl: true, key: "H" }), Document, "book-open"),
c("view.focus_page_input", "Go to page…", VIEW, Some(Shortcut::cmd_shift("N")), Document, "text-cursor-input"),
c("view.theme", "Switch light / dark theme", None, None, Nothing, "moon"),
c("view.theme.system", "Use system setting", None, None, Nothing, "settings"),
c("view.theme.light", "Light gray", None, None, Nothing, "sun"),

View File

@ -170,6 +170,7 @@ impl crate::Document {
size: b.size,
bold: f.contains("bold") || f.contains("black") || f.contains("heavy"),
italic: f.contains("italic") || f.contains("oblique"),
color: b.color,
}
})
.collect();

View File

@ -25,7 +25,7 @@ pub struct ExportSource {
impl Document {
/// The current state, for exporting on another thread.
pub fn export_source(&self) -> ExportSource {
ExportSource { bytes: self.bytes.clone(), config: self.config.clone(), pages: self.info.pages.len() }
ExportSource { bytes: self.display.clone(), config: self.config.clone(), pages: self.info.pages.len() }
}
}

View File

@ -20,8 +20,13 @@ pub mod export;
pub mod js;
pub mod links;
pub mod ocr;
pub mod optimizer;
pub mod signature_image;
mod upright_image;
pub mod xfa;
pub use signature_image::{ImageSignaturePreview, SignatureImage};
pub use pdfcraft_organize::{BoxSpec, PageBox, SplitBy, split_ranges};
/// One file produced by a split: (1-based first page, last page, PDF bytes).
@ -29,7 +34,7 @@ pub use pdfcraft_organize::LabelStyle;
pub use pdfcraft_organize::view::{InitialView, Layout as InitialLayout, Magnification, Navigation};
pub use pdfcraft_cos::Algorithm;
pub use pdfcraft_create::ImageResolution;
pub use pdfcraft_create::{CONVERTIBLE, ImageResolution, SourceKind, source_kind};
pub use pdfcraft_edit::{
Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark,
};
@ -39,7 +44,7 @@ pub use pdfcraft_forms::{
};
pub use pdfcraft_a11y as a11y;
pub use pdfcraft_edit::{BlockStyle, PageImage, TextBlock, TextLine};
pub use pdfcraft_edit::{BlockStyle, PageImage, TextBlock, TextLine, first_undrawable};
pub use pdfcraft_measure as measure;
pub use pdfcraft_xfa::Report as XfaLayout;
@ -64,25 +69,33 @@ pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_outline};
/// Fill & Sign: `text` in the script font as a typed signature, its left edge at `at` (user
/// space, vertically centred) and `height` points tall. `None` for text with no outlines.
pub fn typed_signature_shape(at: [f64; 2], text: &str, height: f64) -> Option<Shape> {
///
/// Left, centred and upright are as displayed on a page turned by `rotation` (its `/Rotate`):
/// the box runs along the displayed axes, like an image signature's
/// ([`SignatureImage::rect`]), and the outlines are turned back so the name reads across.
pub fn typed_signature_shape(at: [f64; 2], text: &str, height: f64, rotation: i64) -> Option<Shape> {
let o = script_outline(text);
let [left, bottom, right, top] = o.bounds();
let span = (top - bottom).max(0.1);
let width = right - left;
if o.contours.is_empty() || o.width <= 0.0 {
if o.contours.is_empty() || o.width <= 0.0 || !(width > 0.0 && height > 0.0) {
return None;
}
let k = height / span;
let rect = [at[0], at[1] - height / 2.0, at[0] + width * k, at[1] + height / 2.0];
let contours = o.contours.iter().map(|c| c.iter().map(|p| [(p[0] - left) / width, (p[1] - bottom) / span]).collect()).collect();
let rect = signature_image::upright_box(rotation, at, width * k, height)?;
// Displayed right and up as user-space unit vectors: a point at (nx, ny) of the displayed box
// is at this fraction of the user-space box. Exact for an unturned page.
let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(rotation, [0.0; 4]);
let to_user = |nx: f64, ny: f64| [a * nx + c * ny + (-a).max(0.0) + (-c).max(0.0), b * nx + d * ny + (-b).max(0.0) + (-d).max(0.0)];
let contours = o.contours.iter().map(|c| c.iter().map(|p| to_user((p[0] - left) / width, (p[1] - bottom) / span)).collect()).collect();
Some(Shape::TypedSignature { rect, contours })
}
/// Comment geometry helpers (text-box line breaking) for frontends.
pub use pdfcraft_annot::appearance as annot_text;
pub use pdfcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle};
pub use pdfcraft_annot::{
AttachIcon, FillMark, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, Props as CommentProps, ReviewState, Rgb, Shape,
StampGroup, StampKind, Style, rect_quad,
AttachIcon, FillMark, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, Props as CommentProps, ReviewState, Rgb,
Shape, StampGroup, StampKind, Style, rect_quad,
};
pub use pdfcraft_forms::detect;
pub use pdfcraft_optimize as optimize;
@ -190,8 +203,11 @@ pub struct Document {
pub id: DocId,
pub name: String,
pub path: Option<String>,
/// The working file: what Save writes and what is displayed.
/// The working file: what Save writes.
pub bytes: Arc<Vec<u8>>,
/// What is displayed (the page view, `page_render`, page images): `bytes`, plus appearances
/// drawn for display only for comments that have none ([`display_bytes`]). Never saved.
pub display: Arc<Vec<u8>>,
pub info: DocInfo,
pub renderer: RenderPool,
/// The password the document was opened with (needed to read attachments, etc.).
@ -252,6 +268,9 @@ impl Document {
pub fn page_image_file(&self, page: usize, index: usize) -> Result<(&'static str, Vec<u8>), String> {
let editor = self.editor.as_ref().ok_or("the document can't be read")?;
let img = self.page_images(page).into_iter().nth(index).ok_or_else(|| format!("page {} has no image {}", page + 1, index + 1))?;
if img.is_form {
return Err("grouped Form artwork is not a bitmap; Save Image As is only available for raster images".into());
}
pdfcraft_create::image_file(&editor.cos, img.object.ok_or("the image has no object")?)
}
@ -470,6 +489,15 @@ impl Document {
}
}
/// What is displayed: the working file, plus (in memory only, never saved) the appearances
/// PdfCraft draws for comments that have none ([`pdfcraft_annot::with_missing_appearances`]).
/// The working file is unchanged, and so is everything that reads it (saving, signatures, …).
fn display_bytes(editor: Option<&Editor>, bytes: &Arc<Vec<u8>>) -> Arc<Vec<u8>> {
let Some(display) = editor.and_then(|e| pdfcraft_annot::with_missing_appearances(&e.cos)) else { return bytes.clone() };
// Writing fails only as saving the document would; the page then shows as it always did.
write_incremental(&display, &SaveOptions::default()).map(Arc::new).unwrap_or_else(|_| bytes.clone())
}
fn render_threads() -> usize {
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 8) - 1
}
@ -478,7 +506,7 @@ fn render_threads() -> usize {
fn use_layer_choices(doc: &mut Document) {
let overrides: Vec<(i32, i32, bool)> = doc.info.layers.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect();
doc.config.layers = Arc::new(overrides);
doc.renderer = RenderPool::new(doc.bytes.clone(), render_threads(), doc.config.clone());
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
}
/// Apply a set-layer-visibility action to `layers`, one change at a time, so a toggle flips the
@ -827,6 +855,8 @@ pub enum Edit {
color: Option<Rgb>,
opacity: Option<f64>,
width: Option<f64>,
/// Line or polyline: two endings. Callout: one. `None` leaves `/LE` unchanged.
endings: Option<Vec<pdfcraft_annot::LineEnding>>,
},
/// Comment properties ▸ General / note icon.
SetAnnotationInfo {
@ -1172,7 +1202,7 @@ fn annotation_noun(s: &Shape) -> &'static str {
Shape::TextMarkup { kind: Markup::Squiggly, .. } => "squiggly underline",
Shape::Rectangle { .. } => "rectangle",
Shape::Oval { .. } => "oval",
Shape::Line { arrow: true, .. } => "arrow",
Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "arrow",
Shape::Line { .. } => "line",
Shape::Ink { .. } => "drawing",
Shape::TextBox { .. } => "text box",
@ -1434,9 +1464,11 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
Edit::AddCustomStamp { page, rect, name, file, author } => {
let src = mark_source(doc, file)?;
let (sw, sh) = (src.size.0.max(1.0), src.size.1.max(1.0));
let rect = if (rect[2] - rect[0]).abs() < 1.0 || (rect[3] - rect[1]).abs() < 1.0 {
let natural = rect[2] == rect[0] && rect[3] == rect[1];
let rotation = if natural && src.image { pdfcraft_organize::page_rotation(doc, *page)? } else { 0 };
let rect = if natural {
let k = (200.0 / sw.max(sh)).min(1.0);
let (w, h) = (sw * k, sh * k);
let (w, h) = if matches!(rotation, 90 | 270) { (sh * k, sw * k) } else { (sw * k, sh * k) };
[rect[0] - w / 2.0, rect[1] - h / 2.0, rect[0] + w / 2.0, rect[1] + h / 2.0]
} else {
*rect
@ -1444,7 +1476,8 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
let shape = Shape::CustomStamp { rect, name: name.clone(), picture: src.xobject, image: src.image, size: (sw, sh) };
let style = pdfcraft_annot::Style::default_for(&shape);
let new = NewAnnotation { page: *page, shape, style, contents: name.clone(), author: author.clone() };
pdfcraft_annot::add_annotation(doc, &new, &cx.meta())?;
let index = pdfcraft_annot::add_annotation(doc, &new, &cx.meta())?;
pdfcraft_annot::orient_image_stamp(doc, *page, index, rotation)?;
}
Edit::DeleteAnnotation { page, index } => pdfcraft_annot::delete_annotation(doc, *page, *index)?,
Edit::SetAnnotationContents { page, index, text } => pdfcraft_annot::set_contents(doc, *page, *index, text, &cx.meta())?,
@ -1466,8 +1499,8 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
Edit::LockAnnotation { page, index, locked } => pdfcraft_annot::set_locked(doc, *page, *index, *locked)?,
Edit::MoveAnnotation { page, index, dx, dy } => pdfcraft_annot::move_annotation(doc, *page, *index, *dx, *dy, &cx.meta())?,
Edit::ResizeAnnotation { page, index, rect } => pdfcraft_annot::set_rect(doc, *page, *index, *rect, &cx.meta())?,
Edit::StyleAnnotation { page, index, color, opacity, width } => {
pdfcraft_annot::set_style(doc, *page, *index, *color, *opacity, *width, &cx.meta())?;
Edit::StyleAnnotation { page, index, color, opacity, width, endings } => {
pdfcraft_annot::set_style(doc, *page, *index, *color, *opacity, *width, endings.as_deref(), &cx.meta())?;
}
Edit::SetAnnotationInfo { page, index, author, subject, icon } => {
pdfcraft_annot::set_info(doc, *page, *index, author.as_deref(), subject.as_deref(), *icon, &cx.meta())?;
@ -1489,7 +1522,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
ran.map_err(EditError::Invalid)?;
}
Edit::SetFieldImage { name, image } => {
let (img, _) = pdfcraft_create::image_xobject(doc, name, image)?;
let (img, _) = embed_image(doc, name, image)?;
let px = match &*doc.get(img) {
pdfcraft_cos::Object::Stream(s) => (s.dict.int(b"Width").unwrap_or(1) as u32, s.dict.int(b"Height").unwrap_or(1) as u32),
_ => (1, 1),
@ -1546,7 +1579,14 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
ImageEdit::Flip { horizontal } => {
pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(img.rect, 0, *horizontal, !*horizontal))
}
ImageEdit::Replace { name, bytes } => pdfcraft_edit::ImageChange::Replace(pdfcraft_create::image_xobject(doc, name, bytes)?.0),
ImageEdit::Replace { name, bytes } => {
if img.is_form {
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid(
"grouped Form artwork cannot be replaced with a bitmap".into(),
)));
}
pdfcraft_edit::ImageChange::Replace(embed_image(doc, name, bytes)?.0)
}
ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete,
};
pdfcraft_edit::change_image(doc, *page, *index, &c)?;
@ -1586,7 +1626,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
pdfcraft_edit::add_content(doc, *page, &AddedContent::Text(text.clone()))?;
}
Edit::AddImage { page, rect, name, bytes } => {
let (image, natural) = pdfcraft_create::image_xobject(doc, name, bytes)?;
let (image, natural) = embed_image(doc, name, bytes)?;
let rect = match rect {
Some(r) => *r,
None => {
@ -1606,7 +1646,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
let Some(AddedContent::Image(old)) = item.map(|a| a.content) else {
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("that item is not an image".into())));
};
let (image, _) = pdfcraft_create::image_xobject(doc, name, bytes)?;
let (image, _) = embed_image(doc, name, bytes)?;
pdfcraft_edit::update_content(doc, *page, *index, &AddedContent::Image(pdfcraft_edit::AddedImage { image, ..old }))?;
}
Edit::ApplyRedactions { pages } => {
@ -1732,19 +1772,57 @@ pub fn guard<T>(f: impl FnOnce() -> T) -> Result<T, String> {
})
}
/// The most files Create ▸ Multiple files takes in one run.
pub const MAX_CREATE_FILES: usize = 1000;
/// Parse another PDF to copy pages from.
fn open_source(name: &str, bytes: &Arc<Vec<u8>>) -> Result<pdfcraft_cos::Document, EditError> {
match std::panic::catch_unwind(|| pdfcraft_cos::Document::open(bytes.clone())) {
Ok(Ok(d)) if d.permissions().is_some_and(|p| !p.assemble()) => {
Err(EditError::Source(format!("{name}: its security settings don't allow copying pages")))
}
open_source_with(name, bytes, None)
}
/// [`open_source`], authenticating with `password` (user or owner) for an encrypted file.
fn open_source_with(name: &str, bytes: &Arc<Vec<u8>>, password: Option<&str>) -> Result<pdfcraft_cos::Document, EditError> {
source_document(bytes, password).map_err(|p| EditError::Source(format!("{name}: {p}")))
}
/// Why a file can't be a source for Combine Files (or Insert / Replace pages).
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum SourceProblem {
#[error("it is password-protected")]
Password,
#[error("the password is wrong")]
WrongPassword,
#[error("its security settings don't allow copying pages")]
NotPermitted,
#[error("{0}")]
Unreadable(String),
}
fn source_document(bytes: &Arc<Vec<u8>>, password: Option<&str>) -> Result<pdfcraft_cos::Document, SourceProblem> {
match std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)) {
Ok(Ok(d)) if d.permissions().is_some_and(|p| !p.assemble()) => Err(SourceProblem::NotPermitted),
Ok(Ok(d)) => Ok(d),
Ok(Err(pdfcraft_cos::CosError::NeedsPassword)) => Err(EditError::Source(format!("{name}: it is password-protected"))),
Ok(Err(e)) => Err(EditError::Source(format!("{name}: {e}"))),
Err(_) => Err(EditError::Source(format!("{name}: the file could not be read"))),
Ok(Err(pdfcraft_cos::CosError::NeedsPassword)) => Err(SourceProblem::Password),
Ok(Err(pdfcraft_cos::CosError::WrongPassword)) => Err(SourceProblem::WrongPassword),
Ok(Err(e)) => Err(SourceProblem::Unreadable(e.to_string())),
Err(_) => Err(SourceProblem::Unreadable("the file could not be read".into())),
}
}
/// Whether `bytes` can be combined, opened with `password` (user or owner) if given, and if not
/// why: what Combine would refuse it for, so the Combine files list can say so before Combine
/// is pressed. The owner (permissions) password lifts the restriction on copying pages.
pub fn combine_source_check(bytes: &Arc<Vec<u8>>, password: Option<&str>) -> Result<(), SourceProblem> {
source_document(bytes, password).map(|_| ())
}
/// The number of pages of `bytes`, opened with `password` (user or owner) if given, whatever
/// its permissions allow; `None` when it can't be read.
pub fn source_page_count(bytes: &Arc<Vec<u8>>, password: Option<&str>) -> Option<usize> {
let doc = std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)).ok()?.ok()?;
pdfcraft_organize::page_count(&doc).ok()
}
fn plural(s: &str, n: usize) -> String {
if n == 1 { s.to_string() } else { format!("{s}s") }
}
@ -1795,6 +1873,8 @@ pub enum EditError {
Sign(String),
#[error("{0}")]
Optimize(String),
#[error("cancelled")]
Cancelled,
#[error("{0} isn't possible in a signed document: it would rewrite the file and invalidate the signatures")]
SignedRewrite(String),
#[error("this document is signed: rewriting it would invalidate its signatures (save it incrementally instead)")]
@ -1885,6 +1965,17 @@ fn xfa_values_from_datasets(doc: &mut pdfcraft_cos::Document) -> Result<Vec<Stri
for (name, value) in pdfcraft_xfa::read_values(doc, &data) {
let Some(f) = fields.iter().find(|f| f.name == name) else { continue };
let new = match value {
pdfcraft_xfa::FieldData::Text(t) if matches!(f.kind, pdfcraft_forms::FieldKind::Combo | pdfcraft_forms::FieldKind::List) => {
// Saved values, one per line for a multi-select list; shown text stands for
// its saved value (an older viewer may have written it).
let saved = |v: &str| f.options.iter().find(|(e, s)| e == v || s == v).map_or(v.to_string(), |(e, _)| e.clone());
let picked: Vec<String> = if f.has(pdfcraft_forms::flags::MULTI_SELECT) {
t.lines().map(str::trim).filter(|l| !l.is_empty()).map(saved).collect()
} else {
vec![saved(t.trim())]
};
(f.value != picked).then_some(FieldValue::Choice(picked))
}
pdfcraft_xfa::FieldData::Text(t) => (f.value.join("\n") != t).then_some(FieldValue::Text(t)),
pdfcraft_xfa::FieldData::Check(on) => (f.value.is_empty() == on).then_some(FieldValue::Check(on)),
pdfcraft_xfa::FieldData::Radio(sel) => (f.value.first() != sel.as_ref()).then_some(FieldValue::Radio(sel)),
@ -2127,7 +2218,6 @@ impl Session {
xfa: Option<XfaLayout>,
) -> Result<DocId, OpenError> {
let config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() };
let renderer = RenderPool::new(bytes.clone(), render_threads(), config.clone());
let (editor, read_only_reason) = match cos {
Ok(Ok(cos)) => {
let keys = Keys { render: render_password.clone(), reopen: password.map(str::to_owned) };
@ -2136,6 +2226,8 @@ impl Session {
Ok(Err(e)) => (None, Some(e.to_string())),
Err(_) => (None, Some("the document structure could not be read for editing".into())),
};
let display = display_bytes(editor.as_ref(), &bytes);
let renderer = RenderPool::new(display.clone(), render_threads(), config.clone());
let mut form = editor.as_ref().map(|e| pdfcraft_forms::fields(&e.cos)).unwrap_or_default();
if let Some(e) = editor.as_ref() {
xfa::mark_script_buttons(&e.cos, &mut form);
@ -2152,6 +2244,7 @@ impl Session {
name,
path,
bytes,
display,
info,
renderer,
password: render_password,
@ -2370,8 +2463,9 @@ impl Session {
if !doc.signatures.is_empty() {
doc.signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache);
}
doc.bytes = bytes.clone();
doc.renderer = RenderPool::new(bytes, render_threads(), doc.config.clone());
doc.display = display_bytes(Some(editor), &bytes);
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
doc.bytes = bytes;
Ok(())
}
@ -2406,8 +2500,9 @@ impl Session {
doc.added = pdfcraft_edit::list_added(&editor.cos);
doc.links = pdfcraft_annot::links::list(&editor.cos);
doc.signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache);
doc.bytes = bytes.clone();
doc.renderer = RenderPool::new(bytes, render_threads(), doc.config.clone());
doc.display = display_bytes(Some(editor), &bytes);
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
doc.bytes = bytes;
Ok(())
}
@ -2503,6 +2598,23 @@ impl Session {
self.write_new(&pdfcraft_create::from_text(title, text, pdfcraft_create::LETTER, 11.0)?)
}
/// Convert a file Create understands (an image or plain text) to PDF bytes; a PDF is checked
/// (it must open and allow copying pages) and returned as it is.
pub fn convert_to_pdf(&self, name: &str, bytes: &Arc<Vec<u8>>) -> Result<(SourceKind, Arc<Vec<u8>>), EditError> {
let Some(kind) = source_kind(name, bytes) else {
return Err(EditError::Source(format!("{name}: this file type can't be converted; use a PDF, an image or a .txt file")));
};
let title = name.rsplit_once('.').map_or(name, |(s, _)| s);
// Image decoders read untrusted bytes: a panic in one must not take the app down.
let created = guard(|| match kind {
SourceKind::Pdf => open_source(name, bytes).map(|_| bytes.clone()),
SourceKind::Image => self.create_from_images(&[(name.to_string(), bytes.to_vec())]),
SourceKind::Text => self.create_from_text(title, &String::from_utf8_lossy(bytes)),
})
.map_err(|_| EditError::Source(format!("{name}: the file could not be read")))?;
Ok((kind, created?))
}
/// Reduce File Size: Acrobat's defaults (images above 225 ppi to 150 ppi, JPEG medium
/// quality; thumbnails dropped), identical resources merged, unused objects dropped, objects
/// packed into compressed object streams. Returns the bytes and how many objects were
@ -2514,21 +2626,10 @@ impl Session {
/// Optimize PDF ▸ Advanced optimization: `settings` for images and objects, plus Remove
/// Hidden Information's `discard` categories (user data). A full rewrite: signed documents
/// are refused. The open document is not changed.
/// are refused. The open document is not changed. This is [`Self::optimize_job`] run in
/// place, without progress.
pub fn optimized_bytes(&self, id: DocId, settings: &optimize::Settings, discard: &[Hidden]) -> Result<(Arc<Vec<u8>>, OptimizeReport), EditError> {
let doc = self.get(id).ok_or(EditError::NoDocument)?;
if doc.is_signed() {
return Err(EditError::Signed);
}
let editor = doc.editor.as_ref().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?;
let mut cos = editor.cos.clone();
let discarded = if discard.is_empty() { Vec::new() } else { pdfcraft_redact::sanitize::remove_hidden(&mut cos, discard)? };
let report = optimize::optimize(&mut cos, settings).map_err(|e| EditError::Optimize(e.to_string()))?;
let all: Vec<pdfcraft_cos::ObjRef> = cos.object_numbers().into_iter().map(|n| pdfcraft_cos::ObjRef::new(n, cos.generation(n))).collect();
let merged = pdfcraft_organize::dedupe_resources(&mut cos, &all, false);
let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() };
let bytes = write_full(&cos, &opts).map_err(|e| EditError::Write(e.to_string()))?;
Ok((Arc::new(bytes), OptimizeReport { optimize: report, merged, discarded }))
self.optimize_job(id, settings, discard)?.run(|_| true)
}
/// Export comments and/or form data: XFDF and FDF carry either or both; XML, CSV and text
@ -2574,7 +2675,17 @@ impl Session {
/// Combine Files with a page range per file ("1-3, 6"; `None` or empty for all pages).
pub fn combine_ranges(&self, sources: &[CombineSource]) -> Result<Arc<Vec<u8>>, EditError> {
let docs = sources.iter().map(|(n, b, _)| open_source(n, b)).collect::<Result<Vec<_>, _>>()?;
self.combine_unlocked(sources, &[])
}
/// [`Self::combine_ranges`] with the password each encrypted source is opened with, by
/// position (missing or `None`: no password). The result is not encrypted.
pub fn combine_unlocked(&self, sources: &[CombineSource], passwords: &[Option<&str>]) -> Result<Arc<Vec<u8>>, EditError> {
let docs = sources
.iter()
.enumerate()
.map(|(i, (n, b, _))| open_source_with(n, b, passwords.get(i).copied().flatten()))
.collect::<Result<Vec<_>, _>>()?;
let mut pages = Vec::with_capacity(docs.len());
for ((name, _, range), d) in sources.iter().zip(&docs) {
let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty());
@ -2779,7 +2890,7 @@ impl Session {
return false;
}
doc.config.hide_comments = hide;
doc.renderer = RenderPool::new(doc.bytes.clone(), render_threads(), doc.config.clone());
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
doc.generation += 1;
true
}
@ -2901,6 +3012,15 @@ pub struct MarkFile {
pub page: usize,
}
/// Embed an image that is placed on a page, turned the way its EXIF Orientation says.
fn embed_image(
doc: &mut pdfcraft_cos::Document,
name: &str,
bytes: &[u8],
) -> Result<(pdfcraft_cos::ObjRef, (f64, f64)), pdfcraft_create::CreateError> {
pdfcraft_create::image_xobject(doc, name, &upright_image::upright(bytes))
}
/// Bring a mark's picture into `doc`: a PDF page as a form XObject, or an image.
fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Result<pdfcraft_edit::MarkSource, EditError> {
let head = &f.bytes[..f.bytes.len().min(1024)];
@ -2909,7 +3029,7 @@ fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Result<pdfcraf
let (xobject, size) = pdfcraft_organize::page_as_form(doc, &src, f.page)?;
Ok(pdfcraft_edit::MarkSource { xobject, size, image: false })
} else {
let (xobject, size) = pdfcraft_create::image_xobject(doc, &f.name, &f.bytes)?;
let (xobject, size) = embed_image(doc, &f.name, &f.bytes)?;
Ok(pdfcraft_edit::MarkSource { xobject, size, image: true })
}
}

View File

@ -1,6 +1,8 @@
//! Where PdfCraft and the ArtCraft community live on the web. One table, so the Help menu, the
//! About dialog, the home screen, the CLI and the README agree.
use icu_properties::props::Script;
/// The app's name in ArtCraft URLs (`getartcraft.com/apps/{APP}`, `github.com/storytold/{APP}`).
pub const APP: &str = "pdfcraft";
@ -53,6 +55,13 @@ pub enum BlockedLink {
Malformed,
/// A scheme other than [`DOCUMENT_SCHEMES`] (lowercased).
Scheme(String),
/// An email address asking the email app to attach a local file (`attach=`, `attachment=`)
/// or read one into the message (`insert=`).
MailFile,
/// An email address containing text that some email tools decode before reading the address,
/// so it could spell `attach`: an RFC 2047 encoded word (`=?utf-8?q?…?=`) or a backslash
/// escape (`\0141`, `\x61`).
MailEncodedWord,
}
impl std::fmt::Display for BlockedLink {
@ -63,6 +72,8 @@ impl std::fmt::Display for BlockedLink {
Self::Hidden => write!(f, "the address contains hidden or invisible characters"),
Self::Malformed => write!(f, "the address isn't a complete web or email address"),
Self::Scheme(s) => write!(f, "it is a “{s}:” address"),
Self::MailFile => write!(f, "it asks your email app to attach or insert a file from your computer"),
Self::MailEncodedWord => write!(f, "it contains encoded text that some email apps turn into other instructions"),
}
}
}
@ -103,14 +114,106 @@ pub fn document_url(raw: &str) -> Result<String, BlockedLink> {
}
let complete = match scheme.as_str() {
"mailto" => !rest.is_empty(),
_ => authority(url).is_some_and(|a| !a.contains(' ')) && host(url).is_some(),
_ => authority(url).is_some_and(|a| !a.contains(' ')) && display_host(url).is_some(),
};
if !complete {
// The desktop app opens addresses through the webbrowser crate, which treats one the WHATWG URL
// parser rejects as a local file path and opens that instead (`mailto://a^b/` would become
// `file:///…/mailto:/a^b/`). So only an address the parser accepts, as the same scheme, goes on.
if !complete || !url::Url::parse(url).is_ok_and(|u| u.scheme() == scheme) {
return Err(BlockedLink::Malformed);
}
if scheme == "mailto" {
check_mail_parameters(rest)?;
}
Ok(url.to_string())
}
/// Email-address parameters that make an email app read a file from the user's computer: Evolution,
/// KMail and Claws Mail attach the file named by `attach` (Evolution and KMail also by
/// `attachment`), and Claws Mail reads an `insert` file into the message body. Names are matched as
/// prefixes, so `attachments` is refused too.
const MAIL_FILE_PARAMETERS: &[&str] = &["attach", "insert"];
/// How many layers of percent-encoding are looked through. Email apps decode once at most.
const MAIL_DECODE_LAYERS: usize = 8;
/// Refuse an email address (what follows `mailto:`) that asks for a local file. A document can't
/// need this, and a drafted message carrying the user's files is one click from being sent.
///
/// Email apps disagree on how they read the address, so this doesn't try to parse it the way any
/// one of them does. Every email app reads a parameter as `name=value`, so it refuses any word
/// that starts with one of [`MAIL_FILE_PARAMETERS`] (in any case) and is followed by `=`, wherever
/// it stands: after `?`, `&`, `#` or `;` (KMail looks for the first `?` anywhere and reads `&#38;`
/// as `&`), with no `?` at all (xdg-email reads `mailto:a@example.org&attach=…` as a parameter) or
/// after a comma (xdg-email copies the subject into Thunderbird's comma-separated `-compose`
/// argument). The check is repeated after each layer of percent-decoding (KMail decodes names, so
/// `%61ttach` is `attach`). A word without `=` (`Please attach the invoice`) is fine.
///
/// The address is refused, not stripped of the parameter: PdfCraft never rewrites what a document
/// asks to open, and a stripped address could still be read differently by some email app.
fn check_mail_parameters(rest: &str) -> Result<(), BlockedLink> {
let mut text = rest.to_string();
for _ in 0..MAIL_DECODE_LAYERS {
check_mail_layer(&text)?;
let decoded = String::from_utf8_lossy(&percent_decode(text.as_bytes())).into_owned();
if decoded == text {
return Ok(());
}
text = decoded;
}
check_mail_layer(&text)
}
/// One layer of [`check_mail_parameters`].
fn check_mail_layer(text: &str) -> Result<(), BlockedLink> {
// KMail decodes RFC 2047 encoded words (`=?utf-8?q?attach?=`) across the whole address before
// reading it, and its decoder is lenient, so any `=?` followed later by `?=` is refused.
if text.find("=?").is_some_and(|i| text.get(i + 2..).is_some_and(|after| after.contains("?="))) {
return Err(BlockedLink::MailEncodedWord);
}
// xdg-email passes the address through `echo`, which turns `\0141` (octal) or `\x61` into `a`.
if text.split('\\').skip(1).any(|after| after.starts_with(|c: char| c.is_ascii_digit() || matches!(c, 'x' | 'X'))) {
return Err(BlockedLink::MailEncodedWord);
}
let lower = text.to_ascii_lowercase();
let is_word = |c: char| c.is_ascii_alphanumeric() || matches!(c, '_' | '-');
let mut previous = None;
for (i, c) in lower.char_indices() {
let starts_word = is_word(c) && !previous.is_some_and(is_word);
previous = Some(c);
if !starts_word {
continue;
}
let Some(word) = lower.get(i..) else { continue };
let name_end = word.find(|c: char| !is_word(c)).unwrap_or(word.len());
let (name, after) = word.split_at(name_end);
let asks = MAIL_FILE_PARAMETERS.iter().any(|p| name.starts_with(p));
if asks && after.trim_start_matches([' ', '\t', '+', '\'', '"']).starts_with('=') {
return Err(BlockedLink::MailFile);
}
}
Ok(())
}
/// Decode `%XX` escapes. A `%` not followed by two hex digits is kept as it is.
fn percent_decode(s: &[u8]) -> Vec<u8> {
let hex = |b: Option<&u8>| b.and_then(|b| char::from(*b).to_digit(16)).and_then(|d| u8::try_from(d).ok());
let mut out = Vec::with_capacity(s.len());
let mut i = 0;
while let Some(&b) = s.get(i) {
if b == b'%'
&& let (Some(high), Some(low)) = (hex(s.get(i + 1)), hex(s.get(i + 2)))
{
out.push((high << 4) | low);
i += 3;
} else {
out.push(b);
i += 1;
}
}
out
}
/// The authority of a web address: what follows `//`, up to the first `/`, `\`, `?` or `#`.
/// Browsers read `\` as `/` in web addresses (the WHATWG URL standard), so it ends the authority
/// too; otherwise `https://other.example\@trusted.example/` would seem to go to
@ -138,6 +241,102 @@ pub fn host(url: &str) -> Option<&str> {
(!host.is_empty()).then_some(host)
}
/// How to show a web address's host so that a lookalike can't pass for a familiar site.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HostDisplay {
/// The host the browser connects to: lowercase ASCII, with international labels in punycode
/// (`xn--…`) and numeric addresses in their usual form (`192.168.1.1`, `[::1]`). Show this one,
/// not the document's spelling.
pub ascii: String,
/// The host has a label in another alphabet (`xn--…`), whose letters can look like familiar
/// ones.
pub international: bool,
/// A label mixes writing systems, such as the Cyrillic `а` in `pаypal.com`: a common way to
/// imitate another site's name.
pub mixed_scripts: bool,
}
/// The host of a web address as the browser will see it (see [`HostDisplay`]). On the desktop the
/// address is handed to the operating system as the `url` crate (the WHATWG URL standard)
/// serialises it, and in the browser build the browser parses it by the same standard, so this
/// reads the host with that crate: percent-decoded, IDNA-mapped (case, fullwidth letters, `。`) and
/// with numeric hosts such as `0x7f.1` resolved. `None` for a non-web address, one without a host
/// (see [`host`]), or one the browser would reject.
pub fn display_host(url: &str) -> Option<HostDisplay> {
host(url)?;
let parsed = url::Url::parse(url).ok()?;
let ascii = match parsed.host()? {
url::Host::Domain(d) => d.to_string(),
url::Host::Ipv4(a) => a.to_string(),
url::Host::Ipv6(a) => format!("[{a}]"),
};
if !ascii.split('.').any(|label| label.starts_with("xn--")) {
return Some(HostDisplay { ascii, international: false, mixed_scripts: false });
}
let (unicode, valid) = idna::domain_to_unicode(&ascii);
let mixed_scripts = valid.is_err() || unicode.split('.').any(mixes_scripts);
Some(HostDisplay { ascii, international: true, mixed_scripts })
}
/// A writing system for comparing the characters of a label. Han characters are written with
/// Japanese, Korean and Chinese ones, so those three combinations count as one system each
/// (Unicode TR 39's augmented script sets).
#[derive(Clone, Copy, PartialEq, Eq)]
enum Writing {
Script(Script),
Japanese,
Korean,
Chinese,
}
/// The writing systems a character belongs to, or `None` for one shared by all (digits, `-`,
/// combining marks).
fn writing_systems(c: char) -> Option<Vec<Writing>> {
let scripts = icu_properties::script::ScriptWithExtensions::new().get_script_extensions_val(c);
let mut out = Vec::new();
for s in scripts.iter() {
if s == Script::Common || s == Script::Inherited {
return None;
}
out.push(Writing::Script(s));
match s {
Script::Han => out.extend([Writing::Japanese, Writing::Korean, Writing::Chinese]),
Script::Hiragana | Script::Katakana => out.push(Writing::Japanese),
Script::Hangul => out.push(Writing::Korean),
Script::Bopomofo => out.push(Writing::Chinese),
_ => {}
}
}
Some(out)
}
/// The writing systems every character of `label` belongs to (Unicode TR 39's resolved script
/// set), skipping characters that `skip` picks. `None` when no character narrows it down.
fn shared_writing(label: &str, skip: impl Fn(&[Writing]) -> bool) -> Option<Vec<Writing>> {
let mut shared: Option<Vec<Writing>> = None;
for systems in label.chars().filter_map(writing_systems) {
if skip(&systems) {
continue;
}
shared = Some(match shared {
None => systems,
Some(s) => s.into_iter().filter(|w| systems.contains(w)).collect(),
});
}
shared
}
/// Whether a label mixes writing systems. Latin alongside Japanese, Korean or Chinese is allowed, as
/// in Unicode TR 39's "highly restrictive" level (`ソニーstore`).
fn mixes_scripts(label: &str) -> bool {
if shared_writing(label, |_| false).is_none_or(|s| !s.is_empty()) {
return false;
}
let latin = Writing::Script(Script::Latin);
let rest = shared_writing(label, |s| s.contains(&latin)).unwrap_or_default();
!rest.iter().any(|w| matches!(w, Writing::Japanese | Writing::Korean | Writing::Chinese))
}
#[cfg(test)]
mod tests {
#[test]
@ -223,4 +422,136 @@ mod tests {
assert_eq!(host("mailto:a@example.org"), None);
assert_eq!(host("https:///nohost"), None);
}
#[test]
fn addresses_the_url_parser_rejects_are_refused() {
use super::{BlockedLink as B, document_url};
// The webbrowser crate would open each of these as a local file path.
for bad in ["mailto://a^b/x", "mailto://a:99999/", "mailto://[x]/", "mailto://a\\b/", "mailto://a b/", "https://a^b/", "http://a<b/"] {
assert_eq!(document_url(bad), Err(B::Malformed), "{bad}");
}
for ok in ["mailto://example.org/x", "mailto:a@example.org?subject=a b"] {
assert_eq!(document_url(ok).as_deref(), Ok(ok), "{ok}");
}
}
#[test]
fn email_links_may_not_ask_for_a_local_file() {
use super::{BlockedLink as B, document_url};
for bad in [
// Evolution, KMail and Claws Mail attach the named file.
"mailto:a@example.org?attach=/home/me/.ssh/id_ed25519",
"mailto:a@example.org?subject=Hi&attachment=file:///etc/passwd",
// Claws Mail reads an `insert` file into the message body.
"mailto:a@example.org?insert=/home/me/notes.txt",
// Names are matched case-insensitively (Evolution, KMail) and after percent-decoding (KMail).
"mailto:a@example.org?ATTACH=x",
"mailto:a@example.org?Attachment=x",
"mailto:a@example.org?%61ttach=x",
"mailto:a@example.org?%2561ttach=x",
"mailto:a@example.org?%49NSERT=x",
"mailto:a@example.org?attach%3D/etc/passwd",
// An empty value, a repeated name, spaces or quotes before `=`.
"mailto:a@example.org?attach=",
"mailto:a@example.org?subject=a&attach=x&attach=y",
"mailto:a@example.org?subject=a& attach =x",
"mailto:a@example.org?subject=a&'attachment'='x'",
// No recipient, no `?` at all (xdg-email), a second `?`, and a `?` that only exists once decoded.
"mailto:?attach=x",
"mailto:a@example.org&attach=/etc/passwd",
"mailto:a@example.org?subject=a?attach=x",
"mailto:a@example.org%3Fattach=x",
// KMail reads the query from the first `?` even inside a fragment, and reads `&#38;` as `&`.
"mailto:a@example.org#?attach=x",
"mailto:a@example.org?subject=a#&attach=x",
"mailto:a@example.org?subject=a&#38;attach=x",
"mailto:a@example.org?subject=a;attach=x",
// An encoded `&`, and a comma (xdg-email copies the subject into a comma-separated list).
"mailto:a@example.org?subject=a%26attach=x",
"mailto:a@example.org?subject=a,attachment='/etc/passwd'",
// A `+`, an encoded tab or an encoded space between the name and `=`.
"mailto:a@example.org?attach+=x",
"mailto:a@example.org?attach%09=x",
"mailto:a@example.org?attach%20=x",
] {
assert_eq!(document_url(bad), Err(B::MailFile), "{bad}");
}
// KMail decodes RFC 2047 encoded words across the whole address before reading it, so
// `=?utf-8?q?attach?=` could become `attach`. Such words never belong in a link.
for bad in [
"mailto:a@example.org?=?utf-8?q?attach?==/etc/passwd",
"mailto:a@example.org?subject==?UTF-8?B?YXR0YWNo?=",
"mailto:a@example.org?subject=%3D%3Futf-8%3Fq%3Fattach%3F%3D",
"mailto:a@example.org?subject==??q?attach?=",
"mailto:a@example.org?subject==?utf-8?base64?YXR0YWNo?=",
// Backslash escapes that `echo` in xdg-email decodes, raw and percent-encoded.
"mailto:a@example.org?\\0141ttach=x",
"mailto:x\\x61ttachment=y",
"mailto:a@example.org?%5C0141ttach=x",
] {
assert_eq!(document_url(bad), Err(B::MailEncodedWord), "{bad}");
}
for ok in [
"mailto:orders@example.org?subject=Order form&body=Please attach the invoice",
"mailto:a@example.org?subject=Attachments%20to%20follow",
"mailto:a@example.org?body=Questions%3F%20Attach%20your%20CV",
"mailto:a@example.org?body=Hi%3B%20insert%20your%20name",
"mailto:a@example.org?cc=b@example.org&bcc=c@example.org&in-reply-to=%3Cid@example.org%3E",
"mailto:attach@example.org",
"mailto:a@example.org?attach",
"mailto:a@example.org?x-attach=1&reattach=2",
"mailto:a@example.org?body=Saved in C:\\Users\\me\\Documents",
] {
assert_eq!(document_url(ok).as_deref(), Ok(ok), "{ok}");
}
}
#[test]
fn hosts_are_shown_as_the_browser_receives_them() {
use super::{BlockedLink as B, HostDisplay, display_host, document_url};
let plain = |a: &str| Some(HostDisplay { ascii: a.into(), international: false, mixed_scripts: false });
assert_eq!(display_host("https://example.org/path"), plain("example.org"));
assert_eq!(display_host("HTTPS://EXAMPLE.ORG/"), plain("example.org"), "lowercased, as the browser does");
assert_eq!(display_host("https://example.org./"), plain("example.org."));
assert_eq!(display_host("https://ex%61mple.org/"), plain("example.org"), "percent-decoded, as the browser does");
assert_eq!(display_host("https://example。org/"), plain("example.org"), "fullwidth letters and `。` are mapped");
assert_eq!(display_host("https://trusted.example@other.example/"), plain("other.example"));
assert_eq!(display_host("https://other.example\\@trusted.example/"), plain("other.example"));
assert_eq!(display_host("mailto:a@example.org"), None);
// Numeric hosts in the form the browser connects to, so a local address can't hide.
assert_eq!(display_host("https://[::1]:8443/"), plain("[::1]"));
assert_eq!(display_host("https://[0:0:0:0:0:0:0:1]/"), plain("[::1]"));
assert_eq!(display_host("http://3232235777/"), plain("192.168.1.1"));
assert_eq!(display_host("http://0x7f.1/"), plain("127.0.0.1"));
assert_eq!(display_host("http://0300.0250.01.01/"), plain("192.168.1.1"));
// International hosts are shown in punycode and marked.
let bucher = display_host("https://bücher.example/").unwrap();
assert_eq!(bucher, HostDisplay { ascii: "xn--bcher-kva.example".into(), international: true, mixed_scripts: false });
assert!(!display_host("https://ソニーstore.example/").unwrap().mixed_scripts, "Latin with Japanese is normal (UTS #39)");
assert!(!display_host("https://example.ελ/").unwrap().mixed_scripts, "scripts are compared within a label, not across labels");
// Cyrillic `а` (U+0430) in a Latin name: punycode, and flagged as mixed.
let lookalike = display_host("https://p\u{0430}ypal.com/").unwrap();
assert_eq!(lookalike, HostDisplay { ascii: "xn--pypal-4ve.com".into(), international: true, mixed_scripts: true });
// The same lookalike written in punycode in the document, and upper-case.
assert_eq!(display_host("https://XN--PYPAL-4VE.com/"), Some(lookalike));
// A whole-script lookalike (all Cyrillic) isn't mixed, but is still shown in punycode, marked.
let cyrillic = display_host("https://\u{0430}\u{0440}\u{0440}\u{04CF}\u{0435}.com/").unwrap();
assert!(cyrillic.ascii.starts_with("xn--") && cyrillic.international && !cyrillic.mixed_scripts, "{cyrillic:?}");
// A host the browser can't turn into an address is refused rather than shown.
for bad in [
"https://xn--a.example/",
"https://exa%FFmple.org/",
"https://\u{0300}a.example/",
"https://%E2%80%AEexample.org/",
"https://trusted.example%40other.example/",
"https://[evil.example]/",
"https://example.org:port/",
"https://example.org:99999/",
] {
assert_eq!(display_host(bad), None, "{bad}");
assert_eq!(document_url(bad), Err(B::Malformed), "{bad}");
}
}
}

View File

@ -0,0 +1,104 @@
//! Optimize PDF ▸ Advanced optimization as a job that reports its progress.
//!
//! Optimizing a large file takes a while (every image is decoded, resampled and re-encoded), so
//! it is split like OCR: [`Session::optimize_job`] captures a copy of the document and the
//! choices, and [`OptimizeJob::run`] works anywhere (the UI runs it on a worker thread),
//! reporting each [`OptimizeStage`] and stopping when asked to.
use std::sync::Arc;
use pdfcraft_cos::{SaveOptions, write_full};
use crate::{DocId, EditError, Hidden, OptimizeReport, Session, guard, optimize};
/// Where an optimization is.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum OptimizeStage {
/// Removing the user data chosen under Discard User Data.
#[default]
Discarding,
/// About to process image `done` (0-based) of `total`.
Images { done: usize, total: usize },
/// Discarding objects and compressing unencoded streams.
CleaningUp,
/// Merging identical fonts and images.
Merging,
/// Writing the compressed copy.
Writing,
}
impl OptimizeStage {
/// How far along the whole run is, from 0 to 1. Images take most of the time; the rest are
/// small fixed steps.
pub fn fraction(self) -> f32 {
match self {
OptimizeStage::Discarding => 0.0,
OptimizeStage::Images { done, total } => 0.05 + 0.75 * if total == 0 { 1.0 } else { done.min(total) as f32 / total as f32 },
OptimizeStage::CleaningUp => 0.8,
OptimizeStage::Merging => 0.85,
OptimizeStage::Writing => 0.9,
}
}
}
/// The optimized copy's bytes and what was done.
pub type Optimized = (Arc<Vec<u8>>, OptimizeReport);
/// What an optimization needs, independent of the session.
pub struct OptimizeJob {
cos: pdfcraft_cos::Document,
settings: optimize::Settings,
discard: Vec<Hidden>,
opts: SaveOptions,
}
impl Session {
/// Capture what optimizing document `id` with `settings` and the Remove Hidden Information
/// `discard` categories needs. A full rewrite: signed documents are refused.
pub fn optimize_job(&self, id: DocId, settings: &optimize::Settings, discard: &[Hidden]) -> Result<OptimizeJob, EditError> {
let doc = self.get(id).ok_or(EditError::NoDocument)?;
if doc.is_signed() {
return Err(EditError::Signed);
}
let editor = doc.editor.as_ref().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?;
Ok(OptimizeJob {
cos: editor.cos.clone(),
settings: settings.clone(),
discard: discard.to_vec(),
opts: SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() },
})
}
}
impl OptimizeJob {
/// Optimize and write the copy. `progress` is called as each stage starts and before each
/// image; returning `false` stops with [`EditError::Cancelled`]. A panic is returned as an
/// error, so a worker thread always reports back.
pub fn run(self, mut progress: impl FnMut(OptimizeStage) -> bool) -> Result<Optimized, EditError> {
guard(move || self.work(&mut progress)).map_err(EditError::Optimize)?
}
fn work(self, progress: &mut dyn FnMut(OptimizeStage) -> bool) -> Result<Optimized, EditError> {
let OptimizeJob { mut cos, settings, discard, opts } = self;
let mut step = |stage| if progress(stage) { Ok(()) } else { Err(EditError::Cancelled) };
step(OptimizeStage::Discarding)?;
let discarded = if discard.is_empty() { Vec::new() } else { pdfcraft_redact::sanitize::remove_hidden(&mut cos, &discard)? };
let report = optimize::optimize_with_progress(&mut cos, &settings, &mut |s| {
step(match s {
optimize::Stage::Images { done, total } => OptimizeStage::Images { done, total },
optimize::Stage::CleanUp => OptimizeStage::CleaningUp,
})
.is_ok()
})
.map_err(|e| match e {
optimize::OptimizeError::Cancelled => EditError::Cancelled,
e => EditError::Optimize(e.to_string()),
})?;
step(OptimizeStage::Merging)?;
let all: Vec<pdfcraft_cos::ObjRef> = cos.object_numbers().into_iter().map(|n| pdfcraft_cos::ObjRef::new(n, cos.generation(n))).collect();
let merged = pdfcraft_organize::dedupe_resources(&mut cos, &all, false);
step(OptimizeStage::Writing)?;
let bytes = write_full(&cos, &opts).map_err(|e| EditError::Write(e.to_string()))?;
Ok((Arc::new(bytes), OptimizeReport { optimize: report, merged, discarded }))
}
}

View File

@ -0,0 +1,327 @@
//! Local image signatures, shared by Fill & Sign and its automation tool.
use std::io::{Cursor, Read};
use std::sync::Arc;
use image::{DynamicImage, ImageDecoder, ImageFormat, ImageReader, Limits};
use pdfcraft_render::PageInfo;
use crate::{Document, Edit, MarkFile, guard};
/// Read-only layers for interactive movement: the page without this signature, and its image.
/// Preparing them never changes the working bytes, dirty flag or undo history.
pub struct ImageSignaturePreview {
pub image: SignatureImage,
pub opacity: f32,
/// The page `/Rotate` the picture is drawn turned back by (see [`pdfcraft_annot::picture_rotation`]).
pub turn: i64,
page: usize,
bytes: Arc<Vec<u8>>,
config: pdfcraft_render::RenderConfig,
}
impl ImageSignaturePreview {
/// One background worker; the UI reuses its page texture throughout a gesture.
pub fn renderer(&self) -> pdfcraft_render::RenderPool {
pdfcraft_render::RenderPool::new(self.bytes.clone(), 1, self.config.clone())
}
/// Synchronous counterpart for automation, with the renderer's normal pixel limits.
pub fn render_background(&self, scale: f32) -> Result<pdfcraft_render::RenderedPage, String> {
if !scale.is_finite() || scale <= 0.0 {
return Err("preview scale must be finite and positive".into());
}
guard(|| {
let mut renderer = pdfcraft_render::PageRenderer::new(self.bytes.clone(), self.config.clone());
let out = renderer.render(pdfcraft_render::RenderRequest { page: self.page, scale, ..Default::default() });
if let Some(e) = &out.error {
return Err(e.clone());
}
Ok(out)
})?
}
}
impl Document {
/// Prepare the actual embedded signature, including alpha, and an independent background.
/// A cheap COS clone excludes only this annotation; other page content remains visible.
pub fn image_signature_preview(&self, page: usize, index: usize) -> Result<Option<ImageSignaturePreview>, String> {
guard(|| {
let editor = self.editor.as_ref().ok_or("the document can't be read")?;
let Some(picture) = pdfcraft_annot::signature_image(&editor.cos, page, index).map_err(|e| e.to_string())? else { return Ok(None) };
let obj = editor.cos.get(picture);
let pdfcraft_cos::Object::Stream(s) = &*obj else { return Err("the signature image is damaged".into()) };
let (w, h) = (s.dict.int(b"Width").unwrap_or(0), s.dict.int(b"Height").unwrap_or(0));
if w <= 0 || h <= 0 || w > 4096 || h > 4096 || w.saturating_mul(h) > MAX_PIXELS as i64 {
return Err(SignatureImageError::Size.to_string());
}
let (_, bytes) = pdfcraft_create::image_file(&editor.cos, picture)?;
let image = SignatureImage::from_bytes(&bytes).map_err(|e| e.to_string())?;
let opacity =
pdfcraft_annot::props(&editor.cos, page, index).map(|p| p.opacity).filter(|v| v.is_finite()).unwrap_or(1.0).clamp(0.0, 1.0) as f32;
let turn = pdfcraft_annot::picture_rotation(&editor.cos, page, index).map_err(|e| e.to_string())?;
let mut cos = editor.cos.clone();
pdfcraft_annot::delete_annotation(&mut cos, page, index).map_err(|e| e.to_string())?;
let bytes = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).map_err(|e| e.to_string())?);
Ok(Some(ImageSignaturePreview { image, opacity, turn, page, bytes, config: self.config.clone() }))
})?
}
}
/// The user-space box `width` x `height` points as displayed on a page turned by `rotation` (its
/// `/Rotate`), with its left edge at `at` (a user-space point) and vertically centred on it. On a
/// quarter-turned page it is `height` wide and `width` tall in user space. `None` for a non-finite
/// point.
pub fn upright_box(rotation: i64, at: [f64; 2], width: f64, height: f64) -> Option<[f64; 4]> {
if !at.iter().chain(&[width, height]).all(|v| v.is_finite()) {
return None;
}
// Right and up as displayed, as user-space vectors: the page's view matrix without its offset.
let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(rotation, [0.0; 4]);
let (right, up) = ([a * width, b * width], [c * height / 2.0, d * height / 2.0]);
Some([
at[0] + right[0].min(0.0) - up[0].abs(),
at[1] + right[1].min(0.0) - up[1].abs(),
at[0] + right[0].max(0.0) + up[0].abs(),
at[1] + right[1].max(0.0) + up[1].abs(),
])
}
/// Saved signatures live in settings, so both source and normalized PNG are bounded.
pub const MAX_SIGNATURE_IMAGE_BYTES: usize = 4 << 20;
const MAX_PIXELS: u64 = 4 << 20;
#[derive(Debug, thiserror::Error)]
pub enum SignatureImageError {
#[error("Choose a PNG or JPEG image.")]
Format,
#[error("Choose an image smaller than 4 MiB and 4 megapixels (at most 4096 pixels per side).")]
Size,
#[error("Couldn't read the signature image: {0}")]
Read(#[from] std::io::Error),
#[error("Couldn't decode the signature image: {0}")]
Decode(#[from] image::ImageError),
}
/// A validated image, normalized to PNG without source paths or metadata. Alpha is preserved.
#[derive(Clone, Debug, PartialEq)]
pub struct SignatureImage {
bytes: Arc<Vec<u8>>,
rgba: Arc<Vec<u8>>,
size: [usize; 2],
}
impl SignatureImage {
/// Read at most the file limit plus one byte, even if the file grows while being read.
pub fn read(reader: impl Read) -> Result<Self, SignatureImageError> {
let mut bytes = Vec::new();
reader.take(MAX_SIGNATURE_IMAGE_BYTES as u64 + 1).read_to_end(&mut bytes)?;
Self::from_bytes(&bytes)
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self, SignatureImageError> {
if bytes.len() > MAX_SIGNATURE_IMAGE_BYTES {
return Err(SignatureImageError::Size);
}
let format = image::guess_format(bytes).map_err(|_| SignatureImageError::Format)?;
if !matches!(format, ImageFormat::Png | ImageFormat::Jpeg) {
return Err(SignatureImageError::Format);
}
let mut reader = ImageReader::with_format(Cursor::new(bytes), format);
let mut limits = Limits::default();
limits.max_image_width = Some(4096);
limits.max_image_height = Some(4096);
limits.max_alloc = Some(32 << 20);
reader.limits(limits);
let mut decoder = reader.into_decoder()?;
let (w, h) = decoder.dimensions();
if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_PIXELS {
return Err(SignatureImageError::Size);
}
let orientation = decoder.orientation()?;
let mut image = DynamicImage::from_decoder(decoder)?;
image.apply_orientation(orientation);
let rgba = image.into_rgba8();
let size = [rgba.width() as usize, rgba.height() as usize];
let mut png = Cursor::new(Vec::new());
DynamicImage::ImageRgba8(rgba.clone()).write_to(&mut png, ImageFormat::Png)?;
let bytes = png.into_inner();
if bytes.len() > MAX_SIGNATURE_IMAGE_BYTES {
return Err(SignatureImageError::Size);
}
Ok(Self { bytes: Arc::new(bytes), rgba: Arc::new(rgba.into_raw()), size })
}
pub fn bytes(&self) -> &Arc<Vec<u8>> {
&self.bytes
}
pub fn rgba(&self) -> &[u8] {
&self.rgba
}
pub fn size(&self) -> [usize; 2] {
self.size
}
/// The user-space box for the image with its left edge at `at` (a user-space point on `page`),
/// vertically centered, with the same 150 pt width cap as typed names. Left and centered are
/// as displayed: on a page with `/Rotate` the box runs along the turned page's axes.
pub fn rect(&self, page: &PageInfo, at: [f64; 2], initials: bool) -> Option<[f64; 4]> {
if !at.iter().all(|v| v.is_finite()) {
return None;
}
let [w, h] = self.size;
let scale = (150.0 / w as f64).min(if initials { 24.0 } else { 32.0 } / h as f64);
upright_box(i64::from(page.rotation), at, w as f64 * scale, h as f64 * scale)
}
pub fn edit(&self, page: usize, info: &PageInfo, at: [f64; 2], initials: bool, author: &str) -> Option<Edit> {
let rect = self.rect(info, at, initials)?;
let label = if initials { "Add initials" } else { "Add signature" };
Some(Edit::Batch {
label: label.into(),
edits: vec![Edit::AddCustomStamp {
page,
rect,
name: if initials { "Initials" } else { "Signature" }.into(),
file: MarkFile { name: "signature.png".into(), bytes: self.bytes.clone(), page: 0 },
author: author.into(),
}],
})
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A 300 x 400 page shown turned `rotation` degrees clockwise.
fn page(rotation: u16) -> PageInfo {
let (width, height) = if rotation.is_multiple_of(180) { (300.0, 400.0) } else { (400.0, 300.0) };
PageInfo { width, height, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation }
}
fn png(w: u32, h: u32) -> Vec<u8> {
let image = image::RgbaImage::from_pixel(w, h, image::Rgba([40, 60, 80, 128]));
let mut out = Cursor::new(Vec::new());
image.write_to(&mut out, ImageFormat::Png).unwrap();
out.into_inner()
}
#[test]
fn image_signatures_preserve_alpha_and_fit_the_signature_box() {
let image = SignatureImage::from_bytes(&png(400, 40)).unwrap();
assert_eq!(image.size(), [400, 40]);
assert_eq!(&image.rgba()[..4], &[40, 60, 80, 128]);
let Edit::Batch { edits, .. } = image.edit(0, &page(0), [20.0, 100.0], false, "Ada").unwrap() else { panic!() };
let Edit::AddCustomStamp { rect, .. } = &edits[0] else { panic!() };
assert_eq!(*rect, [20.0, 92.5, 170.0, 107.5]);
assert!(image.edit(0, &page(0), [f64::NAN, 0.0], false, "").is_none());
assert_eq!(SignatureImage::from_bytes(image.bytes()).unwrap(), image);
}
#[test]
fn invalid_and_oversized_signature_images_are_rejected() {
assert!(SignatureImage::from_bytes(b"not an image").is_err());
assert!(SignatureImage::from_bytes(b"\x89PNG\r\n\x1a\ntruncated").is_err());
assert!(SignatureImage::from_bytes(&vec![0; MAX_SIGNATURE_IMAGE_BYTES + 1]).is_err());
assert!(SignatureImage::from_bytes(&png(4097, 1)).is_err());
assert!(SignatureImage::from_bytes(&png(4096, 1025)).is_err());
assert!(SignatureImage::read(std::io::repeat(0)).is_err());
assert!(matches!(SignatureImage::from_bytes(b"GIF89a\x02\0\x02\0"), Err(SignatureImageError::Format)));
}
#[test]
fn jpeg_signatures_are_normalized_to_png() {
let mut jpeg = Cursor::new(Vec::new());
image::RgbImage::from_pixel(60, 20, image::Rgb([20, 20, 20])).write_to(&mut jpeg, ImageFormat::Jpeg).unwrap();
let image = SignatureImage::from_bytes(&jpeg.into_inner()).unwrap();
assert_eq!(image.size(), [60, 20]);
assert!(image.bytes().starts_with(b"\x89PNG\r\n\x1a\n"));
assert!(image.rgba().as_chunks::<4>().0.iter().all(|p| p[3] == 255));
}
#[test]
fn jpeg_signature_orientation_is_applied_before_preview_and_embedding() {
let mut jpeg = Cursor::new(Vec::new());
image::RgbImage::from_pixel(60, 20, image::Rgb([20, 20, 20])).write_to(&mut jpeg, ImageFormat::Jpeg).unwrap();
let jpeg = jpeg.into_inner();
// Synthetic EXIF IFD with Orientation=6 (90 degrees clockwise).
let exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0\x06\0\0\0\0\0\0\0";
let mut rotated = jpeg[..2].to_vec();
rotated.extend_from_slice(&[0xff, 0xe1]);
rotated.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes());
rotated.extend_from_slice(exif);
rotated.extend_from_slice(&jpeg[2..]);
let image = SignatureImage::from_bytes(&rotated).unwrap();
assert_eq!(image.size(), [20, 60]);
assert_eq!(SignatureImage::from_bytes(image.bytes()).unwrap().size(), [20, 60]);
assert!(!image.bytes().windows(4).any(|w| w == b"Exif"));
}
#[test]
fn very_wide_image_signatures_keep_the_requested_rectangle() {
let image = SignatureImage::from_bytes(&png(3000, 10)).unwrap();
let mut session = crate::Session::new();
let blank = session.create_blank(300.0, 400.0, 1).unwrap();
let id = session.open_new("form.pdf", blank).unwrap();
session.apply(id, image.edit(0, &page(0), [20.0, 100.0], false, "Ada").unwrap()).unwrap();
let a = &session.get(id).unwrap().info.annotations[0];
assert_eq!(a.rect, [20.0, 99.75, 170.0, 100.25]);
}
#[test]
fn embedded_signature_preview_preserves_alpha_without_editing_the_document() {
let image = SignatureImage::from_bytes(&png(120, 40)).unwrap();
let mut session = crate::Session::new();
let blank = session.create_blank(300.0, 400.0, 1).unwrap();
let id = session.open_new("form.pdf", blank).unwrap();
session.apply(id, image.edit(0, &page(0), [20.0, 100.0], false, "Ada").unwrap()).unwrap();
let doc = session.get(id).unwrap();
let bytes = doc.bytes.clone();
let generation = doc.edit_generation();
let preview = doc.image_signature_preview(0, 0).unwrap().unwrap();
assert_eq!(preview.image.rgba(), image.rgba());
let background = preview.render_background(1.0).unwrap();
assert!(background.rgba.as_chunks::<4>().0.iter().all(|p| *p == [255, 255, 255, 255]));
assert!(Arc::ptr_eq(&bytes, &doc.bytes));
assert_eq!(doc.edit_generation(), generation);
assert_eq!(doc.can_undo(), Some("Add signature"));
assert!(doc.image_signature_preview(0, 1).is_err());
assert!(preview.render_background(0.0).is_err());
}
#[test]
fn oversized_embedded_signature_preview_is_rejected_before_decoding() {
let image = SignatureImage::from_bytes(&png(120, 40)).unwrap();
let mut session = crate::Session::new();
let blank = session.create_blank(300.0, 400.0, 1).unwrap();
let id = session.open_new("form.pdf", blank).unwrap();
session.apply(id, image.edit(0, &page(0), [20.0, 100.0], false, "Ada").unwrap()).unwrap();
let doc = session.doc_mut(id).unwrap();
let cos = &mut doc.editor.as_mut().unwrap().cos;
let picture = pdfcraft_annot::signature_image(cos, 0, 0).unwrap().unwrap();
let pdfcraft_cos::Object::Stream(mut stream) = (*cos.get(picture)).clone() else { panic!() };
stream.dict.set(b"Width".to_vec(), pdfcraft_cos::Object::Int(i64::MAX));
cos.set(picture, pdfcraft_cos::Object::Stream(stream));
let error = doc.image_signature_preview(0, 0).err().unwrap();
assert!(error.contains("4 megapixels"), "{error}");
}
/// "Left edge at the point, centered vertically" is as displayed: on a turned page the box
/// is another one in user space. The image is 64 x 32 pt; the displayed point is (60, 100).
#[test]
fn the_signature_box_is_laid_out_as_displayed_on_turned_pages() {
let image = SignatureImage::from_bytes(&png(80, 40)).unwrap();
for (rotation, at, want) in [
(0, [60.0, 300.0], [60.0, 284.0, 124.0, 316.0]),
(90, [100.0, 60.0], [84.0, 60.0, 116.0, 124.0]),
(180, [240.0, 100.0], [176.0, 84.0, 240.0, 116.0]),
(270, [200.0, 340.0], [184.0, 276.0, 216.0, 340.0]),
] {
let rect = image.rect(&page(rotation), at, false).unwrap();
assert!(rect.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01), "{rotation}: {rect:?}, want {want:?}");
}
}
}

View File

@ -36,6 +36,70 @@ fn session_with(n: usize) -> (Session, DocId) {
(s, id)
}
#[test]
fn custom_image_stamp_keeps_displayed_orientation_on_rotated_pages() {
let colours = [[240, 20, 20, 255], [20, 180, 20, 255], [20, 20, 240, 255], [230, 180, 20, 255]];
let image = image::RgbaImage::from_fn(80, 40, |x, y| image::Rgba(colours[usize::from(y >= 20) * 2 + usize::from(x >= 40)]));
let mut png = std::io::Cursor::new(Vec::new());
image.write_to(&mut png, image::ImageFormat::Png).unwrap();
let bytes = Arc::new(png.into_inner());
for degrees in [0, 90, 180, 270] {
let (mut session, id) = session_with(1);
session.apply(id, Edit::RotatePages { pages: vec![0], degrees }).unwrap();
let at = session.get(id).unwrap().info.pages[0].view_to_user(100.0, 100.0).map(f64::from);
session
.apply(
id,
Edit::AddCustomStamp {
page: 0,
rect: [at[0], at[1], at[0], at[1]],
name: "Original quadrants".into(),
file: MarkFile { name: "quadrants.png".into(), bytes: bytes.clone(), page: 0 },
author: "Test".into(),
},
)
.unwrap();
let check = |session: &Session, points: [(u32, u32); 4]| {
let doc = session.get(id).unwrap();
let mut renderer = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone());
let rendered = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() });
assert!(rendered.error.is_none(), "{:?}", rendered.error);
for ((x, y), colour) in points.into_iter().zip(colours) {
let offset = ((y * rendered.width + x) * 4) as usize;
assert_eq!(&rendered.rgba[offset..offset + 4], &colour, "page rotation {degrees}; displayed point ({x}, {y})");
}
};
let points = [(80, 90), (120, 90), (80, 110), (120, 110)];
check(&session, points);
let info = session.get(id).unwrap().info.pages[0].clone();
let rect = session.get(id).unwrap().info.annotations[0].rect;
let a = info.user_to_view(rect[0], rect[1]);
let b = info.user_to_view(rect[2], rect[3]);
let bounds = [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])];
for (actual, expected) in bounds.into_iter().zip([60.0, 80.0, 140.0, 120.0]) {
assert!((actual - expected).abs() < 0.0001, "displayed bounds {bounds:?}");
}
session.apply(id, Edit::StyleAnnotation { page: 0, index: 0, color: None, opacity: Some(1.0), width: None, endings: None }).unwrap();
check(&session, points);
let resized = info.view_rect_to_user([40.0, 70.0, 160.0, 130.0]).map(f64::from);
session.apply(id, Edit::ResizeAnnotation { page: 0, index: 0, rect: resized }).unwrap();
let resized_points = [(70, 85), (130, 85), (70, 115), (130, 115)];
check(&session, resized_points);
session.undo(id).unwrap();
check(&session, points);
session.redo(id).unwrap();
check(&session, resized_points);
let from = info.view_to_user(100.0, 100.0);
let to = info.view_to_user(110.0, 115.0);
session.apply(id, Edit::MoveAnnotation { page: 0, index: 0, dx: f64::from(to[0] - from[0]), dy: f64::from(to[1] - from[1]) }).unwrap();
check(&session, [(80, 100), (140, 100), (80, 130), (140, 130)]);
session.undo(id).unwrap();
check(&session, resized_points);
session.redo(id).unwrap();
check(&session, [(80, 100), (140, 100), (80, 130), (140, 130)]);
}
}
fn page_texts(s: &Session, id: DocId) -> Vec<String> {
let doc = s.get(id).unwrap();
let config = pdfcraft_render::RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() };
@ -297,6 +361,46 @@ fn combine_extract_split_and_insert_from_file() {
assert!(matches!(bad, Err(EditError::Source(_))));
}
/// A minimal baseline JPEG (headers only; the data is embedded as is).
fn jpeg_bytes() -> Vec<u8> {
let mut v = vec![0xFF, 0xD8];
v.extend_from_slice(&[0xFF, 0xE0, 0, 16, b'J', b'F', b'I', b'F', 0, 1, 1, 1, 0x01, 0x2C, 0x01, 0x2C, 0, 0]);
v.extend_from_slice(&[0xFF, 0xC0, 0, 17, 8, 0, 2, 0, 3, 3, 1, 0x11, 0, 2, 0x11, 1, 3, 0x11, 1]);
v.extend_from_slice(&[0xFF, 0xD9]);
v
}
#[test]
fn mixed_files_convert_and_combine_in_order() {
let mut s = Session::new();
let pdf = Arc::new(fixture(2));
let (kind, same) = s.convert_to_pdf("a.pdf", &pdf).unwrap();
assert_eq!(kind, SourceKind::Pdf);
assert!(Arc::ptr_eq(&same, &pdf), "a PDF is passed through untouched");
let (kind, image) = s.convert_to_pdf("scan.jpg", &Arc::new(jpeg_bytes())).unwrap();
assert_eq!(kind, SourceKind::Image);
let (kind, text) = s.convert_to_pdf("notes.txt", &Arc::new(b"hello".to_vec())).unwrap();
assert_eq!(kind, SourceKind::Text);
let combined = s.combine_ranges(&[("notes".into(), text, None), ("a".into(), pdf, Some("2".into())), ("scan".into(), image, None)]).unwrap();
let id = s.open_new("Combined.pdf", combined).unwrap();
assert_eq!(page_texts(&s, id), ["hello", "Page 2", ""]);
assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["notes→1", "a→2", "scan→3"]);
}
#[test]
fn files_that_cannot_be_converted_are_refused_clearly() {
let s = Session::new();
let err = s.convert_to_pdf("report.docx", &Arc::new(b"PK\x03\x04".to_vec())).unwrap_err();
assert_eq!(err, EditError::Source("report.docx: this file type can't be converted; use a PDF, an image or a .txt file".into()));
assert!(matches!(s.convert_to_pdf("empty", &Arc::new(Vec::new())), Err(EditError::Source(_))));
// Damaged inputs of a known type fail with an error, not a panic.
assert!(s.convert_to_pdf("bad.png", &Arc::new(b"\x89PNG\r\n\x1a\nnope".to_vec())).is_err());
assert!(s.convert_to_pdf("bad.jpg", &Arc::new(vec![0xFF, 0xD8, 0xFF])).is_err());
assert!(s.convert_to_pdf("bad.pdf", &Arc::new(b"%PDF-1.7 nope".to_vec())).is_err());
let err = s.convert_to_pdf("locked.pdf", &protected("pw", "o", -1)).unwrap_err();
assert_eq!(err, EditError::Source("locked.pdf: it is password-protected".into()));
}
fn protected(user: &str, owner: &str, permissions: i32) -> Arc<Vec<u8>> {
let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(2))).unwrap();
doc.set_encryption(&pdfcraft_cos::NewEncryption {
@ -631,6 +735,38 @@ fn permissions_password_restricts_others_but_not_this_session() {
assert!(s2.get(id3).unwrap().allows_security_change());
}
#[test]
fn combine_source_check_says_why_a_file_cannot_be_combined() {
let (mut s, id) = session_with(1);
let plain = s.get(id).unwrap().bytes.clone();
assert_eq!(crate::combine_source_check(&plain, None), Ok(()));
// Page assembly withheld (Changes::None) without a password to open it.
let p = Protection { changes: Changes::None, ..protection(None, Some("boss")) };
s.apply(id, Edit::Protect(p)).unwrap();
let locked = s.save_bytes(id).unwrap();
assert_eq!(crate::combine_source_check(&locked, None), Err(crate::SourceProblem::NotPermitted));
let (mut s, id) = session_with(1);
s.apply(id, Edit::Protect(protection(Some("pw"), Some("owner")))).unwrap();
let secret = s.save_bytes(id).unwrap();
assert_eq!(crate::combine_source_check(&secret, None), Err(crate::SourceProblem::Password));
assert_eq!(crate::combine_source_check(&secret, Some("nope")), Err(crate::SourceProblem::WrongPassword));
// Its open password reads it, but its permissions withhold assembling pages: the owner's
// password allows it.
assert_eq!(crate::combine_source_check(&secret, Some("pw")), Err(crate::SourceProblem::NotPermitted));
assert_eq!(crate::combine_source_check(&secret, Some("owner")), Ok(()));
// The permissions password lifts the restriction on copying pages.
assert_eq!(crate::combine_source_check(&locked, Some("boss")), Ok(()));
// Combining with the passwords: the result opens without any.
let s = Session::new();
let sources = vec![("secret".to_string(), secret, None), ("locked".to_string(), locked, None)];
assert!(s.combine_ranges(&sources).is_err(), "no passwords, no combining");
let out = s.combine_unlocked(&sources, &[Some("owner"), Some("boss")]).unwrap();
let combined = pdfcraft_cos::Document::open(out).unwrap();
assert!(combined.permissions().is_none(), "the combined file is not encrypted");
assert_eq!(pdfcraft_organize::page_count(&combined).unwrap(), 2);
assert!(matches!(crate::combine_source_check(&Arc::new(b"not a pdf".to_vec()), None), Err(crate::SourceProblem::Unreadable(_))));
}
#[test]
fn protection_is_validated_undoable_and_never_logged() {
let (mut s, id) = session_with(1);
@ -915,6 +1051,39 @@ fn create_and_reduce() {
assert_eq!(page_texts(&s3, r), ["Page 1", "Page 1"]);
}
#[test]
fn an_optimize_job_reports_its_stages_and_can_be_cancelled() {
use crate::optimizer::OptimizeStage;
let (s, id) = session_with(2);
let settings = optimize::Settings::default();
let mut stages = Vec::new();
let (bytes, _) = s
.optimize_job(id, &settings, &[])
.unwrap()
.run(|st| {
stages.push(st);
true
})
.unwrap();
assert!(bytes.starts_with(b"%PDF-"));
assert_eq!(stages.first(), Some(&OptimizeStage::Discarding));
assert_eq!(&stages[stages.len() - 3..], [OptimizeStage::CleaningUp, OptimizeStage::Merging, OptimizeStage::Writing]);
let fractions: Vec<f32> = stages.iter().map(|st| st.fraction()).collect();
assert!(fractions.windows(2).all(|w| w[0] <= w[1]), "the bar never goes back: {fractions:?}");
assert!(fractions.iter().all(|f| (0.0..=1.0).contains(f)));
assert_eq!(
OptimizeStage::Images { done: 0, total: 0 }.fraction(),
OptimizeStage::Images { done: 5, total: 5 }.fraction(),
"no images: no division by zero"
);
// Cancelled at the merge: nothing is written, the open document is untouched.
let before = s.get(id).unwrap().bytes.clone();
let r = s.optimize_job(id, &settings, &[]).unwrap().run(|st| st != OptimizeStage::Merging);
assert!(matches!(r, Err(EditError::Cancelled)), "{r:?}");
assert_eq!(s.get(id).unwrap().bytes, before);
}
#[test]
fn flattening_keeps_the_look_and_drops_the_objects() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
@ -1482,6 +1651,39 @@ fn exporting_office_files_keeps_images() {
assert!(String::from_utf8(d.export_office(compare::OfficeFormat::Html)).unwrap().contains("data:image/png;base64,"));
}
#[test]
fn exporting_office_files_keeps_text_colour() {
// #526: a dark green heading came out black in Word.
let content = "BT /F1 24 Tf 0.05 0.23 0.18 rg 72 700 Td (Annual Report) Tj ET \
BT /F1 11 Tf 0 g 72 650 Td (Black body text that is long enough to be the body size.) Tj ET";
let objs = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>".into(),
];
let mut pdf = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = pdf.len();
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
let mut s = Session::new();
let id = s.open("c.pdf", None, Arc::new(pdf), None).unwrap();
let d = s.get(id).unwrap();
let html = String::from_utf8(d.export_office(compare::OfficeFormat::Html)).unwrap();
assert!(html.contains("style=\"color:#0D3B2E\">Annual Report"), "{html}");
let blocks = &d.export_pages()[0].blocks;
assert!(blocks.iter().any(|b| b.text == "Annual Report" && b.color == [0.05, 0.23, 0.18]), "{blocks:?}");
}
#[test]
fn guard_turns_a_panic_into_an_error() {
assert_eq!(guard(|| 7), Ok(7));
@ -1652,6 +1854,68 @@ fn xfa_data_no_field_binds_to_survives_an_edit_and_save() {
assert!(doc.xfa_warnings.iter().any(|w| w.contains("structured content")));
}
#[test]
fn xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save() {
let data = "<form><page1><country>Japan</country><langs>English\nSpanish</langs></page1></form>";
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::fields_template(data)));
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("fields.pdf", None, bytes, None).expect("opens");
let field = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).cloned().unwrap_or_else(|| panic!("no field {n}"));
use pdfcraft_forms::FieldKind as K;
// The forms layer sees a combo box with its options and the data's saved value, a
// multi-select list with both picks, an editable combo, a password field and a signature.
let country = field(&s, "country");
assert_eq!((country.kind, country.value.clone()), (K::Combo, vec!["JP".to_string()]));
assert_eq!(country.options, vec![("CA".into(), "Canada".into()), ("FR".into(), "France".into()), ("JP".into(), "Japan".into())]);
let langs = field(&s, "langs");
assert_eq!(langs.kind, K::List);
assert!(langs.has(pdfcraft_forms::flags::MULTI_SELECT));
assert_eq!(langs.value, vec!["English".to_string(), "Spanish".to_string()]);
assert!(field(&s, "other").has(pdfcraft_forms::flags::EDIT));
assert!(field(&s, "pin").has(pdfcraft_forms::flags::PASSWORD));
assert_eq!(field(&s, "sign").kind, K::Signature);
// Scripts read a choice list's saved value.
assert_eq!(field(&s, "summary").value, vec!["Country: JP".to_string()], "{:?}", s.take_js_output(id));
// Choosing another item recalculates; the list takes several values; a typed value is kept
// in the editable list and refused in the fixed one.
s.apply(id, Edit::SetFieldValue { name: "country".into(), value: FieldValue::Choice(vec!["CA".into()]) }).unwrap();
assert_eq!(field(&s, "summary").value, vec!["Country: CA".to_string()]);
s.apply(id, Edit::SetFieldValue { name: "langs".into(), value: FieldValue::Choice(vec!["French".into(), "Spanish".into()]) }).unwrap();
s.apply(id, Edit::SetFieldValue { name: "other".into(), value: FieldValue::Text("typed".into()) }).unwrap();
assert!(s.apply(id, Edit::SetFieldValue { name: "country".into(), value: FieldValue::Choice(vec!["Mars".into()]) }).is_err());
// Saved: the datasets hold the saved values, several on separate lines; reopened, the
// fields and the calculation are what they were.
let saved = s.save_bytes(id).unwrap();
let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap();
let d = pdfcraft_xfa::data_of(&cos).unwrap();
assert_eq!(d.text_at(&pdfcraft_xfa::som_to_path("form[0].page1[0].country[0]")), Some("CA"));
assert_eq!(d.text_at(&pdfcraft_xfa::som_to_path("form[0].page1[0].langs[0]")), Some("French\nSpanish"));
assert_eq!(d.text_at(&pdfcraft_xfa::som_to_path("form[0].page1[0].other[0]")), Some("typed"));
let id2 = s.open("again.pdf", None, saved.clone(), None).unwrap();
let d2 = s.get(id2).unwrap();
let f2 = |n: &str| d2.form.iter().find(|f| f.name == n).unwrap().value.clone();
assert_eq!(f2("country"), vec!["CA".to_string()]);
assert_eq!(f2("langs"), vec!["French".to_string(), "Spanish".to_string()]);
assert_eq!(f2("summary"), vec!["Country: CA".to_string()]);
// The PNG of the image field and the GIF of the draw are pictures on the page.
assert_eq!(s.get(id).unwrap().page_images(0).len(), 2);
// Filled in by another viewer since: saved values, several on separate lines, and shown
// text (an older viewer's), are taken on reopen; shown text that means the current value
// is not a change.
let mut cos = pdfcraft_cos::Document::open(saved).unwrap();
pdfcraft_xfa::write_data_value(&mut cos, &pdfcraft_xfa::som_to_path("form[0].page1[0].langs[0]"), "English\nSpanish").unwrap();
pdfcraft_xfa::write_data_value(&mut cos, &pdfcraft_xfa::som_to_path("form[0].page1[0].country[0]"), "Canada").unwrap();
let edited = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).unwrap());
let id3 = s.open("elsewhere.pdf", None, edited, None).unwrap();
let d3 = s.get(id3).unwrap();
let f3 = |n: &str| d3.form.iter().find(|f| f.name == n).unwrap().value.clone();
assert_eq!(f3("langs"), vec!["English".to_string(), "Spanish".to_string()]);
assert_eq!(f3("country"), vec!["CA".to_string()]);
let taken: Vec<&String> = d3.info.warnings.iter().filter(|w| w.contains("another viewer")).collect();
assert_eq!(taken.len(), 1, "{:?}", d3.info.warnings);
assert!(taken[0].contains("langs") && !taken[0].contains("country"), "{}", taken[0]);
}
#[test]
fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() {
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template()));
@ -1937,3 +2201,69 @@ fn runaway_xfa_calculations_at_open_end_quickly_with_a_report() {
assert!(started.elapsed() < std::time::Duration::from_secs(2));
assert!(s.take_js_output(id).errors.is_empty());
}
/// The page view's raster of `page` at 1 px/pt (the document's own render pool).
fn shown(s: &Session, id: DocId, page: usize) -> pdfcraft_render::RenderedPage {
let doc = s.get(id).unwrap();
doc.renderer.set_queue(vec![pdfcraft_render::RenderRequest { page, scale: 1.0, ..Default::default() }]);
let start = std::time::Instant::now();
loop {
if let Some(p) = doc.renderer.try_recv() {
assert!(p.error.is_none(), "{:?}", p.error);
return p;
}
assert!(start.elapsed() < std::time::Duration::from_secs(20), "no render");
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
/// #260 (page 7): comments without an appearance stream, here a FreeText box (`/C` background,
/// blue `/DA` text) and an Ink stroke, weren't drawn. The page view now draws them from
/// appearances made for display only; the working file and what Save writes stay as they were.
#[test]
fn comments_without_appearances_are_drawn_but_not_saved() {
let objs: Vec<&[u8]> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>",
b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>",
b"<< /Type /Annot /Subtype /FreeText /Rect [20 200 180 260] /Contents (Box) /DA (/Helv 10 Tf 0 0 1 rg) /Border [0 0 1] /C [.85 .47 .02] >>",
b"<< /Type /Annot /Subtype /Ink /Rect [20 20 180 120] /InkList [[30 30 100 110 170 30]] /Border [0 0 4] /C [0 .6 0] >>",
];
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
bytes.extend_from_slice(o);
bytes.extend_from_slice(b"\nendobj\n");
}
let xref = bytes.len();
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
let original = Arc::new(bytes);
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("comments.pdf", None, original.clone(), None).expect("opens");
let check = |s: &Session| {
let p = shown(s, id, 0);
let px = |x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec();
// y down: the FreeText box spans rows 40..100, the Ink apex is at (100, 190).
assert_eq!(px(100, 90), vec![217, 120, 5, 255], "the FreeText background");
// On the apex row: Ink is drawn as a curve through its points (#374), level at the apex.
assert_eq!(px(100, 190), vec![0, 153, 0, 255], "the Ink stroke");
};
check(&s);
let doc = s.get(id).unwrap();
assert!(Arc::ptr_eq(&doc.bytes, &original), "the working file is the file as opened");
assert!(!Arc::ptr_eq(&doc.display, &doc.bytes) && Arc::ptr_eq(&doc.export_source().bytes, &doc.display), "page images show it too");
// After an edit (a scoped refresh) they are still drawn, and Save writes no appearances.
s.apply(id, rect_comment(0, [150.0, 270.0, 190.0, 290.0])).unwrap();
check(&s);
let saved = s.save_bytes(id).unwrap();
let reopened = pdfcraft_cos::Document::open(saved).unwrap();
for r in [pdfcraft_cos::ObjRef::new(4, 0), pdfcraft_cos::ObjRef::new(5, 0)] {
assert!(reopened.get(r).as_dict().is_some_and(|d| !d.contains(b"AP")), "{r:?} is saved without /AP");
}
}

View File

@ -0,0 +1,133 @@
//! Photos from phones are stored sideways with an EXIF Orientation tag. PDF image XObjects have
//! no such tag, so a JPEG embedded as it is would show up turned. Images placed into a page
//! (stamps, added images, replacements) go through here first.
use std::borrow::Cow;
use std::io::Cursor;
use image::codecs::jpeg::{JpegEncoder, PixelDensity, PixelDensityUnit};
use image::metadata::Orientation;
use image::{DynamicImage, ImageDecoder, ImageFormat, ImageReader};
/// Quality of a re-encoded JPEG (the camera's own is rarely higher).
const QUALITY: u8 = 92;
/// Larger photos are embedded as they are: turning them would take hundreds of MB.
const MAX_PIXELS: u64 = 64 << 20;
/// A JPEG whose EXIF Orientation is not "as stored" has its pixels turned (and re-encoded);
/// everything else, and any JPEG this can't read, is returned unchanged.
pub(crate) fn upright(bytes: &[u8]) -> Cow<'_, [u8]> {
match turned(bytes) {
Some(out) => Cow::Owned(out),
None => Cow::Borrowed(bytes),
}
}
fn turned(bytes: &[u8]) -> Option<Vec<u8>> {
if !bytes.starts_with(&[0xFF, 0xD8]) {
return None;
}
let mut decoder = ImageReader::with_format(Cursor::new(bytes), ImageFormat::Jpeg).into_decoder().ok()?;
let orientation = decoder.orientation().ok()?;
let (w, h) = decoder.dimensions();
if orientation == Orientation::NoTransforms || u64::from(w) * u64::from(h) > MAX_PIXELS {
return None;
}
let mut image = DynamicImage::from_decoder(decoder).ok()?;
image.apply_orientation(orientation);
let mut out = Vec::new();
{
let mut encoder = JpegEncoder::new_with_quality(&mut out, QUALITY);
// Keep the resolution, which sets the image's natural size on the page.
if let Some(mut density) = jfif_density(bytes) {
if matches!(orientation, Orientation::Rotate90 | Orientation::Rotate270 | Orientation::Rotate90FlipH | Orientation::Rotate270FlipH) {
density.density = (density.density.1, density.density.0);
}
encoder.set_pixel_density(density);
}
match &image {
DynamicImage::ImageLuma8(gray) => encoder.encode_image(gray).ok()?,
_ => encoder.encode_image(&image.to_rgb8()).ok()?,
}
}
Some(out)
}
/// The resolution in a JFIF header, if the file has one in inches or centimetres.
fn jfif_density(bytes: &[u8]) -> Option<PixelDensity> {
let mut i = 2usize;
while let Some(&[0xFF, marker, hi, lo]) = bytes.get(i..i.saturating_add(4)) {
let len = usize::from(u16::from_be_bytes([hi, lo]));
let seg = bytes.get(i + 4..i + 2 + len)?;
if marker == 0xE0 && seg.starts_with(b"JFIF\0") {
let &[unit, x0, x1, y0, y1] = seg.get(7..12)? else { return None };
let unit = match unit {
1 => PixelDensityUnit::Inches,
2 => PixelDensityUnit::Centimeters,
_ => return None,
};
return Some(PixelDensity { density: (u16::from_be_bytes([x0, x1]), u16::from_be_bytes([y0, y1])), unit });
}
if marker == 0xDA {
return None;
}
i += 2 + len;
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Edit, MarkFile};
/// A 60 x 20 JPEG at `dpi` x 72 dpi, tagged with EXIF `orientation` (6: shown turned 90
/// degrees clockwise).
fn phone_jpeg(orientation: u8, dpi: u16) -> Vec<u8> {
let mut jpeg = Vec::new();
let mut encoder = JpegEncoder::new(&mut jpeg);
encoder.set_pixel_density(PixelDensity { density: (dpi, 72), unit: PixelDensityUnit::Inches });
encoder.encode_image(&image::RgbImage::from_pixel(60, 20, image::Rgb([20, 20, 20]))).unwrap();
let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0".to_vec();
exif.extend_from_slice(&[orientation, 0, 0, 0, 0, 0, 0, 0]);
let mut out = jpeg[..2].to_vec();
out.extend_from_slice(&[0xff, 0xe1]);
out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes());
out.extend_from_slice(&exif);
out.extend_from_slice(&jpeg[2..]);
out
}
#[test]
fn orientation_is_applied_and_plain_images_are_left_alone() {
let photo = phone_jpeg(6, 144);
let turned = upright(&photo);
assert!(matches!(turned, Cow::Owned(_)));
let decoded = image::load_from_memory(&turned).unwrap();
assert_eq!((decoded.width(), decoded.height()), (20, 60));
assert_eq!(jfif_density(&turned), Some(PixelDensity { density: (72, 144), unit: PixelDensityUnit::Inches }));
let stored = phone_jpeg(1, 72);
assert!(matches!(upright(&stored), Cow::Borrowed(_)));
assert!(matches!(upright(b"\x89PNG\r\n\x1a\n"), Cow::Borrowed(_)));
assert!(matches!(upright(b"\xff\xd8 not a jpeg"), Cow::Borrowed(_)));
}
#[test]
fn a_phone_photo_used_as_a_custom_stamp_is_embedded_upright() {
let mut session = crate::Session::new();
let blank = session.create_blank(300.0, 400.0, 1).unwrap();
let id = session.open_new("form.pdf", blank).unwrap();
// A click places the stamp at its natural size: 30 x 20 pt as stored, 20 x 30 pt upright.
// Named "Signature" so `signature_image` finds its picture.
let file = MarkFile { name: "photo.jpg".into(), bytes: std::sync::Arc::new(phone_jpeg(6, 144)), page: 0 };
let stamp = Edit::AddCustomStamp { page: 0, rect: [100.0, 100.0, 100.0, 100.0], name: "Signature".into(), file, author: "Ada".into() };
session.apply(id, stamp).unwrap();
let cos = &session.get(id).unwrap().editor.as_ref().unwrap().cos;
let picture = pdfcraft_annot::signature_image(cos, 0, 0).unwrap().unwrap();
let obj = cos.get(picture);
let pdfcraft_cos::Object::Stream(s) = &*obj else { panic!() };
assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(20), Some(60)));
let rect = pdfcraft_annot::summaries(cos)[0].rect;
assert!(((rect[2] - rect[0]) - 20.0).abs() < 0.01 && ((rect[3] - rect[1]) - 30.0).abs() < 0.01, "{rect:?}");
}
}

View File

@ -208,6 +208,8 @@ struct FieldInfo {
kind: pdfcraft_forms::FieldKind,
value: Vec<String>,
on_state: String,
/// List boxes that take several values (one per line in the data).
multi: bool,
}
fn field_infos(doc: &pdfcraft_cos::Document) -> HashMap<String, FieldInfo> {
@ -215,7 +217,8 @@ fn field_infos(doc: &pdfcraft_cos::Document) -> HashMap<String, FieldInfo> {
.into_iter()
.map(|f| {
let on_state = f.widgets.first().and_then(|w| w.on_state.clone()).unwrap_or_else(|| "1".into());
(f.name, FieldInfo { kind: f.kind, value: f.value, on_state })
let multi = f.kind == pdfcraft_forms::FieldKind::List && f.has(pdfcraft_forms::flags::MULTI_SELECT);
(f.name, FieldInfo { kind: f.kind, value: f.value, on_state, multi })
})
.collect()
}
@ -380,11 +383,13 @@ impl<'a, 'b> Runner<'a, 'b> {
}
// Read-only fields still take values from scripts (calculated totals are read-only).
K::PushButton | K::Signature => return false,
K::List if info.multi => value.lines().map(str::trim).filter(|l| !l.is_empty()).map(str::to_string).collect(),
_ => vec![value.to_string()],
};
let same = match info.kind {
K::CheckBox => info.value.is_empty() == new_value.is_empty(),
K::Radio => info.value.first() == new_value.first(),
K::List if info.multi => info.value == new_value,
_ => info.value.first().map_or(value.is_empty(), |v| v == value),
};
if same {

View File

@ -21,6 +21,8 @@ pub struct Block {
pub size: f64,
pub bold: bool,
pub italic: bool,
/// Text fill colour as sRGB components in 0..=1 (black when unknown).
pub color: [f64; 3],
}
/// An image of a page.
@ -48,6 +50,8 @@ pub struct Cell {
pub size: f64,
pub bold: bool,
pub italic: bool,
/// Text fill colour as sRGB components in 0..=1.
pub color: [f64; 3],
pub span: usize,
}
@ -96,10 +100,11 @@ fn level(b: &Block, body: f64) -> u8 {
/// How far two cell left edges may drift (points) and still be the same grid column.
const COL_TOL: f64 = 4.0;
/// More grid columns than this isn't a table. It also bounds a table's size: every row is
/// materialized to all its columns, so a hostile page laid out as a staircase of blocks would
/// otherwise make (blocks / 2)² cells.
const MAX_COLS: usize = 64;
/// More grid columns than this isn't a table. It is Word's limit: a .docx whose table has 64 or
/// more columns doesn't open. It also bounds a table's size: every row is materialized to all
/// its columns, so a hostile page laid out as a staircase of blocks would otherwise make
/// (blocks / 2)² cells.
const MAX_COLS: usize = 63;
/// A cell is short: taller blocks are body text (or a multi-column layout), not table cells.
fn is_cell_like(b: &Block) -> bool {
(b.rect[3] - b.rect[1]) <= b.size * 5.0 && !b.text.trim().is_empty()
@ -198,7 +203,7 @@ fn tables(blocks: &[Block]) -> (Vec<Table>, Vec<bool>) {
let b = &blocks[bi];
let Some(slot) = edges.iter().position(|e| (*e - b.rect[0]).abs() <= COL_TOL) else { continue };
let span = edges[slot + 1..].iter().filter(|e| **e > b.rect[0] + COL_TOL && **e < b.rect[2] - COL_TOL).count() + 1;
let cell = Cell { text: b.text.trim().to_string(), size: b.size, bold: b.bold, italic: b.italic, span };
let cell = Cell { text: b.text.trim().to_string(), size: b.size, bold: b.bold, italic: b.italic, color: b.color, span };
match &mut slots[slot] {
Some(c) => {
c.text.push(' ');
@ -219,7 +224,7 @@ fn tables(blocks: &[Block]) -> (Vec<Table>, Vec<bool>) {
}
None => {
column += 1;
materialized.push(Cell { text: String::new(), size: 11.0, bold: false, italic: false, span: 1 });
materialized.push(Cell { text: String::new(), size: 11.0, bold: false, italic: false, color: [0.0; 3], span: 1 });
}
}
}
@ -360,9 +365,10 @@ table{{border-collapse:collapse;margin:1em 0}}td,th{{border:1px solid #999;paddi
if b.bold && lvl == 0 {
t = format!("<strong>{t}</strong>");
}
let style = hex(b.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\""));
match lvl {
0 => s.push_str(&format!("<p>{t}</p>\n")),
l => s.push_str(&format!("<h{l}>{t}</h{l}>\n")),
0 => s.push_str(&format!("<p{style}>{t}</p>\n")),
l => s.push_str(&format!("<h{l}{style}>{t}</h{l}>\n")),
}
}
Item::Table(t) => {
@ -378,7 +384,8 @@ table{{border-collapse:collapse;margin:1em 0}}td,th{{border:1px solid #999;paddi
body = format!("<strong>{body}</strong>");
}
let span = if c.span > 1 { format!(" colspan=\"{}\"", c.span) } else { String::new() };
s.push_str(&format!("<td{span}>{body}</td>"));
let style = hex(c.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\""));
s.push_str(&format!("<td{span}{style}>{body}</td>"));
}
s.push_str("</tr>\n");
}
@ -395,8 +402,20 @@ table{{border-collapse:collapse;margin:1em 0}}td,th{{border:1px solid #999;paddi
s
}
/// A colour's 8-bit sRGB components (NaN and out-of-range values clamp; NaN reads as 0).
fn rgb8(c: [f64; 3]) -> [u8; 3] {
// The value is clamped to 0..=255 before the cast, so it cannot truncate.
c.map(|v| if v.is_finite() { (v.clamp(0.0, 1.0) * 255.0).round() as u8 } else { 0 })
}
/// The colour as `RRGGBB`, or `None` for black (the default every format already uses).
fn hex(c: [f64; 3]) -> Option<String> {
let [r, g, b] = rgb8(c);
(r | g | b != 0).then(|| format!("{r:02X}{g:02X}{b:02X}"))
}
/// A formatted text run (shared by paragraphs and table cells).
fn run_xml(text: &str, size: f64, bold: bool, italic: bool) -> String {
fn run_xml(text: &str, size: f64, bold: bool, italic: bool, color: [f64; 3]) -> String {
let mut rpr = String::new();
if bold {
rpr.push_str("<w:b/>");
@ -404,6 +423,9 @@ fn run_xml(text: &str, size: f64, bold: bool, italic: bool) -> String {
if italic {
rpr.push_str("<w:i/>");
}
if let Some(h) = hex(color) {
rpr.push_str(&format!("<w:color w:val=\"{h}\"/>"));
}
let half_points = if size.is_finite() { (size * 2.0).round().clamp(2.0, 3276.0) as i64 } else { 24 };
rpr.push_str(&format!("<w:sz w:val=\"{half_points}\"/>"));
format!("<w:r><w:rPr>{rpr}</w:rPr><w:t xml:space=\"preserve\">{}</w:t></w:r>", esc(text))
@ -439,7 +461,7 @@ fn docx_table(t: &Table) -> String {
let w: i64 = widths.iter().take(c.span.min(widths.len())).sum();
s.push_str(&format!(
"<w:tc><w:tcPr><w:tcW w:w=\"{w}\" w:type=\"dxa\"/>{span}</w:tcPr><w:p>{}</w:p></w:tc>",
run_xml(&c.text, c.size, c.bold, c.italic)
run_xml(&c.text, c.size, c.bold, c.italic, c.color)
));
}
// Pad the grid so every row covers all columns (Word rejects short rows).
@ -467,7 +489,7 @@ pub fn docx(pages: &[Page], title: &str) -> Vec<u8> {
let mut media: Vec<(String, &Image)> = Vec::new();
// Word needs a paragraph between adjacent tables and after the last one in the body.
let mut after_table = false;
let run = |b: &Block| run_xml(&b.text, b.size, b.bold, b.italic);
let run = |b: &Block| run_xml(&b.text, b.size, b.bold, b.italic, b.color);
for it in items(pages) {
match &it {
Item::Para(b, lvl) => {
@ -585,8 +607,36 @@ fn rtf_text(s: &str) -> String {
/// Rich Text Format: paragraphs with their sizes and bold/italic, real table rows, page breaks
/// between pages (images are left out).
pub fn rtf(pages: &[Page]) -> String {
let items = items(pages);
// The colour table: entry 0 is the default (auto) colour, then each non-black colour once.
let mut colors: Vec<[u8; 3]> = Vec::new();
let mut note = |c: [f64; 3]| {
let c = rgb8(c);
if c != [0, 0, 0] && !colors.contains(&c) {
colors.push(c);
}
};
for it in &items {
match it {
Item::Para(b, _) => note(b.color),
Item::Table(t) => t.rows.iter().flatten().for_each(|c| note(c.color)),
Item::Img(_) | Item::PageBreak => {}
}
}
// `\cfN` for a colour (nothing for black, which keeps the default).
let cf = |c: [f64; 3]| {
let c = rgb8(c);
colors.iter().position(|e| *e == c).map_or(String::new(), |i| format!("\\cf{}", i + 1))
};
let mut s = String::from("{\\rtf1\\ansi\\deff0{\\fonttbl{\\f0 Helvetica;}}\n");
for it in items(pages) {
if !colors.is_empty() {
s.push_str("{\\colortbl;");
for [r, g, b] in &colors {
s.push_str(&format!("\\red{r}\\green{g}\\blue{b};"));
}
s.push_str("}\n");
}
for it in items {
match it {
Item::Para(b, _) => {
let mut fmt = format!("\\fs{}", (b.size * 2.0).round() as i64);
@ -596,6 +646,7 @@ pub fn rtf(pages: &[Page]) -> String {
if b.italic {
fmt.push_str("\\i");
}
fmt.push_str(&cf(b.color));
s.push_str(&format!("{{\\pard{fmt} {}\\par}}\n", rtf_text(&b.text)));
}
Item::Table(t) => {
@ -621,6 +672,7 @@ pub fn rtf(pages: &[Page]) -> String {
if c.italic {
fmt.push_str("\\i");
}
fmt.push_str(&cf(c.color));
s.push_str(&format!("{{{fmt} {}}}\\cell", rtf_text(&c.text)));
}
s.push_str("\\row\n");
@ -639,7 +691,14 @@ mod tests {
use super::*;
fn page() -> Page {
let b = |t: &str, y: f64, size: f64, bold: bool| Block { text: t.into(), rect: [72.0, y, 500.0, y + size], size, bold, italic: false };
let b = |t: &str, y: f64, size: f64, bold: bool| Block {
text: t.into(),
rect: [72.0, y, 500.0, y + size],
size,
bold,
italic: false,
color: [0.0; 3],
};
Page {
width: 612.0,
height: 792.0,
@ -711,6 +770,7 @@ mod tests {
size: f64::NAN,
bold: false,
italic: false,
color: [0.0; 3],
});
let xml = part(&docx(&[p.clone()], "Receipt\u{1}"), "word/document.xml");
assert!(xml.contains(">Total 4.50\tGBP</w:t>"), "{xml}");
@ -724,10 +784,35 @@ mod tests {
assert!(xml.contains("<w:pgMar w:top=\"500\" w:right=\"250\" w:bottom=\"500\" w:left=\"250\""), "{xml}");
}
#[test]
fn text_colour_survives_every_format() {
// #526: a dark green heading (0.05 0.23 0.18 rg) came out black in Word.
let mut p = page();
p.blocks[1].color = [0.05, 0.23, 0.18];
p.blocks[3].color = [f64::NAN, 2.0, -1.0];
let xml = part(&docx(&[p.clone()], "Colour"), "word/document.xml");
assert!(xml.contains("<w:color w:val=\"0D3B2E\"/>"), "{xml}");
assert!(xml.contains("<w:color w:val=\"00FF00\"/>"), "out-of-range values clamp: {xml}");
// Black text keeps Word's default colour.
assert_eq!(xml.matches("<w:color ").count(), 2, "{xml}");
let h = html(&[p.clone()], "Colour");
assert!(h.contains("<h1 style=\"color:#0D3B2E\">Annual Report</h1>"), "{h}");
assert!(h.contains("<p style=\"color:#00FF00\">"), "{h}");
let r = rtf(&[p]);
assert!(r.contains("{\\colortbl;\\red13\\green59\\blue46;\\red0\\green255\\blue0;}"), "{r}");
assert!(r.contains("\\cf1 Annual Report"), "{r}");
// A table cell keeps its colour too.
let mut t = table_page();
t.blocks[0].color = [1.0, 0.0, 0.0];
let xml = part(&docx(&[t.clone()], "Grid"), "word/document.xml");
assert!(xml.contains("<w:color w:val=\"FF0000\"/>"), "{xml}");
assert!(html(&[t], "Grid").contains("style=\"color:#FF0000\""));
}
#[test]
fn rtf_escapes_and_sizes() {
let mut p = page();
p.blocks.push(Block { text: "Café {x}".into(), rect: [72.0, 100.0, 200.0, 110.0], size: 10.0, bold: false, italic: true });
p.blocks.push(Block { text: "Café {x}".into(), rect: [72.0, 100.0, 200.0, 110.0], size: 10.0, bold: false, italic: true, color: [0.0; 3] });
let r = rtf(&[p]);
assert!(r.starts_with("{\\rtf1") && r.ends_with('}'));
assert!(r.contains("\\fs48\\b Annual Report"));
@ -736,7 +821,14 @@ mod tests {
/// A 3-column table with a spanning header row and a hole in the last row.
fn table_page() -> Page {
let cell = |t: &str, x: f64, y: f64, w: f64| Block { text: t.into(), rect: [x, y, x + w, y + 12.0], size: 11.0, bold: false, italic: false };
let cell = |t: &str, x: f64, y: f64, w: f64| Block {
text: t.into(),
rect: [x, y, x + w, y + 12.0],
size: 11.0,
bold: false,
italic: false,
color: [0.0; 3],
};
Page {
width: 612.0,
height: 792.0,
@ -757,6 +849,7 @@ mod tests {
size: 11.0,
bold: false,
italic: false,
color: [0.0; 3],
},
],
images: Vec::new(),
@ -800,7 +893,7 @@ mod tests {
.map(|i| {
let (row, col) = (i / 2, i / 2 + i % 2);
let (x, y) = (10.0 + col as f64 * 20.0, 10_000.0 - row as f64 * 14.0);
Block { text: "x".into(), rect: [x, y, x + 8.0, y + 12.0], size: 11.0, bold: false, italic: false }
Block { text: "x".into(), rect: [x, y, x + 8.0, y + 12.0], size: 11.0, bold: false, italic: false, color: [0.0; 3] }
})
.collect();
let started = std::time::Instant::now();
@ -809,6 +902,28 @@ mod tests {
assert!(started.elapsed() < std::time::Duration::from_secs(5), "took {:?}", started.elapsed());
}
#[test]
fn word_opens_tables_up_to_its_column_limit() {
// Rows of short numbers on a regular grid, n columns wide.
let grid = |n: usize| Page {
width: 1500.0,
height: 792.0,
blocks: (0..3)
.flat_map(|row| {
(0..n).map(move |col| {
let (x, y) = (20.0 + col as f64 * 22.0, 700.0 - row as f64 * 14.0);
Block { text: (col + 1).to_string(), rect: [x, y, x + 8.0, y + 8.0], size: 8.0, bold: false, italic: false, color: [0.0; 3] }
})
})
.collect(),
images: Vec::new(),
};
let grid_cols = |p: &Page| part(&docx(std::slice::from_ref(p), "Grid"), "word/document.xml").matches("<w:gridCol ").count();
assert_eq!(grid_cols(&grid(63)), 63, "63 columns is still a table");
// Word refuses to open a document with a 64-column table, so that grid stays text.
assert_eq!(grid_cols(&grid(64)), 0);
}
#[test]
fn two_column_body_text_is_not_mistaken_for_a_table() {
let para = |t: &str, x: f64, y: f64| Block {
@ -817,6 +932,7 @@ mod tests {
size: 11.0,
bold: false,
italic: false,
color: [0.0; 3],
};
let p = Page {
width: 612.0,

View File

@ -12,3 +12,5 @@ workspace = true
pdfcraft-cos = { workspace = true }
# Glyph outlines of the bundled script font (typed signatures); already in the tree via egui.
skrifa = "0.44"
# Arabic shaping for text written into PDFs; already in the tree via egui, on the same read-fonts.
harfrust = "0.12"

View File

@ -23,12 +23,34 @@ set). Without that variable `CRAFT_FONTS` is empty and everything below copes:
in Chinese mode, so one line never mixes faces with different vertical metrics).
- `ui_arabic_fonts`: the `Arab` faces for Arabic-script interface text (file names, document
titles), in manifest order.
- `ui_telugu_fonts`: the `Telu` faces for Telugu interface text (the Telugu catalog, file names,
document titles), in manifest order.
- `document_japanese_font` / `japanese_glyph`: the face (Shippori Mincho, then BIZ UDMincho) whose
outlines become the Type 3 fallback font for Japanese text written into PDFs. Without it,
`japanese_glyph` returns `GlyphError::NoFont` and the editor reports a clear error.
- `document_japanese_font_for_style` / `japanese_glyph_from`: select and draw a real Japanese
fallback face for edited text. Sans sources prefer BIZ UDPGothic Regular/Bold; serif sources
keep Mincho. Type 3 paths and advances both come from that selected face. Missing weights
fall back to a real Regular face, never synthetic bold; no italic face is supplied. The
default `document_japanese_font` / `japanese_glyph` APIs still use regular document Mincho.
- `document_arabic_font` / `shape_arabic` / `arabic_glyph`: the first `Arab` face, shaped with
harfrust (joining forms, ligatures, mark positions) into clusters for Arabic text written into
PDFs; the editor draws each cluster as one Type 3 glyph with a ToUnicode entry. Without the face
they return `GlyphError::NoFont` and the editor reports a clear error.
- `document_japanese_fonts_for_style`: every `Jpan` face in that order of preference, so the
editor can use the first one that has all of the replacement's glyphs (the faces differ in
coverage, e.g. of Cyrillic).
- `SHIPPORI_MINCHO`: Shippori Mincho's bytes, or `None`.
wasm32 builds embed only BIZ UDPGothic Regular and any `Arab` face, to keep the web build small: the web build
Generated Japanese Type 3 fonts record the fallback face's family and style in an indirect
`FontDescriptor`. `pdf::Metrics::base_font` uses its `FontName` when the Type 3 dictionary has
no `BaseFont`, so text inspection and edit-tool results identify the fallback after save/reopen.
The descriptor does not change glyph programs or replace the Type 3 `FontBBox` metrics when
`Ascent`/`Descent` are omitted (PDF Reference 1.7, tables 5.9 and 5.19). The small web input has
only Gothic Regular, so it cannot preserve bold. No new font assets or changes to that input are
needed.
wasm32 builds embed only BIZ UDPGothic Regular and any `Arab` or `Telu` face, to keep the web build small: the web build
currently has no Chinese face, so Chinese there still shows the replacement glyph.
Font files are never
committed here (`AGENTS.md` §1.4; team members: [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md), internal).

View File

@ -1,8 +1,8 @@
//! Embeds the fonts of the optional craft-fonts build input (`CRAFT_FONTS_DIR`) as `CRAFT_FONTS`.
//! The recipe is craft-fonts' `docs/integration.md`; unset, `CRAFT_FONTS` is empty. It only reads
//! the local checkout (no network). On wasm32 it embeds only the UI faces (BIZ UDPGothic Regular
//! and any `Arab` face), to keep the web build within hosting limits (Cloudflare Pages: 25 MiB
//! per file).
//! and any `Arab` or `Telu` face), to keep the web build within hosting limits (Cloudflare Pages:
//! 25 MiB per file).
use std::fmt::Write as _;
use std::path::PathBuf;
@ -41,7 +41,10 @@ fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
};
// Arabic interface faces are small, so the web build keeps them too.
let arabic = scripts.split(',').any(|s| s.trim() == "Arab");
if wasm && !(*family == "BIZ UDPGothic" && *style == "Regular") && !arabic {
// Telugu faces too, for the Telugu interface.
let telugu = scripts.split(',').any(|s| s.trim() == "Telu");
let web_face = (*family == "BIZ UDPGothic" && *style == "Regular") || arabic || telugu;
if wasm && !web_face {
continue;
}
let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?;

171
crates/fonts/src/arabic.rs Normal file
View File

@ -0,0 +1,171 @@
//! Arabic text written into PDFs: shaping (joining forms, lam-alef ligatures, mark placement) with
//! the craft-fonts `Arab` face, and that face's glyph outlines.
use std::sync::OnceLock;
use harfrust::{BufferClusterLevel, Direction, ShapeOptions, ShaperData, UnicodeBuffer, script};
use skrifa::instance::{LocationRef, Size};
use skrifa::raw::TableProvider;
use skrifa::{FontRef, GlyphId, MetadataProvider};
use crate::{GlyphError, GlyphOutline};
/// One cluster of shaped Arabic text: the glyphs that show one character (a letter's body and
/// dots, say), or several characters for a ligature. Em units.
#[derive(Clone, Debug, PartialEq)]
pub struct ShapedCluster {
/// Glyph ids, each with its offset from the cluster's pen position (marks sit above or below).
pub glyphs: Vec<(u32, [f64; 2])>,
pub advance: f64,
/// The characters it shows, in logical order.
pub text: String,
}
fn face() -> Option<&'static (FontRef<'static>, ShaperData)> {
static FACE: OnceLock<Option<(FontRef<'static>, ShaperData)>> = OnceLock::new();
FACE.get_or_init(|| {
let font = FontRef::new(crate::document_arabic_font()?.bytes).ok()?;
let data = ShaperData::new(&font);
Some((font, data))
})
.as_ref()
}
/// Whether the Arabic document face has a glyph for `c` (false without the face).
pub fn arabic_has(c: char) -> bool {
face().is_some_and(|(font, _)| font.charmap().map(c).is_some())
}
/// Shape one directional run of `text` with the Arabic document face. The clusters come in
/// drawing order, left to right. [`GlyphError::NoFont`] without the face;
/// [`GlyphError::Missing`] when it lacks a character.
pub fn shape_arabic(text: &str, rtl: bool) -> Result<Vec<ShapedCluster>, GlyphError> {
let (font, data) = face().ok_or(GlyphError::NoFont)?;
let shaper = data.shaper(font).build();
let scale = 1.0 / f64::from(shaper.units_per_em().max(1));
let mut buffer = UnicodeBuffer::new();
buffer.push_str(text);
buffer.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight });
buffer.set_script(script::ARABIC);
// One cluster per character, so marks keep their own text for copy and search.
buffer.set_cluster_level(BufferClusterLevel::Characters);
let shaped = shaper.shape(buffer, ShapeOptions::new());
// Clusters are byte offsets into `text`; a cluster shows the characters up to the next one.
let mut starts: Vec<usize> = shaped.glyph_infos().iter().map(|g| g.cluster as usize).collect();
starts.sort_unstable();
starts.dedup();
let mut out: Vec<(usize, ShapedCluster)> = Vec::new();
for (info, pos) in shaped.glyph_infos().iter().zip(shaped.glyph_positions()) {
if info.glyph_id == 0 {
return Err(GlyphError::Missing);
}
let start = info.cluster as usize;
// A cluster's glyphs are adjacent in the shaped run.
let cluster = match out.last_mut() {
Some((s, c)) if *s == start => c,
_ => {
let end = starts.get(starts.partition_point(|s| *s <= start)).copied().unwrap_or(text.len());
let text = text.get(start..end).unwrap_or_default().to_string();
out.push((start, ShapedCluster { glyphs: Vec::new(), advance: 0.0, text }));
// Just pushed.
let Some((_, c)) = out.last_mut() else { continue };
c
}
};
let at = [cluster.advance + f64::from(pos.x_offset) * scale, f64::from(pos.y_offset) * scale];
cluster.glyphs.push((info.glyph_id, at));
cluster.advance += f64::from(pos.x_advance) * scale;
}
Ok(out.into_iter().map(|(_, c)| c).collect())
}
/// Glyph `id` of the Arabic document face, bounded like [`crate::japanese_glyph`]. Glyphs without
/// outlines (spaces) come back empty.
pub fn arabic_glyph(id: u32) -> Result<GlyphOutline, GlyphError> {
let (font, _) = face().ok_or(GlyphError::NoFont)?;
if font.maxp().map_or(true, |m| id >= u32::from(m.num_glyphs())) {
return Err(GlyphError::Missing);
}
let gid = GlyphId::new(id);
let loc = LocationRef::default();
let scale = 1.0 / font.metrics(Size::unscaled(), loc).units_per_em.max(1) as f64;
let width = font.glyph_metrics(Size::unscaled(), loc).advance_width(gid).unwrap_or(0.0) as f64 * scale;
// Marks have no advance; the widest ligatures (U+FDFD) span a few em.
if !width.is_finite() || !(0.0..=8.0).contains(&width) {
return Err(GlyphError::Missing);
}
if font.outline_glyphs().get(gid).is_none() {
return Ok(GlyphOutline { contours: Vec::new(), width, bbox: [0.0, 0.0, width, 0.0] });
}
crate::script::bounded_outline(font, gid, width)
}
#[cfg(test)]
mod tests {
use super::*;
fn face_or_skip() -> bool {
if crate::document_arabic_font().is_none() {
eprintln!("skipping the Arabic shaping checks: built without a craft-fonts Arab face (set CRAFT_FONTS_DIR)");
assert_eq!(shape_arabic("ب", true), Err(GlyphError::NoFont));
assert_eq!(arabic_glyph(1).map(|_| ()), Err(GlyphError::NoFont));
assert!(!arabic_has('ب'));
return false;
}
true
}
#[test]
fn letters_take_their_joining_forms() {
if !face_or_skip() {
return;
}
let ids =
|s: &str| shape_arabic(s, true).unwrap().iter().flat_map(|c| c.glyphs.iter().map(|g| g.0)).collect::<std::collections::BTreeSet<_>>();
let (isolated, joined) = (ids("ب"), ids("ببب"));
// Initial, medial and final forms are glyphs the isolated letter doesn't use.
assert!(joined.difference(&isolated).count() >= 2, "{isolated:?} {joined:?}");
// One cluster per letter, however many glyphs the face draws it with (Noto Sans Arabic:
// body and dots).
let c = shape_arabic("ببب", true).unwrap();
assert!(c.len() == 3 && c.iter().all(|c| c.text == "ب" && c.advance > 0.0 && !c.glyphs.is_empty()), "{c:?}");
}
#[test]
fn right_to_left_runs_are_drawn_from_their_last_letter() {
if !face_or_skip() {
return;
}
let g = shape_arabic("سلام", true).unwrap();
let drawn: String = g.iter().map(|g| g.text.as_str()).collect();
assert_eq!(drawn.chars().rev().collect::<String>(), "سلام");
assert!(shape_arabic("سلام", false).unwrap().iter().map(|g| g.text.as_str()).collect::<String>() == "سلام");
}
#[test]
fn marks_keep_their_own_text_and_no_advance() {
if !face_or_skip() {
return;
}
let c = shape_arabic("بَ", true).unwrap();
assert_eq!(c.len(), 2, "{c:?}");
let mark = c.iter().find(|c| c.text == "\u{064E}").unwrap();
assert_eq!(mark.advance, 0.0);
let outline = arabic_glyph(mark.glyphs[0].0).unwrap();
assert!(!outline.contours.is_empty() && outline.bbox[1] > 0.0, "the fatha sits above the baseline");
}
#[test]
fn spaces_and_missing_characters() {
if !face_or_skip() {
return;
}
let space = shape_arabic(" ", true).unwrap();
assert!(space[0].advance > 0.0);
assert!(arabic_glyph(space[0].glyphs[0].0).unwrap().contours.is_empty());
assert!(arabic_has('ب') && !arabic_has('日'));
assert_eq!(shape_arabic("日", true), Err(GlyphError::Missing));
assert_eq!(arabic_glyph(u32::MAX).map(|_| ()), Err(GlyphError::Missing));
assert!(shape_arabic("", true).unwrap().is_empty());
}
}

View File

@ -49,10 +49,23 @@ pub fn ui_arabic_fonts() -> Vec<&'static CraftFont> {
arabic(CRAFT_FONTS.iter())
}
/// The face for Arabic text written into PDFs: the first `Arab` face. `None` when built without
/// craft-fonts or when its revision has no Arabic face.
pub fn document_arabic_font() -> Option<&'static CraftFont> {
ui_arabic_fonts().into_iter().next()
}
fn arabic<'a>(faces: impl IntoIterator<Item = &'a CraftFont>) -> Vec<&'a CraftFont> {
faces.into_iter().filter(|f| f.covers("Arab")).collect()
}
/// The `Telu` craft-fonts faces for Telugu interface text (the Telugu catalog, file names,
/// document titles), in manifest order. Empty when built without craft-fonts or when it has no
/// Telugu face.
pub fn ui_telugu_fonts() -> Vec<&'static CraftFont> {
CRAFT_FONTS.iter().filter(|f| f.covers("Telu")).collect()
}
/// Interface CJK faces in fallback order for the UI language: Simplified Chinese first when
/// `prefer_hans`, otherwise Japanese first (the historical default).
///
@ -78,12 +91,47 @@ fn order_cjk<'a>(faces: impl IntoIterator<Item = &'a CraftFont>, prefer_hans: bo
/// The face for Japanese text written into PDFs (serif document text): Shippori Mincho, then
/// BIZ UDMincho, then any other regular `Jpan` face. `None` without craft-fonts.
pub fn document_japanese_font() -> Option<&'static CraftFont> {
let jpan = || CRAFT_FONTS.iter().filter(|f| f.covers("Jpan"));
["Shippori Mincho", "BIZ UDMincho"]
.iter()
.find_map(|family| jpan().find(|f| f.family == *family && f.style == "Regular"))
.or_else(|| jpan().find(|f| f.style == "Regular"))
.or_else(|| jpan().next())
document_face(CRAFT_FONTS, true, false)
}
/// A real Japanese document face matching serif/sans and weight where available.
/// Sans text prefers BIZ UDPGothic Bold or Regular. Missing weights fall back to Regular;
/// serif text keeps the document Mincho preference. No synthetic weight or slant is applied.
/// `None` without craft-fonts. The small web input currently has only Gothic Regular.
pub fn document_japanese_font_for_style(serif: bool, bold: bool) -> Option<&'static CraftFont> {
document_face(CRAFT_FONTS, serif, bold)
}
/// Every `Jpan` face that can stand in for the document text, the best match for the style first
/// (the face [`document_japanese_font_for_style`] returns), then the others. Not every face has
/// every glyph (their coverage of e.g. Cyrillic differs), so a caller can move on to the next one.
/// Empty without craft-fonts.
pub fn document_japanese_fonts_for_style(serif: bool, bold: bool) -> Vec<&'static CraftFont> {
document_faces(CRAFT_FONTS, serif, bold)
}
fn document_face(faces: &[CraftFont], serif: bool, bold: bool) -> Option<&CraftFont> {
document_faces(faces, serif, bold).into_iter().next()
}
fn document_faces(faces: &[CraftFont], serif: bool, bold: bool) -> Vec<&CraftFont> {
let jpan = || faces.iter().filter(|f| f.covers("Jpan"));
let mut preferred = Vec::new();
if !serif {
let style = if bold { "Bold" } else { "Regular" };
preferred.extend(jpan().find(|f| f.family == "BIZ UDPGothic" && f.style == style));
preferred.extend(jpan().find(|f| f.family == "BIZ UDPGothic" && f.style == "Regular"));
}
for family in ["Shippori Mincho", "BIZ UDMincho"] {
preferred.extend(jpan().find(|f| f.family == family && f.style == "Regular"));
}
let mut out: Vec<&CraftFont> = Vec::new();
for face in preferred.into_iter().chain(jpan().filter(|f| f.style == "Regular")).chain(jpan()) {
if !out.iter().any(|f| std::ptr::eq(*f, face)) {
out.push(face);
}
}
out
}
const fn str_eq(a: &str, b: &str) -> bool {
@ -149,6 +197,29 @@ mod tests {
assert_eq!(ja, ["BIZ UDPGothic", "Shippori Mincho", "FakeHans"]);
}
#[test]
fn document_faces_match_style_without_inventing_missing_weights() {
let faces = [
CraftFont { family: "Shippori Mincho", style: "Regular", scripts: &["Jpan"], bytes: b"serif" },
CraftFont { family: "BIZ UDPGothic", style: "Regular", scripts: &["Jpan"], bytes: b"sans" },
CraftFont { family: "BIZ UDPGothic", style: "Bold", scripts: &["Jpan"], bytes: b"bold" },
CraftFont { family: "BIZ UDPGothic", style: "Bold", scripts: &["Latn"], bytes: b"not-japanese" },
];
assert_eq!(document_face(&faces, false, false).unwrap().bytes, b"sans");
assert_eq!(document_face(&faces, false, true).unwrap().bytes, b"bold");
assert_eq!(document_face(&faces, true, false).unwrap().bytes, b"serif");
assert_eq!(document_face(&faces, true, true).unwrap().bytes, b"serif");
assert_eq!(document_face(&faces[..2], false, true).unwrap().bytes, b"sans");
assert_eq!(document_face(&faces[..1], false, true).unwrap().bytes, b"serif");
// Every Japanese face is a candidate, the style's match first and none twice.
let order: Vec<&[u8]> = document_faces(&faces, false, true).iter().map(|f| f.bytes).collect();
assert_eq!(order, [b"bold".as_slice(), b"sans", b"serif"]);
let order: Vec<&[u8]> = document_faces(&faces, true, false).iter().map(|f| f.bytes).collect();
assert_eq!(order, [b"serif".as_slice(), b"sans", b"bold"]);
assert!(document_face(&faces[3..], false, true).is_none());
assert!(document_face(&[], false, true).is_none());
}
#[test]
fn arabic_faces_are_picked_by_script_in_manifest_order() {
// Synthetic faces: the filter must not depend on the real build input.

View File

@ -5,14 +5,17 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod arabic;
mod craft;
mod encodings;
pub mod pdf;
mod script;
pub use arabic::{ShapedCluster, arabic_glyph, arabic_has, shape_arabic};
pub use craft::{
CRAFT_FONTS, CraftFont, SHIPPORI_MINCHO, document_japanese_font, ui_arabic_fonts, ui_chinese_fonts, ui_cjk_fonts, ui_japanese_fonts,
CRAFT_FONTS, CraftFont, SHIPPORI_MINCHO, document_arabic_font, document_japanese_font, document_japanese_font_for_style,
document_japanese_fonts_for_style, ui_arabic_fonts, ui_chinese_fonts, ui_cjk_fonts, ui_japanese_fonts, ui_telugu_fonts,
};
pub use script::{GlyphError, GlyphOutline, MAX_SIGNATURE_CHARS, ScriptOutline, japanese_glyph, script_outline};
pub use script::{GlyphError, GlyphOutline, MAX_SIGNATURE_CHARS, ScriptOutline, japanese_glyph, japanese_glyph_from, script_outline};
/// Approximate advance of `s` in Helvetica (or Arial) at `size` points.
pub fn helvetica_width(s: &str, size: f64) -> f64 {
@ -36,15 +39,51 @@ pub fn helvetica_width(s: &str, size: f64) -> f64 {
units * size / 1000.0
}
/// Approximate advance of `s` at `size` points in a CJK font: full-width characters take one
/// em, half-width forms half of one, and everything else is measured as Helvetica.
pub fn cjk_width(s: &str, size: f64) -> f64 {
let mut buf = [0u8; 4];
s.chars()
.map(|c| match c {
'\u{ff61}'..='\u{ffdc}' | '\u{ffe8}'..='\u{ffee}' => size * 0.5,
_ if is_wide(c) => size,
_ => helvetica_width(c.encode_utf8(&mut buf), size),
})
.sum()
}
/// East Asian wide and full-width characters (Unicode UAX #11 W and F, by block).
fn is_wide(c: char) -> bool {
matches!(c,
'\u{1100}'..='\u{115f}' // Hangul Jamo initials
| '\u{2e80}'..='\u{303e}' // CJK radicals, Kangxi, ideographic description, CJK symbols
| '\u{3041}'..='\u{33ff}' // kana, Bopomofo, Hangul compatibility, Kanbun, CJK compatibility
| '\u{3400}'..='\u{4dbf}' // CJK extension A
| '\u{4e00}'..='\u{9fff}' // CJK unified ideographs
| '\u{a000}'..='\u{a4cf}' // Yi
| '\u{ac00}'..='\u{d7a3}' // Hangul syllables
| '\u{f900}'..='\u{faff}' // CJK compatibility ideographs
| '\u{fe30}'..='\u{fe4f}' // CJK compatibility forms
| '\u{ff00}'..='\u{ff60}' // full-width forms
| '\u{ffe0}'..='\u{ffe6}'
| '\u{20000}'..='\u{3fffd}' // supplementary ideographic planes
)
}
/// Greedy line breaking within `width` points (paragraphs split on newlines; words longer
/// than a line are broken by character).
pub fn wrap(text: &str, size: f64, width: f64) -> Vec<String> {
wrap_with(text, size, width, helvetica_width)
}
/// [`wrap`], measuring with `measure` (for example [`cjk_width`]).
pub fn wrap_with(text: &str, size: f64, width: f64, measure: impl Fn(&str, f64) -> f64) -> Vec<String> {
let mut lines = Vec::new();
for para in text.split(['\n', '\r']) {
let mut line = String::new();
for word in para.split(' ') {
let candidate = if line.is_empty() { word.to_string() } else { format!("{line} {word}") };
if helvetica_width(&candidate, size) <= width || line.is_empty() && helvetica_width(word, size) <= width {
if measure(&candidate, size) <= width || line.is_empty() && measure(word, size) <= width {
line = candidate;
continue;
}
@ -52,7 +91,7 @@ pub fn wrap(text: &str, size: f64, width: f64) -> Vec<String> {
lines.push(std::mem::take(&mut line));
}
for ch in word.chars() {
if !line.is_empty() && helvetica_width(&format!("{line}{ch}"), size) > width {
if !line.is_empty() && measure(&format!("{line}{ch}"), size) > width {
lines.push(std::mem::take(&mut line));
}
line.push(ch);
@ -63,28 +102,88 @@ pub fn wrap(text: &str, size: f64, width: f64) -> Vec<String> {
lines
}
/// Encode text in WinAnsiEncoding (ISO 32000-2 Annex D); unmappable characters become `?`.
/// The WinAnsiEncoding (ISO 32000-2 Annex D) code for `c`, if the encoding has one.
///
/// Codes 0x80–0x9F come from the same table the text extractor decodes with, so encoding and
/// decoding agree (Š š Ž ž Œ œ Ÿ ƒ † ‡ ˆ ˜ ‰ ‹ › as well as quotes, dashes, € and ™).
pub fn win_ansi_byte(c: char) -> Option<u8> {
match c {
'\u{20}'..='\u{7e}' | '\u{a0}'..='\u{ff}' => u8::try_from(u32::from(c)).ok(),
_ => {
let high = encodings::WIN_ANSI.get(0x80..0xa0)?;
let i = high.iter().position(|&u| u != 0 && u == u32::from(c))?;
u8::try_from(0x80 + i).ok()
}
}
}
/// Encode text in WinAnsiEncoding (ISO 32000-2 Annex D); tabs become spaces and unmappable
/// characters become `?`.
pub fn win_ansi(s: &str) -> Vec<u8> {
s.chars()
.map(|c| match c {
'\u{20}'..='\u{7e}' => c as u8,
'\u{a0}'..='\u{ff}' => c as u32 as u8,
'€' => 0x80,
'‚' => 0x82,
'„' => 0x84,
'…' => 0x85,
'‘' => 0x91,
'’' => 0x92,
'“' => 0x93,
'”' => 0x94,
'•' => 0x95,
'–' => 0x96,
'—' => 0x97,
'™' => 0x99,
'\t' => b' ',
_ => b'?',
s.chars().map(|c| if c == '\t' { b' ' } else { win_ansi_byte(c).unwrap_or(b'?') }).collect()
}
/// The character encoding of a predefined Unicode CMap (`UniJIS-UTF16-H`, `UniGB-UCS2-H`, …;
/// ISO 32000-2 §9.7.5.2), which maps Unicode text straight to codes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum UnicodeCMap {
Ucs2,
Utf16,
Utf8,
Utf32,
}
impl UnicodeCMap {
/// The encoding of the predefined CMap `name`, if it is a horizontal Unicode one. Vertical
/// (`-V`) CMaps are refused: field appearances are laid out horizontally, so their glyphs
/// would be drawn stacked down the field instead of across it.
pub fn from_name(name: &[u8]) -> Option<Self> {
let name = std::str::from_utf8(name).ok()?;
if !name.starts_with("Uni") || !name.ends_with("-H") {
return None;
}
name.split('-').find_map(|part| match part {
"UCS2" => Some(Self::Ucs2),
"UTF16" => Some(Self::Utf16),
"UTF8" => Some(Self::Utf8),
"UTF32" => Some(Self::Utf32),
_ => None,
})
.collect()
}
/// Encode `s`; tabs become spaces, and characters UCS-2 can't hold become `?`.
pub fn encode(self, s: &str) -> Vec<u8> {
let mut out = Vec::with_capacity(s.len().saturating_mul(2));
let mut units = [0u16; 2];
let mut buf = [0u8; 4];
for c in s.chars() {
let c = if c == '\t' { ' ' } else { c };
match self {
Self::Ucs2 => {
let unit = u16::try_from(u32::from(c)).unwrap_or(u16::from(b'?'));
out.extend_from_slice(&unit.to_be_bytes());
}
Self::Utf16 => {
for unit in c.encode_utf16(&mut units) {
out.extend_from_slice(&unit.to_be_bytes());
}
}
Self::Utf8 => out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes()),
Self::Utf32 => out.extend_from_slice(&u32::from(c).to_be_bytes()),
}
}
out
}
}
/// Whether [`win_ansi`] keeps `c` rather than writing `?` (a tab becomes a space).
pub fn win_ansi_encodable(c: char) -> bool {
c == '\t' || win_ansi_byte(c).is_some()
}
/// The first character of `s` that [`win_ansi`] can't encode and would write as `?`.
pub fn first_non_win_ansi(s: &str) -> Option<char> {
s.chars().find(|c| !win_ansi_encodable(*c))
}
/// Bytes as a PDF literal string, `(` … `)`, with delimiters escaped.
@ -107,6 +206,18 @@ pub fn literal(bytes: &[u8]) -> Vec<u8> {
mod tests {
use super::*;
#[test]
fn win_ansi_encodability_matches_the_encoder() {
for c in ['a', 'é', 'ÿ', '€', '—', '™', '\t', '?'] {
assert!(win_ansi_encodable(c), "{c:?}");
}
for c in ['中', 'あ', 'Ω', 'č', 'ă', '☃', '😀'] {
assert!(!win_ansi_encodable(c), "{c:?}");
}
assert_eq!(first_non_win_ansi("Café — 5€"), None);
assert_eq!(first_non_win_ansi("Dvořák"), Some('ř'));
}
#[test]
fn widths_wrap_and_encode() {
assert!(helvetica_width("MMMM", 10.0) > helvetica_width("iiii", 10.0) * 2.0);
@ -119,4 +230,43 @@ mod tests {
assert_eq!(win_ansi("Café — 5€ ☃"), b"Caf\xe9 \x97 5\x80 ?");
assert_eq!(literal(b"a(b)\\c"), b"(a\\(b\\)\\\\c)");
}
#[test]
fn win_ansi_covers_the_0x80_to_0x9f_glyphs() {
// Every character in the WinAnsiEncoding table encodes to its code and back (#347).
for (code, &u) in encodings::WIN_ANSI.iter().enumerate() {
let Some(c) = char::from_u32(u).filter(|_| u != 0) else { continue };
assert_eq!(win_ansi_byte(c), Some(code as u8), "{c:?} (U+{u:04X})");
assert_eq!(win_ansi(&c.to_string()), [code as u8]);
}
assert_eq!(
win_ansi("Šta? žaba, œuvre — „ok“ ‰ ƒ † ‡ ˆ ˜ ‹›Ÿ"),
b"\x8ata? \x9eaba, \x9cuvre \x97 \x84ok\x93 \x89 \x83 \x86 \x87 \x88 \x98 \x8b\x9b\x9f"
);
// Not in WinAnsi: č ć still need the fallback font; undefined codes are never produced.
assert_eq!(win_ansi("čć\u{81}\u{8d}\t"), b"???? ");
assert_eq!(win_ansi_byte('\0'), None);
}
#[test]
fn unicode_cmaps_encode_cjk_text() {
assert_eq!(UnicodeCMap::from_name(b"UniJIS-UTF16-H"), Some(UnicodeCMap::Utf16));
assert_eq!(UnicodeCMap::from_name(b"UniGB-UCS2-V"), None);
assert_eq!(UnicodeCMap::from_name(b"UniJIS-UCS2-HW-H"), Some(UnicodeCMap::Ucs2));
assert_eq!(UnicodeCMap::from_name(b"UniKS-UTF8-H"), Some(UnicodeCMap::Utf8));
assert_eq!(UnicodeCMap::from_name(b"UniJIS2004-UTF32-H"), Some(UnicodeCMap::Utf32));
assert_eq!(UnicodeCMap::from_name(b"Identity-H"), None);
assert_eq!(UnicodeCMap::from_name(b"90ms-RKSJ-H"), None);
assert_eq!(UnicodeCMap::from_name(b"\xff\xfe"), None);
assert_eq!(UnicodeCMap::Utf16.encode("令A"), [0x4e, 0xe4, 0x00, 0x41]);
assert_eq!(UnicodeCMap::Utf16.encode("𠀋"), [0xd8, 0x40, 0xdc, 0x0b]);
assert_eq!(UnicodeCMap::Ucs2.encode("𠀋\t"), [0x00, b'?', 0x00, b' ']);
assert_eq!(UnicodeCMap::Utf8.encode("令"), "令".as_bytes());
assert_eq!(UnicodeCMap::Utf32.encode("令"), [0, 0, 0x4e, 0xe4]);
assert_eq!(cjk_width("令和", 10.0), 20.0);
assert_eq!(cjk_width("ア", 10.0), 5.0);
assert_eq!(cjk_width("A", 10.0), helvetica_width("A", 10.0));
let lines = wrap_with("日本語のテキストは文字単位で折り返されます", 10.0, 45.0, cjk_width);
assert!(lines.len() > 3 && lines.iter().all(|l| cjk_width(l, 10.0) <= 45.0), "{lines:?}");
}
}

View File

@ -47,7 +47,7 @@ pub struct Metrics {
pub composite: bool,
/// Code → Unicode text.
unicode: HashMap<u32, String>,
/// `/BaseFont`, and whether it is a subset (`ABCDEF+Name`: other glyphs are missing).
/// `/BaseFont` (or Type 3 descriptor's `/FontName`), and whether it is a subset (`ABCDEF+Name`: other glyphs are missing).
pub base_font: String,
pub subset: bool,
/// Whether the PDF identifies this face as bold or italic. These come from the
@ -68,12 +68,125 @@ fn dict(doc: &Document, o: Option<&Object>) -> Option<Dict> {
o.and_then(|o| doc.resolve(o).as_dict().cloned())
}
fn hex_bytes(tok: &str) -> Option<Vec<u8>> {
let t = tok.trim_start_matches('<').trim_end_matches('>');
if !t.len().is_multiple_of(2) || t.is_empty() {
return None;
/// Most entries a CMap may add, so a small stream of overlapping `bfrange`s can't take minutes
/// or gigabytes; a full 2-byte code space is 65 536. Entries past it are ignored.
const MAX_CMAP_ENTRIES: usize = 1 << 20;
/// Most codespace or CID ranges kept from a CMap; Adobe's largest published CMaps have a few
/// thousand.
const MAX_CMAP_RANGES: usize = 1 << 16;
/// A token of a CMap stream (PostScript syntax, ISO 32000-2 §9.10.3).
#[derive(Debug, PartialEq)]
enum Tok<'a> {
/// A `<…>` string; `None` when it isn't an even number of hex digits.
Hex(Option<Vec<u8>>),
Open,
Close,
/// An operator, number or name (with its `/`), a `(…)` string or a `<<`/`>>`.
Word(&'a [u8]),
}
/// Splits a CMap into tokens without assuming whitespace between them (`<01><0041>` is two
/// strings), skipping `%` comments.
struct Lexer<'a> {
data: &'a [u8],
pos: usize,
}
impl<'a> Lexer<'a> {
fn new(data: &'a [u8]) -> Self {
Self { data, pos: 0 }
}
fn at(&self, i: usize) -> Option<u8> {
self.data.get(i).copied()
}
}
fn is_cmap_space(b: u8) -> bool {
matches!(b, 0 | b'\t' | b'\n' | 0x0c | b'\r' | b' ')
}
fn is_cmap_delimiter(b: u8) -> bool {
matches!(b, b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%')
}
impl<'a> Iterator for Lexer<'a> {
type Item = Tok<'a>;
fn next(&mut self) -> Option<Tok<'a>> {
loop {
let b = self.at(self.pos)?;
if is_cmap_space(b) {
self.pos += 1;
} else if b == b'%' {
while self.at(self.pos).is_some_and(|c| c != b'\n' && c != b'\r') {
self.pos += 1;
}
} else {
break;
}
}
let start = self.pos;
let b = self.at(start)?;
self.pos += 1;
match b {
b'[' => Some(Tok::Open),
b']' => Some(Tok::Close),
b'<' | b'>' if self.at(self.pos) == Some(b) => {
self.pos += 1;
Some(Tok::Word(self.data.get(start..self.pos)?))
}
b'<' => {
let mut digits = Vec::new();
let mut valid = true;
loop {
match self.at(self.pos) {
// Unterminated: what's left is one bad string.
None => return Some(Tok::Hex(None)),
Some(b'>') => break,
Some(c) if is_cmap_space(c) => {}
Some(c) => match (c as char).to_digit(16) {
// A hex digit is below 16, so it fits a u8.
Some(d) => digits.push(d as u8),
None => valid = false,
},
}
self.pos += 1;
}
self.pos += 1;
if !valid || digits.is_empty() || !digits.len().is_multiple_of(2) {
return Some(Tok::Hex(None));
}
// `chunks` never yields an empty slice, so `p[0]` exists.
Some(Tok::Hex(Some(digits.chunks(2).map(|p| p[0] << 4 | p.get(1).copied().unwrap_or(0)).collect())))
}
b'(' => {
// Literal strings nest and escape their parentheses; only skipping them matters.
let mut depth = 1usize;
while depth > 0 {
match self.at(self.pos) {
None => break,
Some(b'\\') => self.pos += 1,
Some(b'(') => depth += 1,
Some(b')') => depth -= 1,
_ => {}
}
self.pos += 1;
}
Some(Tok::Word(self.data.get(start..self.pos.min(self.data.len()))?))
}
_ => {
// A name keeps its `/`; other delimiters (`)`, `>`, `{`, `}`) stand alone.
if b == b'/' || !is_cmap_delimiter(b) {
while self.at(self.pos).is_some_and(|c| !is_cmap_space(c) && !is_cmap_delimiter(c)) {
self.pos += 1;
}
}
Some(Tok::Word(self.data.get(start..self.pos)?))
}
}
}
(0..t.len()).step_by(2).map(|i| u8::from_str_radix(&t[i..i + 2], 16).ok()).collect()
}
fn be(b: &[u8]) -> u32 {
@ -89,47 +202,64 @@ fn style_from_name(name: &str) -> (bool, bool) {
)
}
/// Codespace ranges and CID mappings from an embedded CMap stream.
/// The next entry of a `begin… end…` section: `N` tokens, or `None` at its `end` operator or
/// the end of the stream.
fn entry<'a, const N: usize>(toks: &mut Lexer<'a>, end: &[u8]) -> Option<[Tok<'a>; N]> {
let mut out = Vec::with_capacity(N);
for _ in 0..N {
match toks.next()? {
Tok::Word(w) if w == end => return None,
t => out.push(t),
}
}
out.try_into().ok()
}
fn number(t: &Tok) -> Option<u32> {
match t {
Tok::Word(w) => std::str::from_utf8(w).ok()?.parse().ok(),
_ => None,
}
}
/// Codespace ranges and CID mappings from an embedded CMap stream, at most
/// [`MAX_CMAP_RANGES`] of each (every glyph searches them).
#[allow(clippy::type_complexity)]
fn parse_cmap(data: &[u8]) -> (Vec<(usize, Vec<u8>, Vec<u8>)>, Vec<(u32, u32, u32)>) {
let text = String::from_utf8_lossy(data);
let toks: Vec<&str> = text.split_whitespace().collect();
let (mut spaces, mut cids) = (Vec::new(), Vec::new());
let mut i = 0;
while i < toks.len() {
match toks[i] {
"begincodespacerange" => {
i += 1;
while i + 1 < toks.len() && toks[i] != "endcodespacerange" {
if let (Some(lo), Some(hi)) = (hex_bytes(toks[i]), hex_bytes(toks[i + 1]))
let mut toks = Lexer::new(data);
while let Some(t) = toks.next() {
match t {
Tok::Word(b"begincodespacerange") => {
while let Some([lo, hi]) = entry(&mut toks, b"endcodespacerange") {
if let (Tok::Hex(Some(lo)), Tok::Hex(Some(hi))) = (lo, hi)
&& lo.len() == hi.len()
&& spaces.len() < MAX_CMAP_RANGES
{
spaces.push((lo.len(), lo, hi));
}
i += 2;
}
}
"begincidrange" => {
i += 1;
while i + 2 < toks.len() && toks[i] != "endcidrange" {
if let (Some(lo), Some(hi), Ok(c)) = (hex_bytes(toks[i]), hex_bytes(toks[i + 1]), toks[i + 2].parse::<u32>()) {
cids.push((be(&lo), be(&hi), c));
Tok::Word(b"begincidrange") => {
while let Some([lo, hi, c]) = entry(&mut toks, b"endcidrange") {
if let (Tok::Hex(Some(lo)), Tok::Hex(Some(hi)), Some(c)) = (&lo, &hi, number(&c))
&& cids.len() < MAX_CMAP_RANGES
{
cids.push((be(lo), be(hi), c));
}
i += 3;
}
}
"begincidchar" => {
i += 1;
while i + 1 < toks.len() && toks[i] != "endcidchar" {
if let (Some(code), Ok(c)) = (hex_bytes(toks[i]), toks[i + 1].parse::<u32>()) {
cids.push((be(&code), be(&code), c));
Tok::Word(b"begincidchar") => {
while let Some([code, c]) = entry(&mut toks, b"endcidchar") {
if let (Tok::Hex(Some(code)), Some(c)) = (&code, number(&c))
&& cids.len() < MAX_CMAP_RANGES
{
cids.push((be(code), be(code), c));
}
i += 2;
}
}
_ => {}
}
i += 1;
}
spaces.sort_by_key(|s| s.0);
(spaces, cids)
@ -162,7 +292,16 @@ impl Metrics {
pub fn from_dict(doc: &Document, font: &Dict) -> Self {
let mut m = Self::read_metrics(doc, font);
let base = String::from_utf8_lossy(font.name(b"BaseFont").unwrap_or(b"")).into_owned();
let base = font
.name(b"BaseFont")
.map(|name| String::from_utf8_lossy(name).into_owned())
.or_else(|| {
(font.name(b"Subtype") == Some(b"Type3"))
.then(|| dict(doc, font.get(b"FontDescriptor")))
.flatten()
.and_then(|d| d.name(b"FontName").map(|name| String::from_utf8_lossy(name).into_owned()))
})
.unwrap_or_default();
m.subset = base.len() > 7 && base.as_bytes()[6] == b'+' && base[..6].bytes().all(|b| b.is_ascii_uppercase());
let (bold, italic) = style_from_name(&base);
m.bold |= bold;
@ -263,11 +402,18 @@ impl Metrics {
let weight = d.get(b"FontWeight").and_then(|v| doc.resolve(v).as_f64()).unwrap_or(0.0);
m.italic |= flags & 64 != 0 || angle.abs() > 0.1;
m.bold |= flags & 262_144 != 0 || weight >= 600.0;
let a = d.get(b"Ascent").and_then(|v| doc.resolve(v).as_f64()).unwrap_or(0.0) / 1000.0;
let de = d.get(b"Descent").and_then(|v| doc.resolve(v).as_f64()).unwrap_or(0.0) / 1000.0;
// Type 3 descriptors may omit these metrics; the glyph-space FontBBox still applies.
let ascent = d.get(b"Ascent").and_then(|v| doc.resolve(v).as_f64());
let descent = d.get(b"Descent").and_then(|v| doc.resolve(v).as_f64());
// Fonts often claim 0; never shrink the glyph box below a sensible minimum.
m.ascent = if a > 0.3 { a.min(1.5) } else { 0.9 };
m.descent = if de < -0.05 { de.max(-0.6) } else { -0.25 };
if ascent.is_some() || subtype != b"Type3" {
let a = ascent.unwrap_or(0.0) / 1000.0;
m.ascent = if a > 0.3 { a.min(1.5) } else { 0.9 };
}
if descent.is_some() || subtype != b"Type3" {
let de = descent.unwrap_or(0.0) / 1000.0;
m.descent = if de < -0.05 { de.max(-0.6) } else { -0.25 };
}
}
m
}
@ -296,7 +442,7 @@ impl Metrics {
if self.cid_map.is_empty() {
return code;
}
self.cid_map.iter().find(|(lo, hi, _)| code >= *lo && code <= *hi).map_or(0, |(lo, _, c)| c + (code - lo))
self.cid_map.iter().find(|(lo, hi, _)| code >= *lo && code <= *hi).map_or(0, |(lo, _, c)| c.saturating_add(code - lo))
}
/// The advance of `code` in text space per unit of font size.
@ -376,60 +522,66 @@ fn utf16(b: &[u8]) -> String {
String::from_utf16_lossy(&units)
}
/// `bfchar`/`bfrange` entries of a ToUnicode CMap.
/// `bfchar`/`bfrange` entries of a ToUnicode CMap, at most [`MAX_CMAP_ENTRIES`] of them.
fn parse_to_unicode(data: &[u8], out: &mut HashMap<u32, String>) {
let text = String::from_utf8_lossy(data);
// Tokenise, keeping arrays' brackets as tokens.
let spaced = text.replace('[', " [ ").replace(']', " ] ");
let toks: Vec<&str> = spaced.split_whitespace().collect();
let mut i = 0;
while i < toks.len() {
match toks[i] {
"beginbfchar" => {
i += 1;
while i + 1 < toks.len() && toks[i] != "endbfchar" {
if let (Some(c), Some(u)) = (hex_bytes(toks[i]), hex_bytes(toks[i + 1])) {
let mut budget = MAX_CMAP_ENTRIES;
let mut toks = Lexer::new(data);
while let Some(t) = toks.next() {
match t {
Tok::Word(b"beginbfchar") => {
while let Some([c, u]) = entry(&mut toks, b"endbfchar") {
if let (Tok::Hex(Some(c)), Tok::Hex(Some(u))) = (c, u) {
let Some(left) = budget.checked_sub(1) else { return };
budget = left;
out.insert(be(&c), utf16(&u));
}
i += 2;
}
}
"beginbfrange" => {
i += 1;
while i + 2 < toks.len() && toks[i] != "endbfrange" {
let (Some(lo), Some(hi)) = (hex_bytes(toks[i]), hex_bytes(toks[i + 1])) else { break };
Tok::Word(b"beginbfrange") => {
while let Some([lo, hi, dst]) = entry(&mut toks, b"endbfrange") {
let (Tok::Hex(Some(lo)), Tok::Hex(Some(hi))) = (lo, hi) else { break };
let (lo, hi) = (be(&lo), be(&hi));
if toks[i + 2] == "[" {
let mut j = i + 3;
let mut code = lo;
while j < toks.len() && toks[j] != "]" {
if let Some(u) = hex_bytes(toks[j]) {
out.insert(code, utf16(&u));
match dst {
// One destination per code, from `lo` up to `hi`.
Tok::Open => {
let mut code = (hi >= lo).then_some(lo);
for t in toks.by_ref() {
let Some(c) = code else {
if t == Tok::Close {
break;
}
continue;
};
match t {
Tok::Close => break,
Tok::Hex(Some(u)) => {
let Some(left) = budget.checked_sub(1) else { return };
budget = left;
out.insert(c, utf16(&u));
}
// A bad entry still stands for its code.
_ => {}
}
code = c.checked_add(1).filter(|c| *c <= hi);
}
code += 1;
j += 1;
}
i = j + 1;
} else {
if let Some(u) = hex_bytes(toks[i + 2])
&& hi >= lo
&& hi - lo < 65536
{
Tok::Hex(Some(u)) if hi >= lo && hi - lo < 65536 => {
let mut units: Vec<u16> = u.chunks(2).map(|c| u16::from_be_bytes([c[0], *c.get(1).unwrap_or(&0)])).collect();
for code in lo..=hi {
let Some(left) = budget.checked_sub(1) else { return };
budget = left;
out.insert(code, String::from_utf16_lossy(&units));
if let Some(last) = units.last_mut() {
*last = last.wrapping_add(1);
}
}
}
i += 3;
_ => {}
}
}
}
_ => {}
}
i += 1;
}
}
@ -468,13 +620,14 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> HashMap<u32, Str
let mut code = 0u32;
for item in diffs {
match &item {
Object::Int(n) => code = (*n).max(0) as u32,
// Out-of-range codes map nothing a simple font can show.
Object::Int(n) => code = u32::try_from((*n).max(0)).unwrap_or(u32::MAX),
Object::Name(n) => {
match glyph_unicode(&String::from_utf8_lossy(n)) {
Some(c) => out.insert(code, c.to_string()),
None => out.remove(&code),
};
code += 1;
code = code.saturating_add(1);
}
_ => {}
}
@ -505,6 +658,36 @@ mod tests {
d
}
#[test]
fn type3_descriptor_names_the_font_without_changing_its_metrics() {
let mut doc = Document::new_empty();
let mut f = font(
&mut doc,
vec![
("Subtype", Object::name("Type3")),
(
"FontMatrix",
Object::Array(vec![Object::Real(0.001), Object::Int(0), Object::Int(0), Object::Real(0.001), Object::Int(0), Object::Int(0)]),
),
("FontBBox", Object::Array(vec![Object::Int(0), Object::Int(-300), Object::Int(1000), Object::Int(1000)])),
],
);
let before = Metrics::from_dict(&doc, &f);
let mut descriptor = Dict::new();
descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor"));
descriptor.set(b"FontName".to_vec(), Object::name("ExampleMincho-Regular"));
descriptor.set(b"Flags".to_vec(), Object::Int(6));
descriptor.set(b"ItalicAngle".to_vec(), Object::Int(0));
let reference = doc.add(Object::Dict(descriptor));
f.set(b"FontDescriptor".to_vec(), Object::Ref(reference));
let after = Metrics::from_dict(&doc, &f);
assert_eq!(after.base_font, "ExampleMincho-Regular");
assert_eq!((after.ascent, after.descent, after.scale), (before.ascent, before.descent, before.scale));
assert!(!after.bold && !after.italic && !after.subset);
f.set(b"BaseFont".to_vec(), Object::name("ExplicitName"));
assert_eq!(Metrics::from_dict(&doc, &f).base_font, "ExplicitName", "an explicit BaseFont takes precedence");
}
#[test]
fn simple_fonts_decode_and_encode_through_their_encoding() {
let mut doc = Document::new_empty();
@ -591,4 +774,74 @@ mod tests {
assert_eq!(glyph_unicode("u1F600"), Some('😀'));
assert_eq!(glyph_unicode("nonsense"), None);
}
fn to_unicode(cmap: &[u8]) -> HashMap<u32, String> {
let mut out = HashMap::new();
parse_to_unicode(cmap, &mut out);
out
}
#[test]
fn cmap_tokens_need_no_space_between_them() {
// Hex strings, brackets and operators may touch; comments and literal strings are skipped.
let cmap = b"/CIDSystemInfo << /Registry (Adobe (x) \\) beginbfchar) >> def % beginbfchar <0001> <0058>\n\
2 beginbfchar<0003><0020><0010><00 E9>endbfchar \
1 beginbfrange<0024><0026><0041>endbfrange 1 beginbfrange<0030><0031>[<0048><0069>]endbfrange";
let map = to_unicode(cmap);
let mut got: Vec<_> = map.iter().map(|(c, u)| (*c, u.as_str())).collect();
got.sort_unstable();
assert_eq!(got, [(3, " "), (0x10, "é"), (0x24, "A"), (0x25, "B"), (0x26, "C"), (0x30, "H"), (0x31, "i")]);
let (spaces, cids) = parse_cmap(b"1 begincodespacerange<00><FF>endcodespacerange 1 begincidrange<20><7E>1 endcidrange");
assert_eq!(spaces, [(1, vec![0], vec![0xff])]);
assert_eq!(cids, [(0x20, 0x7e, 1)]);
}
#[test]
fn malformed_cmaps_never_panic() {
// Non-ASCII bytes inside hex strings once split a UTF-8 character and panicked.
for cmap in [
"1 beginbfchar <0é0> <0041> <AéB> <0042> endbfchar".as_bytes(),
"1 beginbfrange <0é0> <00é> <0041> endbfrange".as_bytes(),
b"1 beginbfchar <0001> <004",
b"1 beginbfchar <123> <0041> <0002> <zz> <0003> endbfchar",
b"1 beginbfrange <FFFF> <0001> [<0041>] endbfrange",
b"1 beginbfrange <0001> <0002> [<0041> (",
b"1 beginbfrange <0001>",
b"\xff\xfe beginbfchar \x80<\xc3\xa9> <0041> endbfchar %",
] {
to_unicode(cmap);
parse_cmap(cmap);
}
// Code counters at the top of the range stop instead of overflowing.
let map = to_unicode(b"1 beginbfrange <FFFFFFFE> <FFFFFFFF> [<0041> <0042> <0043>] endbfrange");
assert_eq!(map.len(), 2);
assert_eq!(map.get(&u32::MAX).map(String::as_str), Some("B"));
// A malformed array entry keeps its place, so the next one maps the next code.
let map = to_unicode(b"1 beginbfrange <0001> <0003> [<0041> <zz> <0043>] endbfrange");
assert_eq!((map.get(&2), map.get(&3).map(String::as_str)), (None, Some("C")));
let mut m = Metrics::fallback();
(_, m.cid_map) = parse_cmap(b"1 begincidrange <0000> <FFFF> 4294967295 endcidrange");
assert_eq!(m.cid(5), u32::MAX);
let mut doc = Document::new_empty();
let mut enc = Dict::new();
enc.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(i64::MAX), Object::name("A"), Object::name("B")]));
let f = font(&mut doc, vec![("Subtype", Object::name("Type1")), ("Encoding", Object::Dict(enc))]);
assert_eq!(unicode_map(&doc, &f, false).get(&u32::MAX).map(String::as_str), Some("B"));
}
#[test]
fn a_cmap_adds_at_most_max_entries() {
// 200 ranges of 65 536 codes would be 13 million strings from 4 KB of stream.
let mut cmap = b"200 beginbfrange ".to_vec();
for k in 0..200u32 {
cmap.extend(format!("<{:08X}> <{:08X}> <0041> ", k << 16, (k << 16) | 0xffff).bytes());
}
cmap.extend(b"endbfrange");
assert_eq!(to_unicode(&cmap).len(), MAX_CMAP_ENTRIES);
let mut many = b"begincidrange ".to_vec();
for k in 0..MAX_CMAP_RANGES + 10 {
many.extend(format!("<{k:08X}> <{k:08X}> 1 ").bytes());
}
assert_eq!(parse_cmap(&many).1.len(), MAX_CMAP_RANGES);
}
}

View File

@ -2,7 +2,7 @@
use skrifa::instance::{LocationRef, Size};
use skrifa::outline::{DrawSettings, OutlinePen};
use skrifa::{FontRef, MetadataProvider};
use skrifa::{FontRef, GlyphId, MetadataProvider};
static FONT: &[u8] = include_bytes!("../../../assets/fonts/DancingScript.ttf");
@ -129,6 +129,12 @@ impl OutlinePen for Flatten {
/// outline data. [`GlyphError::NoFont`] when the build has no Japanese face.
pub fn japanese_glyph(ch: char) -> Result<GlyphOutline, GlyphError> {
let Some(face) = crate::document_japanese_font() else { return Err(GlyphError::NoFont) };
japanese_glyph_from(face, ch)
}
/// The bounded outline and advance from the explicitly selected craft-fonts face.
/// Uses the same complexity and missing-glyph checks as [`japanese_glyph`].
pub fn japanese_glyph_from(face: &crate::CraftFont, ch: char) -> Result<GlyphOutline, GlyphError> {
let Ok(font) = FontRef::new(face.bytes) else { return Err(GlyphError::Missing) };
let loc = LocationRef::default();
let metrics = font.metrics(Size::unscaled(), loc);
@ -139,6 +145,13 @@ pub fn japanese_glyph(ch: char) -> Result<GlyphOutline, GlyphError> {
if !width.is_finite() || width <= 0.0 || width > 2.0 {
return Err(GlyphError::Missing);
}
bounded_outline(&font, gid, width)
}
/// Glyph `gid` of `font` in em units with advance `width`, bounded like [`japanese_glyph`].
pub(crate) fn bounded_outline(font: &FontRef, gid: GlyphId, width: f64) -> Result<GlyphOutline, GlyphError> {
let loc = LocationRef::default();
let scale = 1.0 / font.metrics(Size::unscaled(), loc).units_per_em.max(1) as f64;
let Some(glyph) = font.outline_glyphs().get(gid) else { return Err(GlyphError::Missing) };
let mut pen = Flatten::new(scale);
let _ = glyph.draw(DrawSettings::unhinted(Size::unscaled(), loc), &mut pen);

View File

@ -17,3 +17,4 @@ thiserror = { workspace = true }
[dev-dependencies]
hayro-syntax = { workspace = true }
pdfcraft-render = { workspace = true }

View File

@ -39,5 +39,6 @@ delete_field(&mut doc, "email")?;
## Not yet
JavaScript actions (format, keystroke, validate, calculate; M6.4/M6.5), rich-text values,
`/MK /R` rotation, the Appearance (colours, fonts), Actions, Format, Validate and Calculate
property tabs, FDF/XFDF import and export (M6.7).
the Appearance (colours, fonts), Actions, Format, Validate and Calculate property tabs,
FDF/XFDF import and export (M6.7). `/MK /R` rotation (0, 90, 180, 270) is drawn into the
appearance.

View File

@ -5,12 +5,18 @@
//! marked-content block as viewers expect. Supported: single-line, multiline (wrapped), comb,
//! password, quadding, auto font size (`0 Tf`), combo boxes and list boxes (selection shown).
//!
//! The `/DA` font is used when the form's `/DR` defines it as a simple font; text is encoded in
//! WinAnsi. Otherwise (composite fonts, missing resources) Helvetica is used, so text is always
//! visible. Widths use the approximate Helvetica metrics of `pdfcraft-fonts`.
//! The `/DA` font is used when the form's `/DR` defines it as a simple font (text encoded in
//! WinAnsi) or as a composite font with a predefined Unicode CMap such as `UniJIS-UTF16-H`, as
//! Japanese forms use (text encoded in that CMap). Otherwise (other composite fonts, missing
//! resources) Helvetica is used, so text is always visible. Widths use the approximate metrics
//! of `pdfcraft-fonts` (Helvetica, or one em per full-width character in composite fonts).
//!
//! `/MK /R` (0, 90, 180, 270, counterclockwise) lays the content out in the upright box and sets
//! the appearance `/Matrix` so that box lands on the widget rectangle. Any other value is 0, and
//! rotation 0 leaves `/Matrix` off.
use pdfcraft_cos::{Dict, Document, Object, Stream};
use pdfcraft_fonts::{helvetica_width, literal, win_ansi, wrap};
use pdfcraft_fonts::{UnicodeCMap, cjk_width, helvetica_width, literal, win_ansi, wrap_with};
use crate::{Field, FieldKind, Widget, acroform, flags};
@ -26,6 +32,57 @@ pub fn fmt(v: f64) -> String {
n(v)
}
/// `/MK /R` as a quadrant, or 0 when it is missing or not a multiple of 90.
pub(crate) fn mk_rotation(doc: &Document, wd: &Dict) -> i64 {
let Some(raw) = wd.get(b"MK") else { return 0 };
let mk_obj = doc.resolve(raw);
let Some(mk) = mk_obj.as_dict() else { return 0 };
let Some(r_obj) = mk.get(b"R") else { return 0 };
let resolved = doc.resolve(r_obj);
let Some(r) = degrees(&resolved) else { return 0 };
match r.rem_euclid(360) {
q @ (0 | 90 | 180 | 270) => q,
_ => 0,
}
}
fn degrees(o: &Object) -> Option<i64> {
if let Some(i) = o.as_int() {
return Some(i);
}
let v = o.as_f64()?;
(v.is_finite() && v.abs() <= 1.0e6).then_some(v.round() as i64)
}
/// Where an appearance is drawn: the upright layout box, and the `/Matrix` that turns it onto
/// the widget rectangle. Rotation 0 has no matrix.
#[derive(Clone, Copy, Debug)]
pub(crate) struct Placement {
pub layout_w: f64,
pub layout_h: f64,
pub matrix: Option<[f64; 6]>,
}
pub(crate) fn placement(rotation: i64, page_w: f64, page_h: f64) -> Placement {
let (layout_w, layout_h, matrix) = match rotation {
// (x, y) -> (page_w - y, x): text that ran to the right runs upward.
90 => (page_h, page_w, Some([0.0, 1.0, -1.0, 0.0, page_w, 0.0])),
180 => (page_w, page_h, Some([-1.0, 0.0, 0.0, -1.0, page_w, page_h])),
// (x, y) -> (y, page_h - x): text that ran to the right runs downward.
270 => (page_h, page_w, Some([0.0, -1.0, 1.0, 0.0, 0.0, page_h])),
_ => (page_w, page_h, None),
};
Placement { layout_w, layout_h, matrix }
}
pub(crate) fn set_form_box(d: &mut Dict, place: Placement) {
let box_ = [0.0, 0.0, place.layout_w, place.layout_h];
d.set(b"BBox".to_vec(), Object::Array(box_.iter().map(|v| Object::Real(*v)).collect()));
if let Some(m) = place.matrix {
d.set(b"Matrix".to_vec(), Object::Array(m.iter().map(|v| Object::Real(*v)).collect()));
}
}
/// A parsed default appearance string: font resource name, size (0 = auto) and colour operator.
#[derive(Clone, Debug, PartialEq)]
pub struct Da {
@ -53,8 +110,32 @@ pub fn parse_da(da: &str) -> Da {
out
}
/// The font resource for `name` from the form's `/DR`, if it is a simple font we can encode for.
fn dr_font(doc: &Document, name: &str) -> Option<Object> {
/// How a field font's text is encoded and measured.
#[derive(Clone, Copy, Debug, PartialEq)]
enum FontText {
WinAnsi,
/// A composite font with a predefined Unicode CMap.
Unicode(UnicodeCMap),
}
impl FontText {
fn encode(self, s: &str) -> Vec<u8> {
match self {
Self::WinAnsi => win_ansi(s),
Self::Unicode(cmap) => cmap.encode(s),
}
}
fn width(self, s: &str, size: f64) -> f64 {
match self {
Self::WinAnsi => helvetica_width(s, size),
Self::Unicode(_) => cjk_width(s, size),
}
}
}
/// The font resource for `name` from the form's `/DR`, if it is a font we can encode for.
fn dr_font(doc: &Document, name: &str) -> Option<(Object, FontText)> {
let af = acroform(doc)?;
let dr = doc.resolve(af.get(b"DR")?);
let fonts = doc.resolve(dr.as_dict()?.get(b"Font")?);
@ -63,13 +144,17 @@ fn dr_font(doc: &Document, name: &str) -> Option<Object> {
let fd = font.as_dict()?;
match fd.name(b"Subtype") {
Some(b"Type1" | b"TrueType" | b"MMType1") => {}
// Japanese, Chinese and Korean forms: a CID font addressed by Unicode (a non-embedded
// `/HeiseiMin-W3` with `/UniJIS-UCS2-H`, say). Embedded CMap streams and Identity-H need
// the font's own mapping, so those still fall back to Helvetica.
Some(b"Type0") => return Some((entry, FontText::Unicode(UnicodeCMap::from_name(fd.name(b"Encoding")?)?))),
_ => return None,
}
// Symbolic fonts (ZapfDingbats, Symbol) can't show WinAnsi text.
if matches!(fd.name(b"BaseFont"), Some(b"ZapfDingbats" | b"Symbol")) {
return None;
}
Some(entry)
Some((entry, FontText::WinAnsi))
}
fn helvetica() -> Object {
@ -192,12 +277,15 @@ pub fn field_appearance_as(doc: &Document, f: &Field, w: &Widget, values: &[Stri
};
let wobj = doc.get(w.obj);
let wd = wobj.as_dict().cloned().unwrap_or_default();
let (width, height) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0));
let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0));
let place = placement(mk_rotation(doc, &wd), page_w, page_h);
let (width, height) = (place.layout_w, place.layout_h);
let da = parse_da(wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).as_deref().unwrap_or(&f.da));
let (font_name, font_obj) = match dr_font(doc, &da.font) {
Some(o) => (da.font.clone(), o),
None => ("Helv".to_string(), helvetica()),
let (font_name, font_obj, enc) = match dr_font(doc, &da.font) {
Some((o, enc)) => (da.font.clone(), o, enc),
None => ("Helv".to_string(), helvetica(), FontText::WinAnsi),
};
let width_of = |text: &str, size: f64| enc.width(text, size);
let (mut c, bw) = frame(doc, &wd, width, height);
let pad = 2.0 + bw;
let inner_w = (width - 2.0 * pad).max(1.0);
@ -205,11 +293,11 @@ pub fn field_appearance_as(doc: &Document, f: &Field, w: &Widget, values: &[Stri
let mut body: Vec<u8> = Vec::new();
let show = |body: &mut Vec<u8>, x: f64, y: f64, text: &str| {
body.extend(format!("1 0 0 1 {} {} Tm ", n(x), n(y)).bytes());
body.extend(literal(&win_ansi(text)));
body.extend(literal(&enc.encode(text)));
body.extend_from_slice(b" Tj\n");
};
let x_for = |text: &str, size: f64| -> f64 {
let tw = helvetica_width(text, size);
let tw = width_of(text, size);
match q {
1 => pad + (inner_w - tw) / 2.0,
2 => width - pad - tw,
@ -249,12 +337,12 @@ pub fn field_appearance_as(doc: &Document, f: &Field, w: &Widget, values: &[Stri
if size == 0.0 {
// Auto size: the largest size (≤ 12) whose wrapped lines fit the height.
size = 12.0;
while size > 4.0 && wrap(&text, size, inner_w).len() as f64 * size * 1.15 > height - 2.0 * pad {
while size > 4.0 && wrap_with(&text, size, inner_w, width_of).len() as f64 * size * 1.15 > height - 2.0 * pad {
size -= 0.5;
}
}
let mut y = height - pad - size * 0.85;
for line in wrap(&text, size, inner_w) {
for line in wrap_with(&text, size, inner_w, width_of) {
if y < -size {
break;
}
@ -264,7 +352,7 @@ pub fn field_appearance_as(doc: &Document, f: &Field, w: &Widget, values: &[Stri
} else {
if size == 0.0 {
size = ((height - 2.0 * pad) / 1.15).clamp(4.0, 12.0);
let tw = helvetica_width(&text, size);
let tw = width_of(&text, size);
if tw > inner_w && !comb {
size = (size * inner_w / tw).max(4.0);
}
@ -276,7 +364,7 @@ pub fn field_appearance_as(doc: &Document, f: &Field, w: &Widget, values: &[Stri
let cell = width / cells as f64;
for (i, ch) in text.chars().take(cells).enumerate() {
let s = ch.to_string();
show(&mut body, cell * i as f64 + (cell - helvetica_width(&s, size)) / 2.0, y, &s);
show(&mut body, cell * i as f64 + (cell - width_of(&s, size)) / 2.0, y, &s);
}
} else {
show(&mut body, x_for(&text, size), y, &text);
@ -307,7 +395,7 @@ pub fn field_appearance_as(doc: &Document, f: &Field, w: &Widget, values: &[Stri
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("XObject"));
d.set(b"Subtype".to_vec(), Object::name("Form"));
d.set(b"BBox".to_vec(), Object::Array([0.0, 0.0, width, height].iter().map(|v| Object::Real(*v)).collect()));
set_form_box(&mut d, place);
d.set(b"Resources".to_vec(), Object::Dict(res));
Stream::flate(d, &content)
}
@ -318,20 +406,22 @@ pub fn field_appearance_as(doc: &Document, f: &Field, w: &Widget, values: &[Stri
pub fn check_box_states(doc: &mut Document, w: &Widget, kind: FieldKind, on_name: &str) -> Dict {
let wobj = doc.get(w.obj);
let wd = wobj.as_dict().cloned().unwrap_or_default();
let (width, height) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0));
let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0));
let place = placement(mk_rotation(doc, &wd), page_w, page_h);
let (width, height) = (place.layout_w, place.layout_h);
let (frame_c, _) = frame(doc, &wd, width, height);
let style = crate::author::CheckStyle::of_widget(doc, &wd, kind);
let mut form = |content: String| -> Object {
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("XObject"));
d.set(b"Subtype".to_vec(), Object::name("Form"));
d.set(b"BBox".to_vec(), Object::Array([0.0, 0.0, width, height].iter().map(|v| Object::Real(*v)).collect()));
set_form_box(&mut d, place);
Object::Ref(doc.add(Object::Stream(Stream::flate(d, content.as_bytes()))))
};
let s = width.min(height);
let mark = check_mark(style, width / 2.0, height / 2.0, s);
let mut nd = Dict::new();
nd.set(on_name.as_bytes().to_vec(), form(format!("{frame_c}{mark}")));
nd.set(crate::name_bytes(on_name), form(format!("{frame_c}{mark}")));
nd.set(b"Off".to_vec(), form(frame_c));
let mut ap = Dict::new();
ap.set(b"N".to_vec(), Object::Dict(nd));

View File

@ -91,6 +91,10 @@ pub struct FieldProps {
pub actions: Option<Vec<(crate::Trigger, crate::FieldAction)>>,
/// Options tab: the mark of a check box or radio button (every widget).
pub check_style: Option<CheckStyle>,
/// Position tab: rotate one widget (`/MK /R`) to 0, 90, 180 or 270 degrees counterclockwise.
/// Changing between horizontal and vertical swaps that widget's width and height around its
/// center, unless [`Self::rect`] is also set.
pub rotation: Option<(usize, i64)>,
}
fn invalid<T>(m: impl Into<String>) -> Result<T, FormError> {
@ -524,6 +528,7 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel
tab: usize::MAX,
locked: false,
hidden: false,
rotation: 0,
};
let ap = appearance::check_box_states(doc, &w, FieldKind::Radio, export);
doc.update_dict(widget, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?;
@ -569,15 +574,18 @@ pub fn redraw_field(doc: &mut Document, name: &str) -> Result<(), FormError> {
d
}
};
doc.update_dict(w.obj, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?;
let existing = crate::merged_appearance(doc, w.obj, &ap);
doc.update_dict(w.obj, |d| d.set(b"AP".to_vec(), Object::Dict(existing)))?;
}
Ok(())
}
fn frame_only(doc: &Document, w: &Widget) -> (String, f64, f64) {
let (width, height) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0));
fn frame_only(doc: &Document, w: &Widget) -> (String, crate::appearance::Placement) {
let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0));
let wobj = doc.get(w.obj);
let wd = wobj.as_dict().cloned().unwrap_or_default();
let place = crate::appearance::placement(crate::appearance::mk_rotation(doc, &wd), page_w, page_h);
let (width, height) = (place.layout_w, place.layout_h);
let mk = wd.get(b"MK").and_then(|m| m.as_dict()).cloned().unwrap_or_default();
let col = |k: &[u8]| -> Option<String> {
let v: Vec<f64> = mk.get(k)?.as_array()?.iter().filter_map(|x| x.as_f64()).collect();
@ -590,14 +598,14 @@ fn frame_only(doc: &Document, w: &Widget) -> (String, f64, f64) {
if let Some(bc) = col(b"BC") {
c.push_str(&format!("{bc} RG 1 w 0.5 0.5 {:.3} {:.3} re S\n", width - 1.0, height - 1.0));
}
(c, width, height)
(c, place)
}
fn form_stream(width: f64, height: f64, content: Vec<u8>, resources: Dict) -> Stream {
fn form_stream(place: crate::appearance::Placement, content: Vec<u8>, resources: Dict) -> Stream {
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("XObject"));
d.set(b"Subtype".to_vec(), Object::name("Form"));
d.set(b"BBox".to_vec(), Object::Array([0.0, 0.0, width, height].iter().map(|v| Object::Real(*v)).collect()));
crate::appearance::set_form_box(&mut d, place);
d.set(b"Resources".to_vec(), Object::Dict(resources));
Stream::flate(d, &content)
}
@ -605,11 +613,12 @@ fn form_stream(width: f64, height: f64, content: Vec<u8>, resources: Dict) -> St
/// A push button: background, border, its icon (`/MK /I`, scaled to fit and centred) and its
/// centred caption (unless the layout is icon only, `/TP 1`).
fn button_appearance(doc: &Document, w: &Widget) -> Stream {
let (mut c, width, height) = frame_only(doc, w);
let (mut c, place) = frame_only(doc, w);
let (width, height) = (place.layout_w, place.layout_h);
let wobj = doc.get(w.obj);
let mk = wobj.as_dict().and_then(|d| d.get(b"MK")).map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()).unwrap_or_default();
let caption = mk.get(b"CA").and_then(|c| c.as_string()).map(|s| s.to_text()).unwrap_or_default();
let icon_only = mk.int(b"TP") == Some(1);
let caption = mk.get(b"CA").map(|c| doc.resolve(c)).and_then(|c| c.as_string().map(|s| s.to_text())).unwrap_or_default();
let icon_only = mk.get(b"TP").and_then(|t| doc.resolve(t).as_int()) == Some(1);
let mut content = std::mem::take(&mut c).into_bytes();
let mut xobjects = Dict::new();
if let Some(icon) = mk.get(b"I").and_then(Object::as_ref)
@ -646,7 +655,7 @@ fn button_appearance(doc: &Document, w: &Widget) -> Stream {
if !xobjects.is_empty() {
res.set(b"XObject".to_vec(), Object::Dict(xobjects));
}
form_stream(width, height, content, res)
form_stream(place, content, res)
}
/// Give a push button (an image field) the picture `image` (an image XObject of `px` pixels):
@ -662,7 +671,8 @@ pub fn set_button_icon(doc: &mut Document, name: &str, image: ObjRef, px: (u32,
xo.set(b"Im0".to_vec(), Object::Ref(image));
let mut res = Dict::new();
res.set(b"XObject".to_vec(), Object::Dict(xo));
let icon = doc.add(Object::Stream(form_stream(w, h, format!("q {w} 0 0 {h} 0 0 cm /Im0 Do Q").into_bytes(), res)));
let icon =
doc.add(Object::Stream(form_stream(crate::appearance::placement(0, w, h), format!("q {w} 0 0 {h} 0 0 cm /Im0 Do Q").into_bytes(), res)));
for wd in &f.widgets {
let mut mk =
doc.get(wd.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m))).and_then(|m| m.as_dict().cloned()).unwrap_or_default();
@ -676,14 +686,43 @@ pub fn set_button_icon(doc: &mut Document, name: &str, image: ObjRef, px: (u32,
}
fn empty_box(doc: &Document, w: &Widget) -> Stream {
let (c, width, height) = frame_only(doc, w);
form_stream(width, height, c.into_bytes(), Dict::new())
let (c, place) = frame_only(doc, w);
form_stream(place, c.into_bytes(), Dict::new())
}
/// Swap width and height, keeping the center. `None` when that is not a usable widget.
fn swapped_around_center(r: [f64; 4]) -> Option<[f64; 4]> {
if !r.iter().all(|v| v.is_finite()) {
return None;
}
let (x0, y0, x1, y1) = (r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3]));
let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
let (nw, nh) = (y1 - y0, x1 - x0);
let out = [cx - nw / 2.0, cy - nh / 2.0, cx + nw / 2.0, cy + nh / 2.0];
(out.iter().all(|v| v.is_finite()) && out[2] - out[0] >= 4.0 && out[3] - out[1] >= 4.0).then_some(out)
}
/// Change a field's properties (General and Options tabs) and redraw it.
pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<String, FormError> {
let all = fields(doc);
let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?.clone();
// Validated before anything is written, so a refused rotation leaves the document unchanged.
// `Some(Some(rect))`: the widget's axis changes and it gets the swapped rectangle.
let mut rotated_rect: Option<Option<[f64; 4]>> = None;
if let Some((wi, rot)) = props.rotation {
if !matches!(rot, 0 | 90 | 180 | 270) {
return invalid("rotation must be 0, 90, 180 or 270 degrees");
}
let Some(w) = f.widgets.get(wi) else {
return invalid(format!("{name} has no widget {}", wi.saturating_add(1)));
};
let axis_change = (w.rotation % 180 == 0) != (rot % 180 == 0);
rotated_rect = Some(None);
if axis_change && props.rect.is_none() {
let Some(rect) = swapped_around_center(w.rect) else { return invalid("the field is too small") };
rotated_rect = Some(Some(rect));
}
}
// A locked field only takes unlocking (Acrobat greys its properties out).
if f.locked() && props.locked != Some(false) && *props != (FieldProps { locked: props.locked, ..FieldProps::default() }) {
return invalid(format!("{name} is locked: unlock it first"));
@ -809,7 +848,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
}
match &props.default_value {
// Buttons name their default state; text and choices hold a string.
Some(Some(v)) if matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) => d.set(b"DV".to_vec(), Object::name(v)),
Some(Some(v)) if matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) => d.set(b"DV".to_vec(), Object::Name(crate::name_bytes(v))),
Some(Some(v)) => d.set(b"DV".to_vec(), PdfString::text(v)),
Some(None) => {
d.remove(b"DV");
@ -943,6 +982,26 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
}
}
}
if let (Some((wi, rot)), Some(swapped)) = (props.rotation, rotated_rect)
&& let Some(w) = f.widgets.get(wi)
{
if let Some(rect) = swapped {
doc.update_dict(w.obj, |d| d.set(b"Rect".to_vec(), Object::Array(rect.iter().map(|v| Object::Real(*v)).collect())))?;
}
let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default();
if rot == 0 {
mk.remove(b"R");
} else {
mk.set(b"R".to_vec(), Object::Int(rot));
}
doc.update_dict(w.obj, |d| {
if mk.is_empty() {
d.remove(b"MK");
} else {
d.set(b"MK".to_vec(), Object::Dict(mk));
}
})?;
}
redraw_field(doc, &new_name)?;
if props.calculate.is_some() {
crate::recalculate(doc)?;

View File

@ -101,6 +101,8 @@ pub struct Widget {
pub locked: bool,
/// The widget's Hidden or NoView flag (`/F` bit 2 or 6): it isn't shown and takes no input.
pub hidden: bool,
/// `/MK /R` as 0, 90, 180 or 270 degrees counterclockwise. Anything else is stored as 0.
pub rotation: i64,
}
/// A terminal form field.
@ -144,6 +146,35 @@ impl Field {
self.widgets.iter().any(|w| w.locked)
}
/// Check boxes and radio groups with `/Opt` (PDF 1.4): one export value per widget, in widget
/// order. Their on states are then often just positions (`/0`, `/1` …), as pdf-lib writes them.
fn button_exports(&self) -> Option<&[(String, String)]> {
(matches!(self.kind, FieldKind::CheckBox | FieldKind::Radio) && !self.options.is_empty() && self.options.len() == self.widgets.len())
.then_some(self.options.as_slice())
}
/// The export value of widget `i` of a check box or radio group: its `/Opt` entry when the
/// field has one per widget, otherwise the widget's on-state name.
pub fn export_of(&self, i: usize) -> Option<&str> {
let on = self.widgets.get(i)?.on_state.as_deref()?;
Some(self.button_exports().and_then(|o| o.get(i)).map_or(on, |(e, _)| e.as_str()))
}
/// The on-state name that selects `choice` in a check box or radio group, where `choice` is an
/// on-state name or an export value from `/Opt`.
pub fn state_for(&self, choice: &str) -> Option<&str> {
self.widgets.iter().filter_map(|w| w.on_state.as_deref()).find(|s| *s == choice).or_else(|| {
let i = (0..self.widgets.len()).find(|&i| self.export_of(i) == Some(choice))?;
self.widgets.get(i)?.on_state.as_deref()
})
}
/// The export value for an on-state name such as the field's value; the name itself when the
/// field has no `/Opt`.
pub fn export_for_state<'a>(&'a self, state: &'a str) -> &'a str {
self.widgets.iter().position(|w| w.on_state.as_deref() == Some(state)).and_then(|i| self.export_of(i)).unwrap_or(state)
}
/// The value as one string: text, the state name, or the selected display texts.
pub fn display_value(&self) -> String {
match self.kind {
@ -175,11 +206,49 @@ pub enum FieldValue {
fn text_of(o: &Object) -> Option<String> {
match o {
Object::String(s) => Some(s.to_text()),
Object::Name(n) => Some(String::from_utf8_lossy(n).into_owned()),
Object::Name(n) => Some(name_text(n)),
_ => None,
}
}
/// A name object's bytes as text that [`name_bytes`] turns back into the same bytes. UTF-8 names
/// read as themselves; other bytes (Shift-JIS check box states such as 「はい」 in Japanese
/// forms) and `#` are written `#XX`, as in PDF name syntax.
pub fn name_text(bytes: &[u8]) -> String {
let escape = |b: u8| format!("#{b:02X}");
match std::str::from_utf8(bytes) {
Ok(s) => s.replace('#', "#23"),
Err(_) => bytes.iter().map(|&b| if (0x21..=0x7e).contains(&b) && b != b'#' { char::from(b).to_string() } else { escape(b) }).collect(),
}
}
/// The bytes of the name written as `text` by [`name_text`] (`#XX` is the byte XX).
pub fn name_bytes(text: &str) -> Vec<u8> {
let b = text.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while let Some(&c) = b.get(i) {
let hex = |j: usize| b.get(j).and_then(|h| char::from(*h).to_digit(16));
match (c, hex(i + 1), hex(i + 2)) {
// Both digits are below 16, so the byte fits.
(b'#', Some(hi), Some(lo)) => {
out.push(u8::try_from(hi * 16 + lo).unwrap_or(b'#'));
i += 3;
}
_ => {
out.push(c);
i += 1;
}
}
}
out
}
/// A name object from text written by [`name_text`].
fn name_obj(text: &str) -> Object {
Object::Name(name_bytes(text))
}
/// At most this many `/State` entries of a set-layer-visibility action are read.
const MAX_LAYER_STATE: usize = 1024;
@ -625,16 +694,17 @@ fn walk(
.map(|ap| doc.resolve(ap))
.and_then(|ap| ap.as_dict().and_then(|a| a.get(b"N").cloned()))
.and_then(|n| doc.resolve(&n).as_dict().cloned())
.and_then(|n| n.iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).find(|k| k != "Off"));
.and_then(|n| n.iter().map(|(k, _)| name_text(k)).find(|k| k != "Off"));
Some(Widget {
obj: w,
page: page_of.get(&w).copied(),
rect: [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])],
on_state: on_state.filter(|_| matches!(kind, FieldKind::CheckBox | FieldKind::Radio)),
state: wd.name(b"AS").map(|s| String::from_utf8_lossy(s).into_owned()),
state: wd.name(b"AS").map(name_text),
tab: usize::MAX,
locked: annot_flags & 128 != 0,
hidden: annot_flags & (2 | 32) != 0,
rotation: appearance::mk_rotation(doc, wd),
})
})
.collect();
@ -780,7 +850,7 @@ fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scripts: &mut
// The on-state name or a yes/no word also work (agents, FDF-style data).
FieldValue::Text(t) | FieldValue::Radio(Some(t)) => {
let t = t.trim();
if t == state || ["yes", "true", "on", "1", "x", "checked"].contains(&t.to_lowercase().as_str()) {
if t == state || f.state_for(t).is_some() || ["yes", "true", "on", "1", "x", "checked"].contains(&t.to_lowercase().as_str()) {
true
} else if t.is_empty() || ["no", "false", "off", "0", "unchecked"].contains(&t.to_lowercase().as_str()) {
false
@ -801,10 +871,12 @@ fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scripts: &mut
FieldValue::Check(false) => None,
_ => return invalid(format!("{:?} is a radio group: choose one of its options", f.name)),
};
// An export value from /Opt selects its widget's on state.
let choice = choice.map(|c| f.state_for(&c).map(str::to_owned).unwrap_or(c));
if let Some(c) = &choice
&& !f.widgets.iter().any(|w| w.on_state.as_deref() == Some(c.as_str()))
{
let opts: Vec<&str> = f.widgets.iter().filter_map(|w| w.on_state.as_deref()).collect();
let opts: Vec<&str> = (0..f.widgets.len()).filter_map(|i| f.export_of(i)).collect();
return invalid(format!("{:?} has no option {c:?} (options: {})", f.name, opts.join(", ")));
}
if choice.is_none() && f.has(flags::NO_TOGGLE_TO_OFF) && !f.value.is_empty() {
@ -862,7 +934,7 @@ fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scripts: &mut
/// Check boxes and radio buttons: `/V` on the field, `/AS` on each widget.
fn set_states(doc: &mut Document, f: &Field, on: Option<&str>) -> Result<(), FormError> {
let v = on.unwrap_or("Off");
doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), Object::name(v)))?;
doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), name_obj(v)))?;
for w in &f.widgets {
let state = match (on, w.on_state.as_deref()) {
(Some(c), Some(s)) if c == s => s,
@ -875,11 +947,54 @@ fn set_states(doc: &mut Document, f: &Field, on: Option<&str>) -> Result<(), For
let ap = appearance::check_box_states(doc, w, f.kind, &on_name);
doc.update_dict(w.obj, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?;
}
doc.update_dict(w.obj, |d| d.set(b"AS".to_vec(), Object::name(state)))?;
doc.update_dict(w.obj, |d| d.set(b"AS".to_vec(), name_obj(state)))?;
}
Ok(())
}
/// Copy an existing appearance dictionary before replacing its normal appearance.
fn appearance_dict(doc: &Document, widget: ObjRef) -> Dict {
doc.get(widget).as_dict().and_then(|d| d.get(b"AP").map(|a| doc.resolve(a))).and_then(|a| a.as_dict().cloned()).unwrap_or_default()
}
/// `widget`'s appearance dictionary (a copy, so a shared one is left alone) with the freshly
/// drawn entries of `fresh` (its `/N`). Other entries are kept, but the old down and rollover
/// appearances (`/D`, `/R`) would show the old value, caption or colour on press or hover, so
/// they go — except, for check boxes and radio buttons (whose `/N` is a dictionary of states),
/// the states the new `/N` still draws under the same names.
pub(crate) fn merged_appearance(doc: &Document, widget: ObjRef, fresh: &Dict) -> Dict {
let mut apd = appearance_dict(doc, widget);
// Only a dictionary of states counts (`as_dict` would also see a stream's own dictionary).
let states_of = |o: &Object| match &*doc.resolve(o) {
Object::Dict(d) => Some(d.clone()),
_ => None,
};
let states: Option<Vec<Vec<u8>>> = fresh.get(b"N").and_then(states_of).map(|d| d.iter().map(|(k, _)| k.clone()).collect());
for key in [&b"D"[..], &b"R"[..]] {
if fresh.contains(key) {
continue;
}
let kept = states.as_ref().and_then(|names| {
let old = apd.get(key).and_then(states_of)?;
let mut d = Dict::new();
for (k, v) in old.iter().filter(|(k, _)| names.contains(k)) {
d.set(k.clone(), v.clone());
}
(!d.is_empty()).then_some(d)
});
match kept {
Some(d) => apd.set(key.to_vec(), Object::Dict(d)),
None => {
apd.remove(key);
}
}
}
for (k, v) in fresh.iter() {
apd.set(k.clone(), v.clone());
}
apd
}
/// Regenerate the normal appearance of every widget of a text or choice field.
fn redraw(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Scripts) -> Result<(), FormError> {
let shown = match values {
@ -892,8 +1007,9 @@ fn redraw(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Sc
None => appearance::field_appearance(doc, f, w, values),
};
let ap = doc.add(Object::Stream(stream));
let mut apd = Dict::new();
apd.set(b"N".to_vec(), Object::Ref(ap));
let mut fresh = Dict::new();
fresh.set(b"N".to_vec(), Object::Ref(ap));
let apd = merged_appearance(doc, w.obj, &fresh);
doc.update_dict(w.obj, |d| {
d.set(b"AP".to_vec(), Object::Dict(apd));
d.remove(b"AS");

View File

@ -36,6 +36,11 @@ fn fixture() -> Document {
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (go) /Ff 65536 /Rect [300 450 380 470] /P 3 0 R >>".into(), // 25
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (bare) /Rect [400 700 415 715] /P 3 0 R /Foo (kept) >>".into(), // 26 check box without AP
];
document(&objs)
}
/// A PDF of the given objects (numbered from 1).
fn document(objs: &[String]) -> Document {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
@ -90,6 +95,40 @@ fn the_field_tree_is_read_with_inheritance() {
assert_eq!(field(&all, "address.city").quadding, 1);
}
/// pdf-lib and other writers give radio groups and check boxes an `/Opt` array and name the on
/// states by position (`/0`, `/1`): the export values select them, as in Acrobat.
#[test]
fn opt_export_values_select_button_states() {
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), // 1
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2
"<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R] >>".into(), // 3
"<< /Fields [5 0 R 8 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), // 4
"<< /FT /Btn /T (ship) /Ff 49152 /Opt [(Post) (Pick-up)] /V /Off /Kids [6 0 R 7 0 R] >>".into(), // 5 radio
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [100 700 115 715] /P 3 0 R /AP << /N << /0 9 0 R /Off 10 0 R >> >> >>".into(), // 6
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [200 700 215 715] /P 3 0 R /AP << /N << /1 9 0 R /Off 10 0 R >> >> >>".into(), // 7
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (terms) /Opt [(Accepted)] /V /Off /AS /Off /Rect [100 650 115 665] /P 3 0 R /AP << /N << /0 9 0 R /Off 10 0 R >> >> >>".into(), // 8 check box
"<< /Length 0 >>\nstream\n\nendstream".into(), // 9
"<< /Length 0 >>\nstream\n\nendstream".into(), // 10
];
let mut doc = document(&objs);
let all = fields(&doc);
let ship = field(&all, "ship");
assert_eq!((0..2).map(|i| ship.export_of(i)).collect::<Vec<_>>(), [Some("Post"), Some("Pick-up")]);
assert_eq!((ship.state_for("Pick-up"), ship.state_for("1"), ship.state_for("Courier")), (Some("1"), Some("1"), None));
set_value(&mut doc, "ship", &FieldValue::Radio(Some("Pick-up".into()))).unwrap();
set_value(&mut doc, "terms", &FieldValue::Text("Accepted".into())).unwrap();
let mut doc = reopen(&doc);
let all = fields(&doc);
let ship = field(&all, "ship");
assert_eq!(ship.value, ["1"]);
assert_eq!(ship.export_for_state("1"), "Pick-up");
assert_eq!(ship.widgets.iter().map(|w| w.state.as_deref()).collect::<Vec<_>>(), [Some("Off"), Some("1")]);
assert_eq!(field(&all, "terms").value, ["0"]);
let err = set_value(&mut doc, "ship", &FieldValue::Radio(Some("Courier".into()))).unwrap_err();
assert!(err.to_string().contains("options: Post, Pick-up"), "{err}");
}
#[test]
fn text_fields_get_new_appearances() {
let mut doc = fixture();
@ -120,6 +159,55 @@ fn text_fields_get_new_appearances() {
assert!(raw.windows(8).any(|x| x == b"(Z\xfcrich)"));
}
#[test]
fn japanese_choices_use_the_unicode_cid_font() {
// A choice field whose /DA names a non-embedded CID font with a predefined Unicode CMap
// (UniJIS-UTF16-H). Selecting 令 must draw it in that font, not "?" in Helvetica.
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
"<< /Type /Page /Parent 2 0 R /Annots [8 0 R 9 0 R 10 0 R] >>".into(),
"<< /Fields [8 0 R 9 0 R 10 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R /HeiseiMin-W3 6 0 R /Emb 7 0 R >> >> >>".into(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
"<< /Type /Font /Subtype /Type0 /BaseFont /HeiseiMin-W3 /Encoding /UniJIS-UTF16-H /DescendantFonts [<< /Type /Font /Subtype /CIDFontType0 /BaseFont /HeiseiMin-W3 /CIDSystemInfo << /Registry (Adobe) /Ordering (Japan1) /Supplement 6 >> >>] >>".into(),
"<< /Type /Font /Subtype /Type0 /BaseFont /ABCDEF+Subset /Encoding /Identity-H /DescendantFonts [] >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Ch /T (era) /Ff 131072 /DA (/HeiseiMin-W3 10 Tf 0 g) /Q 1 /Opt [<FEFF3000> <FEFF660E> <FEFF4EE4>] /V <FEFF3000> /Rect [50 700 80 715] /P 3 0 R >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (address) /Ff 4096 /DA (/HeiseiMin-W3 10 Tf 0 g) /Rect [50 600 100 680] /P 3 0 R >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (subset) /DA (/Emb 10 Tf 0 g) /Rect [50 500 100 520] /P 3 0 R >>".into(),
];
let mut doc = document(&objs);
assert_eq!(field(&fields(&doc), "era").options.get(2), Some(&("令".to_string(), "令".to_string())));
set_value(&mut doc, "era", &FieldValue::Text("令".into())).unwrap();
set_value(&mut doc, "address", &FieldValue::Text("日本語のテキスト入力欄です".into())).unwrap();
set_value(&mut doc, "subset", &FieldValue::Text("令和".into())).unwrap();
let doc = reopen(&doc);
let all = fields(&doc);
assert_eq!(field(&all, "era").value, ["令"]);
let raw = |name: &str| {
let w = &field(&all, name).widgets[0];
let n = doc.get(w.obj).as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap().reference(b"N").unwrap();
let Object::Stream(s) = &*doc.get(n) else { panic!() };
let fonts = doc.resolve(s.dict.get(b"Resources").unwrap()).as_dict().unwrap().get(b"Font").map(|f| doc.resolve(f)).unwrap();
let fonts: Vec<String> = fonts.as_dict().unwrap().iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).collect();
(s.decoded().unwrap(), fonts)
};
let utf16 = |t: &str| t.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
let (era, era_fonts) = raw("era");
assert!(era.windows(4).any(|x| x == b"(N\xe4)"), "令 as UTF-16BE: {}", String::from_utf8_lossy(&era));
assert!(String::from_utf8_lossy(&era).contains("/HeiseiMin-W3 10 Tf"));
assert!(!era.windows(3).any(|x| x == b"(?)"), "no WinAnsi fallback");
assert_eq!(era_fonts, ["HeiseiMin-W3"]);
// Multiline Japanese wraps by character at about one em each (50 pt wide, 10 pt type).
let (addr, _) = raw("address");
let addr_text = String::from_utf8_lossy(&addr);
assert!(addr_text.matches(" Tj").count() >= 4, "{addr_text}");
assert!(addr.windows(4).any(|x| x == utf16("日本").as_slice()));
// An Identity-H subset can't be addressed by Unicode: Helvetica, as before.
let (sub, sub_fonts) = raw("subset");
assert!(String::from_utf8_lossy(&sub).contains("/Helv "), "{}", String::from_utf8_lossy(&sub));
assert_eq!(sub_fonts, ["Helv"]);
}
#[test]
fn check_boxes_and_radios_switch_states() {
let mut doc = fixture();
@ -146,6 +234,46 @@ fn check_boxes_and_radios_switch_states() {
assert!(field(&fields(&doc), "agree").value.is_empty());
}
#[test]
fn check_boxes_keep_non_utf8_state_names() {
// Japanese forms often name a check box's on state 「はい」 in Shift-JIS: ticking it has to
// write those exact bytes to /AS and /V, or no appearance matches and no mark shows.
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>".into(),
"<< /Fields [5 0 R] >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /AS /Off /Rect [50 700 56 706] /P 3 0 R /AP << /N << /Off 6 0 R /#82#CD#82#A2 7 0 R >> >> >>".into(),
"<< /Length 0 >>\nstream\n\nendstream".into(),
"<< /Length 0 >>\nstream\n\nendstream".into(),
];
let sjis_hai: &[u8] = b"\x82\xcd\x82\xa2";
let mut doc = document(&objs);
let all = fields(&doc);
assert_eq!(field(&all, "agree").widgets[0].on_state.as_deref(), Some("#82#CD#82#A2"));
set_value(&mut doc, "agree", &FieldValue::Check(true)).unwrap();
let doc = reopen(&doc);
let f = field(&fields(&doc), "agree").clone();
let wd = doc.get(f.widgets[0].obj).as_dict().cloned().unwrap();
assert_eq!(wd.name(b"AS"), Some(sjis_hai));
assert_eq!(doc.get(f.obj).as_dict().unwrap().name(b"V"), Some(sjis_hai));
assert_eq!(f.value, ["#82#CD#82#A2"]);
let mut doc = doc;
set_value(&mut doc, "agree", &FieldValue::Check(false)).unwrap();
assert_eq!(doc.get(f.widgets[0].obj).as_dict().unwrap().name(b"AS"), Some(&b"Off"[..]));
}
#[test]
fn name_text_round_trips() {
for bytes in [&b"Yes"[..], b"\x82\xcd\x82\xa2", "はい".as_bytes(), b"A#1", b"a b", b"", b"\xff#\x00"] {
assert_eq!(name_bytes(&name_text(bytes)), bytes, "{bytes:?}");
}
assert_eq!(name_text("はい".as_bytes()), "はい");
assert_eq!(name_text(b"A#1"), "A#231");
// Malformed escapes stay as they are.
assert_eq!(name_bytes("#G1#4"), b"#G1#4");
}
#[test]
fn choices_accept_exports_or_display_text() {
let mut doc = fixture();
@ -827,3 +955,349 @@ fn page_shapes_reads_boxes_and_rules() {
assert_eq!(sh.boxes, [[20.0, 20.0, 32.0, 32.0]]);
assert_eq!(sh.rules, [[40.0, 100.0, 240.0, 100.0], [60.0, 600.5, 260.0, 600.5]]);
}
fn close(a: f64, b: f64) -> bool {
(a - b).abs() < 0.05
}
fn nums(o: &Object) -> Vec<f64> {
o.as_array().unwrap().iter().filter_map(Object::as_f64).collect()
}
/// Normal appearance `/BBox` and `/Matrix` (absent when the widget is not rotated).
fn appearance_box(doc: &Document, w: &Widget) -> (Vec<f64>, Option<Vec<f64>>) {
let n = doc.get(w.obj).as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap().reference(b"N").unwrap();
let Object::Stream(s) = &*doc.get(n) else { panic!("appearance") };
(nums(s.dict.get(b"BBox").unwrap()), s.dict.get(b"Matrix").map(nums))
}
#[test]
fn rotated_fields_draw_in_their_quadrant() {
let mut doc = fixture();
// No /R: the appearance is the widget rectangle and carries no /Matrix.
set_value(&mut doc, "name", &FieldValue::Text("Ada".into())).unwrap();
let (bbox, matrix) = appearance_box(&doc, &field(&fields(&doc), "name").widgets[0]);
assert!(close(bbox[2], 200.0) && close(bbox[3], 20.0), "{bbox:?}");
assert!(matrix.is_none(), "{matrix:?}");
let set_r = |doc: &mut Document, degrees: i64| {
let obj = field(&fields(doc), "name").widgets[0].obj;
doc.update_dict(obj, |d| {
let mut mk = d.get(b"MK").and_then(|m| m.as_dict().cloned()).unwrap_or_default();
mk.set(b"R".to_vec(), Object::Int(degrees));
d.set(b"MK".to_vec(), Object::Dict(mk));
})
.unwrap();
set_value(doc, "name", &FieldValue::Text("Ada".into())).unwrap();
};
// 90 and 270 lay the text out in the swapped box. 180 keeps the box and turns it over.
set_r(&mut doc, 90);
let (bbox, matrix) = appearance_box(&doc, &field(&fields(&doc), "name").widgets[0]);
assert!(close(bbox[2], 20.0) && close(bbox[3], 200.0), "{bbox:?}");
let m = matrix.unwrap();
assert!(close(m[0], 0.0) && close(m[1], 1.0) && close(m[2], -1.0) && close(m[4], 200.0), "{m:?}");
set_r(&mut doc, 180);
let (bbox, matrix) = appearance_box(&doc, &field(&fields(&doc), "name").widgets[0]);
assert!(close(bbox[2], 200.0) && close(bbox[3], 20.0), "{bbox:?}");
let m = matrix.unwrap();
assert!(close(m[0], -1.0) && close(m[3], -1.0) && close(m[4], 200.0) && close(m[5], 20.0), "{m:?}");
set_r(&mut doc, 270);
let (bbox, matrix) = appearance_box(&doc, &field(&fields(&doc), "name").widgets[0]);
assert!(close(bbox[2], 20.0) && close(bbox[3], 200.0), "{bbox:?}");
let m = matrix.unwrap();
assert!(close(m[1], -1.0) && close(m[2], 1.0) && close(m[5], 20.0), "{m:?}");
// A non-quadrant /R is drawn upright.
set_r(&mut doc, 45);
let (bbox, matrix) = appearance_box(&doc, &field(&fields(&doc), "name").widgets[0]);
assert!(matrix.is_none() && close(bbox[2], 200.0) && close(bbox[3], 20.0), "{bbox:?} {matrix:?}");
assert_eq!(field(&fields(&doc), "name").widgets[0].rotation, 0);
let before = field(&fields(&doc), "name").widgets[0].rect;
assert!(set_props(&mut doc, "name", &FieldProps { rotation: Some((0, 45)), ..FieldProps::default() }).is_err());
assert!(field(&fields(&doc), "name").widgets[0].rect.iter().zip(before).all(|(a, b)| close(*a, b)));
set_props(&mut doc, "name", &FieldProps { rotation: Some((0, 90)), ..FieldProps::default() }).unwrap();
let turned = field(&fields(&doc), "name").widgets[0].clone();
assert_eq!(turned.rotation, 90);
let r = turned.rect;
// [50 700 250 720] around its center becomes 20 wide and 200 tall.
assert!(close(r[0], 140.0) && close(r[1], 610.0) && close(r[2], 160.0) && close(r[3], 810.0), "{r:?}");
let widget = doc.get(turned.obj);
let mk = widget.as_dict().unwrap().get(b"MK").unwrap().as_dict().cloned().unwrap();
assert!(mk.contains(b"BG") && mk.contains(b"BC") && mk.int(b"R") == Some(90));
let (_, matrix) = appearance_box(&doc, &turned);
assert!(matrix.is_some());
// 270 stays on the vertical axis, so the rectangle is not swapped again.
set_props(&mut doc, "name", &FieldProps { rotation: Some((0, 270)), ..FieldProps::default() }).unwrap();
let r270 = field(&fields(&doc), "name").widgets[0].rect;
assert!(r270.iter().zip(r).all(|(a, b)| close(*a, b)), "{r270:?}");
// A check box and a push button use the same matrix.
let mut doc = one_page();
add_field(&mut doc, 0, [20.0, 40.0, 40.0, 60.0], &NewField::CheckBox, Some("agree")).unwrap();
add_field(&mut doc, 0, [20.0, 80.0, 120.0, 110.0], &NewField::Button { caption: "Go".into() }, Some("go")).unwrap();
set_props(&mut doc, "agree", &FieldProps { rotation: Some((0, 90)), ..FieldProps::default() }).unwrap();
set_props(&mut doc, "go", &FieldProps { rotation: Some((0, 90)), ..FieldProps::default() }).unwrap();
let agree = field(&fields(&doc), "agree").widgets[0].clone();
let widget = doc.get(agree.obj);
let n = widget.as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap().get(b"N").cloned().unwrap();
let states = doc.resolve(&n);
let yes = states.as_dict().unwrap().reference(b"Yes").unwrap();
let Object::Stream(s) = &*doc.get(yes) else { panic!("check") };
assert!(s.dict.contains(b"Matrix"), "the check mark is rotated");
let go = field(&fields(&doc), "go").widgets[0].clone();
let (text, matrix) = (ap(&doc, &go), appearance_box(&doc, &go).1);
assert!(text.contains("(Go) Tj") && matrix.is_some(), "{text}");
}
#[test]
fn a_rotation_refused_as_too_small_changes_nothing_else() {
let mut doc = one_page();
add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, Some("thin")).unwrap();
// 200 x 3: turned a quarter it would be 3 wide, too small to use.
let obj = field(&fields(&doc), "thin").widgets[0].obj;
doc.update_dict(obj, |d| d.set(b"Rect".to_vec(), Object::Array([50.0, 700.0, 250.0, 703.0].iter().map(|v| Object::Real(*v)).collect()))).unwrap();
let props = FieldProps { tooltip: Some("changed".into()), rotation: Some((0, 90)), ..FieldProps::default() };
assert!(set_props(&mut doc, "thin", &props).is_err());
let f = field(&fields(&doc), "thin").clone();
assert_eq!(f.tooltip, None, "the tooltip was written before the rotation was refused");
assert_eq!(f.widgets[0].rotation, 0);
}
#[test]
fn a_rotated_field_draws_along_the_vertical_edge() {
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind};
let mut doc = one_page();
add_field(&mut doc, 0, [10.0, 40.0, 90.0, 60.0], &NewField::Text { multiline: false }, Some("wide")).unwrap();
set_value(&mut doc, "wide", &FieldValue::Text("MMMMMM".into())).unwrap();
set_props(&mut doc, "wide", &FieldProps { font_size: Some(12.0), ..FieldProps::default() }).unwrap();
let span = |doc: &Document| -> (u32, u32) {
let bytes = write_incremental(doc, &SaveOptions::default()).unwrap();
let mut r = PageRenderer::new(Arc::new(bytes), RenderConfig::default());
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: 0 });
assert!(p.error.is_none(), "{:?}", p.error);
let (mut x0, mut y0, mut x1, mut y1) = (u32::MAX, u32::MAX, 0, 0);
let mut ink = false;
for y in 0..p.height {
for x in 0..p.width {
let i = ((y * p.width + x) * 4) as usize;
if p.rgba[i] < 200 {
ink = true;
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y));
}
}
}
assert!(ink, "the field drew no text");
(x1 - x0, y1 - y0)
};
let (wide, tall) = span(&doc);
assert!(wide > tall, "upright text is a horizontal run: {wide}x{tall}");
set_props(&mut doc, "wide", &FieldProps { rotation: Some((0, 90)), ..FieldProps::default() }).unwrap();
let (wide, tall) = span(&doc);
assert!(tall > wide, "rotated text is a vertical run: {wide}x{tall}");
}
#[test]
fn filling_and_authoring_keep_shared_appearances_and_drop_stale_alternates() {
let alternates: [(&[u8], &[u8]); 2] = [(b"R", b"0 0 20 10 re f\n"), (b"D", b"1 1 18 8 re S\n")];
let cases = [
(NewField::Text { multiline: false }, true),
(NewField::Text { multiline: false }, false),
(NewField::CheckBox, false),
(NewField::Button { caption: "Button".into() }, false),
(NewField::Signature, false),
];
for (kind, fill) in cases {
for indirect in [false, true] {
let mut doc = one_page();
let first = add_field(&mut doc, 0, [50.0, 600.0, 250.0, 630.0], &kind, Some("First")).unwrap();
let other = add_field(&mut doc, 0, [50.0, 550.0, 250.0, 580.0], &kind, Some("Other")).unwrap();
let first_widget = field(&fields(&doc), &first).widgets[0].obj;
let other_widget = field(&fields(&doc), &other).widgets[0].obj;
let mut entries = doc.get(first_widget).as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap().clone();
for (key, bytes) in alternates {
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("XObject"));
d.set(b"Subtype".to_vec(), Object::name("Form"));
d.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 20.into(), 10.into()]));
let r = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(d, bytes.to_vec())));
entries.set(key.to_vec(), Object::Ref(r));
}
entries.set(b"VendorState".to_vec(), Object::name("Retained"));
let shared = if indirect { Object::Ref(doc.add(Object::Dict(entries))) } else { Object::Dict(entries) };
for widget in [first_widget, other_widget] {
doc.update_dict(widget, |d| d.set(b"AP".to_vec(), shared.clone())).unwrap();
}
let original = reopen(&doc);
for full in [false, true] {
let mut doc = original.clone();
let other_widget = field(&fields(&doc), &other).widgets[0].obj;
let source = doc.get(other_widget).as_dict().unwrap().get(b"AP").unwrap().clone();
let before = doc.resolve(&source);
if fill {
set_value(&mut doc, &first, &FieldValue::Text("Updated value".into())).unwrap();
} else {
set_props(&mut doc, &first, &FieldProps { tooltip: Some("Updated help".into()), ..FieldProps::default() }).unwrap();
}
assert_eq!(doc.resolve(&source), before, "a shared AP must not change");
let bytes = if full {
pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap()
} else {
assert!(!doc.revisions().is_empty(), "incremental save needs an existing revision");
assert!(!doc.encryption_changed() && !doc.full_save_required(), "this edit must not require a full rewrite");
let original = doc.bytes();
let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap();
assert!(bytes.len() > original.len(), "incremental save appends the changed appearance");
assert!(bytes.starts_with(original.as_slice()), "incremental save preserves the complete original prefix");
bytes
};
hayro_syntax::Pdf::new(bytes.clone()).unwrap();
let doc = Document::open(Arc::new(bytes)).unwrap();
let all = fields(&doc);
let first = field(&all, &first);
let other = field(&all, &other);
let first_ap = doc.resolve(doc.get(first.widgets[0].obj).as_dict().unwrap().get(b"AP").unwrap());
let other_ap = doc.resolve(doc.get(other.widgets[0].obj).as_dict().unwrap().get(b"AP").unwrap());
let first_ap = first_ap.as_dict().unwrap();
let other_ap = other_ap.as_dict().unwrap();
assert_ne!(first_ap.get(b"N"), other_ap.get(b"N"), "normal appearance was regenerated");
assert_eq!(other_ap.len(), 4);
// The redrawn widget keeps unknown entries, but not down/rollover looks that
// would show its old value or caption on press or hover.
assert_eq!(
first_ap.len(),
2,
"{kind:?} fill={fill} indirect={indirect} full={full}: {:?}",
first_ap.iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).collect::<Vec<_>>()
);
for (key, bytes) in alternates {
assert!(first_ap.get(key).is_none(), "stale /{}", String::from_utf8_lossy(key));
let object = doc.resolve(other_ap.get(key).unwrap());
let Object::Stream(stream) = &*object else { panic!("alternate appearance") };
assert_eq!(stream.decoded().unwrap(), bytes);
}
assert_eq!(first_ap.name(b"VendorState"), Some(&b"Retained"[..]));
assert_eq!(other_ap.name(b"VendorState"), Some(&b"Retained"[..]));
if fill {
assert_eq!(first.value, ["Updated value"]);
assert!(ap(&doc, &first.widgets[0]).contains("(Updated value) Tj"));
assert!(other.value.is_empty());
} else {
assert_eq!(first.tooltip.as_deref(), Some("Updated help"));
assert_eq!(other.tooltip, None);
}
}
}
}
}
#[test]
fn pushbutton_redraw_resolves_caption_and_layout_after_save_and_reopen() {
fn saved(doc: &Document, full: bool) -> Document {
let bytes = if full {
pdfcraft_cos::write_full(doc, &SaveOptions::default()).unwrap()
} else {
assert!(!doc.revisions().is_empty(), "incremental save needs an existing revision");
assert!(!doc.encryption_changed() && !doc.full_save_required(), "this edit must not require a full rewrite");
let original = doc.bytes();
let bytes = write_incremental(doc, &SaveOptions::default()).unwrap();
assert!(bytes.len() > original.len(), "incremental save appends the changed appearance");
assert!(bytes.starts_with(original.as_slice()), "incremental save preserves the complete original prefix");
bytes
};
hayro_syntax::Pdf::new(bytes.clone()).unwrap();
Document::open(Arc::new(bytes)).unwrap()
}
for indirect_caption in [false, true] {
for indirect_layout in [false, true] {
for icon_only in [false, true] {
let mut doc = one_page();
let name =
add_field(&mut doc, 0, [50.0, 600.0, 250.0, 650.0], &NewField::Button { caption: "Original caption".into() }, Some("Picture"))
.unwrap();
let widget = field(&fields(&doc), &name).widgets[0].obj;
let caption = Object::String(pdfcraft_cos::PdfString::literal(b"Original caption".to_vec()));
let caption = if indirect_caption { Object::Ref(doc.add(caption)) } else { caption };
let layout = Object::Int(i64::from(icon_only));
let layout = if indirect_layout { Object::Ref(doc.add(layout)) } else { layout };
let mut icon_dict = Dict::new();
icon_dict.set(b"Type".to_vec(), Object::name("XObject"));
icon_dict.set(b"Subtype".to_vec(), Object::name("Form"));
icon_dict.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 4.into(), 2.into()]));
let icon_bytes = b"0 0 4 2 re f\n";
let icon = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(icon_dict, icon_bytes.to_vec())));
let mut mk = Dict::new();
mk.set(b"CA".to_vec(), caption);
mk.set(b"TP".to_vec(), layout);
mk.set(b"I".to_vec(), Object::Ref(icon));
mk.set(b"VendorNote".to_vec(), Object::name("Retained"));
let mk = doc.add(Object::Dict(mk));
doc.update_dict(widget, |d| d.set(b"MK".to_vec(), Object::Ref(mk))).unwrap();
// Import the operands from a real saved file before an unrelated public edit.
let original = saved(&doc, true);
for full in [false, true] {
let mut doc = original.clone();
let widget = field(&fields(&doc), &name).widgets[0].obj;
let mk_before = doc.get(widget).as_dict().unwrap().get(b"MK").unwrap().clone();
let original_mk = doc.resolve(&mk_before);
set_props(&mut doc, &name, &FieldProps { tooltip: Some("Picture help".into()), ..FieldProps::default() }).unwrap();
assert_eq!(doc.get(widget).as_dict().unwrap().get(b"MK"), Some(&mk_before));
assert_eq!(doc.resolve(&mk_before), original_mk, "redraw must not rewrite the source MK");
let doc = saved(&doc, full);
let f = field(&fields(&doc), &name).clone();
assert_eq!(f.tooltip.as_deref(), Some("Picture help"));
let w = &f.widgets[0];
let wd = doc.get(w.obj);
let mk_object = doc.resolve(wd.as_dict().unwrap().get(b"MK").unwrap());
let mk = mk_object.as_dict().unwrap();
assert_eq!(mk.get(b"CA").unwrap().as_ref().is_some(), indirect_caption);
assert_eq!(doc.resolve(mk.get(b"CA").unwrap()).as_string().unwrap().bytes, b"Original caption");
assert_eq!(mk.get(b"TP").unwrap().as_ref().is_some(), indirect_layout);
assert_eq!(doc.resolve(mk.get(b"TP").unwrap()).as_int(), Some(i64::from(icon_only)));
assert_eq!(mk.name(b"VendorNote"), Some(&b"Retained"[..]));
let appearance = ap(&doc, w);
assert_eq!(appearance.contains("(Original caption) Tj"), !icon_only, "{appearance}");
assert_eq!(appearance.matches("/Icon Do").count(), 1, "{appearance}");
assert!(appearance.contains("24.000000 0 0 24.000000 52.000 1.000 cm"), "{appearance}");
let n = wd.as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap().reference(b"N").unwrap();
let appearance_object = doc.get(n);
let Object::Stream(stream) = &*appearance_object else { panic!("appearance") };
let resource_icon =
stream.dict.get(b"Resources").unwrap().as_dict().unwrap().get(b"XObject").unwrap().as_dict().unwrap().get(b"Icon").unwrap();
assert_eq!(resource_icon, mk.get(b"I").unwrap());
let icon_object = doc.resolve(resource_icon);
let Object::Stream(icon) = &*icon_object else { panic!("icon") };
assert_eq!(icon.decoded().unwrap(), icon_bytes);
}
}
}
}
}
/// A check box's down appearances survive a redraw for the states it still draws (same names),
/// and a down appearance for a state it no longer has is dropped rather than left stale.
#[test]
fn check_box_redraw_keeps_down_states_it_still_draws() {
let mut doc = one_page();
let name = add_field(&mut doc, 0, [50.0, 600.0, 70.0, 620.0], &NewField::CheckBox, Some("agree")).unwrap();
let widget = field(&fields(&doc), &name).widgets[0].obj;
let on = field(&fields(&doc), &name).widgets[0].on_state.clone().unwrap_or_else(|| "Yes".into());
let mut ap = doc.get(widget).as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap().clone();
let mut down = Dict::new();
for state in [on.as_bytes(), b"Off".as_slice(), b"Gone".as_slice()] {
let mut d = Dict::new();
d.set(b"Subtype".to_vec(), Object::name("Form"));
d.set(b"BBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 20.into(), 20.into()]));
let r = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(d, b"0 0 20 20 re f\n".to_vec())));
down.set(state.to_vec(), Object::Ref(r));
}
ap.set(b"D".to_vec(), Object::Dict(down));
doc.update_dict(widget, |d| d.set(b"AP".to_vec(), Object::Dict(ap))).unwrap();
set_props(&mut doc, &name, &FieldProps { tooltip: Some("Tick to agree".into()), ..FieldProps::default() }).unwrap();
let ap = doc.get(widget).as_dict().unwrap().get(b"AP").map(|a| doc.resolve(a)).unwrap();
let d = ap.as_dict().unwrap().get(b"D").map(|d| doc.resolve(d)).expect("down states kept");
let d = d.as_dict().unwrap();
assert!(d.contains(on.as_bytes()) && d.contains(b"Off"));
assert!(!d.contains(b"Gone"), "a state the box no longer draws");
}

View File

@ -336,9 +336,12 @@ pub fn add(doc: &mut Document, new: &NewMeasurement, meta: &Meta) -> Result<usiz
Kind::Distance => Shape::Line {
from: new.points.first().copied().ok_or_else(|| invalid("missing start"))?,
to: new.points.last().copied().ok_or_else(|| invalid("missing end"))?,
arrow: true,
start: pdfcraft_annot::LineEnding::None,
end: pdfcraft_annot::LineEnding::OpenArrow,
},
Kind::Perimeter => Shape::PolyLine { vertices: new.points.clone() },
Kind::Perimeter => {
Shape::PolyLine { vertices: new.points.clone(), start: pdfcraft_annot::LineEnding::None, end: pdfcraft_annot::LineEnding::None }
}
Kind::Area => Shape::Polygon { vertices: new.points.clone(), cloud: false },
};
let index = pdfcraft_annot::add_annotation(

View File

@ -92,7 +92,7 @@ fn standard_annotations_captions_restyle_move_and_unknown_keys() {
let (r, dict) = annot(&d, 0, 0).unwrap();
assert_eq!(dict.name(b"IT"), Some(&b"LineDimension"[..]));
d.update_dict(r, |d| d.set(b"VendorKey".to_vec(), Object::Int(42))).unwrap();
pdfcraft_annot::set_style(&mut d, 0, 0, Some([1.0, 0.0, 0.0]), None, Some(2.0), &meta).unwrap();
pdfcraft_annot::set_style(&mut d, 0, 0, Some([1.0, 0.0, 0.0]), None, Some(2.0), None, &meta).unwrap();
let (_, dict) = annot(&d, 0, 0).unwrap();
assert_eq!(dict.int(b"VendorKey"), Some(42));
let ap = d.resolve(dict.get(b"AP").unwrap());

View File

@ -8,6 +8,8 @@ L3/L4 crates need:
- `Page::crop`, `Page::rotation` and `Page::view_matrix`: the displayed page's geometry, and the
matrix from "display space" (origin at the bottom-left of the page as shown, after `/Rotate`)
to user space, so content can be placed the way the reader sees the page.
`view_matrix_for(rotation, rect)` is the same matrix for any rectangle, e.g. to draw a
comment's picture upright inside its user-space `/Rect` on a turned page.
`organize`, `annot` and `forms` still carry their own small walkers; they move here as the model
grows (ADR-0004).

View File

@ -49,13 +49,21 @@ impl Page {
/// The matrix `[a b c d e f]` from display space (origin at the bottom-left of the page as
/// shown, y up, after `/Rotate`) to user space.
pub fn view_matrix(&self, doc: &Document) -> [f64; 6] {
let [x0, y0, x1, y1] = self.crop(doc);
match self.rotation(doc) {
90 => [0.0, 1.0, -1.0, 0.0, x1, y0],
180 => [-1.0, 0.0, 0.0, -1.0, x1, y1],
270 => [0.0, -1.0, 1.0, 0.0, x0, y1],
_ => [1.0, 0.0, 0.0, 1.0, x0, y0],
}
view_matrix_for(self.rotation(doc), self.crop(doc))
}
}
/// [`Page::view_matrix`] for a region `crop` (`[x0, y0, x1, y1]`) shown turned clockwise by
/// `rotation` (0, 90, 180 or 270): the matrix from the turned region's own display space
/// (origin at its bottom-left, y up) to the region's space. For a rectangle in user space it
/// puts an upright picture into that rectangle as the page displays it.
pub fn view_matrix_for(rotation: i64, crop: [f64; 4]) -> [f64; 6] {
let [x0, y0, x1, y1] = crop;
match rotation {
90 => [0.0, 1.0, -1.0, 0.0, x1, y0],
180 => [-1.0, 0.0, 0.0, -1.0, x1, y1],
270 => [0.0, -1.0, 1.0, 0.0, x0, y1],
_ => [1.0, 0.0, 0.0, 1.0, x0, y0],
}
}

View File

@ -5,6 +5,8 @@ Advanced optimization).
```rust
let report = optimize(&mut doc, &Settings::default())?; // Reduce File Size's choices
// Or with progress: called before each image and the clean-up; `false` cancels.
let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going)?;
```
- **Images:** the effective resolution of each image is measured where pages draw it (the CTM
@ -17,5 +19,7 @@ let report = optimize(&mut doc, &Settings::default())?; // Reduce File Size's
- **Clean up:** Flate for streams with no filter.
The engine runs Remove Hidden Information (`pdfcraft-redact`) for the user-data categories,
then this, then merges identical objects and writes a full, compressed save. JPEG decoding and
then this, then merges identical objects and writes a full, compressed save
(`pdfcraft_engine::optimizer::OptimizeJob`, which the UI runs on a worker thread with a
progress bar). JPEG decoding and
encoding and resampling come from the `image` crate.

View File

@ -5,7 +5,7 @@ use std::collections::{HashMap, HashSet};
use pdfcraft_content::Matrix;
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
use crate::{Compression, ImageSettings, OptimizeError, Report, Settings};
use crate::{Compression, ImageSettings, OptimizeError, Report, Settings, Stage};
/// Images with more pixels than this are left alone (memory).
const MAX_PIXELS: u64 = 80_000_000;
@ -53,10 +53,8 @@ fn walk(doc: &Document, data: &[u8], resources: &Dict, ctm: Matrix, depth: usize
let top = stack.last().copied().unwrap_or(ctm);
match op.op.as_slice() {
b"q" => stack.push(top),
b"Q" => {
if stack.len() > 1 {
stack.pop();
}
b"Q" if stack.len() > 1 => {
stack.pop();
}
b"cm" => {
if let Some(m) = Matrix::from_operands(&op.operands)
@ -294,7 +292,13 @@ fn process(doc: &Document, s: &Stream, ppi: f64, settings: &ImageSettings, n: us
(after < before).then_some((new, smask))
}
pub(crate) fn run(doc: &mut Document, pages: &[ObjRef], settings: &Settings, report: &mut Report) -> Result<(), OptimizeError> {
pub(crate) fn run(
doc: &mut Document,
pages: &[ObjRef],
settings: &Settings,
report: &mut Report,
progress: &mut dyn FnMut(Stage) -> bool,
) -> Result<(), OptimizeError> {
let ppi = effective_resolutions(doc, pages);
// Soft masks are processed with their image, not on their own.
let masks: HashSet<ObjRef> = ppi
@ -306,7 +310,11 @@ pub(crate) fn run(doc: &mut Document, pages: &[ObjRef], settings: &Settings, rep
.collect();
let mut refs: Vec<(ObjRef, f64)> = ppi.into_iter().filter(|(r, _)| !masks.contains(r)).collect();
refs.sort_by_key(|(r, _)| r.num);
for (r, ppi) in refs {
let total = refs.len();
for (done, (r, ppi)) in refs.into_iter().enumerate() {
if !progress(Stage::Images { done, total }) {
return Err(OptimizeError::Cancelled);
}
let obj = doc.get(r);
let Object::Stream(s) = &*obj else { continue };
report.images += 1;
@ -326,5 +334,8 @@ pub(crate) fn run(doc: &mut Document, pages: &[ObjRef], settings: &Settings, rep
doc.set(mr, Object::Stream(m));
}
}
if !progress(Stage::Images { done: total, total }) {
return Err(OptimizeError::Cancelled);
}
Ok(())
}

View File

@ -33,6 +33,17 @@ pub enum OptimizeError {
NoPages,
#[error(transparent)]
Cos(#[from] pdfcraft_cos::CosError),
#[error("the optimization was cancelled")]
Cancelled,
}
/// Where an optimization is, reported to [`optimize_with_progress`]'s callback.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Stage {
/// About to process image `done` (0-based) of `total`; `done == total` once all are done.
Images { done: usize, total: usize },
/// Discarding objects, cleaning up links and compressing unencoded streams.
CleanUp,
}
/// How resampled (or recompressed) images are stored.
@ -118,9 +129,18 @@ pub struct Report {
/// Optimize `doc` in place.
pub fn optimize(doc: &mut Document, settings: &Settings) -> Result<Report, OptimizeError> {
optimize_with_progress(doc, settings, &mut |_| true)
}
/// [`optimize`], calling `progress` before each image and before the clean-up. Returning `false`
/// stops with [`OptimizeError::Cancelled`]; `doc` is then partly optimized and should be dropped.
pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress: &mut dyn FnMut(Stage) -> bool) -> Result<Report, OptimizeError> {
let mut report = Report::default();
let pages = pdfcraft_annot::page_refs(doc).map_err(|_| OptimizeError::NoPages)?;
images::run(doc, &pages, settings, &mut report)?;
images::run(doc, &pages, settings, &mut report, progress)?;
if !progress(Stage::CleanUp) {
return Err(OptimizeError::Cancelled);
}
if settings.discard_thumbnails {
for p in &pages {
if doc.get(*p).as_dict().is_some_and(|d| d.contains(b"Thumb")) {

View File

@ -265,3 +265,42 @@ fn invalid_links_and_unreferenced_destinations_go() {
let off = Settings { remove_invalid_links: false, remove_unreferenced_dests: false, ..Settings::default() };
assert_eq!(optimize(&mut again, &off).unwrap().invalid_links, 0);
}
#[test]
fn progress_is_reported_per_image_and_a_refusal_cancels() {
use pdfcraft_optimize::{OptimizeError, Stage, optimize_with_progress};
let build = || {
let mut doc = Document::new_empty();
let a = image(&mut doc, 300, 300, 3, false, None);
let b = image(&mut doc, 300, 300, 1, false, None);
let c = image(&mut doc, 300, 300, 3, true, None);
page(&mut doc, &[("A", a), ("B", b), ("C", c)], "q 72 0 0 72 0 0 cm /A Do Q q 72 0 0 72 100 0 cm /B Do Q q 72 0 0 72 200 0 cm /C Do Q");
doc
};
let mut seen = Vec::new();
let report = optimize_with_progress(&mut build(), &Settings::default(), &mut |s| {
seen.push(s);
true
})
.unwrap();
assert_eq!(report.images, 3);
assert_eq!(
seen,
[
Stage::Images { done: 0, total: 3 },
Stage::Images { done: 1, total: 3 },
Stage::Images { done: 2, total: 3 },
Stage::Images { done: 3, total: 3 },
Stage::CleanUp
]
);
// Stopping after the first image.
let mut calls = 0;
let r = optimize_with_progress(&mut build(), &Settings::default(), &mut |_| {
calls += 1;
calls < 2
});
assert!(matches!(r, Err(OptimizeError::Cancelled)), "{r:?}");
assert_eq!(calls, 2, "nothing runs after the refusal");
}

View File

@ -112,7 +112,7 @@ fn hash_obj(o: &Object, map: &HashMap<ObjRef, ObjRef>, h: &mut impl Hasher) {
Object::Stream(s) => {
8u8.hash(h);
hash_obj(&Object::Dict(s.dict.clone()), map, h);
s.raw.as_slice().hash(h);
s.raw[..].hash(h);
}
Object::Ref(r) => {
let c = canon(map, *r);
@ -125,9 +125,7 @@ fn eq_obj(a: &Object, b: &Object, map: &HashMap<ObjRef, ObjRef>) -> bool {
match (a, b) {
(Object::Array(x), Object::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(p, q)| eq_obj(p, q, map)),
(Object::Dict(x), Object::Dict(y)) => x.len() == y.len() && x.iter().all(|(k, v)| y.get(k).is_some_and(|w| eq_obj(v, w, map))),
(Object::Stream(x), Object::Stream(y)) => {
(std::sync::Arc::ptr_eq(&x.raw, &y.raw) || x.raw == y.raw) && eq_obj(&Object::Dict(x.dict.clone()), &Object::Dict(y.dict.clone()), map)
}
(Object::Stream(x), Object::Stream(y)) => x.raw == y.raw && eq_obj(&Object::Dict(x.dict.clone()), &Object::Dict(y.dict.clone()), map),
(Object::Ref(x), Object::Ref(y)) => canon(map, *x) == canon(map, *y),
(Object::Real(x), Object::Real(y)) => x.to_bits() == y.to_bits(),
(Object::String(x), Object::String(y)) => x.bytes == y.bytes,
@ -241,10 +239,9 @@ pub fn dedupe_resources(doc: &mut Document, candidates: &[ObjRef], index_existin
mod tests {
use super::*;
use pdfcraft_cos::{Dict, Stream};
use std::sync::Arc;
fn stream(data: &[u8]) -> Object {
Object::Stream(Stream { dict: Dict::new(), raw: Arc::new(data.to_vec()) })
Object::Stream(Stream { dict: Dict::new(), raw: data.to_vec().into() })
}
fn font(doc: &mut Document, file: ObjRef, name: &str) -> ObjRef {

View File

@ -181,6 +181,13 @@ impl Copier<'_> {
}
}
/// Copy `o` from `src` into `dst`, reusing the copies listed in `map` (source → destination), so
/// an object the copied pages already brought along (an ICC profile) is not stored twice.
pub(crate) fn copy_object(dst: &mut Document, src: &Document, o: &Object, map: HashMap<ObjRef, ObjRef>) -> Object {
let mut c = Copier { src, map, pages: HashMap::new(), annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new() };
c.copy_value(dst, o, None)
}
/// Copy pages `src_pages` (0-based, in the given order) of `src` into `dst`, inserting them at
/// position `at`. Returns the new page references in order.
///
@ -188,19 +195,24 @@ impl Copier<'_> {
/// shared rather than stored twice (see `dedupe`).
pub fn import_pages(dst: &mut Document, src: &Document, src_pages: &[usize], at: usize) -> Result<Vec<ObjRef>, OrganizeError> {
let first_new = dst.object_numbers().last().map_or(1, |n| n + 1);
let pages = import_pages_mapped(dst, src, src_pages, at)?.0;
let pages = import_pages_mapped(dst, src, src_pages, at)?.pages;
let created: Vec<ObjRef> = dst.object_numbers().into_iter().filter(|n| *n >= first_new).map(|n| ObjRef::new(n, dst.generation(n))).collect();
crate::dedupe::dedupe_resources(dst, &created, true);
Ok(pages)
}
/// `import_pages`, also returning the source-page → destination-page map.
fn import_pages_mapped(
dst: &mut Document,
src: &Document,
src_pages: &[usize],
at: usize,
) -> Result<(Vec<ObjRef>, HashMap<ObjRef, ObjRef>), OrganizeError> {
/// What `import_pages_mapped` copied.
struct Imported {
/// The new pages, in order.
pages: Vec<ObjRef>,
/// Source page → destination page.
page_map: HashMap<ObjRef, ObjRef>,
/// Every source object copied → its copy.
objects: HashMap<ObjRef, ObjRef>,
}
/// `import_pages`, also returning what was copied from where.
fn import_pages_mapped(dst: &mut Document, src: &Document, src_pages: &[usize], at: usize) -> Result<Imported, OrganizeError> {
let source = walk(src)?;
if let Some(bad) = src_pages.iter().find(|i| **i >= source.len()) {
return Err(OrganizeError::NoSuchPage(*bad));
@ -278,7 +290,7 @@ fn import_pages_mapped(
let inserted: Vec<(ObjRef, Dict)> = new_pages.iter().map(|r| (*r, Dict::new())).collect();
existing.splice(at..at, inserted);
rebuild(dst, &existing)?;
Ok((new_pages, copier.pages))
Ok(Imported { pages: new_pages, page_map: copier.pages, objects: copier.map })
}
/// Look up a named destination in the source (`/Dests` dictionary or `/Names /Dests` tree).
@ -410,16 +422,19 @@ fn register_fields(dst: &mut Document, fields: &[ObjRef]) -> Result<(), Organize
}
/// A new document containing copies of `pages` from `src` (Extract Pages / Split).
/// Document information (title, author…) is carried over.
/// Document information (title, author…) is carried over, and so is the print standard the
/// source declares (PDF/X: its output intents and identification, #263).
pub fn extract_pages(src: &Document, pages: &[usize]) -> Result<Document, OrganizeError> {
let mut out = Document::new_empty();
// One source: nothing to deduplicate.
import_pages_mapped(&mut out, src, pages, 0)?;
let imported = import_pages_mapped(&mut out, src, pages, 0)?;
for key in crate::INFO_KEYS {
if let Some(v) = crate::info(src, key) {
crate::set_info(&mut out, key, &v)?;
}
}
// After the title: the XMP packet repeats it.
crate::pdfx::carry(&mut out, src, &crate::pdfx::PrintStandard::of(src), imported.objects)?;
// The pages share their source's resource dictionary, so the part would otherwise carry every
// XObject the source lists — images and all (#204). Keep only what these pages draw.
crate::prune::prune_unused_xobjects(&mut out)?;
@ -463,7 +478,8 @@ pub fn split(src: &Document, by: &SplitBy) -> Result<Vec<Document>, OrganizeErro
/// Combine whole documents, in order, into a new document. Each source gets a top-level
/// bookmark (its `title`) pointing to its first page, like Acrobat's Combine Files.
/// The source's own bookmarks are nested (collapsed) under its entry, and its document-level
/// attachments are carried over.
/// attachments are carried over. When every source declares the same print standard (PDF/X
/// with the same output intent), so does the result.
pub fn combine(sources: &[(&str, &Document)]) -> Result<Document, OrganizeError> {
let all: Vec<(&str, &Document, Option<&[usize]>)> = sources.iter().map(|(t, d)| (*t, *d, None)).collect();
combine_selected(&all)
@ -475,6 +491,10 @@ pub fn combine_selected(sources: &[(&str, &Document, Option<&[usize]>)]) -> Resu
let mut out = Document::new_empty();
let mut marks = Vec::new();
let mut attachments = Vec::new();
// The first source's print standard, and what its pages brought along; it carries over
// only when every source declares the same one.
let mut standard: Option<(crate::pdfx::PrintStandard, &Document, HashMap<ObjRef, ObjRef>)> = None;
let mut agreed = true;
for (title, src, chosen) in sources {
let n = crate::page_count(src)?;
let pages: Vec<usize> = match chosen {
@ -487,11 +507,19 @@ pub fn combine_selected(sources: &[(&str, &Document, Option<&[usize]>)]) -> Resu
None => (0..n).collect(),
};
let at = crate::page_count(&out)?;
let (new, page_map) = import_pages_mapped(&mut out, src, &pages, at)?;
if let Some(first) = new.first() {
marks.push((title.to_string(), *first, *src, page_map));
let imported = import_pages_mapped(&mut out, src, &pages, at)?;
if let Some(first) = imported.pages.first() {
marks.push((title.to_string(), *first, *src, imported.page_map));
}
collect_attachments(&mut out, src, &mut attachments);
let declared = crate::pdfx::PrintStandard::of(src);
match &standard {
None => standard = Some((declared, *src, imported.objects)),
Some((first, _, _)) => agreed &= first.same_as(&declared),
}
}
if let Some((declared, src, objects)) = standard.filter(|_| agreed) {
crate::pdfx::carry(&mut out, src, &declared, objects)?;
}
// Sources often share fonts, images and profiles (or are the same file): store them once.
let all: Vec<ObjRef> = out.object_numbers().into_iter().map(|n| ObjRef::new(n, out.generation(n))).collect();

View File

@ -18,6 +18,7 @@ mod dedupe;
mod import;
mod labels;
mod outline;
mod pdfx;
mod prune;
pub mod view;
@ -99,6 +100,13 @@ pub fn pages(doc: &Document) -> Result<Vec<PageRef>, OrganizeError> {
Ok(walk(doc)?.into_iter().map(|(obj, _)| PageRef { obj }).collect())
}
/// Effective inherited page rotation, clockwise in degrees.
pub fn page_rotation(doc: &Document, index: usize) -> Result<i64, OrganizeError> {
let pages = walk(doc)?;
let (_, attrs) = pages.get(index).ok_or(OrganizeError::NoSuchPage(index))?;
Ok(attrs.int(b"Rotate").unwrap_or(0).rem_euclid(360))
}
pub fn page_count(doc: &Document) -> Result<usize, OrganizeError> {
Ok(walk(doc)?.len())
}
@ -347,12 +355,17 @@ pub fn info(doc: &Document, key: &str) -> Option<String> {
pub fn set_info(doc: &mut Document, key: &str, value: &str) -> Result<(), OrganizeError> {
let value = value.trim();
let entry = (!value.is_empty()).then(|| Object::String(PdfString::text(value)));
set_info_entry(doc, key.as_bytes(), entry)
}
/// Set (or remove, with `None`) a document-information entry of any type.
fn set_info_entry(doc: &mut Document, key: &[u8], entry: Option<Object>) -> Result<(), OrganizeError> {
match doc.trailer().get(b"Info").cloned() {
Some(Object::Ref(r)) if doc.get(r).as_dict().is_some() => {
doc.update_dict(r, |d| match entry {
Some(v) => d.set(key.as_bytes().to_vec(), v),
Some(v) => d.set(key.to_vec(), v),
None => {
d.remove(key.as_bytes());
d.remove(key);
}
})?;
}
@ -362,7 +375,7 @@ pub fn set_info(doc: &mut Document, key: &str, value: &str) -> Result<(), Organi
Some(Object::Dict(d)) => d.clone(),
_ => Dict::new(),
};
d.set(key.as_bytes().to_vec(), v);
d.set(key.to_vec(), v);
let r = doc.add(d);
doc.trailer_mut().set(b"Info".to_vec(), Object::Ref(r));
}

195
crates/organize/src/pdfx.rs Normal file
View File

@ -0,0 +1,195 @@
//! The print standard a document declares (PDF/X, ISO 15930), carried into the new documents
//! Extract, Split and Combine make from its pages (#263). Apart from its pages, a PDF/X file is
//! the catalog's `/OutputIntents` (the printing condition and its ICC profile) and the version
//! it declares, in the document information (`GTS_PDFXVersion`, `GTS_PDFXConformance`,
//! `Trapped`) and in its XMP metadata (`pdfxid:`).
//!
//! The new document gets an XMP packet of its own, holding that identification and the title.
//! The source's packet is not copied: it can also claim standards the new document no longer
//! meets, such as PDF/UA, whose structure tree is not copied.
use std::collections::HashMap;
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
use crate::OrganizeError;
/// Decoding limit for an output profile or an XMP packet (ICC profiles are a few MB at most).
const MAX_DECODED: usize = 64 << 20;
/// What a document declares about the print standard it meets.
#[derive(Debug, Default)]
pub(crate) struct PrintStandard {
/// The catalog's `/OutputIntents`, as written.
intents: Option<Object>,
/// Each output intent's type (`/S`), printing condition and decoded profile, to compare.
conditions: Vec<(Vec<u8>, Vec<u8>, Vec<u8>)>,
/// `GTS_PDFXVersion` ("PDF/X-4"), from the document information or else the XMP metadata.
version: Option<String>,
/// `GTS_PDFXConformance` ("PDF/X-1a:2001"), which PDF/X-1a and PDF/X-3 add.
conformance: Option<String>,
/// `Trapped`.
trapped: Option<&'static str>,
/// Whether the document has XMP metadata (a PDF/X-1a:2001 file, PDF 1.3, has none).
xmp: bool,
}
impl PrintStandard {
/// What `doc` declares.
pub(crate) fn of(doc: &Document) -> Self {
let catalog = doc.root().and_then(|r| doc.get(r).as_dict().cloned()).unwrap_or_default();
let intents = catalog.get(b"OutputIntents").cloned();
let list = intents.as_ref().and_then(|o| doc.resolve(o).as_array().cloned()).unwrap_or_default();
let conditions = list
.iter()
.map(|oi| {
let oi = doc.resolve(oi);
let d = oi.as_dict();
let kind = d.and_then(|d| d.name(b"S")).unwrap_or_default().to_vec();
let id = d.and_then(|d| d.get(b"OutputConditionIdentifier")).and_then(|v| doc.resolve(v).as_string().map(|s| s.bytes.clone()));
let profile = d.and_then(|d| d.get(b"DestOutputProfile")).map(|p| doc.resolve(p)).and_then(|p| match &*p {
// Undecodable: compare the bytes as stored.
Object::Stream(s) => Some(s.decoded_within(MAX_DECODED).unwrap_or_else(|_| s.raw.to_vec())),
_ => None,
});
(kind, id.unwrap_or_default(), profile.unwrap_or_default())
})
.collect();
let xmp = metadata(doc, &catalog);
let declared = |info_key: &str, xmp_key: &str| {
crate::info(doc, info_key)
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
.or_else(|| xmp.as_deref().and_then(|x| xmp_value(x, xmp_key)))
};
let trapped = info_trapped(doc).or_else(|| xmp.as_deref().and_then(|x| xmp_value(x, "pdf:Trapped")).and_then(|t| trapped(t.as_bytes())));
Self {
intents,
conditions,
version: declared("GTS_PDFXVersion", "pdfxid:GTS_PDFXVersion"),
conformance: declared("GTS_PDFXConformance", "pdfxid:GTS_PDFXConformance"),
trapped,
xmp: xmp.is_some(),
}
}
/// Whether `other` declares the same standard: the same version and trapping, and the same
/// output intents (type, printing condition and profile).
pub(crate) fn same_as(&self, other: &Self) -> bool {
self.conditions == other.conditions && self.version == other.version && self.conformance == other.conformance && self.trapped == other.trapped
}
}
/// Give `dst` the print standard `src` declares: its output intents, copied with their profiles
/// (through `map`, what the pages already brought along), and, for PDF/X, the document
/// information entries and the XMP metadata that identify it.
pub(crate) fn carry(dst: &mut Document, src: &Document, standard: &PrintStandard, map: HashMap<ObjRef, ObjRef>) -> Result<(), OrganizeError> {
let root = dst.root().ok_or(OrganizeError::NoPageTree)?;
if let Some(intents) = &standard.intents {
let copied = crate::import::copy_object(dst, src, intents, map);
dst.update_dict(root, |c| c.set(b"OutputIntents".to_vec(), copied))?;
}
let Some(version) = &standard.version else { return Ok(()) };
crate::set_info_entry(dst, b"GTS_PDFXVersion", Some(Object::String(PdfString::text(version))))?;
if let Some(c) = &standard.conformance {
crate::set_info_entry(dst, b"GTS_PDFXConformance", Some(Object::String(PdfString::text(c))))?;
}
if let Some(t) = standard.trapped {
crate::set_info_entry(dst, b"Trapped", Some(Object::name(t)))?;
}
if standard.xmp {
let mut md = Dict::new();
md.set(b"Type".to_vec(), Object::name("Metadata"));
md.set(b"Subtype".to_vec(), Object::name("XML"));
// Uncompressed, so other tools can find it.
let m = dst.add(Object::Stream(Stream::from_raw(md, packet(dst, standard, version).into_bytes())));
dst.update_dict(root, |c| c.set(b"Metadata".to_vec(), Object::Ref(m)))?;
}
Ok(())
}
/// The XMP packet identifying `doc` as `version`, with its title and trapping.
fn packet(doc: &Document, standard: &PrintStandard, version: &str) -> String {
let mut props = String::new();
if let Some(t) = crate::info(doc, "Title").filter(|t| !t.trim().is_empty()) {
props.push_str(&format!(" <dc:title><rdf:Alt><rdf:li xml:lang=\"x-default\">{}</rdf:li></rdf:Alt></dc:title>\n", esc(&t)));
}
if let Some(t) = standard.trapped {
props.push_str(&format!(" <pdf:Trapped>{t}</pdf:Trapped>\n"));
}
props.push_str(&format!(" <pdfxid:GTS_PDFXVersion>{}</pdfxid:GTS_PDFXVersion>\n", esc(version)));
if let Some(c) = &standard.conformance {
props.push_str(&format!(" <pdfxid:GTS_PDFXConformance>{}</pdfxid:GTS_PDFXConformance>\n", esc(c)));
}
format!(
"<?xpacket begin=\"\u{feff}\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n\
<x:xmpmeta xmlns:x=\"adobe:ns:meta/\">\n\
<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\n\
<rdf:Description rdf:about=\"\"\n\
xmlns:dc=\"http://purl.org/dc/elements/1.1/\"\n\
xmlns:pdf=\"http://ns.adobe.com/pdf/1.3/\"\n\
xmlns:pdfxid=\"http://www.npes.org/pdfx/ns/id/\">\n\
{props} </rdf:Description>\n\
</rdf:RDF>\n\
</x:xmpmeta>\n\
<?xpacket end=\"w\"?>"
)
}
/// The document information's `/Trapped` (a name, or a string in older files).
fn info_trapped(doc: &Document) -> Option<&'static str> {
let info = doc.resolve(doc.trailer().get(b"Info")?);
let v = doc.resolve(info.as_dict()?.get(b"Trapped")?);
match &*v {
Object::Name(n) => trapped(n),
Object::String(s) => trapped(&s.bytes),
_ => None,
}
}
fn trapped(v: &[u8]) -> Option<&'static str> {
match v {
b"True" => Some("True"),
b"False" => Some("False"),
b"Unknown" => Some("Unknown"),
_ => None,
}
}
/// The catalog's XMP metadata, as text.
fn metadata(doc: &Document, catalog: &Dict) -> Option<String> {
match &*doc.resolve(catalog.get(b"Metadata")?) {
Object::Stream(s) => s.decoded_within(MAX_DECODED).ok().map(|b| String::from_utf8_lossy(&b).into_owned()),
_ => None,
}
}
/// A simple XMP property, written as an attribute (`pdfxid:GTS_PDFXVersion="PDF/X-4"`) or as an
/// element (`<pdfxid:GTS_PDFXVersion>PDF/X-4</pdfxid:GTS_PDFXVersion>`).
fn xmp_value(xmp: &str, key: &str) -> Option<String> {
for quote in ['"', '\''] {
let attr = format!("{key}={quote}");
if let Some(rest) = xmp.find(&attr).and_then(|i| xmp.get(i + attr.len()..)) {
return rest.find(quote).and_then(|end| rest.get(..end)).map(unescape).filter(|v| !v.is_empty());
}
}
let open = format!("<{key}>");
let rest = xmp.get(xmp.find(&open)? + open.len()..)?;
let value = rest.get(..rest.find(&format!("</{key}>"))?)?;
Some(unescape(value.trim())).filter(|v| !v.is_empty())
}
fn unescape(s: &str) -> String {
s.replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"").replace("&apos;", "'").replace("&amp;", "&")
}
/// `s` as XML text: markup escaped, and characters XML 1.0 does not allow left out.
fn esc(s: &str) -> String {
s.chars()
.filter(|c| (!c.is_control() || matches!(c, '\t' | '\n' | '\r')) && !matches!(c, '\u{FFFE}' | '\u{FFFF}'))
.collect::<String>()
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}

View File

@ -4,6 +4,18 @@ use pdfcraft_cos::{Document, Object, SaveOptions, write_full, write_incremental}
use super::*;
#[test]
fn page_rotation_resolves_inheritance_overrides_and_missing_pages() {
let mut doc = Document::open(Arc::new(fixture())).unwrap();
for page in 0..3 {
assert_eq!(page_rotation(&doc, page).unwrap(), 90);
}
rotate_pages(&mut doc, &[1], -180).unwrap();
assert_eq!(page_rotation(&doc, 1).unwrap(), 270);
assert_eq!(page_rotation(&doc, 0).unwrap(), 90);
assert_eq!(page_rotation(&doc, 3), Err(OrganizeError::NoSuchPage(3)));
}
/// A 3-page document with a nested page tree. MediaBox and Rotate are inherited from the root,
/// Resources from an intermediate node; each page's content says which page it is.
fn fixture() -> Vec<u8> {
@ -401,6 +413,167 @@ fn split_produces_standalone_documents_with_metadata() {
}
}
// ── PDF/X (#263) ───────────────────────────────────────────────────────────────────────────────
/// PDF/X-4 identification in XMP, as an attribute, next to a PDF/UA claim.
const X4_XMP: &str = "<?xpacket begin=\"\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?><x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><rdf:RDF \
xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\"><rdf:Description rdf:about=\"\" \
xmlns:pdfxid=\"http://www.npes.org/pdfx/ns/id/\" xmlns:pdfuaid=\"http://www.aiim.org/pdfua/ns/id/\" \
pdfxid:GTS_PDFXVersion=\"PDF/X-4\" pdfuaid:part=\"1\"/></rdf:RDF></x:xmpmeta><?xpacket end=\"r\"?>";
/// A two-page print file whose output intent prints to `condition` with `profile` as its ICC
/// profile, with `info` as its document information and `xmp` as its XMP metadata.
fn doc_print(condition: &str, profile: &str, info: &str, xmp: Option<&str>) -> Document {
let metadata = if xmp.is_some() { " /Metadata 10 0 R" } else { "" };
let mut b: Vec<String> = vec![
format!("<< /Type /Catalog /Pages 2 0 R /OutputIntents [7 0 R]{metadata} >>"), // 1
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 252 144] >>".into(), // 2
"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /TrimBox [9 9 243 135] >>".into(), // 3
"<< /Type /Page /Parent 2 0 R /Contents 6 0 R /TrimBox [9 9 243 135] >>".into(), // 4
body("X1"), // 5
body("X2"), // 6
format!("<< /Type /OutputIntent /S /GTS_PDFX /OutputConditionIdentifier ({condition}) /DestOutputProfile 8 0 R >>"), // 7
format!("<< /N 4 /Length {} >>\nstream\n{profile}\nendstream", profile.len()), // 8
format!("<< {info} >>"), // 9
];
if let Some(x) = xmp {
b.push(format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{x}\nendstream", x.len())); // 10
}
open(build(&b, "/Root 1 0 R /Info 9 0 R"))
}
/// A PDF/X-4 business card printing to `condition` with `profile`.
fn doc_x4(condition: &str, profile: &str) -> Document {
doc_print(condition, profile, "/Title (Card) /GTS_PDFXVersion (PDF/X-4) /Trapped /False", Some(X4_XMP))
}
/// What a document says about PDF/X.
#[derive(Debug, Default, PartialEq)]
struct Pdfx {
/// Each output intent's printing condition and decoded profile.
intents: Vec<(String, Vec<u8>)>,
version: Option<String>,
trapped: Option<Vec<u8>>,
xmp: Option<String>,
}
fn pdfx_of(doc: &Document) -> Pdfx {
let cat = catalog(doc);
let list = cat.get(b"OutputIntents").map(|o| doc.resolve(o).as_array().cloned().unwrap_or_default()).unwrap_or_default();
let intents = list
.iter()
.filter_map(|oi| {
let d = doc.resolve(oi).as_dict().cloned()?;
let id = d.get(b"OutputConditionIdentifier").and_then(|v| doc.resolve(v).as_string().map(|s| s.to_text())).unwrap_or_default();
let profile = match d.get(b"DestOutputProfile").map(|p| doc.resolve(p)).as_deref() {
Some(Object::Stream(s)) => s.decoded().unwrap(),
_ => Vec::new(),
};
Some((id, profile))
})
.collect();
let trapped = doc.trailer().get(b"Info").map(|i| doc.resolve(i)).and_then(|i| i.as_dict().and_then(|d| d.name(b"Trapped")).map(<[u8]>::to_vec));
let xmp = match cat.get(b"Metadata").map(|m| doc.resolve(m)).as_deref() {
Some(Object::Stream(s)) => Some(String::from_utf8(s.decoded().unwrap()).unwrap()),
_ => None,
};
Pdfx { intents, version: info(doc, "GTS_PDFXVersion"), trapped, xmp }
}
#[test]
fn extract_and_split_keep_the_pdfx_output_intent_and_identification() {
// #263: the parts had no /OutputIntents, no GTS_PDFXVersion and no XMP pdfxid, so a PDF/X
// file stopped being one.
let src = doc_x4("FOGRA39", "icc-fogra39");
let mut outs = vec![extract_pages(&src, &[1]).unwrap()];
outs.extend(split(&src, &SplitBy::PageCount(1)).unwrap());
for out in &outs {
let x = pdfx_of(&full_roundtrip(out));
assert_eq!(x.intents, [("FOGRA39".to_string(), b"icc-fogra39".to_vec())]);
assert_eq!(x.version.as_deref(), Some("PDF/X-4"));
assert_eq!(x.trapped.as_deref(), Some(&b"False"[..]));
let xmp = x.xmp.expect("XMP metadata");
assert!(xmp.contains("<pdfxid:GTS_PDFXVersion>PDF/X-4</pdfxid:GTS_PDFXVersion>"), "{xmp}");
assert!(xmp.contains(">Card</rdf:li>") && xmp.contains("<pdf:Trapped>False</pdf:Trapped>"), "{xmp}");
// The structure tree is not copied, so the source's PDF/UA claim must not be either.
assert!(!xmp.contains("pdfuaid"), "{xmp}");
}
}
#[test]
fn combine_keeps_pdfx_when_every_source_agrees() {
// #263: a PDF/X-4 card combined with itself.
let (a, b) = (doc_x4("FOGRA39", "icc-fogra39"), doc_x4("FOGRA39", "icc-fogra39"));
let out = full_roundtrip(&combine(&[("a", &a), ("b", &b)]).unwrap());
assert_eq!(labels(&out), ["X1", "X2", "X1", "X2"]);
let x = pdfx_of(&out);
assert_eq!(x.intents, [("FOGRA39".to_string(), b"icc-fogra39".to_vec())], "one output intent, not one per source");
assert_eq!(x.version.as_deref(), Some("PDF/X-4"));
assert!(x.xmp.is_some_and(|x| x.contains("<pdfxid:GTS_PDFXVersion>PDF/X-4</pdfxid:GTS_PDFXVersion>")));
}
#[test]
fn combine_claims_no_pdfx_when_the_sources_disagree() {
// Another printing condition, the same name with another profile, or a source that is not
// PDF/X: no one standard describes the result, so it claims none.
let x4 = doc_x4("FOGRA39", "icc-fogra39");
for other in [doc_x4("GRACoL2013", "icc-gracol"), doc_x4("FOGRA39", "icc-other"), doc_b()] {
let out = full_roundtrip(&combine(&[("a", &x4), ("b", &other)]).unwrap());
assert_eq!(pdfx_of(&out), Pdfx::default());
}
}
#[test]
fn documents_without_a_print_standard_gain_none() {
assert_eq!(pdfx_of(&full_roundtrip(&extract_pages(&doc_a(), &[0]).unwrap())), Pdfx::default());
assert_eq!(pdfx_of(&full_roundtrip(&combine(&[("a", &doc_a()), ("b", &doc_b())]).unwrap())), Pdfx::default());
}
#[test]
fn the_pdfx_version_comes_from_xmp_or_the_document_information() {
// A PDF/X-4 file may name its version only in XMP (here as an element); a PDF/X-1a:2001 file
// (PDF 1.3) only in its document information, with no XMP, and gets no XMP added.
let xmp = "<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\"><rdf:Description \
rdf:about=\"\" xmlns:pdfxid=\"http://www.npes.org/pdfx/ns/id/\" xmlns:pdf=\"http://ns.adobe.com/pdf/1.3/\">\
<pdfxid:GTS_PDFXVersion> PDF/X-4 </pdfxid:GTS_PDFXVersion><pdf:Trapped>True</pdf:Trapped></rdf:Description></rdf:RDF></x:xmpmeta>";
let x = pdfx_of(&full_roundtrip(&extract_pages(&doc_print("FOGRA51", "icc", "/Title (Flyer)", Some(xmp)), &[0]).unwrap()));
assert_eq!((x.version.as_deref(), x.trapped.as_deref()), (Some("PDF/X-4"), Some(&b"True"[..])));
assert!(x.xmp.is_some_and(|x| x.contains("<pdfxid:GTS_PDFXVersion>PDF/X-4</pdfxid:GTS_PDFXVersion>")));
let x1a = doc_print("CGATS TR 001", "icc", "/GTS_PDFXVersion (PDF/X-1:2001) /GTS_PDFXConformance (PDF/X-1a:2001) /Trapped (False)", None);
let out = full_roundtrip(&extract_pages(&x1a, &[0]).unwrap());
let x = pdfx_of(&out);
assert_eq!((x.version.as_deref(), x.trapped.as_deref()), (Some("PDF/X-1:2001"), Some(&b"False"[..])));
assert_eq!(info(&out, "GTS_PDFXConformance").as_deref(), Some("PDF/X-1a:2001"));
assert_eq!(x.xmp, None);
}
#[test]
fn malformed_output_intents_and_identification_do_not_fail() {
// Untrusted input: intents that are not dictionaries, a missing profile, an intent that is
// its own profile, an XMP packet that never closes, and a version full of markup and NULs.
let b: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /OutputIntents [5 0 R 42 (junk) 6 0 R] /Metadata 7 0 R >>".into(), // 1
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>".into(), // 2
"<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>".into(), // 3
body("M1"), // 4
"<< /Type /OutputIntent /S /GTS_PDFX /DestOutputProfile 99 0 R >>".into(), // 5
"<< /Type /OutputIntent /S /GTS_PDFX /OutputConditionIdentifier (x) /DestOutputProfile 6 0 R >>".into(), // 6
"<< /Length 30 >>\nstream\n<pdfxid:GTS_PDFXConformance>X\nendstream".into(), // 7
"<< /GTS_PDFXVersion (<PDF/X-4 & \"more\">\\000) /Trapped /Maybe >>".into(), // 8
];
let src = open(build(&b, "/Root 1 0 R /Info 8 0 R"));
let out = full_roundtrip(&extract_pages(&src, &[0]).unwrap());
assert_eq!(labels(&out), ["M1"]);
let list = catalog(&out).get(b"OutputIntents").map(|o| out.resolve(o).as_array().cloned().unwrap_or_default()).unwrap_or_default();
assert_eq!(list.len(), 4, "every entry is kept as written");
let x = pdfx_of(&out);
assert_eq!(x.trapped, None, "/Maybe is not a trapping state");
let xmp = x.xmp.expect("XMP metadata");
assert!(xmp.contains("<pdfxid:GTS_PDFXVersion>&lt;PDF/X-4 &amp; &quot;more&quot;&gt;</pdfxid:GTS_PDFXVersion>"), "{xmp}");
assert!(!xmp.contains('\0') && !xmp.contains("GTS_PDFXConformance"), "{xmp}");
}
#[test]
fn duplicate_pages_become_independent_copies() {
let out = full_roundtrip(&extract_pages(&doc_b(), &[0, 0]).unwrap());

View File

@ -0,0 +1,11 @@
[package]
name = "pdfcraft-platform"
description = "Operating-system services shared by the PdfCraft desktop app and automation tools"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]

View File

@ -0,0 +1,7 @@
# pdfcraft-platform
Layer L7 holds operating-system services that both the desktop app (`pdfcraft-ui-egui`) and the automation tools (`pdfcraft-automation`) need. Those two crates sit on the same layer and can't depend on each other, so a helper they share lives here instead of being copied into each (`xtask/src/layers.rs` allows both edges). It uses only the standard library and has no UI dependencies.
`staging` creates the temporary file an atomic save writes before renaming it over the target. The file is opened with `create_new`, so anything already at the name (a file, a hard link, a symbolic link even when dangling, a folder) is skipped and never written through. On Windows a folder at the name fails with "access denied" rather than "already exists", and is skipped only when something is actually there. Names carry an unpredictable 64-bit suffix from `staging_suffixes` (the standard library's keyed hash, currently SipHash-1-3, keyed from the operating system's random source), which stops a name being planted in advance but is not a CSPRNG; fixed suffixes are only for tests. At most 16 names are tried, and then the save is refused. The target's name is cut to 128 bytes on a character boundary so the staging name fits every system's limit. Each caller keeps its own name form (`.{name}.{suffix}.pdfcraft-tmp` for automation, `.{name}.pdfcraft-{suffix}.tmp` for the desktop save). Writing, closing the file before the rename, renaming and removing the staging file on failure stay with the caller.
Synthetic tests pin both name forms, the 128-byte cut, the attempt limit and refusal when no name is left. The planted-file, link, folder and failed-rename tests stay with each caller, where they exercise the whole save.

View File

@ -0,0 +1,6 @@
//! pdfcraft-platform — L7 operating-system services shared by the desktop app (`ui-egui`) and the
//! automation tools (`automation`), which sit on the same layer and can't depend on each other.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod staging;

View File

@ -0,0 +1,129 @@
//! Staging files for atomic saves: a new, unpredictably named file beside the target, written and
//! then renamed over it. Writing, closing, renaming and cleaning up stay with the caller.
use std::path::{Path, PathBuf};
/// How many staging names [`create_staging`] tries. A random 64-bit name is only taken if someone
/// put a file there on purpose, so running out means refusing, not trying harder.
pub const STAGING_ATTEMPTS: usize = 16;
/// Where the suffix goes in a staging name. Each caller keeps the form it has always used.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StagingName {
/// `.{name}.{suffix}.pdfcraft-tmp`, used by the automation tools.
SuffixThenTag,
/// `.{name}.pdfcraft-{suffix}.tmp`, used by the desktop app's save.
TagThenSuffix,
}
/// Create a new, empty staging file in `dir` for the file `name`, one name per suffix. It is
/// opened with `create_new`, which fails if anything already has the name (a file, a hard link,
/// a symbolic link even when dangling, a folder), on Windows as everywhere else; such a name is
/// skipped, never opened, so a file planted at the staging path can't receive or redirect the
/// write. Pass [`staging_suffixes`]: fixed suffixes are for tests, and a predictable name can be
/// taken in advance to refuse every save.
pub fn create_staging(
dir: &Path,
name: &str,
form: StagingName,
suffixes: impl IntoIterator<Item = u64>,
) -> std::io::Result<(PathBuf, std::fs::File)> {
// At most 128 bytes of the target's name, cut between characters, so the staging name fits
// the 255-byte (Linux, macOS) and 255-unit (Windows) limits however long that name is.
let mut stem = String::new();
for c in name.chars() {
if stem.len() + c.len_utf8() > 128 {
break;
}
stem.push(c);
}
for suffix in suffixes.into_iter().take(STAGING_ATTEMPTS) {
let tmp = dir.join(match form {
StagingName::SuffixThenTag => format!(".{stem}.{suffix:016x}.pdfcraft-tmp"),
StagingName::TagThenSuffix => format!(".{stem}.pdfcraft-{suffix:016x}.tmp"),
});
match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) {
Ok(file) => return Ok((tmp, file)),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
// Windows reports a folder at the name as "access denied"; it is taken all the same.
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied && tmp.symlink_metadata().is_ok() => {}
Err(e) => return Err(e),
}
}
Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "every temporary file name tried is taken"))
}
/// Unpredictable staging-name suffixes, so a name can't be planted in advance. Each is the
/// standard library's keyed hash of a counter (`RandomState::hash_one`, currently SipHash-1-3),
/// keyed from the operating system's random source. That is all a staging name needs, but it is
/// not a CSPRNG: don't use these where a value must stay secret or unguessable after it has been
/// seen, such as a token or a key.
pub fn staging_suffixes() -> impl Iterator<Item = u64> {
use std::hash::BuildHasher;
let state = std::hash::RandomState::new();
(0u64..).map(move |i| state.hash_one(i))
}
#[cfg(test)]
mod tests {
use super::*;
/// A fresh, empty folder for one test.
fn test_dir(test: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("pdfcraft-platform-staging-{}-{test}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn file_name(p: &Path) -> String {
p.file_name().unwrap().to_string_lossy().into_owned()
}
/// The callers' tests plant files at these exact names; if a name changed, those tests would
/// plant where nothing writes and pass without testing anything.
#[test]
fn each_form_keeps_its_exact_name() {
let dir = test_dir("forms");
let (tools, _) = create_staging(&dir, "out.pdf", StagingName::SuffixThenTag, [0xab]).unwrap();
assert_eq!(file_name(&tools), ".out.pdf.00000000000000ab.pdfcraft-tmp");
let (desktop, _) = create_staging(&dir, "out.pdf", StagingName::TagThenSuffix, [0xab]).unwrap();
assert_eq!(file_name(&desktop), ".out.pdf.pdfcraft-00000000000000ab.tmp");
assert_eq!(std::fs::read(&tools).unwrap(), b"", "a new, empty file");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_long_name_is_cut_to_128_bytes_between_characters() {
let dir = test_dir("long");
// One byte, then four-byte characters: the 128-byte limit falls inside the 32nd one.
let name = format!("a{}.pdf", "\u{1F600}".repeat(40));
let (tmp, _) = create_staging(&dir, &name, StagingName::SuffixThenTag, [1]).unwrap();
assert_eq!(file_name(&tmp), format!(".a{}.0000000000000001.pdfcraft-tmp", "\u{1F600}".repeat(31)));
// One-byte characters: exactly 128 of them are kept.
let (tmp, _) = create_staging(&dir, &"x".repeat(200), StagingName::SuffixThenTag, [2]).unwrap();
assert_eq!(file_name(&tmp), format!(".{}.0000000000000002.pdfcraft-tmp", "x".repeat(128)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn running_out_of_suffixes_refuses_and_creates_nothing() {
let dir = test_dir("empty");
let e = create_staging(&dir, "out.pdf", StagingName::TagThenSuffix, []).unwrap_err();
assert_eq!(e.kind(), std::io::ErrorKind::AlreadyExists);
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn only_the_first_sixteen_names_are_tried() {
let dir = test_dir("cap");
let name = |s: u64| dir.join(format!(".out.pdf.{s:016x}.pdfcraft-tmp"));
for s in 1..=STAGING_ATTEMPTS as u64 {
std::fs::write(name(s), "PLANTED").unwrap();
}
assert!(create_staging(&dir, "out.pdf", StagingName::SuffixThenTag, 1..).is_err());
assert!(!name(STAGING_ATTEMPTS as u64 + 1).exists(), "a 17th name was tried");
let _ = std::fs::remove_dir_all(&dir);
}
}

View File

@ -33,7 +33,8 @@ annotations); sheets place them with a clip. The result is a fresh, unencrypted,
garbage-collected file (callers check the print permission).
`spool` talks to CUPS (`lpstat -p -d`, `lp` with copies, collation, duplex and monochrome
options). Other platforms report that printing to a printer isn't available yet; the
options). The job is piped to `lp` on stdin and never written to a temp file, where another
local user could read or swap it. Other platforms report that printing to a printer isn't available yet; the
print-ready PDF can always be saved.
Not yet: Windows and web spoolers, print as image, poster labels, PostScript output, colour

View File

@ -1,6 +1,6 @@
//! The system print spooler. On macOS and Linux this is CUPS: printers come from `lpstat`, jobs
//! go to `lp` with the job options (copies, collation, duplex, colour). Other platforms report
//! that printing isn't available yet; the print-ready PDF can still be saved.
//! are piped to `lp` with the job options (copies, collation, duplex, colour). Other platforms
//! report that printing isn't available yet; the print-ready PDF can still be saved.
use crate::PrintError;
@ -45,8 +45,8 @@ pub fn parse_lpstat(out: &str) -> Vec<Printer> {
.collect()
}
/// The `lp` arguments for a job printing `file`.
pub fn lp_args(job: &Job, file: &str) -> Vec<String> {
/// The `lp` arguments for a job. There is no file argument: `lp` reads the job from stdin.
pub fn lp_args(job: &Job) -> Vec<String> {
let mut a = Vec::new();
if let Some(p) = &job.printer {
a.extend(["-d".to_string(), p.clone()]);
@ -65,8 +65,6 @@ pub fn lp_args(job: &Job, file: &str) -> Vec<String> {
}
// The sheets are already laid out at their final size.
opt("fit-to-page=false");
a.push("--".into());
a.push(file.to_string());
a
}
@ -99,19 +97,7 @@ pub fn printers() -> Vec<Printer> {
pub fn submit(pdf: &[u8], job: &Job) -> Result<String, PrintError> {
#[cfg(all(unix, not(target_arch = "wasm32")))]
{
let dir = std::env::temp_dir().join(format!("pdfcraft-print-{}", std::process::id()));
std::fs::create_dir_all(&dir).map_err(|e| PrintError::Spool(e.to_string()))?;
let file = dir.join(format!("job-{}.pdf", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos())));
std::fs::write(&file, pdf).map_err(|e| PrintError::Spool(e.to_string()))?;
let out = std::process::Command::new("lp").args(lp_args(job, &file.to_string_lossy())).output();
let _ = std::fs::remove_file(&file);
let out = out.map_err(|e| PrintError::Spool(format!("the print spooler is not available: {e}")))?;
if out.status.success() {
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
} else {
let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
Err(PrintError::Spool(if err.is_empty() { "the print job was refused".into() } else { err }))
}
submit_via(std::process::Command::new("lp"), pdf, job)
}
#[cfg(not(all(unix, not(target_arch = "wasm32"))))]
{
@ -119,3 +105,64 @@ pub fn submit(pdf: &[u8], job: &Job) -> Result<String, PrintError> {
Err(PrintError::Spool("printing to a printer isn't available on this platform yet; save the print-ready PDF instead".into()))
}
}
/// [`submit`] with the spooler command given, so tests can stand in for `lp`.
///
/// The job goes to `lp` on stdin, never through a file: a predictable job folder in a shared
/// temp directory lets another local user read printed documents or swap the file before `lp`
/// reads it.
#[cfg(any(test, all(unix, not(target_arch = "wasm32"))))]
pub(crate) fn submit_via(mut lp: std::process::Command, pdf: &[u8], job: &Job) -> Result<String, PrintError> {
use std::io::Write;
use std::process::Stdio;
let mut child = lp
.args(lp_args(job))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| PrintError::Spool(format!("the print spooler is not available: {e}")))?;
let stdin = child.stdin.take();
// Feed the job on its own thread while collecting the output, so neither side can fill a
// pipe and wait on the other. Dropping `stdin` at the end closes it: the end of the job.
let fed_and_out = std::thread::scope(|s| {
let feeder = std::thread::Builder::new().name("print job".into()).spawn_scoped(s, move || match stdin {
Some(mut stdin) => stdin.write_all(pdf),
None => Err(std::io::Error::other("no pipe to the spooler")),
});
match feeder {
Ok(feeder) => {
let out = child.wait_with_output();
Ok((feeder.join(), out))
}
Err(e) => {
// No thread to feed it: stop `lp` rather than leave it with an empty job.
let _ = child.kill();
let _ = child.wait();
Err(PrintError::Spool(format!("the print job could not be sent to the spooler: {e}")))
}
}
});
let (fed, out) = fed_and_out?;
let out = out.map_err(|e| PrintError::Spool(format!("the print spooler is not available: {e}")))?;
if !out.status.success() {
// `lp` may refuse before reading the job (an unknown printer); its message says why,
// and the broken pipe that leaves behind does not.
let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
return Err(PrintError::Spool(if err.is_empty() { "the print job was refused".into() } else { err }));
}
match fed {
Ok(Ok(())) => Ok(job_message(&out.stdout)),
Ok(Err(e)) => Err(PrintError::Spool(format!("the print job could not be sent to the spooler: {e}"))),
Err(_) => Err(PrintError::Spool("the print job could not be sent to the spooler".into())),
}
}
/// `lp`'s reply ("request id is Office-12"), without the "(0 file(s))" CUPS adds when the job came
/// on stdin: it reads as if nothing was sent.
#[cfg(any(test, all(unix, not(target_arch = "wasm32"))))]
pub(crate) fn job_message(stdout: &[u8]) -> String {
let s = String::from_utf8_lossy(stdout);
let s = s.trim();
s.strip_suffix("(0 file(s))").map_or(s, str::trim_end).to_string()
}

View File

@ -193,10 +193,10 @@ fn spooler_arguments_and_printer_list() {
let job =
Job { printer: Some("Office_Laser".into()), copies: 3, collate: false, duplex: Duplex::LongEdge, grayscale: true, title: "memo.pdf".into() };
assert_eq!(
lp_args(&job, "/tmp/x.pdf").join(" "),
"-d Office_Laser -n 3 -t memo.pdf -o collate=false -o sides=two-sided-long-edge -o print-color-mode=monochrome -o fit-to-page=false -- /tmp/x.pdf"
lp_args(&job).join(" "),
"-d Office_Laser -n 3 -t memo.pdf -o collate=false -o sides=two-sided-long-edge -o print-color-mode=monochrome -o fit-to-page=false"
);
assert_eq!(lp_args(&Job::default(), "f.pdf")[0], "-n", "no -d: the default printer");
assert_eq!(lp_args(&Job::default())[0], "-n", "no -d: the default printer");
}
#[test]
@ -275,3 +275,87 @@ fn multiple_rejects_hostile_grids_without_panicking() {
assert!(matches!(layout(&[size], &settings(vec![0], cut_stack(2, 2))), Err(PrintError::Invalid(_))));
}
}
/// Set on the child process when a test runs this test binary as a stand-in for `lp`.
const STAND_IN_LP: &str = "PDFCRAFT_STAND_IN_LP";
/// Not a test of its own: [`stand_in_lp`] re-runs this binary with only this test selected, and it
/// then plays `lp`. It records its arguments and stdin in the folder named by [`STAND_IN_LP`], or,
/// when the folder is `refuse`, exits without reading stdin, the way `lp` refuses an unknown
/// printer.
#[test]
fn stand_in_for_lp() {
let Some(record) = std::env::var_os(STAND_IN_LP) else { return };
if record == "refuse" {
eprintln!("lp: The printer or class does not exist.");
std::process::exit(1);
}
let record = std::path::PathBuf::from(record);
let args: Vec<String> = std::env::args().skip_while(|a| a != "--").skip(1).collect();
let mut stdin = Vec::new();
std::io::Read::read_to_end(&mut std::io::stdin(), &mut stdin).unwrap();
std::fs::write(record.join("args"), args.join(" ")).unwrap();
std::fs::write(record.join("stdin"), stdin).unwrap();
}
/// A spooler command that runs [`stand_in_for_lp`] with `record` (see there).
fn stand_in_lp(record: &std::ffi::OsStr) -> std::process::Command {
let mut c = std::process::Command::new(std::env::current_exe().unwrap());
c.args(["--exact", "tests::stand_in_for_lp", "--nocapture", "--"]).env(STAND_IN_LP, record);
c
}
#[test]
fn print_jobs_reach_lp_on_stdin_never_through_a_shared_temp_folder() {
// The job used to be written to `<temp>/pdfcraft-print-<pid>/job-<time>.pdf` and handed to `lp`
// by name. In a shared /tmp another local user can predict that folder, create it first (or
// plant a symlink there) and so read every printed document or swap the file before `lp`
// reads it. Piping the job to `lp` leaves nothing on disk.
static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
let record =
std::env::temp_dir().join(format!("pdfcraft-print-test-{}-{}", std::process::id(), N.fetch_add(1, std::sync::atomic::Ordering::Relaxed)));
std::fs::create_dir(&record).unwrap();
// Bigger than any pipe buffer, so the spooler must read while the job is still being written.
let pdf: Vec<u8> = b"%PDF-1.7 synthetic print job\n".iter().copied().cycle().take(3 << 20).collect();
let job = Job { title: "memo.pdf".into(), ..Job::default() };
let sent = spool::submit_via(stand_in_lp(record.as_os_str()), &pdf, &job);
let args = std::fs::read_to_string(record.join("args"));
let stdin = std::fs::read(record.join("stdin"));
let _ = std::fs::remove_dir_all(&record);
assert!(sent.is_ok(), "{sent:?}");
assert_eq!(
args.unwrap(),
"-n 1 -t memo.pdf -o collate=true -o sides=one-sided -o fit-to-page=false",
"no file argument: lp reads the job from stdin"
);
assert!(stdin.unwrap() == pdf, "lp receives the whole job on stdin");
let predictable = std::env::temp_dir().join(format!("pdfcraft-print-{}", std::process::id()));
assert!(!predictable.exists(), "nothing is created at {predictable:?}, a name other local users can predict");
}
#[test]
fn a_refused_print_job_reports_the_spoolers_message() {
// `lp` refuses an unknown printer without reading the job; the user sees why, not a broken pipe.
let pdf = vec![b'%'; 3 << 20];
let err = spool::submit_via(stand_in_lp("refuse".as_ref()), &pdf, &Job::default());
assert_eq!(err, Err(PrintError::Spool("lp: The printer or class does not exist.".into())));
}
#[test]
fn the_spoolers_reply_drops_the_file_count_of_a_job_sent_on_stdin() {
assert_eq!(
spool::job_message(
b"request id is Office_Laser-12 (0 file(s))
"
),
"request id is Office_Laser-12"
);
assert_eq!(
spool::job_message(
b"request id is Office_Laser-13 (1 file(s))
"
),
"request id is Office_Laser-13 (1 file(s))"
);
assert_eq!(spool::job_message(b""), "");
}

View File

@ -56,7 +56,7 @@ fn digit_run(s: &[char], start: usize, seps: &[char], max_len: usize) -> (usize,
let c = s[i];
if c.is_ascii_digit() {
n += 1;
} else if !(seps.contains(&c) && digit(s.get(i + 1)) || (c == '(' || c == ')') && seps.contains(&c)) {
} else if !(seps.contains(&c) && (digit(s.get(i + 1)) || s.get(i + 1) == Some(&'(')) || (c == '(' || c == ')') && seps.contains(&c)) {
break;
}
i += 1;
@ -73,14 +73,31 @@ fn phone(s: &[char], i: usize) -> Option<usize> {
if !start_ok || word(i.checked_sub(1).and_then(|p| s.get(p))) || s.get(i.wrapping_sub(1)) == Some(&'+') {
return None;
}
let first = if s[i] == '+' { i + 1 } else { i };
let (end, n) = digit_run(s, first, &[' ', '-', '.', '(', ')'], 20);
let seps = s[first..end].iter().filter(|c| !c.is_ascii_digit()).count();
// 10 digits (11 with a country code), grouped by at least one separator.
if !(10..=11).contains(&n) || seps == 0 || digit(s.get(end)) {
let plus = s[i] == '+';
let first = if plus { i + 1 } else { i };
let (end, n) = digit_run(s, first, &[' ', '-', '.', '(', ')'], 28);
let run = s.get(first..end)?;
let seps = run.iter().filter(|c| !c.is_ascii_digit()).count();
// Always grouped by at least one separator, so plain numbers never match.
if seps == 0 || digit(s.get(end)) {
return None;
}
Some(end - i)
let ok = if plus {
// E.164: a `+` country code and up to 15 digits in all ("+49 2151 123456").
(8..=15).contains(&n)
} else {
// North American: 10 digits, 11 with a country code ("(555) 123-4567").
let nanp = (10..=11).contains(&n);
// National with a trunk 0 and 6–14 more digits ("0211 123456", "01 23 45 67 89").
// The first group needs two digits so decimals ("0.1234567") don't match, and
// day-first dates ("01.10.2026") are left to the date pattern.
let mut digits = run.iter().skip_while(|c| !c.is_ascii_digit());
let trunk_zero = digits.clone().next() == Some(&'0');
let lead = digits.by_ref().take_while(|c| c.is_ascii_digit()).count();
let trunk = trunk_zero && (7..=15).contains(&n) && lead >= 2 && date(s, i).is_none();
nanp || trunk
};
ok.then_some(end - i)
}
fn email(s: &[char], i: usize) -> Option<usize> {
@ -255,6 +272,20 @@ mod tests {
found(Pattern::Phone, "Call (555) 123-4567 or 555.987.6543, +1 555 222 3333; not 12345 or 5551234567890"),
["(555) 123-4567", "555.987.6543", "+1 555 222 3333"]
);
// #525: international numbers match whole; "+49" used to stay visible.
assert_eq!(found(Pattern::Phone, "Tel. +49 2151 123456."), ["+49 2151 123456"]);
assert_eq!(
found(Pattern::Phone, "+44 20 7946 0018, +33 1 23 45 67 89, +49 (0) 2151 123456, (0211) 123456, 030 12345678, 01 23 45 67 89"),
["+44 20 7946 0018", "+33 1 23 45 67 89", "+49 (0) 2151 123456", "(0211) 123456", "030 12345678", "01 23 45 67 89"]
);
// Not phone numbers: day-first dates, decimals, unseparated, too short or too long.
assert_eq!(
found(
Pattern::Phone,
"on 01.10.2026 or 09-10-2026, ratio 0.1234567, id 0211123456, +4921511234567, +1 234 567, 0211 12, +49 1234 5678 9012 3456"
),
Vec::<String>::new()
);
}
#[test]

View File

@ -9,13 +9,20 @@ license.workspace = true
workspace = true
[dependencies]
pdfcraft-fonts = { workspace = true }
hayro = { workspace = true }
hayro-syntax = { workspace = true }
lopdf = { workspace = true }
kurbo = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
# Reads rendered pixel words as bytes; already in the tree through hayro.
bytemuck = { version = "1" }
# lopdf (bootstrap) draws random bytes for encryption; on the web they come from the browser.
[target.'cfg(target_arch = "wasm32")'.dependencies]
getrandom = { version = "0.4", features = ["wasm_js"] }
[dev-dependencies]
# Checks the Japanese substitute's character coverage; the version pdfcraft-fonts already uses.
skrifa = "0.44"

Some files were not shown because too many files have changed in this diff Show More