Merge land/417 into land/450; image tools say page_images numbers count forms too

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-09 13:40:04 -04:00
commit 76c307df2f
380 changed files with 82639 additions and 856 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 (251)
## In this repository (257)
| Asset | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|
@ -14,9 +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 |
@ -259,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 = "50ec1133889f3a3948a3901a9fa40b3499f3c549b605e55848138483946d74da"
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 = "3746a7d87fb98c9f3aeb41c63ce50371c42dfebca64b67ccbb599abb36a93ba8"
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 = "2c09a121e493fb123d9f886529f62268119ca580ad5d5300eca61d4a39216827"
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 = "0ccf62e34424981d5980eeaee8799db2653a4c186807df143326ef9881959006"
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 = "0b579afad6e52b98cef5e61e07e238d9cf76cc7f295395f7e1c855400e5fbaf9"
sha256 = "e9a9bad1252afdcb42c36b24ca85411f8c63df7debada94d4423bd48761ecb05"
[[asset]]
path = "crates/ui-egui/src/i18n/fr.tsv"
@ -74,7 +74,18 @@ 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 = "956753e05b5a9e1d4c4a63ee57667cc9fa5bb95288c3d46c6b15435b83d6ed8d"
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"
@ -85,7 +96,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "e8b96edd718a1cd212b7440c4ab100f2555c97dccb0cda289bdb1775d5f3aa61"
sha256 = "d3630f989e9725c22444dc27765142674b66f72436502ca969b2851fc38d703d"
[[asset]]
path = "crates/ui-egui/src/i18n/ru.tsv"
@ -96,7 +107,29 @@ 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 = "136a9ae206c4ee5aad29d248613c568799080b4b3ee9cd197233072ee9a2983a"
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"
@ -107,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 = "d2e7114c70da3c52200cf68e9023dc32ff462439795784e49c2ae771531240e2"
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"
@ -3105,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"

9
Cargo.lock generated
View File

@ -3477,6 +3477,7 @@ dependencies = [
"hayro-syntax",
"pdfcraft-cos",
"pdfcraft-fonts",
"pdfcraft-model",
"thiserror 2.0.21",
]
@ -3569,6 +3570,7 @@ dependencies = [
"pdfcraft-fonts",
"pdfcraft-model",
"thiserror 2.0.21",
"unicode-bidi",
]
[[package]]
@ -3627,6 +3629,7 @@ dependencies = [
name = "pdfcraft-fonts"
version = "0.4.0"
dependencies = [
"harfrust",
"pdfcraft-cos",
"skrifa 0.44.0",
]
@ -3640,6 +3643,7 @@ dependencies = [
"pdfcraft-cos",
"pdfcraft-fonts",
"pdfcraft-model",
"pdfcraft-render",
"thiserror 2.0.21",
]
@ -3746,6 +3750,7 @@ dependencies = [
name = "pdfcraft-render"
version = "0.4.0"
dependencies = [
"bytemuck",
"getrandom 0.4.3",
"hayro",
"hayro-syntax",
@ -3753,6 +3758,7 @@ dependencies = [
"log",
"lopdf",
"pdfcraft-fonts",
"skrifa 0.44.0",
"thiserror 2.0.21",
]
@ -3788,6 +3794,7 @@ version = "0.4.0"
dependencies = [
"arboard",
"base64 0.22.1",
"bytemuck",
"eframe",
"egui",
"egui_extras",
@ -6586,8 +6593,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

@ -75,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
@ -92,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>

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, 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, 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,8 +91,8 @@ 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: 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 |
@ -103,7 +104,8 @@ Hours are for a single agent (low–high). "Done" is the estimated fraction of t
| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 165–290 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA |
| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 23% | 155–295 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export. Missing: autotag, Tags/Order/Content panels, Reading Order tool, alt-text workflow, compare, geospatial/3D measurement, search index, XFA |
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 33–66 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 5% | 115–240 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`; Japanese, Traditional Chinese, Simplified Chinese, Russian, 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,6 +127,11 @@ 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.
@ -138,6 +145,20 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run
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%).

View File

@ -1,23 +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] [--compact] MCP server on stdin/stdout (opt-in)
//! --compact lists a core set of tools plus tool_search and tool_call
//! 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
@ -43,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,

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

@ -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")]
@ -150,6 +153,85 @@ fn main() -> eframe::Result {
}
}
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])
@ -165,77 +247,82 @@ 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}");
}
// 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);
}
} 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.
@ -294,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
@ -330,52 +419,78 @@ 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)
}
@ -403,6 +518,26 @@ mod tests {
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();
@ -420,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);
@ -428,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"))?;
@ -466,9 +654,19 @@ 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(())
}

View File

@ -59,3 +59,8 @@ 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

@ -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
@ -40,6 +40,10 @@ deterministic; the engine supplies them.
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,77 @@ 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
@ -200,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
@ -334,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));
}
@ -383,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.
@ -444,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)?;
@ -458,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")?;
@ -536,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 {
@ -677,6 +730,16 @@ pub fn signature_image(doc: &Document, page: usize, index: usize) -> Result<Opti
.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).
@ -716,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}"));
@ -755,11 +826,11 @@ fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotError> {
}
r
}
Shape::Line { from, to, arrow } => {
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 } => {
@ -780,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);
@ -836,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",
@ -933,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);
}
@ -968,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));
@ -990,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 { .. } => {
@ -1029,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]]));
}
@ -1078,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));
@ -1469,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,
@ -1477,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)?;
@ -1485,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 {
@ -1516,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
@ -1666,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)`.
@ -1690,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 },
];
@ -179,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 },
];
@ -261,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];
@ -271,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(_))));
}
@ -283,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%");
@ -337,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();
@ -429,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]
@ -515,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();
@ -576,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());
@ -735,3 +762,160 @@ fn comments_without_appearances_get_one_for_display_only() {
]);
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

@ -23,7 +23,7 @@ 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 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.
- `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.

View File

@ -3,7 +3,9 @@
//! 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};
@ -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]))
@ -109,6 +138,7 @@ impl Automation {
};
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,
])
@ -180,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),
@ -253,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());
@ -286,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" },
}
}
@ -309,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)")?;
@ -386,7 +434,7 @@ impl Automation {
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, at, kind == "initials", &author).ok_or_else(|| ToolError::InvalidArgs("at must be finite".into()))?;
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;
@ -405,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 => {
@ -483,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

@ -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

@ -1690,7 +1690,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!({

View File

@ -245,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(
@ -298,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!({
@ -318,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\"." },
@ -527,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(
@ -555,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"],
@ -642,7 +644,7 @@ 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 and annotation opacity. Cache these layers for live placement/resizing; commit once with comment_edit. Does not change the document or undo history.")
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(
@ -972,7 +974,7 @@ 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 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.")
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"])),
@ -990,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"])),
@ -998,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 and no font/weight change is requested; otherwise it uses a standard font matching the source style or, for Japanese, real Mincho/Gothic fallback outlines matching the requested/source family and available weight (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.")
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 },

View File

@ -1530,6 +1530,7 @@ fn image_signature_preview_layers_are_read_only_and_survive_encrypted_save() {
assert_eq!(meta["layer"], "background");
assert_eq!(image_meta["layer"], "image");
assert_eq!(meta["rotation"], 90);
assert_eq!(meta["image_rotation"], 0, "placed upright as displayed on the turned page");
assert_eq!(meta["opacity"], 0.5);
assert_eq!((*width, *height), (120, 40));
assert_eq!(image::load_from_memory(background).unwrap().to_rgba8(), expected, "page text and the other signature remain");
@ -1742,6 +1743,34 @@ fn preparing_a_form_through_tools() {
assert!(matches!(a.call("form_set_image", &json!({ "doc": doc, "field": "size", "path": "pics/a_page_1.png" })), Err(ToolError::Failed(_))));
}
#[test]
fn rotating_a_field_through_tools() {
let dir = workdir("rotate-field");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, 20, 180, 42], "name": "City" }));
let rect_of = |a: &mut Automation| {
let f = &ok(a, "form_fields", json!({ "doc": doc }))["fields"][0];
(f["rotation"].as_i64().unwrap(), f["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect::<Vec<_>>())
};
let (rot, before) = rect_of(&mut a);
assert_eq!(rot, 0);
assert!(matches!(a.call("form_set_props", &json!({ "doc": doc, "field": "City", "rotation": 45 })), Err(ToolError::InvalidArgs(_))));
assert_eq!(rect_of(&mut a).1, before, "a rejected rotation changes nothing");
ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "City", "rotation": 90 }));
let (rot, turned) = rect_of(&mut a);
assert_eq!(rot, 90);
let (bw, bh) = (before[2] - before[0], before[3] - before[1]);
let (tw, th) = (turned[2] - turned[0], turned[3] - turned[1]);
assert!((tw - bh).abs() < 0.2 && (th - bw).abs() < 0.2, "swapped {before:?} -> {turned:?}");
let center = |r: &[f64]| ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0);
let (bc, tc) = (center(&before), center(&turned));
assert!((bc.0 - tc.0).abs() < 0.2 && (bc.1 - tc.1).abs() < 0.2, "center moved {bc:?} -> {tc:?}");
assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Change field properties");
let (rot, back) = rect_of(&mut a);
assert_eq!((rot, back), (0, before));
}
#[test]
fn redacting_through_tools() {
let dir = workdir("redact");
@ -1927,6 +1956,47 @@ fn comments_and_form_data_travel_as_xfdf_fdf_and_text() {
assert_eq!(r["undo"], "Import all.xfdf");
}
#[test]
fn natural_image_stamps_through_tools_on_rotated_pages() {
for degrees in [0, 90, 180, 270] {
let dir = workdir(&format!("natural-stamps-{degrees}"));
let mut bytes = Vec::new();
{
let mut encoder = png::Encoder::new(&mut bytes, 80, 40);
encoder.set_color(png::ColorType::Rgb);
encoder.set_depth(png::BitDepth::Eight);
let pixels: Vec<u8> = (0..40)
.flat_map(|y| {
(0..80).flat_map(move |x| {
[[240, 20, 20], [20, 180, 20], [20, 20, 240], [230, 180, 20]][usize::from(y >= 20) * 2 + usize::from(x >= 40)]
})
})
.collect();
encoder.write_header().unwrap().write_image_data(&pixels).unwrap();
}
std::fs::write(dir.join("quadrants.png"), bytes).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({"path":"a.pdf"}))["doc"].as_u64().unwrap();
ok(&mut a, "page_rotate", json!({"doc":doc,"pages":[1],"degrees":degrees}));
ok(&mut a, "stamp_custom", json!({"doc":doc,"page":1,"path":"quadrants.png","at":[100,100]}));
let rendered = a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72})).unwrap();
let Content::Png { data, .. } = &rendered[0] else { panic!("expected PNG") };
let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap();
let mut pixels = vec![0; reader.output_buffer_size().unwrap()];
let info = reader.next_frame(&mut pixels).unwrap();
assert_eq!(info.color_type, png::ColorType::Rgba);
for ((x, y), colour) in [(80, 90), (120, 90), (80, 110), (120, 110)].into_iter().zip([
[240, 20, 20, 255],
[20, 180, 20, 255],
[20, 20, 240, 255],
[230, 180, 20, 255],
]) {
let offset = ((y * info.width + x) * 4) as usize;
assert_eq!(&pixels[offset..offset + 4], &colour, "page rotation {degrees}");
}
}
}
#[test]
fn stamps_through_tools() {
let dir = workdir("stamps");
@ -2085,6 +2155,112 @@ fn note_icons_anchor_at_the_requested_corner_on_rotated_pages() {
}
}
/// An image signature is drawn upright as displayed, and anchored at the displayed point asked
/// for, on every `/Rotate`: its picture is placed in user space, which the page turns, so the
/// appearance is counter-rotated and its box is found in user space.
#[test]
fn image_signatures_stay_upright_on_rotated_pages() {
let dir = workdir("image-upright");
// 80 x 40 px, one colour per quadrant: red and green above, blue and yellow below.
let quadrants = image::RgbaImage::from_fn(80, 40, |x, y| match (x < 40, y < 20) {
(true, true) => image::Rgba([255, 0, 0, 255]),
(false, true) => image::Rgba([0, 255, 0, 255]),
(true, false) => image::Rgba([0, 0, 255, 255]),
(false, false) => image::Rgba([255, 255, 0, 255]),
});
quadrants.save(dir.join("quadrants.png")).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_create", json!({ "from": "blank", "width": 200, "height": 300, "pages": 4 }))["doc"].as_u64().unwrap();
for (page, degrees) in [(2, 90), (3, 180), (4, 270)] {
ok(&mut a, "page_rotate", json!({ "doc": doc, "pages": [page], "degrees": degrees }));
}
for page in 1..=4 {
// 64 x 32 pt from x = 60 as displayed, centred on y = 140.
ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": page, "type": "signature", "at": [60, 140], "path": "quadrants.png" }));
}
let (white, red, green, blue, yellow) = ([255, 255, 255], [255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0]);
let check = |a: &mut Automation, doc: u64| {
for page in 1..=4 {
let output = a.call("page_render", &json!({ "doc": doc, "page": page, "dpi": 72 })).unwrap();
let Content::Png { data, .. } = &output[0] else { panic!() };
let pixels = image::load_from_memory(data).unwrap().to_rgba8();
for (x, y, want, what) in [
(76, 132, red, "signature top-left"),
(108, 132, green, "signature top-right"),
(76, 148, blue, "signature bottom-left"),
(108, 148, yellow, "signature bottom-right"),
(56, 140, white, "left of the signature"),
(128, 140, white, "right of the signature"),
(92, 120, white, "above the signature"),
(92, 160, white, "below the signature"),
] {
let got = pixels.get_pixel(x, y).0;
assert!(got[..3].iter().zip(want).all(|(g, w)| g.abs_diff(w) < 12), "page {page}, {what} at ({x}, {y}): {got:?}, want {want:?}");
}
}
};
check(&mut a, doc);
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "placed.pdf" }));
let reopened = ok(&mut a, "doc_open", json!({ "path": "placed.pdf" }))["doc"].as_u64().unwrap();
check(&mut a, reopened);
}
/// Issue #299: a typed signature and initials read across, as displayed, on every `/Rotate`,
/// anchored at the displayed point, and look the same as on an unturned page.
#[test]
fn typed_signatures_stay_upright_on_rotated_pages() {
let dir = workdir("typed-upright");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_create", json!({ "from": "blank", "width": 200, "height": 300, "pages": 4 }))["doc"].as_u64().unwrap();
for (page, degrees) in [(2, 90), (3, 180), (4, 270)] {
ok(&mut a, "page_rotate", json!({ "doc": doc, "pages": [page], "degrees": degrees }));
}
for page in 1..=4 {
ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": page, "type": "signature", "at": [30, 60], "text": "Ada Lovelace" }));
ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": page, "type": "initials", "at": [30, 140], "text": "AL" }));
}
let check = |a: &mut Automation, doc: u64| {
let mut upright = Vec::new();
for page in 1..=4 {
let output = a.call("page_render", &json!({ "doc": doc, "page": page, "dpi": 72 })).unwrap();
let Content::Png { data, .. } = &output[0] else { panic!() };
let pixels = image::load_from_memory(data).unwrap().to_rgba8();
assert_eq!(pixels.dimensions(), if page % 2 == 1 { (200, 300) } else { (300, 200) }, "page {page}");
let dark: Vec<bool> = pixels.pixels().map(|p| p.0[..3].iter().all(|v| *v < 128)).collect();
for y in [60, 140] {
let mut b = [u32::MAX, u32::MAX, 0, 0];
for (x, py, p) in pixels.enumerate_pixels() {
if py.abs_diff(y) < 40 && p.0[..3].iter().all(|v| *v < 128) {
b = [b[0].min(x), b[1].min(py), b[2].max(x), b[3].max(py)];
}
}
assert!(b[0] <= b[2], "page {page}: no ink near y = {y}");
assert!(b[2] - b[0] > b[3] - b[1], "page {page}: the name reads across: {b:?}");
assert!(b[0].abs_diff(30) <= 2 && b[1] < y && b[3] > y, "page {page}: anchored at (30, {y}): {b:?}");
}
if page == 1 {
upright = dark;
continue;
}
// Same pixels as the unturned page where both pages are (pages 2 and 4 are wider).
let width = pixels.width();
let (mut differ, mut ink) = (0, 0);
for y in 0..200 {
for x in 0..200 {
let (want, got) = (upright[(y * 200 + x) as usize], dark[(y * width + x) as usize]);
ink += usize::from(want);
differ += usize::from(want != got);
}
}
assert!(differ * 10 < ink, "page {page}: {differ} of {ink} ink pixels differ from the unturned page");
}
};
check(&mut a, doc);
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "placed.pdf" }));
let reopened = ok(&mut a, "doc_open", json!({ "path": "placed.pdf" }))["doc"].as_u64().unwrap();
check(&mut a, reopened);
}
#[test]
fn comment_checkmarks_locks_hiding_and_summaries_through_tools() {
let dir = workdir("comment-polish");
@ -2116,6 +2292,72 @@ fn comment_checkmarks_locks_hiding_and_summaries_through_tools() {
assert!(matches!(a.call("comments_summarize", &json!({ "doc": doc, "sort": "colour" })), Err(ToolError::InvalidArgs(_))));
}
#[test]
fn line_endings_through_the_tool() {
let dir = workdir("endings");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "line", "from": [20, 40], "to": [120, 80], "endings": ["None", "Diamond"] }));
ok(
&mut a,
"comment_add",
json!({ "doc": doc, "page": 1, "type": "polyline", "points": [[20, 120], [60, 100], [100, 140]], "endings": ["Circle", "Slash"] }),
);
ok(
&mut a,
"comment_add",
json!({ "doc": doc, "page": 1, "type": "callout", "rect": [110, 200, 190, 240], "to": [40, 160], "contents": "Look", "endings": ["Square"] }),
);
assert!(matches!(
a.call("comment_add", &json!({ "doc": doc, "page": 1, "type": "line", "from": [20, 40], "to": [120, 80], "endings": ["Sparkle", "None"] })),
Err(ToolError::InvalidArgs(_))
));
assert!(matches!(
a.call(
"comment_add",
&json!({ "doc": doc, "page": 1, "type": "callout", "rect": [10, 10, 80, 40], "to": [90, 80], "endings": ["None", "None"] })
),
Err(ToolError::InvalidArgs(_))
));
assert!(matches!(
a.call("comment_add", &json!({ "doc": doc, "page": 1, "type": "rectangle", "rect": [10, 10, 40, 40], "endings": ["Circle"] })),
Err(ToolError::InvalidArgs(_))
));
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "ended.pdf" }));
let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("ended.pdf")).unwrap())).unwrap();
for name in [b"Diamond".as_slice(), b"Circle", b"Slash", b"Square"] {
assert!(
saved.object_numbers().iter().any(|n| saved.try_get(*n).ok().is_some_and(|o| object_has_name(&o, name))),
"missing /{}",
String::from_utf8_lossy(name)
);
}
let drawn: Vec<String> = saved
.object_numbers()
.into_iter()
.filter_map(|n| {
let obj = saved.try_get(n).ok()?;
let pdfcraft_cos::Object::Stream(s) = &*obj else { return None };
if s.dict.name(b"Subtype") != Some(b"Form") {
return None;
}
s.decoded().ok().map(|b| String::from_utf8_lossy(&b).into_owned())
})
.collect();
assert!(drawn.iter().any(|s| s.contains("h B")), "a filled ending was drawn: {drawn:?}");
assert!(drawn.iter().any(|s| s.contains(" l S")), "an open ending was drawn: {drawn:?}");
}
fn object_has_name(obj: &pdfcraft_cos::Object, name: &[u8]) -> bool {
match obj {
pdfcraft_cos::Object::Name(n) => n.as_slice() == name,
pdfcraft_cos::Object::Array(items) => items.iter().any(|o| object_has_name(o, name)),
pdfcraft_cos::Object::Dict(d) => d.iter().any(|(_, o)| object_has_name(o, name)),
pdfcraft_cos::Object::Stream(s) => s.dict.iter().any(|(_, o)| object_has_name(o, name)),
_ => false,
}
}
#[test]
fn drawing_comments_through_tools() {
let dir = workdir("drawing");

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

@ -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

@ -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![
@ -414,6 +550,28 @@ fn japanese_fallback_reports_the_face_after_save_and_reopen() {
}
}
#[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") {
@ -426,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") {
@ -570,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");
@ -1012,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

@ -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 {
@ -538,21 +538,49 @@ fn pdf_num(v: f64) -> String {
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 {
@ -570,9 +598,13 @@ fn no_japanese_font() -> EditError {
)
}
/// 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 face = pdfcraft_fonts::document_japanese_font_for_style(family == crate::added::Family::Times, bold).ok_or_else(no_japanese_font)?;
let family = face.family;
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) {
@ -585,6 +617,10 @@ fn type3_font(doc: &mut Document, fonts_res: &mut Dict, text: &str, family: crat
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());
@ -1138,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());
}
}

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

@ -150,6 +150,8 @@ const HELP: Option<&str> = Some("Help");
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"),

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

@ -22,6 +22,7 @@ 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};
@ -43,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;
@ -68,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;
@ -846,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 {
@ -1191,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",
@ -1453,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] && rect[3] == rect[1] {
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
@ -1463,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())?,
@ -1485,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())?;
@ -1508,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),
@ -1571,7 +1585,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
"grouped Form artwork cannot be replaced with a bitmap".into(),
)));
}
pdfcraft_edit::ImageChange::Replace(pdfcraft_create::image_xobject(doc, name, bytes)?.0)
pdfcraft_edit::ImageChange::Replace(embed_image(doc, name, bytes)?.0)
}
ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete,
};
@ -1612,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 => {
@ -1632,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 } => {
@ -2987,6 +3001,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)];
@ -2995,7 +3018,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

@ -4,6 +4,7 @@ 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};
@ -12,6 +13,8 @@ use crate::{Document, Edit, MarkFile, guard};
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,
@ -56,14 +59,34 @@ impl Document {
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, page, bytes, config: self.config.clone() }))
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;
@ -141,19 +164,20 @@ impl SignatureImage {
self.size
}
/// Left edge at `at`, vertically centered, with the same 150 pt width cap as typed names.
pub fn rect(&self, at: [f64; 2], initials: bool) -> Option<[f64; 4]> {
/// 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);
let (width, height) = (w as f64 * scale, h as f64 * scale);
Some([at[0], at[1] - height / 2.0, at[0] + width, at[1] + height / 2.0])
upright_box(i64::from(page.rotation), at, w as f64 * scale, h as f64 * scale)
}
pub fn edit(&self, page: usize, at: [f64; 2], initials: bool, author: &str) -> Option<Edit> {
let rect = self.rect(at, initials)?;
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(),
@ -172,6 +196,12 @@ impl SignatureImage {
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());
@ -184,10 +214,10 @@ mod tests {
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, [20.0, 100.0], false, "Ada").unwrap() else { panic!() };
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, [f64::NAN, 0.0], false, "").is_none());
assert!(image.edit(0, &page(0), [f64::NAN, 0.0], false, "").is_none());
assert_eq!(SignatureImage::from_bytes(image.bytes()).unwrap(), image);
}
@ -236,7 +266,7 @@ mod tests {
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, [20.0, 100.0], false, "Ada").unwrap()).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]);
}
@ -247,7 +277,7 @@ mod tests {
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, [20.0, 100.0], false, "Ada").unwrap()).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();
@ -268,7 +298,7 @@ mod tests {
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, [20.0, 100.0], false, "Ada").unwrap()).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();
@ -278,4 +308,20 @@ mod tests {
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() };
@ -1587,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));

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

@ -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

@ -33,6 +33,13 @@ set). Without that variable `CRAFT_FONTS` is empty and everything below copes:
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`.
Generated Japanese Type 3 fonts record the fallback face's family and style in an indirect

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,6 +49,12 @@ 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()
}
@ -96,22 +102,36 @@ pub fn document_japanese_font_for_style(serif: bool, bold: bool) -> Option<&'sta
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" };
if let Some(face) = jpan()
.find(|f| f.family == "BIZ UDPGothic" && f.style == style)
.or_else(|| jpan().find(|f| f.family == "BIZ UDPGothic" && f.style == "Regular"))
{
return Some(face);
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);
}
}
["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())
out
}
const fn str_eq(a: &str, b: &str) -> bool {
@ -191,6 +211,11 @@ mod tests {
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());
}

View File

@ -5,13 +5,15 @@
#![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, document_japanese_font_for_style, ui_arabic_fonts, ui_chinese_fonts,
ui_cjk_fonts, ui_japanese_fonts, ui_telugu_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, japanese_glyph_from, script_outline};
@ -37,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;
}
@ -53,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);
@ -64,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.
@ -108,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);
@ -120,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

@ -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)
@ -312,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.
@ -392,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;
}
}
@ -484,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);
}
_ => {}
}
@ -637,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");
@ -145,6 +145,13 @@ pub fn japanese_glyph_from(face: &crate::CraftFont, ch: char) -> Result<GlyphOut
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

@ -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

@ -100,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())
}

View File

@ -49,7 +49,7 @@ impl PrintStandard {
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.as_ref().clone())),
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())

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> {

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

@ -16,7 +16,13 @@ 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"

View File

@ -112,6 +112,27 @@ impl PageInfo {
[a * self.width, b * self.height]
}
/// A destination's user-space point (each coordinate possibly unspecified) → the same point
/// as a fraction of the displayed page `[x, y]` (0..1 from its top-left corner, y down),
/// after `/Rotate` and a further clockwise `view_rotation` (0, 90, 180 or 270). On a page
/// turned a quarter, the user `x` decides the displayed `y` and vice versa, so each output
/// is `None` when the input it comes from is (or is not finite). Points off the page are
/// clamped to its edge.
pub fn dest_fraction(&self, x: Option<f32>, y: Option<f32>, view_rotation: u16) -> [Option<f32>; 2] {
let [cx0, cy0, cx1, cy1] = self.crop;
let frac = |v: f32| if v.is_finite() { Some(v.clamp(0.0, 1.0)) } else { None };
// Unrotated fractions across (u, from the crop's left) and down (v, from its top).
let u = x.and_then(|x| frac((x - cx0) / (cx1 - cx0).max(1e-3)));
let v = y.and_then(|y| frac((cy1 - y) / (cy1 - cy0).max(1e-3)));
let flip = |f: Option<f32>| f.map(|f| 1.0 - f);
match (u32::from(self.rotation) + u32::from(view_rotation)) % 360 {
90 => [flip(v), u],
180 => [flip(u), flip(v)],
270 => [v, flip(u)],
_ => [u, v],
}
}
/// A view-space rectangle → the normalized user-space rectangle covering the same area of the
/// page (`[x0, y0, x1, y1]`, `x0 <= x1`, `y0 <= y1`). Under `/Rotate` the corners swap roles,
/// so a view rectangle's top-left is not in general the user rectangle's `[x0, y1]`.
@ -143,7 +164,8 @@ pub struct Link {
#[derive(Clone, Debug, PartialEq)]
pub enum LinkTarget {
Page(usize),
/// A destination in this document: the 0-based page and where on it the view goes.
Page(usize, DestView),
Uri(String),
/// A set-layer-visibility action (`SetOCGState`, ISO 32000-2 §12.6.4.13): each change in
/// order, naming the layer by its optional content group. With `preserve_rb`, a layer turned
@ -163,10 +185,55 @@ pub enum LayerOp {
Toggle,
}
/// Where an explicit destination (ISO 32000-2 §12.3.2.2, Table 149) places its page in the
/// window. Coordinates are PDF user space; `None` is a `null` (or unusable) operand, which keeps
/// that part of the view as it is.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub enum DestView {
/// The top of the page at the current zoom: a destination with no position, or one whose
/// type is missing or unknown.
#[default]
Top,
/// `/XYZ left top zoom`: the point (left, top) at the window's upper-left corner, at `zoom`
/// (1.0 = 100%; 0 or null keeps the current zoom).
Xyz { left: Option<f32>, top: Option<f32>, zoom: Option<f32> },
/// `/Fit`, and `/FitB` (fit the page's content box; read as the whole page).
Fit,
/// `/FitH top`, and `/FitBH`: fit the width, with `top` at the window's top edge.
FitH { top: Option<f32> },
/// `/FitV left`, and `/FitBV`: fit the height, with `left` at the window's left edge.
FitV { left: Option<f32> },
/// `/FitR left bottom right top`: fit the rectangle `[x0, y0, x1, y1]` (normalized).
FitR { rect: [f32; 4] },
}
impl DestView {
/// The view an explicit destination array asks for, from its elements after the page
/// (`/XYZ left top zoom`, `/FitH top`, …). Hostile input never fails: a missing or unknown
/// type, or a `/FitR` without four usable numbers, is [`DestView::Top`]; a non-numeric,
/// non-finite or missing operand is `None`; a zoom that is not positive is `None`.
pub fn from_operands(kind: Option<&[u8]>, operands: &[Option<f32>]) -> Self {
let n = |i: usize| operands.get(i).copied().flatten().filter(|v| v.is_finite());
match kind {
Some(b"XYZ") => Self::Xyz { left: n(0), top: n(1), zoom: n(2).filter(|z| *z > 0.0) },
Some(b"Fit" | b"FitB") => Self::Fit,
Some(b"FitH" | b"FitBH") => Self::FitH { top: n(0) },
Some(b"FitV" | b"FitBV") => Self::FitV { left: n(0) },
Some(b"FitR") => match (n(0), n(1), n(2), n(3)) {
(Some(x0), Some(y0), Some(x1), Some(y1)) => Self::FitR { rect: [x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)] },
_ => Self::Top,
},
_ => Self::Top,
}
}
}
#[derive(Clone, Debug)]
pub struct OutlineItem {
pub title: String,
pub page: Option<usize>,
/// Where on `page` the bookmark goes ([`DestView::Top`] for a bookmark without a page).
pub view: DestView,
pub children: Vec<OutlineItem>,
pub open: bool,
}
@ -304,30 +371,35 @@ pub fn inspect(bytes: Arc<Vec<u8>>, password: Option<&str>) -> Result<DocInfo, O
Err(p) => return Err(OpenError::Invalid(format!("the page tree could not be read: {}", crate::raster::panic_message(&p)))),
}
let options = load_options(password);
let structure = catch_unwind(AssertUnwindSafe(|| match Document::load_mem_with_options(&bytes, options) {
Ok(doc) => {
let mut tmp = DocInfo::default();
std::mem::swap(&mut tmp.pages, &mut info.pages);
Inspector::new(&doc).fill(&mut tmp);
Ok(tmp)
}
Err(e) => Err(e.to_string()),
inspect_structure(&mut info, |tmp| {
let doc = Document::load_mem_with_options(&bytes, options).map_err(|e| e.to_string())?;
Inspector::new(&doc).fill(tmp);
Ok(())
});
if info.pages.is_empty() {
return Err(OpenError::Invalid("the document has no pages".into()));
}
Ok(info)
}
// Commit auxiliary metadata only after inspection succeeds; renderer geometry is the fallback.
fn inspect_structure(info: &mut DocInfo, fill: impl FnOnce(&mut DocInfo) -> Result<(), String>) {
let structure = catch_unwind(AssertUnwindSafe(|| {
let mut tmp = DocInfo { pages: info.pages.clone(), ..Default::default() };
fill(&mut tmp)?;
Ok::<_, String>(tmp)
}));
match structure {
Ok(Ok(mut filled)) => {
filled.file_size = info.file_size;
filled.pdf_version = std::mem::take(&mut info.pdf_version);
info = filled;
*info = filled;
}
Ok(Err(e)) => info.warnings.push(format!("Some document structure (bookmarks, comments, fields) could not be read: {e}")),
Err(p) => {
info.warnings.push(format!("Document structure inspection crashed and was skipped: {}", crate::raster::panic_message(&p)));
}
}
if info.pages.is_empty() {
return Err(OpenError::Invalid("the document has no pages".into()));
}
Ok(info)
}
struct Inspector<'a> {
@ -444,14 +516,21 @@ impl<'a> Inspector<'a> {
// ── destinations ────────────────────────────────────────────────────────────────────────
fn dest_page(&self, dest: &Object, depth: u32) -> Option<usize> {
/// The page and view of a destination: an explicit array (`[page /XYZ left top zoom]`, …),
/// a dictionary with `/D`, or a named destination that resolves to one.
fn dest(&self, dest: &Object, depth: u32) -> Option<(usize, DestView)> {
if depth > 8 {
return None;
}
match self.resolve(dest) {
Object::Array(a) => a.first().and_then(|p| self.page_of(p)),
Object::Dictionary(d) => d.get(b"D").ok().and_then(|d| self.dest_page(d, depth + 1)),
Object::String(key, _) | Object::Name(key) => self.named_dest(key).and_then(|d| self.dest_page(d, depth + 1)),
Object::Array(a) => {
let page = a.first().and_then(|p| self.page_of(p))?;
let kind = a.get(1).map(|k| self.resolve(k)).and_then(|k| k.as_name().ok());
let operands: Vec<Option<f32>> = a.iter().skip(2).take(4).map(|o| self.resolve(o).as_float().ok()).collect();
Some((page, DestView::from_operands(kind, &operands)))
}
Object::Dictionary(d) => d.get(b"D").ok().and_then(|d| self.dest(d, depth + 1)),
Object::String(key, _) | Object::Name(key) => self.named_dest(key).and_then(|d| self.dest(d, depth + 1)),
_ => None,
}
}
@ -526,17 +605,18 @@ impl<'a> Inspector<'a> {
break; // cycle
}
let Some(d) = self.dict(o) else { break };
let page = d
let dest = d
.get(b"Dest")
.ok()
.and_then(|dest| self.dest_page(dest, 0))
.or_else(|| d.get(b"A").ok().and_then(|a| self.dict(a)).and_then(|a| a.get(b"D").ok()).and_then(|dest| self.dest_page(dest, 0)));
.and_then(|dest| self.dest(dest, 0))
.or_else(|| d.get(b"A").ok().and_then(|a| self.dict(a)).and_then(|a| a.get(b"D").ok()).and_then(|dest| self.dest(dest, 0)));
let (page, view) = dest.map_or((None, DestView::Top), |(p, v)| (Some(p), v));
let children = match (d.get(b"First").ok(), depth < 32) {
(Some(f), true) => self.outline_siblings(f, seen, depth + 1),
_ => Vec::new(),
};
let open = d.get(b"Count").ok().and_then(|c| c.as_i64().ok()).is_some_and(|c| c > 0);
items.push(OutlineItem { title: self.text(d, b"Title").unwrap_or_default(), page, children, open });
items.push(OutlineItem { title: self.text(d, b"Title").unwrap_or_default(), page, view, children, open });
cur = d.get(b"Next").ok();
if items.len() > 100_000 {
break;
@ -667,11 +747,15 @@ impl<'a> Inspector<'a> {
fn link(&self, page: usize, d: &Dictionary) -> Option<Link> {
let rect = rect4(self.resolve(d.get(b"Rect").ok()?));
let target = if let Ok(dest) = d.get(b"Dest") {
LinkTarget::Page(self.dest_page(dest, 0)?)
let (page, view) = self.dest(dest, 0)?;
LinkTarget::Page(page, view)
} else {
let a = d.get(b"A").ok().and_then(|a| self.dict(a))?;
match self.name(a, b"S").as_deref() {
Some("GoTo") => LinkTarget::Page(self.dest_page(a.get(b"D").ok()?, 0)?),
Some("GoTo") => {
let (page, view) = self.dest(a.get(b"D").ok()?, 0)?;
LinkTarget::Page(page, view)
}
Some("URI") => {
LinkTarget::Uri(a.get(b"URI").ok().and_then(|u| self.resolve(u).as_str().ok()).map(|b| String::from_utf8_lossy(b).into_owned())?)
}
@ -955,7 +1039,8 @@ fn alpha(n: usize) -> String {
/// A PDF date (`D:20261001123000Z`) as "2026-10-01 12:30"; other strings unchanged.
pub fn pretty_date(s: &str) -> String {
let d = s.trim_start_matches("D:");
if d.len() >= 12 && d[..12].bytes().all(|b| b.is_ascii_digit()) {
if let Some(d) = d.get(..12).filter(|d| d.bytes().all(|b| b.is_ascii_digit())) {
// Twelve ASCII digits make every slice below a UTF-8 character boundary.
format!("{}-{}-{} {}:{}", &d[0..4], &d[4..6], &d[6..8], &d[8..10], &d[10..12])
} else {
s.to_string()
@ -1007,6 +1092,65 @@ mod tests {
assert_eq!(lopdf::filters::png::decode_frame(&[0, 1, 2, 3, 2, 1, 1, 1], 1, 3).unwrap(), [1, 2, 3, 2, 3, 4]);
}
#[test]
fn empty_png_predictor_frames_do_not_allocate_rows() {
// usize::MAX cannot be reserved, so the old code safely errors before allocating.
// An empty frame needs no rows regardless of the declared width.
assert!(lopdf::filters::png::decode_frame(&[], 1, usize::MAX).unwrap().is_empty());
}
fn tiff_predictor_stream(data: Vec<u8>, columns: i64, colors: i64, bits: i64) -> lopdf::Stream {
let mut params = lopdf::Dictionary::new();
params.set("Predictor", 2i64);
params.set("Columns", columns);
params.set("Colors", colors);
params.set("BitsPerComponent", bits);
let mut dict = lopdf::Dictionary::new();
dict.set("DecodeParms", params);
let mut stream = lopdf::Stream::new(dict, data);
stream.compress().unwrap();
assert_eq!(stream.dict.get(b"Filter").unwrap().as_name().unwrap(), b"FlateDecode");
stream
}
#[test]
fn tiff_subbyte_predictor_row_width_overflow_is_refused() {
// Each multiplication overflows before any scratch allocation in the old debug build.
for bits in [1, 2, 4] {
let stream = tiff_predictor_stream(vec![0; 64], i64::MAX, 3, bits);
assert!(
matches!(stream.decompressed_content_with_limit(64), Err(lopdf::Error::Decompress(lopdf::DecompressError::Predictor(_)))),
"{bits}-bit row"
);
}
}
#[test]
fn tiff_subbyte_predictor_scratch_is_bounded_by_available_samples() {
// 1/2-bit row widths fit usize on 32- and 64-bit hosts; Colors previously made Vec<u16>
// reject the capacity before allocating. A partial row has no preceding pixel.
for bits in [1, 2] {
let data = vec![0b1010_0110; 64];
let stream = tiff_predictor_stream(data.clone(), 1, i64::try_from(isize::MAX).unwrap(), bits);
assert_eq!(stream.decompressed_content_with_limit(64).unwrap(), data, "{bits}-bit row");
}
}
#[test]
fn tiff_subbyte_predictors_keep_components_rows_and_padding() {
// Repeated tiny rows compress through the public Stream API. Rows remain independent,
// differences wrap at each component depth, and trailing padding bits survive.
for (columns, colors, bits, encoded, decoded) in [
(16, 1, 1, vec![255, 170, 8, 255], vec![170, 204, 15, 85]),
(6, 1, 2, vec![85, 179], vec![108, 147]),
(3, 1, 4, vec![25, 16], vec![26, 176]),
(2, 2, 4, vec![18, 34], vec![18, 52]),
] {
let stream = tiff_predictor_stream(encoded.repeat(32), columns, colors, bits);
assert_eq!(stream.decompressed_content_with_limit(128).unwrap(), decoded.repeat(32), "{bits}-bit, {colors} colours");
}
}
#[test]
fn view_and_user_space_round_trip_for_every_rotation() {
for rotation in [0u16, 90, 180, 270] {
@ -1106,6 +1250,208 @@ trailer << /Root 1 0 R >>
);
}
#[test]
fn destination_operands_become_views() {
use DestView::*;
let v = |kind: &[u8], ops: &[Option<f32>]| DestView::from_operands(Some(kind), ops);
// ISO 32000-2 Table 149, each type.
assert_eq!(v(b"XYZ", &[Some(0.0), Some(420.0), Some(0.0)]), Xyz { left: Some(0.0), top: Some(420.0), zoom: None });
assert_eq!(v(b"XYZ", &[Some(72.0), Some(500.0), Some(1.5)]), Xyz { left: Some(72.0), top: Some(500.0), zoom: Some(1.5) });
assert_eq!(v(b"Fit", &[]), Fit);
assert_eq!(v(b"FitB", &[]), Fit);
assert_eq!(v(b"FitH", &[Some(250.0)]), FitH { top: Some(250.0) });
assert_eq!(v(b"FitBH", &[None]), FitH { top: None });
assert_eq!(v(b"FitV", &[Some(120.0)]), FitV { left: Some(120.0) });
assert_eq!(v(b"FitBV", &[Some(5.0)]), FitV { left: Some(5.0) });
// FitR's corners are normalized.
assert_eq!(v(b"FitR", &[Some(110.0), Some(220.0), Some(10.0), Some(20.0)]), FitR { rect: [10.0, 20.0, 110.0, 220.0] });
// Missing operands are null; so are non-finite ones, and a zoom that isn't positive.
assert_eq!(v(b"XYZ", &[]), Xyz { left: None, top: None, zoom: None });
assert_eq!(v(b"XYZ", &[Some(f32::NAN), Some(f32::INFINITY), Some(f32::NEG_INFINITY)]), Xyz { left: None, top: None, zoom: None });
assert_eq!(v(b"XYZ", &[None, Some(-5.0), Some(-1.0)]), Xyz { left: None, top: Some(-5.0), zoom: None });
assert_eq!(v(b"XYZ", &[None, None, Some(f32::NAN)]), Xyz { left: None, top: None, zoom: None });
assert_eq!(v(b"FitH", &[Some(f32::NAN)]), FitH { top: None });
// A FitR without four usable numbers, an unknown type or no type: the top of the page.
assert_eq!(v(b"FitR", &[Some(1.0), Some(2.0)]), Top);
assert_eq!(v(b"FitR", &[Some(1.0), Some(2.0), Some(f32::INFINITY), Some(4.0)]), Top);
assert_eq!(v(b"Zoom", &[Some(3.0)]), Top);
assert_eq!(v(b"xyz", &[Some(3.0)]), Top);
assert_eq!(DestView::from_operands(None, &[Some(1.0)]), Top);
}
/// Two 300×400 pages. Bookmarks and links on page 1 go to page 2 in every destination form,
/// named (`/Names /Dests` and the PDF 1.1 `/Dests`) and malformed.
const DESTS: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R /Outlines 10 0 R /Names << /Dests 40 0 R >> /Dests << /old << /D [4 0 R /FitV 120] >> >> >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /Annots [30 0 R 31 0 R 32 0 R 33 0 R] >> endobj
4 0 obj << /Type /Page /Parent 2 0 R >> endobj
10 0 obj << /Type /Outlines /First 11 0 R /Last 20 0 R /Count 10 >> endobj
11 0 obj << /Title (xyz) /Parent 10 0 R /Next 12 0 R /Dest [4 0 R /XYZ 0 420 0] >> endobj
12 0 obj << /Title (fith) /Parent 10 0 R /Next 13 0 R /Dest [4 0 R /FitH 250] >> endobj
13 0 obj << /Title (named) /Parent 10 0 R /Next 14 0 R /Dest (sec) >> endobj
14 0 obj << /Title (old named) /Parent 10 0 R /Next 15 0 R /Dest /old >> endobj
15 0 obj << /Title (action) /Parent 10 0 R /Next 16 0 R /A << /S /GoTo /D [4 0 R /FitR 110 220 10 20] >> >> endobj
16 0 obj << /Title (wrong types) /Parent 10 0 R /Next 17 0 R /Dest [4 0 R /XYZ (left) /Top true] >> endobj
17 0 obj << /Title (short) /Parent 10 0 R /Next 18 0 R /Dest [4 0 R /FitR 1 2] >> endobj
18 0 obj << /Title (no type) /Parent 10 0 R /Next 19 0 R /Dest [4 0 R] >> endobj
19 0 obj << /Title (huge) /Parent 10 0 R /Next 20 0 R /Dest [4 0 R /XYZ 1000000000000 -5 -1] >> endobj
20 0 obj << /Title (no page) /Parent 10 0 R /Dest [/XYZ 0 420 0] >> endobj
30 0 obj << /Type /Annot /Subtype /Link /Rect [0 0 10 10] /Dest [4 0 R /XYZ 72 500 1.5] >> endobj
31 0 obj << /Type /Annot /Subtype /Link /Rect [0 20 10 30] /A << /S /GoTo /D (sec) >> >> endobj
32 0 obj << /Type /Annot /Subtype /Link /Rect [0 40 10 50] /Dest [4 0 R /FitBH 100] >> endobj
33 0 obj << /Type /Annot /Subtype /Link /Rect [0 60 10 70] /Dest [4 0 R /Fit] >> endobj
40 0 obj << /Names [(sec) << /D [4 0 R /XYZ null 300 2] >>] >> endobj
trailer << /Root 1 0 R >>
%%EOF";
#[test]
fn bookmarks_and_links_keep_their_destination_view() {
use DestView::*;
let info = inspect(Arc::new(DESTS.to_vec()), None).expect("opens");
let outline: Vec<_> = info.outline.iter().map(|o| (o.title.as_str(), o.page, o.view)).collect();
assert_eq!(
outline,
[
("xyz", Some(1), Xyz { left: Some(0.0), top: Some(420.0), zoom: None }),
("fith", Some(1), FitH { top: Some(250.0) }),
("named", Some(1), Xyz { left: None, top: Some(300.0), zoom: Some(2.0) }),
("old named", Some(1), FitV { left: Some(120.0) }),
("action", Some(1), FitR { rect: [10.0, 20.0, 110.0, 220.0] }),
// Hostile operands fall back without failing the bookmark.
("wrong types", Some(1), Xyz { left: None, top: None, zoom: None }),
("short", Some(1), Top),
("no type", Some(1), Top),
("huge", Some(1), Xyz { left: Some(1e12), top: Some(-5.0), zoom: None }),
("no page", None, Top),
]
);
let links: Vec<_> = info.links.iter().map(|l| l.target.clone()).collect();
assert_eq!(
links,
[
LinkTarget::Page(1, Xyz { left: Some(72.0), top: Some(500.0), zoom: Some(1.5) }),
LinkTarget::Page(1, Xyz { left: None, top: Some(300.0), zoom: Some(2.0) }),
LinkTarget::Page(1, FitH { top: Some(100.0) }),
LinkTarget::Page(1, Fit),
]
);
}
#[test]
fn destination_points_map_to_the_displayed_page() {
// A crop box away from the origin: x 100..400, y 50..450.
let mut p = PageInfo { width: 300.0, height: 400.0, label: "1".into(), crop: [100.0, 50.0, 400.0, 450.0], rotation: 0 };
assert_eq!(p.dest_fraction(Some(100.0), Some(450.0), 0), [Some(0.0), Some(0.0)]);
assert_eq!(p.dest_fraction(Some(250.0), Some(350.0), 0), [Some(0.5), Some(0.25)]);
// Unspecified stays unspecified; off-page and huge values are clamped to the page.
assert_eq!(p.dest_fraction(None, Some(350.0), 0), [None, Some(0.25)]);
assert_eq!(p.dest_fraction(Some(-1e30), Some(1e30), 0), [Some(0.0), Some(0.0)]);
assert_eq!(p.dest_fraction(Some(f32::INFINITY), Some(f32::NAN), 0), [None, None]);
// /Rotate 90 (clockwise): user y runs left to right across the displayed page, user x
// top to bottom. A view rotation adds to it.
p.rotation = 90;
assert_eq!(p.dest_fraction(Some(250.0), Some(350.0), 0), [Some(0.75), Some(0.5)]);
assert_eq!(p.dest_fraction(None, Some(350.0), 0), [Some(0.75), None]);
assert_eq!(p.dest_fraction(Some(250.0), Some(350.0), 90), [Some(0.5), Some(0.75)]);
p.rotation = 180;
assert_eq!(p.dest_fraction(Some(250.0), Some(350.0), 0), [Some(0.5), Some(0.75)]);
p.rotation = 270;
assert_eq!(p.dest_fraction(Some(250.0), Some(350.0), 0), [Some(0.25), Some(0.5)]);
assert_eq!(p.dest_fraction(Some(250.0), Some(350.0), 90), [Some(0.5), Some(0.25)]);
// A degenerate crop box never divides by zero.
let flat = PageInfo { crop: [0.0, 0.0, 0.0, 0.0], rotation: 0, ..p };
assert!(flat.dest_fraction(Some(5.0), Some(-5.0), 0).iter().all(|f| f.is_some_and(|f| (0.0..=1.0).contains(&f))));
}
#[test]
fn structure_failure_preserves_renderer_pages() {
for crash in [true, false] {
let mut info = DocInfo {
file_size: 321,
pdf_version: "1.7".into(),
pages: vec![PageInfo { width: 300.0, height: 200.0, label: "1".into(), crop: [10.0, 20.0, 210.0, 320.0], rotation: 90 }],
..Default::default()
};
inspect_structure(&mut info, |tmp| {
tmp.pages.clear();
tmp.title = Some("partially inspected".into());
if crash {
panic!("synthetic structure inspection failure");
}
Err("synthetic structure inspection failure".into())
});
assert_eq!(info.pages.len(), 1);
let page = &info.pages[0];
assert_eq!((page.width, page.height, page.label.as_str(), page.crop, page.rotation), (300.0, 200.0, "1", [10.0, 20.0, 210.0, 320.0], 90));
assert_eq!(info.file_size, 321);
assert_eq!(info.pdf_version, "1.7");
assert!(info.title.is_none());
assert_eq!(info.warnings.len(), 1);
assert!(info.warnings[0].contains("synthetic structure inspection failure"));
assert_eq!(info.warnings[0].contains("crashed"), crash);
}
}
#[test]
fn invalid_dates_with_unicode_are_unchanged() {
for input in ["", "D:", "D:20260930104", "D:202609x01045", "yesterday"] {
assert_eq!(pretty_date(input), input);
}
// Cover every position before the 12-byte prefix, including characters that
// straddle its end. None of these strings is an ASCII PDF date.
for character in ['é', '€', '😀'] {
for prefix_len in 0..12 {
let input = format!("D:{}{character}123456789012", "1".repeat(prefix_len));
assert_eq!(pretty_date(&input), input, "{character} after {prefix_len} digits");
}
}
}
#[test]
fn unicode_annotation_date_does_not_prevent_opening() {
use lopdf::dictionary;
let date = "D:12345678901éX";
let mut encoded_date = vec![0xFE, 0xFF];
encoded_date.extend(date.encode_utf16().flat_map(u16::to_be_bytes));
let mut doc = Document::with_version("1.7");
let pages_id = doc.new_object_id();
let annot_id = doc.add_object(dictionary! {
"Type" => "Annot",
"Subtype" => "Text",
"Rect" => vec![10.into(), 10.into(), 30.into(), 30.into()],
"M" => Object::String(encoded_date, lopdf::StringFormat::Hexadecimal),
});
let page_id = doc.add_object(dictionary! {
"Type" => "Page",
"Parent" => pages_id,
"MediaBox" => vec![0.into(), 0.into(), 200.into(), 300.into()],
"Annots" => vec![annot_id.into()],
});
doc.objects.insert(
pages_id,
dictionary! {
"Type" => "Pages",
"Kids" => vec![page_id.into()],
"Count" => 1,
}
.into(),
);
let catalog_id = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id });
doc.trailer.set("Root", catalog_id);
let mut bytes = Vec::new();
doc.save_to(&mut bytes).expect("write synthetic fixture");
let info = inspect(Arc::new(bytes), None).expect("opens despite a non-date /M string");
assert_eq!(info.pages.len(), 1);
assert_eq!((info.pages[0].width, info.pages[0].height), (200.0, 300.0));
assert_eq!(info.annotations.len(), 1);
assert_eq!(info.annotations[0].modified.as_deref(), Some(date));
assert!(info.warnings.is_empty(), "{:?}", info.warnings);
}
#[test]
fn dates_are_prettified() {
assert_eq!(pretty_date("D:20260930104512-04'00'"), "2026-09-30 10:45");

View File

@ -9,13 +9,15 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod inspect;
mod pixels;
mod raster;
pub mod text;
pub use inspect::{
Annotation, Attachment, AttachmentSource, DocInfo, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, OutlineItem, PageInfo, Xfa,
attachment_data, inspect, pretty_date,
Annotation, Attachment, AttachmentSource, DestView, DocInfo, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, OutlineItem, PageInfo,
Xfa, attachment_data, inspect, pretty_date,
};
pub use pixels::Pixels;
pub use raster::{
MAX_PIXELS, MAX_SIDE, PageRenderer, RenderConfig, RenderPool, RenderRequest, RenderedPage, RequestKind, Tile, device_pixels, effective_scale,
};

View File

@ -0,0 +1,47 @@
//! Raster pixels in a buffer a GUI can take over as texture data.
use std::ops::Deref;
/// A raster's premultiplied RGBA8 bytes, row-major, kept in 4-byte words: one word per pixel,
/// with the bytes in R, G, B, A order in memory. The words make the buffer aligned to whole
/// pixels, so it can become a GUI's pixel array (egui's `Color32`) without copying. Read it as
/// bytes through `Deref`.
#[derive(Clone, Default, PartialEq, Eq)]
pub struct Pixels(Vec<u32>);
impl Pixels {
/// Zeroed pixels for a `width` × `height` raster; `None` when the size overflows.
pub(crate) fn zeroed(width: usize, height: usize) -> Option<Self> {
Some(Self(vec![0; width.checked_mul(height)?]))
}
/// The bytes, for the rasterizer to write into.
pub(crate) fn bytes_mut(&mut self) -> &mut [u8] {
bytemuck::cast_slice_mut(&mut self.0)
}
/// The buffer itself: one word per pixel, its bytes in R, G, B, A memory order.
pub fn into_words(self) -> Vec<u32> {
self.0
}
}
impl Deref for Pixels {
type Target = [u8];
fn deref(&self) -> &[u8] {
bytemuck::cast_slice(&self.0)
}
}
impl AsRef<[u8]> for Pixels {
fn as_ref(&self) -> &[u8] {
self
}
}
impl std::fmt::Debug for Pixels {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Pixels({} bytes)", self.len())
}
}

View File

@ -18,7 +18,9 @@ use hayro::hayro_interpret::font::{FontData, FontQuery};
use hayro::hayro_interpret::hayro_cmap::CidFamily;
use hayro::hayro_syntax::Pdf;
use hayro::vello_cpu::color::palette::css::WHITE;
use hayro::{RenderCache, RenderSettings, render};
use hayro::{RenderCache, RenderSettings, render_into, render_size};
use crate::Pixels;
/// Monotonic-ish timer that is safe on wasm32 (where `std::time::Instant` panics).
#[derive(Clone, Copy)]
@ -73,7 +75,13 @@ fn japanese_fallback(query: &FontQuery) -> Option<(FontData, u32)> {
return None;
}
let face = match japanese_face(f.post_script_name.as_deref().unwrap_or_default(), f.is_serif, f.is_bold || f.font_weight >= 600) {
JapaneseFace::Mincho => pdfcraft_fonts::document_japanese_font(),
// BIZ UDMincho before the document face (Shippori Mincho): it covers half-width katakana
// (U+FF61–U+FF9F), which Shippori Mincho lacks, and hayro draws a CID the substitute
// can't map by Unicode by glyph index, i.e. as an unrelated glyph (fi, fl, …).
JapaneseFace::Mincho => pdfcraft_fonts::ui_japanese_fonts()
.into_iter()
.find(|c| c.family == "BIZ UDMincho" && c.style == "Regular")
.or_else(pdfcraft_fonts::document_japanese_font),
JapaneseFace::Gothic { bold } => {
let faces = pdfcraft_fonts::ui_japanese_fonts();
let style = if bold { "Bold" } else { "Regular" };
@ -145,7 +153,7 @@ pub struct RenderedPage {
pub width: u32,
pub height: u32,
/// Premultiplied RGBA8, row-major. Empty when `error` is set.
pub rgba: Vec<u8>,
pub rgba: Pixels,
/// Why the page could not be rendered (renderer panic, empty page box, …).
pub error: Option<String>,
/// For `RequestKind::Text`.
@ -175,12 +183,12 @@ pub fn device_pixels(pt: f32, scale: f32) -> u32 {
/// Render one page with a caller-owned parser and cache. Panics inside the renderer are caught
/// and reported as `Err((message, panicked))`.
type Output = (u32, u32, Vec<u8>, Option<Arc<crate::text::PageText>>);
type Output = (u32, u32, Pixels, Option<Arc<crate::text::PageText>>);
fn render_page<'a>(pdf: &'a Pdf, cache: &RenderCache<'a>, settings: &InterpreterSettings, req: RenderRequest) -> Result<Output, (String, bool)> {
if req.kind == RequestKind::Text {
return match catch_unwind(AssertUnwindSafe(|| crate::text::extract_page(pdf, req.page, settings))) {
Ok(Some(t)) => Ok((0, 0, Vec::new(), Some(Arc::new(t)))),
Ok(Some(t)) => Ok((0, 0, Pixels::default(), Some(Arc::new(t)))),
Ok(None) => Err((format!("page {} does not exist", req.page + 1), false)),
Err(panic) => Err((format!("text extraction crashed on page {}: {}", req.page + 1, panic_message(&panic)), true)),
};
@ -215,8 +223,11 @@ fn render_page<'a>(pdf: &'a Pdf, cache: &RenderCache<'a>, settings: &Interpreter
RenderSettings { x_scale: scale, y_scale: scale, width: Some(side(w)), height: Some(side(h)), bg_color: WHITE, ..Default::default() }
}
};
let pixmap = render(page, cache, settings, &rs);
Ok((pixmap.width() as u32, pixmap.height() as u32, pixmap.data_as_u8_slice().to_vec(), None))
// Rendered straight into a buffer of whole pixels, which the GUI takes over as is.
let (w, h) = render_size(page, &rs);
let mut pixels = Pixels::zeroed(w.into(), h.into()).ok_or_else(|| format!("page {} is too large to render", req.page + 1))?;
render_into(page, cache, settings, &rs, pixels.bytes_mut());
Ok((w.into(), h.into(), pixels, None))
}));
match result {
Ok(Ok(v)) => Ok(v),
@ -229,7 +240,7 @@ fn finish(req: RenderRequest, start: Stopwatch, r: Result<Output, (String, bool)
let millis = start.millis();
match r {
Ok((width, height, rgba, text)) => RenderedPage { request: req, width, height, rgba, error: None, text, millis },
Err((e, _)) => RenderedPage { request: req, width: 0, height: 0, rgba: Vec::new(), error: Some(e), text: None, millis },
Err((e, _)) => RenderedPage { request: req, width: 0, height: 0, rgba: Pixels::default(), error: Some(e), text: None, millis },
}
}
@ -292,6 +303,10 @@ struct Shared {
/// Pages (and request kinds) the watchdog gave up on: answered with an error at once, so a
/// pathological page cannot trap every worker in turn.
stuck: Mutex<std::collections::HashSet<(usize, RequestKind)>>,
/// Per worker id: set to stop that worker's render at its next content operator once nobody
/// can receive its answer (the pool was dropped, or the watchdog gave up on the render).
/// Lock `busy` first when holding both.
stop: Mutex<Vec<Arc<std::sync::atomic::AtomicBool>>>,
/// Test hook: make one page slow.
#[cfg(test)]
slow_page: Mutex<Option<(usize, std::time::Duration)>>,
@ -325,9 +340,11 @@ fn take_next(shared: &Shared) -> Option<RenderRequest> {
///
/// **Watchdog:** a render running longer than `stuck_after` (default [`STUCK_AFTER`]) is reported
/// as an error for that page, the page is not attempted again, and a replacement worker takes
/// the stuck one's place (threads cannot be killed; the stuck one exits when its render
/// finally returns, and its late result is dropped). At most `threads` replacements are started
/// per pool, so a document full of pathological pages cannot spawn threads without bound.
/// the stuck one's place (threads cannot be killed: the stuck one is told to stop at its next
/// content operator, exits when its render returns, and its late result is dropped; a single
/// long operator, such as decoding a huge image, still runs to its end). At most `threads`
/// replacements are started per pool, so a document full of pathological pages cannot spawn
/// threads without bound.
pub struct RenderPool {
shared: Arc<Shared>,
wake: Mutex<Vec<Sender<()>>>,
@ -377,14 +394,16 @@ impl RenderPool {
return false;
#[cfg(not(target_arch = "wasm32"))]
{
let id = {
let (id, stop) = {
let mut busy = lock(&self.shared.busy);
busy.push(None);
busy.len() - 1
let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
lock(&self.shared.stop).push(stop.clone());
(busy.len() - 1, stop)
};
let (wtx, wrx) = channel::<()>();
let (shared, out, bytes, config) = (self.shared.clone(), self.results_tx.clone(), self.bytes.clone(), self.config.clone());
match std::thread::Builder::new().name(format!("pdfcraft-render-{id}")).spawn(move || worker(id, bytes, config, shared, wrx, out)) {
match std::thread::Builder::new().name(format!("pdfcraft-render-{id}")).spawn(move || worker(id, bytes, config, shared, wrx, out, stop)) {
Ok(h) => {
lock(&self.wake).push(wtx);
lock(&self._workers).push(h);
@ -403,7 +422,9 @@ impl RenderPool {
let mut pool = Self::new(bytes.clone(), 0, config.clone());
if pool.inline.is_none() {
// Native `new` always spawns at least one worker; drop to inline explicitly.
pool = Self { shared: Arc::default(), wake: Mutex::new(Vec::new()), _workers: Mutex::new(Vec::new()), ..pool };
pool.shared = Arc::default();
pool.wake = Mutex::new(Vec::new());
pool._workers = Mutex::new(Vec::new());
pool.inline = Some(std::cell::RefCell::new(PageRenderer::new(bytes, config)));
}
pool
@ -454,11 +475,15 @@ impl RenderPool {
let mut gave_up = Vec::new();
{
let mut busy = lock(&self.shared.busy);
for slot in busy.iter_mut() {
let stop = lock(&self.shared.stop);
for (id, slot) in busy.iter_mut().enumerate() {
if let Some((req, since)) = *slot
&& since.elapsed() > self.stuck_after
{
*slot = None; // the worker sees this and exits when it returns
if let Some(s) = stop.get(id) {
s.store(true, std::sync::atomic::Ordering::Relaxed);
}
gave_up.push(req);
}
}
@ -475,7 +500,7 @@ impl RenderPool {
request: req,
width: 0,
height: 0,
rgba: Vec::new(),
rgba: Pixels::default(),
error: Some(error),
text: None,
millis: 0,
@ -489,9 +514,31 @@ impl RenderPool {
}
}
impl Drop for RenderPool {
fn drop(&mut self) {
for s in lock(&self.shared.stop).iter() {
s.store(true, std::sync::atomic::Ordering::Relaxed);
}
}
}
/// A worker's interpreter settings: the document's, stopping once `stop` is set.
#[cfg(not(target_arch = "wasm32"))]
fn worker(id: usize, bytes: Arc<Vec<u8>>, config: RenderConfig, shared: Arc<Shared>, wake: Receiver<()>, out: Sender<RenderedPage>) {
let settings = config.settings();
fn worker_settings(config: &RenderConfig, stop: &Arc<std::sync::atomic::AtomicBool>) -> InterpreterSettings {
InterpreterSettings { cancelled: Some(stop.clone()), ..config.settings() }
}
#[cfg(not(target_arch = "wasm32"))]
fn worker(
id: usize,
bytes: Arc<Vec<u8>>,
config: RenderConfig,
shared: Arc<Shared>,
wake: Receiver<()>,
out: Sender<RenderedPage>,
stop: Arc<std::sync::atomic::AtomicBool>,
) {
let settings = worker_settings(&config, &stop);
// Outer loop: (re)build parser + cache; rebuilt after a renderer panic.
loop {
let pdf = parse(&bytes, config.password.as_deref());
@ -506,7 +553,7 @@ fn worker(id: usize, bytes: Arc<Vec<u8>>, config: RenderConfig, shared: Arc<Shar
};
if lock(&shared.stuck).contains(&(req.page, req.kind)) {
let error = format!("page {} was skipped earlier because it took too long to render", req.page + 1);
let page = RenderedPage { request: req, width: 0, height: 0, rgba: Vec::new(), error: Some(error), text: None, millis: 0 };
let page = RenderedPage { request: req, width: 0, height: 0, rgba: Pixels::default(), error: Some(error), text: None, millis: 0 };
if out.send(page).is_err() {
return;
}
@ -709,6 +756,40 @@ trailer << /Root 1 0 R >>
assert_eq!(px(80, 5), &[255, 255, 255, 255]);
}
#[test]
fn pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over() {
let mut r = PageRenderer::new(Arc::new(ONE_PAGE.to_vec()), RenderConfig::default());
let out = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: 0 });
assert!(out.error.is_none(), "{:?}", out.error);
// The same bytes as hayro's own render to a pixmap, with the same settings.
let pdf = Pdf::new(Arc::new(ONE_PAGE.to_vec())).expect("parses");
let pages = pdf.pages();
let page = pages.first().expect("a page");
let rs = RenderSettings { x_scale: 2.0, y_scale: 2.0, width: Some(200), height: Some(100), bg_color: WHITE, ..Default::default() };
let plain = hayro::render(page, &RenderCache::new(), &RenderConfig::default().settings(), &rs);
assert_eq!((out.width, out.height), (200, 100));
assert_eq!(&*out.rgba, plain.data_as_u8_slice());
// Whole pixels, aligned, handed over without a copy.
let at = out.rgba.as_ptr();
assert_eq!(at as usize % 4, 0);
let words = out.rgba.into_words();
assert_eq!(words.as_ptr().cast::<u8>(), at);
}
#[test]
fn render_into_a_buffer_of_the_wrong_length_leaves_it_untouched() {
let pdf = Pdf::new(Arc::new(ONE_PAGE.to_vec())).expect("parses");
let pages = pdf.pages();
let page = pages.first().expect("a page");
let rs = RenderSettings { width: Some(10), height: Some(10), bg_color: WHITE, ..Default::default() };
let settings = RenderConfig::default().settings();
for len in [0, 399, 401, 4000] {
let mut buf = vec![7u8; len];
render_into(page, &RenderCache::new(), &settings, &rs, &mut buf);
assert!(buf.iter().all(|&b| b == 7), "a {len}-byte buffer was written");
}
}
#[test]
fn missing_page_and_garbage_input_fail_gracefully() {
let mut r = PageRenderer::new(Arc::new(ONE_PAGE.to_vec()), RenderConfig::default());
@ -1098,6 +1179,58 @@ trailer << /Root 1 0 R >>
assert!(pool.try_recv().is_none());
}
#[test]
fn dropping_the_pool_cancels_its_renders() {
const TEXT: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj
4 0 obj << /Length 35 >> stream
BT /F1 12 Tf 20 70 Td (Hello) Tj ET
endstream endobj
5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj
trailer << /Root 1 0 R >>
%%EOF";
let pool = RenderPool::new(Arc::new(ONE_PAGE.to_vec()), 1, RenderConfig::default());
let flag = lock(&pool.shared.stop).first().cloned().expect("one worker");
let settings = worker_settings(&pool.config, &flag);
assert!(settings.cancelled.as_ref().is_some_and(|c| Arc::ptr_eq(c, &flag)));
let shapes = Pdf::new(Arc::new(ONE_PAGE.to_vec())).expect("parses");
let text = Pdf::new(Arc::new(TEXT.to_vec())).expect("parses");
let pages = shapes.pages();
let page = pages.first().expect("a page");
let rs = RenderSettings { bg_color: WHITE, ..Default::default() };
let blue = |s: &InterpreterSettings| {
let p = hayro::render(page, &RenderCache::new(), s, &rs).sample(20, 30);
[p.r, p.g, p.b, p.a] == [0, 0, 255, 255]
};
let glyphs = |s: &InterpreterSettings| crate::text::extract_page(&text, 0, s).map_or(0, |t| t.glyphs.len());
assert!(blue(&settings) && glyphs(&settings) == 5, "a live pool renders and reads everything");
drop(pool);
assert!(flag.load(std::sync::atomic::Ordering::Relaxed));
assert!(!blue(&settings) && glyphs(&settings) == 0, "once the pool is gone, interpretation stops before drawing");
}
#[test]
fn the_watchdog_stops_the_render_it_gives_up_on() {
let mut pool = RenderPool::new(Arc::new(ONE_PAGE_TWICE.to_vec()), 1, RenderConfig::default());
pool.set_stuck_after(std::time::Duration::from_millis(100));
*lock(&pool.shared.slow_page) = Some((0, std::time::Duration::from_millis(1500)));
pool.set_queue(vec![RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag: 0 }]);
let t = std::time::Instant::now();
let first = loop {
if let Some(p) = pool.try_recv() {
break p;
}
assert!(t.elapsed() < std::time::Duration::from_secs(8), "no answer");
std::thread::sleep(std::time::Duration::from_millis(5));
};
assert!(first.error.as_deref().is_some_and(|e| e.contains("took longer")), "{:?}", first.error);
// The abandoned worker is told to stop at its next operator; its replacement isn't.
let stop: Vec<bool> = lock(&pool.shared.stop).iter().map(|s| s.load(std::sync::atomic::Ordering::Relaxed)).collect();
assert_eq!(stop, vec![true, false]);
}
#[test]
fn extracts_text_with_positions() {
let pdf = b"%PDF-1.7
@ -1334,6 +1467,106 @@ trailer << /Root 1 0 R >>
assert_eq!(&page.rgba[((20 * 40 + 2) * 4)..][..4], &[255, 255, 255, 255], "beyond the butt cap");
}
/// Render a 40 × 40 pt page at 1:1 on its own thread, with `/GS1` (from `gs1`) in its
/// resources, and fail instead of waiting when the renderer stalls.
fn render_with_gs1(content: &str, gs1: &str) -> RenderedPage {
let pdf = format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /ExtGState << /GS1 {gs1} >> >> >> endobj
4 0 obj << /Length {} >> stream
{content}
endstream endobj
trailer << /Root 1 0 R >>
%%EOF",
content.len()
);
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }));
});
let page = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("must not stall the renderer");
assert!(page.error.is_none(), "{:?}", page.error);
page
}
/// The pixel at device (x, y), y down, of a 40-pixel-wide page.
fn px40(page: &RenderedPage, x: usize, y: usize) -> &[u8] {
&page.rgba[((y * 40 + x) * 4)..][..4]
}
/// From `cargo xtask fuzz`: an ExtGState `/D [[11] 0]` on a line that starts at x =
/// 92234775807 cut it into billions of dashes, and stroke expansion allocated more than
/// 5 GB. A tiny dash on an ordinary line does the same (`[0.000001] 0 d` on 20 pt passed
/// 10 GB), and so does a long pattern on many short subpaths, because every subpath restarts
/// it. Vendored hayro patch: a stroke whose dash pattern could cut it into more than
/// `MAX_DASHES_PER_STROKE` pieces is drawn solid.
#[test]
fn dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render() {
let red = [255, 0, 0, 255];
// The fuzzed shape. A line that far out isn't drawn even undashed (its coordinates are
// beyond the rasterizer's range), so this case only proves the page finishes and the
// content after the stroke is drawn.
let page = render_with_gs1("1 0 0 RG 4 w /GS1 gs 92234775807 20 m 30 20 l S 0 0 1 rg 0 0 4 4 re f", "<< /D [[11] 0] >>");
assert_eq!(px40(&page, 1, 38), &[0, 0, 255, 255], "the square after the stroke is drawn");
for (what, content, gs1) in [
("a tiny dash on an ordinary line", "1 0 0 RG 4 w [0.000001] 0 d 10 20 m 30 20 l S", "<< >>"),
("a tiny dash from an ExtGState", "1 0 0 RG 4 w /GS1 gs 10 20 m 30 20 l S", "<< /D [[0.000001] 0] >>"),
(
"a tiny dash under a shrinking matrix",
"1 0 0 RG 0.0000001 0 0 0.0000001 0 0 cm 40000000 w [1] 0 d 100000000 200000000 m 300000000 200000000 l S",
"<< >>",
),
] {
let page = render_with_gs1(content, gs1);
// Device row 20 is user y 20; the stroke covers x 10 to 30.
for x in [12, 20, 28] {
assert_eq!(px40(&page, x, 20), &red, "{what}: ({x}, 20) is drawn, solid");
}
assert_eq!(px40(&page, 35, 20), &[255, 255, 255, 255], "{what}: beyond the end of the line");
}
// A long pattern on many short subpaths: 9,999 tiny dashes, then a long one, restarted on
// each of 10,000 subpaths 0.01 pt long, is 100 million pieces from a 300 KB stream.
let mut many = String::from("1 0 0 RG 4 w [");
many.push_str(&"0.000001 ".repeat(9_999));
many.push_str("1000] 0 d ");
many.push_str(&"10 20 m 10.01 20 l ".repeat(10_000));
many.push_str("S 0 0 1 rg 0 0 4 4 re f");
let page = render_with_gs1(&many, "<< >>");
assert_eq!(px40(&page, 1, 38), &[0, 0, 255, 255], "a pattern restarted on many subpaths finishes");
// A fine pattern below the cap still dashes: 200,000 pieces of 0.0001 pt cover about
// half of each pixel, so the line is neither solid red nor missing.
let page = render_with_gs1("1 0 0 RG 4 w [0.0001] 0 d 10 20 m 30 20 l S", "<< >>");
let p = px40(&page, 20, 20);
assert!(p[0] == 255 && (40..=215).contains(&p[1]), "a fine dash below the cap is drawn dashed: {p:?}");
// An ordinary dash pattern still dashes: butt-capped 4 pt dashes from x 0.
let page = render_with_gs1("1 0 0 RG 4 w [4 4] 0 d 0 20 m 40 20 l S", "<< >>");
assert_eq!(px40(&page, 2, 20), &red, "inside the first dash");
assert_eq!(px40(&page, 6, 20), &[255, 255, 255, 255], "inside the first gap");
assert_eq!(px40(&page, 10, 20), &red, "inside the second dash");
}
/// From `cargo xtask fuzz` triage: a dash array whose entries sum to less than zero made
/// kurbo's search for the starting dash loop for ever (`[-1 -1] 0 d` never finished).
/// ISO 32000-2 §8.4.3.6 requires nonnegative entries. Vendored hayro patch: a pattern with a
/// negative entry, or a period that isn't positive, is drawn solid.
#[test]
fn invalid_dash_patterns_terminate_and_are_drawn_solid() {
for (what, content, gs1) in [
("[-1 -1]", "1 0 0 RG 4 w [-1 -1] 0 d 10 20 m 30 20 l S", "<< >>"),
("[-1] with a phase", "1 0 0 RG 4 w [-1] 5 d 10 20 m 30 20 l S", "<< >>"),
("[-2 -3] from an ExtGState", "1 0 0 RG 4 w /GS1 gs 10 20 m 30 20 l S", "<< /D [[-2 -3] 0] >>"),
("[-1 3], a negative entry in a positive period", "1 0 0 RG 4 w [-1 3] 0 d 10 20 m 30 20 l S", "<< >>"),
] {
let page = render_with_gs1(content, gs1);
for x in [12, 20, 28] {
assert_eq!(px40(&page, x, 20), &[255, 0, 0, 255], "{what}: ({x}, 20) is drawn, solid");
}
}
}
/// From the nightly `cargo xtask fuzz`: a Type 3 glyph that shows several glyphs of its own
/// font. The nesting cap bounds the depth but not the breadth: eight glyphs per glyph, sixteen
/// deep, is 8^16 paints. Vendored hayro-interpret patch: nested paints (forms, Type 3 glyphs,
@ -1438,6 +1671,70 @@ trailer << /Root 1 0 R >>
assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "the rest of the page draws");
}
/// From `cargo xtask fuzz`: one changed byte in a symbol dictionary's arithmetic-coded data
/// hung the renderer. Two of its loops can stop advancing: an export run of length 0, and a
/// height class whose first width is already the end-of-class marker. Past the end of its
/// data the decoder can return those for ever. Vendored hayro-jbig2 patch: at most one height
/// class per new symbol and `2 × symbols + 1` export runs, each plus `EXTRA_EMPTY_STEPS`.
/// These synthetic dictionaries (two new symbols each, a few bytes of arbitrary data) decode
/// the same way. In the second every class after the first symbol adds 13 to the height, so it
/// only fails when the height overflows `u32`, after 330 million empty classes (about five
/// seconds; a delta of 0 would never end). The page draws it as twenty images, so that the
/// stall without the patch is well past the timeout on any machine.
#[test]
fn jbig2_symbol_dictionaries_that_never_advance_terminate() {
for (what, data, copies) in
[("zero-length export runs", &[0x05, 0x70, 0x05, 0x14, 0x02, 0x1d, 0x56, 0x3f][..], 1), ("empty height classes", &[0x06, 0x99][..], 20)]
{
// Embedded JBIG2 segments (ISO 14492 §7.2): page information, then a symbol dictionary.
let mut jbig2 = Vec::new();
let mut segment = |number: u32, kind: u8, data: &[u8]| {
jbig2.extend_from_slice(&number.to_be_bytes());
jbig2.extend_from_slice(&[kind, 0, 1]);
jbig2.extend_from_slice(&u32::try_from(data.len()).unwrap().to_be_bytes());
jbig2.extend_from_slice(data);
};
let mut page = Vec::new();
page.extend_from_slice(&8u32.to_be_bytes());
page.extend_from_slice(&8u32.to_be_bytes());
page.extend_from_slice(&[0; 8]); // resolution
page.extend_from_slice(&[0, 0, 0]); // flags, striping
segment(0, 48, &page);
let mut dictionary = vec![0, 0]; // arithmetic coding, template 0, no refinement
dictionary.extend_from_slice(&[3, 0xff, 0xfd, 0xff, 2, 0xfe, 0xfe, 0xfe]); // AT pixels
dictionary.extend_from_slice(&1u32.to_be_bytes()); // exported symbols
dictionary.extend_from_slice(&2u32.to_be_bytes()); // new symbols
dictionary.extend_from_slice(data);
segment(1, 0, &dictionary);
// Each copy is its own image XObject (5, 6, …), all drawn by the page.
let images: Vec<usize> = (5..5 + copies).collect();
let mut content: String = images.iter().map(|n| format!("q 20 0 0 20 5 5 cm /Im{n} Do Q ")).collect();
content.push_str("1 0 0 rg 0 0 4 4 re f");
let names: String = images.iter().map(|n| format!("/Im{n} {n} 0 R ")).collect();
let mut pdf = format!("%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /XObject << {names}>> >> >> endobj\n").into_bytes();
pdf.extend_from_slice(format!("4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n", content.len()).as_bytes());
for n in &images {
pdf.extend_from_slice(
format!("{n} 0 obj << /Type /XObject /Subtype /Image /Width 8 /Height 8 /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode /Length {} >> stream\n", jbig2.len())
.as_bytes(),
);
pdf.extend_from_slice(&jbig2);
pdf.extend_from_slice(b"\nendstream endobj\n");
}
pdf.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF\n");
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }));
});
let page = rx
.recv_timeout(std::time::Duration::from_secs(20))
.unwrap_or_else(|_| panic!("{what}: a JBIG2 symbol dictionary must not stall the renderer"));
assert!(page.error.is_none(), "{what}: {:?}", page.error);
assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "{what}: the rest of the page draws");
}
}
/// From the nightly `cargo xtask fuzz`: an embedded Type 1 font program holding a long run of
/// integers. read-fonts 0.39 (through skrifa 0.42) looked ahead after every integer by
/// parsing the next token, which looked ahead again, recursing through the whole run: a long
@ -1652,6 +1949,29 @@ trailer << /Root 1 0 R >>
}
}
/// Half-width katakana are common in Japanese documents set in a non-embedded Mincho font
/// such as HeiseiMin-W3. The Mincho substitute must have those glyphs: one that lacks them
/// (Shippori Mincho) draws each CID by glyph index instead, as unrelated glyphs (fi, fl).
#[test]
fn mincho_substitute_covers_half_width_katakana() {
use hayro::hayro_interpret::font::FallbackFontQuery;
use hayro::hayro_interpret::hayro_cmap::CharacterCollection;
use skrifa::MetadataProvider;
let query = FontQuery::Fallback(FallbackFontQuery {
post_script_name: Some("HeiseiMin-W3".into()),
character_collection: Some(CharacterCollection { family: CidFamily::AdobeJapan1, supplement: 2 }),
..FallbackFontQuery::default()
});
let Some((data, index)) = super::japanese_fallback(&query) else {
eprintln!("built without craft-fonts (CRAFT_FONTS_DIR unset): no Japanese face to check");
return;
};
let font = skrifa::FontRef::from_index((*data).as_ref(), index).expect("a craft-fonts face parses");
let charmap = font.charmap();
let missing: Vec<char> = ('\u{FF61}'..='\u{FF9F}').chain(['日', '本']).filter(|&c| charmap.map(c).is_none()).collect();
assert!(missing.is_empty(), "the Mincho substitute lacks {missing:?}");
}
#[test]
fn japanese_font_names_pick_mincho_or_gothic() {
use super::JapaneseFace::{Gothic, Mincho};

View File

@ -1,7 +1,14 @@
//! A small DER reader and writer (ITU-T X.690): just what X.509, CMS and PKCS#12 need.
//! A small BER/DER reader and writer (ITU-T X.690): just what X.509, CMS and PKCS#12 need.
//!
//! [`Tlv`] borrows from the input and keeps its full encoding (`raw`), so signed structures can
//! be verified over the exact bytes that were signed.
//!
//! Reading is strict DER by default, which is what everything signed must use. [`Tlv::parse_ber`]
//! also accepts the two BER constructions a reader meets in practice — the indefinite length form
//! and strings split into segments — which PKCS #12 needs: Windows writes `.p12` files that way.
//! Writing is always DER.
use std::borrow::Cow;
use crate::SignError;
@ -11,6 +18,8 @@ pub mod tag {
pub const INTEGER: u8 = 0x02;
pub const BIT_STRING: u8 = 0x03;
pub const OCTET_STRING: u8 = 0x04;
/// An OCTET STRING split into segments (BER only, X.690 §8.21); see [`super::Tlv::octets`].
pub const OCTET_STRING_SEGMENTS: u8 = 0x24;
pub const NULL: u8 = 0x05;
pub const OID: u8 = 0x06;
pub const UTF8_STRING: u8 = 0x0C;
@ -36,56 +45,107 @@ fn bad(what: &str) -> SignError {
SignError::Malformed(what.to_string())
}
/// One DER element.
/// Which encoding rules a reader accepts.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Form {
/// DER: every length definite, every string primitive. Anything whose bytes are signed is read
/// this way, so that what is verified is exactly what was written.
Der,
/// BER as well: the indefinite length form (X.690 §8.1.3.6) and strings split into segments
/// (§8.21).
Ber,
}
/// One BER/DER element.
#[derive(Clone, Copy, Debug, Eq)]
pub struct Tlv<'a> {
pub tag: u8,
/// The contents octets.
/// The contents octets (without the end-of-contents octets of an indefinite length).
pub value: &'a [u8],
/// The whole encoding (identifier, length and contents).
/// The whole encoding (identifier, length, contents and any end-of-contents octets).
pub raw: &'a [u8],
/// The rules this element was read with. Its children are read the same way.
form: Form,
}
/// Two elements are equal when they encode the same thing; the rules they were read with are not
/// part of that.
impl PartialEq for Tlv<'_> {
fn eq(&self, other: &Self) -> bool {
self.tag == other.tag && self.value == other.value && self.raw == other.raw
}
}
impl<'a> Tlv<'a> {
/// Parse one element at the start of `input`; returns it and the rest.
/// Parse one DER element at the start of `input`; returns it and the rest.
pub fn parse(input: &'a [u8]) -> Result<(Tlv<'a>, &'a [u8]), SignError> {
Tlv::read(input, Form::Der, 0)
}
/// Parse one BER element at the start of `input`; returns it and the rest.
pub fn parse_ber(input: &'a [u8]) -> Result<(Tlv<'a>, &'a [u8]), SignError> {
Tlv::read(input, Form::Ber, 0)
}
fn read(input: &'a [u8], form: Form, depth: usize) -> Result<(Tlv<'a>, &'a [u8]), SignError> {
let (&tag, rest) = input.split_first().ok_or_else(|| bad("truncated DER"))?;
if tag & 0x1F == 0x1F {
return Err(bad("multi-byte DER tags are not supported"));
}
let (&first, rest) = rest.split_first().ok_or_else(|| bad("truncated DER length"))?;
let (len, rest) = if first < 0x80 {
(first as usize, rest)
// `trailer` is the two end-of-contents octets, which belong to the element but not to its
// contents.
let (len, rest, trailer) = if first < 0x80 {
(first as usize, rest, 0)
} else if first == 0x80 {
if form == Form::Der {
return Err(bad("indefinite lengths are not allowed in DER"));
}
if tag & 0x20 == 0 {
return Err(bad("an indefinite length needs a constructed element"));
}
(contents_len(rest, form, depth)?, rest, 2)
} else {
let n = (first & 0x7F) as usize;
if n == 0 || n > 4 || rest.len() < n {
if n > 4 || rest.len() < n {
return Err(bad("unsupported DER length"));
}
let len = rest[..n].iter().fold(0usize, |acc, b| (acc << 8) | *b as usize);
(len, &rest[n..])
(len, &rest[n..], 0)
};
if rest.len() < len {
let end = len.checked_add(trailer).ok_or_else(|| bad("DER length overflows"))?;
if rest.len() < end {
return Err(bad("DER element runs past the end"));
}
// `end <= rest.len()`, so neither sum below can overflow.
let header = input.len() - rest.len();
Ok((Tlv { tag, value: &rest[..len], raw: &input[..header + len] }, &rest[len..]))
Ok((Tlv { tag, value: &rest[..len], raw: &input[..header + end], form }, &rest[end..]))
}
/// Parse `input` as exactly one element.
/// Parse `input` as exactly one DER element.
pub fn parse_all(input: &'a [u8]) -> Result<Tlv<'a>, SignError> {
let (t, rest) = Tlv::parse(input)?;
Tlv::only(input, Form::Der)
}
/// Parse `input` as exactly one BER element.
pub fn parse_all_ber(input: &'a [u8]) -> Result<Tlv<'a>, SignError> {
Tlv::only(input, Form::Ber)
}
fn only(input: &'a [u8], form: Form) -> Result<Tlv<'a>, SignError> {
let (t, rest) = Tlv::read(input, form, 0)?;
if !rest.is_empty() {
return Err(bad("trailing bytes after DER element"));
}
Ok(t)
}
/// The children of a constructed element.
/// The children of a constructed element, read with the same rules as their parent.
pub fn children(&self) -> Result<Vec<Tlv<'a>>, SignError> {
let mut out = Vec::new();
let mut rest = self.value;
while !rest.is_empty() {
let (t, r) = Tlv::parse(rest)?;
let (t, r) = Tlv::read(rest, self.form, 0)?;
out.push(t);
rest = r;
}
@ -99,7 +159,34 @@ impl<'a> Tlv<'a> {
/// The element inside an EXPLICIT tag.
pub fn inner(&self) -> Result<Tlv<'a>, SignError> {
Tlv::parse_all(self.value)
Tlv::only(self.value, self.form)
}
/// An OCTET STRING's contents, joining the segments of BER's constructed form (X.690 §8.21).
/// Borrowed in the ordinary case, so the common path copies nothing.
pub fn octets(&self, what: &str) -> Result<Cow<'a, [u8]>, SignError> {
self.join(what, 0)
}
fn join(&self, what: &str, depth: usize) -> Result<Cow<'a, [u8]>, SignError> {
/// Deep enough for anything real: a writer that segments at all segments once.
const MAX_DEPTH: usize = 16;
match self.tag {
tag::OCTET_STRING => Ok(Cow::Borrowed(self.value)),
tag::OCTET_STRING_SEGMENTS if self.form == Form::Ber => {
if depth >= MAX_DEPTH {
return Err(bad(&format!("{what}: OCTET STRING segments nested too deeply")));
}
// The join is never larger than the segments it is made of, so this is bounded by
// the input.
let mut out: Vec<u8> = Vec::with_capacity(self.value.len());
for s in self.children()? {
out.extend_from_slice(&s.join(what, depth + 1)?);
}
Ok(Cow::Owned(out))
}
found => Err(bad(&format!("{what}: expected an OCTET STRING, found tag {found:#04x}"))),
}
}
/// An OID in dotted form.
@ -172,6 +259,26 @@ impl<'a> Tlv<'a> {
}
}
/// How many contents octets an indefinite-length element has: everything up to the matching
/// end-of-contents octets, which have to be there (X.690 §8.1.5). The contents are elements, so
/// finding the end means reading them, and one of those may be indefinite too — hence the depth
/// limit. Every step consumes at least two octets, so the walk always ends.
fn contents_len(input: &[u8], form: Form, depth: usize) -> Result<usize, SignError> {
/// Far deeper than any real file nests, and shallow enough never to exhaust the stack.
const MAX_DEPTH: usize = 48;
if depth >= MAX_DEPTH {
return Err(bad("BER elements nested too deeply"));
}
let mut rest = input;
loop {
match rest {
[] => return Err(bad("an indefinite length with no end-of-contents")),
[0, 0, ..] => return Ok(input.len() - rest.len()),
_ => rest = Tlv::read(rest, form, depth + 1)?.1,
}
}
}
/// A UTC time.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct Time {
@ -439,6 +546,36 @@ mod tests {
assert!(Tlv::parse(&[0x30, 5, 1]).is_err());
}
#[test]
fn reads_ber_indefinite_lengths_and_segmented_strings() {
// SEQUENCE { INTEGER 3, OCTET STRING { "hi", "!" } }, every wrapper in the indefinite form:
// the shape Windows gives a .p12 file.
let ber: &[u8] = &[0x30, 0x80, 0x02, 0x01, 0x03, 0x24, 0x80, 0x04, 0x02, b'h', b'i', 0x04, 0x01, b'!', 0, 0, 0, 0];
let e = Tlv::parse_all(ber).unwrap_err().to_string();
assert!(e.contains("indefinite lengths are not allowed in DER"), "{e}");
let t = Tlv::parse_all_ber(ber).unwrap();
assert_eq!(t.tag, tag::SEQUENCE);
assert_eq!(t.raw, ber, "the end-of-contents octets belong to the element");
assert_eq!(t.value, &ber[2..ber.len() - 2], "but not to its contents");
let c = t.children().unwrap();
assert_eq!(c[0].u64().unwrap(), 3);
assert_eq!(c[1].octets("segments").unwrap().as_ref(), b"hi!", "the segments are joined");
assert!(c[0].octets("not a string").is_err());
// Definite lengths read the same either way, and a segmented string stays a DER error.
assert_eq!(Tlv::parse_all_ber(&int(65537)).unwrap().u64().unwrap(), 65537);
assert_eq!(Tlv::parse_all_ber(&octets(b"hi!")).unwrap().octets("plain").unwrap().as_ref(), b"hi!");
assert!(Tlv::parse_all(&[0x24, 2, 0x04, 0]).unwrap().octets("segments").is_err());
// Malformed: no end-of-contents, and an indefinite length on a primitive element.
assert!(Tlv::parse_all_ber(&ber[..ber.len() - 2]).is_err());
assert!(Tlv::parse_all_ber(&[0x04, 0x80, 0, 0]).is_err());
// Nesting is bounded, so a file made of nothing but wrappers fails instead of crashing.
let deep = [[0x30u8, 0x80].repeat(400), [0u8, 0].repeat(400)].concat();
assert!(Tlv::parse_all_ber(&deep).is_err());
}
#[test]
fn times() {
let t = Time { year: 2026, month: 10, day: 2, hour: 9, minute: 30, second: 5 };

View File

@ -7,12 +7,18 @@
//! base64 body (#159).
//! Writing uses PBES2 AES-256-CBC with PBKDF2-HMAC-SHA-256 and an HMAC-SHA-256 MAC, OpenSSL 3's
//! defaults.
//!
//! The envelope is read as BER, not DER: Windows writes `.p12` files with indefinite lengths and
//! the AuthenticatedSafe split into OCTET STRING segments, and nothing in the envelope is signed
//! over its own bytes. The certificates inside it are still read as strict DER, which is how they
//! were signed.
use std::borrow::Cow;
use base64::Engine as _;
use cbc::cipher::block_padding::Pkcs7;
use cbc::cipher::{BlockModeDecrypt, BlockModeEncrypt, InnerIvInit, KeyIvInit};
use hmac::Mac;
use std::borrow::Cow;
use crate::SignError;
use crate::der::{self, Tlv, tag};
@ -182,7 +188,7 @@ fn pbe_decrypt(alg: &Tlv<'_>, password: &str, ct: &[u8]) -> Result<Vec<u8>, Sign
return Err(SignError::Unsupported("PBES2 key derivation other than PBKDF2".into()));
}
let kp = kdf.get(1).ok_or_else(|| bad("PBKDF2 parameters"))?.children()?;
let salt = kp.first().ok_or_else(|| bad("salt"))?.expect(tag::OCTET_STRING, "salt")?.value;
let salt = kp.first().ok_or_else(|| bad("salt"))?.octets("salt")?;
let rounds = kp.get(1).ok_or_else(|| bad("iterations"))?.u64()? as u32;
let prf = match kp
.iter()
@ -208,13 +214,13 @@ fn pbe_decrypt(alg: &Tlv<'_>, password: &str, ct: &[u8]) -> Result<Vec<u8>, Sign
Some(DES_EDE3_CBC) => Cipher::TripleDes,
other => return Err(SignError::Unsupported(format!("PBES2 cipher {}", other.unwrap_or("?")))),
};
let iv = sc.get(1).ok_or_else(|| bad("IV"))?.expect(tag::OCTET_STRING, "IV")?.value;
let key = pbkdf2(prf, password.as_bytes(), salt, rounds, cipher.key_len());
cipher.decrypt(&key, iv, ct)
let iv = sc.get(1).ok_or_else(|| bad("IV"))?.octets("IV")?;
let key = pbkdf2(prf, password.as_bytes(), &salt, rounds, cipher.key_len());
cipher.decrypt(&key, &iv, ct)
}
PBE_SHA_3DES | PBE_SHA_2DES | PBE_SHA_RC2_128 | PBE_SHA_RC2_40 => {
let p = params.children()?;
let salt = p.first().ok_or_else(|| bad("salt"))?.expect(tag::OCTET_STRING, "salt")?.value;
let salt = p.first().ok_or_else(|| bad("salt"))?.octets("salt")?;
let rounds = p.get(1).ok_or_else(|| bad("iterations"))?.u64()? as u32;
let (cipher, key_len, iv_len) = match o.as_str() {
PBE_SHA_3DES => (Cipher::TripleDes, 24, 8),
@ -223,13 +229,13 @@ fn pbe_decrypt(alg: &Tlv<'_>, password: &str, ct: &[u8]) -> Result<Vec<u8>, Sign
_ => (Cipher::Rc2(40), 5, 8),
};
let pw = bmp(password);
let mut key = pkcs12_kdf(DigestAlg::Sha1, &pw, salt, 1, rounds, key_len);
let mut key = pkcs12_kdf(DigestAlg::Sha1, &pw, &salt, 1, rounds, key_len);
if key.len() == 16 && matches!(cipher, Cipher::TripleDes) {
// Two-key 3DES: K1 K2 K1.
let k1 = key[..8].to_vec();
key.extend(k1);
}
let iv = pkcs12_kdf(DigestAlg::Sha1, &pw, salt, 2, rounds, iv_len);
let iv = pkcs12_kdf(DigestAlg::Sha1, &pw, &salt, 2, rounds, iv_len);
cipher.decrypt(&key, &iv, ct)
}
other => Err(SignError::Unsupported(format!("PKCS #12 encryption {other}"))),
@ -248,7 +254,7 @@ enum BagKind {
}
fn bags(safe_contents: &[u8], password: &str, out: &mut Vec<Bag>) -> Result<(), SignError> {
for bag in Tlv::parse_all(safe_contents)?.children()? {
for bag in Tlv::parse_all_ber(safe_contents)?.children()? {
let f = bag.children()?;
let Some(id) = f.first().and_then(|o| o.oid().ok()) else { continue };
let Some(value) = f.get(1).filter(|v| v.tag == tag::ctx(0)).and_then(|v| v.inner().ok()) else { continue };
@ -258,7 +264,7 @@ fn bags(safe_contents: &[u8], password: &str, out: &mut Vec<Bag>) -> Result<(),
let p = a.children()?;
let v = p.get(1).and_then(|s| s.children().ok()).and_then(|s| s.into_iter().next());
match (p.first().and_then(|o| o.oid().ok()).as_deref(), v) {
(Some(LOCAL_KEY_ID), Some(v)) => local_key_id = Some(v.value.to_vec()),
(Some(LOCAL_KEY_ID), Some(v)) => local_key_id = v.octets("local key id").ok().map(Cow::into_owned),
(Some(FRIENDLY_NAME), Some(v)) => friendly_name = v.text(),
_ => {}
}
@ -269,15 +275,15 @@ fn bags(safe_contents: &[u8], password: &str, out: &mut Vec<Bag>) -> Result<(),
SHROUDED_KEY_BAG => {
let p = value.children()?;
let [alg, data] = p.as_slice() else { return Err(bad("EncryptedPrivateKeyInfo")) };
BagKind::Key(pbe_decrypt(alg, password, data.value)?)
BagKind::Key(pbe_decrypt(alg, password, &data.octets("encrypted private key")?)?)
}
CERT_BAG => {
let p = value.children()?;
if p.first().and_then(|o| o.oid().ok()).as_deref() != Some(X509_CERT) {
continue;
}
let Some(c) = p.get(1).and_then(|c| c.inner().ok()) else { continue };
BagKind::Cert(c.value.to_vec())
let Some(c) = p.get(1).and_then(|c| c.inner().ok()).and_then(|c| c.octets("certificate").ok()) else { continue };
BagKind::Cert(c.into_owned())
}
_ => continue,
};
@ -330,7 +336,7 @@ fn pem_body(pem: &[u8]) -> Result<&[u8], SignError> {
/// Open a `.p12` / `.pfx` file. A wrong password is [`SignError::WrongPassword`].
pub fn open(bytes: &[u8], password: &str) -> Result<DigitalId, SignError> {
let der = normalize(bytes)?;
let pfx = Tlv::parse_all(&der)
let pfx = Tlv::parse_all_ber(&der)
.map_err(|e| match e {
// Keep "not a PKCS #12 file" but carry the exact reason through.
SignError::Malformed(why) => bad(&format!("not a PKCS #12 file: {why}")),
@ -341,38 +347,45 @@ pub fn open(bytes: &[u8], password: &str) -> Result<DigitalId, SignError> {
if auth_safe.first().map(|o| o.oid()).transpose()?.as_deref() != Some(DATA) {
return Err(SignError::Unsupported("public-key protected PKCS #12 files".into()));
}
let content = auth_safe.get(1).ok_or_else(|| bad("authSafe content"))?.inner()?.expect(tag::OCTET_STRING, "authSafe")?.value;
// The MAC proves the password before anything is decrypted.
let content = auth_safe.get(1).ok_or_else(|| bad("authSafe content"))?.inner()?.octets("authSafe")?;
// The MAC proves the password before anything is decrypted. It covers the contents of that
// OCTET STRING — the segments joined, if the file is segmented, which is what wrote them.
if let Some(mac) = pfx.get(2) {
let m = mac.children()?;
let digest_info = m.first().ok_or_else(|| bad("MacData"))?.children()?;
let alg = digest_info.first().ok_or_else(|| bad("MAC algorithm"))?.children()?;
let alg_oid = alg.first().ok_or_else(|| bad("MAC algorithm"))?.oid()?;
let alg = DigestAlg::from_oid(&alg_oid).ok_or_else(|| SignError::Unsupported(format!("MAC digest {alg_oid}")))?;
let expected = digest_info.get(1).ok_or_else(|| bad("MAC"))?.value;
let salt = m.get(1).ok_or_else(|| bad("MAC salt"))?.value;
let expected = digest_info.get(1).ok_or_else(|| bad("MAC"))?.octets("MAC")?;
let salt = m.get(1).ok_or_else(|| bad("MAC salt"))?.octets("MAC salt")?;
let iterations = m.get(2).map(|i| i.u64()).transpose()?.unwrap_or(1) as u32;
let key_len = alg.digest(&[]).len();
let key = pkcs12_kdf(alg, &bmp(password), salt, 3, iterations, key_len);
if hmac(alg, &key, content)? != expected {
let key = pkcs12_kdf(alg, &bmp(password), &salt, 3, iterations, key_len);
if hmac(alg, &key, &content)? != *expected {
return Err(SignError::WrongPassword);
}
}
let mut all = Vec::new();
for ci in Tlv::parse_all(content)?.children()? {
for ci in Tlv::parse_all_ber(&content)?.children()? {
let p = ci.children()?;
let kind = p.first().ok_or_else(|| bad("ContentInfo"))?.oid()?;
let body = p.get(1).ok_or_else(|| bad("ContentInfo content"))?.inner()?;
match kind.as_str() {
DATA => bags(body.expect(tag::OCTET_STRING, "SafeContents")?.value, password, &mut all)?,
DATA => bags(&body.octets("SafeContents")?, password, &mut all)?,
ENCRYPTED_DATA => {
let ed = body.children()?;
let eci = ed.get(1).ok_or_else(|| bad("EncryptedContentInfo"))?.children()?;
let alg = eci.get(1).ok_or_else(|| bad("content encryption algorithm"))?;
let ct = match eci.get(2) {
Some(t) if t.tag == tag::ctx_prim(0) => t.value.to_vec(),
// Constructed form: a sequence of OCTET STRING segments.
Some(t) if t.tag == tag::ctx(0) => t.children()?.iter().flat_map(|s| s.value.iter().copied()).collect(),
let ct: Cow<'_, [u8]> = match eci.get(2) {
Some(t) if t.tag == tag::ctx_prim(0) => Cow::Borrowed(t.value),
// Constructed form: the OCTET STRING segments, joined.
Some(t) if t.tag == tag::ctx(0) => {
let mut v = Vec::with_capacity(t.value.len());
for s in t.children()? {
v.extend_from_slice(&s.octets("encryptedContent")?);
}
Cow::Owned(v)
}
_ => return Err(bad("encryptedContent")),
};
let plain = pbe_decrypt(alg, password, &ct)?;

View File

@ -1,6 +1,6 @@
//! The cryptographic core against files made by OpenSSL 3 (tests/data/README.md).
use pdfcraft_sign::der::Time;
use pdfcraft_sign::der::{Time, Tlv};
use pdfcraft_sign::keys::DigestAlg;
use pdfcraft_sign::{Certificate, Name, PublicKey, SignError, cms, pkcs12};
@ -58,6 +58,74 @@ fn opens_every_openssl_flavour_of_pkcs12() {
assert_eq!(chain.certificate.key_usage.map(|u| u & 0b11), Some(0b11), "digitalSignature + nonRepudiation");
}
/// One element with BER's indefinite length: contents, then the end-of-contents octets.
fn indefinite(tag: u8, body: &[u8]) -> Vec<u8> {
let mut v = vec![tag, 0x80];
v.extend_from_slice(body);
v.extend([0, 0]);
v
}
/// An OCTET STRING split into segments, BER's constructed form.
fn segmented(bytes: &[u8]) -> Vec<u8> {
let parts: Vec<u8> = bytes.chunks(64).flat_map(der::octets).collect();
indefinite(0x24, &parts)
}
/// A `.p12` in the shape Windows writes one: the PFX, its ContentInfo and the `[0]` holding the
/// AuthenticatedSafe all in the indefinite form, and the AuthenticatedSafe split into segments.
/// Nothing changes meaning, and the MAC still covers the same octets, so whatever a reader makes
/// of it, it is making it of the form and not of the contents.
///
/// With `deep`, every ContentInfo inside is rewritten the same way, as a real Windows file has it.
/// That re-encodes the AuthenticatedSafe, which is what the MAC covers, so the MacData is dropped
/// — RFC 7292 §4 makes it optional — and a wrong password is then caught by the decryption.
fn as_windows_writes_it(der: &[u8], deep: bool) -> Vec<u8> {
let pfx = Tlv::parse_all(der).unwrap().children().unwrap();
let auth = pfx[1].children().unwrap();
let mut content = auth[1].inner().unwrap().value.to_vec();
if deep {
let mut inner = Vec::new();
for ci in Tlv::parse_all(&content).unwrap().children().unwrap() {
let p = ci.children().unwrap();
let body = p[1].inner().unwrap();
// A Data content is an OCTET STRING, so it gets segmented; an EncryptedData is a
// SEQUENCE and stays as it is inside its indefinite wrappers.
let wrapped = if body.tag == 0x04 { segmented(body.value) } else { body.raw.to_vec() };
let zero = indefinite(0xA0, &wrapped);
inner.extend(indefinite(0x30, &[p[0].raw, zero.as_slice()].concat()));
}
content = indefinite(0x30, &inner);
}
let zero = indefinite(0xA0, &segmented(&content));
let mut body = pfx[0].raw.to_vec();
body.extend(indefinite(0x30, &[auth[0].raw, zero.as_slice()].concat()));
if !deep {
for extra in &pfx[2..] {
body.extend_from_slice(extra.raw);
}
}
indefinite(0x30, &body)
}
#[test]
fn opens_pkcs12_as_windows_writes_it() {
for file in ["rsa-aes.p12", "rsa-legacy.p12", "chain.p12"] {
let der = data(file);
let want = pkcs12::open(&der, "test").unwrap();
for deep in [false, true] {
let ber = as_windows_writes_it(&der, deep);
assert_ne!(ber, der, "{file} ({deep})");
let got = pkcs12::open(&ber, "test").unwrap_or_else(|e| panic!("{file} in BER (deep: {deep}): {e}"));
assert_eq!(got.certificate.raw, want.certificate.raw, "{file} ({deep})");
assert_eq!(got.key.public_key(), want.key.public_key(), "{file} ({deep})");
assert_eq!(got.chain.len(), want.chain.len(), "{file} ({deep})");
assert_eq!(got.friendly_name, want.friendly_name, "{file} ({deep})");
assert!(matches!(pkcs12::open(&ber, "nope"), Err(SignError::WrongPassword)), "{file} ({deep})");
}
}
}
#[test]
fn certificates_parse_as_openssl_made_them() {
let c = Certificate::parse(&pem("rsa.crt.pem")).unwrap();

View File

@ -12,13 +12,16 @@ workspace = true
pdfcraft-fonts = { workspace = true }
pdfcraft-engine = { workspace = true }
pdfcraft-render = { workspace = true }
pdfcraft-model = { workspace = true }
pdfcraft-platform = { workspace = true }
egui = { workspace = true }
egui = { workspace = true, features = ["bytemuck"] }
egui_extras = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "persistence", "links"] }
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
# Hands rendered rasters to egui as its pixels, without copying; already in the tree through egui and wgpu.
bytemuck = { version = "1", features = ["extern_crate_alloc"] }
rfd = { workspace = true }
png = "0.18"
@ -57,7 +60,6 @@ web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlE
[dev-dependencies]
pdfcraft-xfa = { workspace = true }
pdfcraft-cos = { workspace = true }
pdfcraft-model = { workspace = true }
egui_kittest = { workspace = true, features = ["eframe", "wgpu"] }
image = { version = "0.25", default-features = false, features = ["png"] }
# Glyph-coverage test of the interface fonts; the same skrifa pdfcraft-fonts already uses.

View File

@ -10,7 +10,7 @@ use std::sync::Arc;
use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Stroke, TextureHandle, TextureOptions, Vec2, pos2, vec2};
use pdfcraft_engine::{DocId, Edit};
use pdfcraft_render::{DocInfo, LayerOp, LinkTarget, PageText, RenderPool, RenderRequest, RequestKind, Tile, device_pixels};
use pdfcraft_render::{DestView, DocInfo, LayerOp, LinkTarget, PageText, RenderPool, RenderRequest, RequestKind, Tile, device_pixels};
use crate::theme::{self, Tokens};
use crate::{PdfCraftApp, QuickTool, RightPanel, comments, icons, widgets};
@ -200,9 +200,16 @@ pub struct DocView {
pub forward: Vec<usize>,
/// Pending navigation: page and fraction down the page to align with the viewport top.
pub goto: Option<(usize, f32)>,
/// Pending position within that page from a destination ([`Self::go_to_dest`]): the point
/// (fractions of the displayed page; `None` leaves that axis to `goto`) to put `rel` points
/// from the viewport's top-left corner.
goto_point: Option<(usize, Option<f32>, Option<f32>, Vec2)>,
/// Pending keyboard scrolling, in points down (negative: up): ↓ / ↑, and Page Down /
/// Page Up where the pages scroll.
pub key_scroll: f32,
/// Single-page view, as last drawn: the page shown and whether the view was at its top and
/// at its bottom, so ↑ / ↓ turn the page there instead of doing nothing (#273).
single_edges: Option<(usize, bool, bool)>,
/// Briefly outline an annotation after navigating to it from a panel.
pub flash: Option<(usize, [f32; 4], f64)>,
/// Compare files: differences shaded on this document's pages (page, user-space box, colour).
@ -237,6 +244,8 @@ pub struct DocView {
viewport_screen: Rect,
/// Pending zoom anchor: page, position within it (0..1), and offset from the viewport corner.
zoom_anchor: Option<(usize, f32, f32, Vec2)>,
/// The page view has been shown, so its scroll offset in egui's memory is this document's.
shown: bool,
/// Turns wheel input into page turns in single-page view.
wheel: crate::wheel_pager::WheelPager,
pub(crate) auto_scroll: crate::autoscroll::AutoScroll,
@ -352,7 +361,9 @@ impl DocView {
back: Vec::new(),
forward: Vec::new(),
goto: None,
goto_point: None,
key_scroll: 0.0,
single_edges: None,
flash: None,
compare_marks: Vec::new(),
pages: HashMap::new(),
@ -375,6 +386,7 @@ impl DocView {
screen_xforms: Vec::new(),
viewport_screen: Rect::NOTHING,
zoom_anchor: None,
shown: false,
wheel: Default::default(),
auto_scroll: Default::default(),
selected: BTreeSet::new(),
@ -417,10 +429,15 @@ impl DocView {
self.current = self.current.min(last);
self.page_input = (self.current + 1).to_string();
self.selected.retain(|p| *p <= last);
// Text being typed on a page that no longer exists goes with the page.
if self.content.draft.as_ref().is_some_and(|d| d.page > last || self.page_count == 0) {
self.content.draft = None;
}
if self.select_anchor.is_some_and(|a| a > last) {
self.select_anchor = None;
}
self.goto = None;
self.goto_point = None;
self.zoom_anchor = None;
self.flash = None;
}
@ -668,6 +685,67 @@ impl DocView {
self.page_input = (page + 1).to_string();
}
/// Go to a destination, a bookmark's or a link's (ISO 32000-2 §12.3.2.2): its page, scrolled
/// and zoomed as `dest` asks, as Acrobat does. `/XYZ` puts (left, top) at the window's
/// top-left at its zoom (null or 0 keeps the zoom); `/Fit`, `/FitH` and `/FitV` switch to fit
/// page, width and height with `top` or `left` at the window's edge; `/FitR` zooms so the
/// rectangle fits, centred. The `B` (content box) forms are read as the page. A null
/// coordinate keeps that scroll position (null `top` on a new page: its top), and a point
/// past the end of the document scrolls as far as it can.
pub fn go_to_dest(&mut self, page: usize, dest: DestView, info: &DocInfo) {
self.go_to_page(page);
let page = self.current;
self.goto_point = None;
let Some(p) = info.pages.get(page) else { return };
let point = |x: Option<f32>, y: Option<f32>| p.dest_fraction(x, y, self.rotation);
// The window's top-left as far as pages go: its left edge is the gutter that keeps them
// clear of the floating quick-action bar, so `left` 0 shows the page's edge beside it.
let corner = vec2(SIDE, 0.0);
let (fx, fy, rel) = match dest {
DestView::Top => return,
DestView::Xyz { left, top, zoom } => {
if let Some(z) = zoom {
self.zoom = z.clamp(0.08, 64.0);
self.fit = Fit::None;
self.zoom_anchor = None;
}
let [fx, fy] = point(left, top);
(fx, fy, corner)
}
DestView::Fit => {
self.fit = Fit::Page;
return;
}
DestView::FitH { top } => {
self.fit = Fit::Width;
let [fx, fy] = point(None, top);
(fx, fy, corner)
}
DestView::FitV { left } => {
self.fit = Fit::Height;
let [fx, fy] = point(left, None);
(fx, fy, corner)
}
DestView::FitR { rect } => {
let ([Some(ax), Some(ay)], [Some(bx), Some(by)]) = (point(Some(rect[0]), Some(rect[3])), point(Some(rect[2]), Some(rect[1]))) else {
return;
};
// The rectangle as displayed, in points (it can be turned a quarter).
let (dw, dh) = self.display_size(p);
let (w, h) = ((ax - bx).abs() * dw, (ay - by).abs() * dh);
if w >= 1.0 && h >= 1.0 && self.viewport_w >= 1.0 && self.viewport_h >= 1.0 {
self.zoom = (self.viewport_w / (w * PT)).min(self.viewport_h / (h * PT)).clamp(0.08, 64.0);
self.fit = Fit::None;
self.zoom_anchor = None;
}
(Some((ax + bx) / 2.0), Some((ay + by) / 2.0), vec2(self.viewport_w, self.viewport_h) / 2.0)
}
};
if fx.is_some() || fy.is_some() {
self.goto_point = Some((page, fx, fy, rel));
}
}
/// Next page (`true`) or previous page. In two-page view this moves a whole spread: the other
/// page of the spread is already on screen, so stepping to it wouldn't move the view (#70).
pub fn step_page(&mut self, forward: bool) {
@ -872,7 +950,7 @@ impl DocView {
self.errors.insert(r.request.page, e);
continue;
}
let img = egui::ColorImage::from_rgba_premultiplied([r.width as usize, r.height as usize], &r.rgba);
let img = texture_image([r.width as usize, r.height as usize], r.rgba);
let page = r.request.page;
if let Some(t) = r.request.tile {
let tex = ctx.load_texture(format!("tile-{:?}-{page}-{}-{}", self.id, t.x, t.y), img, TextureOptions::LINEAR);
@ -991,21 +1069,28 @@ pub struct PageXform {
}
impl PageXform {
/// Draw an image in PDF user space, preserving orientation through both page rotations.
/// Draw an image in the PDF user-space `rect` as a custom stamp's appearance draws it: turned
/// back by `turn` (the page /Rotate it was placed for), so with the page's own rotation it
/// reads upright as displayed.
pub(crate) fn paint_user_image(
&self,
painter: &egui::Painter,
tex: egui::TextureId,
info: &DocInfo,
page: usize,
p: &pdfcraft_render::PageInfo,
rect: [f64; 4],
turn: i64,
color: Color32,
) {
let Some(p) = info.pages.get(page) else { return };
let mut mesh = egui::Mesh::with_texture(tex);
for (x, y, u, v) in [(rect[0], rect[3], 0.0, 0.0), (rect[2], rect[3], 1.0, 0.0), (rect[2], rect[1], 1.0, 1.0), (rect[0], rect[1], 0.0, 1.0)] {
let p = p.user_to_view(x as f32, y as f32);
mesh.vertices.push(egui::epaint::Vertex { pos: self.norm_to_screen(p[0] / self.pw, p[1] / self.ph), uv: pos2(u, v), color });
let (w, h) = (rect[2] - rect[0], rect[3] - rect[1]);
let (shown_w, shown_h) = if turn % 180 == 0 { (w, h) } else { (h, w) };
let [a, b, c, d, e, f] = pdfcraft_model::view_matrix_for(turn, rect);
for (u, v) in [(0.0_f32, 0.0_f32), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] {
// The texture's (u, v), from its top-left, in the picture's upright frame, then in user space.
let (dx, dy) = (f64::from(u) * shown_w, f64::from(1.0 - v) * shown_h);
let (x, y) = (a * dx + c * dy + e, b * dx + d * dy + f);
let q = p.user_to_view(x as f32, y as f32);
mesh.vertices.push(egui::epaint::Vertex { pos: self.norm_to_screen(q[0] / self.pw, q[1] / self.ph), uv: pos2(u, v), color });
}
mesh.add_triangle(0, 1, 2);
mesh.add_triangle(0, 2, 3);
@ -1159,12 +1244,31 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context) {
if key(Key::ArrowLeft) || (key(Key::PageUp) && (command || !scrolls)) {
view.step_page(false);
}
// ↓ / ↑ scroll a line, and Page Down / Page Up a screen where the pages scroll.
// ↓ / ↑ scroll a line, and Page Down / Page Up a screen where the pages scroll. In
// single-page view, ↓ / ↑ turn the page once there is nothing left to scroll that way, as
// the wheel does (#273): always on a page that fits.
if !command {
let screen = (view.viewport_h - KEY_SCROLL_LINE).max(KEY_SCROLL_LINE);
let steps = [(Key::ArrowDown, KEY_SCROLL_LINE), (Key::ArrowUp, -KEY_SCROLL_LINE), (Key::PageDown, screen), (Key::PageUp, -screen)];
for (k, by) in steps {
if key(k) && (scrolls || matches!(k, Key::ArrowDown | Key::ArrowUp)) {
let arrow = matches!(k, Key::ArrowDown | Key::ArrowUp);
if !key(k) || !(scrolls || arrow) {
continue;
}
let forward = k == Key::ArrowDown;
// Only an up-to-date view counts: the page drawn is the current one, with no jump pending.
let at_end = !scrolls
&& view.goto.is_none()
&& view.single_edges.is_some_and(|(page, top, bottom)| page == view.current && if forward { bottom } else { top });
if at_end {
let before = view.current;
view.step_page(forward);
if !forward && view.current != before {
// Going back up lands on the bottom of the previous page.
view.goto = Some((view.current, 1.0));
}
view.single_edges = None;
} else {
view.key_scroll += by;
}
}
@ -1296,6 +1400,12 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
});
// Each document keeps its own scroll position when switching tabs (#189).
scroll = scroll.id_salt(("page-view", view.id));
// A newly opened document starts at the top. eframe saves egui's memory with the settings
// and document ids start again at 1 every launch, so the offset stored under this id can be
// where an earlier session left another document.
if !std::mem::replace(&mut view.shown, true) {
scroll = scroll.scroll_offset(Vec2::ZERO);
}
if let Some((page, fx, fy, rel)) = view.zoom_anchor.take() {
let r = rects[page.min(rects.len() - 1)];
let point = pos2(r.left() + fx * r.width(), r.top() - y_shift + fy * r.height());
@ -1370,6 +1480,19 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
view.content.selected = None;
}
// A destination's position (bookmarks, links) refines `goto`: its point at the window's
// top-left, or the centre of a /FitR rectangle at the window's centre.
if let Some((page, fx, fy, rel)) = view.goto_point.take() {
let r = rects[page.min(rects.len() - 1)];
// Within the content, so an unreachable point never shows a frame scrolled past the end.
let (max_x, max_y) = ((content_w - avail.width()).max(0.0), (content_h - avail.height()).max(0.0));
if let Some(fx) = fx {
scroll = scroll.horizontal_scroll_offset((r.left() + fx * r.width() - rel.x).clamp(0.0, max_x));
}
if let Some(fy) = fy {
scroll = scroll.vertical_scroll_offset((r.top() - y_shift + fy * r.height() - rel.y).clamp(0.0, max_y));
}
}
let out = scroll.show_viewport(ui, |ui, viewport| {
// egui's drag responses accept every pointer button. Keep a wheel gesture from also
// selecting text, drawing a mark, or panning with the Hand tool.
@ -1448,10 +1571,13 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
painter.galley(pos2(r.center().x - msg.size().x / 2.0, r.center().y), msg, Color32::BLACK);
} else {
let (pw_pt, ph_pt) = (info.pages[i].width.max(1.0), info.pages[i].height.max(1.0));
let tiled = pw_pt.max(ph_pt) * scale > TILE_THRESHOLD;
// A GPU may take smaller textures than these (OpenGL drivers report as little as
// 2048 pixels, and uploading a bigger one aborts), so both stay within its limit.
let max_side = ui.ctx().input(|inp| inp.max_texture_side) as f32;
let tiled = pw_pt.max(ph_pt) * scale > TILE_THRESHOLD.min(max_side);
// Whole-page raster: sharp when small, a low-res backdrop when tiled.
let (want_scale, want_tag) = if tiled {
let bs = BASE_SIDE / pw_pt.max(ph_pt);
let bs = BASE_SIDE.min(max_side) / pw_pt.max(ph_pt);
(bs, scale_tag(bs))
} else {
(scale, tag)
@ -1728,7 +1854,14 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
view.selection = Some(Selection { page: i, anchor: first, head: last });
}
} else if resp.clicked() && !over_link {
view.selection = None;
// ⇧-click extends a selection on this page to the click, keeping its
// anchor (#527). Otherwise (no shift, or no selection on this page; a
// selection cannot yet span pages) a click clears the selection.
let shift = ui.input(|inp| inp.modifiers.shift);
match (view.selection.as_mut().filter(|s| shift && s.page == i), text.nearest(vx, vy)) {
(Some(sel), Some(h)) => sel.head = h,
_ => view.selection = None,
}
}
}
}
@ -1785,7 +1918,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
if sr.contains(p) {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
let label = match &l.target {
LinkTarget::Page(n) => {
LinkTarget::Page(n, _) => {
crate::i18n::fmt(tl!("Go to page {p}"), &[("p", info.pages.get(*n).map(|p| p.label.as_str()).unwrap_or("?"))])
}
LinkTarget::Uri(u) => u.clone(),
@ -1798,8 +1931,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
}
}
}
// Annotation hover shows the comment, as Acrobat's popups do.
let gesturing = view.comments.gesture.is_some();
// Annotation hover shows the comment, as Acrobat's popups do; not while drawing
// freehand, where it would cover the next stroke (#429).
let freehand = matches!(tool, QuickTool::Comment(comments::CommentTool::Ink | comments::CommentTool::Eraser));
let gesturing = view.comments.gesture.is_some() || freehand;
for a in info.annotations.iter().filter(|a| a.page == i && a.in_reply_to.is_none() && !gesturing) {
let sr = xf.user_rect(info, i, a.rect);
if sr.contains(p) && hover_text.is_none() {
@ -1901,6 +2036,13 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
});
(wanted, visible_now)
});
view.single_edges = visible_pages.first().copied().filter(|_| view.layout == PageLayout::Single).map(|page| {
// At the top once the page's top edge shows where going to a page puts it (a gap
// below the window's top). Within a point, as for wheel paging, so layout rounding
// can't hide an edge.
let max_y = (out.content_size.y - out.inner_rect.height()).max(0.0);
(page, out.state.offset.y <= MARGIN - GAP + 1.0, out.state.offset.y >= max_y - 1.0)
});
view.auto_scroll.paint(ui, avail);
@ -2001,12 +2143,16 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
let field_props = field_props.then(|| view.prepare.selected.clone()).flatten();
match canvas_action {
Some(comments::CanvasAction::Edit(e)) => view.pending_edit = Some(*e),
Some(comments::CanvasAction::OpenComments) => app.right = Some(RightPanel::Comments),
// `choose_right_panel` would borrow all of `app` while `view` is held; same effect.
Some(comments::CanvasAction::OpenComments) => {
app.right = Some(RightPanel::Comments);
app.comments_panel_closed = false;
}
Some(comments::CanvasAction::Properties(p, i)) => open_props = Some((p, i)),
None => {}
}
match clicked_link {
Some(LinkTarget::Page(p)) => view.go_to_page(p),
Some(LinkTarget::Page(p, dest)) => view.go_to_dest(p, dest, info),
Some(LinkTarget::Uri(u)) => app.request_document_url(&u, crate::LinkOrigin::Link),
Some(LinkTarget::SetLayers { changes, preserve_rb }) => set_layers(app, index, &changes, preserve_rb),
Some(LinkTarget::Other(s)) => app.notify_fmt("{s} actions run in the JavaScript engine (M6)", &[("s", &s)]),
@ -2889,10 +3035,42 @@ fn organize_grid(
}
}
/// A rendered raster as texture data. Its words are premultiplied RGBA bytes, exactly
/// `Color32`s, so the renderer's buffer becomes the image without a copy on the UI thread.
fn texture_image(size: [usize; 2], pixels: pdfcraft_render::Pixels) -> egui::ColorImage {
match bytemuck::allocation::try_cast_vec::<u32, Color32>(pixels.into_words()) {
Ok(px) if px.len() == size[0].saturating_mul(size[1]) => egui::ColorImage::new(size, px),
Ok(px) => egui::ColorImage::from_rgba_premultiplied(size, bytemuck::cast_slice(&px)),
Err((_, words)) => egui::ColorImage::from_rgba_premultiplied(size, bytemuck::cast_slice(&words)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rendered_rasters_become_texture_images_without_a_copy() {
let pdf = b"%PDF-1.4
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 50] /Contents 4 0 R >> endobj
4 0 obj << /Length 35 >> stream
0 0 1 rg 10 10 30 20 re f
endstream endobj
trailer << /Root 1 0 R >>
%%EOF";
let mut r = pdfcraft_render::PageRenderer::new(Arc::new(pdf.to_vec()), Default::default());
let out = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.5, tag: 0 });
assert!(out.error.is_none(), "{:?}", out.error);
let size = [out.width as usize, out.height as usize];
let copied = egui::ColorImage::from_rgba_premultiplied(size, &out.rgba);
let at = out.rgba.as_ptr();
let img = texture_image(size, out.rgba);
assert_eq!(img, copied);
assert_eq!(img.pixels.as_ptr().cast::<u8>(), at, "the renderer's own buffer");
}
fn view(pages: usize, layout: PageLayout) -> DocView {
let info = pdfcraft_render::DocInfo {
pages: (0..pages)
@ -2984,4 +3162,47 @@ mod tests {
assert_eq!(PageLayout::try_parse(" Two-Up "), Some(PageLayout::TwoUp));
assert_eq!(PageLayout::try_parse("facing"), None);
}
#[test]
fn destinations_set_the_zoom_mode_and_the_point_to_show() {
let info = pdfcraft_render::DocInfo {
pages: (0..3)
.map(|_| pdfcraft_render::PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 })
.collect(),
..Default::default()
};
let mut v = view(3, PageLayout::Continuous);
v.fit = Fit::Width;
// No position (or a malformed one): the top of the page, zoom as it was.
v.go_to_dest(1, DestView::Top, &info);
assert_eq!((v.current, v.goto, v.goto_point, v.fit), (1, Some((1, 0.0)), None, Fit::Width));
// /XYZ with a null zoom keeps the zoom mode; its point is a quarter down page 3.
v.go_to_dest(2, DestView::Xyz { left: None, top: Some(300.0), zoom: None }, &info);
assert_eq!((v.current, v.fit, v.goto_point), (2, Fit::Width, Some((2, None, Some(0.25), vec2(SIDE, 0.0)))));
v.go_to_dest(0, DestView::Xyz { left: Some(150.0), top: None, zoom: Some(2.5) }, &info);
assert_eq!((v.fit, v.zoom, v.goto_point), (Fit::None, 2.5, Some((0, Some(0.5), None, vec2(SIDE, 0.0)))));
// A zoom beyond the view's range is clamped to it.
v.go_to_dest(0, DestView::Xyz { left: None, top: None, zoom: Some(1e30) }, &info);
assert_eq!((v.zoom, v.goto_point), (64.0, None));
v.go_to_dest(0, DestView::Fit, &info);
assert_eq!((v.fit, v.goto_point), (Fit::Page, None));
v.go_to_dest(0, DestView::FitH { top: Some(100.0) }, &info);
assert_eq!((v.fit, v.goto_point), (Fit::Width, Some((0, None, Some(0.75), vec2(SIDE, 0.0)))));
v.go_to_dest(0, DestView::FitV { left: Some(75.0) }, &info);
assert_eq!((v.fit, v.goto_point), (Fit::Height, Some((0, Some(0.25), None, vec2(SIDE, 0.0)))));
// /FitR: the zoom that fits a 100×50 pt rectangle in an 800×600 window, centred.
(v.viewport_w, v.viewport_h) = (800.0, 600.0);
v.go_to_dest(0, DestView::FitR { rect: [0.0, 300.0, 100.0, 350.0] }, &info);
assert_eq!(v.fit, Fit::None);
assert!((v.zoom - 8.0 / PT).abs() < 1e-4, "{}", v.zoom);
let (page, fx, fy, rel) = v.goto_point.expect("a point to show");
assert_eq!((page, rel), (0, vec2(400.0, 300.0)));
assert!((fx.unwrap_or(-1.0) - 50.0 / 300.0).abs() < 1e-4 && (fy.unwrap_or(-1.0) - 75.0 / 400.0).abs() < 1e-4, "{fx:?} {fy:?}");
// A page past the end goes to the last page; a page the document info lacks, to its top.
v.go_to_dest(usize::MAX, DestView::FitH { top: Some(100.0) }, &info);
assert_eq!((v.current, v.goto_point.map(|g| g.0)), (2, Some(2)));
let empty = pdfcraft_render::DocInfo::default();
v.go_to_dest(1, DestView::FitH { top: Some(100.0) }, &empty);
assert_eq!((v.current, v.goto_point), (1, None));
}
}

View File

@ -28,31 +28,59 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
app.active = None;
app.combine_tab.focused = false;
}
// Keep the two 28-point buttons, Discord's text/padding and three gaps outside the scrolling tabs.
let controls_width = ui.fonts_mut(|f| f.layout_no_wrap("Discord".into(), theme::medium(13.0), t.text).size().x) + 106.0;
let state = (app.active, app.views.len(), app.combine_showing());
let changed = ui.data_mut(|data| {
let id = ui.id().with("active_tab");
let changed = data.get_temp::<(Option<usize>, usize, bool)>(id) != Some(state);
data.insert_temp(id, state);
changed
});
let mut close = None;
for i in 0..app.views.len() {
let Some(doc) = app.session.get(app.views[i].id) else { continue };
let (name, dirty) = (doc.display_name(), doc.dirty);
if tab(ui, &t, "file-text", &name, dirty, app.active == Some(i), &mut close, i).clicked() {
app.active = Some(i);
}
}
if let Some(i) = close {
app.request_close_tab(i);
}
if app.combine_tab.open {
// After the document tabs; its index can't clash with theirs.
let mut close = None;
if tab(ui, &t, "files", tl!("Combine files"), false, app.combine_showing(), &mut close, usize::MAX).clicked() {
app.open_combine_tab();
}
if close.is_some() {
app.close_combine_tab();
}
}
ui.add_space(4.0);
if widgets::ghost_button(ui, "plus", tl!("Open")).on_hover_text(tl!("Open a PDF (⌘O)")).clicked() {
app.open_dialog();
}
ui.scope(|ui| {
ui.style_mut().always_scroll_the_only_direction = true;
egui::ScrollArea::horizontal()
.id_salt("document_tabs")
.max_width((ui.available_width() - controls_width).max(0.0))
.auto_shrink([false, true])
.show(ui, |ui| {
ui.horizontal_centered(|ui| {
for i in 0..app.views.len() {
let Some(doc) = app.session.get(app.views[i].id) else { continue };
let (name, dirty) = (doc.display_name(), doc.dirty);
let response = tab(ui, &t, "file-text", &name, dirty, app.active == Some(i), &mut close, i);
if changed && app.active == Some(i) && !app.combine_showing() {
response.scroll_to_me(Some(Align::Center));
}
if response.clicked() {
app.active = Some(i);
}
}
if let Some(i) = close {
app.request_close_tab(i);
}
if app.combine_tab.open {
// After the document tabs; its index can't clash with theirs.
let mut close = None;
let response = tab(ui, &t, "files", tl!("Combine files"), false, app.combine_showing(), &mut close, usize::MAX);
if changed && app.combine_showing() {
response.scroll_to_me(Some(Align::Center));
}
if response.clicked() {
app.open_combine_tab();
}
if close.is_some() {
app.close_combine_tab();
}
}
ui.add_space(4.0);
if widgets::ghost_button(ui, "plus", tl!("Open")).on_hover_text(tl!("Open a PDF (⌘O)")).clicked() {
app.open_dialog();
}
});
});
});
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
let (icon, label) = match app.theme_preference {
ThemePreference::System => ("settings", tl!("Use system setting")),
@ -240,7 +268,7 @@ fn main_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
(RightPanel::Compare, tl!("Compare")),
] {
if ui.radio(app.right == Some(p), tl!(label)).clicked() {
app.right = Some(p);
app.choose_right_panel(Some(p));
}
}
});
@ -278,7 +306,7 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
ui.painter().circle_filled(c, 3.0, t.accent);
}
if r.clicked() {
app.right = if selected { None } else { Some(panel) };
app.choose_right_panel(if selected { None } else { Some(panel) });
}
};
rail_button(ui, RightPanel::Comments, "message-square-text", "Comments", has_comments);

View File

@ -85,6 +85,8 @@ impl PdfCraftApp {
Some(p) => self.open_recent(&p),
None => self.notify_tr("No recent files"),
},
"file.clear_recent" if self.recent.is_empty() => self.notify_tr("No recent files"),
"file.clear_recent" => self.recent.clear(),
"file.pin_folder" => self.pin_folder_dialog(),
"page.combine" => self.open_combine_tab(),
"file.save" => {
@ -180,13 +182,13 @@ impl PdfCraftApp {
"view.theme.system" => self.set_theme_preference(ThemePreference::System),
"view.theme.light" => self.set_theme_preference(ThemePreference::Light),
"view.theme.dark" => self.set_theme_preference(ThemePreference::Dark),
"comment.list" => self.right = Some(RightPanel::Comments),
"comment.list" => self.choose_right_panel(Some(RightPanel::Comments)),
tool if crate::comments::CommentTool::from_command(tool).is_some() => {
let Some(tool) = crate::comments::CommentTool::from_command(tool) else { return false };
self.comment_prefs.group_tool[tool.group()] = tool;
self.quick_tool = crate::QuickTool::Comment(tool);
// Acrobat opens the Comments panel with the commenting tools.
if self.right.is_none() {
// Acrobat opens the Comments panel with the commenting tools, unless the user closed it.
if self.right.is_none() && !self.comments_panel_closed {
self.right = Some(RightPanel::Comments);
}
// A text selection made before picking a markup tool is marked right away.
@ -464,7 +466,10 @@ impl PdfCraftApp {
if let Some(i) = active {
self.views[i].forms.focus = None;
}
self.notify_fmt("Click on the page to add a {tool}, or drag to set its size", &[("tool", &tl!(tool.label()).to_lowercase())]);
self.notify_fmt(
"Click on the page to add a {tool}, or drag to set its size",
&[("tool", &crate::i18n::in_sentence(tl!(tool.label())))],
);
}
"sign.fill.signature.remove" => self.signature = None,
"sign.fill.initials.remove" => self.initials = None,
@ -570,13 +575,15 @@ pub(crate) fn registry_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui, menu: &str
let label = commands::current_label(spec, &app.session, app.active_ids().map(|(_, id)| id));
let label = crate::i18n::menu_label(spec.id, &label);
// Open Recent is a submenu of the live recent list, not one action: disabled while the
// list is empty, otherwise each entry opens its file (or focuses the tab showing it).
// list is empty, otherwise each entry opens its file (or focuses the tab showing it), and
// Clear Recent Files at the foot empties the list (#430).
if spec.id == "file.open_recent" {
if app.recent.is_empty() {
ui.add_enabled(false, egui::Button::new(label));
continue;
}
let mut open: Option<String> = None;
let mut clear = false;
ui.menu_button(label, |ui| {
for r in &app.recent {
if ui.button(&r.name).on_hover_text(&r.path).clicked() {
@ -584,11 +591,20 @@ pub(crate) fn registry_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui, menu: &str
ui.close();
}
}
ui.separator();
if ui.button(tl!("Clear Recent Files")).clicked() {
clear = true;
ui.close();
}
});
if let Some(p) = open {
app.open_recent(&p);
ui.close();
}
if clear {
app.execute("file.clear_recent");
ui.close();
}
continue;
}
let shortcut = spec.shortcut.map(|s| s.label(mac)).unwrap_or_default();

View File

@ -3,7 +3,7 @@
//! subject, modified) and Review History (status changes), with Acrobat's Locked box.
use egui::{Align, Layout};
use pdfcraft_engine::{CommentProps, Edit, NoteIcon};
use pdfcraft_engine::{CommentProps, Edit, LineEnding, NoteIcon};
use crate::comments::swatch_grid;
use crate::theme::{self, Tokens};
@ -85,7 +85,7 @@ impl PdfCraftApp {
style.width = w;
}
self.comment_prefs.set_style(tool, style);
self.notify_fmt("New {tool} comments will look like this one", &[("tool", &tl!(tool.label()).to_lowercase())]);
self.notify_fmt("New {tool} comments will look like this one", &[("tool", &crate::i18n::in_sentence(tl!(tool.label())))]);
}
/// Open Comment Properties for the comment at `(page, index)` of the active document.
@ -108,8 +108,9 @@ pub fn edits(d: &PropsDraft) -> Vec<Edit> {
let color = (e.color != o.color).then_some(e.color).flatten();
let opacity = ((e.opacity - o.opacity).abs() > 1e-6).then_some(e.opacity);
let width = (e.width != o.width).then_some(e.width).flatten();
if e.restylable && (color.is_some() || opacity.is_some() || width.is_some()) {
out.push(Edit::StyleAnnotation { page: d.page, index: d.index, color, opacity, width });
let endings = (e.endings != o.endings).then(|| e.endings.clone()).flatten();
if e.restylable && (color.is_some() || opacity.is_some() || width.is_some() || endings.is_some()) {
out.push(Edit::StyleAnnotation { page: d.page, index: d.index, color, opacity, width, endings });
}
let author = (e.author != o.author).then(|| e.author.clone());
let subject = (e.subject != o.subject).then(|| e.subject.clone());
@ -188,6 +189,19 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo
ui.add(egui::Slider::new(w, 0.5..=12.0).step_by(0.5).suffix(" pt"));
ui.end_row();
}
if let Some(ends) = e.endings.as_mut() {
ui.label(tl!("Line ending"));
ui.horizontal(|ui| {
for (i, ending) in ends.iter_mut().enumerate() {
egui::ComboBox::from_id_salt(("line-ending", i)).selected_text(ending.name()).width(130.0).show_ui(ui, |ui| {
for style in LineEnding::ALL {
ui.selectable_value(ending, style, style.name());
}
});
}
});
ui.end_row();
}
});
});
}

View File

@ -13,7 +13,7 @@
//! applied by the app, so each change is one undo step.
use egui::{Color32, CornerRadius, Pos2, Rect, Sense, Stroke, pos2, vec2};
use pdfcraft_engine::{Edit, Markup, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, Style};
use pdfcraft_engine::{Edit, LineEnding, Markup, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, Style};
use pdfcraft_render::{Annotation, DocInfo};
use crate::canvas::{DocView, PageXform};
@ -200,14 +200,14 @@ impl CommentTool {
Shape::TextMarkup { kind: self.markup().unwrap_or(Markup::Highlight), quads: Vec::new() }
}
Self::Ink => Shape::Ink { strokes: Vec::new() },
Self::Line => Shape::Line { from: [0.0; 2], to: [0.0; 2], arrow: false },
Self::Arrow => Shape::Line { from: [0.0; 2], to: [0.0; 2], arrow: true },
Self::Line => Shape::Line { from: [0.0; 2], to: [0.0; 2], start: LineEnding::None, end: LineEnding::None },
Self::Arrow => Shape::Line { from: [0.0; 2], to: [0.0; 2], start: LineEnding::None, end: LineEnding::OpenArrow },
Self::Rectangle => Shape::Rectangle { rect: [0.0; 4] },
Self::Oval => Shape::Oval { rect: [0.0; 4] },
Self::Polygon => Shape::Polygon { vertices: Vec::new(), cloud: false },
Self::Cloud => Shape::Polygon { vertices: Vec::new(), cloud: true },
Self::PolyLine => Shape::PolyLine { vertices: Vec::new() },
Self::Callout => Shape::Callout { rect: [0.0; 4], knee: [0.0; 2], point: [0.0; 2], font_size: 10.0 },
Self::PolyLine => Shape::PolyLine { vertices: Vec::new(), start: LineEnding::None, end: LineEnding::None },
Self::Callout => Shape::Callout { rect: [0.0; 4], knee: [0.0; 2], point: [0.0; 2], font_size: 10.0, ending: LineEnding::OpenArrow },
Self::Caret => Shape::Caret { rect: [0.0; 4] },
Self::ReplaceText => Shape::TextMarkup { kind: Markup::StrikeOut, quads: Vec::new() },
Self::Eraser => Shape::Ink { strokes: Vec::new() },
@ -808,11 +808,9 @@ fn select_input(
&& let Some(o) = origin
{
if let (Some(h), Some(a)) = (handle_at(o), selected) {
let aspect_ratio = view.signature_drag.aspect_ratio(cx.page, a.index).and_then(|ratio| {
let p = cx.info.pages.get(cx.page)?;
let rotation = u32::from(p.rotation) + u32::from(cx.xf.rot);
Some(if rotation % 180 == 0 { ratio } else { ratio.recip() })
});
// On screen, the image is also turned by the view's rotation.
let turned = !cx.xf.rot.is_multiple_of(180);
let aspect_ratio = view.signature_drag.aspect_ratio(cx.page, a.index).map(|ratio| if turned { ratio.recip() } else { ratio });
cv.gesture = Some(Gesture::Resize { page: cx.page, index: a.index, handle: h, from: o, aspect_ratio });
consumed = true;
} else if let Some(a) = cx.hit(o)
@ -1010,13 +1008,20 @@ fn drawn_shape(tool: CommentTool, points: &[[f64; 2]]) -> Option<Shape> {
let big = rect[2] - rect[0] >= 2.0 && rect[3] - rect[1] >= 2.0;
match tool {
CommentTool::Ink if points.len() >= 2 => Some(Shape::Ink { strokes: vec![points.to_vec()] }),
CommentTool::Line | CommentTool::Arrow if far => Some(Shape::Line { from: first, to: last, arrow: tool == CommentTool::Arrow }),
CommentTool::Line | CommentTool::Arrow if far => Some(Shape::Line {
from: first,
to: last,
start: LineEnding::None,
end: if tool == CommentTool::Arrow { LineEnding::OpenArrow } else { LineEnding::None },
}),
CommentTool::Rectangle if big => Some(Shape::Rectangle { rect }),
CommentTool::Oval if big => Some(Shape::Oval { rect }),
CommentTool::Polygon | CommentTool::Cloud if points.len() >= 3 => {
Some(Shape::Polygon { vertices: points.to_vec(), cloud: tool == CommentTool::Cloud })
}
CommentTool::PolyLine if points.len() >= 2 => Some(Shape::PolyLine { vertices: points.to_vec() }),
CommentTool::PolyLine if points.len() >= 2 => {
Some(Shape::PolyLine { vertices: points.to_vec(), start: LineEnding::None, end: LineEnding::None })
}
_ => None,
}
}
@ -1076,7 +1081,7 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo,
ui.set_width(260.0);
ui.horizontal(|ui| {
ui.label(egui::RichText::new(&prefs.author).font(theme::semibold(13.0)));
ui.label(egui::RichText::new(title).font(theme::regular(11.5)).color(t.text_faint));
ui.label(egui::RichText::new(tl!(title)).font(theme::regular(11.5)).color(t.text_faint));
});
ui.add_space(6.0);
let hint = match c.kind {
@ -1146,7 +1151,7 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo,
(rect[0] + rect[2]) / 2.0
};
let knee = [(point[0] + side) / 2.0, (rect[1] + rect[3]) / 2.0];
Some(new_comment(&cx, CommentTool::Callout, Shape::Callout { rect, knee, point, font_size: 10.0 }, text))
Some(new_comment(&cx, CommentTool::Callout, Shape::Callout { rect, knee, point, font_size: 10.0, ending: LineEnding::OpenArrow }, text))
}
ComposerKind::Replace => {
view.comments.tool_done = true;
@ -1238,8 +1243,14 @@ pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo
});
ui.menu_button(tl!("Colour"), |ui| {
if let Some(c) = swatch_grid(ui, a.color.map(|c| c.map(f64::from))) {
action =
Some(CanvasAction::Edit(Box::new(Edit::StyleAnnotation { page, index, color: Some(c), opacity: None, width: None })));
action = Some(CanvasAction::Edit(Box::new(Edit::StyleAnnotation {
page,
index,
color: Some(c),
opacity: None,
width: None,
endings: None,
})));
ui.close();
}
});
@ -1406,7 +1417,10 @@ mod tests {
assert_eq!(drawn_shape(CommentTool::Rectangle, &[[0.0, 0.0], [1.0, 1.0]]), None);
assert_eq!(drawn_shape(CommentTool::Rectangle, &[[10.0, 0.0], [0.0, 10.0]]), Some(Shape::Rectangle { rect: [0.0, 0.0, 10.0, 10.0] }));
assert_eq!(drawn_shape(CommentTool::Ink, &[[0.0, 0.0]]), None);
assert!(matches!(drawn_shape(CommentTool::Arrow, &[[0.0, 0.0], [5.0, 0.0]]), Some(Shape::Line { arrow: true, .. })));
assert!(matches!(
drawn_shape(CommentTool::Arrow, &[[0.0, 0.0], [5.0, 0.0]]),
Some(Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. })
));
}
#[test]

View File

@ -11,6 +11,37 @@ use crate::canvas::{DocView, PageXform};
use crate::theme::Tokens;
use crate::widgets;
/// The colour of the warning under text the standard fonts can't draw.
const UNDRAWABLE: Color32 = Color32::from_rgb(0xD7, 0x37, 0x3F);
/// The first character of a draft that can't be drawn, as the engine decides it (it refuses
/// such text, #125): outside the standard fonts and not shaped with the Arabic face.
pub(crate) fn undrawable(d: &TextDraft, memo: &UndrawableMemo) -> Option<char> {
// Checked as `finish` will commit it: without trailing whitespace.
let t = AddedText { text: d.text.trim_end().to_string(), rect: d.rect, ..d.style.clone() };
if let Some((key, c)) = memo.borrow().as_ref()
&& *key == t
{
return *c;
}
let c = pdfcraft_engine::first_undrawable(&t);
*memo.borrow_mut() = Some((t, c));
c
}
/// The last draft [`undrawable`] checked and its answer: the check lays the text out (and shapes
/// Arabic), too slow to repeat every frame for long text, so it reruns only when the text or
/// anything that changes its layout does.
pub(crate) type UndrawableMemo = std::cell::RefCell<Option<(AddedText, Option<char>)>>;
/// The warning shown for text with `c` in it.
pub(crate) fn undrawable_message(c: char) -> String {
crate::i18n::fmt(
tl!("The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now."),
&[("c", &c.to_string()), ("code", &format!("U+{:04X}", u32::from(c)))],
)
}
const SELECT_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6);
/// The text being typed or retyped in place.
@ -41,6 +72,24 @@ pub struct ContentView {
/// Select the item an add creates (the page's item count before it).
pub select_added: Option<(usize, usize)>,
grab: Option<(usize, Grab, Pos2)>,
undrawable: UndrawableMemo,
}
impl ContentView {
/// The draft's first character the standard fonts can't draw. Such a draft is kept open, and
/// counts as unsaved work, until it is corrected or discarded.
pub(crate) fn blocked(&self) -> Option<char> {
self.draft.as_ref().and_then(|d| undrawable(d, &self.undrawable))
}
/// Keeps a blocked draft open and focused instead of finishing or replacing it.
pub(crate) fn hold_blocked(&mut self) -> bool {
let blocked = self.blocked().is_some();
if let Some(d) = self.draft.as_mut().filter(|_| blocked) {
d.focus = true;
}
blocked
}
}
/// Text style for new text (the panel's Format controls set it).
@ -151,6 +200,10 @@ pub(crate) fn page_input(
if adding_text && hit.is_none() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Text);
if resp.clicked() {
// Text that can't be drawn stays where it is being typed (#125).
if cv.hold_blocked() {
return true;
}
// A click elsewhere keeps the text being typed and starts a new box (#74). The click
// can reach the page before the editor sees it lose focus, so finish it here.
let typed = cv.draft.take().and_then(|d| finish(cv, d, added));
@ -195,6 +248,9 @@ pub(crate) fn page_input(
&& let Some((i, a)) = hit
&& let AddedContent::Text(t) = &a.content
{
if cv.hold_blocked() {
return true;
}
cv.draft = Some(TextDraft { page, index: Some(i), rect: t.rect, text: t.text.clone(), style: t.clone(), focus: true });
cv.selected = Some((page, i));
consumed = true;
@ -277,6 +333,8 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad
let zoom = xf.rect.width() / xf.pw.max(1.0);
let tokens = Tokens::get(ctx);
let (mut commit, mut cancel, mut done) = (false, false, false);
let mut undrawable = None;
let memo = &view.content.undrawable;
egui::Area::new(egui::Id::new(("added-text", view.id.0))).order(egui::Order::Foreground).fixed_pos(r.min).show(ctx, |ui| {
let Some(t) = view.content.draft.as_mut() else { return };
let [cr, cg, cb] = t.style.color.map(|v| (v.clamp(0.0, 1.0) * 255.0) as u8);
@ -315,12 +373,18 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad
cancel |= ui.input(|i| i.key_pressed(egui::Key::Escape));
commit = done || resp.lost_focus() && !on_buttons;
});
// Page text is drawn with the standard fonts; anything else would be saved as `?` (#125).
undrawable = self::undrawable(t, memo);
if let Some(c) = undrawable {
ui.label(egui::RichText::new(undrawable_message(c)).small().color(UNDRAWABLE));
}
});
if cancel {
view.content.draft = None;
return (None, false);
}
if !commit {
// Text that can't be drawn stays in the editor, with the warning, rather than being lost.
if !commit || undrawable.is_some() {
return (None, false);
}
let edit = view.content.draft.take().and_then(|d| finish(&mut view.content, d, added));

View File

@ -1,7 +1,7 @@
//! Edit a PDF ▸ Edit text & images: boxes around the paragraphs and images already on the page.
//! Click a paragraph to edit it in place (⌘Enter or clicking away applies and rewraps it to the
//! box, Esc cancels); drag it to move it, or drag the handle on its right edge to rewrap it to a
//! new width. Click an image to select it: drag to move, drag a corner to resize (keeping
//! box, Esc cancels); drag it to move it, or drag the handle on its left or right edge to rewrap
//! it to a new width. Click an image to select it: drag to move, drag a corner to resize (keeping
//! its proportions), right-click for rotate, flip, replace, save and delete; Delete removes it.
use egui::{Color32, CornerRadius, FontFamily, FontId, Pos2, Rect, Stroke};
@ -170,19 +170,56 @@ pub struct ImageSelection {
drag: Option<(Pos2, Option<Pos2>)>,
}
/// A paragraph box being dragged: moved, or (from the handle on its right edge) resized.
/// What a drag on a paragraph box takes hold of.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Grip {
Move,
/// An edge (screen left or right), dragged to rewrap the paragraph.
Left,
Right,
}
/// A paragraph box being dragged: moved, or (from a handle on its left or right edge) resized.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct BlockDrag {
page: usize,
block: usize,
/// Where the drag started (screen).
start: Pos2,
resize: bool,
grip: Grip,
}
/// The resize handle on a paragraph box's right edge (screen).
fn width_handle(b: Rect) -> Rect {
Rect::from_center_size(Pos2::new(b.right(), b.center().y), egui::vec2(7.0, 14.0))
/// How far an edge grip reaches either side of a box's edge (screen pixels).
const EDGE_REACH: f32 = 6.0;
/// The rewrap handles drawn on a paragraph box's left and right edges (screen).
fn width_handles(b: Rect) -> [Rect; 2] {
let h = (b.height() * 0.5).clamp(14.0, 28.0);
[b.left(), b.right()].map(|x| Rect::from_center_size(Pos2::new(x, b.center().y), egui::vec2(7.0, h)))
}
/// The edge of `b` that `p` grabs: anywhere along its height, within reach of the line. A narrow
/// box keeps its middle for moving.
fn edge_at(b: Rect, p: Pos2) -> Option<Grip> {
if p.y < b.top() - EDGE_REACH || p.y > b.bottom() + EDGE_REACH {
return None;
}
let inside = EDGE_REACH.min(b.width() / 4.0);
let (dl, dr) = (p.x - b.left(), b.right() - p.x);
match (dl >= -EDGE_REACH && dl <= inside, dr >= -EDGE_REACH && dr <= inside) {
(true, true) if dl <= dr => Some(Grip::Left),
(_, true) => Some(Grip::Right),
(true, false) => Some(Grip::Left),
_ => None,
}
}
/// The topmost box under `p` and what it grabs there; edges only on an upright page.
fn grab_at(boxes: &[Rect], p: Pos2, upright: bool) -> Option<(usize, Grip)> {
boxes.iter().enumerate().rev().find_map(|(i, b)| match upright.then(|| edge_at(*b, p)).flatten() {
Some(g) => Some((i, g)),
None => b.contains(p).then_some((i, Grip::Move)),
})
}
/// What a right-click on a selected image asks for.
@ -361,25 +398,38 @@ pub(crate) fn page_input(
// The width handle needs the page upright (or upside down): on a quarter-turned page the
// screen's horizontal is the paragraph's vertical.
let upright = info.pages.get(page).is_some_and(|p| p.rotation % 180 == 0);
// A drag in progress: the box follows the pointer (or its right edge does); releasing applies it.
let draw_handles = |b: Rect| {
for h in width_handles(b) {
painter.rect(h, CornerRadius::same(2), Color32::WHITE, Stroke::new(1.0, ACCENT), egui::StrokeKind::Middle);
}
};
// A drag in progress: the box follows the pointer (or the grabbed edge does); releasing applies it.
if let Some(d) = view.block_drag.filter(|d| d.page == page)
&& let (Some(b), Some(l)) = (boxes.get(d.block).copied(), lines.get(d.block))
{
let p = ui.input(|i| i.pointer.interact_pos()).unwrap_or(d.start);
let preview = if d.resize {
Rect::from_min_max(b.min, Pos2::new((b.right() + p.x - d.start.x).max(b.left() + 12.0), b.max.y))
} else {
b.translate(p - d.start)
let dx = p.x - d.start.x;
let preview = match d.grip {
Grip::Move => b.translate(p - d.start),
Grip::Left => Rect::from_min_max(Pos2::new((b.left() + dx).min(b.right() - 12.0), b.min.y), b.max),
Grip::Right => Rect::from_min_max(b.min, Pos2::new((b.right() + dx).max(b.left() + 12.0), b.max.y)),
};
painter.rect_stroke(preview, CornerRadius::same(2), Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside);
ui.ctx().set_cursor_icon(if d.resize { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::Grabbing });
let resizing = d.grip != Grip::Move;
if resizing {
draw_handles(preview);
}
ui.ctx().set_cursor_icon(if resizing { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::Grabbing });
if resp.drag_stopped() || !ui.input(|i| i.pointer.any_down()) {
view.block_drag = None;
let (from, to) = (user_box(xf, info, page, b), user_box(xf, info, page, preview));
let style = if d.resize {
// The new width in user space: the box's change, added to the paragraph's own.
let style = if resizing {
// The new width in user space: the box's change, added to the paragraph's own. A
// moved left side (in user space; on an upside-down page that's the screen's right
// edge) moves the paragraph with it.
let width = (l.rect[2] - l.rect[0]) + (to[2] - to[0]) - (from[2] - from[0]);
pdfcraft_engine::BlockStyle { width: Some(width), ..Default::default() }
let shift = to[0] - from[0];
pdfcraft_engine::BlockStyle { width: Some(width), offset: (shift != 0.0).then_some([shift, 0.0]), ..Default::default() }
} else {
pdfcraft_engine::BlockStyle { offset: Some([to[0] - from[0], to[1] - from[1]]), ..Default::default() }
};
@ -389,26 +439,28 @@ pub(crate) fn page_input(
}
return true;
}
let Some(p) = ui.input(|i| i.pointer.hover_pos()).filter(|p| xf.rect.contains(*p)) else { return false };
let Some(hit) = boxes.iter().rposition(|b| b.contains(p) || (upright && width_handle(*b).contains(p))) else { return false };
if active != Some(hit) {
painter.rect_stroke(boxes[hit], CornerRadius::same(2), Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside);
}
let on_handle = upright && active.is_none() && width_handle(boxes[hit]).contains(p);
if upright && active.is_none() {
painter.rect(width_handle(boxes[hit]), CornerRadius::same(2), Color32::WHITE, Stroke::new(1.0, ACCENT), egui::StrokeKind::Middle);
}
ui.ctx().set_cursor_icon(if on_handle { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::Text });
// Dragging a box (not while a paragraph is open for typing) moves it; from the handle, resizes it.
// Dragging a box (not while a paragraph is open for typing) moves it; from an edge, resizes it.
// What it grabs comes from where the button went down, not where the pointer is now: egui
// only calls it a drag once the pointer has moved a few pixels (or been held a moment), and a
// quick flick has left the edge by then.
if resp.drag_started()
&& active.is_none()
&& let Some(o) = ui.input(|i| i.pointer.press_origin())
&& let Some(block) = boxes.iter().rposition(|b| b.contains(o) || (upright && width_handle(*b).contains(o)))
&& let Some((block, grip)) = grab_at(&boxes, o, upright)
{
let resize = upright && width_handle(boxes[block]).contains(o);
view.block_drag = Some(BlockDrag { page, block, start: o, resize });
view.block_drag = Some(BlockDrag { page, block, start: o, grip });
return true;
}
let Some(p) = ui.input(|i| i.pointer.hover_pos()).filter(|p| xf.rect.contains(*p)) else { return false };
let Some((hit, grip)) = grab_at(&boxes, p, upright) else { return false };
if active != Some(hit) {
painter.rect_stroke(boxes[hit], CornerRadius::same(2), Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside);
}
let on_edge = active.is_none() && grip != Grip::Move;
if upright && active.is_none() {
draw_handles(boxes[hit]);
}
ui.ctx().set_cursor_icon(if on_edge { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::Text });
if resp.clicked() {
let l = &lines[hit];
// Screen pixels per point, from the box's width.

View File

@ -52,11 +52,27 @@ impl PdfCraftApp {
}
Edit::InsertBlankPage { at, .. } => view.select_pages(&[at.min(info.pages.len() - 1)]),
Edit::DeletePages { .. } => view.select_pages(&[]),
// A stroke drawn with the pen stays unselected, so the selection box and
// author popup don't sit over the next stroke (#429).
Edit::AddAnnotation(a)
if matches!(a.shape, pdfcraft_engine::Shape::Ink { .. })
&& self.quick_tool == crate::QuickTool::Comment(crate::comments::CommentTool::Ink) =>
{
view.comments.selected = None;
}
Edit::AddAnnotation(a) => {
// Select the new comment (appended last among the page's comments).
let newest = info.annotations.iter().filter(|x| x.page == a.page && x.in_reply_to.is_none()).map(|x| x.index).max();
view.comments.selected = newest.map(|n| (a.page, n));
view.comments.reveal = true;
// A new highlight opens its note for typing straight away, as in Acrobat,
// unless a note typed into another card is still unsaved.
if let (pdfcraft_engine::Shape::TextMarkup { kind: pdfcraft_engine::Markup::Highlight, .. }, Some(n)) = (&a.shape, newest)
&& a.contents.is_empty()
&& view.comments.editing.as_ref().is_none_or(|(_, _, text)| text.is_empty())
{
view.comments.editing = Some((a.page, n, String::new()));
}
}
Edit::DeleteAnnotation { .. } => view.comments.selected = None,
_ => {}
@ -251,6 +267,21 @@ impl PdfCraftApp {
/// brought forward first, so the commit and any field scripts act on that document.
pub(crate) fn commit_typing_in(&mut self, id: pdfcraft_engine::DocId) -> bool {
let Some(i) = self.views.iter().position(|v| v.id == id) else { return true };
// Added text the standard fonts can't draw can't be saved: show it rather than drop it.
// Its page is brought into view, so the text, the warning and Discard are on screen.
let blocked = self.views.get_mut(i).and_then(|v| {
let c = v.content.blocked()?;
v.content.hold_blocked();
if let Some(page) = v.content.draft.as_ref().map(|d| d.page) {
v.go_to_page(page);
}
Some(c)
});
if let Some(c) = blocked {
self.active = Some(i);
self.notify(crate::content_ui::undrawable_message(c));
return false;
}
if self.active != Some(i) {
let typing = self.views.get(i).is_some_and(|v| v.pending_edit.is_some() || v.forms.focus.is_some());
if !typing {
@ -265,7 +296,10 @@ impl PdfCraftApp {
pub(crate) fn has_unsaved_work(&self, index: usize) -> bool {
let Some(v) = self.views.get(index) else { return false };
let Some(doc) = self.session.get(v.id) else { return false };
doc.dirty || v.pending_edit.is_some() || v.forms.focus.as_ref().is_some_and(|f| crate::forms_ui::draft_edit(f, &doc.form).is_some())
doc.dirty
|| v.pending_edit.is_some()
|| v.content.blocked().is_some()
|| v.forms.focus.as_ref().is_some_and(|f| crate::forms_ui::draft_edit(f, &doc.form).is_some())
}
/// Save the active document. Returns `true` if it was written.
@ -419,7 +453,13 @@ impl PdfCraftApp {
if let Some(i) = self.active
&& let Some(d) = self.session.get(id)
{
self.views[i].document_changed(&d.info);
let view = &mut self.views[i];
view.document_changed(&d.info);
// Revert discards this source's uncommitted typing as well as model edits.
// Otherwise a later Save could write a rejected draft back into the file.
view.forms.focus = None;
view.forms.committed = None;
view.pending_edit = None;
}
self.notify_tr("Reverted to the last saved version");
}
@ -450,6 +490,14 @@ impl PdfCraftApp {
}
},
};
// Quitting closes the unsaved tabs one by one (and brings each forward to save it):
// remember what was open, and which tab was active, before the first one goes (#442).
if req == CloseRequest::Quit {
match choice {
Some(_) => self.note_quit_session(),
None => self.forget_quit_session(),
}
}
match choice {
None => {} // cancelled: nothing closes
Some(false) => self.close_and_continue(ctx, index, req),
@ -788,3 +836,95 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]
mod revert_draft_tests {
use super::*;
use crate::forms_ui::Focus;
use pdfcraft_engine::FieldValue;
fn focus(text: &str) -> Focus {
Focus {
name: "name".into(),
widget: 0,
text: text.into(),
picked: Vec::new(),
request_focus: false,
select_all: false,
calendar: None,
calendar_rect: None,
}
}
fn app() -> PdfCraftApp {
let mut app = PdfCraftApp::new();
app.open_bytes("source.pdf", None, include_bytes!("../tests/data/form.pdf").to_vec()).unwrap();
app
}
fn queue_draft(app: &mut PdfCraftApp, index: usize, text: &str) {
app.views[index].forms.focus = Some(focus(text));
let form = app.session.get(app.views[index].id).unwrap().form.clone();
crate::forms_ui::commit(&mut app.views[index], &form);
assert!(app.views[index].forms.committed.is_some() && app.views[index].pending_edit.is_some());
}
#[test]
fn successful_revert_clears_focused_or_queued_drafts_only_on_its_document() {
for queued in [false, true] {
let mut app = app();
let source = app.views[0].id;
assert!(app.apply_edit(Edit::SetFieldValue { name: "name".into(), value: FieldValue::Text("Committed change".into()) }));
if queued {
queue_draft(&mut app, 0, "Queued source draft");
} else {
app.views[0].forms.focus = Some(focus("Focused source draft"));
}
app.open_bytes("other.pdf", None, include_bytes!("../tests/data/form.pdf").to_vec()).unwrap();
let other = app.views[1].id;
assert!(app.apply_edit(Edit::SetFieldValue { name: "name".into(), value: FieldValue::Text("Other committed value".into()) }));
queue_draft(&mut app, 1, "Other queued draft");
app.views[1].forms.focus = Some(focus("Other focused draft"));
let other_bytes = app.session.get(other).unwrap().bytes.clone();
let other_generation = app.session.get(other).unwrap().edit_generation();
let source_generation = app.session.get(source).unwrap().edit_generation();
app.active = Some(0);
app.revert_active();
let reverted = app.session.get(source).unwrap();
assert!(!reverted.dirty);
assert_eq!(reverted.edit_generation(), source_generation + 1);
assert!(reverted.form.iter().find(|field| field.name == "name").unwrap().value.is_empty());
assert!(app.views[0].forms.focus.is_none() && app.views[0].forms.committed.is_none() && app.views[0].pending_edit.is_none());
assert!(!app.has_unsaved_work(0));
assert_eq!(app.views[1].forms.focus.as_ref().unwrap().text, "Other focused draft");
assert_eq!(app.views[1].forms.committed.as_ref().unwrap().text, "Other queued draft");
assert!(
matches!(&app.views[1].pending_edit, Some(Edit::SetFieldValue { name, value: FieldValue::Text(text) }) if name == "name" && text == "Other queued draft")
);
let untouched = app.session.get(other).unwrap();
assert_eq!(untouched.bytes, other_bytes);
assert_eq!(untouched.edit_generation(), other_generation);
assert!(untouched.dirty && app.has_unsaved_work(1));
assert_eq!(app.active_ids(), Some((0, source)));
}
}
#[test]
fn failed_revert_preserves_focused_and_queued_drafts() {
let mut app = app();
let source = app.views[0].id;
queue_draft(&mut app, 0, "Queued draft");
app.views[0].forms.focus = Some(focus("Focused draft"));
// Exercise the ordinary NoDocument error boundary without a corrupt fixture.
// This verifies UI failure preservation, not other engine refresh failures.
app.session.close(source);
app.revert_active();
assert_eq!(app.views[0].forms.focus.as_ref().unwrap().text, "Focused draft");
assert_eq!(app.views[0].forms.committed.as_ref().unwrap().text, "Queued draft");
assert!(
matches!(&app.views[0].pending_edit, Some(Edit::SetFieldValue { name, value: FieldValue::Text(text) }) if name == "name" && text == "Queued draft")
);
assert_eq!(app.active_ids(), Some((0, source)));
assert_eq!(app.toast.as_ref().unwrap().0, pdfcraft_engine::EditError::NoDocument.to_string());
}
}

View File

@ -5,7 +5,7 @@
use egui::{Color32, CornerRadius, Pos2, Sense, Stroke, pos2, vec2};
use pdfcraft_engine::{Edit, FillMark, NewAnnotation, Shape, SignatureImage, Style};
use pdfcraft_render::DocInfo;
use pdfcraft_render::{DocInfo, PageInfo};
use crate::canvas::{DocView, PageXform};
use crate::theme::Tokens;
@ -190,38 +190,50 @@ pub fn typed(page: usize, at: [f64; 2], text: &str, author: &str) -> Edit {
}
/// Place a saved signature (strokes normalised to a 0–1 box, y up) with its left edge at `at`,
/// 150 pt wide.
pub fn signature_at(page: usize, at: [f64; 2], strokes: &[Vec<[f32; 2]>], author: &str) -> Option<Edit> {
/// 150 pt wide. Left, centred and upright are as displayed on a page turned by `rotation` (its
/// `/Rotate`), so the strokes are turned back into user space.
pub fn signature_at(page: usize, at: [f64; 2], strokes: &[Vec<[f32; 2]>], rotation: i64, author: &str) -> Option<Edit> {
let w = 150.0;
let (min_y, max_y) = strokes.iter().flatten().fold((f32::MAX, f32::MIN), |(a, b), p| (a.min(p[1]), b.max(p[1])));
if !min_y.is_finite() {
return None;
}
let h = f64::from(max_y - min_y).max(0.05) * w;
// Displayed right and up as user-space unit vectors (the identity on an unturned page).
let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(rotation, [0.0; 4]);
let strokes: Vec<Vec<[f64; 2]>> = strokes
.iter()
.filter(|s| !s.is_empty())
.map(|s| s.iter().map(|p| [at[0] + f64::from(p[0]) * w, at[1] - h / 2.0 + f64::from(p[1] - min_y) * w]).collect())
.map(|s| {
s.iter()
.map(|p| {
let (dx, dy) = (f64::from(p[0]) * w, f64::from(p[1] - min_y) * w - h / 2.0);
[at[0] + a * dx + c * dy, at[1] + b * dx + d * dy]
})
.collect()
})
.collect();
(!strokes.is_empty()).then(|| new(page, Shape::Signature { strokes }, String::new(), author))
}
/// Place typed text in the script font with its left edge at `at`, `height` points tall.
pub fn typed_signature_at(page: usize, at: [f64; 2], text: &str, height: f64, author: &str) -> Option<Edit> {
pdfcraft_engine::typed_signature_shape(at, text, height).map(|shape| new(page, shape, String::new(), author))
/// Place typed text in the script font with its left edge at `at`, `height` points tall, upright
/// as displayed on a page turned by `rotation`.
pub fn typed_signature_at(page: usize, at: [f64; 2], text: &str, height: f64, rotation: i64, author: &str) -> Option<Edit> {
pdfcraft_engine::typed_signature_shape(at, text, height, rotation).map(|shape| new(page, shape, String::new(), author))
}
/// Place a saved signature or initials.
pub fn place(page: usize, at: [f64; 2], sig: &SavedSig, initials: bool, author: &str) -> Option<Edit> {
/// Place a saved signature or initials, upright as `info`'s page is displayed.
pub fn place(page: usize, info: &PageInfo, at: [f64; 2], sig: &SavedSig, initials: bool, author: &str) -> Option<Edit> {
let rotation = i64::from(info.rotation);
match sig {
SavedSig::Drawn(strokes) => signature_at(page, at, strokes, author),
SavedSig::Image(image) => image.edit(page, at, initials, author),
SavedSig::Drawn(strokes) => signature_at(page, at, strokes, rotation, author),
SavedSig::Image(image) => image.edit(page, info, at, initials, author),
SavedSig::Typed(text) => {
let [left, bottom, right, top] = pdfcraft_engine::script_outline(text).bounds();
let height = if initials { 24.0_f64 } else { 32.0_f64 };
// Keep long names within the same placement width as drawn signatures.
let height = height.min(150.0 * (top - bottom).max(0.1) / (right - left).max(0.01));
typed_signature_at(page, at, text, height, author)
typed_signature_at(page, at, text, height, rotation, author)
}
}
}
@ -373,6 +385,7 @@ pub(crate) fn page_input(
if !resp.contains_pointer() || !xf.rect.contains(pointer) {
return None;
}
let p = info.pages.get(page)?;
let at = to_user(xf, info, page, pointer);
let saved = match tool {
FillTool::Signature => signature,
@ -380,9 +393,9 @@ pub(crate) fn page_input(
_ => None,
};
if let Some(SavedSig::Image(image)) = saved {
if let Some(rect) = image.rect(at, tool == FillTool::Initials) {
if let Some(rect) = image.rect(p, at, tool == FillTool::Initials) {
let tex = image_texture(ui, image, preview);
xf.paint_user_image(ui.painter(), tex, info, page, rect, Color32::WHITE);
xf.paint_user_image(ui.painter(), tex, p, rect, i64::from(p.rotation), Color32::WHITE);
ui.ctx().set_cursor_icon(egui::CursorIcon::None);
}
} else {
@ -401,11 +414,11 @@ pub(crate) fn page_input(
Some(FillAction::Edit(Box::new(typed(page, [at[0], at[1] + TEXT_SIZE * 0.6], &format!("{m}/{d}/{y}"), author))))
}
FillTool::Signature => match signature {
Some(s) => place(page, at, s, false, author).map(|e| FillAction::Signature(Box::new(e))),
Some(s) => place(page, p, at, s, false, author).map(|e| FillAction::Signature(Box::new(e))),
None => Some(FillAction::CreateSignature),
},
FillTool::Initials => match initials {
Some(s) => place(page, at, s, true, author).map(|e| FillAction::Signature(Box::new(e))),
Some(s) => place(page, p, at, s, true, author).map(|e| FillAction::Signature(Box::new(e))),
None => Some(FillAction::CreateInitials),
},
mark => {

View File

@ -364,11 +364,12 @@ pub(crate) fn section(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
18.0,
egui::Color32::from_rgb(0xE0, 0x3E, 0x3E),
);
ui.painter().text(rect.min + vec2(62.0, 19.0), Align2::LEFT_CENTER, crate::bidi::visual(&f.name), theme::medium(13.0), t.text);
let size = crate::panels::human_size(usize::try_from(f.size).unwrap_or(usize::MAX));
let when = f.modified.map(crate::combine_ui::ago).unwrap_or_default();
let detail = if when.is_empty() { size } else { format!("{when} · {size}") };
ui.painter().text(rect.right_center() - vec2(12.0, 0.0), Align2::RIGHT_CENTER, detail, theme::regular(12.0), t.text_muted);
let detail = ui.painter().text(rect.right_center() - vec2(12.0, 0.0), Align2::RIGHT_CENTER, detail, theme::regular(12.0), t.text_muted);
let width = detail.left() - 16.0 - (rect.left() + 62.0);
widgets::row_text(ui, rect.min + vec2(62.0, 19.0), crate::bidi::visual(&f.name), theme::medium(13.0), t.text, width);
if resp.on_hover_text(&f.path).clicked() {
open = Some(f.path.clone());
}

View File

@ -226,7 +226,11 @@ fn calendar(ctx: &egui::Context, view: &mut DocView, field: egui::Rect, fmt: &st
.selectable_label(selected, text)
.on_hover_text(crate::i18n::fmt(
tl!("{month} {day}, {y}"),
&[("month", tl!(MONTHS[(m - 1) as usize])), ("day", &day.to_string()), ("y", &y.to_string())],
&[
("month", tl_ctx!("calendar date", MONTHS[(m - 1) as usize])),
("day", &day.to_string()),
("y", &y.to_string()),
],
))
.clicked()
{

View File

@ -60,13 +60,22 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
ui.painter().rect(rect, CornerRadius::same(10), fill, Stroke::new(1.0, t.divider), egui::StrokeKind::Inside);
let color = egui::Color32::from_rgb(g.hue[0], g.hue[1], g.hue[2]);
icons::paint(ui, Rect::from_min_size(rect.min + vec2(14.0, 14.0), vec2(22.0, 22.0)), g.icon, 21.0, color);
ui.painter().text(rect.min + vec2(44.0, 25.0), Align2::LEFT_CENTER, tl!(g.label), theme::semibold(13.5), t.text);
let mut title_job = egui::text::LayoutJob::simple_singleline(tl!(g.label).to_owned(), theme::semibold(13.5), t.text);
title_job.wrap =
egui::text::TextWrapping { max_width: rect.width() - 58.0, max_rows: 1, break_anywhere: true, ..Default::default() };
let title = ui.fonts_mut(|f| f.layout_job(title_job));
let title_elided = title.elided;
ui.painter().galley(rect.min + vec2(44.0, 25.0 - title.size().y * 0.5), title, t.text);
let blurb = g
.sections
.first()
.map(|s| s.items.iter().take(3).map(|i| tl!(i.label)).collect::<Vec<_>>().join(" · "))
.unwrap_or_default();
let galley = ui.fonts_mut(|f| f.layout(blurb, theme::regular(11.5), t.text_muted, rect.width() - 28.0));
// Fixed-height cards must leave room for their action in every language.
let mut job = egui::text::LayoutJob::simple(blurb.clone(), theme::regular(11.5), t.text_muted, rect.width() - 28.0);
job.wrap.max_rows = 2;
let galley = ui.fonts_mut(|f| f.layout_job(job));
let blurb_elided = galley.elided;
ui.painter().galley(rect.min + vec2(14.0, 46.0), galley, t.text_muted);
ui.painter().text(
rect.left_bottom() + vec2(14.0, -14.0),
@ -75,6 +84,7 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
theme::medium(12.0),
t.accent_text,
);
let resp = if title_elided || blurb_elided { resp.on_hover_text(format!("{}\n{blurb}", tl!(g.label))) } else { resp };
if resp.clicked() {
app.left = LeftPanel::Tool(g.id);
app.left_open = true;
@ -104,17 +114,42 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
}
ui.add_space(26.0);
ui.label(egui::RichText::new(tl!("Recent")).font(theme::semibold(17.0)));
let mut clear = false;
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tl!("Recent")).font(theme::semibold(17.0)));
if !app.recent.is_empty() {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let resp = widgets::ghost_button(ui, "trash-2", tl!("Clear"));
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Clear Recent Files")));
clear = resp.on_hover_text(tl!("Clear Recent Files")).clicked();
});
}
});
ui.add_space(8.0);
if app.recent.is_empty() {
ui.label(egui::RichText::new(tl!("Files you open in PdfCraft appear here. Drop a PDF anywhere to open it.")).color(t.text_muted));
}
let mut open = None;
let mut remove = None;
for r in &app.recent {
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &r.name));
if resp.hovered() {
// The row, not its response: the pointer stays "in" the row over its remove button.
let hovered = ui.rect_contains_pointer(rect);
if hovered {
ui.painter().rect_filled(rect, CornerRadius::same(8), t.hover);
// Remove just this file from the list (#430); the file itself is untouched.
let x_rect = Rect::from_center_size(rect.right_center() - vec2(26.0, 0.0), vec2(28.0, 28.0));
let x = ui.interact(x_rect, ui.id().with(("remove-recent", &r.path)), Sense::click());
let label = crate::i18n::fmt(tl!("Remove {name} from Recent"), &[("name", &r.name)]);
x.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &label));
if x.hovered() {
ui.painter().rect_filled(x_rect, CornerRadius::same(6), t.pressed);
}
icons::paint(ui, x_rect.shrink(6.0), "x", 16.0, t.icon);
if x.on_hover_text(tl!("Remove from Recent")).clicked() {
remove = Some(r.path.clone());
}
}
icons::paint(
ui,
@ -123,22 +158,31 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
22.0,
egui::Color32::from_rgb(0xE0, 0x3E, 0x3E),
);
ui.painter().text(rect.min + vec2(46.0, 15.0), Align2::LEFT_CENTER, crate::bidi::visual(&r.name), theme::medium(13.5), t.text);
ui.painter().text(rect.min + vec2(46.0, 32.0), Align2::LEFT_CENTER, crate::bidi::visual(&r.path), theme::regular(11.0), t.text_faint);
ui.painter().text(
rect.right_center() - vec2(12.0, 0.0),
// Room on the right for the remove button.
let detail = ui.painter().text(
rect.right_center() - vec2(48.0, 0.0),
Align2::RIGHT_CENTER,
format!("{} {} · {}", r.pages, tl!("pages"), human_size(r.size)),
theme::regular(12.0),
t.text_muted,
);
if resp.clicked() {
// The name and path stop short of the page count; hovering shows the whole path.
let width = detail.left() - 16.0 - (rect.left() + 46.0);
widgets::row_text(ui, rect.min + vec2(46.0, 15.0), crate::bidi::visual(&r.name), theme::medium(13.5), t.text, width);
widgets::row_text(ui, rect.min + vec2(46.0, 32.0), crate::bidi::visual(&r.path), theme::regular(11.0), t.text_faint, width);
if resp.on_hover_text(&r.path).clicked() {
open = Some(r.path.clone());
}
}
if let Some(p) = open {
app.open_recent(&p);
}
if let Some(p) = remove {
app.recent.retain(|r| r.path != p);
}
if clear {
app.execute("file.clear_recent");
}
ui.add_space(20.0);
widgets::section_title(ui, tl!("Privacy"));
ui.label(
@ -148,3 +192,46 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
});
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn translated_card_description_leaves_room_for_the_action() {
let de = crate::i18n::Lang::from_code("de").unwrap();
crate::i18n::set_current(de);
let ctx = egui::Context::default();
ctx.set_fonts(theme::font_definitions());
let mut app = PdfCraftApp::new();
let blurb = catalog::group("form")
.unwrap()
.sections
.first()
.unwrap()
.items
.iter()
.take(3)
.map(|item| crate::i18n::tr(de, item.label))
.collect::<Vec<_>>()
.join(" · ");
let output = ctx
.run_ui(egui::RawInput { screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(1440.0, 900.0))), ..Default::default() }, |ui| {
show(&mut app, ui)
});
crate::i18n::set_current(crate::i18n::Lang::EN);
let text_shapes: Vec<_> =
output.shapes.iter().filter_map(|shape| if let egui::Shape::Text(text) = &shape.shape { Some(text) } else { None }).collect();
let description = text_shapes.iter().find(|text| text.galley.job.text == blurb).unwrap();
let action = text_shapes
.iter()
.find(|text| text.galley.job.text == crate::i18n::tr(de, "Use now") && (text.pos.x - description.pos.x).abs() < 0.1)
.unwrap();
assert!(description.galley.rows.len() <= 2);
assert!(
description.pos.y + description.galley.size().y + 4.0 <= action.pos.y,
"the translated description must leave a visible gap before its action"
);
output.drop_without_applying_deltas();
}
}

File diff suppressed because it is too large Load Diff

View File

@ -10,6 +10,7 @@
Menu Nabídka
File Soubor
Rotation: Otočení:
Edit Úpravy
Pages Stránky
View Zobrazení
@ -29,6 +30,10 @@
OK OK
Open… Otevřít…
Open Recent Otevřít nedávné soubory
Clear Recent Files Vymazat nedávné soubory
Remove from Recent Odebrat z nedávných souborů
Remove {name} from Recent Odebrat {name} z nedávných souborů
Clear Vymazat
Pin folder to Home… Připnout složku na úvodní stránku…
No recent files Žádné nedávné soubory
New blank PDF Nový prázdný dokument PDF
@ -108,6 +113,7 @@
Fit to width scrolling Plynulé posouvání s přizpůsobením šířce
Fit one full page Celá jedna stránka
Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. Používá se, když PDF nepožaduje rozložení ani zvětšení. Plynulé posouvání mezi stránkami neskáče.
Reopen the files that were open when PdfCraft last closed Znovu otevřít soubory, které byly otevřené při posledním zavření aplikace PdfCraft
Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. Vyberte obrázek PNG nebo JPEG (nejvýše 4 MiB). Průhlednost bude zachována.
Couldn't import the signature image: {e} Obrázek podpisu nelze importovat: {e}
Portable mode is off: {folder} can't be written ({error}). Settings are kept in your user folder instead. Přenosný režim je vypnutý: do složky {folder} nelze zapisovat ({error}). Nastavení se místo toho ukládá do vaší uživatelské složky.

Can't render this file because it contains an unexpected character in line 7 and column 81.

File diff suppressed because it is too large Load Diff

View File

@ -22,6 +22,9 @@
Name on new comments Nombre en los comentarios nuevos
Open… Abrir…
Open Recent Abrir recientes
Clear Recent Files Borrar archivos recientes
Remove from Recent Quitar de recientes
Remove {name} from Recent Quitar {name} de recientes
Pin folder to Home… Anclar carpeta en Inicio…
Folders Carpetas
Pin a folder… Anclar una carpeta…
@ -596,6 +599,7 @@
Bottom: Inferior:
Width: Ancho:
Height: Alto:
Rotation: Rotación:
Points from the page's bottom-left corner. Puntos desde la esquina inferior izquierda de la página.
Alignment: Alineación:
Default Value: Valor predeterminado:
@ -1986,6 +1990,7 @@
Default page display Presentación de página predeterminada
Default zoom Zoom predeterminado
Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. Se usa cuando un PDF no pide una presentación ni un zoom. El desplazamiento continuo nunca salta de página en página.
Reopen the files that were open when PdfCraft last closed Volver a abrir los archivos que estaban abiertos al cerrar PdfCraft por última vez
Created a PDF from {n} files Se creó un PDF a partir de {n} archivos
PDF, images and text PDF, imágenes y texto
Insert a file before page {label} Insertar un archivo antes de la página {label}
@ -1999,3 +2004,4 @@
Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. Elija una imagen PNG o JPEG (hasta 4 MiB). Se conserva la transparencia.
Couldn't import the signature image: {e} No se pudo importar la imagen de la firma: {e}
Go to page… Ir a la página…
The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. Las fuentes estándar no pueden mostrar «{c}» ({code}). Por ahora solo se pueden añadir como texto caracteres de Europa occidental.

Can't render this file because it contains an unexpected character in line 6 and column 60.

View File

@ -576,6 +576,7 @@
Bottom: Bas :
Width: Largeur :
Height: Hauteur :
Rotation: Rotation :
Points from the page's bottom-left corner. Points depuis le coin inférieur gauche de la page.
Alignment: Alignement :
Default Value: Valeur par défaut :
@ -894,6 +895,7 @@
This comment's appearance can't be changed yet. L'apparence de ce commentaire ne peut pas encore être modifiée.
Icon Icône
Thickness Épaisseur
Line ending Extrémité de ligne
Modified Modifié
No status has been set. Aucun état n'a été défini.
Compare Files Comparer des fichiers
@ -1909,6 +1911,7 @@
Default page display Affichage des pages par défaut
Default zoom Zoom par défaut
Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. Utilisé lorsqu'un PDF ne demande ni présentation ni zoom. Le défilement continu ne saute jamais d'une page à l'autre.
Reopen the files that were open when PdfCraft last closed Rouvrir les fichiers qui étaient ouverts lors de la dernière fermeture de PdfCraft
Show Afficher
Sort Trier
Reverse the order Inverser l'ordre
@ -2013,6 +2016,9 @@
Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. Choisissez une image PNG ou JPEG (jusqu’à 4 Mio). La transparence est conservée.
Couldn't import the signature image: {e} Impossible d’importer l’image de la signature : {e}
Open Recent Ouvrir un fichier récent
Clear Recent Files Effacer les fichiers récents
Remove from Recent Retirer des fichiers récents
Remove {name} from Recent Retirer {name} des fichiers récents
Pin folder to Home… Épingler un dossier à l'Accueil…
Folders Dossiers
Pin a folder… Épingler un dossier…
@ -2033,3 +2039,4 @@
Pinned {folder} to Home {folder} épinglé à l'Accueil
No recent files Aucun fichier récent
Go to page… Aller à la page…
The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. Les polices standard ne peuvent pas afficher « {c} » ({code}). Pour l'instant, seuls les caractères d'Europe occidentale peuvent être ajoutés comme texte.

Can't render this file because it contains an unexpected character in line 6 and column 39.

File diff suppressed because it is too large Load Diff

View File

@ -28,6 +28,9 @@
Name on new comments 新しい注釈の作成者名
Open… 開く…
Open Recent 最近のファイルを開く
Clear Recent Files 最近のファイルをクリア
Remove from Recent 最近のファイルから外す
Remove {name} from Recent {name} を最近のファイルから外す
Pin folder to Home… フォルダーをホームにピン留め…
Folders フォルダー
Pin a folder… フォルダーをピン留め…
@ -602,6 +605,7 @@
Bottom: 下:
Width: 幅:
Height: 高さ:
Rotation: 回転:
Points from the page's bottom-left corner. ページの左下隅からの距離(ポイント)。
Alignment: 配置:
Default Value: 既定値:
@ -1975,3 +1979,5 @@
Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. PNG または JPEG 画像を選択してください(最大 4 MiB)。透明度は保持されます。
Couldn't import the signature image: {e} 署名画像を読み込めませんでした: {e}
Go to page… ページへ移動…
Reopen the files that were open when PdfCraft last closed 前回 PdfCraft を閉じたときに開いていたファイルを再度開く
The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. 標準フォントでは「{c}」({code})を表示できません。現在、テキストとして追加できるのは西ヨーロッパの文字のみです。

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -86,8 +86,21 @@ fn plural_russian(n: u64) -> usize {
}
}
/// Ukrainian (CLDR `uk`, integer counts): one (1, 21), few (2–4, 22–24), many (0, 5–19).
/// Counts ending in 11–14 always take many, regardless of the last digit.
fn plural_ukrainian(n: u64) -> usize {
match n % 100 {
11..=14 => 2,
_ => match n % 10 {
1 => 0,
2..=4 => 1,
_ => 2,
},
}
}
/// The registry. English first: it is the fallback and the source language.
pub static LANGUAGES: [LangInfo; 10] = [
pub static LANGUAGES: [LangInfo; 14] = [
LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, catalog: OnceLock::new() },
LangInfo { code: "ja", name: "日本語", source: include_str!("ja.tsv"), plural: plural_none, catalog: OnceLock::new() },
// Simplified Chinese; `zh`, `zh-CN`, `zh-SG` and `zh-Hans-*` locales resolve here (see `candidates`).
@ -98,14 +111,22 @@ pub static LANGUAGES: [LangInfo; 10] = [
LangInfo { code: "cs", name: "Čeština", source: include_str!("cs.tsv"), plural: plural_cs, catalog: OnceLock::new() },
// Brazilian Portuguese; `pt`, `pt-BR` and `pt-PT` locales all resolve here (see `candidates`).
LangInfo { code: "pt-br", name: "Português (Brasil)", source: include_str!("pt-br.tsv"), plural: plural_pt, catalog: OnceLock::new() },
// German (informal "du"); every `de-*` locale (`de-DE`, `de-AT`, `de-CH` ...) resolves here.
LangInfo { code: "de", name: "Deutsch", source: include_str!("de.tsv"), plural: plural_one_other, catalog: OnceLock::new() },
// Spanish (European vocabulary); every `es-*` locale (`es-ES`, `es-MX`, `es-419` ...) resolves here.
LangInfo { code: "es", name: "Español", source: include_str!("es.tsv"), plural: plural_one_other, catalog: OnceLock::new() },
// French; every `fr-*` locale (`fr-FR`, `fr-CA`, `fr-BE` ...) resolves here.
LangInfo { code: "fr", name: "Français", source: include_str!("fr.tsv"), plural: plural_fr, catalog: OnceLock::new() },
// Russian; every `ru-*` locale (`ru-RU`, `ru-BY`, `ru-KZ` ...) resolves here.
LangInfo { code: "ru", name: "Русский", source: include_str!("ru.tsv"), plural: plural_russian, catalog: OnceLock::new() },
// Bulgarian; every `bg-*` locale (`bg-BG`) resolves here.
LangInfo { code: "bg", name: "Български", source: include_str!("bg.tsv"), plural: plural_one_other, catalog: OnceLock::new() },
// Telugu; every `te-*` locale (`te-IN`) resolves here.
LangInfo { code: "te", name: "తెలుగు", source: include_str!("te.tsv"), plural: plural_one_other, catalog: OnceLock::new() },
// Hungarian; every `hu-*` locale (`hu-HU`) resolves here.
LangInfo { code: "hu", name: "Magyar", source: include_str!("hu.tsv"), plural: plural_one_other, catalog: OnceLock::new() },
// Ukrainian; every `uk-*` locale (`uk-UA`, `uk_UA.UTF-8`) resolves here.
LangInfo { code: "uk", name: "Українська", source: include_str!("uk.tsv"), plural: plural_ukrainian, catalog: OnceLock::new() },
];
impl LangInfo {
@ -218,7 +239,9 @@ pub fn lang_from_tag(tag: &str) -> Option<Lang> {
/// The system language (cached). English when it can't be determined.
pub fn system_lang() -> Lang {
// Tests drive the UI by its English labels whatever the developer's locale is.
// Tests drive the UI by its English labels whatever the developer's locale is. This covers
// the unit tests; the integration tests link the library as it ships, where `cfg!(test)` is
// off, so each of their harnesses pins the `language` option to `en` instead.
if cfg!(test) {
return Lang::EN;
}
@ -229,7 +252,7 @@ pub fn system_lang() -> Lang {
#[cfg(not(target_arch = "wasm32"))]
fn detect_system_lang() -> Lang {
for var in ["LC_ALL", "LC_MESSAGES", "LANG"] {
if let Some(l) = std::env::var(var).ok().filter(|v| !v.is_empty()).and_then(|v| lang_from_tag(&v)) {
if let Some(l) = std::env::var(var).ok().and_then(|v| language_override(&v)) {
return l;
}
}
@ -249,6 +272,17 @@ fn detect_system_lang() -> Lang {
Lang::EN
}
/// A named locale can override the display language. On Windows, the neutral
/// C/POSIX locale inherited from a launcher says nothing about the user's UI language.
#[cfg(not(target_arch = "wasm32"))]
fn language_override(tag: &str) -> Option<Lang> {
let tag = tag.trim();
if cfg!(target_os = "windows") && matches!(candidates(tag).first().map(String::as_str), Some("c" | "posix")) {
return None;
}
lang_from_tag(tag)
}
/// The Windows display languages in preference order, one tag per line, queried once when Auto
/// first resolves. `sys-locale` asks Windows directly (`GetUserPreferredUILanguages`), so no
/// process is started.
@ -282,6 +316,12 @@ pub fn set_current(lang: Lang) {
}
/// The language the UI is drawn in.
/// `label` as it reads inside a sentence ("add a text box"): lowercased, except in languages
/// that capitalise nouns (German), where lowercasing would misspell it.
pub fn in_sentence(label: &str) -> std::borrow::Cow<'_, str> {
if current().code() == "de" { std::borrow::Cow::Borrowed(label) } else { std::borrow::Cow::Owned(label.to_lowercase()) }
}
pub fn current() -> Lang {
CURRENT.get()
}
@ -447,13 +487,31 @@ mod tests {
assert_eq!(first_supported("\r\n"), None);
}
#[cfg(target_os = "windows")]
#[test]
fn neutral_locale_does_not_override_display_language() {
let fr = Lang::from_code("fr").unwrap();
for tag in ["C", "C.UTF-8", "POSIX", "posix.UTF-8", "", " C.UTF-8 ", "unsupported"] {
assert_eq!(language_override(tag), None, "{tag}");
assert_eq!(language_override(tag).or_else(|| first_supported("unsupported-XY\nfr-FR\nen-US")), Some(fr));
}
assert_eq!(["C.UTF-8", "POSIX", "fr_FR.UTF-8"].into_iter().find_map(language_override), Some(fr));
assert_eq!(language_override("fr_FR.UTF-8"), Some(fr));
assert_eq!(language_override("en_US.UTF-8"), Some(Lang::EN));
assert_eq!(language_override("ja_JP.UTF-8"), Some(JA()));
}
#[cfg(target_os = "windows")]
#[test]
fn windows_display_language_query_returns_a_locale_tag() {
let tag = windows_ui_language().expect("Windows should report a display language");
let tag = tag.trim();
assert!(!tag.is_empty());
assert!(tag.split('-').all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_alphanumeric())));
// One tag per line, as `first_supported` reads them: a machine with several display
// languages reports them all, so each line is checked rather than the list as a whole.
let list = windows_ui_language().expect("Windows should report a display language");
let tags: Vec<&str> = list.lines().map(str::trim).filter(|t| !t.is_empty()).collect();
assert!(!tags.is_empty(), "no display language in {list:?}");
for tag in tags {
assert!(tag.split('-').all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_alphanumeric())), "not a locale tag: {tag:?}");
}
}
#[test]
@ -1112,6 +1170,133 @@ mod tests {
set_current(Lang::EN);
}
#[test]
fn german_is_registered() {
let de = Lang::from_code("de").expect("de registered");
assert_eq!(de.name(), "Deutsch");
assert_eq!(normalize_pref("DE"), Some("de"));
assert_eq!(lang_from_tag("de_DE.UTF-8"), Some(de));
assert_eq!(lang_from_tag("de-AT"), Some(de));
assert_eq!(lang_from_tag("de_CH"), Some(de));
assert_eq!(first_supported("de-DE\r\nen-US"), Some(de));
assert_eq!(tr(de, "File"), "Datei");
assert_eq!(tr(de, "Save as…"), "Speichern unter…");
assert_eq!(tr(de, "Bookmarks"), "Lesezeichen");
assert_eq!(tr(de, "Layers"), "Ebenen");
assert_eq!(tr(de, "Bericht des Nutzers.pdf"), "Bericht des Nutzers.pdf");
assert_eq!((0..=3).map(|n| (de.0.plural)(n)).collect::<Vec<_>>(), [1, 0, 1, 1]);
assert_eq!(trn(de, 0, "{n} page", "{n} pages"), "0 Seiten");
assert_eq!(trn(de, 1, "{n} page", "{n} pages"), "1 Seite");
assert_eq!(trn(de, 2, "{n} page", "{n} pages"), "2 Seiten");
assert_eq!(trn(de, 1, "{n} field", "{n} fields"), "1 Feld");
assert_eq!(trn(de, 2, "{n} field", "{n} fields"), "2 Felder");
let mut app = crate::PdfCraftApp::default();
app.set_option("language", "de").unwrap();
assert_eq!(app.language, "de");
let mut restored = crate::PdfCraftApp::default();
restored.restore(&app.persist());
assert_eq!(restored.language, "de");
}
/// German translates every registered command and every All tools group, section and item.
#[test]
fn german_covers_commands_and_catalogue() {
let de = Lang::from_code("de").expect("de registered");
for command in pdfcraft_engine::commands::COMMANDS {
assert!(has(de, command.label), "missing command: {}", command.label);
if let Some(menu) = command.menu {
assert!(has(de, menu), "missing menu: {menu}");
}
}
for group in pdfcraft_engine::catalog::TOOL_GROUPS {
assert!(has(de, group.label), "missing group: {}", group.label);
for section in group.sections {
assert!(has(de, section.title), "missing section: {}", section.title);
for item in section.items {
assert!(has(de, item.label), "missing item: {}", item.label);
}
}
}
}
/// New tl!("literal") labels must not silently fall back to English.
#[test]
fn german_covers_ui_literals() {
let de = Lang::from_code("de").expect("de registered");
let mut stack = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")];
let mut literals = std::collections::BTreeSet::new();
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(dir).expect("UI source directory") {
let path = entry.expect("UI source entry").path();
if path.is_dir() {
if path.file_name().is_some_and(|name| name != "i18n") {
stack.push(path);
}
} else if path.extension().is_some_and(|ext| ext == "rs") {
let source = std::fs::read_to_string(path).expect("UI source file").replace("\r\n", "\n");
let mut rest = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default();
while let Some((_, after)) = rest.split_once("tl!(\"") {
let mut escaped = false;
let end = after
.char_indices()
.find_map(|(i, c)| {
if c == '"' && !escaped {
return Some(i);
}
escaped = c == '\\' && !escaped;
None
})
.expect("closed tl! literal");
let (raw, tail) = after.split_at(end);
if tail.starts_with("\")") {
let label: String = serde_json::from_str(&format!("\"{raw}\"")).expect("UI literal escapes");
literals.insert(label);
}
rest = tail.strip_prefix('"').expect("closing quote");
}
}
}
}
assert!(literals.len() > 900, "source scan found only {} literals", literals.len());
let missing: Vec<_> = literals.iter().filter(|label| !has(de, label)).collect();
assert!(missing.is_empty(), "untranslated German UI literals: {missing:#?}");
}
#[test]
fn tool_names_inside_sentences_are_lowercased_except_in_german() {
set_current(Lang::EN);
assert_eq!(in_sentence("Text Box"), "text box");
set_current(Lang::from_code("de").expect("de registered"));
assert_eq!(in_sentence("Textfeld"), "Textfeld");
set_current(Lang::EN);
}
#[test]
fn german_certificate_subject_has_a_distinct_context() {
let de = Lang::from_code("de").expect("de registered");
assert_eq!(tr(de, "Subject"), "Thema");
assert_eq!(tr_ctx(de, "certificate", "Subject"), "Zertifikatsinhaber");
assert_eq!(tr_ctx(de, "certificate", "Issuer"), tr(de, "Issuer"));
assert_eq!(tr_ctx(Lang::EN, "certificate", "Subject"), "Subject");
}
#[test]
fn german_history_and_diagnostics_preserve_user_values() {
let de = Lang::from_code("de").expect("de registered");
set_current(de);
assert_eq!(command_label("Undo Insert pages from Rapport {n}.pdf"), "Seiten aus Rapport {n}.pdf einfügen rückgängig machen");
assert_eq!(command_label("Redo Fill in Contact {key}"), "Contact {key} ausfüllen wiederholen");
assert_eq!(action_label("Change Title"), "Titel ändern");
assert_eq!(action_label("Custom action {n}"), "Custom action {n}");
assert_eq!(
fmt(t("This page couldn't be displayed.\n{e}"), &[("e", "OS error {n}")]),
"Diese Seite konnte nicht angezeigt werden.\nOS error {n}"
);
assert_eq!(fmt(t("{n} pages selected"), &[("n", "3")]), "3 Seiten ausgewählt");
assert_eq!(tr(de, "CheckBox"), "Kontrollkästchen");
set_current(Lang::EN);
}
#[test]
fn russian_is_registered() {
let ru = Lang::from_code("ru").expect("ru registered");
@ -1223,6 +1408,426 @@ mod tests {
set_current(Lang::EN);
}
#[test]
fn bulgarian_is_registered() {
let bg = Lang::from_code("bg").expect("bg registered");
assert_eq!(bg.name(), "Български");
assert_eq!(normalize_pref("BG"), Some("bg"));
assert_eq!(lang_from_tag("bg_BG.UTF-8"), Some(bg));
assert_eq!(lang_from_tag("bg-BG"), Some(bg));
assert_eq!(first_supported("bg-BG\r\nen-US"), Some(bg));
assert_eq!(tr(bg, "File"), "Файл");
assert_eq!(tr(bg, "Save as…"), "Запиши като…");
assert_eq!(tr(bg, "Bookmarks"), "Отметки");
assert_eq!(tr(bg, "Layers"), "Слоеве");
assert_eq!(tr(bg, "Rapport de l'utilisateur.pdf"), "Rapport de l'utilisateur.pdf");
// one (1), other (0, 2+)
assert_eq!((0..=3).map(|n| (bg.0.plural)(n)).collect::<Vec<_>>(), [1, 0, 1, 1]);
assert_eq!(trn(bg, 0, "{n} page", "{n} pages"), "0 страници");
assert_eq!(trn(bg, 1, "{n} page", "{n} pages"), "1 страница");
assert_eq!(trn(bg, 2, "{n} page", "{n} pages"), "2 страници");
assert_eq!(trn(bg, 21, "{n} page", "{n} pages"), "21 страници");
assert_eq!(trn(bg, 1, "{n} field", "{n} fields"), "1 поле");
assert_eq!(trn(bg, 5, "{n} field", "{n} fields"), "5 полета");
let mut app = crate::PdfCraftApp::default();
app.set_option("language", "bg").unwrap();
assert_eq!(app.language, "bg");
let mut restored = crate::PdfCraftApp::default();
restored.restore(&app.persist());
assert_eq!(restored.language, "bg");
}
/// Bulgarian translates every registered command and every All tools group, section and item.
#[test]
fn bulgarian_covers_commands_and_catalogue() {
let bg = Lang::from_code("bg").expect("bg registered");
for command in pdfcraft_engine::commands::COMMANDS {
assert!(has(bg, command.label), "missing command: {}", command.label);
if let Some(menu) = command.menu {
assert!(has(bg, menu), "missing menu: {menu}");
}
}
for group in pdfcraft_engine::catalog::TOOL_GROUPS {
assert!(has(bg, group.label), "missing group: {}", group.label);
for section in group.sections {
assert!(has(bg, section.title), "missing section: {}", section.title);
for item in section.items {
assert!(has(bg, item.label), "missing item: {}", item.label);
}
}
}
}
/// New tl!("literal") labels must not silently fall back to English.
#[test]
fn bulgarian_covers_ui_literals() {
let bg = Lang::from_code("bg").expect("bg registered");
let mut stack = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")];
let mut literals = std::collections::BTreeSet::new();
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(dir).expect("UI source directory") {
let path = entry.expect("UI source entry").path();
if path.is_dir() {
if path.file_name().is_some_and(|name| name != "i18n") {
stack.push(path);
}
} else if path.extension().is_some_and(|ext| ext == "rs") {
let source = std::fs::read_to_string(path).expect("UI source file").replace("\r\n", "\n");
let mut rest = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default();
while let Some((_, after)) = rest.split_once("tl!(\"") {
let mut escaped = false;
let end = after
.char_indices()
.find_map(|(i, c)| {
if c == '"' && !escaped {
return Some(i);
}
escaped = c == '\\' && !escaped;
None
})
.expect("closed tl! literal");
let (raw, tail) = after.split_at(end);
if tail.starts_with("\")") {
let label: String = serde_json::from_str(&format!("\"{raw}\"")).expect("UI literal escapes");
literals.insert(label);
}
rest = tail.strip_prefix('"').expect("closing quote");
}
}
}
}
assert!(literals.len() > 900, "source scan found only {} literals", literals.len());
let missing: Vec<_> = literals.iter().filter(|label| !has(bg, label)).collect();
assert!(missing.is_empty(), "untranslated Bulgarian UI literals: {missing:#?}");
}
#[test]
fn bulgarian_diagnostics_preserve_user_values() {
let bg = Lang::from_code("bg").expect("bg registered");
set_current(bg);
assert_eq!(action_label("Custom action {n}"), "Custom action {n}");
assert_eq!(fmt(t("{n} pages selected"), &[("n", "3")]), "Избрани страници: 3");
assert_eq!(tr(bg, "CheckBox"), "Квадратче за отметка");
set_current(Lang::EN);
}
#[test]
fn hungarian_is_registered() {
let hu = Lang::from_code("hu").expect("hu registered");
assert_eq!(hu.name(), "Magyar");
assert_eq!(normalize_pref("HU"), Some("hu"));
assert_eq!(lang_from_tag("hu_HU.UTF-8"), Some(hu));
assert_eq!(lang_from_tag("hu-HU"), Some(hu));
assert_eq!(first_supported("hu-HU\r\nen-US"), Some(hu));
assert_eq!(tr(hu, "File"), "Fájl");
assert_eq!(tr(hu, "Save as…"), "Mentés másként…");
assert_eq!(tr(hu, "Bookmarks"), "Könyvjelzők");
assert_eq!(tr(hu, "Layers"), "Rétegek");
assert_eq!(tr(hu, "Rapport de l'utilisateur.pdf"), "Rapport de l'utilisateur.pdf");
assert_eq!(tr_ctx(hu, "signature pad", "Type"), "Gépelés");
assert_eq!(tr_ctx(hu, "action wizard", "Start"), "Indítás");
// one (1), other (0, 2+)
assert_eq!((0..=3).map(|n| (hu.0.plural)(n)).collect::<Vec<_>>(), [1, 0, 1, 1]);
assert_eq!(trn(hu, 0, "{n} page", "{n} pages"), "0 oldal");
assert_eq!(trn(hu, 1, "{n} page", "{n} pages"), "1 oldal");
assert_eq!(trn(hu, 2, "{n} page", "{n} pages"), "2 oldal");
assert_eq!(trn(hu, 1, "{n} field", "{n} fields"), "1 mező");
assert_eq!(trn(hu, 5, "{n} field", "{n} fields"), "5 mező");
let mut app = crate::PdfCraftApp::default();
app.set_option("language", "hu").unwrap();
assert_eq!(app.language, "hu");
let mut restored = crate::PdfCraftApp::default();
restored.restore(&app.persist());
assert_eq!(restored.language, "hu");
}
/// Hungarian translates every registered command and every All tools group, section and item.
#[test]
fn hungarian_covers_commands_and_catalogue() {
let hu = Lang::from_code("hu").expect("hu registered");
for command in pdfcraft_engine::commands::COMMANDS {
assert!(has(hu, command.label), "missing command: {}", command.label);
if let Some(menu) = command.menu {
assert!(has(hu, menu), "missing menu: {menu}");
}
}
for group in pdfcraft_engine::catalog::TOOL_GROUPS {
assert!(has(hu, group.label), "missing group: {}", group.label);
for section in group.sections {
assert!(has(hu, section.title), "missing section: {}", section.title);
for item in section.items {
assert!(has(hu, item.label), "missing item: {}", item.label);
}
}
}
}
/// New tl!("literal") labels must not silently fall back to English.
#[test]
fn hungarian_covers_ui_literals() {
let hu = Lang::from_code("hu").expect("hu registered");
let mut stack = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")];
let mut literals = std::collections::BTreeSet::new();
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(dir).expect("UI source directory") {
let path = entry.expect("UI source entry").path();
if path.is_dir() {
if path.file_name().is_some_and(|name| name != "i18n") {
stack.push(path);
}
} else if path.extension().is_some_and(|ext| ext == "rs") {
let source = std::fs::read_to_string(path).expect("UI source file").replace("\r\n", "\n");
let mut rest = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default();
while let Some((_, after)) = rest.split_once("tl!(\"") {
let mut escaped = false;
let end = after
.char_indices()
.find_map(|(i, c)| {
if c == '"' && !escaped {
return Some(i);
}
escaped = c == '\\' && !escaped;
None
})
.expect("closed tl! literal");
let (raw, tail) = after.split_at(end);
if tail.starts_with("\")") {
let label: String = serde_json::from_str(&format!("\"{raw}\"")).expect("UI literal escapes");
literals.insert(label);
}
rest = tail.strip_prefix('"').expect("closing quote");
}
}
}
}
assert!(literals.len() > 900, "source scan found only {} literals", literals.len());
let missing: Vec<_> = literals.iter().filter(|label| !has(hu, label)).collect();
assert!(missing.is_empty(), "untranslated Hungarian UI literals: {missing:#?}");
}
#[test]
fn hungarian_history_and_diagnostics_preserve_user_values() {
let hu = Lang::from_code("hu").expect("hu registered");
set_current(hu);
assert_eq!(command_label("Undo Insert pages from Rapport {n}.pdf"), "Oldalak beszúrása ebből: Rapport {n}.pdf visszavonása");
assert_eq!(command_label("Redo Fill in Contact {key}"), "Contact {key} kitöltése megismétlése");
assert_eq!(action_label("Change Title"), "Cím módosítása");
assert_eq!(action_label("Custom action {n}"), "Custom action {n}");
assert_eq!(
fmt(t("This page couldn't be displayed.\n{e}"), &[("e", "OS error {n}")]),
"Ezt az oldalt nem sikerült megjeleníteni.\nOS error {n}"
);
assert_eq!(fmt(t("{n} pages selected"), &[("n", "3")]), "3 oldal kijelölve");
assert_eq!(tr(hu, "CheckBox"), "Jelölőnégyzet");
set_current(Lang::EN);
}
#[test]
fn ukrainian_is_registered_and_persists() {
let uk = Lang::from_code("uk").expect("uk registered");
assert_eq!(uk.name(), "Українська");
assert_eq!(normalize_pref("UK"), Some("uk"));
for tag in ["uk", "uk-UA", "uk_UA.UTF-8", "UK-ua", "uk_UA@variant"] {
assert_eq!(lang_from_tag(tag), Some(uk), "{tag}");
}
assert_eq!(first_supported("uk-UA\r\nen-US"), Some(uk));
assert_eq!(first_supported("(\n \"uk-UA\",\n \"en-US\"\n)\n"), Some(uk));
assert_eq!(tr(uk, "File"), "Файл");
assert_eq!(tr(uk, "Save as…"), "Зберегти як…");
assert_eq!(tr(uk, "Bookmarks"), "Закладки");
assert_eq!(tr(uk, "Layers"), "Шари");
assert_eq!(tr(uk, "Звіт {n}.pdf"), "Звіт {n}.pdf");
assert_eq!(tr_ctx(uk, "comment menu", "Edit"), "Редагувати");
assert_eq!(tr_ctx(uk, "signature pad", "Type"), "Ввести");
assert_eq!(tr_ctx(uk, "action wizard", "Start"), "Почати");
let mut app = crate::PdfCraftApp::default();
app.set_option("language", "UK").unwrap();
assert_eq!(app.language, "uk");
let mut restored = crate::PdfCraftApp::default();
restored.restore(&app.persist());
assert_eq!(restored.language, "uk");
assert!(restored.set_option("language", "xx").is_err());
assert_eq!(restored.language, "uk");
}
#[test]
fn ukrainian_integer_plurals_cover_teens_and_large_counts() {
let uk = Lang::from_code("uk").expect("uk registered");
for (counts, page, field) in [
(vec![1, 21, 31, 101, 121, 1001], "сторінка", "поле"),
(vec![2, 3, 4, 22, 23, 24, 102, 104, 122, 1002], "сторінки", "поля"),
(vec![0, 5, 10, 11, 12, 13, 14, 19, 20, 25, 100, 110, 111, 112, 114, 1000, u64::MAX], "сторінок", "полів"),
] {
for n in counts {
assert_eq!(trn(uk, n, "{n} page", "{n} pages"), format!("{n} {page}"));
assert_eq!(trn(uk, n, "{n} field", "{n} fields"), format!("{n} {field}"));
}
}
}
#[test]
fn ukrainian_calendar_uses_month_case_for_dates() {
let uk = Lang::from_code("uk").expect("uk registered");
for (source, standalone, date) in [
("January", "Січень", "січня"),
("February", "Лютий", "лютого"),
("March", "Березень", "березня"),
("April", "Квітень", "квітня"),
("May", "Травень", "травня"),
("June", "Червень", "червня"),
("July", "Липень", "липня"),
("August", "Серпень", "серпня"),
("September", "Вересень", "вересня"),
("October", "Жовтень", "жовтня"),
("November", "Листопад", "листопада"),
("December", "Грудень", "грудня"),
] {
assert_eq!(tr(uk, source), standalone);
let month = tr_ctx(uk, "calendar date", source);
assert_eq!(month, date);
assert_eq!(fmt(tr(uk, "{month} {day}, {y}"), &[("month", month), ("day", "9"), ("y", "2026")]), format!("9 {date} 2026"));
// Catalogs without this context retain their existing month translation.
assert_eq!(tr_ctx(Lang::EN, "calendar date", source), source);
assert_eq!(tr_ctx(JA(), "calendar date", source), tr(JA(), source));
}
}
/// Preserve only product/format names, abbreviations and templates with no English words.
#[test]
fn ukrainian_catalog_does_not_leave_english_ui_text() {
let uk = Lang::from_code("uk").expect("uk registered");
let (entries, errors) = parse_entries(uk.0.source, uk.0.plural_forms());
assert!(errors.is_empty(), "{errors:?}");
let keep = [
"OK",
"PDF/A…",
"Microsoft Word (.docx)",
"PostScript / EPS",
// Key-cap names, printed in English on Ukrainian keyboards.
"Home / End",
"JavaScript",
"ZIP",
"{n} {kind}",
"{rule} - {status}",
"{field}: {e}",
"`{command}` {when}",
"{0}: {1}",
"{month} {y}",
"+LOC",
"−LOC",
"ΔLOC",
"+Bin",
"−Bin",
];
let unchanged: Vec<_> = entries.iter().filter(|e| e.source == e.translation && !keep.contains(&e.source.as_str())).collect();
assert!(unchanged.is_empty(), "untranslated Ukrainian catalog entries: {unchanged:#?}");
}
/// Ukrainian translates every registered command and every All tools label.
#[test]
fn ukrainian_covers_commands_and_catalogue() {
let uk = Lang::from_code("uk").expect("uk registered");
for command in pdfcraft_engine::commands::COMMANDS {
assert!(has(uk, command.label), "missing command: {}", command.label);
if let Some(menu) = command.menu {
assert!(has(uk, menu), "missing menu: {menu}");
}
}
for group in pdfcraft_engine::catalog::TOOL_GROUPS {
assert!(has(uk, group.label), "missing group: {}", group.label);
for section in group.sections {
assert!(has(uk, section.title), "missing section: {}", section.title);
for item in section.items {
assert!(has(uk, item.label), "missing item: {}", item.label);
}
}
}
}
/// Check direct lookups and multiline literals as well as ordinary tl!("literal") calls,
/// following the complete-catalog check for Simplified Chinese.
#[test]
fn ukrainian_covers_ui_literals() {
let uk = Lang::from_code("uk").expect("uk registered");
let mut stack = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")];
let mut literals = std::collections::BTreeSet::new();
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(dir).expect("UI source directory") {
let path = entry.expect("UI source entry").path();
if path.is_dir() {
if path.file_name().is_some_and(|name| name != "i18n") {
stack.push(path);
}
} else if path.extension().is_some_and(|ext| ext == "rs") {
let source = std::fs::read_to_string(path).expect("UI source file").replace("\r\n", "\n").replace("crate::i18n::t(", "tl!(");
let mut rest = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default();
while let Some((_, after)) = rest.split_once("tl!(") {
let after = after.trim_start();
let Some(after) = after.strip_prefix('"') else {
rest = after;
continue;
};
let mut escaped = false;
let end = after
.char_indices()
.find_map(|(i, c)| {
if c == '"' && !escaped {
return Some(i);
}
escaped = c == '\\' && !escaped;
None
})
.expect("closed tl! literal");
let (raw, tail) = after.split_at(end);
let closing = tail.strip_prefix('"').expect("closing quote").trim_start();
let closing = closing.strip_prefix(',').unwrap_or(closing).trim_start();
if closing.starts_with(')') {
let label: String = serde_json::from_str(&format!("\"{raw}\"")).expect("UI literal escapes");
literals.insert(label);
}
rest = tail.strip_prefix('"').expect("closing quote");
}
}
}
}
assert!(literals.len() > 900, "source scan found only {} literals", literals.len());
let missing: Vec<_> = literals.iter().filter(|label| !has(uk, label)).collect();
assert!(missing.is_empty(), "untranslated Ukrainian UI literals: {missing:#?}");
}
#[test]
fn ukrainian_history_and_diagnostics_preserve_user_values() {
let uk = Lang::from_code("uk").expect("uk registered");
set_current(uk);
assert_eq!(command_label("Undo Insert pages from Звіт {n}.pdf"), "Скасувати: Вставити сторінки з Звіт {n}.pdf");
assert_eq!(command_label("Redo Fill in Контакт {key}"), "Повторити: Заповнити Контакт {key}");
assert_eq!(action_label("Change Title"), "Змінити: Заголовок");
assert_eq!(action_label("Custom action {n}"), "Custom action {n}");
assert_eq!(fmt(t("This page couldn't be displayed.\n{e}"), &[("e", "OS error {n}")]), "Не вдалося відобразити цю сторінку.\nOS error {n}");
assert_eq!(fmt(t("{n} pages selected"), &[("n", "3")]), "Вибрано сторінок: 3");
assert_eq!(fmt(t("{n} contributors · {c} commits"), &[("n", "2"), ("c", "1,234")]), "Учасників: 2 · Комітів: 1,234");
assert_eq!(tr(uk, "CheckBox"), "Прапорець");
let contributor = crate::credits::Contributor {
login: "reader{n}",
display_name: Some("Save"),
real_name: None,
prs: 2,
commits: 3,
lines_added: 10,
lines_deleted: 4,
binary_added: 1,
binary_deleted: 0,
first_commit: "2026-10-01T00:00:00Z",
last_commit: "2026-10-09T00:00:00Z",
};
assert_eq!(
contributor.summary(),
"@reader{n}: PR: 2, комітів: 3, +10 / −4 рядків (Δ +6), +1 / −0 бінарних ресурсів, 2026-10-01 – 2026-10-09"
);
assert_eq!(contributor.name(crate::credits::NameMode::DisplayName), "Save");
set_current(Lang::EN);
}
/// Every bundled catalog is well-formed and consistent with its sources.
#[test]
fn bundled_catalogs_are_consistent() {

View File

@ -10,6 +10,7 @@
Menu Menu
File Arquivo
Rotation: Rotação:
Edit Editar
Pages Páginas
View Exibir
@ -21,6 +22,10 @@
Name on new comments Nome nos novos comentários
Open… Abrir…
Open Recent Abrir recentes
Clear Recent Files Limpar arquivos recentes
Remove from Recent Remover dos recentes
Remove {name} from Recent Remover {name} dos recentes
Clear Limpar
Pin folder to Home… Fixar pasta no Início…
No recent files Nenhum arquivo recente
New blank PDF Novo PDF em branco
@ -66,3 +71,4 @@
Couldn't import the signature image: {e} Não foi possível importar a imagem da assinatura: {e}
Portable mode is off: {folder} can't be written ({error}). Settings are kept in your user folder instead. O modo portátil está desativado: não é possível gravar em {folder} ({error}). As configurações ficam na sua pasta de usuário.
Go to page… Ir para a página…
Reopen the files that were open when PdfCraft last closed Reabrir os arquivos que estavam abertos quando o PdfCraft foi fechado pela última vez

Can't render this file because it contains an unexpected character in line 9 and column 16.

View File

@ -21,6 +21,9 @@
Name on new comments Имя в новых комментариях
Open… Открыть…
Open Recent Открыть недавние
Clear Recent Files Очистить недавние файлы
Remove from Recent Убрать из недавних
Remove {name} from Recent Убрать {name} из недавних
Pin folder to Home… Закрепить папку на главной…
Folders Папки
Pin a folder… Закрепить папку…
@ -594,6 +597,7 @@
Bottom: Снизу:
Width: Ширина:
Height: Высота:
Rotation: Поворот:
Points from the page's bottom-left corner. Точки от левого нижнего угла страницы.
Alignment: Выравнивание:
Default Value: Значение по умолчанию:
@ -912,6 +916,7 @@
This comment's appearance can't be changed yet. Внешний вид этого комментария пока нельзя изменить.
Icon Значок
Thickness Толщина
Line ending Окончание линии
Modified Изменено
No status has been set. Статус не установлен.
Compare Files Сравнить файлы
@ -1972,6 +1977,7 @@
Default page display Отображение страниц по умолчанию
Default zoom Масштаб по умолчанию
Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. Используется, когда PDF не запрашивает макет или масштаб. Непрерывная прокрутка никогда не привязывается между страницами.
Reopen the files that were open when PdfCraft last closed Повторно открывать файлы, которые были открыты при последнем закрытии PdfCraft
Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. Выберите изображение PNG или JPEG (до 4 МиБ). Прозрачность сохраняется.
Couldn't import the signature image: {e} Не удалось импортировать изображение подписи: {e}
@ -1994,3 +2000,4 @@
Created a PDF from {n} files; left out: {names} PDF создан из файлов: {n}; пропущены: {names}
Created a PDF from the first {n} files PDF создан из первых файлов: {n}
Go to page… Перейти на страницу…
The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. Стандартные шрифты не могут отобразить «{c}» ({code}). Пока в виде текста можно добавлять только западноевропейские символы.

Can't render this file because it contains an unexpected character in line 9 and column 16.

View File

@ -582,6 +582,7 @@ comment menu Edit సవరించు
Bottom: కింద:
Width: వెడల్పు:
Height: ఎత్తు:
Rotation: భ్రమణం:
Points from the page's bottom-left corner. పేజీ కింది ఎడమ మూల నుండి పాయింట్‌లలో కొలుస్తారు.
Alignment: అమరిక:
Default Value: డిఫాల్ట్ విలువ:
@ -1911,6 +1912,7 @@ action wizard Start ప్రారంభించు
Default page display డిఫాల్ట్ పేజీ ప్రదర్శన
Default zoom డిఫాల్ట్ జూమ్
Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. PDF ఏ లేఅవుట్‌ను గానీ జూమ్‌ను గానీ కోరనప్పుడు ఉపయోగించబడుతుంది. నిరంతర స్క్రోలింగ్ పేజీల మధ్య ఎప్పుడూ స్నాప్ అవ్వదు.
Reopen the files that were open when PdfCraft last closed PdfCraftను చివరిసారి మూసివేసినప్పుడు తెరిచి ఉన్న ఫైల్‌లను మళ్లీ తెరువు
About పరిచయం
Contributors సహకారులు
Models మోడల్‌లు
@ -2016,6 +2018,9 @@ action wizard Start ప్రారంభించు
Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. PNG లేదా JPEG చిత్రాన్ని ఎంచుకోండి (గరిష్ఠంగా 4 MiB). పారదర్శకత అలాగే ఉంటుంది.
Couldn't import the signature image: {e} సంతకం చిత్రాన్ని దిగుమతి చేయడం సాధ్యం కాలేదు: {e}
Open Recent ఇటీవలివి తెరువు
Clear Recent Files ఇటీవలి ఫైళ్లను క్లియర్ చేయి
Remove from Recent ఇటీవలి ఫైళ్ల నుండి తీసివేయి
Remove {name} from Recent {name}ను ఇటీవలి ఫైళ్ల నుండి తీసివేయి
Pin folder to Home… ఫోల్డర్‌ను హోమ్‌కు పిన్ చేయి…
No recent files ఇటీవలి ఫైళ్లు లేవు
Go to page… పేజీకి వెళ్లు…

Can't render this file because it contains an unexpected character in line 13 and column 16.

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