Merge remote-tracking branch 'upstream/main' into fix/util-scand

# Conflicts:
#	crates/js/src/tests.rs
This commit is contained in:
Eric Kaden
2026-10-22 03:15:57 -04:00
328 changed files with 70012 additions and 2476 deletions
+1 -1
View File
@@ -123,7 +123,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: EmbarkStudios/cargo-deny-action@v2
- uses: EmbarkStudios/cargo-deny-action@3c6349835b2b7b196a839186cb8b78e02f7b5f25 # v2.1.1
with:
command: check
+36 -25
View File
@@ -4,30 +4,37 @@
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 (276)
## In this repository (288)
| Asset | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|
| `crates/ui-egui/src/i18n/el.tsv` | Greek interface translation catalog | Camthalion77 and contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels | Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/zh-hans.tsv` | Simplified Chinese 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 | Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/zh-hant.tsv` | Traditional Chinese (Taiwan) 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 | Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/cs.tsv` | Czech 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 | Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese interface translation catalog | André Cardoso (andrecards), PdfCraft contributors | MIT OR Apache-2.0 | Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors | 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/it.tsv` | Italian 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 | Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/pl.tsv` | Polish 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 | Polish 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/kk.tsv` | Kazakh interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources | Kazakh 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 |
| `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/el.tsv` | Greek interface translation catalog | Camthalion77 and contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/zh-hans.tsv` | Simplified Chinese 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/zh-hant.tsv` | Traditional Chinese (Taiwan) 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/cs.tsv` | Czech 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed | Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese interface translation catalog | André Cardoso (andrecards), PdfCraft contributors | MIT OR Apache-2.0 | Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed | 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; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/lo.tsv` | Lao interface translation catalog | @bounkirdni-2025 | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Lao 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/it.tsv` | Italian 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/pl.tsv` | Polish 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Polish 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/kk.tsv` | Kazakh interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Kazakh 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/ckb.tsv` | Central Kurdish (Sorani) 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; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/hi.tsv` | Hindi 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; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Hindi UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/id.tsv` | Indonesian 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; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Indonesian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/ko.tsv` | Korean 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; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Korean UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/ro.tsv` | Romanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Romanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/fi.tsv` | Finnish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Finnish 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 |
@@ -223,6 +230,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `vendor/hayro-interpret/assets/FoxitSymbol.pfb` | FoxitSymbol (standard-14 substitute font) | Foxit Software Inc. / PDFium Authors | BSD-3-Clause | https://pdfium.googlesource.com/pdfium (via hayro-interpret 0.7.0 assets/) | Renders non-embedded standard-14 fonts (vendored hayro-interpret) |
| `vendor/hayro-interpret/assets/CGATS001Compat-v2-micro.icc` | CGATS001Compat v2 micro ICC profile | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | CC0-1.0 | https://github.com/saucecontrol/Compact-ICC-Profiles | Default CMYK colour conversion (vendored hayro-interpret) |
| `crates/fonts/src/std14_metrics.rs` | Standard-14 font advance widths and encodings (as Rust tables) | Adobe (Core14 AFM metrics, PDF specification encodings); tables generated by the PdfCraft contributors | MIT OR Apache-2.0 | ISO 32000-2 Annex D and the Adobe Core14 AFM metrics. Generated by joining the published WinAnsi, Symbol and ZapfDingbats encodings (code to glyph name) with the per-glyph advance widths, then spot-checked against the specification by hand in the tests of crates/fonts/src/std14.rs. Data only: the code that reads these tables lives in std14.rs, so that ordinary edits do not churn this file's hash. | Advance widths for the fourteen standard fonts, which a PDF may name without embedding. Non-visual data, not a typeface: the item AGENTS.md 1.1 permits, held first-party so generated appearance streams and the renderer agree. |
| `vendor/hayro-cmap/assets/cmaps.brotli` | Adobe CMap resources (predefined CJK CMaps) | Adobe | BSD-3-Clause | https://github.com/adobe-type-tools/cmap-resources (via hayro-cmap 0.1.0 assets/) | Character-code mapping tables required to read CJK PDFs. Non-visual data, allowed by AGENTS.md §1.1 |
| `docs/brand/artcraft-logo-white.png` | ArtCraft brand: artcraft-logo-white | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |
| `docs/brand/artcraft-logo-white.svg` | ArtCraft brand: artcraft-logo-white | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |
| `docs/brand/artcraft-logo.png` | ArtCraft brand: artcraft-logo | ArtCraft Team | LicenseRef-Storyteller-Trademark | ArtCraft Team brand assets (provided by the project owner, 2026-10-01; identical copies in storytold/photocraft) | ArtCraft branding next to the community links (About dialog, Help menu, home screen) and in docs. ArtCraft Team trademark, not open source; may be used only unmodified within PdfCraft; forks and modified versions must remove it. |
@@ -277,15 +285,19 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `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 |
| `crates/sign/tests/data/x509-rsa-sha1.pdf` | One-page PDF with a legacy adbe.x509.rsa_sha1 signature by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl dgst -sha1 -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/pdf.rs): the legacy adbe.x509.rsa_sha1 format |
| `assets/icons/monitor.svg` | Lucide icon "monitor" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/monitor.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `vendor/vello_common/assets/probe.png` | vello_common probe reference image (rendered from probe.rgba) | Linebender vello contributors | MIT OR Apache-2.0 | vello_common 0.0.8 (crates.io), vendored in vendor/vello_common; regenerated by its vello_sparse_tests regenerate_probe_reference binary | Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file |
| `crates/ui-egui/src/i18n/nb.tsv` | Norwegian Bokmål 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; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `assets/icons/superscript.svg` | Lucide icon "superscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/subscript.svg` | Lucide icon "subscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/pilcrow.svg` | Lucide icon "pilcrow" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/move-horizontal.svg` | Lucide icon "move-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `crates/ui-egui/src/i18n/sq.tsv` | Albanian 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; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Albanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/th.tsv` | Thai 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; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Thai UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/ca.tsv` | Catalan interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Catalan UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
## Compiled in through dependencies (6)
## Compiled in through dependencies (5)
| Crate | File | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|---|
@@ -293,7 +305,6 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `epaint_default_fonts` 0.36.2 | `fonts/Hack-Regular.ttf` | Hack Regular | Source Foundry Authors (derived from Bitstream Vera / DejaVu) | MIT AND Bitstream-Vera | https://github.com/source-foundry/Hack | egui fallback fonts (eframe feature default_fonts) |
| `epaint_default_fonts` 0.36.2 | `fonts/NotoEmoji-Regular.ttf` | Noto Emoji (monochrome) | Google Inc. | OFL-1.1 | https://github.com/googlefonts/noto-emoji | egui fallback fonts (eframe feature default_fonts) |
| `epaint_default_fonts` 0.36.2 | `fonts/emoji-icon-font.ttf` | emoji-icon-font | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | egui fallback fonts (eframe feature default_fonts) |
| `hayro-cmap` 0.1.0 | `assets/cmaps.brotli` | Adobe CMap resources (predefined CJK CMaps) (non-visual data, AGENTS.md §1.1) | Adobe | BSD-3-Clause | https://github.com/adobe-type-tools/cmap-resources (via hayro-cmap) | Character-code mapping tables required to read CJK PDFs. Non-visual data, allowed by AGENTS.md §1.1 |
| `hayro-interpret` 0.7.0 | `src/font/generated/metrics.rs` | Standard-14 font metrics and encodings (as Rust tables) (non-visual data, AGENTS.md §1.1) | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | MIT OR Apache-2.0 | ISO 32000-2 Annex D; Adobe Core14 AFM files (via vendored hayro-interpret) | Glyph widths and encodings for non-embedded standard fonts. Non-visual data, allowed by AGENTS.md §1.1 |
## Downloaded at build time (22)
+167 -49
View File
@@ -2,12 +2,12 @@
path = "crates/ui-egui/src/i18n/el.tsv"
title = "Greek interface translation catalog"
author = "Camthalion77 and contributors"
source = "Contributor-original: clean-room translations of PdfCraft English UI labels"
source = "Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "8be1b81993259177ec53d3ffcf4da073e395e50ad6644eb55ce636b7488712d8"
sha256 = "1902f1c31698e6adfdaf65b35b1fe1628f5cd2bd0182dd3b99bcf5e21720f394"
# ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences.
# ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`).
@@ -25,211 +25,286 @@ sha256 = "8be1b81993259177ec53d3ffcf4da073e395e50ad6644eb55ce636b7488712d8"
path = "crates/ui-egui/src/i18n/zh-hans.tsv"
title = "Simplified Chinese interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
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 = "b770b000410c1213a8560424105429ac3504554974525b10e74470cd6c646f8f"
sha256 = "2e23c41fcd5b9118cfde03b3a45aa2f988f7eeba650677b6f1f6664e543c7a59"
[[asset]]
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
title = "Traditional Chinese (Taiwan) interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
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 = "4f44b9d3f17c8bed1728c5afc4b16dc7c38e7c1fab4754e8b886a9c49f3ff0fe"
sha256 = "105703f2fbc07c0663308ebd6852199de12a5851a37507d7e51e477eb5e57218"
[[asset]]
path = "crates/ui-egui/src/i18n/cs.tsv"
title = "Czech interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed"
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 = "bd7f9eb26e1ec98c7899bcf1fd6eeb3b6f5b7b866e97f083f10bee7676c5ddb5"
sha256 = "7358300715a54e72a477c5888dad53f426ddf76da141a6202bec74594eb5ff0a"
[[asset]]
path = "crates/ui-egui/src/i18n/pt-br.tsv"
title = "Brazilian Portuguese interface translation catalog"
author = "André Cardoso (andrecards), PdfCraft contributors"
source = "Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors"
source = "Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed"
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 = "2aaeb565b95f1471feaca31497d4da61257f098f4ef7dbe5c4a8a48e7cc1c76e"
sha256 = "265083ea7dfa129fcae0b9e401aef8f7c84982011ba91fb2023c52705b81e639"
[[asset]]
path = "crates/ui-egui/src/i18n/es.tsv"
title = "Spanish interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
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 = "acc8d3e7f132d5bafaed3ad6ae10e2392d1bcdd6c96ac92e01c2daae555b96f0"
sha256 = "ffcf42859cfe38ae13c6fe4219cbe6225a20b4572c202db9e82ee38bfb433799"
[[asset]]
path = "crates/ui-egui/src/i18n/fr.tsv"
title = "French interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
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 = "3e7f4ef94a55fa11abc042312566267a13172895931b8a7b5af3c0ca41f9c8f4"
sha256 = "3f31e5c9abdb0208d48d8c68aa81437d9d1e10aeb615dedf811fefa539d93279"
[[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"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
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 = "1e38ddb698123f152c4eaf6f567e29fcefb4cefde4121924d1fc604f40dcea54"
sha256 = "815a3a672421cc3590fb1f267755316659cdb2046e30bbb3ad8b31e96f08b20a"
[[asset]]
path = "crates/ui-egui/src/i18n/lo.tsv"
title = "Lao interface translation catalog"
author = "@bounkirdni-2025"
source = "Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Lao UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "6bd67286795ed487eedf2ce549249c8a83286cb4273490c1349996050b540fed"
[[asset]]
path = "crates/ui-egui/src/i18n/te.tsv"
title = "Telugu interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
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 = "f8225ad3d5b25c465a3cd555fedfe1bb0bfbcfa026586653a8df1be38f7f7b7e"
sha256 = "9cd63425705fb9a3aa029ff3a5110d0877a34f9038f1d64a2102ccd6c3cb35a6"
[[asset]]
path = "crates/ui-egui/src/i18n/it.tsv"
title = "Italian interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "caa382d3fad6d00c87fe8760bfec1c3130a6ccbea7cc687ab8b660a521993c65"
sha256 = "76f138293fc12ea3584568a81f6f90a1cedfff6256e78b4f60cb21b1b66bbba4"
[[asset]]
path = "crates/ui-egui/src/i18n/pl.tsv"
title = "Polish interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "5db94802049eac0b0321fb784a060b9f939343075db2dfd07b7293868ef9ed88"
sha256 = "7bca90240f873fcec687bea84e9ea5835f0903827501d0e73ec26b98b82e8a52"
[[asset]]
path = "crates/ui-egui/src/i18n/ru.tsv"
title = "Russian interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
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 = "c63009ccf8afb975a7749f313c58fce4b8b41d290d80633f44cb08873195d86f"
sha256 = "3d539a28afcf8c2e36b7087ac23334b50bac7971e03da20a62b6e7e400763a87"
[[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"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
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 = "14f743ecfd488fc87e058b41df72f0835dc13b21b230c8b88054d39846ad4e31"
sha256 = "71b7e8180fc759aebad4660953ab8b55383def0572c1ace2288cd8bfeb1b82dc"
[[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"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
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 = "ba43b383c8c8eb97f6bc39f4d635fe2282d92cb4a4bfdcaf81bba81c82fc64a3"
sha256 = "e689fb24a8acfa7e85f0f60644f7e2da1225595e6cd6a94109fbe86082926244"
[[asset]]
path = "crates/ui-egui/src/i18n/kk.tsv"
title = "Kazakh interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources"
source = "Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "2ba2a772b2991b306253ad5b853698af2502487fcc354d3906454f58fc3be4ba"
sha256 = "c601ea117ec2a3cdba606fdc9a62220fcffbd0c20c1133bacb653a979cf046a5"
[[asset]]
path = "crates/ui-egui/src/i18n/ja.tsv"
title = "Japanese interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
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 = "71217d26f3ea73d3746a76066365877a9964340981f5b1547073f9cb634c2e98"
sha256 = "006867b891ae2f995c7a711e55b6401db29d20ab493889c6284dc3671f9faa2d"
[[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"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
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 = "1029628f81cce97ddd7468629fdfcfab13987742efc585f308b9af1ffa3f0f97"
sha256 = "9e5b18a09c209525cf08ef53a242ed457cfa346295eef7facde482a75cf72f52"
[[asset]]
path = "crates/ui-egui/src/i18n/ar.tsv"
title = "Arabic interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "76478b1a56a92f6527fd13babb3b013463a0dc70a2be6a50fca0ab25665b8e6c"
sha256 = "2306dbbe6a73671fd20df116adb159a7f66c85fbbd1bae5ecef5f6e882d6b7f1"
[[asset]]
path = "crates/ui-egui/src/i18n/ckb.tsv"
title = "Central Kurdish (Sorani) interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "864db8aa0a5e443c92e7e388f89349eb0f0e7beda35a0b81695d864dcec51b72"
[[asset]]
path = "crates/ui-egui/src/i18n/tr.tsv"
title = "Turkish interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "e3346071d10f3f2d42c6f5fec56c4e7b5f8988a2a9e3a08d1c38e399fa6218b0"
sha256 = "463a2d28bd5c5ac8df31a5b5c68400d7a61ce51237a58d9337078c973e6f9e3d"
[[asset]]
path = "crates/ui-egui/src/i18n/lt.tsv"
title = "Lithuanian interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "7275aa4245985cbcd72b15ae6ba81fef28e0df74ccafbf6dc0e4075fe1b0f8cc"
sha256 = "dea5076ce9c19f054812b678241931ad5e7f85dee08acf1c1e12c7ecaaa5d0f5"
[[asset]]
path = "crates/ui-egui/src/i18n/hi.tsv"
title = "Hindi interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Hindi UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "09d2840c02b40a6049b86c2cb68151d863913e8c91c22226f25d07496c6366aa"
[[asset]]
path = "crates/ui-egui/src/i18n/id.tsv"
title = "Indonesian interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Indonesian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "0dc1abdb262e4568f19ffb2233bfeb3686ae42df8a8d8bec2951351a50e6a886"
[[asset]]
path = "crates/ui-egui/src/i18n/ko.tsv"
title = "Korean interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Korean UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "a3e67cee4f3fff776e816523f810b2bb6353235dbdf85181c3f12896604a3339"
[[asset]]
path = "crates/ui-egui/src/i18n/ro.tsv"
title = "Romanian interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Romanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "18d4beda8495d7f0b91f0dc0a52c2248d9ce6fb3f4e3de0d51f42235eaab7aa8"
[[asset]]
path = "crates/ui-egui/src/i18n/fi.tsv"
title = "Finnish interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Finnish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "7b13e11191a3f2c0e38f1940996ae62da16e3bdb057215b2737f3a9c6a840000"
[[asset]]
path = "assets/icons/accessibility.svg"
@@ -2425,14 +2500,13 @@ kind = "font"
usage = "egui fallback fonts (eframe feature default_fonts)"
sha256 = "f426bec371f484646d002717e2c555a95cd5145e635d83a48bbef83f3abf18ca"
[[bundled]]
crate = "hayro-cmap"
version = "0.1.0"
path = "assets/cmaps.brotli"
[[asset]]
path = "vendor/hayro-cmap/assets/cmaps.brotli"
title = "Adobe CMap resources (predefined CJK CMaps)"
author = "Adobe"
source = "https://github.com/adobe-type-tools/cmap-resources (via hayro-cmap)"
source = "https://github.com/adobe-type-tools/cmap-resources (via hayro-cmap 0.1.0 assets/)"
licence = "BSD-3-Clause"
licence_file = "vendor/hayro-cmap/assets/LICENSE.txt"
kind = "data"
adobe_data = true
usage = "Character-code mapping tables required to read CJK PDFs. Non-visual data, allowed by AGENTS.md §1.1"
@@ -3347,23 +3421,23 @@ sha256 = "d1a443233345724859de8e4fb48968ba91e885e12ddbd710701548bd91b39428"
path = "crates/ui-egui/src/i18n/vi.tsv"
title = "Vietnamese interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "f6aff113d8c5a43e4c52d465ee106f36238dd8581423cb0cd39cdcf991cbead2"
sha256 = "1c73f9d51b599923d704bf00c8e56b23696de16141b7a2c20aae6f60f88927a5"
[[asset]]
path = "crates/ui-egui/src/i18n/sl.tsv"
title = "Slovene interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "cb2cd5e2bc11c23f4f56eef1f836eecf0710b77d77b992d795a1dfa5bbebd9a8"
sha256 = "a3b8351f0da80c2d394221dbe5d62e794bb019c74c2adfc5d933d999f7d3236f"
[[asset]]
@@ -3377,6 +3451,17 @@ kind = "image"
usage = "Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file"
sha256 = "78c59a0cfd2c829909d77006f94e34dac1ae5d1f90e7f10f72e84de541fef697"
[[asset]]
path = "crates/ui-egui/src/i18n/nb.tsv"
title = "Norwegian Bokmål interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "03477c4ab2d0d52543cb91b3aec6f27b2689e6b9bb7c95bbb8463664c75a9dc0"
[[asset]]
path = "assets/icons/superscript.svg"
title = "Lucide icon \"superscript\""
@@ -3420,3 +3505,36 @@ licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
sha256 = "864ab2f19254e6ac7240bc6a98323197332e8f714d4d3f94831f41309e5ef2f7"
[[asset]]
path = "crates/ui-egui/src/i18n/sq.tsv"
title = "Albanian interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Albanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "ebd177262d8fe20f960f06df084f8a03c9504bcd9990dded3a006e35bd8ef7c9"
[[asset]]
path = "crates/ui-egui/src/i18n/th.tsv"
title = "Thai interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Thai UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "1dbb66c05af0aec06d5a6fa154e89f1684203dde32c8e8aedd9aeec6c2d5ae02"
[[asset]]
path = "crates/ui-egui/src/i18n/ca.tsv"
title = "Catalan interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Catalan UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "cde106ae68eea0a2d6229863ea21bbea235e4c3c5e3e728d52689d54041f0593"
+1
View File
@@ -39,6 +39,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado
- **External PRs are held to safety, security and completeness before style.** Provenance first (no Adobe-derived asset, no committed font, no copied GPL/AGPL code, behaviour from the spec and not from reading another implementation), then no new panic path or `unsafe`, then `--root` confinement on every path including ones reached by nested dispatch, opt-in-only MCP and control channel, no new outbound network, then a headless `automation` tool, tests at the right zoom and an honest `parity/` entry. Provenance is the part a contributor cannot be expected to know, and the only part that cannot be undone after merge.
- **Quality gates** before every commit: `cargo fmt --check`, clippy with `-D warnings`, the tests, `cargo xtask layers`, `cargo xtask assets`, and the wasm check once `xtask ci` exists (M0).
- **Scope builds and tests to the crates you touched** — `cargo clippy -p <crate> -j 2 --all-targets` and `cargo test -p <crate> -j 2`, under a `timeout`. A full `--workspace` build has locked up a development machine's CPU; it is CI's job and CI runs it on every PR. Run the workspace sweep locally only when you have a reason to and the machine is free.
- **Check for existing work before starting a fix or feature.** Search the issues and PRs, open and closed, with `gh issue list -R storytold/pdfcraft --state all --search "<terms>"` and the same with `gh pr list` (try a few phrasings), and check whether a merged fix is already on `main` (`git merge-base --is-ancestor <commit> origin/main`). If one is, the report is likely from an older build: say so rather than fixing it again. If an open PR covers it, review or build on that PR instead of opening a competing one. If an issue exists, reference it in the PR (`Fixes #123`).
- **Commits and PRs:** `main` is the only integration branch and is **never pushed to directly**. Branch from an up-to-date `main` as `<type>/<slug>` (`feat/`, `fix/`, `docs/`, `chore/`, `refactor/`), one task id per commit (e.g. `M1.4: xref stream reader`), commit only green states, and open a PR into `main` using `.github/pull_request_template.md`. Merge only when CI is green.
- **`main` moves fast enough that PRs rot.** It took 396 commits in the three days to 2026-10-12, and most open PRs are conflicting at any given time. Rebase your own branch the same day `main` moves under it, and ask the author to rebase theirs — never rewrite a contributor's branch. Conflict risk tracks how *contested* the files are, not how large the change is: tooling, tests and docs are nearly free, while `crates/fonts`, `ui-egui` and the i18n catalogues are where several people collide at once.
Generated
+4 -2
View File
@@ -2549,8 +2549,6 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a"
[[package]]
name = "hayro-cmap"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d285dc30731c8485de5fa732fbdf2b3affdf01e4da7c2135022ca6fa664bf6"
dependencies = [
"brotli",
"hayro-postscript",
@@ -4012,6 +4010,7 @@ dependencies = [
"pdfcraft-render",
"pdfcraft-xfa",
"png 0.18.1",
"rustix 1.1.5",
"serde_json",
"sha2 0.11.0",
"tempfile",
@@ -4029,6 +4028,7 @@ dependencies = [
"pdfcraft-platform",
"pdfcraft-preflight",
"pdfcraft-render",
"png 0.18.1",
"rustix 1.1.5",
"serde_json",
"winresource",
@@ -4134,6 +4134,7 @@ dependencies = [
"pdfcraft-redact",
"pdfcraft-render",
"pdfcraft-sign",
"pdfcraft-testkit",
"pdfcraft-xfa",
"pdfcraft-xfdf",
"png 0.18.1",
@@ -4282,6 +4283,7 @@ dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
"pdfcraft-model",
"pdfcraft-render",
"thiserror 2.0.21",
]
+1
View File
@@ -114,6 +114,7 @@ codegen-units = 1
hayro-interpret = { path = "vendor/hayro-interpret" }
hayro = { path = "vendor/hayro" }
hayro-syntax = { path = "vendor/hayro-syntax" }
hayro-cmap = { path = "vendor/hayro-cmap" }
hayro-jbig2 = { path = "vendor/hayro-jbig2" }
lopdf = { path = "vendor/lopdf" }
winit = { path = "vendor/winit" }
+2
View File
@@ -140,6 +140,8 @@ Right-click a PDF tab to close it, reopen the last closed file-backed tab, or re
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.
In **Inspector > Pages**, use **Thumbnail size** and the smaller/larger buttons to show more pages or inspect larger previews (50–150%). This changes only the thumbnails, keeping multi-page selection and the current page in view. The size belongs to the open document view and survives reloading its file; it is not remembered after restarting. Agents can set it with `ui.set {"key":"thumbnail-zoom","value":"75"}` and read `active.thumbnail_zoom` from `ui.state`.
<table>
<tr>
<td width="50%"><img src="docs/images/pdfcraft-find.png" alt="The find bar showing match 10 of 16 for the word 'type', highlighted in the Expressive Type chapter heading"></td>
+96 -7
View File
@@ -2,7 +2,17 @@
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M5, PR #1971: bounded nested FDF export excludes source page/page-tree dictionaries; refreshed against upstream 4db6ac4a; estimates unchanged)
> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-21 · **Change:** minor (M3, #3808: the Pages inspector has an independent 50–150% thumbnail size slider and buttons; selection and current-page following are retained, with headless ui.set coverage. Estimates unchanged.)
> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M11 #3507: image merging preserves content stream boundaries; estimates unchanged)
> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged)
> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (M8, gap 6 #2662: the redaction audit counts a cover over one word of a long line; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M12 #3673: Character encoding accepts Identity-H/V with Adobe CJK collections; estimates unchanged)
> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M2, #3601: missing spacing glyphs in non-embedded Japanese CID substitutes draw as blanks while PDF widths and Unicode stay unchanged; estimates unchanged)
> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M3, #3541/#3620: comment hover follows individual marked lines and original annotation paint order; estimates unchanged)
> **Last reviewed:** 2026-10-20 - **Last updated:** 2026-10-20 - **Change:** minor (M6, #3502: form detection uses the existing content-stream boundary policy; estimates unchanged)
> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M2, #3574: named form properties retain page origin and own dictionary boundaries; estimates unchanged)
> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M4: initial-view options resolve indirect boolean preferences; estimates unchanged)
> **Last reviewed:** 2026-10-16; **Last updated:** 2026-10-16; **Change:** minor (M5 #2669, item 5: reply groups and page root counts per frame; estimates unchanged)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M7: the Edit text caret is as tall as the row it is on; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: CSV tab-containing headers; estimates unchanged)
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged)
@@ -16,10 +26,12 @@
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M6: preserve XML/XFDF attribute whitespace; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M1: raw-deflate fallback preserves decoding-limit errors; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-16 | **Last updated:** 2026-10-16 | **Change:** minor (M10 #1201 partial: lossless image Decode; estimates unchanged) | **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-16 · **Change:** minor (M14: Czech catalog completed and reviewed; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 #2080 candidate: explicit search jumps join per-tab history; rail and control-channel Back/Forward; estimates unchanged)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M4 #1910: Organize Pages opens on the current page; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 candidate: coordinate control clicks keep press/release together on slow frames and preserve text multi-click selection; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M10 #2156 candidate: remember selected print paper by canonical name; estimates unchanged)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4 #1310 #1311 #1312: undo, revert and the next save keep the last saved revision; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
@@ -41,6 +53,12 @@
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (architectural paper controls from #599; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (closed issues synced: #774, #756 and the #799–#821 batch; earlier: localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M6 #2892: Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged)
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M7 #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Estimates unchanged)
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2569: Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Estimates unchanged)
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2572: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Estimates unchanged)
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2570: Dynamic XFA rich-text fields carry exData/maxLength into AcroForm MaxLen for reported and enforced Unicode character limits, retained after save/reopen. Estimates unchanged)
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M4 #3309: bounded iterative named-destination pruning on page deletion; estimates unchanged)
> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M1 #2658: macOS atomic saves retain destination extended attributes, including Finder tags, and reject partial resource-fork reads after size changes. Metadata failures retain the original PDF and unsaved edit. Estimates unchanged)
PdfCraft's one-page summary: how close it is to Adobe Acrobat Pro's offline feature set, and what
comes next. Adobe's cloud services (Document Cloud, Acrobat Sign requests, shared review, the AI
@@ -132,18 +150,18 @@ What's strong and missing in each: [target-app-parity.md](docs/target-app-parity
## Languages
Twelve key languages (historical % snapshot vs 2,143 keys, except Vietnamese vs the current
union). Detail and script notes: [localization-parity.md](docs/localization-parity.md).
Twelve key languages (historical % snapshot vs 2,143 keys, except Vietnamese, Brazilian
Portuguese and Spanish vs the current union). Detail and script notes: [localization-parity.md](docs/localization-parity.md).
| Language | Code | Strings | Status |
|---|---|---|---|
| English | `en` | 100% | full |
| Simplified Chinese | `zh-hans` | 97.4% | partial (no Hans UI face in releases, #826) |
| Spanish | `es` | 95.5% | partial |
| Spanish | `es` | 100% of current union | partial (dialogs / tooltips; no help) |
| Hindi | `hi` | 0% | none |
| Arabic | `ar` | 100% | partial (layout not mirrored) |
| French | `fr` | 99.3% | partial |
| Portuguese (Brazil) | `pt-br` | 99.4% | partial |
| Portuguese (Brazil) | `pt-br` | 100% of current union | partial |
| Indonesian | `id` | 0% | none |
| Japanese | `ja` | 99.1% | partial |
| German | `de` | 99.3% | partial |
@@ -151,8 +169,8 @@ union). Detail and script notes: [localization-parity.md](docs/localization-pari
| Vietnamese | `vi` | 100% of current union | partial (dialogs / tooltips; no help) |
Also shipped: Traditional Chinese, Russian, Bulgarian, Telugu, Hungarian, Ukrainian, Italian,
Kazakh, Polish, Slovenian, Greek, Turkish, Lithuanian and Czech (menus only): **14 more catalogs**
(23 interface languages including English).
Kazakh, Polish, Slovenian, Greek, Turkish, Lithuanian, Romanian and Czech: **15 more catalogs**
(24 interface languages including English).
## Upcoming
@@ -171,22 +189,67 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
| 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 |
## Progress log
- **2026-10-13 (M7, free image rotation):** A selected page image has a rotate knob (Shift: 15 degree steps) and `image_edit` a `rotate_by` action; the angle is written as one undoable `cm`. Snapping to page edges while moving is in #1740; resize snapping is not done. Estimates unchanged.
- **2026-10-20 (M4, initial view):** Window and interface options read indirect boolean ViewerPreferences values. Synthetic regressions cover true/false, direct/indirect preference dictionaries and save/reopen; malformed entries retain false defaults. Estimates unchanged.
- **2026-10-20 (M3, #3535):** Escape with a menu open closes the menu and keeps the text selection; the next Escape clears it, as before. One keypress used to do both. A synthetic UI regression checks it. Estimates unchanged.
- **2026-10-16 (M3, close prompt keyboard):** The "Save changes?" prompt opens with Save chosen (Enter saves, as before) and the arrow keys and Tab / Shift+Tab move the choice to Cancel or Don't save, shown with a focus ring; Enter or Space press the chosen button and Escape still cancels. Before, Enter always saved whatever was Tab-focused. Estimates unchanged.
- **2026-10-21 (M3, #3808):** Page thumbnails in the Inspector can be resized independently from the document and Organize, with a slider, buttons and `thumbnail-zoom` control option. Selection, page following and reload are covered by regressions. Estimates unchanged.
- **2026-10-22 (M2, #3107 / PR #3438):** Inline-image parsing falls back to the existing EI heuristic when missing declared samples, headers or terminators exceed the content-window budget, instead of waiting for an impossible allocation. Exact-length parsing for available samples and ordinary large streamed images is retained. Estimates unchanged.
- **2026-10-21 (M14, Brazilian Portuguese):** Completed the `pt-br` catalog against the current 2,674-key union, including signature validation and accessibility messages; no build or compilation run. Localization remains partial because help and unpredictable engine/OS error details are not translated.
- **2026-10-21 (M14, #3557):** Preferences: the OK button stays below the scrolling settings instead of being the last thing in them, so it is in reach in a short window.
- **2026-10-19 (M11, #2178):** Optimize checks image and soft-mask dimensions and byte counts before decoding; hostile-dimension regressions pass. Estimates unchanged.
- **2026-10-20 (M11, #3507):** Image merging keeps trailing comments, split operands and graphics state across content streams. Synthetic tool tests check saved text, rendered regions and exact image rows.
- **2026-10-17 (M8, gap 6, #2662):** A filled cover over a long line counts when it hides half of any one word, not only 40% of the line's words or box, so a black box over one number in a long line — or an unapplied redaction mark over it — no longer audits clean while the number is still extractable; a sliver under half of every word it touches and under 40% of the line is still not flagged
- **2026-10-13 (M10, #1757):** Office export keeps each dotted-leader contents entry in one paragraph, with its page number, instead of inferring table cells from its separate text objects.
- **2026-10-20 (M6, calculation order page):** Prepare a form ▸ Calculation order, a page of its own, lists the calculated fields in the order they calculate with their formulas and values, moved with Up/Down arrows (as Acrobat's Set Field Calculation Order), Alt+↑/↓ or by dragging. A card says what is wrong with the order and holds Fix order; a loop has a card of its own that opens one of its formulas. On the page, each calculated field shows its step, and arrows show what the field in focus reads and what reads it. Estimates unchanged.
- **2026-10-20 (M6, calculation order):** The order a form's fields calculate in (`/CO`) is read, checked, fixed and set headlessly with `form_calculation_order`: it says which fields read one calculated later (they show stale values), which read each other in a loop, and which the PDF's order leaves out; Fix order moves only what must move, in one undo step, and the form recalculates. Editing a formula no longer moves its field to the end of the order, and a field the order lists twice calculates once. Estimates unchanged.
- **2026-10-20 (M2, #3574):** Forms without Resources use page Properties for ActualText and layer visibility. Synthetic tests cover boundaries, nested/shared forms, and save/reopen. Estimates unchanged.
- **2026-10-20 (M2, PR #3348 review):** Tracking samples retain short repeated punctuation (up to four glyphs), keeping tracked ellipses intact and searchable while longer TOC leaders still preserve title word spaces. Synthetic regressions cover isolated and surrounded ellipses. Estimates unchanged.
- **2026-10-20 (M2, #3601):** A missing spacing glyph in an OpenType substitute uses its ordinary-space glyph. Synthetic Japanese CID renderer tests check blank spaces, `/W` and `/DW` placement, and unchanged Unicode extraction. Font selection and embedded fonts are unchanged. Estimates unchanged.
- **2026-10-20 (M12, #3673):** Character encoding accepts Identity-H/V fonts with Adobe CJK collections and recognizes predefined non-Identity CMaps independently. Unknown CMaps and foreign registries remain refused; synthetic checker and read-only automation regressions passed after failing before the fix. Per-glyph mapping validation and embedded-font extraction remain separate. Estimates unchanged.
- **2026-10-19 (M6, renames keep formulas):** Renaming a field renames it in every calculation that reads it, in the same undo step: `AFSimple_Calculate` field lists, simplified field notation (the comment and the JavaScript after it) and `getField` calls naming it in one string literal; the rest of each script is kept as written. A sum used to drop the renamed field, notation read it as 0 and a script kept a stale value. Field Properties says how many formulas changed and `form_set_props` lists them as `formulas_updated`. Names built at run time and other events' scripts are not rewritten. Estimates unchanged.
- **2026-10-19 (M6, text field table):** Prepare a form ▸ Text field table drags out an area and adds rows and columns of named text fields as one undoable edit, laid out on the page as displayed, with a live preview, per-column name patterns and widths, and refusal before anything is added when a cell would be too small or a name taken; `form_add_field_table` does the same headlessly. `form.multiple-copies` is partial: copying an existing field into a grid is still missing. Estimates unchanged.
- **2026-10-16 (M7, #2516):** A paragraph number from `text_paragraphs` keeps naming the paragraph it listed after edits regroup the page: `text_edit`, `text_edit_layout` and `page_render`'s `hide_text_block` find it again by its text and box, and refuse one that was merged into a neighbour (the edited paragraph included) instead of rewriting the paragraph that took its number (`stale_paragraphs` lists the numbers gone). The inline editor's switch-paragraph click holds the same key. No stable identity across save and reopen yet (#2508).
- **2026-10-19 (M7, #3591):** A background added to a page that already has one is painted over the old one instead of going under it and staying hidden. Behind the content, backgrounds come first, in the order added, and then behind-content watermarks, so a watermark added after a background no longer disappears. Synthetic regressions in `crates/edit` check the order. Estimates unchanged.
- **2026-10-19 (M2, radial luminosity masks):** Added synthetic radial-fade coverage for the existing masked-gradient fix (#2132): centre, midpoint, antialiased clip and per-pixel shading reference, including previews, scales and tiles. Removing the existing native-gradient mask guard reproduces an opaque midpoint; restoring it passes. No new renderer behavior or estimate change.
- **2026-10-20 (M6, #3502):** Form detection preserves comments across content streams and does not add fields over rectangles hidden by those comments. Synthetic tests cover visible controls, graphics state, and saved field inventories. Estimates unchanged.
- **2026-10-20 (M6, #2273 / PR #2279):** Rebased the field-highlight default and Appearance preference onto main, retaining secured-form parity evidence and updating secured AcroForm/XFA toggle expectations. Build and test validation delegated to GitHub CI. Estimates unchanged.
- **2026-10-20 (M5, reshape lines):** A line or arrow could only be moved after it was drawn; its length and angle stayed. A selected line, connected lines, polygon or cloud now has a handle on each end or corner: dragging one reshapes it (Shift holds it to 45°), as one undo step, with the new shape drawn while dragging. `pdfcraft_annot::set_points`, `Edit::ReshapeAnnotation`, `comment_edit points`; comment lists carry the points. Estimates unchanged.
- **2026-10-20 (M3, #3620):** Overlapping comment hover uses the original annotation index independently of spatial inspection order. A synthetic UI regression covers different annotation upper edges. Estimates unchanged.
- **2026-10-20 (M3, #3541/#3620):** Comment hover follows the individual marked lines instead of the enclosing multiline rectangle, and overlapping comments follow paint order. Synthetic UI regressions cover all four text-markup kinds, page/view rotations, neighboring highlights, legacy rectangle fallback and unchanged PDF bytes. Estimates unchanged.
- **2026-10-20 (M10, #3532 partial):** Vector printing retains existing widget artwork when its appearance lacks Subtype. A print-owned copy adds Form dispatch metadata. The source stream and all explicit subtypes stay unchanged. Synthetic source/output raster checks cover resources, selected/shared appearances, placement, flags and content modes. Appearance regeneration and physical printer validation remain outside this fix. Estimates unchanged.
- **2026-10-19 (M14, #3373):** The main menu and its long submenus scroll in short windows, keeping their commands reachable. Estimates unchanged.
- **2026-10-19 (M3, #3387):** Save-prompt shortcuts yield to later consent and utility dialogs; a synthetic UI regression checks that an edited document stays open. Estimates unchanged.
- **2026-10-18 (M3, PR #3101 review):** Gap changes in Organize Pages preserve document scroll position; menu and control changes apply to both comparison panes without changing unrelated tabs. Three synthetic UI regressions were observed failing before the fixes. Readiness estimates unchanged.
- **2026-10-18 (M3, PR #3101):** Optional gapless continuous page display with persisted defaults and UI control support; both new labels translated in all 22 catalogs. Rebased onto current main, preserving navigation and rendering fixes. Readiness estimates unchanged.
- **2026-10-18 (M10, #3120):** Export to Word, PowerPoint, HTML and RTF reads text from form XObjects nested as deep as the renderer draws them (50 levels), and up to 50 000 forms a page (the renderer's budget for nested ones); it stopped at 12 levels and 4 096 forms and left the rest out silently. A page past those limits is listed in `doc_export_office`'s `truncated_pages`, with a warning. Estimates unchanged.
- **2026-10-18 (M5, PR #1971 review):** Nested FDF annotation values that identify a source `/Page` or `/Pages` dictionary become null at every copy depth. A synthetic three-page PDF saved and reopened before export verifies that destination actions cannot pull source page content into the FDF, while unrelated nested metadata and shared references remain intact. FDF import semantics, copied-graph limits and estimates are unchanged.
- **2026-10-15 (M5, PR #1971 refresh):** Nested FDF annotation values retain shared or cyclic copied graphs. Discovery borrows direct values and records annotation positions with lazy array owners; retained entries are budgeted before cloning. Synthetic stress fixtures include repeated aliases and saved-PDF cache eviction. Limits cover the copied graph; source parsing and page discovery retain their existing limits, with no total source-memory bound. FDF import semantics and estimates are unchanged.
- **2026-10-13 (M5, nested FDF values):** Comment export copies and renumbers nested indirect values, retaining geometry, colours, borders and shared graphs. Graph/data limits return an error before the destination is written; synthetic saved-PDF and automation exchange regressions. Estimates unchanged.
- **2026-10-18 (M2, #3086):** Text extraction estimates letter tracking without repeated punctuation gaps so dotted TOC leaders do not swallow word spaces. Synthetic PDF regressions cover leader lengths, phrase search and selection; strongly tracked text stays intact. Estimates unchanged.
- **2026-10-18 (M6, #2892):** Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged.
- **2026-10-18 (M7, #3212):** Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, with original content preserved.
- **2026-10-18 (M7, #3187):** Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Estimates unchanged.
- **2026-10-18 (M12, #2569):** Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Estimates unchanged.
- **2026-10-18 (M12, #2572):** Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Estimates unchanged.
- **2026-10-18 (M12, #2570):** Dynamic XFA rich-text fields carry exData/maxLength into AcroForm MaxLen for reported and enforced Unicode character limits, retained after save/reopen. Estimates unchanged.
- **2026-10-19 (M4, #3309 review):** Page deletion keeps working with 60,000 named destinations, without a name-count traversal limit. Synthetic regressions verify every surviving target after deletion and restoration of the deleted page and its destinations by undo/redo, full/incremental save/reopen and unchanged rendering. Estimates unchanged.
- **2026-10-18 (M4, #3309):** Deleting a page prunes named destinations without recursive calls. Cyclic and shared indirect nodes are visited once. Synthetic deep/cyclic graph, save/reopen and automation regressions cover the change. Estimates unchanged.
- **2026-10-18 (M1, #2658):** macOS atomic saves retain destination extended attributes, including Finder tags, and reject partial resource-fork reads after size changes. Metadata failures retain the original PDF and unsaved edit. Estimates unchanged.
- **2026-10-15 (M7, Edit text caret):** In a text region that mixes sizes (a 24 pt title over 12 pt lines), the inline editor's caret on a small row was as tall as the region's first size and reached into the row below: egui draws the caret at least one row of the text field's font tall, and that font had the paragraph's first size. The field's font is now half the region's smallest size, so each row's own height decides. Kittest regression; checked in the app on a real document. Estimates unchanged.
- **2026-10-16 (M3, #2781):** The Pages panel follows the current page, as Acrobat's page thumbnails do: going to a page whose thumbnail is out of sight (scrolling, Page Down, Go to page, a bookmark) scrolls the panel just enough to show it. A thumbnail already in view, or clicked, leaves the panel where it is, and a page change while the user scrolls the panel or drags thumbnails does not move it. Estimates unchanged.
- **2026-10-16 (M12, #1645):** CLI info, text and render use the engine's dynamic XFA layout rather than placeholder pages. Synthetic two-page and encrypted-form regressions cover counts, text, pixels, copy permissions and unchanged input bytes. Estimates unchanged.
@@ -203,6 +266,10 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-16 (M10, #1201 partial):** Lossless image export applies each component's Decode pair. Indexed values round and clip before palette lookup. Color-key masks keep raw samples. The existing 16-bit precision limit remains. JPEG and mask Decode, ICC transforms, and calibrated colors remain outside this fix. Estimates unchanged.
- **2026-10-16 (M3, #2739):** Wrapped revision buttons stay above Binding in Document Properties. A synthetic regression checks control bounds and opens a saved revision at two viewport widths. Estimates unchanged.
- **2026-10-19 (M7, tracked text):** Editable paragraphs and object labels distinguish repeated glyph tracking from word gaps in TJ arrays and separately positioned glyphs. Synthetic regressions cover character spacing, horizontal scaling, rotated baselines, larger word gaps, and rows of one-glyph words or cells (inferred tracking is capped at half an em, so `a b c` and digit or Y/N columns keep their spaces). Estimates unchanged.
- **2026-10-16 (M5, #2669, item 5):** The Comments panel groups replies by name and counts all page roots once per frame. Estimates unchanged.
- **2026-10-16 (M7, #2140):** Header & footer (and Bates numbering, which uses the same dialog) can shrink the page content so the header and footer don't cover it: the original content is scaled evenly into the band they leave free, centred across, also on rotated pages; Remove and Update take the scaling away, and margins that leave no room are refused. Dialog checkbox with a preview of the band, `shrink` on `doc_header_footer`, all catalogs. Annotations and added text and images are not moved yet. Estimates unchanged.
- **2026-10-20 (M14, Spanish catalog gaps):** `es.tsv` gains the 143 `(context, English)` keys the other catalogs carried and it was missing, so Spanish covers the whole bundled key union like Lithuanian and Vietnamese: calendar months and weekdays, the `certificate` context, X.509 key usages, signature-revision and digital-ID lines, password validation, eight plural messages, the combine-from-folder strings, the rendering and stale-file notices and the shortcut-spelled tooltips. Counts in `localization-parity.md` re-measured against the current union (`vi` restated against it for the same reason). Status stays `partial`: no translated help, and OS details stay outside the catalog. Estimates unchanged.
- **2026-10-16 (M14, localization):** Czech goes from menus-and-dialogs to the whole surface: 2,674 entries after adding the ~220 keys the other catalogs had -- the sentences the engine words (signature details, accessibility findings and report, PDF/A problems, pages to print, history labels), the date picker's weekday heads, the certificate viewer's fields and shared buttons. "Tag" is *tag* throughout (the Tags panel said *značky*, which is also a redaction mark), and "{n} tags" no longer reads "1 značek". Two new catalog tests. Estimates unchanged.
- **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged.
- **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged.
- **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `<occur>` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. A page area's `occur min` is honoured too: one the body never reached gets the pages it still needs after the body (the once-only closing page of a form, with its button, comes last). Acrobat Pro also puts that page last, and when printing keeps it but leaves it blank (`relevant="-print"` on the page area), which PdfCraft does not do yet; both re-observed on a synthetic fixture, see the 2026-10-19 line. Synthetic regression tests. Estimates unchanged.
@@ -212,16 +279,21 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-15 (M6, AcroForm XML text):** Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Synthetic regressions verify exact values and unknown catalog data after incremental and full save/reopen. Nested-element and XFA interpretation are unchanged. Estimates unchanged.
- **2026-10-13 (M6, data exchange fidelity):** XML and XFDF exports preserve tabs and line breaks in field names and comment metadata using character references. Saved-PDF regressions cover distinct names that XML would normalize to the same attribute value, restored field values and replacement of the original comment identities. Estimates unchanged.
- **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `<occur>` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. Synthetic regression test; the page area's `occur min` (a once-only page of its own) is still not laid out, recorded in `form.xfa-dynamic`. Estimates unchanged.
- **2026-10-14 (M12, reading colours, #2059):** Pending page backgrounds use the document's reading colour immediately, preventing white flashes while scrolling to uncached pages. A headless canvas regression covers three pending pages in every preset and a custom background. Estimates unchanged.
- **2026-10-15 (community, M6, #2608):** In Prepare a form the selected field moves with the arrow keys: a point per press, ten with Shift, as the object nudge and dragging it do. Each press is one undo step ("Change field properties"), and the keys no longer turn the page while a field is selected. Estimates unchanged.
- **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged.
- **2026-10-15 (M14, #2383 candidate):** Device-limit requests now clamp unsupported maxima and minimum alignments before enabling the adapter's full 2D texture extent. This addresses the reported V3D request of 16 shader variables on an adapter supporting 15. Synthetic regressions fail before the clamp and pass afterward, along with the existing HiDPI case and Windows GPU-required application tests. Raspberry Pi startup remains unverified; milestone and overall estimates are unchanged.
- **2026-10-12 (M6):** XFDF field import and data-file extraction join all text nodes in a value, preserving text after XML comments and processing instructions. Synthetic regressions cover Unicode, CDATA, empty values and PDF save/reopen. Estimates unchanged.
- **2026-10-12 (M7, picture right-click menu):** The Edit text & images menu of a picture gains Cut Image, Redact Image (a redaction mark over the picture, applied from Redact a PDF), Create Link… and Recognize Text… (for that page), in all 21 catalogs. The menu was drawn only while the pointer was over the picture, so its items outside the picture (most of a logo's menu, and Delete below a page) could not be clicked; an open menu now stays drawn. Still missing from Acrobat's menu: Arrange (bring to front, send backward), Remove crop, Draw freehand and Edit image using… (opening an external editor).
- **2026-10-15 (M14, Norwegian Bokmål localization):** Added Norwegian Bokmål (`nb`) as an interface and date language: a catalog covering every key the other catalogs have, Norwegian key names, month and weekday names for Fill & Sign dates, and `no`/`nn` locales resolving to it until a Nynorsk catalog exists. Written from the meaning of the English labels; terminology reviewed by a native-speaking contributor, not yet every line. Estimates unchanged.
- **2026-10-12 (M2, inline-image boundaries):** Unfiltered inline images with known sample layouts keep pixel bytes that look like the `EI` terminator when content is rewritten. Regression tests cover row padding, colour-space and parameter aliases, masks and malformed sizes; classic and packed saved PDFs retain the pixels in independent readers and renderers. Filtered and resource-defined layouts retain the existing tolerant heuristic. Estimates unchanged.
- **2026-10-12 (M3, drawing markups):** While a comment is being drawn, the pointer in a band along any edge of the view (or just past it) scrolls the view up, down, left or right, so points beyond what is shown can be reached. A polygon, cloud or run of connected lines takes back its last point with Backspace, Delete or Undo (the first point's removal cancels it). Edge scrolling has unit tests of its maths only; it has not been exercised end to end in the app by an automated test.
- **2026-10-15 (M2, large object streams):** An object stream's index of `objnum offset` pairs is read once per stream instead of once per object fetched from it (vendored hayro-syntax patch 22), so reading every object of a stream of N objects no longer takes N² steps. A Firefox page whose 65,542 objects share one stream (pdf.js corpus `bug1978317.pdf`) drew in 37 s and extracted its text in 40 s; it now takes 0.5 s and 1.9 s, and leaves the robustness baseline's known timeouts. 40,000 pages in one stream open in 0.2 s instead of 54 s. The regression fails before the fix. Estimates unchanged.
- **2026-10-12 (gap 6, #1319, #1305, #1324):** Editing an XFA form keeps a radio item's stored text when it contains a space, so a later layout still selects it. Replace Pages on a split selection replaces those pages and leaves the gap; editing the dialog range still replaces the span. Marking a figure decorative rewrites only that figure's content stream, so a header, added text and unknown stream entries stay. Estimates unchanged.
- **2026-10-15 (M14, gap 24):** `misc.localization` and `misc.installers` move from planned to partial. Localization: 22 catalogs ship, but Chinese shows tofu in releases, Telugu has no release font and Arabic is not mirrored. Installers: macOS and Windows releases are signed, Linux packages are not. The Acrobat menu diff itself remains open. Estimates unchanged.
- **2026-10-15 (M14, PNG export):** Export ▸ Image as PNG, `pdfcraft-cli render` and the `page_render` automation/MCP tool write an opaque page as RGB at deflate level 3 instead of RGBA at the default level 6: the files stay the same size and are written 3–5× faster (a 300-dpi scan drawn at 220 dpi: 805 → 167 ms; a text page at 220 dpi: 70 → 40 ms; an annual-report page at 150 dpi: 179 → 64 ms). A page with translucent pixels is still written as RGBA. The automation tools use the engine's encoder instead of a copy of it. Estimates unchanged.
- **2026-10-15 (G14, Romanian localization):** Added Romanian (`ro`, Română) as an interface and date language with a 2,673-entry catalog covering every bundled key, and a CLDR plural rule (`plural_ro`: one / few / other, "20 de pagini"). Locale tags `ro` / `ro-RO` / `ro_RO.UTF-8` / `ro-MD` resolve, so Auto picks it on Romanian Windows. Coverage and plural tests added. Status `partial` like the other catalogs (no translated help).
- **2026-10-22 (M14, JPEG and TIFF export):** Export ▸ Image as JPEG or TIFF refuses a raster that is not exactly width × height pixels, as PNG export has since #2425. A raster a pixel short panicked inside the JPEG and TIFF encoders, and a partial pixel at the end was dropped and the rest written as a valid image. Rendered pages are always the right size, so what exports is unchanged. Estimates unchanged.
- **2026-10-13 (G14, Vietnamese localization):** Added Vietnamese (`vi`, Tiếng Việt) as an interface and date language with a 2,591-entry catalog (`plural_none`) covering 100% of the current localizable key union (plus `theme`/`Appearance`). Locale tags `vi` / `vi-VN` / `vi_VN.UTF-8` resolve; calendar months use numeric `Tháng 1`…`12` / `tháng 1`…`12`. Native-speaker community review recorded. The twelve-language localization mean moves from ≈59% to ≈68%; weighted readiness ≈49% → ≈50%. Status stays `partial`: engine/OS details, document contents, filenames, CLI/MCP and external help stay outside the catalog by design.
- **2026-10-13 (M3, typed zoom):** The right rail's zoom percentage is an editable field: type 150, 150% or 62.5 % and press Enter, Escape discards, and nonsense keeps the current zoom. The control channel's zoom option shares its parser, which now refuses zero, negative and non-finite values.
@@ -264,6 +336,13 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-14 (M3, control-click candidate):** Coordinate clicks retain hover warmup and pair press/release in one input pass so slow frames do not turn a click into a hold. Text multi-click selection accepts the actual press event when egui has already cleared its origin. Regression cases cover slow-frame button activation, disabled controls and coalesced text selection; estimates unchanged.
- **2026-10-14 (community, M7, #128 Hebrew in Add text):** Add text and `page_add_text` draw Hebrew with the craft-fonts Hebr face (Noto Sans Hebrew), through the same shaped path as Arabic and Thai: right-to-left display order with numbers and Latin in place, points and cantillation kept on their letter as one Type 3 glyph, ToUnicode and per-line ActualText so extraction, search and the editor get typing order. Builds without the face refuse Hebrew rather than write `?`. Regular style only. Estimates unchanged.
- **2026-10-14 (M9, #1787):** Changes after signing are judged by the role an object held when signed for a page's resources too: a rewritten font, XObject, graphics state or resources dictionary is page content however its new version is labelled, so a `/FT` or `/Subtype /Widget` added by the attacker no longer reads as a permitted form fill. A category dictionary that only gained a name is judged as before.
- **2026-10-14 (M3, full screen as presentation, #379/#1459/#1353):** Full Screen Mode shows one page (or one spread) at a time fitted to the screen on black, with no tools; click, Space, Enter and the arrows turn pages; Esc restores the display from before. Transitions, auto-advance and loop remain planned.
- **2026-10-14 (M10, #1818):** Properties… reads a CUPS printer's driver options off the GUI thread, the dialog saying so meanwhile, and `lpoptions` is given ten seconds before it is killed and the PPD's defaults stand; a read that dies leaves the panel with nothing rather than a frozen window. The bounded wait is tested with a stand-in command that never answers.
- **2026-10-14 (M10 #2156 candidate):** Print paper selection survives Cancel and settings restoration; canonical names avoid preset-index dependence. Other print preferences and estimates unchanged.
- **2026-10-15 (community, M14, Polish signature panel):** A Polish signature panel listed its details in English: the catalog lacked every signature wording, the changes after signing they name and 34 UI literals. All 101 are translated; `polish_covers_ui_literals` and `polish_says_what_a_signature_says` keep them so. Estimates unchanged.
- **2026-10-14 (M14, Finnish localization):** Added Finnish as another interface and date language, with a 2,674-entry catalog covering every key of the other bundled catalogs and two-form plurals (partitive after numbers other than 1). Calendar dates use partitive month names (`14. lokakuuta 2026`). Coverage tests check commands/tools, scanned UI literals and bundled catalog keys. AI-assisted; native proofreading pending.
- **2026-10-14 (M14, Catalan localization):** Added Catalan as another interface and date language, with a 2,592-entry clean-room catalog, one/other plurals and coverage tests for commands, tools, UI literals and every key in the other catalogs. A native Catalan speaker (the contributor) has reviewed the translation. Estimates unchanged.
- **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged.
- **2026-10-13 (community, M3, #531 touchpad momentum):** On Wayland a two-finger swipe keeps gliding after the fingers lift and slows to a stop, measured from the gesture's last 0.1 s and decaying with a 0.35 s time constant through the #759 1:1 path. Any wheel input, click, key, zoom or edge ends it (fingers resting on the touchpad don't yet: the vendored winit binds no Wayland hold gestures); single-page paging, macOS (which sends its own momentum), the web, and Animation effects Off never glide. Unit tests for the physics and two headless view tests. Estimates unchanged.
- **2026-10-13 (M3, native-open admission):** Later prepared replies stay queued while a modal or native picker owns the foreground. Picker completion, cancellation and abandonment wake retained opens. Regressions cover recipient ownership, exactly-once admission, source/history preservation and one-shot repaint requests. Estimates unchanged.
@@ -556,6 +635,10 @@ Newest first: the date, what moved, and the new overall estimate.
| Date | Change | Summary |
|---|---|---|
| 2026-10-20 | minor | M4: initial-view window and interface options resolve indirect boolean ViewerPreferences values; synthetic true/false and save/reopen regressions retain defaults for malformed entries. Estimates unchanged. |
| 2026-10-20 | minor | M6, #3502: form detection preserves comments across content streams; estimates unchanged. |
| 2026-10-20 | minor | M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged. |
| 2026-10-20 | minor | M11 #3507: image merging preserves comments and state across content streams. Estimates unchanged. |
| 2026-10-18 | minor | M5, PR #1971: nested source `/Page` and `/Pages` dictionaries export as null without traversing page content or the page tree. Synthetic saved/reopened three-page regression retains unrelated metadata and aliases. FDF import semantics, copied-graph limits and estimates unchanged. |
| 2026-10-15 | minor | M5: budget retained FDF entries before cloning; discovery keeps lazy array owners and annotation positions, with alias/cache-eviction stress fixtures. Limits cover the copied graph; source parsing/page discovery retain existing COS/parser limits, with no total source-memory bound. FDF import semantics and estimates unchanged. |
| 2026-10-15 | minor | M7: the Edit text caret is as tall as the row it is on in a region with several sizes; estimates unchanged. |
@@ -564,6 +647,7 @@ Newest first: the date, what moved, and the new overall estimate.
| 2026-10-15 | minor | M6: Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. Estimates unchanged. |
| 2026-10-13 | minor | M6: XML/XFDF attribute whitespace survives form and comment export/import; estimates unchanged |
| 2026-10-14 | minor | M1: raw-deflate fallback preserves decoding-limit errors in strict and tolerant decoding; synthetic filter and COS stream regressions. Estimates unchanged. |
| 2026-10-16 | minor | M14: Czech catalog completed (2,674 entries) and "tag" terminology unified. Estimates unchanged. |
| 2026-10-13 | minor | M9: separate page validation overlays preserve signature artwork, and snapshot tabs reuse instead of multiplying; baked captions remain unchanged (#79 open); estimates unchanged |
| 2026-10-16 | minor | M10 #1201 partial: lossless image export applies component Decode pairs and Indexed mapping. Estimates unchanged. |
| 2026-10-15 | minor | M6: CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Estimates unchanged. |
@@ -595,3 +679,8 @@ Newest first: the date, what moved, and the new overall estimate.
- **2026-10-13 (M7):** Edit a PDF ▸ Add content: Header and footer, Watermark and Background open an Add / Update / Remove menu (Update and Remove enabled only when the document has that mark), as Acrobat's Watermark control does. The catalogue gains `ToolItem::actions`, so any tool can list more commands (#1192).
- **2026-10-18 (M8, #1350):** Published the history-preserving integration; simplified the inherited-form resource guard for Rust 1.96 Clippy. Formatting, 64 redaction tests, scoped Clippy, layering, assets and the scoped wasm check pass locally. CI remains pending; estimates unchanged.
- **2026-10-12 (i18n):** Albanian (`sq`, Shqip) added: 2,343-entry catalog, month and weekday names, Preferences entry; not yet reviewed by a native speaker.
- **2026-10-21 (M4, #3865):** A new bookmark records where the reader is: the top-left of the selected text, or the top of the window, as `/XYZ left top null` (zoom kept), instead of the page top (`Edit::AddBookmark` takes `left` and `top`; `bookmark_add` takes `top` and `left`; `PageInfo::xyz_for_view`).
- **2026-10-18 (renderer, hayro patches 19/35):** A mesh shading (types 4–7) is sampled over its own triangles instead of the whole path: a BricsCAD plan with 310 one-triangle `sh` fills took 2 s per 1024 px tile at every zoom (and hit the 20 s watchdog on a busy machine); now about 70 ms, so zooming into dense topology drawings stays fluid. Estimates unchanged.
+4
View File
@@ -23,6 +23,10 @@ pdfcraft-automation = { workspace = true }
pdfcraft-platform = { workspace = true }
base64 = "0.22"
[dev-dependencies]
# Decodes `render` output in tests/owner_password.rs; already a dependency of the engine.
png = "0.18"
[target.'cfg(unix)'.dependencies]
# `geteuid` for `ui`'s control-file owner check: safe, so no `unsafe` libc call here.
# Already in Cargo.lock through the GUI crates.
+79 -18
View File
@@ -176,7 +176,12 @@ fn audit(args: &[String]) -> Result<(), CliError> {
let findings = pdfcraft_audit::audit_redactions(&doc).map_err(|e| format!("{path}: {e}"))?;
// Rectangles in points from the top-left of the displayed page, as `doc_audit_redactions`
// reports them; the page geometry is read only when there is something to place.
let info = if findings.is_empty() { None } else { Some(inspect(bytes, password).map_err(|e| format!("{path}: {e}"))?) };
let info = if findings.is_empty() {
None
} else {
let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password);
Some(inspect(bytes, render_password.as_deref()).map_err(|e| format!("{path}: {e}"))?)
};
let items: Vec<serde_json::Value> = findings
.into_iter()
.map(|f| {
@@ -299,7 +304,9 @@ fn info(args: &[String]) -> Result<(), CliError> {
let path = *positional(args).first().ok_or("info: missing file")?;
let bytes = read(path)?;
let password = flag(args, "--password");
let mut info = inspect(bytes.clone(), password).map_err(|e| e.to_string())?;
// The renderer takes only the user password of R2–R4 files; `Session::open` takes either.
let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password);
let mut info = inspect(bytes.clone(), render_password.as_deref()).map_err(|e| e.to_string())?;
let pdf_pages = info.pages.len();
let acroform_fields = info.fields.len();
// Dynamic XFA forms: say what laying the template out gives (what the app shows).
@@ -366,6 +373,9 @@ fn text(args: &[String]) -> Result<(), CliError> {
}
let password = flag(args, "--password");
let mut bytes = read(path)?;
// The renderer takes only the user password of R2–R4 files; `Session::open` takes either.
// Worked out from the file as read: `bytes` may become the session's display copy below.
let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password);
// Printing the text is copying it, so ask what `text_extract` asks (#1645): without the
// permissions password, a document that doesn't allow copying gives no text.
let mut session = pdfcraft_engine::Session::new();
@@ -378,12 +388,12 @@ fn text(args: &[String]) -> Result<(), CliError> {
bytes = doc.display.clone();
}
}
let mut r = PageRenderer::new(bytes.clone(), RenderConfig { password: password.map(Arc::from), ..Default::default() });
let mut r = PageRenderer::new(bytes.clone(), RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() });
// A document the renderer could not open (wrong or missing password, unparseable file) has
// no pages, and "extract every page" of nothing would print nothing and exit 0 (#132). Ask
// `inspect` why instead, so a protected file fails the same way `info` does.
if r.page_count() == 0 {
let info = inspect(bytes.clone(), password).map_err(|e| format!("text: {e}"))?;
let info = inspect(bytes.clone(), render_password.as_deref()).map_err(|e| format!("text: {e}"))?;
if !info.pages.is_empty() {
return Err("text: the document could not be parsed".into());
}
@@ -478,17 +488,31 @@ fn edit(args: &[String]) -> Result<(), CliError> {
"--password" => password = Some(value),
"--rotate" => {
let (pages, deg) = value.split_once(':').ok_or("--rotate PAGES:DEGREES")?;
edits.push(Edit::RotatePages { pages: page_list(pages)?, degrees: deg.parse().map_err(|_| "bad degrees")? });
let degrees: i64 = deg.parse().map_err(|_| format!("bad --rotate degrees: expected a whole number of degrees, got {deg:?}"))?;
// The same rule `rotate_pages` enforces, checked here so a bad angle is named
// with its option before any file is opened.
if degrees % 90 != 0 {
return Err(format!("bad --rotate degrees: expected a multiple of 90 (such as 90, -90 or 180), got {degrees}").into());
}
edits.push(Edit::RotatePages { pages: page_list(pages)?, degrees });
}
"--delete" => edits.push(Edit::DeletePages { pages: page_list(value)? }),
"--move" => {
let (pages, to) = value.split_once(':').ok_or("--move PAGES:TO")?;
let to: usize = to.parse().map_err(|_| "bad target")?;
edits.push(Edit::MovePages { pages: page_list(pages)?, to: to.checked_sub(1).ok_or("--move target must be at least 1")? });
let target: usize = to
.parse()
.ok()
.filter(|target| *target > 0)
.ok_or_else(|| format!("bad --move target: expected a page number of at least 1, got {to:?}"))?;
edits.push(Edit::MovePages { pages: page_list(pages)?, to: target - 1 });
}
"--insert-blank" => {
let at: usize = value.parse().map_err(|_| "bad position")?;
edits.push(Edit::InsertBlankPage { at: at.checked_sub(1).ok_or("--insert-blank must be at least 1")?, width: 612.0, height: 792.0 });
let position: usize = value
.parse()
.ok()
.filter(|position| *position > 0)
.ok_or_else(|| format!("bad --insert-blank position: expected a page position of at least 1, got {value:?}"))?;
edits.push(Edit::InsertBlankPage { at: position - 1, width: 612.0, height: 792.0 });
}
"--title" => edits.push(Edit::SetInfo { key: "Title".into(), value: value.into() }),
"--author" => edits.push(Edit::SetInfo { key: "Author".into(), value: value.into() }),
@@ -570,7 +594,16 @@ fn split(args: &[String]) -> Result<(), CliError> {
use pdfcraft_engine::SplitBy;
let path = *positional(args).first().ok_or("split: missing file")?;
let by = match (flag(args, "--every"), flag(args, "--before")) {
(Some(n), None) => SplitBy::PageCount(n.parse().map_err(|_| "bad --every")?),
(Some(n), None) => {
// A count the splitter cannot hold is refused here, before the file is opened,
// rather than silently clamped to 1 further down.
let count = n
.parse::<usize>()
.ok()
.filter(|count| *count > 0)
.ok_or_else(|| format!("bad --every: expected a positive whole number of pages, got {n:?}"))?;
SplitBy::PageCount(count)
}
(None, Some(list)) => SplitBy::Before(page_list(list)?),
_ => return Err("split: give either --every N or --before PAGES".into()),
};
@@ -590,12 +623,25 @@ fn split(args: &[String]) -> Result<(), CliError> {
Ok(())
}
/// A `--dpi` the renderer can be asked for: a positive, finite number. `0`, a negative value
/// or `NaN` parse as an `f32` but reach the renderer as a scale it refuses with its own
/// internal message (and in `check`, passed on as text, they would turn every file into a
/// child that printed nothing), so the CLI names the option and the value instead.
fn parse_dpi(value: &str) -> Result<f32, String> {
value
.parse::<f32>()
.ok()
.filter(|dpi| dpi.is_finite() && *dpi > 0.0)
.ok_or_else(|| format!("bad --dpi: expected a positive number of dots per inch, got {value:?}"))
}
fn render(args: &[String]) -> Result<(), CliError> {
validate_options("render", args, RENDER_OPTIONS)?;
let path = *positional(args).first().ok_or("render: missing file")?;
let page: usize = flag(args, "--page").unwrap_or("1").parse().map_err(|_| "bad --page")?;
let page_index = page.checked_sub(1).ok_or("--page must be at least 1")?;
let dpi: f32 = flag(args, "--dpi").unwrap_or("96").parse().map_err(|_| "bad --dpi")?;
let page: usize =
flag(args, "--page").unwrap_or("1").parse().ok().filter(|page| *page > 0).ok_or("bad --page: expected a positive page number")?;
let page_index = page - 1;
let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("96"))?;
let out = flag(args, "--out").ok_or("render: missing --out (.png, .jpg, .tif or .pam)")?;
// The file is what its name says (#248): a `.png` used to get a netpbm PAM stream.
let format = match Path::new(out).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref() {
@@ -609,14 +655,18 @@ fn render(args: &[String]) -> Result<(), CliError> {
// must agree with it (and with independent viewers), while keeping the file it reads intact.
let bytes = read(path)?;
let password = flag(args, "--password");
let display = if inspect(bytes.clone(), password).is_ok_and(|info| info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) {
// The renderer takes only the user password of R2–R4 files; `Session::open` and the display
// copy take either.
let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password);
let display = if inspect(bytes.clone(), render_password.as_deref()).is_ok_and(|info| info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) {
let mut session = pdfcraft_engine::Session::new();
let id = session.open(path, None, bytes, password).map_err(|e| e.to_string())?;
session.get(id).ok_or("render: the document could not be opened")?.display.clone()
} else {
pdfcraft_engine::display_bytes_for(bytes, password)
};
let mut r = PageRenderer::new(display, RenderConfig { password: password.map(Arc::from), reject_oversize: true, ..Default::default() });
let mut r =
PageRenderer::new(display, RenderConfig { password: render_password.as_deref().map(Arc::from), reject_oversize: true, ..Default::default() });
let p = r.render(RenderRequest { page: page_index, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false });
if let Some(e) = p.error {
return Err(e.into());
@@ -647,7 +697,7 @@ fn render(args: &[String]) -> Result<(), CliError> {
fn check_one(args: &[String]) -> Result<(), CliError> {
validate_options("check-one", args, CHECK_ONE_OPTIONS)?;
let path = *positional(args).first().ok_or("check-one: missing file")?;
let dpi: f32 = flag(args, "--dpi").unwrap_or("36").parse().map_err(|_| "bad --dpi")?;
let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("36"))?;
let start = Instant::now();
let bytes = read(path)?;
let (status, pages, failed, detail, warnings) = match inspect(bytes.clone(), None) {
@@ -739,12 +789,23 @@ fn collect(paths: &[&str]) -> Vec<PathBuf> {
fn check(args: &[String]) -> Result<(), CliError> {
validate_options("check", args, CHECK_OPTIONS)?;
// Every option is checked before the inputs are scanned, so a bad value is one message,
// not one problem per file: a zero timeout used to make every file time out, and a bad
// dpi used to reach each child, which printed nothing and was reported as a crash.
let timeout_value = flag(args, "--timeout").unwrap_or("20");
let timeout = Duration::from_secs(
timeout_value
.parse::<u64>()
.ok()
.filter(|secs| *secs > 0)
.ok_or_else(|| format!("bad --timeout: expected a positive whole number of seconds, got {timeout_value:?}"))?,
);
let dpi = flag(args, "--dpi").unwrap_or("36").to_string();
parse_dpi(&dpi)?;
let files = collect(&positional(args));
if files.is_empty() {
return Err("check: no PDF files found".into());
}
let timeout = Duration::from_secs(flag(args, "--timeout").unwrap_or("20").parse().map_err(|_| "bad --timeout")?);
let dpi = flag(args, "--dpi").unwrap_or("36").to_string();
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
let mut results = Vec::new();
let mut counts: std::collections::BTreeMap<String, usize> = Default::default();
+20
View File
@@ -1,4 +1,5 @@
//! The robustness harness visits each directory once, including symlink aliases and cycles.
//! Its `--timeout` and `--dpi` options are checked before anything is scanned.
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
@@ -109,3 +110,22 @@ fn check_follows_directory_and_file_symlinks() {
assert_eq!(results[0]["status"], "ok");
assert_eq!(results[0]["file"], file_alias.to_string_lossy().as_ref());
}
/// `--timeout` and `--dpi` are checked before any input is scanned: a zero timeout used to
/// make every file time out at once, and a dpi that is not a positive finite number used to
/// reach each child, which printed nothing and was reported as a crash per file.
#[test]
fn a_bad_timeout_or_dpi_is_refused_before_scanning() {
let out = Command::new(BIN).args(["check", "no-such-input", "--timeout", "0"]).output().unwrap();
assert_eq!(out.status.code(), Some(1));
assert_eq!(out.stderr, b"pdfcraft-cli: bad --timeout: expected a positive whole number of seconds, got \"0\"\n");
for value in ["abc", "0", "-3", "NaN"] {
let out = Command::new(BIN).args(["check", "no-such-input", "--dpi", value]).output().unwrap();
assert_eq!(out.status.code(), Some(1), "--dpi {value}");
assert_eq!(
out.stderr,
format!("pdfcraft-cli: bad --dpi: expected a positive number of dots per inch, got {value:?}\n").as_bytes(),
"--dpi {value}"
);
}
}
+30 -3
View File
@@ -141,9 +141,9 @@ mod one_based_arguments {
for destination in ["output.pam", "new.pam", "input.pdf"] {
std::fs::write(dir.0.join("output.pam"), b"keep destination").unwrap();
for (command, option, value, diagnostic) in [
("render", "--page", "0", "--page must be at least 1"),
("edit", "--move", "1:0", "--move target must be at least 1"),
("edit", "--insert-blank", "0", "--insert-blank must be at least 1"),
("render", "--page", "0", "bad --page: expected a positive page number"),
("edit", "--move", "1:0", "bad --move target: expected a page number of at least 1, got \"0\""),
("edit", "--insert-blank", "0", "bad --insert-blank position: expected a page position of at least 1, got \"0\""),
] {
let out = Command::new(BIN).current_dir(&dir.0).args([command, input, "--out", destination, option, value]).output().unwrap();
assert_eq!(out.status.code(), Some(1), "{command} {input} {option} {value}");
@@ -194,6 +194,33 @@ mod one_based_arguments {
}
}
/// A value the option cannot hold names the option and the value, and fails before the input
/// file is opened: a missing input still gets the option's own message.
#[test]
fn a_value_an_option_cannot_hold_is_named_before_the_file_is_opened() {
let dir = tmp("values", "");
for (option, value, diagnostic) in [
("--rotate", "1:abc", "bad --rotate degrees: expected a whole number of degrees, got \"abc\""),
("--rotate", "1:45", "bad --rotate degrees: expected a multiple of 90 (such as 90, -90 or 180), got 45"),
("--rotate", "1:-45", "bad --rotate degrees: expected a multiple of 90 (such as 90, -90 or 180), got -45"),
("--move", "1:abc", "bad --move target: expected a page number of at least 1, got \"abc\""),
("--move", "1:0", "bad --move target: expected a page number of at least 1, got \"0\""),
("--insert-blank", "abc", "bad --insert-blank position: expected a page position of at least 1, got \"abc\""),
("--insert-blank", "0", "bad --insert-blank position: expected a page position of at least 1, got \"0\""),
] {
let out = Command::new(BIN).current_dir(&dir).args(["edit", "missing.pdf", "--out", "out.pdf", option, value]).output().unwrap();
assert_eq!(out.status.code(), Some(1), "{option} {value}");
assert_eq!(out.stderr, format!("pdfcraft-cli: {diagnostic}\n").as_bytes(), "{option} {value}");
assert!(out.stdout.is_empty(), "{option} {value}");
assert!(!dir.join("out.pdf").exists(), "{option} {value}: no output may be written");
}
// A negative multiple of 90 is still a rotation the engine accepts: the refusal above is
// the multiple-of-90 rule, not a sign problem (the missing input is the next error).
let out = Command::new(BIN).current_dir(&dir).args(["edit", "missing.pdf", "--out", "out.pdf", "--rotate", "1:-90"]).output().unwrap();
assert_eq!(out.status.code(), Some(1));
assert!(!String::from_utf8_lossy(&out.stderr).contains("--rotate degrees"), "{}", String::from_utf8_lossy(&out.stderr));
}
/// A destination past the end must fail, not silently become an append (#1330).
#[test]
fn a_destination_past_the_end_fails_and_writes_nothing() {
+154
View File
@@ -0,0 +1,154 @@
//! `info`, `text` and `render` take the owner password of revision 2–4 files, as `run doc_open`
//! does (#2670, item 8). The renderer authenticates only user passwords of those revisions, so
//! the CLI must hand it the user password that owner authentication recovers.
use std::process::Command;
use std::sync::Arc;
use pdfcraft_cos::{Algorithm, NewEncryption};
const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli");
/// A one-page 200×300 pt PDF reading "Page 1", drawn by `content`.
fn fixture(content: &str) -> Vec<u8> {
let stream = format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len());
let objs: [&[u8]; 5] = [
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>",
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
b"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >> >>",
stream.as_bytes(),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
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 PAGE: &str = "BT /F1 24 Tf 20 150 Td (Page 1) Tj ET";
/// `fixture` protected with user password "user" and owner password "owner", allowing nothing,
/// so copying its text needs the owner password.
fn protected(algorithm: Algorithm) -> Vec<u8> {
protected_with(algorithm, PAGE)
}
fn protected_with(algorithm: Algorithm, content: &str) -> Vec<u8> {
let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(content))).unwrap();
doc.set_encryption(&NewEncryption {
algorithm,
user_password: "user",
owner_password: "owner",
permissions: 0,
encrypt_metadata: true,
seed: [3; 32],
})
.unwrap();
pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()
}
fn tmp(name: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("pdfcraft-cli-owner-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d.join(name)
}
const ALGORITHMS: [Algorithm; 4] = [Algorithm::Rc4_40, Algorithm::Rc4_128, Algorithm::Aes128, Algorithm::Aes256];
#[test]
fn info_accepts_the_owner_password() {
for alg in ALGORITHMS {
let path = tmp(&format!("info-{alg:?}.pdf"));
std::fs::write(&path, protected(alg)).unwrap();
for pw in ["owner", "user"] {
let out = Command::new(BIN).args(["info", path.to_str().unwrap(), "--password", pw]).output().unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "{alg:?} with {pw:?}: {stderr}");
let json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(json["pages"], 1, "{alg:?} with {pw:?}");
assert_eq!(json["encrypted"], true, "{alg:?} with {pw:?}");
}
let out = Command::new(BIN).args(["info", path.to_str().unwrap(), "--password", "wrong"]).output().unwrap();
assert!(!out.status.success(), "{alg:?}: a wrong password still fails");
assert!(String::from_utf8_lossy(&out.stderr).contains("incorrect"), "{alg:?}");
}
}
#[test]
fn text_accepts_the_owner_password() {
for alg in ALGORITHMS {
let path = tmp(&format!("text-{alg:?}.pdf"));
std::fs::write(&path, protected(alg)).unwrap();
let out = Command::new(BIN).args(["text", path.to_str().unwrap(), "--password", "owner"]).output().unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "{alg:?}: {stderr}");
assert!(String::from_utf8_lossy(&out.stdout).contains("Page 1"), "{alg:?}");
// The user password opens it, but the permissions still forbid copying.
let out = Command::new(BIN).args(["text", path.to_str().unwrap(), "--password", "user"]).output().unwrap();
assert!(!out.status.success(), "{alg:?}: copying needs the owner password");
assert!(String::from_utf8_lossy(&out.stderr).contains("don't allow copying"), "{alg:?}");
}
}
/// Asserts that `data` is a PNG of the fixture's 200×300 pt page at `dpi` with the text drawn:
/// a blank page of the right size fails too.
fn assert_rendered_page(data: &[u8], dpi: u32, what: &str) {
let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap_or_else(|e| panic!("{what}: {e}"));
let mut buf = vec![0; reader.output_buffer_size().unwrap()];
let frame = reader.next_frame(&mut buf).unwrap();
assert_eq!((frame.width, frame.height), (200 * dpi / 72, 300 * dpi / 72), "{what}");
// A rendered page is opaque, so `render` writes it as 8-bit RGB (`pdfcraft_engine::export::encode_png`).
assert_eq!((frame.color_type, frame.bit_depth), (png::ColorType::Rgb, png::BitDepth::Eight), "{what}");
// Pixels with every colour channel below half intensity: ink, not white paper.
let inked = buf[..frame.buffer_size()].as_chunks::<3>().0.iter().filter(|px| px.iter().all(|c| *c < 128)).count();
assert!(inked > 100, "{what}: only {inked} inked pixels, the page is blank");
}
#[test]
fn render_accepts_the_owner_password() {
const DPI: u32 = 144;
for alg in ALGORITHMS {
let path = tmp(&format!("render-{alg:?}.pdf"));
std::fs::write(&path, protected(alg)).unwrap();
for pw in ["owner", "user"] {
let png = tmp(&format!("render-{alg:?}-{pw}.png"));
let _ = std::fs::remove_file(&png);
let out = Command::new(BIN)
.args(["render", path.to_str().unwrap(), "--page", "1", "--dpi", &DPI.to_string()])
.args(["--password", pw, "--out", png.to_str().unwrap()])
.output()
.unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "{alg:?} with {pw:?}: {stderr}");
assert_rendered_page(&std::fs::read(&png).unwrap(), DPI, &format!("{alg:?} with {pw:?}"));
}
}
}
#[test]
fn audit_places_findings_with_the_owner_password() {
// A black box over the text: a faux redaction, so `audit` reads the page geometry too.
let covered = format!("{PAGE} 0 g 10 140 120 40 re f");
for alg in ALGORITHMS {
let path = tmp(&format!("audit-{alg:?}.pdf"));
std::fs::write(&path, protected_with(alg, &covered)).unwrap();
let out = Command::new(BIN).args(["audit", path.to_str().unwrap(), "--password", "owner"]).output().unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(!stderr.contains("incorrect"), "{alg:?}: {stderr}");
let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap_or_else(|e| panic!("{alg:?}: {e}: {stderr}"));
assert_eq!(report["findings"][0]["covered_text"], "Page 1", "{alg:?}");
// Top-left points on the 300 pt high page: the box spans y 140–180 from the bottom.
assert_eq!(report["findings"][0]["rect"], serde_json::json!([10.0, 120.0, 130.0, 160.0]), "{alg:?}");
}
}
+24
View File
@@ -88,3 +88,27 @@ fn an_unknown_extension_fails_and_writes_nothing() {
assert!(stderr.contains(".png, .jpg, .tif or .pam"), "{stderr}");
assert!(!out.exists());
}
/// `--page` and `--dpi` are checked before the file is opened. A page that is not a positive
/// whole number names the same expectation `text` names; a dpi that parses as an `f32` but
/// cannot be rendered (`0`, a negative value, `NaN`) names the option and the value, instead
/// of reaching the renderer as a scale it refuses with its own internal message.
#[test]
fn a_page_or_dpi_the_renderer_cannot_use_is_refused_before_reading_the_file() {
for (option, value, diagnostic) in [
("--page", "abc", "bad --page: expected a positive page number"),
("--page", "0", "bad --page: expected a positive page number"),
("--dpi", "abc", "bad --dpi: expected a positive number of dots per inch, got \"abc\""),
("--dpi", "0", "bad --dpi: expected a positive number of dots per inch, got \"0\""),
("--dpi", "-3", "bad --dpi: expected a positive number of dots per inch, got \"-3\""),
("--dpi", "NaN", "bad --dpi: expected a positive number of dots per inch, got \"NaN\""),
("--dpi", "inf", "bad --dpi: expected a positive number of dots per inch, got \"inf\""),
] {
let out = tmp("refuse", "out.png");
// `missing.pdf` is never read: the option's own message is the error.
let r = Command::new(BIN).args(["render", "missing.pdf", "--out", out.to_str().unwrap(), option, value]).output().unwrap();
assert_eq!(r.status.code(), Some(1), "{option} {value}");
assert_eq!(r.stderr, format!("pdfcraft-cli: {diagnostic}\n").as_bytes(), "{option} {value}");
assert!(!out.exists(), "{option} {value}: no output may be written");
}
}
+20
View File
@@ -1,4 +1,5 @@
//! `split --out-dir DIR` creates DIR when it does not exist yet (#249), and says so when it can't.
//! `--every` must be a positive whole number of pages, checked before the file is opened.
use std::path::PathBuf;
use std::process::{Command, Output};
@@ -74,3 +75,22 @@ fn an_out_dir_that_cannot_be_created_is_reported_as_such() {
assert_eq!(std::fs::read(&not_a_dir).unwrap(), b"in the way");
let _ = std::fs::remove_dir_all(&dir);
}
/// `--every` must be a positive whole number of pages. `0` parses, but the engine used to
/// clamp it to 1 silently; refused here, before the file is opened, it cannot surprise anyone.
#[test]
fn a_non_positive_or_unparseable_every_is_refused_before_the_file_is_opened() {
let dir = workdir("every");
for value in ["0", "abc"] {
let out_dir = dir.join("parts");
let out = Command::new(BIN).args(["split", "missing.pdf", "--every", value, "--out-dir", out_dir.to_str().unwrap()]).output().unwrap();
assert_eq!(out.status.code(), Some(1), "--every {value}");
assert_eq!(
out.stderr,
format!("pdfcraft-cli: bad --every: expected a positive whole number of pages, got {value:?}\n").as_bytes(),
"--every {value}"
);
assert!(!out_dir.exists(), "--every {value}: nothing may be written");
}
let _ = std::fs::remove_dir_all(&dir);
}
+29 -2
View File
@@ -7,7 +7,8 @@
//! `--page N --zoom 150 --layout single|continuous|two-up|two-page --continuous on|off --panel comments|bookmarks|pages|fields|layers|attachments|none
//! --theme light|dark|system --language auto|<code> --mode all|read|edit|convert|sign --tool <catalogue id> --left open|closed
//! --organize on --fields on --dialog properties|shortcuts|about --palette <query> --home on
//! --cover on|off --default-layout single|continuous|two-up|two-page --default-continuous on|off --default-zoom fit-width|fit-page|<percent>
//! --cover on|off --default-layout single|continuous|two-up|two-page --default-continuous on|off --default-zoom fit-width|fit-page|<percent> --always-default-view on|off
//! --page-gaps on|off --default-page-gaps on|off
//! --combine-view grid|list --combine-zoom 60..200`
//!
//! `--new-window` opens a window of its own. Without it, on Windows, a launch that only names files
@@ -557,6 +558,11 @@ fn write_control_file(path: &str, port: u16, token: &str) -> std::io::Result<()>
/// `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
/// its window appeared. D3D12 is the native, best-supported backend there.
/// - On Windows, Direct3D 12 presents through a DirectComposition visual rather than a swap chain
/// on the window itself, unless `WGPU_DX12_PRESENTATION_SYSTEM` says otherwise. While the window
/// is being resized, Windows stretched the window's swap chain, still at the old size, to the
/// new one, so the text shimmered and the left panel's width bounced for as long as the drag
/// lasted (issue #2738). A visual's last frame is shown at its own size instead.
fn configure_gpu(native: &mut eframe::NativeOptions) {
let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &mut native.wgpu_options.wgpu_setup else { return };
// Nothing draws or dispatches indirectly, so wgpu's check of indirect arguments only costs a
@@ -604,6 +610,15 @@ fn configure_gpu(native: &mut eframe::NativeOptions) {
if cfg!(target_os = "windows") && std::env::var_os("WGPU_BACKEND").is_none() {
setup.instance_descriptor.backends = eframe::wgpu::Backends::DX12 | eframe::wgpu::Backends::GL;
}
if cfg!(target_os = "windows") {
setup.instance_descriptor.backend_options.dx12.presentation_system = dx12_presentation(eframe::wgpu::Dx12SwapchainKind::from_env());
}
}
/// How Direct3D 12 presents on Windows: through a DirectComposition visual (issue #2738), unless
/// `WGPU_DX12_PRESENTATION_SYSTEM` asked for something else (`asked`).
fn dx12_presentation(asked: Option<eframe::wgpu::Dx12SwapchainKind>) -> eframe::wgpu::Dx12SwapchainKind {
asked.unwrap_or(eframe::wgpu::Dx12SwapchainKind::DxgiFromVisual)
}
/// Surface textures must fit the device's enabled limits, not just the physical GPU's limits.
@@ -878,7 +893,7 @@ mod tests {
}
#[test]
fn gpu_backends_avoid_vulkan_on_windows_and_prefer_low_power() {
fn gpu_setup_avoids_vulkan_and_stretched_resizes_on_windows_and_prefers_low_power() {
let mut native = eframe::NativeOptions::default();
super::configure_gpu(&mut native);
let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &native.wgpu_options.wgpu_setup else {
@@ -896,6 +911,18 @@ mod tests {
assert!(backends.contains(eframe::wgpu::Backends::DX12), "{backends:?}");
assert!(!backends.contains(eframe::wgpu::Backends::VULKAN), "issue #37: {backends:?}");
}
use eframe::wgpu::Dx12SwapchainKind::{DxgiFromHwnd, DxgiFromVisual};
assert_eq!(super::dx12_presentation(None), DxgiFromVisual);
assert_eq!(super::dx12_presentation(Some(DxgiFromHwnd)), DxgiFromHwnd, "WGPU_DX12_PRESENTATION_SYSTEM still decides");
assert_eq!(super::dx12_presentation(Some(DxgiFromVisual)), DxgiFromVisual);
let presentation = setup.instance_descriptor.backend_options.dx12.presentation_system;
if cfg!(target_os = "windows") {
// A composition visual unless WGPU_DX12_PRESENTATION_SYSTEM asks otherwise.
let asked = eframe::wgpu::Dx12SwapchainKind::from_env();
assert_eq!(presentation, asked.unwrap_or(DxgiFromVisual), "issue #2738: resizing stretches a window swap chain");
} else {
assert_eq!(presentation, eframe::wgpu::Dx12SwapchainKind::default().with_env(), "only Windows changes it");
}
}
#[test]
+20
View File
@@ -20,6 +20,11 @@
[people.YouseifUS]
sync_github_name = true
[people.brian-moon]
real_name = "Il Min Brian Moon"
display_name = "Il Min Brian Moon"
sync_github_name = false
[people.echelon]
real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
@@ -30,6 +35,9 @@ real_name = "Eduardo Soares"
display_name = "Eduardo Soares"
sync_github_name = false
[people.guillehr2]
real_name = "Guillem Hermida"
[people.jcl-bit]
real_name = "James Locke"
display_name = "James Locke"
@@ -153,3 +161,15 @@ sync_github_name = true
real_name = "Eric Kaden"
display_name = "Eric Kaden"
sync_github_name = false
[people.MatrixMagician]
sync_github_name = true
[people.Lcoding15]
real_name = "Livio Oswald"
[people.Bode512]
sync_github_name = true
[people.amoisttowe1]
real_name = "Nick Nyssen"
+4
View File
@@ -16,6 +16,10 @@ let html = report_html(&report, "file.pdf", "2026-10-02", "en", &in_english); /
- Each result is Passed, Failed (with findings and pages), Needs manual check, or Skipped.
- **Content checks** walk the page content streams (and form XObjects) tracking marked
content: painting outside MCID-marked or `/Artifact` content is untagged.
- **Character encoding** accepts a predefined non-Identity CJK CMap or an Adobe CJK
character collection (`GB1`, `CNS1`, `Japan1`, `Korea1`, `KR`), including Identity-H/V.
Unknown CMaps and foreign registries do not establish that route. This is a font-level
declaration check, not validation of every ToUnicode entry or used glyph.
- **Structure checks** read the structure tree through the role map; table regularity counts
`ColSpan`/`RowSpan` attributes.
- The report is a self-contained HTML page. Fixes that edit the document (language, title, tab
+27 -24
View File
@@ -165,7 +165,7 @@ pub(crate) fn page_content(doc: &Document, page: &Dict) -> Vec<u8> {
.flat_map(|x| match &*doc.resolve(x) {
Object::Stream(s) => {
let mut v = s.decoded().unwrap_or_default();
v.push(b'\n');
v.push(b' ');
v
}
_ => Vec::new(),
@@ -321,25 +321,33 @@ fn marked(doc: &Document, props: &Dict, op: &pdfcraft_content::Op, ids: &HashSet
fn artifact_marked_content(doc: &mut Document, page: &pdfcraft_model::Page, ids: &HashSet<i64>) -> Result<bool, AltError> {
let Some(entries) = content_entries(doc, &page.dict) else { return Ok(false) };
let props = page_properties(doc, &page.dict);
let mut streams: Vec<Option<(Stream, Vec<u8>)>> = Vec::with_capacity(entries.len());
for entry in &entries {
match &*doc.resolve(entry) {
Object::Stream(s) => streams.push(Some((s.clone(), s.decoded().unwrap_or_default()))),
_ => streams.push(None),
}
}
let decoded: Vec<&[u8]> = streams.iter().flatten().map(|(_, bytes)| bytes.as_slice()).collect();
let pieces = pdfcraft_content::Pieces::join(&decoded);
let edits: Vec<_> =
pieces.parse().ops.into_iter().filter(|op| marked(doc, &props, op, ids)).map(|op| (op.span, b"/Artifact BMC".to_vec())).collect();
if edits.is_empty() {
return Ok(false);
}
let mut spliced = pieces.splice(edits).into_iter();
let mut next = Vec::with_capacity(entries.len());
let mut changed = false;
for entry in entries {
let Some(stream) = (match &*doc.resolve(&entry) {
Object::Stream(s) => Some(s.clone()),
_ => None,
}) else {
for (entry, stream) in entries.into_iter().zip(streams) {
let Some((stream, old)) = stream else {
next.push(entry);
continue;
};
let mut ops = pdfcraft_content::parse(&stream.decoded().unwrap_or_default()).ops;
let mut ours = false;
for op in &mut ops {
if marked(doc, &props, op, ids) {
*op = pdfcraft_content::Op::new("BMC", vec![Object::name("Artifact")]);
ours = true;
}
}
if !ours {
let Some(new) = spliced.next() else {
next.push(entry);
continue;
};
if new == old {
next.push(entry);
continue;
}
@@ -348,15 +356,10 @@ fn artifact_marked_content(doc: &mut Document, page: &pdfcraft_model::Page, ids:
dict.remove(b"Length");
dict.remove(b"Filter");
dict.remove(b"DecodeParms");
let stream = Object::Stream(Stream::flate(dict, &pdfcraft_content::serialize_ops(&ops)));
let stored = match entry {
Object::Ref(r) => {
doc.set(r, stream);
Object::Ref(r)
}
_ => Object::Ref(doc.add(stream)),
};
next.push(stored);
let stream = Object::Stream(Stream::flate(dict, &new));
// Splicing depends on this occurrence's neighbours. The original stream may also
// appear elsewhere in this array or on another page, where it must stay unchanged.
next.push(Object::Ref(doc.add(stream)));
}
if changed {
let contents = match next.as_slice() {
+1 -1
View File
@@ -70,7 +70,7 @@ fn content_bytes(doc: &Document, page: &pdfcraft_model::Page, index: usize) -> R
.map_err(|e| unsupported(index, format!("content cannot be decoded strictly: {e}")))?
};
all.extend_from_slice(&decoded);
all.push(b'\n');
all.push(b' ');
}
Ok(all)
}
+82 -11
View File
@@ -47,7 +47,7 @@ fn page_content(doc: &Document, page: &Dict) -> Vec<u8> {
.flat_map(|x| match &*doc.resolve(x) {
Object::Stream(s) => {
let mut v = s.decoded().unwrap_or_default();
v.push(b'\n');
v.push(b' ');
v
}
_ => Vec::new(),
@@ -143,6 +143,28 @@ fn catalog(doc: &Document) -> Dict {
doc.root().and_then(|r| doc.get(r).as_dict().cloned()).unwrap_or_default()
}
/// The most references one value may be written through. A chain is unusual but legal, and the
/// render inspector follows them (lopdf's `dereference` has a limit of its own), so the two
/// readers of a flag must agree about one.
const MAX_REFERENCE_CHAIN: usize = 32;
/// The value a chain of indirect references ends at. [`Document::resolve`] follows one reference,
/// and an indirect object's value may itself be a reference (ISO 32000-2 §7.3.10): `/Marked
/// 8 0 R` with object 8 holding `9 0 R` holding `true` is a tagged document, which this rule read
/// as untagged while `doc_info` read it as tagged. Bounded and cycle-safe, since a damaged file
/// can point a reference at itself; a chain longer than that, or one that loops, is no value.
fn resolved(doc: &Document, o: &Object) -> std::sync::Arc<Object> {
let mut seen: HashSet<ObjRef> = HashSet::new();
let mut value = doc.resolve(o);
while let Object::Ref(r) = &*value {
if seen.len() >= MAX_REFERENCE_CHAIN || !seen.insert(*r) {
return std::sync::Arc::new(Object::Null);
}
value = doc.get(*r);
}
value
}
fn text_of(doc: &Document, d: &Dict, key: &[u8]) -> Option<String> {
d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text())).map(|s| s.trim().to_owned()).filter(|s| !s.is_empty())
}
@@ -167,12 +189,16 @@ pub(crate) fn document_rule(doc: &Document, rule: Rule, pages: &[Page], scan: &S
}
}
Rule::TaggedPdf => {
let marked = cat.get(b"MarkInfo").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().and_then(|d| d.get(b"Marked").cloned()));
// `/Marked` may be an indirect reference like any other value -- or a chain of
// them -- so it is followed to the value before the test below, which looks for the
// boolean itself (#3671).
let marked =
cat.get(b"MarkInfo").map(|m| resolved(doc, m)).and_then(|m| m.as_dict().and_then(|d| d.get(b"Marked").map(|v| resolved(doc, v))));
let mut out = Vec::new();
if !cat.contains(b"StructTreeRoot") {
out.push(Finding::plain(None, "The document has no tags (structure tree)"));
}
if !matches!(marked, Some(Object::Bool(true))) {
if !matches!(marked.as_deref(), Some(Object::Bool(true))) {
out.push(Finding::plain(None, "The document isn't marked as tagged (MarkInfo Marked)"));
}
out
@@ -360,8 +386,47 @@ fn full_name(doc: &Document, field: ObjRef) -> String {
parts.join(".")
}
/// Whether text in this font can be mapped to Unicode (ToUnicode, a standard encoding, or a
/// predefined CJK character collection).
/// Predefined CJK CMaps (ISO 32000-2 §9.7.5.2, Table 116), excluding Identity-H/V.
/// Test complete names: neither a custom CMap nor a nonexistent vertical variant is predefined.
fn predefined_cjk_cmap(name: &[u8]) -> bool {
if matches!(name, b"H" | b"V" | b"83pv-RKSJ-H" | b"90pv-RKSJ-H" | b"KSCpc-EUC-H" | b"UniAKR-UTF8-H" | b"UniAKR-UTF16-H" | b"UniAKR-UTF32-H") {
return true;
}
let Some(base) = name.strip_suffix(b"-H").or_else(|| name.strip_suffix(b"-V")) else { return false };
matches!(
base,
b"GB-EUC"
| b"GBpc-EUC"
| b"GBK-EUC"
| b"GBKp-EUC"
| b"GBK2K"
| b"UniGB-UCS2"
| b"UniGB-UTF16"
| b"B5pc"
| b"HKscs-B5"
| b"ETen-B5"
| b"ETenms-B5"
| b"CNS-EUC"
| b"UniCNS-UCS2"
| b"UniCNS-UTF16"
| b"90ms-RKSJ"
| b"90msp-RKSJ"
| b"Add-RKSJ"
| b"EUC"
| b"Ext-RKSJ"
| b"UniJIS-UCS2"
| b"UniJIS-UCS2-HW"
| b"UniJIS-UTF16"
| b"KSC-EUC"
| b"KSCms-UHC"
| b"KSCms-UHC-HW"
| b"UniKS-UCS2"
| b"UniKS-UTF16"
)
}
/// Whether text in this font declares a Unicode mapping route (ToUnicode, a standard encoding,
/// a predefined CJK CMap, or an Adobe CJK character collection). This does not validate every glyph.
fn maps_to_unicode(doc: &Document, f: &Dict) -> bool {
if f.contains(b"ToUnicode") {
return true;
@@ -369,16 +434,22 @@ fn maps_to_unicode(doc: &Document, f: &Dict) -> bool {
let enc = f.get(b"Encoding").map(|e| doc.resolve(e));
match f.name(b"Subtype") {
Some(b"Type0") => {
let identity = matches!(enc.as_deref(), Some(Object::Name(n)) if n.starts_with(b"Identity"));
let ordering = f
// ISO 32000-2 §9.10.2: either a predefined non-Identity CMap or the descendant's
// Adobe CJK collection supplies the route. Identity-H/V do not rule out the latter.
if matches!(enc.as_deref(), Some(Object::Name(n)) if predefined_cjk_cmap(n)) {
return true;
}
let system = f
.get(b"DescendantFonts")
.map(|d| doc.resolve(d))
.and_then(|d| d.as_array().and_then(|a| a.first().cloned()))
.and_then(|d| doc.resolve(&d).as_dict().cloned())
.and_then(|d| d.get(b"CIDSystemInfo").map(|c| doc.resolve(c)).and_then(|c| c.as_dict().cloned()))
.and_then(|c| c.get(b"Ordering").map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.bytes.clone())));
let known = matches!(ordering.as_deref(), Some(b"Japan1" | b"GB1" | b"CNS1" | b"Korea1" | b"KR"));
!identity && known
.and_then(|d| d.get(b"CIDSystemInfo").map(|c| doc.resolve(c)).and_then(|c| c.as_dict().cloned()));
let Some(system) = system else { return false };
let registry = system.get(b"Registry").map(|r| doc.resolve(r));
let ordering = system.get(b"Ordering").map(|o| doc.resolve(o));
matches!(registry.as_deref(), Some(Object::String(s)) if s.bytes == b"Adobe")
&& matches!(ordering.as_deref(), Some(Object::String(s)) if matches!(s.bytes.as_slice(), b"Japan1" | b"GB1" | b"CNS1" | b"Korea1" | b"KR"))
}
Some(b"Type3") => enc.is_some(),
_ => match enc.as_deref() {
+3 -1
View File
@@ -263,7 +263,9 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap<i64, String> {
tm = tlm;
}
let pieces: Vec<&Object> = match op.op.as_slice() {
b"TJ" => op.operands.first().and_then(Object::as_array).map(|a| a.iter().collect()).unwrap_or_default(),
// A surplus array (`[(A)] [(B)] TJ`) is malformed, but the operator still takes the
// one immediately before it, as the renderer and every other reader here do (#3569).
b"TJ" => op.operands.last().and_then(Object::as_array).map(|a| a.iter().collect()).unwrap_or_default(),
_ => op.operands.last().into_iter().collect(),
};
for piece in pieces {
+168
View File
@@ -192,6 +192,44 @@ fn an_untagged_document_fails_the_document_rules() {
assert_eq!((only.count(Status::Failed), only.count(Status::Skipped)), (1, 31));
}
/// `/MarkInfo /Marked` may be written as an indirect reference, like any value. The rule read the
/// reference itself and so reported a tagged document as untagged (#3671).
#[test]
fn marked_as_an_indirect_reference_still_counts_as_tagged() {
let mut bodies = good();
// The catalog is object 1; point /Marked at a new object holding `true`.
let last = bodies.len() + 1;
bodies[0] = bodies[0].replace("/MarkInfo << /Marked true >>", &format!("/MarkInfo << /Marked {last} 0 R >>"));
bodies.push("true".into());
let doc = pdf(&bodies, "");
let report = check(&doc, &Options { rules: [Rule::TaggedPdf].into(), pages: None });
assert_eq!(status(&report, Rule::TaggedPdf), Status::Passed, "{:?}", failed(&report));
// A reference to a reference is followed too: the render inspector follows chains, so the
// two readers of the flag would otherwise disagree about one file.
let mut chain = good();
let first = chain.len() + 1;
chain[0] = chain[0].replace("/MarkInfo << /Marked true >>", &format!("/MarkInfo << /Marked {first} 0 R >>"));
chain.push(format!("{} 0 R", first + 1));
chain.push("true".into());
let report = check(&pdf(&chain, ""), &Options { rules: [Rule::TaggedPdf].into(), pages: None });
assert_eq!(status(&report, Rule::TaggedPdf), Status::Passed, "{:?}", failed(&report));
// A reference that points at itself is no value, not a hang.
let mut loops = good();
let at = loops.len() + 1;
loops[0] = loops[0].replace("/MarkInfo << /Marked true >>", &format!("/MarkInfo << /Marked {at} 0 R >>"));
loops.push(format!("{at} 0 R"));
let report = check(&pdf(&loops, ""), &Options { rules: [Rule::TaggedPdf].into(), pages: None });
assert_eq!(status(&report, Rule::TaggedPdf), Status::Failed);
// The direct form is unchanged, and a document with no flag at all still fails.
assert_eq!(status(&check(&pdf(&good(), ""), &Options { rules: [Rule::TaggedPdf].into(), pages: None }), Rule::TaggedPdf), Status::Passed);
let mut none = good();
none[0] = none[0].replace("/MarkInfo << /Marked true >>", "");
assert_eq!(status(&check(&pdf(&none, ""), &Options { rules: [Rule::TaggedPdf].into(), pages: None }), Rule::TaggedPdf), Status::Failed);
}
/// A tagged document that passes every automatic rule.
fn good() -> Vec<String> {
vec![
@@ -536,6 +574,126 @@ fn scans_identity_fonts_and_long_documents() {
assert_eq!(some.result(Rule::CharacterEncoding).unwrap().findings[0].page, Some(3));
}
fn cid_encoding_pdf(encoding: &str, system: &str, to_unicode: bool) -> Document {
let unicode = if to_unicode { "/ToUnicode 7 0 R" } else { "" };
pdf(
&[
"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(),
stream("/P << /MCID 0 >> BDC BT /F1 12 Tf <002200230024> Tj ET EMC"),
format!("<< /Type /Font /Subtype /Type0 /BaseFont /SyntheticCID /Encoding {encoding} /DescendantFonts [6 0 R] {unicode} >>"),
format!("<< /Type /Font /Subtype /CIDFontType0 /BaseFont /SyntheticCID /CIDSystemInfo {system} >>"),
stream(
"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /SyntheticUnicode def /CMapType 2 def 1 begincodespacerange <0000> <FFFF> endcodespacerange 3 beginbfchar <0022> <0041> <0023> <0042> <0024> <0043> endbfchar endcmap CMapName currentdict /CMap defineresource pop end end",
),
],
"",
)
}
#[test]
fn character_encoding_accepts_identity_cjk_collections() {
for encoding in ["/Identity-H", "/Identity-V"] {
for ordering in ["Japan1", "GB1", "CNS1", "Korea1", "KR"] {
let system = format!("<< /Registry (Adobe) /Ordering ({ordering}) /Supplement 0 >>");
let doc = cid_encoding_pdf(encoding, &system, false);
let report = checked(&doc, &[Rule::CharacterEncoding]);
let result = report.result(Rule::CharacterEncoding).unwrap();
assert_eq!(result.status, Status::Passed, "{encoding}, Adobe-{ordering}: {:?}", result.findings);
assert!(result.findings.is_empty());
}
}
}
#[test]
fn character_encoding_accepts_predefined_cmaps_without_a_known_collection() {
// The predefined CMap and the descendant's character collection are alternative routes.
let system = "<< /Registry (Private) /Ordering (Custom) /Supplement 0 >>";
for name in [
"GB-EUC-H",
"GB-EUC-V",
"GBK2K-H",
"GBK2K-V",
"UniGB-UTF16-H",
"UniGB-UTF16-V",
"B5pc-H",
"B5pc-V",
"UniCNS-UCS2-H",
"UniCNS-UCS2-V",
"83pv-RKSJ-H",
"90pv-RKSJ-H",
"90ms-RKSJ-H",
"90ms-RKSJ-V",
"H",
"V",
"UniJIS-UCS2-HW-H",
"UniJIS-UCS2-HW-V",
"KSCpc-EUC-H",
"KSC-EUC-V",
"KSCms-UHC-HW-H",
"KSCms-UHC-HW-V",
"UniKS-UTF16-H",
"UniKS-UTF16-V",
"UniAKR-UTF8-H",
"UniAKR-UTF16-H",
"UniAKR-UTF32-H",
] {
let doc = cid_encoding_pdf(&format!("/{name}"), system, false);
let report = checked(&doc, &[Rule::CharacterEncoding]);
assert_eq!(status(&report, Rule::CharacterEncoding), Status::Passed, "{name}");
}
}
#[test]
fn character_encoding_rejects_unknown_cmaps_and_foreign_collections() {
for encoding in
["/Identity-H", "/Identity-V", "/Custom-H", "/UniCustom-UTF16-H", "/UniJIS-UCS2-HW-extra-H", "/83pv-RKSJ-V", "/KSCpc-EUC-V", "null"]
{
for system in [
"<< /Registry (Adobe) /Ordering (Identity) /Supplement 0 >>",
"<< /Registry (Adobe) /Ordering (Custom) /Supplement 0 >>",
"<< /Registry (Private) /Ordering (Japan1) /Supplement 0 >>",
"<< /Registry (adobe) /Ordering (Japan1) /Supplement 0 >>",
"<< /Ordering (Japan1) /Supplement 0 >>",
"<< /Registry /Adobe /Ordering /Japan1 /Supplement 0 >>",
"null",
] {
let doc = cid_encoding_pdf(encoding, system, false);
let report = checked(&doc, &[Rule::CharacterEncoding]);
let result = report.result(Rule::CharacterEncoding).unwrap();
assert_eq!(result.status, Status::Failed, "{encoding}, {system}");
assert_eq!(result.findings.len(), 1);
assert_eq!(result.findings[0].page, Some(0));
assert!(result.findings[0].message.contains("SyntheticCID"));
}
}
let doc = cid_encoding_pdf("/Identity-H", "<< /Registry (Private) /Ordering (Custom) /Supplement 0 >>", true);
assert_eq!(status(&checked(&doc, &[Rule::CharacterEncoding]), Rule::CharacterEncoding), Status::Passed, "ToUnicode is still sufficient");
}
#[test]
fn character_encoding_resolves_indirect_cid_metadata() {
use pdfcraft_cos::{Dict, ObjRef, PdfString};
let mut doc = cid_encoding_pdf("/Identity-H", "null", false);
let registry = doc.add(Object::String(PdfString::literal(b"Adobe".to_vec())));
let ordering = doc.add(Object::String(PdfString::literal(b"Japan1".to_vec())));
let mut system = Dict::new();
system.set(b"Registry".to_vec(), Object::Ref(registry));
system.set(b"Ordering".to_vec(), Object::Ref(ordering));
let system = doc.add(Object::Dict(system));
doc.update_dict(ObjRef::new(6, 0), |d| d.set(b"CIDSystemInfo".to_vec(), Object::Ref(system))).unwrap();
let descendants = doc.add(Object::Array(vec![Object::Ref(ObjRef::new(6, 0))]));
let encoding = doc.add(Object::Name(b"Identity-V".to_vec()));
doc.update_dict(ObjRef::new(5, 0), |d| {
d.set(b"DescendantFonts".to_vec(), Object::Ref(descendants));
d.set(b"Encoding".to_vec(), Object::Ref(encoding));
})
.unwrap();
let report = checked(&doc, &[Rule::CharacterEncoding]);
assert_eq!(status(&report, Rule::CharacterEncoding), Status::Passed);
}
#[test]
fn rules_have_ids_and_the_report_lists_them() {
assert_eq!(Rule::ALL.len(), 32);
@@ -926,6 +1084,16 @@ fn an_untagged_document_has_no_headings() {
assert!(headings(&pdf(&objs, "")).is_empty());
}
/// `[(FIRSTARR)] [(LASTARR)] TJ` is malformed (a surplus array operand), but the operator still
/// draws only the one immediately before it, as the renderer and every other reader agree (#3569)
/// -- not the first, which is never drawn at all.
#[test]
fn a_heading_with_a_tj_surplus_array_reads_the_one_the_page_draws() {
let doc = heading_page(&["BT /F1 20 Tf 72 700 Td [(FIRSTARR)] [(LASTARR)] TJ ET".into()]);
let titles: Vec<String> = headings(&doc).into_iter().map(|h| h.title).collect();
assert_eq!(titles, ["LASTARR"]);
}
/// The text of every `"…"` literal with a space in it, outside comments and byte strings.
fn sentences(source: &str) -> Vec<String> {
let b = source.as_bytes();
+254
View File
@@ -0,0 +1,254 @@
use pdfcraft_a11y::{Options, Rule, Status, autotag_text, check, figures, mark_decorative, mark_tag_artifact, tag_content, tags};
use pdfcraft_content::{Op, Pieces};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full, write_incremental};
const FONT: &str = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>";
const HIDDEN_AND_AFTER: &str = "BT /F1 12 Tf 72 520 Td (Hidden) Tj ET\nBT /F1 12 Tf 72 500 Td (After) Tj ET";
fn stream(data: &str) -> String {
format!("<< /Length {} >>\nstream\n{data}\nendstream", data.len())
}
fn build_pdf(objects: &[(u32, String)]) -> Document {
let mut raw = Vec::from(&b"%PDF-1.7\n"[..]);
let max_num = objects.iter().map(|(n, _)| *n).max().unwrap_or(0);
let mut offsets = std::collections::HashMap::new();
for (num, body) in objects {
offsets.insert(*num, raw.len());
raw.extend_from_slice(format!("{num} 0 obj\n{body}\nendobj\n").as_bytes());
}
let xref = raw.len();
let size = max_num + 1;
raw.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f \n").as_bytes());
for num in 1..size {
if let Some(off) = offsets.get(&num) {
raw.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
} else {
raw.extend_from_slice(b"0000000000 65535 f \n");
}
}
raw.extend_from_slice(format!("trailer\n<< /Size {size} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
Document::open(std::sync::Arc::new(raw)).expect("valid synthetic PDF")
}
fn untagged_two_streams(first: &str, second: &str) -> Document {
build_pdf(&[
(1, "<< /Type /Catalog /Pages 2 0 R >>".into()),
(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into()),
(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents [5 0 R 6 0 R] /Resources << /Font << /F1 4 0 R >> >> >>".into()),
(4, FONT.into()),
(5, stream(first)),
(6, stream(second)),
])
}
fn tagged_figure_two_streams(after_figure: &str, second: &str) -> Document {
let figure = "/Figure << /MCID 0 >> BDC q 50 0 0 50 72 600 cm /Im1 Do Q EMC\n";
let first = format!("{figure}{after_figure}");
build_pdf(&[
(
1,
"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 6 0 R /MarkInfo << /Marked true >> >>"
.into(),
),
(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into()),
(
3,
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /StructParents 0 /Contents [10 0 R 11 0 R] /Resources << /Font << /F1 4 0 R >> /XObject << /Im1 5 0 R >> >> >>".into(),
),
(4, FONT.into()),
(
5,
"<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 4 >>\nstream\n\x00\x7f\x40\x20\nendstream".into(),
),
(6, "<< /Type /StructTreeRoot /K 7 0 R /ParentTree 9 0 R >>".into()),
(7, "<< /Type /StructElem /S /Document /P 6 0 R /K [8 0 R] >>".into()),
(
8,
"<< /Type /StructElem /S /Figure /P 7 0 R /Pg 3 0 R /K 0 /Alt (A grey square) >>"
.into(),
),
(9, "<< /Nums [0 [8 0 R]] >>".into()),
(10, stream(&first)),
(11, stream(second)),
])
}
fn joined_ops(doc: &Document) -> (Vec<Op>, usize) {
let page = &pdfcraft_model::pages(doc)[0];
let contents = page.dict.get(b"Contents").expect("page contents");
let pieces: Vec<Vec<u8>> = match &*doc.resolve(contents) {
Object::Stream(s) => vec![s.decoded().expect("decoded stream")],
Object::Array(items) => items
.iter()
.filter_map(|item| match &*doc.resolve(item) {
Object::Stream(s) => Some(s.decoded().expect("decoded stream")),
_ => None,
})
.collect(),
_ => Vec::new(),
};
let joined = Pieces::join(&pieces);
let parsed = joined.parse();
(parsed.ops, parsed.skipped)
}
fn shown_strings(ops: &[Op]) -> Vec<String> {
ops.iter().filter(|op| op.is("Tj")).filter_map(|op| op.operands.first()?.as_string().map(|s| s.to_text())).collect()
}
#[test]
fn mark_decorative_leaves_the_other_stream_untouched() {
let mut doc = tagged_figure_two_streams("0 0 1 rg 300 600 100 50 re f", "BT /F1 12 Tf 72 500 Td (After) Tj ET");
let before = doc.get(ObjRef::new(11, 0)).clone();
mark_decorative(&mut doc, ObjRef::new(8, 0)).expect("mark decorative succeeds");
assert!(figures(&doc).is_empty());
assert_eq!(*doc.get(ObjRef::new(11, 0)), *before, "the second stream holds no figure and keeps its object");
let page = doc.get(ObjRef::new(3, 0));
let contents = page.as_dict().unwrap().get(b"Contents").unwrap().as_array().unwrap();
assert_eq!(contents[1].as_ref(), Some(ObjRef::new(11, 0)), "the untouched stream must not be replaced by a re-encoded copy");
}
/// A and B supply different operands to R's BDC. Reusing R must not let the first
/// occurrence's splice remove the unselected second occurrence's BDC.
fn split_marked_content(shared: bool) -> Document {
let mut doc = tagged_figure_two_streams("", "");
doc.set(ObjRef::new(10, 0), Object::Stream(Stream::flate(Dict::new(), b"/Figure << /MCID 0 >>")));
let body = Object::Stream(Stream::flate(Dict::new(), b"BDC 0 0 10 10 re f EMC"));
doc.set(ObjRef::new(11, 0), body.clone());
let b = doc.add(Object::Stream(Stream::flate(Dict::new(), b"/Span << /MCID 1 >>")));
let second = if shared { ObjRef::new(11, 0) } else { doc.add(body) };
doc.update_dict(ObjRef::new(3, 0), |p| {
p.set(
b"Contents".to_vec(),
Object::Array(vec![Object::Ref(ObjRef::new(10, 0)), Object::Ref(ObjRef::new(11, 0)), Object::Ref(b), Object::Ref(second)]),
);
})
.unwrap();
Document::open(std::sync::Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap()
}
fn assert_selected_occurrence_only(shared: bool) {
let mut doc = split_marked_content(shared);
let (before, skipped) = joined_ops(&doc);
assert_eq!(skipped, 0);
assert_eq!(before.iter().filter(|op| op.is("BDC")).count(), 2);
let figure = figures(&doc)[0].obj;
mark_decorative(&mut doc, figure).unwrap();
for bytes in [write_full(&doc, &SaveOptions::default()).unwrap(), write_incremental(&doc, &SaveOptions::default()).unwrap()] {
let reopened = Document::open(std::sync::Arc::new(bytes)).unwrap();
let (ops, skipped) = joined_ops(&reopened);
assert_eq!(skipped, 0);
assert_eq!(ops.iter().filter(|op| op.is("BMC") && op.name(0) == Some(b"Artifact")).count(), 1);
let unselected: Vec<_> = ops.iter().filter(|op| op.is("BDC")).collect();
assert_eq!(unselected.len(), 1, "unselected Span must retain its BDC after save/reopen");
assert_eq!(unselected[0].name(0), Some(b"Span".as_slice()));
assert_eq!(unselected[0].operands[1].as_dict().unwrap().get(b"MCID").and_then(Object::as_int), Some(1));
assert_eq!(ops.iter().filter(|op| op.is("re")).count(), 2);
assert_eq!(ops.iter().filter(|op| op.is("EMC")).count(), 2);
assert!(figures(&reopened).is_empty());
}
}
#[test]
fn mark_decorative_isolates_shared_stream_occurrences_after_save() {
assert_selected_occurrence_only(true);
}
#[test]
fn mark_decorative_preserves_unique_stream_occurrences_after_save() {
assert_selected_occurrence_only(false);
}
#[test]
fn mark_decorative_leaves_another_page_using_the_stream_untouched() {
let mut doc = tagged_figure_two_streams("", "BT /F1 12 Tf 72 500 Td (After) Tj ET");
let page = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().clone();
let other_page = doc.add(Object::Dict(page));
doc.update_dict(ObjRef::new(2, 0), |p| {
p.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(3, 0)), Object::Ref(other_page)]));
p.set(b"Count".to_vec(), Object::Int(2));
})
.unwrap();
let before = doc.get(ObjRef::new(10, 0)).clone();
mark_decorative(&mut doc, ObjRef::new(8, 0)).unwrap();
for bytes in [write_full(&doc, &SaveOptions::default()).unwrap(), write_incremental(&doc, &SaveOptions::default()).unwrap()] {
let reopened = Document::open(std::sync::Arc::new(bytes)).unwrap();
let pages = pdfcraft_model::pages(&reopened);
assert_eq!(pages.len(), 2);
let contents = pages[1].dict.get(b"Contents").unwrap().as_array().unwrap();
assert_eq!(*reopened.resolve(&contents[0]), *before, "the other page's shared figure stream must stay intact");
let (ops, skipped) = joined_ops(&reopened);
assert_eq!(skipped, 0);
assert!(ops.iter().any(|op| op.is("BMC") && op.name(0) == Some(b"Artifact")));
}
}
#[test]
fn autotag_keeps_text_commented_out_across_stream_boundary_hidden() {
let mut doc = untagged_two_streams("BT /F1 12 Tf 72 540 Td (Visible) Tj ET\n% a note at the end of the first stream", HIDDEN_AND_AFTER);
let result = autotag_text(&mut doc).expect("autotag succeeds");
assert_eq!(result.pages, 1);
assert_eq!(result.paragraphs, 2, "commented-out text at start of second stream must not be tagged");
let (ops, skipped) = joined_ops(&doc);
assert_eq!(skipped, 0);
assert_eq!(shown_strings(&ops), ["Visible", "After"]);
let p_tags: Vec<_> = tags(&doc).into_iter().filter(|t| t.kind == "P").collect();
assert_eq!(p_tags.len(), 2);
let texts: Vec<_> = p_tags.iter().flat_map(|t| tag_content(&doc, t.object).expect("tag content")).map(|item| item.text).collect();
assert_eq!(texts, ["Visible", "After"]);
// Control: when the comment ends with a newline inside the first stream, Hidden is visible.
let mut control = untagged_two_streams("BT /F1 12 Tf 72 540 Td (Visible) Tj ET\n% a note at the end of the first stream\n", HIDDEN_AND_AFTER);
let control_res = autotag_text(&mut control).expect("control autotag succeeds");
assert_eq!(control_res.paragraphs, 3);
let (control_ops, _) = joined_ops(&control);
assert_eq!(shown_strings(&control_ops), ["Visible", "Hidden", "After"]);
}
#[test]
fn mark_decorative_preserves_shape_split_across_content_streams() {
let mut doc = tagged_figure_two_streams("0 0 1 rg 300 600 100 50", "re f\nBT /F1 12 Tf 72 500 Td (After) Tj ET");
mark_decorative(&mut doc, ObjRef::new(8, 0)).expect("mark decorative succeeds");
assert!(figures(&doc).is_empty());
let (ops, skipped) = joined_ops(&doc);
assert_eq!(skipped, 0, "split rectangle operands must not be dropped as skipped tokens");
let re_op = ops.iter().find(|op| op.is("re")).expect("rectangle operator must survive");
assert_eq!(re_op.nums::<4>(), Some([300.0, 600.0, 100.0, 50.0]));
assert_eq!(shown_strings(&ops), ["After"]);
// Control: rectangle whole in the first stream.
let mut control = tagged_figure_two_streams("0 0 1 rg 300 600 100 50 re f", "BT /F1 12 Tf 72 500 Td (After) Tj ET");
mark_decorative(&mut control, ObjRef::new(8, 0)).expect("control mark decorative succeeds");
let (control_ops, control_skipped) = joined_ops(&control);
assert_eq!(control_skipped, 0);
let control_re = control_ops.iter().find(|op| op.is("re")).expect("control rectangle");
assert_eq!(control_re.nums::<4>(), Some([300.0, 600.0, 100.0, 50.0]));
assert_eq!(shown_strings(&control_ops), ["After"]);
}
#[test]
fn mark_decorative_and_mark_artifact_keep_cross_stream_comment_hidden() {
let mut doc = tagged_figure_two_streams("% a note at the end of the first stream", HIDDEN_AND_AFTER);
mark_decorative(&mut doc, ObjRef::new(8, 0)).expect("mark decorative succeeds");
let (ops, skipped) = joined_ops(&doc);
assert_eq!(skipped, 0);
assert_eq!(shown_strings(&ops), ["After"], "commented-out Hidden text must not become visible after mark_decorative");
let mut doc_artifact = tagged_figure_two_streams("% a note at the end of the first stream", HIDDEN_AND_AFTER);
mark_tag_artifact(&mut doc_artifact, ObjRef::new(8, 0)).expect("mark_tag_artifact succeeds");
let (artifact_ops, artifact_skipped) = joined_ops(&doc_artifact);
assert_eq!(artifact_skipped, 0);
assert_eq!(shown_strings(&artifact_ops), ["After"], "commented-out Hidden text must not become visible after mark_tag_artifact");
// Checker also honours the cross-stream comment when scanning for untagged content.
let mut comment_only = tagged_figure_two_streams("% a note at the end of the first stream", "BT /F1 12 Tf 72 520 Td (Hidden) Tj ET\n");
let report = check(&comment_only, &Options { rules: [Rule::TaggedContent].into(), pages: None });
assert_eq!(report.result(Rule::TaggedContent).expect("rule result").status, Status::Passed);
mark_decorative(&mut comment_only, ObjRef::new(8, 0)).expect("mark decorative");
let report_after = check(&comment_only, &Options { rules: [Rule::TaggedContent].into(), pages: None });
assert_eq!(report_after.result(Rule::TaggedContent).expect("rule result").status, Status::Passed);
}
+27 -22
View File
@@ -559,7 +559,7 @@ pub fn build(d: &Dict) -> Option<Stream> {
c.push_str("h\n");
}
}
c.push_str("f*\n");
c.push_str("f\n");
return Some(form(rect, c.as_bytes(), res));
}
// A custom stamp: its picture (an image, or a form mapped to /PCPictureSize) fills
@@ -593,7 +593,28 @@ pub fn build(d: &Dict) -> Option<Stream> {
// Only PdfCraft's own Fill & Sign marks are drawn here.
let name = d.name(b"Name")?;
let col = stroke.unwrap_or([0.0; 3]);
let [x0, y0, x1, y1] = rect;
// A mark or stamp placed on a turned page is drawn upright in its displayed box, then
// mapped into `/Rect` the way the page maps display space into user space. Viewers
// fit that box to `/Rect`; the page rotation then shows it the right way up.
let turn = stamp_turn(d);
let draw = if turn == 0 {
rect
} else {
let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]);
let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) };
if dw < 1.0 || dh < 1.0 {
return None;
}
[0.0, 0.0, dw, dh]
};
let turned = |mut stream: Stream| {
if turn != 0 {
let m = pdfcraft_model::view_matrix_for(turn, rect);
stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect()));
}
stream
};
let [x0, y0, x1, y1] = draw;
let (w, h) = (x1 - x0, y1 - y0);
let lw = w.min(h) * 0.12;
match name {
@@ -643,28 +664,12 @@ pub fn build(d: &Dict) -> Option<Stream> {
}
let kind = crate::StampKind::from_name(other)?;
let by = d.get(b"PCByLine").and_then(|o| o.as_string()).map(PdfString::to_text);
let turn = stamp_turn(d);
let draw = if turn == 0 {
rect
} else {
let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]);
let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) };
if dw < 1.0 || dh < 1.0 {
return None;
}
[0.0, 0.0, dw, dh]
};
let mut stream = stamp(kind, draw, stroke.unwrap_or(kind.color()), by.as_deref(), opacity, res);
if turn != 0 {
// Upright in the stamp's displayed box, then mapped into `/Rect` the way
// the page maps display space into user space. Viewers fit that box to
// `/Rect`; the page rotation then shows the label the right way up.
let m = pdfcraft_model::view_matrix_for(turn, rect);
stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect()));
}
return Some(stream);
return Some(turned(stamp(kind, draw, stroke.unwrap_or(kind.color()), by.as_deref(), opacity, res)));
}
}
if turn != 0 {
return Some(turned(form(draw, c.as_bytes(), res)));
}
}
b"FreeText" => {
let (text_color, size) = parse_da(d);
+155 -19
View File
@@ -125,6 +125,14 @@ impl FillMark {
FillMark::Line => "Line",
}
}
/// The user-space box of the mark placed at `at` on a page turned by `rotation`: centred
/// there and sized in displayed axes, so a line runs across the page as displayed (#2392).
pub fn rect_at(self, at: [f64; 2], rotation: i64) -> [f64; 4] {
let (w, h) = if self == FillMark::Line { (36.0, 4.0) } else { (12.0, 12.0) };
let (w, h) = if matches!(rotation.rem_euclid(360), 90 | 270) { (h, w) } else { (w, h) };
[at[0] - w / 2.0, at[1] - h / 2.0, at[0] + w / 2.0, at[1] + h / 2.0]
}
}
/// The stamps of Acrobat's stamp palette (drawn in PdfCraft's own style).
@@ -386,7 +394,7 @@ pub enum Shape {
by: Option<String>,
},
/// A typed signature or initials: filled outlines (each contour as points normalised to
/// `rect`, 0–1, y up), drawn with the even-odd rule.
/// `rect`, 0–1, y up), drawn with the non-zero winding rule.
TypedSignature {
rect: [f64; 4],
contours: Vec<Vec<[f64; 2]>>,
@@ -651,25 +659,54 @@ impl ReviewState {
pub fn page_refs(doc: &Document) -> Result<Vec<ObjRef>, AnnotError> {
let root = doc.root().ok_or(AnnotError::NoPageTree)?;
let pages = doc.get(root).as_dict().and_then(|d| d.reference(b"Pages")).ok_or(AnnotError::NoPageTree)?;
let mut out = Vec::new();
let mut seen = std::collections::HashSet::new();
let mut stack = vec![pages];
while let Some(node) = stack.pop() {
if !seen.insert(node) || seen.len() > 1_000_000 {
continue;
}
let obj = doc.get(node);
let Some(d) = obj.as_dict() else { continue };
match d.get(b"Kids").map(|k| doc.resolve(k)) {
Some(kids) if d.name(b"Type") != Some(b"Page") => {
if let Some(a) = kids.as_array() {
stack.extend(a.iter().rev().filter_map(|k| k.as_ref()));
}
}
_ => out.push(node),
}
Ok(PageWalk::new(doc, pages).collect())
}
/// Depth-first walk over the leaf pages under `top`. It keeps one cursor per open `/Kids` array,
/// never one entry per child, so a child listed many times or shared between nodes is skipped by
/// the seen check without being queued first (`pdfcraft_model::pages` walks the same way).
/// Cycles end at the seen check and at most a million nodes are visited.
struct PageWalk<'a> {
doc: &'a Document,
seen: std::collections::HashSet<ObjRef>,
pending: Vec<(std::sync::Arc<Object>, usize)>,
}
impl<'a> PageWalk<'a> {
fn new(doc: &'a Document, top: ObjRef) -> Self {
let top = std::sync::Arc::new(Object::Array(vec![Object::Ref(top)]));
Self { doc, seen: Default::default(), pending: vec![(top, 0)] }
}
}
impl Iterator for PageWalk<'_> {
type Item = ObjRef;
fn next(&mut self) -> Option<ObjRef> {
while let Some((kids, next)) = self.pending.last_mut() {
let Some(kid) = kids.as_array().and_then(|a| a.get(*next)) else {
self.pending.pop();
continue;
};
let node = kid.as_ref();
*next += 1;
let Some(node) = node else { continue };
if self.seen.len() >= 1_000_000 {
self.pending.clear();
return None;
}
if !self.seen.insert(node) {
continue;
}
let obj = self.doc.get(node);
let Some(d) = obj.as_dict() else { continue };
match d.get(b"Kids").map(|k| self.doc.resolve(k)) {
Some(kids) if d.name(b"Type") != Some(b"Page") => self.pending.push((kids, 0)),
_ => return Some(node),
}
}
None
}
Ok(out)
}
fn page_ref(doc: &Document, page: usize) -> Result<ObjRef, AnnotError> {
@@ -803,6 +840,27 @@ pub fn picture_rotation(doc: &Document, page: usize, index: usize) -> Result<i64
Ok(doc.resolve(entry).as_dict().map_or(0, appearance::picture_turn))
}
/// Leave page `page` of (a copy of) a document showing only its annotation at `index`, without
/// the page's content or other annotations, so a render draws that annotation alone: to lay it
/// over the page while it is dragged. `false`, with `doc` unchanged, when the annotation has no
/// normal appearance (`/AP /N`) for that render to draw.
pub fn isolate_annotation(doc: &mut Document, page: usize, index: usize) -> Result<bool, AnnotError> {
let p = page_ref(doc, page)?;
let entry = annots(doc, p).get(index).cloned().ok_or(AnnotError::NoSuchAnnotation { page, index })?;
let has_normal = {
let obj = doc.resolve(&entry);
obj.as_dict().and_then(|d| d.get(b"AP")).is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N")))
};
if !has_normal {
return Ok(false);
}
set_annots(doc, p, vec![entry])?;
doc.update_dict(p, |d| {
d.remove(b"Contents");
})?;
Ok(true)
}
// ── building ────────────────────────────────────────────────────────────────────────────────
/// Annotation flags (§12.5.3).
@@ -1081,6 +1139,11 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
d.set(b"C".to_vec(), rgb(style.color));
d.set(b"Name".to_vec(), Object::name(mark.name()));
border(&mut d);
// Drawn upright for the page as displayed, as a stamp is (#2392).
let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc));
if matches!(turn, 90 | 180 | 270) {
d.set(b"PCStampRotate".to_vec(), Object::Int(turn));
}
}
Shape::Stamp { stamp, by, .. } => {
d.set(b"C".to_vec(), rgb(style.color));
@@ -1355,8 +1418,22 @@ fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, stream: Stream) ->
apd.remove(b"D");
apd.remove(b"R");
apd.set(b"N".to_vec(), Object::Ref(ap));
// A note icon has a fixed size (`NOTE_SIZE`); viewers draw it at the upper-left corner of `/Rect`.
// The appearance's box is mapped onto `/Rect`, so any other `/Rect` (a large one, or one with
// no width, as LaTeX `pdfcomment` writes) would stretch the icon or shrink it to nothing.
let icon_rect = matches!(d.name(b"Subtype"), Some(b"Text" | b"FileAttachment"))
.then(|| d.get(b"Rect")?.as_array()?.iter().map(|o| o.as_f64()).collect::<Option<Vec<f64>>>())
.flatten()
.filter(|q| q.len() == 4 && finite(q))
.map(|q| {
let (x, top) = (q[0].min(q[2]), q[1].max(q[3]));
[x, top - NOTE_SIZE, x + NOTE_SIZE, top]
});
doc.update_dict(r, |d| {
d.set(b"AP".to_vec(), Object::Dict(apd));
if let Some(rect) = icon_rect {
d.set(b"Rect".to_vec(), num_array(&rect));
}
d.remove(b"AS");
pdfcraft_fonts::set_undrawn_mark(d, missing);
})?;
@@ -1433,8 +1510,15 @@ pub fn with_missing_appearances(doc: &Document) -> Option<Document> {
/// redrawing it. See [`AppearanceCache`] for when entries stay valid.
pub fn with_missing_appearances_cached(doc: &Document, cache: &mut AppearanceCache) -> Option<Document> {
let mut copy: Option<Document> = None;
// An annotation listed more than once, on one page or several, is drawn once. `has_normal`
// reads `doc`, which never gets the appearance, so every repeat used to add another stream to
// the copy, and the display bytes grew with the number of listings.
let mut seen = std::collections::HashSet::new();
for page in page_refs(doc).ok()? {
for r in annots(doc, page).iter().filter_map(Object::as_ref) {
if !seen.insert(r) {
continue;
}
let d = annot_dict(doc, r);
if matches!(d.name(b"Subtype"), None | Some(b"Link" | b"Widget" | b"Popup")) {
continue;
@@ -1774,6 +1858,48 @@ pub fn set_rect(doc: &mut Document, page: usize, index: usize, rect: [f64; 4], m
apply_text_box(doc, r, rect, meta, false)
}
/// At most this many points reshape a comment (a drawn polygon has a handful).
const MAX_POINTS: usize = 100_000;
/// Reshape a line, connected lines, a polygon or a cloud: its points in user space (a line's two
/// ends, the others' corners), as dragging the comment's handles sets them. `/Rect` follows the
/// points with room for the line's width and endings, as when the comment was drawn, and the
/// appearance is redrawn. A measurement keeps the points it measured.
pub fn set_points(doc: &mut Document, page: usize, index: usize, points: &[[f64; 2]], meta: &Meta) -> Result<(), AnnotError> {
let (_, r) = annot_ref(doc, page, index)?;
unlocked(doc, r)?;
let d = annot_dict(doc, r);
if d.contains(b"Measure") {
return Err(AnnotError::Invalid("a measurement's points are where it measured".into()));
}
if points.len() > MAX_POINTS {
return Err(AnnotError::Invalid(format!("at most {MAX_POINTS} points")));
}
let style = Style { width: border_width_of(&d), ..Style::default() };
let ends = line_endings_of(&d).unwrap_or_default();
let (start, end) = (ends.first().copied().unwrap_or(LineEnding::None), ends.get(1).copied().unwrap_or(LineEnding::None));
let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
let (shape, key): (Shape, &[u8]) = match (subtype.as_str(), points) {
("Line", [from, to]) => (Shape::Line { from: *from, to: *to, start, end }, b"L"),
("Line", _) => return Err(AnnotError::Invalid("a line has two ends".into())),
("PolyLine", _) => (Shape::PolyLine { vertices: points.to_vec(), start, end }, b"Vertices"),
("Polygon", _) => {
let cloud = d.get(b"BE").and_then(|b| b.as_dict()).is_some_and(|b| b.name(b"S") == Some(b"C"));
(Shape::Polygon { vertices: points.to_vec(), cloud }, b"Vertices")
}
(other, _) => return Err(AnnotError::Invalid(format!("{other} comments have no points to move"))),
};
// Checks the points too: finite, and far enough apart to draw.
let rect = rect_for(&shape, &style)?;
let before = d;
doc.update_dict(r, |d| {
d.set(key.to_vec(), num_array(&points.concat()));
d.set(b"Rect".to_vec(), num_array(&rect));
touch(d, meta);
})?;
redraw_or_restore(doc, r, before)
}
/// Scale a drawing's strokes into `rect`. The margin between the strokes and the old `/Rect` (half
/// the line width and a little more) stays the same on each side, so the line isn't clipped.
fn resize_ink(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta) -> Result<(), AnnotError> {
@@ -2082,6 +2208,9 @@ pub struct Summary {
pub intent: Option<String>,
/// A Fill & Sign mark ([`is_fill_sign`]).
pub fill_sign: bool,
/// A line's two ends (`/L`), or the corners of connected lines, a polygon or a cloud
/// (`/Vertices`), in user space; empty for other comments.
pub points: Vec<[f32; 2]>,
}
fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option<String> {
@@ -2136,6 +2265,12 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
}
}
let quads = nums(b"QuadPoints").as_chunks::<8>().0.to_vec();
// Keep in step with `pdfcraft_render::inspect`.
let points = match subtype.as_str() {
"Line" => Some(nums(b"L")).filter(|l| l.len() == 4).map(|l| l.as_chunks::<2>().0.to_vec()).unwrap_or_default(),
"PolyLine" | "Polygon" => nums(b"Vertices").as_chunks::<2>().0.to_vec(),
_ => Vec::new(),
};
let in_reply_to = d.get(b"IRT").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).and_then(|p| text_value(doc, &p, b"NM"));
out.push(Summary {
page,
@@ -2155,6 +2290,7 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
locked: annotation_flags(doc, d) & FLAG_LOCKED != 0,
intent: d.get(b"IT").and_then(|o| doc.resolve(o).as_name().map(|n| String::from_utf8_lossy(n).into_owned())),
fill_sign: is_fill_sign(doc, d),
points,
});
}
}
+122
View File
@@ -991,6 +991,29 @@ fn malformed_optional_interior_color_does_not_hide_shape_strokes() {
assert!(list(&doc, 0)[0].get(b"AP").is_none(), "display generation leaves the source document unchanged");
}
/// #3175: viewers draw a note without an appearance as a fixed-size icon at the upper-left corner
/// of its `/Rect`. The display copy must not stretch the icon over a large `/Rect` or shrink it
/// to nothing in one with no width.
#[test]
fn a_note_without_an_appearance_is_shown_at_icon_size() {
let doc = document_of(&[
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 600] >>",
b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>",
b"<< /Type /Annot /Subtype /Text /Rect [-23 240 480 468] /C [1 0 0] /Open true >>",
b"<< /Type /Annot /Subtype /Text /Rect [310 380 310 392] /Name /Comment /F 4 >>",
b"<< /Type /Annot /Subtype /FileAttachment /Rect [100 50 300 10] >>",
]);
let display = with_missing_appearances(&doc).expect("the notes get a display appearance");
let rects = |doc: &Document| -> Vec<Vec<f64>> {
let rect = |d: &Dict| d.get(b"Rect").and_then(|r| r.as_array()).map(|a| a.iter().filter_map(Object::as_f64).collect::<Vec<f64>>());
list(doc, 0).iter().map(|d| rect(d).unwrap_or_default()).collect()
};
assert_eq!(rects(&display), [vec![-23.0, 448.0, -3.0, 468.0], vec![310.0, 372.0, 330.0, 392.0], vec![100.0, 30.0, 120.0, 50.0]]);
let original = [vec![-23.0, 240.0, 480.0, 468.0], vec![310.0, 380.0, 310.0, 392.0], vec![100.0, 50.0, 300.0, 10.0]];
assert_eq!(rects(&doc), original, "the source document is unchanged");
}
#[test]
fn every_line_ending_is_drawn() {
let filled = [LineEnding::Square, LineEnding::Circle, LineEnding::Diamond, LineEnding::ClosedArrow, LineEnding::RClosedArrow];
@@ -1301,6 +1324,28 @@ fn cached_appearances_are_reused_while_annotations_are_unchanged() {
assert_eq!(a, b, "the reused appearance is byte-identical");
}
/// A comment that `/Annots` lists many times, and that a second page lists too, is drawn once in
/// the display copy. Every listing used to add another appearance stream, so a short array of
/// repeated references multiplied the display bytes the engine writes on each refresh.
#[test]
fn a_comment_listed_many_times_is_drawn_once_for_display() {
let first = format!("<< /Type /Page /Parent 2 0 R /Annots [{}] >>", vec!["5 0 R"; 1_000].join(" "));
let doc = document_of(&[
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 612 792] >>",
first.as_bytes(),
b"<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>",
b"<< /Type /Annot /Subtype /Square /Rect [10 10 50 50] /C [1 0 0] >>",
]);
let mut cache = AppearanceCache::default();
let display = with_missing_appearances_cached(&doc, &mut cache).expect("the square needs an appearance");
assert_eq!(display.object_numbers().len(), doc.object_numbers().len() + 1, "one appearance stream, not one per listing");
assert_eq!((cache.builds(), cache.hits()), (1, 0));
assert!(list(&display, 0).iter().chain(&list(&display, 1)).all(|d| d.contains(b"AP")), "every listing shows it");
with_missing_appearances_cached(&doc, &mut cache).expect("a refresh");
assert_eq!((cache.builds(), cache.hits()), (1, 1), "a refresh reuses the cached stream once");
}
/// #1908: a text box's font size changes in `/DA` (colour kept), the rich text follows, the box
/// grows to hold the bigger text but never shrinks, and bad sizes and other subtypes are refused.
#[test]
@@ -1382,3 +1427,80 @@ fn free_text_properties_show_legacy_or_absent_border_width() {
.unwrap();
assert_eq!(props(&doc, 0, index).unwrap().width, Some(4.0));
}
/// A line's ends and the corners of connected lines and polygons can be moved after drawing:
/// the points change, `/Rect` follows them (with room for an arrowhead), and the appearance is
/// drawn anew. A line needs two ends far enough apart; a locked comment keeps its shape.
#[test]
fn lines_and_polygons_are_reshaped_by_their_points() {
let mut doc = fixture();
let l = add_annotation(
&mut doc,
&new(0, Shape::Line { from: [100.0, 100.0], to: [200.0, 100.0], start: LineEnding::None, end: LineEnding::OpenArrow }),
&meta("l"),
)
.unwrap();
let before = ap_content(&doc, &list(&doc, 0)[l]);
set_points(&mut doc, 0, l, &[[100.0, 100.0], [100.0, 300.0]], &meta("")).unwrap();
let doc2 = reopen(&doc);
let d = &list(&doc2, 0)[l];
let ends: Vec<f64> = d.get(b"L").unwrap().as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect();
assert_eq!(ends, [100.0, 100.0, 100.0, 300.0]);
let r = rect(d);
assert!(r[0] < 100.0 && r[2] > 100.0 && r[1] < 100.0 && r[3] > 300.0, "the rectangle holds the line and its arrowhead: {r:?}");
assert!(r[2] - r[0] < 40.0, "and no more: {r:?}");
assert_ne!(ap_content(&doc2, d), before, "drawn anew");
assert_eq!(summaries(&doc2).into_iter().find(|s| s.index == l).unwrap().points, vec![[100.0, 100.0], [100.0, 300.0]]);
// Two ends exactly, far enough apart.
assert!(matches!(set_points(&mut doc, 0, l, &[[1.0, 1.0]], &meta("")), Err(AnnotError::Invalid(_))));
assert!(matches!(set_points(&mut doc, 0, l, &[[1.0, 1.0], [1.2, 1.0]], &meta("")), Err(AnnotError::Invalid(_))));
assert!(matches!(set_points(&mut doc, 0, l, &[[f64::NAN, 1.0], [10.0, 1.0]], &meta("")), Err(AnnotError::Invalid(_))));
// A polygon's corner.
let g = add_annotation(
&mut doc,
&new(0, Shape::Polygon { vertices: vec![[300.0, 300.0], [400.0, 300.0], [350.0, 380.0]], cloud: false }),
&meta("g"),
)
.unwrap();
set_points(&mut doc, 0, g, &[[300.0, 300.0], [450.0, 300.0], [350.0, 380.0], [300.0, 360.0]], &meta("")).unwrap();
let d = &list(&doc, 0)[g];
let v: Vec<f64> = d.get(b"Vertices").unwrap().as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect();
assert_eq!(v.len(), 8);
assert!(rect(d)[2] > 450.0);
// Locked: refused, and nothing changes.
set_locked(&mut doc, 0, l, true).unwrap();
assert!(set_points(&mut doc, 0, l, &[[0.0, 0.0], [50.0, 50.0]], &meta("")).is_err());
// Other comments have no points to move.
let s = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [10.0, 10.0, 50.0, 50.0] }), &meta("s")).unwrap();
assert!(matches!(set_points(&mut doc, 0, s, &[[0.0, 0.0], [50.0, 50.0]], &meta("")), Err(AnnotError::Invalid(_))));
}
#[test]
fn page_walk_skips_repeated_and_cyclic_kids_without_queueing_them() {
let mut doc = fixture();
let root = ObjRef { num: 2, generation: 0 };
let (first, second) = (ObjRef { num: 3, generation: 0 }, ObjRef { num: 4, generation: 0 });
// A cycle back to the root, then both pages 128 times over.
let mut kids = vec![Object::Ref(root)];
kids.extend([Object::Ref(first), Object::Ref(second)].into_iter().cycle().take(256));
doc.update_dict(root, |d| d.set(b"Kids".to_vec(), Object::Array(kids))).unwrap();
let mut walk = PageWalk::new(&doc, root);
assert_eq!(walk.next(), Some(first));
assert!(walk.pending.len() <= 2, "repeated kids retained {} pending entries", walk.pending.len());
assert_eq!(walk.next(), Some(second));
assert_eq!(walk.next(), None);
assert_eq!(page_refs(&doc).unwrap(), [first, second]);
}
/// #1786: TrueType glyph contours (such as Inter's composite Ђ and ћ) overlap with the same
/// winding direction where strokes cross, so typed signature appearances must fill with the
/// non-zero winding operator (`f`), not even-odd (`f*`).
#[test]
fn typed_signature_uses_nonzero_winding_fill() {
let mut doc = fixture();
let contours = vec![vec![[0.4, 0.0], [0.6, 0.0], [0.6, 1.0], [0.4, 1.0]], vec![[0.0, 0.7], [1.0, 0.7], [1.0, 0.85], [0.0, 0.85]]];
let idx = add_annotation(&mut doc, &new(0, Shape::TypedSignature { rect: [100.0, 100.0, 200.0, 140.0], contours }), &meta("sig")).unwrap();
let ap = ap_content(&doc, &list(&doc, 0)[idx]);
assert!(ap.ends_with("h\nf\n"), "expected non-zero winding fill (`f`), got: {ap}");
assert!(!ap.contains("f*"), "must not use even-odd fill (`f*`): {ap}");
}
+1 -1
View File
@@ -10,7 +10,7 @@ It reports two kinds of finding:
- **faux redaction**: an opaque filled rectangle painted *after* text it overlaps, dark enough
against the text's colour (WCAG contrast below 3) to hide it, while the text is still
extractable underneath;
extractable underneath; opaque filled Square annotations count as covers too;
- **unapplied mark**: a Redact annotation that was marked but never applied.
A properly applied redaction removes the text, so it never flags. Page backgrounds, table
+57 -13
View File
@@ -12,8 +12,9 @@
//! Detection limits (documented, not hidden): `re` rectangles and single closed `m`/`l`
//! subpaths whose four corners form an axis-aligned rectangle in user space (#1510) are
//! considered when closed by a fill operator — other filled paths, shadings and patterns are
//! not; a rectangle drawn with a translucent ExtGState (`/ca < 1`) is not flagged; annotation
//! rectangles are compared in unrotated user space, so pages with `/Rotate` may misalign;
//! not; a rectangle drawn with a translucent ExtGState (`/ca < 1`) is not flagged; opaque
//! filled `/Square` annotations count as covers, but annotation rectangles are compared in
//! unrotated user space, so pages with `/Rotate` may misalign;
//! covers inside form XObjects are found, but only text drawn directly in the page content
//! is checked under them, so text inside a form XObject is not audited.
//!
@@ -114,11 +115,15 @@ pub fn audit_redactions(doc: &Document) -> Result<Vec<AuditFinding>, AuditError>
out.push(AuditFinding { page: n, kind: FindingKind::CoveredText, rect: r.rect, covered_text: covered });
}
}
// 2. Redact annotations that were never applied.
// 2. Annotation covers over extractable text.
for annot in annots(doc, &page.dict)? {
let mut covered = String::new();
for line in &lines {
if covers(&annot, line) {
let hidden = match &annot {
AnnotationCover::Redact(rect) => covers(rect, line),
AnnotationCover::Square { rect, rgb } => covers(rect, line) && contrast(line.color, *rgb) < COVER_CONTRAST,
};
if hidden {
if !covered.is_empty() {
covered.push(' ');
}
@@ -126,7 +131,11 @@ pub fn audit_redactions(doc: &Document) -> Result<Vec<AuditFinding>, AuditError>
}
}
if !covered.trim().is_empty() {
out.push(AuditFinding { page: n, kind: FindingKind::UnappliedMark, rect: annot, covered_text: covered });
let (kind, rect) = match annot {
AnnotationCover::Redact(rect) => (FindingKind::UnappliedMark, rect),
AnnotationCover::Square { rect, .. } => (FindingKind::CoveredText, rect),
};
out.push(AuditFinding { page: n, kind, rect, covered_text: covered });
}
}
}
@@ -191,9 +200,14 @@ fn contrast(a: [f64; 3], b: [f64; 3]) -> f64 {
(hi + 0.05) / (lo + 0.05)
}
/// Rectangles of `/Redact` annotations on a page (unrotated user space). A missing `/Annots`
/// means no marks; a malformed one fails the audit rather than silently passing.
fn annots(doc: &Document, page: &Dict) -> Result<Vec<[f64; 4]>, AuditError> {
enum AnnotationCover {
Redact([f64; 4]),
Square { rect: [f64; 4], rgb: [f64; 3] },
}
/// Opaque annotation covers on a page (unrotated user space). A missing `/Annots` means no
/// covers; a malformed one fails the audit rather than silently passing.
fn annots(doc: &Document, page: &Dict) -> Result<Vec<AnnotationCover>, AuditError> {
let Some(a) = page.get(b"Annots") else { return Ok(Vec::new()) };
let resolved = doc.resolve(a);
let items: Vec<pdfcraft_cos::Object> = match &*resolved {
@@ -207,16 +221,46 @@ fn annots(doc: &Document, page: &Dict) -> Result<Vec<[f64; 4]>, AuditError> {
pdfcraft_cos::Object::Dict(d) => d.clone(),
_ => continue,
};
if dict.name(b"Subtype") != Some(b"Redact") {
continue;
}
let v: Vec<f64> = match dict.get(b"Rect") {
None => continue,
Some(r) => doc.resolve(r).as_array().map(|arr| arr.iter().filter_map(|o| doc.resolve(o).as_f64()).collect()).unwrap_or_default(),
};
if v.len() == 4 && v.iter().all(|x| x.is_finite()) {
out.push([v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])]);
if v.len() != 4 || !v.iter().all(|x| x.is_finite()) {
continue;
}
let rect = [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])];
match dict.name(b"Subtype") {
Some(b"Redact") => out.push(AnnotationCover::Redact(rect)),
Some(b"Square") => {
let flags = dict.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0);
if flags & 2 != 0 || flags & 32 != 0 {
continue;
}
let alpha = dict.get(b"CA").map(|o| doc.resolve(o)).and_then(|o| o.as_f64()).unwrap_or(1.0);
if alpha >= 1.0
&& let Some(rgb) = annotation_color(doc, &dict, b"IC")
{
out.push(AnnotationCover::Square { rect, rgb });
}
}
_ => {}
}
}
Ok(out)
}
fn annotation_color(doc: &Document, dict: &Dict, key: &[u8]) -> Option<[f64; 3]> {
let values: Vec<f64> = dict.get(key).map(|o| doc.resolve(o))?.as_array()?.iter().map(|o| doc.resolve(o).as_f64()).collect::<Option<_>>()?;
if !values.iter().all(|v| v.is_finite()) {
return None;
}
match values.as_slice() {
[g] => Some([g.clamp(0.0, 1.0); 3]),
[r, g, b] => Some([r.clamp(0.0, 1.0), g.clamp(0.0, 1.0), b.clamp(0.0, 1.0)]),
[c, m, y, k] => {
let (c, m, y, k) = (c.clamp(0.0, 1.0), m.clamp(0.0, 1.0), y.clamp(0.0, 1.0), k.clamp(0.0, 1.0));
Some([(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)])
}
_ => None,
}
}
+63
View File
@@ -216,6 +216,69 @@ fn a_box_grazing_a_word_is_not_flagged() {
assert!(findings.is_empty(), "{findings:?}");
}
#[test]
fn opaque_filled_square_annotation_over_text_is_found() {
let content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n";
let mut b = Builder::new();
b.add("<< /Type /Catalog /Pages 2 0 R >>");
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>");
b.add_stream(content);
b.add(FONT);
b.add("<< /Type /Annot /Subtype /Square /Rect [95 685 325 725] /C [0 0 0] /IC [0 0 0] >>");
let findings = audit_redactions(&open(b.build())).expect("audit must run");
assert_eq!(findings.len(), 1, "{findings:?}");
assert_eq!(findings[0].kind, FindingKind::CoveredText);
assert_eq!(findings[0].rect, [95.0, 685.0, 325.0, 725.0]);
assert_eq!(findings[0].covered_text, "SECRET DATA 123");
}
/// A Hidden Square (`/F 2`) is never drawn, so the text under it is not a finding.
#[test]
fn hidden_square_annotation_is_not_a_finding() {
let content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n";
let mut b = Builder::new();
b.add("<< /Type /Catalog /Pages 2 0 R >>");
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>");
b.add_stream(content);
b.add(FONT);
b.add("<< /Type /Annot /Subtype /Square /Rect [95 685 325 725] /C [0 0 0] /IC [0 0 0] /F 2 >>");
let findings = audit_redactions(&open(b.build())).expect("audit must run");
assert!(findings.is_empty(), "{findings:?}");
}
/// A NoView Square (`/F 32`) is never drawn on screen, so the text under it is not a finding.
#[test]
fn no_view_square_annotation_is_not_a_finding() {
let content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n";
let mut b = Builder::new();
b.add("<< /Type /Catalog /Pages 2 0 R >>");
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>");
b.add_stream(content);
b.add(FONT);
b.add("<< /Type /Annot /Subtype /Square /Rect [95 685 325 725] /C [0 0 0] /IC [0 0 0] /F 32 >>");
let findings = audit_redactions(&open(b.build())).expect("audit must run");
assert!(findings.is_empty(), "{findings:?}");
}
/// A Print-only Square (`/F 4`) is still drawn on screen, so the text under it is a finding.
#[test]
fn print_flag_square_annotation_is_still_a_finding() {
let content = b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n";
let mut b = Builder::new();
b.add("<< /Type /Catalog /Pages 2 0 R >>");
b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>");
b.add_stream(content);
b.add(FONT);
b.add("<< /Type /Annot /Subtype /Square /Rect [95 685 325 725] /C [0 0 0] /IC [0 0 0] /F 4 >>");
let findings = audit_redactions(&open(b.build())).expect("audit must run");
assert_eq!(findings.len(), 1, "{findings:?}");
assert_eq!(findings[0].kind, FindingKind::CoveredText);
}
#[test]
fn background_before_text_is_not_flagged() {
// An opaque dark rectangle painted *before* the text is a background, not a cover.
+3
View File
@@ -32,3 +32,6 @@ flate2.workspace = true
pdfcraft-model = { workspace = true }
pdfcraft-xfa = { workspace = true }
image = { version = "0.25", default-features = false, features = ["jpeg"] }
[target.'cfg(target_os = "macos")'.dev-dependencies]
rustix = { version = "1", features = ["fs"] }
+6
View File
@@ -15,6 +15,12 @@ tools() -> Vec<ToolDef> // name, title, descrip
mcp::McpServer::new(a).serve(stdin, stdout)? // newline-delimited JSON-RPC 2.0
```
`content_update.rotate` rotates an added image counter-clockwise by an integer multiple of
90 degrees. Large positive and negative signed 64-bit angles are normalized before combining
them with the image's current orientation. Full turns retain its appearance, rectangle, crop
and flips. Angles that are not multiples of 90 are refused without changing the document or
its undo/redo history.
`Content` is `Json(Value)` or `Png { data, width, height }`. Errors are `UnknownTool`, `InvalidArgs` (the call didn't match the schema) or `Failed` (a readable message for the agent).
## Conventions
+35 -8
View File
@@ -173,8 +173,7 @@ impl Automation {
let only = a.opt_int("page")?.map(|p| (p.max(1) - 1) as usize);
let all = self.comments(a)?;
let view = |c: &Annotation| -> Value {
let p = &doc.info.pages[c.page.min(doc.info.pages.len().saturating_sub(1))];
json!({
let mut v = json!({
"id": c.name,
"page": c.page + 1,
"index": c.index + 1,
@@ -182,9 +181,20 @@ impl Automation {
"author": c.author,
"contents": c.contents,
"modified": c.modified,
"rect": rect_to_view(p, c.rect),
// Unchanged on a page the viewer could not read (#2660), as `doc_info` reports it.
"rect": crate::view_rect(&doc.info, c.page, c.rect),
"color": c.color.map(hex),
})
});
// A line's ends, or the corners of connected lines, a polygon or a cloud, as
// comment_edit's `points` takes them.
// Unchanged on a page the viewer could not read, as `rect` is.
if !c.points.is_empty() {
let round = |v: f32| (v * 100.0).round() / 100.0;
let page = doc.info.pages.get(c.page);
v["points"] =
json!(c.points.iter().map(|q| page.map_or([q[0], q[1]], |p| p.user_to_view(q[0], q[1])).map(round)).collect::<Vec<_>>());
}
v
};
let roots: Vec<Value> = all
.iter()
@@ -551,8 +561,7 @@ impl Automation {
"line" => FillMark::Line,
other => return Err(ToolError::InvalidArgs(format!("unknown type {other:?}"))),
};
let (w, h) = if mark == FillMark::Line { (36.0, 4.0) } else { (12.0, 12.0) };
(Shape::Mark { rect: [at[0] - w / 2.0, at[1] - h / 2.0, at[0] + w / 2.0, at[1] + h / 2.0], mark }, String::new())
(Shape::Mark { rect: mark.rect_at(at, i64::from(info.rotation)), mark }, String::new())
}
};
let style = Style::default_for(&shape);
@@ -589,7 +598,10 @@ impl Automation {
pub(crate) fn comments_hide(&mut self, a: &Args) -> Result<Value> {
let id = self.doc(a)?.id;
let hidden = a.opt_bool("hidden")?.unwrap_or(true);
self.session.set_hide_comments(id, hidden);
if self.session.set_hide_comments(id, hidden) {
// The cached renderer drew with the old setting; page_render must read it again.
self.renderers.remove(&id);
}
Ok(json!({ "hidden": hidden }))
}
@@ -632,8 +644,23 @@ impl Automation {
let (o, d) = (to_user(&info, 0.0, 0.0), to_user(&info, dx, dy));
edits.push(Edit::MoveAnnotation { page, index, dx: d[0] - o[0], dy: d[1] - o[1] });
}
if let Some(pts) = a.get("points") {
let wrong = || ToolError::InvalidArgs("points must be an array of [x, y] points (a line's two ends, or every corner)".into());
let points = pts
.as_array()
.ok_or_else(wrong)?
.iter()
.map(|p| match p.as_array().map(|p| p.iter().map(|v| v.as_f64()).collect::<Vec<_>>()).as_deref() {
Some([Some(x), Some(y)]) => Ok(to_user(&info, *x, *y)),
_ => Err(wrong()),
})
.collect::<Result<Vec<_>>>()?;
edits.push(Edit::ReshapeAnnotation { page, index, points });
}
if edits.is_empty() {
return Err(ToolError::InvalidArgs("nothing to change: pass contents, color, opacity, width, fill, font_size, rect or move".into()));
return Err(ToolError::InvalidArgs(
"nothing to change: pass contents, color, opacity, width, fill, font_size, rect, move or points".into(),
));
}
let edit = if edits.len() == 1 { edits.remove(0) } else { Edit::Batch { label: "Edit comment".into(), edits } };
self.apply(a, edit)
+5 -4
View File
@@ -73,9 +73,9 @@ impl Automation {
.iter()
.map(|it| {
let index = per_page.entry(it.page).or_default();
let ph = doc.info.pages[it.page].height as f64;
let mut v =
json!({ "page": it.page + 1, "index": *index + 1, "rect": to_view(it.content.rect(), ph).map(|x| (x * 100.0).round() / 100.0) });
// Unchanged on a page the viewer could not read (#2660), like `crate::view_rect`.
let rect = doc.info.pages.get(it.page).map_or(it.content.rect(), |p| to_view(it.content.rect(), p.height as f64));
let mut v = json!({ "page": it.page + 1, "index": *index + 1, "rect": rect.map(|x| (x * 100.0).round() / 100.0) });
*index += 1;
match &it.content {
AddedContent::Text(t) => {
@@ -171,7 +171,8 @@ impl Automation {
if deg.rem_euclid(90) != 0 {
return Err(bad("rotate must be a multiple of 90"));
}
i.rotation = ((i64::from(i.rotation) * 90 + deg).rem_euclid(360) / 90) as u8;
// Reduce untrusted degrees before adding the current quarter turns.
i.rotation = (i64::from(i.rotation) + deg.rem_euclid(360) / 90).rem_euclid(4) as u8;
}
if a.opt_bool("flip_h")? == Some(true) {
i.flip_h = !i.flip_h;
+112 -3
View File
@@ -1,8 +1,8 @@
//! Form tools: list fields, fill them (several at once, one undo step), clear the form, and
//! prepare a form (add, change and delete fields).
//! prepare a form (add fields one at a time or as a table, change and delete them).
use pdfcraft_engine::form_scripts::{CalcOp, Calculate, Format, Validate};
use pdfcraft_engine::{Edit, FieldProps, FieldValue, FormField, FormFieldKind, NewField, field_flags};
use pdfcraft_engine::{CalculationIssue, Edit, FieldProps, FieldValue, FormField, FormFieldKind, NewField, field_flags};
use serde_json::{Value, json};
use crate::{Args, Automation, Result, ToolError, failed};
@@ -339,6 +339,67 @@ impl Automation {
Ok(out)
}
/// A table of text fields: `rect` (points from the top-left of the displayed page) divided
/// into rows and columns, one field per cell, as one undo step.
pub(crate) fn form_add_field_table(&mut self, a: &Args) -> Result<Value> {
use pdfcraft_engine::field_table::{self, FieldTable, MAX_COLUMNS, TableColumn, TableError};
let page = self.page(a)?;
let rect = a
.get("rect")
.and_then(Value::as_array)
.and_then(|r| r.iter().map(Value::as_f64).collect::<Option<Vec<f64>>>())
.and_then(|r| <[f64; 4]>::try_from(r).ok())
.ok_or_else(|| bad("rect must be 4 numbers"))?;
let names = a.get("names").map(|_| a.strs("names")).transpose()?;
let widths = match a.get("widths") {
None => None,
Some(v) => Some(
v.as_array()
.and_then(|w| w.iter().map(Value::as_f64).collect::<Option<Vec<f64>>>())
.ok_or_else(|| bad("widths must be an array of numbers"))?,
),
};
// A negative count is no count: the table refuses it with its own words.
let counts = [a.opt_int("columns")?.map(|n| usize::try_from(n).unwrap_or(0)), names.as_ref().map(Vec::len), widths.as_ref().map(Vec::len)];
let mut given = counts.into_iter().flatten();
let columns = given.next().unwrap_or(1);
if given.any(|n| n != columns) {
return Err(bad("columns, names and widths must agree on the number of columns"));
}
// Before anything is sized by it.
if !(1..=MAX_COLUMNS).contains(&columns) {
return Err(bad(TableError::Columns.to_string()));
}
let (edit, fields) = {
let doc = self.doc(a)?;
let base = field_table::free_base(doc.form.iter().map(|f| f.name.as_str()));
let mut table = FieldTable::new(rect, &base);
table.rows = usize::try_from(a.int("rows")?).unwrap_or(0);
table.columns = (0..columns)
.map(|c| TableColumn {
pattern: names.as_ref().and_then(|n| n.get(c)).map_or_else(|| field_table::column_pattern(&base, c + 1), |p| (*p).to_owned()),
width: widths.as_ref().and_then(|w| w.get(c)).copied().unwrap_or(1.0),
})
.collect();
table.row_gap = a.opt_num("row_gap")?.unwrap_or(0.0);
table.column_gap = a.opt_num("column_gap")?.unwrap_or(0.0);
table.first_row = a.opt_int("first_row")?.map_or(1, |n| usize::try_from(n).unwrap_or(usize::MAX));
let info = doc.info.pages.get(page).ok_or_else(|| bad(format!("page {} does not exist", page + 1)))?;
let edit = field_table::edit(&table, page, info, &doc.form).map_err(|e| bad(e.to_string()))?;
// The names the batch adds, in the order it adds them.
let fields: Vec<String> = match &edit {
Edit::Batch { edits, .. } => {
edits.iter().filter_map(|e| if let Edit::AddField { name, .. } = e { name.clone() } else { None }).collect()
}
_ => Vec::new(),
};
(edit, fields)
};
let mut out = self.apply(a, edit)?;
out["fields"] = json!(fields);
Ok(out)
}
pub(crate) fn form_set_props(&mut self, a: &Args) -> Result<Value> {
if a.get("widget").is_some() && a.get("caption").is_none() {
return Err(bad("widget requires caption"));
@@ -417,7 +478,7 @@ impl Automation {
let r: Vec<f64> = v.as_array().map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
let r = <[f64; 4]>::try_from(r).map_err(|_| ToolError::InvalidArgs("rect must be 4 numbers".into()))?;
let page = f.widgets.first().and_then(|w| w.page).ok_or_else(|| failed(format!("{name} is not on a page")))?;
let p = &doc.info.pages[page];
let p = doc.info.pages.get(page).ok_or_else(|| failed(format!("{name} is on page {}, which could not be read", page + 1)))?;
let (u0, u1) = (p.view_to_user(r[0] as f32, r[1] as f32), p.view_to_user(r[2] as f32, r[3] as f32));
Some((0, [u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64]))
}
@@ -536,6 +597,54 @@ impl Automation {
Ok(out)
}
pub(crate) fn form_calculation_order(&mut self, a: &Args) -> Result<Value> {
let current = self.doc(a)?.calculation_order();
let fix = a.opt_bool("fix")?.unwrap_or(false);
let field = a.opt_str("field")?;
let order = a.get("order").map(|_| a.strs("order")).transpose()?;
if usize::from(fix) + usize::from(field.is_some()) + usize::from(order.is_some()) > 1 {
return Err(bad("give one change at a time: order, field with move, or fix"));
}
let names: Option<Vec<String>> = if let Some(order) = order {
Some(order.into_iter().map(str::to_owned).collect())
} else if let Some(field) = field {
let earlier = match a.opt_str("move")?.unwrap_or("earlier") {
"earlier" => true,
"later" => false,
m => return Err(bad(format!("move must be earlier or later, not {m:?}"))),
};
let mut names: Vec<String> = current.fields.iter().map(|f| f.name.clone()).collect();
let i = names.iter().position(|n| n == field).ok_or_else(|| bad(format!("{field:?} is not a calculated field")))?;
// Already first or last: nothing moves.
let j = if earlier { i.checked_sub(1) } else { Some(i + 1).filter(|j| *j < names.len()) };
j.map(|j| {
names.swap(i, j);
names
})
} else {
(fix && current.fixable()).then(|| current.fixed())
};
let mut out = match names {
Some(names) => self.apply(a, Edit::SetCalculationOrder { names })?,
None => json!({}),
};
let order = self.doc(a)?.calculation_order();
let issue = |i: &CalculationIssue| match i {
CalculationIssue::RunsTooEarly { field } => json!({ "issue": "runs_too_early", "field": field }),
CalculationIssue::Cycle { fields } => json!({ "issue": "cycle", "fields": fields }),
CalculationIssue::NotInOrder => json!({ "issue": "not_in_order" }),
};
out["calculation_order"] = json!(order.fields.iter().map(|f| f.name.as_str()).collect::<Vec<_>>());
out["fields"] = order
.fields
.iter()
.map(|f| json!({ "name": f.name, "reads": f.reads, "listed": f.listed, "issues": f.issues.iter().map(issue).collect::<Vec<_>>() }))
.collect();
out["extra"] = json!(order.extra);
out["fixable"] = json!(order.fixable());
Ok(out)
}
fn tab_sequence(&self, a: &Args) -> Result<Value> {
let doc = self.doc(a)?;
let mut seq: Vec<(usize, &str)> =
+47
View File
@@ -0,0 +1,47 @@
//! Layers (optional content groups) as a tool: the Layers panel's eye icon (#2986, AGENTS.md §3).
//!
//! A per-document view setting, like hidden comments: `page_render` draws with it, and the file
//! itself never changes.
use serde_json::{Value, json};
use crate::{Args, Automation, Result, ToolError};
impl Automation {
/// Show or hide a layer, named by `layer`: its name as `doc_info` lists it, or its 1-based
/// position in that list. A name picks every layer that has it, since CAD exporters often
/// write one group per page or per drawing under the same name.
pub(crate) fn layer_set(&mut self, a: &Args) -> Result<Value> {
let doc = self.doc(a)?;
let id = doc.id;
let visible = a.opt_bool("visible")?.ok_or_else(|| ToolError::InvalidArgs("missing argument visible".into()))?;
let layers = &doc.info.layers;
let targets: Vec<usize> = match a.get("layer") {
Some(Value::String(name)) => layers.iter().enumerate().filter(|(_, l)| l.name == *name).map(|(i, _)| i).collect(),
Some(v) => match v.as_u64() {
Some(n) if n >= 1 && usize::try_from(n - 1).is_ok_and(|i| i < layers.len()) => vec![(n - 1) as usize],
Some(_) => Vec::new(),
None => return Err(ToolError::InvalidArgs("layer must be a layer name or its 1-based position in doc_info's layers".into())),
},
None => return Err(ToolError::InvalidArgs("missing argument layer".into())),
};
if targets.is_empty() {
let names: Vec<&str> = layers.iter().map(|l| l.name.as_str()).collect();
return Err(ToolError::InvalidArgs(if names.is_empty() {
"the document has no layers".into()
} else {
format!("no such layer; the document's layers are: {}", names.join(", "))
}));
}
let mut changed = false;
for i in targets {
changed |= self.session.set_layer_visible(id, i, visible);
}
if changed {
// The cached renderer drew with the old visibility; page_render must read it again.
self.renderers.remove(&id);
}
let layers = &self.doc(a)?.info.layers;
Ok(json!({ "layers": layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible })).collect::<Vec<_>>() }))
}
}
+160 -29
View File
@@ -21,6 +21,7 @@ mod content;
mod conventions;
pub mod file_access;
mod forms;
mod layers;
mod links;
#[cfg(feature = "mcp")]
pub mod mcp;
@@ -32,11 +33,11 @@ mod signing;
mod text_cache;
mod tools;
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use pdfcraft_engine::{CombineSource, DocId, Document, Edit, Session, commands};
use pdfcraft_engine::{BlockKey, CombineSource, DocId, Document, Edit, Session, TextBlock, block_overlapping, commands};
use pdfcraft_platform::staging::{StagingName, staging_suffixes};
use pdfcraft_render::{PageRenderer, PageText, RenderConfig, RenderRequest, RequestKind};
use serde_json::{Value, json};
@@ -96,6 +97,9 @@ const WATERMARK_MAX_OFFSET: f64 = 20_000.0;
/// Bookmarks in one page of the bookmark list, by default and at most.
const BOOKMARK_PAGE: usize = 100;
const MAX_BOOKMARK_PAGE: usize = 1000;
/// How far from the page's corner a bookmark's position may be given, in points (a PDF page is at
/// most 14 400 across).
const MAX_BOOKMARK_POSITION: f64 = 1_000_000.0;
/// `text_find` extracts this many pages first, doubling each batch up to [`FIND_MAX_BATCH`], so a
/// match near the start returns without touching the rest of a long document (#971).
@@ -118,6 +122,12 @@ pub struct Automation {
renderers: HashMap<DocId, (Arc<Vec<u8>>, PageRenderer)>,
/// Extracted page text, under one memory budget across all open documents.
texts: TextCache,
/// The paragraphs of the last `text_paragraphs` listing of each page, so a paragraph number
/// keeps naming that paragraph after edits regroup the page (#2516).
listed: HashMap<(DocId, usize), Vec<BlockKey>>,
/// Documents that lost a listing to the cap. A page of one with no listing is refused rather
/// than read as "a number is a position", which the page may no longer match.
evicted: HashSet<DocId>,
}
impl Default for Automation {
@@ -128,7 +138,15 @@ impl Default for Automation {
impl Automation {
pub fn new() -> Self {
Self { session: Session::new(), root: None, roots: None, renderers: HashMap::new(), texts: TextCache::default() }
Self {
session: Session::new(),
root: None,
roots: None,
renderers: HashMap::new(),
texts: TextCache::default(),
listed: HashMap::new(),
evicted: HashSet::new(),
}
}
/// Confine every path the tools read or write to `root` (relative paths resolve inside it).
@@ -339,7 +357,13 @@ impl Automation {
let parent = a.opt_path("parent")?.unwrap_or_default();
let index = a.opt_int("position")?.map_or(usize::MAX, |p| (p.max(1) - 1) as usize);
let title = a.str("title")?.to_string();
self.apply(&a, Edit::AddBookmark { parent, index, title, page })?
let (left, top) = (a.bookmark_coordinate("left")?, a.bookmark_coordinate("top")?);
let [left, top] = match self.doc(&a)?.info.pages.get(page) {
Some(p) => p.xyz_for_view(left, top),
None => [None, None],
};
let (left, top) = (left.map(f64::from), top.map(f64::from));
self.apply(&a, Edit::AddBookmark { parent, index, title, page, left, top })?
}
"bookmark_rename" => {
let (path, title) = (a.path("path")?, a.str("title")?.to_string());
@@ -499,6 +523,11 @@ impl Automation {
let (u0, u1) = (info.view_to_user(r[0] as f32, r[1] as f32), info.view_to_user(r[2] as f32, r[3] as f32));
ImageEdit::Move([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64])
}
"rotate_by" => ImageEdit::RotateBy(
a.get("degrees")
.and_then(Value::as_f64)
.ok_or_else(|| ToolError::InvalidArgs("rotate_by needs degrees: a number (clockwise)".into()))?,
),
"rotate" => ImageEdit::Rotate(a.opt_int("quarters")?.unwrap_or(1) as i32),
"crop" => {
let r: Vec<f64> =
@@ -530,8 +559,9 @@ impl Automation {
let doc = self.doc_to_copy(&a)?;
let info = &doc.info.pages[page];
let r = |x: f32| (x as f64 * 100.0).round() / 100.0;
let blocks: Vec<Value> = doc
.text_blocks(page)
let current = doc.text_blocks(page);
let id = doc.id;
let blocks: Vec<Value> = current
.iter()
.enumerate()
.map(|(i, b)| {
@@ -548,6 +578,7 @@ impl Automation {
})
})
.collect();
self.record_listing(id, page, &current);
json!({ "page": page + 1, "count": blocks.len(), "paragraphs": blocks })
}
"text_font_files" => {
@@ -605,10 +636,8 @@ impl Automation {
let doc = self.doc(&a)?;
let k = a.int("paragraph")?;
let blocks = doc.text_blocks(page);
if k < 1 || k as usize > blocks.len() {
return Err(ToolError::InvalidArgs("paragraph is out of range".into()));
}
let source = &blocks[k as usize - 1];
let index = self.paragraph_index(doc.id, page, k, &blocks)?;
let source = blocks.get(index).ok_or_else(|| ToolError::InvalidArgs("paragraph is out of range".into()))?;
let text = a.opt_str("text")?.unwrap_or(&source.editor_text);
let style = pdfcraft_engine::BlockStyle {
size: a.opt_num("size")?,
@@ -617,7 +646,7 @@ impl Automation {
ranges: parse_style_ranges(a.get("ranges"))?,
..Default::default()
};
let layout = doc.text_edit_layout(page, k as usize - 1, text, &style).map_err(failed)?;
let layout = doc.text_edit_layout(page, index, text, &style).map_err(failed)?;
let info = &doc.info.pages[page];
let view_rect = |r: [f64; 4]| {
let a = info.user_to_view(r[0] as f32, r[1] as f32);
@@ -630,12 +659,13 @@ impl Automation {
}
"text_edit" if a.get("paragraph").is_some() => {
let page = self.page(&a)?;
let n = self.doc(&a)?.text_blocks(page).len();
let k = a.int("paragraph")?;
if k < 1 || k as usize > n {
return Err(ToolError::InvalidArgs(format!("paragraph {k} is out of range: page {} has {n} paragraphs", page + 1)));
}
let block = self.doc(&a)?.text_blocks(page)[k as usize - 1].clone();
let (id, blocks) = {
let doc = self.doc(&a)?;
(doc.id, doc.text_blocks(page))
};
let index = self.paragraph_index(id, page, k, &blocks)?;
let block = blocks.get(index).cloned().ok_or_else(|| ToolError::InvalidArgs(format!("paragraph {k} is out of range")))?;
let text = a.opt_str("text")?.map(str::to_owned).unwrap_or(block.text);
let mut style = pdfcraft_engine::BlockStyle {
ranges: parse_style_ranges(a.get("ranges"))?,
@@ -674,10 +704,25 @@ impl Automation {
other => return Err(ToolError::InvalidArgs(format!("unknown align {other:?}"))),
});
}
let mut out = self.apply(&a, Edit::EditTextBlock { page, block: k as usize - 1, text, style })?;
if let Some(b) = self.doc(&a)?.text_blocks(page).get(k as usize - 1) {
// Where the paragraph is drawn after the edit: moved by dx, dy (up is +dy).
let [dx, dy] = style.offset.unwrap_or([0.0, 0.0]);
let moved = [block.rect[0] + dx, block.rect[1] + dy, block.rect[2] + dx, block.rect[3] + dy];
let wanted: String = text.split_whitespace().collect();
let mut out = self.apply(&a, Edit::EditTextBlock { page, block: index, text, style })?;
let after = self.doc(&a)?.text_blocks(page);
// Only a paragraph that now shows exactly the requested text is the edited one. A
// neighbour that overlaps the old box (overlaid text, a text layer) must not take
// its number, and neither must a paragraph the edit merged it into: rewriting that
// one by this number would drop the neighbour's text.
let edited = block_overlapping(&after, moved)
.filter(|&i| !wanted.is_empty() && after.get(i).is_some_and(|b| b.text.split_whitespace().collect::<String>() == wanted));
if let Some(b) = edited.and_then(|i| after.get(i)) {
out["paragraph"] = json!({ "text": b.text, "lines": b.lines.len(), "font": b.base_font });
}
let stale = self.follow_edit(id, page, k, edited.and_then(|i| after.get(i)), &after);
if !stale.is_empty() {
out["stale_paragraphs"] = json!(stale);
}
out
}
"text_edit" => {
@@ -726,9 +771,11 @@ impl Automation {
}
"form_reset" => self.form_reset(&a)?,
"form_add_field" => self.form_add_field(&a)?,
"form_add_field_table" => self.form_add_field_table(&a)?,
"form_set_props" => self.form_set_props(&a)?,
"form_delete_field" => self.form_delete_field(&a)?,
"form_tab_order" => self.form_tab_order(&a)?,
"form_calculation_order" => self.form_calculation_order(&a)?,
"doc_export_data" => self.doc_export_data(&a)?,
"doc_import_data" => self.doc_import_data(&a)?,
"redact_mark" => self.redact_mark(&a)?,
@@ -790,6 +837,14 @@ impl Automation {
"print_readiness" => self.print_readiness(&a)?,
"doc_export_office" => self.doc_export_office(&a)?,
"pdfa_convert" => self.pdfa_convert(&a)?,
"standards_remove_id" => {
let standard = match a.str("standard")? {
"pdfa" => pdfcraft_engine::Standard::PdfA,
"pdfx" => pdfcraft_engine::Standard::PdfX,
other => return Err(ToolError::InvalidArgs(format!("standard must be pdfa or pdfx, not {other}"))),
};
self.apply(&a, Edit::RemoveStandardId { standard })?
}
"action_run" => self.action_run(&a)?,
"doc_compare_report" => self.doc_compare_report(&a)?,
"doc_compare_mark" => self.doc_compare_mark(&a)?,
@@ -846,6 +901,7 @@ impl Automation {
"comment_mark" => self.comment_mark(&a)?,
"comment_lock" => self.comment_lock(&a)?,
"comments_hide" => self.comments_hide(&a)?,
"layer_set" => self.layer_set(&a)?,
"view_reading_colors" => self.view_reading_colors(&a)?,
"comments_summarize" => self.comments_summarize(&a)?,
"comment_edit" => self.comment_edit(&a)?,
@@ -882,6 +938,59 @@ impl Automation {
self.session.get(DocId(id)).ok_or_else(|| ToolError::Failed(format!("no open document with id {id} (see doc_list)")))
}
/// Most pages whose paragraph listing is remembered.
const MAX_LISTINGS: usize = 256;
/// Remember the paragraphs `text_paragraphs` just listed for `page`, so their numbers can be
/// resolved against that listing.
fn record_listing(&mut self, doc: DocId, page: usize, blocks: &[TextBlock]) {
if self.listed.len() >= Self::MAX_LISTINGS && !self.listed.contains_key(&(doc, page)) {
self.evicted.extend(self.listed.keys().map(|(d, _)| *d));
self.listed.clear();
}
self.listed.insert((doc, page), blocks.iter().map(BlockKey::of).collect());
}
/// The index in `blocks` (the page's paragraphs now) of paragraph number `k` (1-based).
///
/// After `text_paragraphs` a number names the paragraph that listing showed, wherever it is
/// now. Without a listing it is a position in `blocks`. A paragraph an edit merged into a
/// neighbour (or changed) is refused: the number would otherwise rewrite another paragraph
/// and lose its text (#2516). So is any number on a page of a document whose listings were
/// dropped at the cap, until the page is listed again.
fn paragraph_index(&self, doc: DocId, page: usize, k: i64, blocks: &[TextBlock]) -> Result<usize> {
let keys = self.listed.get(&(doc, page));
if keys.is_none() && self.evicted.contains(&doc) {
return Err(ToolError::InvalidArgs(format!(
"page {} has no paragraph listing any more: too many pages were listed and the oldest listings were dropped. List the page again with text_paragraphs",
page + 1
)));
}
let n = keys.map_or(blocks.len(), Vec::len);
let i = usize::try_from(k)
.ok()
.and_then(|k| k.checked_sub(1))
.filter(|i| *i < n)
.ok_or_else(|| ToolError::InvalidArgs(format!("paragraph {k} is out of range: page {} has {n} paragraphs", page + 1)))?;
let Some(keys) = keys else { return Ok(i) };
keys.get(i).and_then(|key| key.find(blocks, i)).ok_or_else(|| {
ToolError::InvalidArgs(format!(
"paragraph {k} of page {} is no longer a paragraph of its own: an edit changed it or how the page is grouped. List the page again with text_paragraphs",
page + 1
))
})
}
/// Update the listing after paragraph `k` was edited into `edited`, one of the page's
/// paragraphs `after`. Returns the numbers of the listed paragraphs that are gone.
fn follow_edit(&mut self, doc: DocId, page: usize, k: i64, edited: Option<&TextBlock>, after: &[TextBlock]) -> Vec<usize> {
let Some(keys) = self.listed.get_mut(&(doc, page)) else { return Vec::new() };
if let (Some(edited), Some(key)) = (edited, usize::try_from(k).ok().and_then(|k| k.checked_sub(1)).and_then(|i| keys.get_mut(i))) {
*key = BlockKey::of(edited);
}
keys.iter().enumerate().filter(|(i, key)| key.find(after, *i).is_none()).map(|(i, _)| i + 1).collect()
}
/// The document, if its security allows copying text and images out of it (Table 22, bit 5).
fn doc_to_copy(&self, a: &Args) -> Result<&Document> {
let doc = self.doc(a)?;
@@ -908,6 +1017,8 @@ impl Automation {
self.session.close(id);
self.renderers.remove(&id);
self.texts.remove(&id);
self.listed.retain(|(d, _), _| *d != id);
self.evicted.remove(&id);
Ok(json!({ "closed": id.0 }))
}
@@ -1199,6 +1310,14 @@ impl Automation {
if let Some(s) = a.opt_num("font_size")? {
hf.font_size = s;
}
hf.font = match a.opt_str("font")? {
None | Some("Helvetica") => pdfcraft_engine::FontFamily::Helvetica,
Some("Times") => pdfcraft_engine::FontFamily::Times,
Some("Courier") => pdfcraft_engine::FontFamily::Courier,
Some(other) => {
return Err(ToolError::InvalidArgs(format!("font must be \"Helvetica\", \"Times\" or \"Courier\", not {other:?}")));
}
};
hf.color = color("color", hf.color)?;
if let Some(m) = a.get("margins").and_then(Value::as_array) {
let m: Vec<f64> = m.iter().filter_map(Value::as_f64).collect();
@@ -1207,6 +1326,7 @@ impl Automation {
if let Some(s) = a.opt_int("start_number")? {
hf.start_number = s.clamp(1, u32::MAX as i64) as u32;
}
hf.shrink = a.opt_bool("shrink")?.unwrap_or(false);
Edit::AddHeaderFooter { pages, settings: hf, replace }
}
"doc_watermark" => {
@@ -1809,11 +1929,11 @@ impl Automation {
fn renderer(&mut self, id: DocId) -> Result<&mut PageRenderer> {
let doc = self.session.get(id).ok_or_else(|| failed("no such document"))?;
let colors = self.session.reading_colors(id).unwrap_or_default();
let fresh = || {
let config = RenderConfig { password: doc.password.as_deref().map(Arc::from), reading_colors: colors, ..Default::default() };
(doc.display.clone(), PageRenderer::new(doc.display.clone(), config))
};
// The document's own view settings: layer visibility, hidden comments, reading colours
// (tools that change one drop this cache so the next render reads it again).
let config =
self.session.render_config(id).unwrap_or_else(|| RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() });
let fresh = || (doc.display.clone(), PageRenderer::new(doc.display.clone(), config.clone()));
let entry = self.renderers.entry(id).or_insert_with(fresh);
if !Arc::ptr_eq(&entry.0, &doc.display) {
*entry = fresh();
@@ -1835,11 +1955,13 @@ impl Automation {
return Err(ToolError::InvalidArgs("choose only one of hide_text_block and hide_added_text".into()));
}
let out = if let Some(block) = block {
let index = usize::try_from(block)
.ok()
.and_then(|n| n.checked_sub(1))
.ok_or_else(|| ToolError::InvalidArgs("hide_text_block must be a positive paragraph index from text_paragraphs".into()))?;
self.doc(a)?.text_edit_preview(page, index).map_err(failed)?.render_background(scale).map_err(failed)?
if block < 1 {
return Err(ToolError::InvalidArgs("hide_text_block must be a positive paragraph index from text_paragraphs".into()));
}
// The number names the paragraph text_paragraphs listed, as it does for text_edit.
let doc = self.doc(a)?;
let index = self.paragraph_index(doc.id, page, block, &doc.text_blocks(page))?;
doc.text_edit_preview(page, index).map_err(failed)?.render_background(scale).map_err(failed)?
} else if let Some(added) = added {
let index = usize::try_from(added)
.ok()
@@ -2287,6 +2409,15 @@ impl Args<'_> {
let Some(v) = self.opt_ints(key)? else { return Ok(None) };
v.iter().map(|i| if *i >= 1 { Ok(*i as usize - 1) } else { Err(Self::wrong(key, "1-based positions")) }).collect::<Result<Vec<_>>>().map(Some)
}
/// A bookmark's `left` or `top`: points on the displayed page, `None` when left out, an error
/// when it is not a number or lies absurdly far off the page.
fn bookmark_coordinate(&self, key: &str) -> Result<Option<f32>> {
match self.opt_num(key)? {
None => Ok(None),
Some(v) if v.is_finite() && v.abs() <= MAX_BOOKMARK_POSITION => Ok(Some(v as f32)),
Some(_) => Err(ToolError::InvalidArgs(format!("{key} must be between -{MAX_BOOKMARK_POSITION} and {MAX_BOOKMARK_POSITION} points"))),
}
}
fn strs(&self, key: &str) -> Result<Vec<&str>> {
let v = self.get(key).ok_or_else(|| Self::missing(key))?;
let arr = v.as_array().ok_or_else(|| Self::wrong(key, "an array of strings"))?;
@@ -2472,7 +2603,7 @@ fn info(d: &Document) -> Value {
pdfcraft_render::LinkTarget::Other(o) => json!({ "other": o }),
},
})).collect::<Vec<_>>(),
"layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible })).collect::<Vec<_>>(),
"layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible, "initial": l.initial })).collect::<Vec<_>>(),
// Full-screen presentation settings a page carries (ISO 32000-2 §12.4.4): only pages with
// a `/Trans` or `/Dur` appear; `style` is null for a display time alone.
"transitions": i.transitions.iter().map(|t| json!({
+2 -3
View File
@@ -70,9 +70,8 @@ impl Automation {
.links()
.iter()
.map(|l| {
let p = &doc.info.pages[l.page];
let (v0, v1) = (p.user_to_view(l.rect[0] as f32, l.rect[1] as f32), p.user_to_view(l.rect[2] as f32, l.rect[3] as f32));
let rect = [v0[0].min(v1[0]), v0[1].min(v1[1]), v0[0].max(v1[0]), v0[1].max(v1[1])].map(|x| (x * 100.0).round() / 100.0);
// The link's page may be one the viewer could not read (#2660): rect unchanged then.
let rect = crate::view_rect(&doc.info, l.page, l.rect.map(|x| x as f32));
let mut v = json!({ "page": l.page + 1, "index": l.index + 1, "rect": rect, "visible": l.style.visible });
// Where an in-document link goes, as the viewer resolves it: named destinations
// included, with the position on the page.
+18 -4
View File
@@ -2,7 +2,7 @@
use crate::{Args, Automation, Result, ToolError, failed};
use pdfcraft_engine::{
Edit, Style,
measure::{self, Kind, NewMeasurement, Scale},
measure::{self, Kind, NewMeasurement, PageUnit, Scale},
};
use serde_json::{Value, json};
@@ -78,16 +78,30 @@ impl Automation {
let info = self.doc(a)?.info.pages.get(page).ok_or_else(|| failed("no such page"))?.clone();
let precision = u8::try_from(a.opt_int("precision")?.unwrap_or(2)).map_err(|_| failed("precision must be between 0 and 6"))?;
let unit = a.opt_str("unit")?.unwrap_or("in");
if ["points", "units_per_point", "page_distance"].iter().filter(|k| a.get(k).is_some()).count() > 1 {
return Err(failed("pass one of calibration points, units_per_point or page_distance"));
}
if a.get("page_unit").is_some() && a.get("page_distance").is_none() {
return Err(failed("page_unit is the unit of page_distance; pass page_distance too"));
}
let scale = if a.get("points").is_some() {
if a.get("units_per_point").is_some() {
return Err(failed("pass calibration points or units_per_point, not both"));
}
let (_, points) = self.measurement_points(a)?;
let [from, to] = points.as_slice() else {
return Err(failed("scale calibration needs exactly two points"));
};
let length = a.opt_num("distance")?.ok_or_else(|| failed("calibration needs a real-world distance"))?;
Scale::calibrate(*from, *to, length, unit, precision).map_err(failed)?
} else if let Some(page_distance) = a.opt_num("page_distance")? {
// A ratio, "page_distance page_unit on the page = distance unit"; the page unit
// defaults to the real-world one, as "1:50" means 1 mm = 50 mm.
let page_unit = match a.opt_str("page_unit")? {
Some(u) => PageUnit::from_label(u).ok_or_else(|| failed("page_unit must be pt, in, mm or cm"))?,
None => PageUnit::from_label(unit).ok_or_else(|| failed("pass page_unit (pt, in, mm or cm) when unit is not one of them"))?,
};
let length = a.opt_num("distance")?.ok_or_else(|| failed("a scale ratio needs the real-world distance"))?;
let from = self.doc(a)?.measurement_to_user(page, [0.0, 0.0]).map_err(failed)?;
let to = self.doc(a)?.measurement_to_user(page, [1.0, 0.0]).map_err(failed)?;
Scale::ratio(from, to, page_distance, page_unit, length, unit, precision).map_err(failed)?
} else if let Some(factor) = a.opt_num("units_per_point")? {
{
let from = self.doc(a)?.measurement_to_user(page, [0.0, 0.0]).map_err(failed)?;
+36 -9
View File
@@ -6,6 +6,22 @@ use serde_json::{Value, json};
use crate::{Args, Automation, Result, ToolError, failed, write_atomic};
/// The job's two-sided setting for `doc_print`'s `duplex`: `printer` sends none, so the printer's
/// own default applies (as the Print dialog's Printer default, #2422), except that cut and stack
/// needs single-sided sheets.
fn duplex(value: &str, cut_stack: bool) -> Result<Option<spool::Duplex>> {
Ok(match value {
"printer" if cut_stack => Some(spool::Duplex::Off),
"printer" => None,
"off" => Some(spool::Duplex::Off),
"long-edge" if cut_stack => return Err(bad("cut-and-stack printing needs duplex off (single-sided sheets)")),
"short-edge" if cut_stack => return Err(bad("cut-and-stack printing needs duplex off (single-sided sheets)")),
"long-edge" => Some(spool::Duplex::LongEdge),
"short-edge" => Some(spool::Duplex::ShortEdge),
d => return Err(bad(format!("unknown duplex {d:?}"))),
})
}
fn bad(m: impl Into<String>) -> ToolError {
ToolError::InvalidArgs(m.into())
}
@@ -92,9 +108,7 @@ impl Automation {
},
l => return Err(bad(format!("unknown layout {l:?}"))),
};
if matches!(layout, Layout::Multiple { order: PageOrder::CutStack, .. }) && a.opt_str("duplex")?.unwrap_or("off") != "off" {
return Err(bad("cut-and-stack printing needs duplex off (single-sided sheets)"));
}
let duplex = duplex(a.opt_str("duplex")?.unwrap_or("off"), matches!(layout, Layout::Multiple { order: PageOrder::CutStack, .. }))?;
if a.opt_str("order")? == Some("cut-stack") && !matches!(layout, Layout::Multiple { .. }) {
return Err(bad("cut-stack order needs layout multiple"));
}
@@ -153,12 +167,7 @@ impl Automation {
printer: if printer == "default" { None } else { Some(printer.to_string()) },
copies: a.opt_int("copies")?.unwrap_or(1).clamp(1, 999) as u32,
collate: a.opt_bool("collate")?.unwrap_or(true),
duplex: Some(match a.opt_str("duplex")?.unwrap_or("off") {
"off" => spool::Duplex::Off,
"long-edge" => spool::Duplex::LongEdge,
"short-edge" => spool::Duplex::ShortEdge,
d => return Err(bad(format!("unknown duplex {d:?}"))),
}),
duplex,
scaling: spool::Scaling::Exact,
grayscale: a.opt_bool("grayscale")?.unwrap_or(false),
title: name,
@@ -181,3 +190,21 @@ impl Automation {
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `printer` leaves two-sided to the printer's own default (#2422); cut and stack still asks
/// for single-sided sheets, and refuses an explicit edge.
#[test]
fn duplex_printer_leaves_two_sided_to_the_printer() {
assert_eq!(duplex("printer", false).ok(), Some(None));
assert_eq!(duplex("printer", true).ok(), Some(Some(spool::Duplex::Off)));
assert_eq!(duplex("off", false).ok(), Some(Some(spool::Duplex::Off)));
assert_eq!(duplex("long-edge", false).ok(), Some(Some(spool::Duplex::LongEdge)));
assert_eq!(duplex("short-edge", false).ok(), Some(Some(spool::Duplex::ShortEdge)));
assert!(duplex("long-edge", true).is_err());
assert!(duplex("sideways", false).is_err());
}
}
+13 -3
View File
@@ -14,9 +14,19 @@ impl Automation {
pub(crate) fn redact_mark(&mut self, a: &Args) -> Result<Value> {
let doc = self.doc(a)?;
let (id, n) = (doc.id, doc.info.pages.len());
let pages: Vec<usize> = match a.opt_ints("pages")? {
Some(_) => self.pages(a, "pages")?,
None => (0..n).collect(),
if a.get("page").is_some() && a.get("pages").is_some() {
return Err(ToolError::InvalidArgs("pass page or pages, not both".into()));
}
if a.get("rect").is_some() && a.get("pages").is_some() {
return Err(ToolError::InvalidArgs("rect requires page, not pages".into()));
}
let pages: Vec<usize> = if a.get("page").is_some() {
vec![self.page(a)?]
} else {
match a.opt_ints("pages")? {
Some(_) => self.pages(a, "pages")?,
None => (0..n).collect(),
}
};
let overlay = match (a.opt_str("overlay")?, a.opt_str("code_set")?) {
(Some(_), Some(_)) => return Err(ToolError::InvalidArgs("pass overlay or code_set with codes, not both".into())),
+1 -1
View File
@@ -208,7 +208,7 @@ impl Automation {
};
let rect = match a.nums::<4>("rect")? {
Some(r) => {
let p = &doc.info.pages[page];
let p = doc.info.pages.get(page).ok_or_else(|| bad("the document has no page to put the signature on"))?;
let (u0, u1) = (p.view_to_user(r[0] as f32, r[1] as f32), p.view_to_user(r[2] as f32, r[3] as f32));
let u = [u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64];
if u[2] - u[0] < 4.0 || u[3] - u[1] < 4.0 {
+68 -17
View File
@@ -161,7 +161,7 @@ pub fn tools() -> Vec<ToolDef> {
t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600).")
.ro()
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "dpi": { "type": "number", "minimum": 1, "maximum": 600 },
"hide_text_block": { "type": "integer", "minimum": 1, "description": "Render an inline editor background without this paragraph (index from text_paragraphs). Does not modify the document." },
"hide_text_block": { "type": "integer", "minimum": 1, "description": "Render an inline editor background without this paragraph (number from text_paragraphs, resolved as text_edit does: refused once an edit merged that paragraph into a neighbour). Does not modify the document." },
"hide_added_text": { "type": "integer", "minimum": 1, "description": "Render a retyping background without this added text item (added index from object_list). Mutually exclusive with hide_text_block. Does not modify the document." }
}), &["doc", "page"])),
t("object_list", "List editable objects", "List existing paragraphs, Image/Form artwork and added content once, with source kind/index references, displayed rectangles and document generation. Indexes change when content changes; use generation in object_move to reject stale references.")
@@ -289,8 +289,21 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc"],
)),
t("bookmark_add", "Add a bookmark", "Add a bookmark that goes to a page, under a parent bookmark (or at the top level), at a position. Undoable.").with(schema(
json!({ "doc": doc(), "title": { "type": "string", "minLength": 1 }, "page": { "type": "integer", "minimum": 1 }, "parent": path("Parent bookmark (omit for the top level)"), "position": { "type": "integer", "minimum": 1, "description": "1-based position among the parent's children (default: last)." } }),
t(
"bookmark_add",
"Add a bookmark",
"Add a bookmark that goes to a page, under a parent bookmark (or at the top level), at a position. It goes to the top of the page, or to a place on it: `top` and `left` are points from the top-left of the displayed page (like the destination bookmark_list reports), each optional, and a coordinate left out keeps the reader's position that way. The zoom is kept. Undoable.",
)
.with(schema(
json!({
"doc": doc(),
"title": { "type": "string", "minLength": 1 },
"page": { "type": "integer", "minimum": 1 },
"parent": path("Parent bookmark (omit for the top level)"),
"position": { "type": "integer", "minimum": 1, "description": "1-based position among the parent's children (default: last)." },
"top": { "type": "number", "minimum": -1000000, "maximum": 1000000, "description": "Points from the top of the displayed page." },
"left": { "type": "number", "minimum": -1000000, "maximum": 1000000, "description": "Points from the left of the displayed page." }
}),
&["doc", "title", "page"],
)),
t("bookmark_rename", "Rename a bookmark", "Change a bookmark's title. Undoable.")
@@ -355,6 +368,27 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc", "page", "type", "rect"],
)),
t(
"form_add_field_table",
"Prepare form: add a table of text fields",
"Divide rect into rows and columns of text fields, one per cell, as one undo step. rect in points from the top-left of the displayed page [x0, y0, x1, y1]; rows 1-100 from the top; columns 1-50, or as many as names or widths list. Each column names its cells from its pattern in names: an N on its own after _ - / or : (price_N), # or {row} is the row number, {col} the column number, {seq} counts every cell along the rows, starting at first_row; with none of them the row number goes at the end. Rows count from first_row (default 1). Without names the columns are Table1_N, Table1_c2_N, … using the first free TableK. widths are relative (default equal); row_gap and column_gap are points between cells (default 0). Nothing is added if a cell would be under 4 points or a name would contain a period, repeat, or already exist; the error says which. Returns the fields' names. Undoable.",
)
.cmd("form.add.text_table")
.with(schema(
json!({
"doc": doc(),
"page": { "type": "integer", "minimum": 1 },
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
"rows": { "type": "integer", "minimum": 1, "maximum": 100 },
"columns": { "type": "integer", "minimum": 1, "maximum": 50 },
"names": { "type": "array", "items": { "type": "string" }, "description": "One name pattern per column." },
"widths": { "type": "array", "items": { "type": "number", "exclusiveMinimum": 0 }, "description": "Relative column widths, one per column." },
"row_gap": { "type": "number", "minimum": 0 },
"column_gap": { "type": "number", "minimum": 0 },
"first_row": { "type": "integer", "minimum": 0, "maximum": 99999 },
}),
&["doc", "page", "rect", "rows"],
)),
t("stamp_custom", "Add a custom stamp", "Stamp a picture: a PDF page (file_page, default 1) or an image file, centred at at [x, y] (points from the top-left of the displayed page) at its natural size, at most 200 pt. name labels it (default: the file name). Undoable.")
.cmd("comment.stamp")
.with(schema(
@@ -410,6 +444,12 @@ pub fn tools() -> Vec<ToolDef> {
json!({ "doc": doc(), "order": { "type": "string", "enum": ["row", "column", "structure", "annotations"] }, "pages": pages("to set (default: all)"), "field": { "type": "string" }, "move": { "type": "string", "enum": ["earlier", "later"] } }),
&["doc"],
)),
t("form_calculation_order", "Read or set the calculation order", "The calculated fields in the order they calculate (Acrobat: Set Field Calculation Order), each with the fields its formula reads (`reads`; a script's names built at run time aren't known) and its `issues`: runs_too_early (it reads `field`, which calculates after it, so it shows a stale value), cycle (it reads `fields`, which read it back: no order fixes that, a formula has to change) and not_in_order (the PDF's order leaves it out, so other viewers may not calculate it). `extra` lists entries of the PDF's order that calculate nothing. Without a change it only reads. Changes, one at a time: `order` (these fields first, in this order, then the other calculated fields in their order), `field` with `move` earlier|later (one place), or `fix` true (only what must move moves, so every field calculates after the fields it reads, and the extra entries go). A change recalculates the form. Undoable.")
.cmd("form.calculation_order")
.with(schema(
json!({ "doc": doc(), "order": { "type": "array", "items": { "type": "string" } }, "field": { "type": "string" }, "move": { "type": "string", "enum": ["earlier", "later"] }, "fix": { "type": "boolean" } }),
&["doc"],
)),
t("doc_export_data", "Export comments or form data", "Write comments and/or form data to path; the extension picks the format: .xfdf or .fdf (comments and/or fields), .xml, .csv or .txt (form data). For an XFA form, .xml is its data alone (rooted at the form's root data node, as Acrobat exports it) and .xdp an XML Data Package holding the whole datasets. what: all (default), comments, fields.")
.destructive()
.with(schema(json!({ "doc": doc(), "path": { "type": "string" }, "what": { "type": "string", "enum": ["all", "comments", "fields"] } }), &["doc", "path"])),
@@ -421,12 +461,12 @@ pub fn tools() -> Vec<ToolDef> {
t(
"redact_mark",
"Mark for redaction",
"Mark content for redaction (nothing is removed until redact_apply). One of: rect [x0, y0, x1, y1] (points from the top-left of the displayed page) with page; find (text, every match); words (a list of words or phrases, every match of each; the result's matched_words says how many marks each made); pattern (phone, email, credit-card, ssn, date: every match, Acrobat's Search & Redact patterns); whole_pages: true. find and words accept case_sensitive and whole_words (both default false). find, words, pattern and whole_pages work on pages (default all). overlay: text shown on the box once applied, or code_set (foia: U.S. FOIA (b)(1)(A)…(b)(9); privacy-act: U.S. Privacy Act (d)(5), (j)(1)…(k)(7)) with codes, shown as \"(b)(6), (b)(7)(C)\"; fill: box colour (default black). Undoable.",
"Mark content for redaction (nothing is removed until redact_apply). One of: rect [x0, y0, x1, y1] (points from the top-left of the displayed page) with page; find (text, every match); words (a list of words or phrases, every match of each; the result's matched_words says how many marks each made); pattern (phone, email, credit-card, ssn, date: every match, Acrobat's Search & Redact patterns); whole_pages: true. find and words accept case_sensitive and whole_words (both default false). Select one page with page or multiple with pages (default all); do not pass both. rect requires page. overlay: text shown on the box once applied, or code_set (foia: U.S. FOIA (b)(1)(A)…(b)(9); privacy-act: U.S. Privacy Act (d)(5), (j)(1)…(k)(7)) with codes, shown as \"(b)(6), (b)(7)(C)\"; fill: box colour (default black). Undoable.",
)
.with(schema(
json!({
"doc": doc(),
"page": { "type": "integer", "minimum": 1 },
"page": { "type": "integer", "minimum": 1, "description": "Single page to mark; mutually exclusive with pages." },
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
"find": { "type": "string", "minLength": 1 },
"words": { "type": "array", "items": { "type": "string", "minLength": 1 }, "minItems": 1, "maxItems": 1000 },
@@ -434,7 +474,7 @@ pub fn tools() -> Vec<ToolDef> {
"whole_words": { "type": "boolean", "description": "Match whole words for find or words (default false)." },
"pattern": { "type": "string", "enum": ["phone", "email", "credit-card", "ssn", "date"] },
"whole_pages": { "type": "boolean" },
"pages": pages("to search or mark (default: all)"),
"pages": pages("to search or mark (default: all); mutually exclusive with page, and not valid with rect"),
"overlay": { "type": "string" },
"code_set": { "type": "string", "enum": ["foia", "privacy-act"] },
"codes": { "type": "array", "items": { "type": "string" }, "minItems": 1, "description": "Redaction codes from code_set, e.g. [\"(b)(6)\"]." },
@@ -487,7 +527,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"doc_print",
"Print",
"Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed; default all); subset odd/even; reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, or A0/A1/A2/A3/A4/A5/A6 (short names or full preset labels); use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).",
"Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed; default all); subset odd/even; reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, A0/A1/A2/A3/A4/A5/A6, or US #10 Envelope (short names or full preset labels); use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).",
)
.with(schema(
json!({
@@ -512,7 +552,7 @@ pub fn tools() -> Vec<ToolDef> {
"printer": { "type": "string" },
"copies": { "type": "integer", "minimum": 1, "maximum": 999 },
"collate": { "type": "boolean" },
"duplex": { "type": "string", "enum": ["off", "long-edge", "short-edge"] },
"duplex": { "type": "string", "enum": ["off", "long-edge", "short-edge", "printer"], "description": "Two-sided printing (default off); printer sends no setting, so the printer's own default applies." },
"grayscale": { "type": "boolean" },
"as_image": { "type": "boolean", "description": "Print as image (Acrobat's Advanced Print Setup): every page rendered to a picture at dpi before it is placed on the paper, for printers that skip text, shading or transparency; the print is no longer searchable text, and with grayscale the pictures are grey." },
"dpi": { "type": "number", "minimum": 72, "maximum": 600, "description": "Resolution for as_image (default 300)." },
@@ -616,8 +656,8 @@ pub fn tools() -> Vec<ToolDef> {
.ro().cmd("measure.info").with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},"points":measure_points(),"type":{"type":"string","enum":["distance","perimeter","area"]}}), &["doc","page","points"])),
t("measure_list", "List measurements", "Saved measurement annotations with calculated values, scale and vertices in display coordinates. Measurements with unsupported imported formats (compound or fractional units, non-rectilinear scales) or invalid geometry are listed under unsupported with a reason.")
.ro().with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}}), &["doc"])),
t("measure_scale", "Set or read a measurement scale", "Read the scale at a point, or add a rectangular viewport using units_per_point or two calibration points and their real-world distance. Existing measurements retain their original scales. Undoable.")
.cmd("measure.scale").with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},"at":point(),"rect":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4},"name":{"type":"string"},"unit":{"type":"string"},"precision":{"type":"integer","minimum":0,"maximum":6},"units_per_point":{"type":"number","exclusiveMinimum":0},"points":measure_points(),"distance":{"type":"number","exclusiveMinimum":0}}), &["doc","page"])),
t("measure_scale", "Set or read a measurement scale", "Read the scale at a point, or add a rectangular viewport using a ratio (page_distance in page_unit on the page = distance in unit, e.g. 1 mm = 50 mm for a 1:50 plan; page_unit defaults to unit), units_per_point or two calibration points and their real-world distance. Existing measurements retain their original scales. Undoable.")
.cmd("measure.scale").with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},"at":point(),"rect":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4},"name":{"type":"string"},"unit":{"type":"string"},"precision":{"type":"integer","minimum":0,"maximum":6},"units_per_point":{"type":"number","exclusiveMinimum":0},"page_distance":{"type":"number","exclusiveMinimum":0},"page_unit":{"type":"string","enum":["pt","in","mm","cm"]},"points":measure_points(),"distance":{"type":"number","exclusiveMinimum":0}}), &["doc","page"])),
t("measure_snap", "Snap a measurement vertex", "Snap a point to vector paths, endpoints, midpoints or intersections. Coordinates and tolerance are in display points. Bounded extraction reports truncated geometry.")
.ro().cmd("measure.snap").with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},"at":point(),"tolerance":{"type":"number","minimum":0,"maximum":10000},"endpoints":{"type":"boolean"},"midpoints":{"type":"boolean"},"intersections":{"type":"boolean"},"paths":{"type":"boolean"}}), &["doc","page","at"])),
t("measure_export", "Export measurements as CSV", "Atomically write saved measurement values, labels, authors and scale ratios as spreadsheet-safe CSV. Returns how many unsupported measurements were left out.")
@@ -750,7 +790,7 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc", "id", "out"],
)),
t("sign_trust", "Trust certificates", "Add certificates (.cer/.crt/.pem/.der, or the certificates in a .p12 with `password`) to the trusted certificates used to validate signatures, or clear the list (clear: true). Every open document is revalidated. Returns the trusted list.").with(schema(
t("sign_trust", "Trust certificates", "Add certificates (.cer/.crt/.pem/.der, a .p7b/.p7c bundle, or the certificates in a .p12 with `password`) to the trusted certificates used to validate signatures, or clear the list (clear: true). Every open document is revalidated. Returns the trusted list.").with(schema(
json!({ "paths": { "type": "array", "items": { "type": "string" } }, "password": { "type": "string" }, "clear": { "type": "boolean" } }),
&[],
)),
@@ -762,6 +802,11 @@ pub fn tools() -> Vec<ToolDef> {
comment_ref(json!({ "locked": { "type": "boolean", "description": "Default true." } })),
&["doc"],
)),
t("layer_set", "Show or hide a layer", "Show or hide a layer (optional content group), as the Layers panel's eye icon does. layer is its name as doc_info lists it (every layer with that name) or its 1-based position in that list. A view setting: page_render draws with it, and the file doesn't change. Returns every layer with its visibility.")
.with(schema(
json!({ "doc": doc(), "layer": { "type": ["string", "integer"], "description": "A layer name, or a 1-based position in doc_info's layers." }, "visible": { "type": "boolean" } }),
&["doc", "layer", "visible"],
)),
t("comments_hide", "Hide all comments", "Hide or show every comment on the page (fields and links still draw). A view setting: the file doesn't change.")
.cmd("comment.hide_all")
.with(schema(json!({ "doc": doc(), "hidden": { "type": "boolean", "description": "Default true." } }), &["doc"])),
@@ -784,7 +829,7 @@ pub fn tools() -> Vec<ToolDef> {
json!({ "doc": doc(), "sort": { "type": "string", "enum": ["page", "author", "date", "type"] }, "out": save_out, "open": open }),
&["doc"],
)),
t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity, line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), rectangle (rectangle/oval/text box/stamp) or position (`move` [dx, dy] in points). Stamps keep their original appearance when resized. One undo step.").with(schema(
t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity, line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), rectangle (rectangle/oval/text box/stamp), position (`move` [dx, dy] in points) or shape (`points`: a line's two ends, or the corners of connected lines, a polygon or a cloud). Stamps keep their original appearance when resized. One undo step.").with(schema(
comment_ref(json!({
"contents": { "type": "string" },
"color": color(),
@@ -794,6 +839,7 @@ pub fn tools() -> Vec<ToolDef> {
"font_size": { "type": "number", "minimum": 1, "maximum": 400, "description": "Font size in points of a text box, typewriter text or callout; the box grows to hold the text." },
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
"move": point(),
"points": { "type": "array", "items": point(), "minItems": 2, "description": "A line's two ends, or every corner of connected lines, a polygon or a cloud, in page coordinates." },
})),
&["doc"],
)),
@@ -869,7 +915,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"doc_header_footer",
"Add header and footer",
"Add a header and/or footer to pages. Text boxes: header_left/center/right, footer_left/center/right. Tokens: <<1>> page number, <<n>> page count, <<1 of n>>, <<Page 1 of n>>, <<1/n>>, dates <<m/d/yyyy>> <<yyyy-mm-dd>> <<mmmm d, yyyy>> (and more), Bates <<Bates Number#6#1#PREFIX#SUFFIX>>. replace: true swaps out existing ones (Update). Undoable.",
"Add a header and/or footer to pages. Text boxes: header_left/center/right, footer_left/center/right. Tokens: <<1>> page number, <<n>> page count, <<1 of n>>, <<Page 1 of n>>, <<1/n>>, dates <<m/d/yyyy>> <<yyyy-mm-dd>> <<mmmm d, yyyy>> (and more), Bates <<Bates Number#6#1#PREFIX#SUFFIX>>. shrink: true scales the page content down evenly into the room the header and footer leave free, so they don't cover it. font: Helvetica (default), Times or Courier. replace: true swaps out existing ones (Update). Undoable.",
)
.cmd("edit.header_footer")
.with(schema(
@@ -878,10 +924,12 @@ pub fn tools() -> Vec<ToolDef> {
"pages": pages("to mark (default: all)"),
"header_left": { "type": "string" }, "header_center": { "type": "string" }, "header_right": { "type": "string" },
"footer_left": { "type": "string" }, "footer_center": { "type": "string" }, "footer_right": { "type": "string" },
"font": { "type": "string", "enum": ["Helvetica", "Times", "Courier"] },
"font_size": { "type": "number", "exclusiveMinimum": 0 },
"color": { "type": "string", "description": "#RRGGBB or a colour name." },
"margins": { "type": "array", "items": { "type": "number", "minimum": 0 }, "minItems": 4, "maxItems": 4, "description": "Top, bottom, left, right in points (default 36, 36, 72, 72)." },
"start_number": { "type": "integer", "minimum": 1 },
"shrink": { "type": "boolean", "description": "Shrink the page content so the header and footer don't cover it (default false)." },
"replace": { "type": "boolean" },
}),
&["doc"],
@@ -1078,6 +1126,8 @@ pub fn tools() -> Vec<ToolDef> {
t("pdfa_convert", "Save as PDF/A", "Standards ▸ Save as PDF/A: fix what can be fixed for PDF/A-2b or 3b (XMP identification and metadata, an sRGB output intent, forbidden actions and JavaScript, annotation print flags, image interpolation, encryption). Returns what was fixed and what remains (e.g. fonts that aren't embedded). Undoable; save the document to keep it.")
.cmd("standards.pdfa")
.with(schema(json!({ "doc": doc(), "level": { "type": "string", "enum": ["2b", "3b"] } }), &["doc"])),
t("standards_remove_id", "Remove PDF/A or PDF/X identification", "Remove what identifies the document as PDF/A (its pdfaid: XMP properties) or PDF/X (GTS_PDFXVersion and GTS_PDFXConformance, in the document information and as pdfxid: XMP properties), e.g. after edits it no longer meets. Output intents and other metadata stay. Fails when the document declares nothing of the kind. Undoable.")
.with(schema(json!({ "doc": doc(), "standard": { "type": "string", "enum": ["pdfa", "pdfx"] } }), &["doc", "standard"])),
t("action_list", "List actions", "Action Wizard: the built-in actions (name, description, steps) and every step an action can use (id, label, whether it takes a text argument).")
.ro()
.cmd("actions.wizard")
@@ -1246,16 +1296,17 @@ pub fn tools() -> Vec<ToolDef> {
.ro()
.cmd("edit.edit_text")
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])),
t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new full placement box in top-left-origin points, based on page_images.rect), crop (rect: the part to keep, top-left-origin points; the pixels stay in place and page_images.visible_rect shows the result; a later move carries the crop along), rotate (quarters clockwise, default 1), flip_horizontal, flip_vertical, replace (path: an image file, drawn in the same place) or delete. Undoable. The selected image crop transforms with its pixels, isolating shared path scopes while retaining neighboring painting. Text-outline clips return a clear error. Rotation/flip use the visible centre. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.")
t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new full placement box in top-left-origin points, based on page_images.rect), crop (rect: the part to keep, top-left-origin points; the pixels stay in place and page_images.visible_rect shows the result; a later move carries the crop along), rotate (quarters clockwise, default 1), rotate_by (degrees: any angle clockwise about the visible centre; the box in page_images then becomes the turned image's bounding box), flip_horizontal, flip_vertical, replace (path: an image file, drawn in the same place) or delete. Undoable. The selected image crop transforms with its pixels, isolating shared path scopes while retaining neighboring painting. Text-outline clips return a clear error. Rotation/flip use the visible centre. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.")
.cmd("edit.edit_text")
.with(schema(
json!({
"doc": doc(),
"page": { "type": "integer", "minimum": 1 },
"image": { "type": "integer", "minimum": 1 },
"action": { "type": "string", "enum": ["move", "crop", "rotate", "flip_horizontal", "flip_vertical", "replace", "delete"] },
"action": { "type": "string", "enum": ["move", "crop", "rotate", "rotate_by", "flip_horizontal", "flip_vertical", "replace", "delete"] },
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
"quarters": { "type": "integer" },
"degrees": { "type": "number" },
"path": { "type": "string" },
}),
&["doc", "page", "image", "action"],
@@ -1273,7 +1324,7 @@ pub fn tools() -> Vec<ToolDef> {
t("text_fonts", "List document fonts", "List validated embedded source faces and approved full preview faces for a page. Full registered resources can be selected by text_edit.font_resource; preview_only faces are not PDF resource names.")
.ro().cmd("edit.edit_text")
.with(schema(json!({"doc":doc(), "page":{"type":"integer","minimum":1}}), &["doc","page"])),
t("text_paragraphs", "List paragraphs", "The paragraphs on a page (adjacent source runs grouped into aligned visual rows, including mixed fonts, sizes and colours): number, text, editor_text (visual rows separated by newlines), rows and source line numbers, box (top-left-origin points), font and size. Use the number with text_edit's paragraph.")
t("text_paragraphs", "List paragraphs", "The paragraphs on a page (adjacent source runs grouped into aligned visual rows, including mixed fonts, sizes and colours): number, text, editor_text (visual rows separated by newlines), rows and source line numbers, box (top-left-origin points), font and size. Use the number with text_edit's paragraph: until you list the page again, a number names the paragraph listed here, even after edits regroup the page.")
.ro()
.cmd("edit.edit_text")
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])),
@@ -1281,7 +1332,7 @@ pub fn tools() -> Vec<ToolDef> {
.ro()
.cmd("edit.edit_text")
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "paragraph": { "type": "integer", "minimum": 1 }, "text": { "type": "string" }, "size": { "type": "number" }, "width": { "type": "number" }, "font_resource":{"type":"string"}, "ranges":{"type":"array","maxItems":1024,"items":{"type":"object"}} }), &["doc", "page", "paragraph"])),
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 mixed-style region, editor_text supplies visual row breaks; surviving text keeps its source-run styles and inserted text inherits the surrounding run. 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.")
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 mixed-style region, editor_text supplies visual row breaks; surviving text keeps its source-run styles and inserted text inherits the surrounding run. 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 text no standard font can show, real fallback outlines from PdfCraft's own faces, from the one that has every character: the craft-fonts Japanese faces (Mincho/Gothic matching the requested/source family and available weight) or, behind them, the bundled Inter face, which carries the Latin, Greek and Cyrillic letters they lack (Uzbek қ, Kazakh ә). 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. A paragraph number is refused when an earlier edit merged that paragraph into a neighbour (list the page again); the result's stale_paragraphs names the numbers gone. Undoable.")
.cmd("edit.edit_text")
.with(schema(
json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "line": { "type": "integer", "minimum": 1 },
File diff suppressed because it is too large Load Diff
+143
View File
@@ -336,3 +336,146 @@ fn listing(dir: &Path) -> Vec<(PathBuf, u64)> {
out.sort();
out
}
/// A six-page file whose fourth `/Kids` entry points at an object that does not exist (#2660),
/// with a link, a URI link and a comment on every page, and a form field and text added by
/// PdfCraft (its `/PCAdded` marker) on the last. Readers
/// disagree on such a file: the viewer stopped at the dangling entry and saw three pages, while
/// the editing layer sees all six, so annotations come back on pages the viewer does not have.
fn dangling_kid_pdf() -> Vec<u8> {
let pages: Vec<u32> = (0..6).map(|i| 10 + 2 * i).collect();
let mut kids: Vec<u32> = pages[..3].to_vec();
kids.push(77); // does not exist
kids.extend(&pages[3..]);
let kids: Vec<String> = kids.iter().map(|k| format!("{k} 0 R")).collect();
let mut objs: Vec<(u32, String)> = vec![
(1, "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [50 0 R] >> >>".into()),
(2, format!("<< /Type /Pages /Kids [{}] /Count 6 >>", kids.join(" "))),
(90, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into()),
];
for (i, &page) in pages.iter().enumerate() {
let text = format!("BT /F1 24 Tf 50 300 Td (Page {}) Tj ET", i + 1);
let (field, contents) = if i == 5 { (" 50 0 R", format!("[{} 0 R 60 0 R]", page + 1)) } else { ("", format!("{} 0 R", page + 1)) };
objs.push((
page,
format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 400] /Resources << /Font << /F1 90 0 R >> >> /Contents {contents} \
/Annots [<< /Type /Annot /Subtype /Link /Rect [10 10 100 40] /Dest [10 0 R /Fit] >> \
<< /Type /Annot /Subtype /Link /Rect [10 50 100 80] /A << /S /URI /URI (https://example.com/) >> >> \
<< /Type /Annot /Subtype /Text /Rect [200 200 220 220] /Contents (note) /T (author) >>{field}] >>"
),
));
objs.push((page + 1, format!("<< /Length {} >>\nstream\n{text}\nendstream", text.len())));
}
objs.push((50, "<< /FT /Tx /T (name) /Type /Annot /Subtype /Widget /Rect [50 100 250 130] /P 20 0 R >>".into()));
let added = "BT /F1 12 Tf 72 80 Td (AddedSample) Tj ET";
objs.push((
60,
format!(
"<< /PCMark /Added /PCAdded << /Kind /Text /Rect [70 70 150 100] /Text (AddedSample) /Size 12 >> /Length {} >>\nstream\n{added}\nendstream",
added.len()
),
));
objs.sort_by_key(|(n, _)| *n);
let size = 100;
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = vec![None; size];
for (num, body) in &objs {
if let Some(slot) = offsets.get_mut(*num as usize) {
*slot = Some(out.len());
}
out.extend_from_slice(format!("{num} 0 obj\n{body}\nendobj\n").as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f \n").as_bytes());
for offset in offsets.iter().skip(1) {
out.extend_from_slice(
match offset {
Some(o) => format!("{o:010} 00000 n \n"),
None => "0000000000 65535 f \n".to_string(),
}
.as_bytes(),
);
}
out.extend_from_slice(format!("trailer << /Size {size} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
out
}
#[test]
fn no_tool_panics_when_the_viewer_sees_fewer_pages_than_the_file_has() {
// #2660: `link_list` indexed the viewer's pages with a page number from the editing layer
// ("index out of bounds: the len is 3 but the index is 3"). Every tool, on a fresh copy of a
// file where the two disagree, must answer with a result or an error.
let root = tmp("dangling-kid");
// The JSON a call answers with, or the error it gave.
let answer = |a: &mut Automation, tool: &str, args: &Value| -> std::result::Result<Value, String> {
match catch_unwind(AssertUnwindSafe(|| a.call(tool, args))) {
Ok(Ok(content)) => content
.into_iter()
.find_map(|c| match c {
pdfcraft_automation::Content::Json(v) => Some(v),
pdfcraft_automation::Content::Png { .. } => None,
})
.ok_or_else(|| format!("{tool} answered without JSON")),
Ok(Err(e)) => Err(format!("{tool} failed: {e:?}")),
Err(_) => Err(format!("{tool} panicked")),
}
};
let pdf = dangling_kid_pdf();
let mut failures = Vec::new();
for def in tools() {
// Written again for every tool: tools that save write to `in.pdf`, the plausible path.
std::fs::write(root.join("in.pdf"), &pdf).expect("fixture");
let mut a = Automation::new().with_root(&root).expect("root");
let opened = a.call("doc_open", &json!({ "path": "in.pdf" })).expect("open the damaged file");
let doc = opened
.into_iter()
.find_map(|c| match c {
pdfcraft_automation::Content::Json(v) => v["doc"].as_u64(),
pdfcraft_automation::Content::Png { .. } => None,
})
.expect("the opened document has an id");
if let Err(e) = guarded(&mut a, def.name, &Value::Object(plausible_with(&def, Some(doc)))) {
failures.push(e);
}
match def.name {
// The field sits on the sixth page; a rect is placed on the field's own page. That is
// refused, saying why, and the field is left as it was.
"form_set_props" => {
let fields = |a: &mut Automation| answer(a, "form_fields", &json!({ "doc": doc }));
let before = fields(&mut a);
match catch_unwind(AssertUnwindSafe(|| a.call(def.name, &json!({ "doc": doc, "field": "name", "rect": [10, 10, 60, 30] })))) {
Ok(Err(ToolError::Failed(m))) if m == "name is on page 6, which could not be read" => {}
Ok(Err(e)) => failures.push(format!("form_set_props gave the wrong error: {e:?}")),
Ok(Ok(_)) => failures.push("form_set_props placed a rect on a page the viewer could not read".into()),
Err(_) => failures.push("form_set_props panicked on a rect for the field on page 6".into()),
}
let after = fields(&mut a);
if before.is_err() || before != after {
failures.push(format!("form_set_props changed the field: {before:?} became {after:?}"));
}
}
// Listing still reports what is on the pages the viewer lacks, not just survives it.
"link_list" => match answer(&mut a, def.name, &json!({ "doc": doc })) {
Ok(v) if v["count"] == 12 => {}
other => failures.push(format!("link_list did not list the file's 12 links: {other:?}")),
},
// The added text on page 6 is reported, its rect unchanged (user space), as
// `doc_info` reports rects on pages the viewer could not read.
"content_list" => match answer(&mut a, def.name, &json!({ "doc": doc })) {
Ok(v)
if v["items"].as_array().is_some_and(|items| {
items.iter().any(|it| it["page"] == 6 && it["type"] == "text" && it["rect"] == json!([70.0, 70.0, 150.0, 100.0]))
}) => {}
other => failures.push(format!("content_list did not report the added text on page 6 at [70, 70, 150, 100]: {other:?}")),
},
_ => {}
}
}
for f in &failures {
eprintln!(" {f}");
}
let _ = std::fs::remove_dir_all(&root);
assert!(failures.is_empty(), "{} failure(s); the first is: {}", failures.len(), failures.first().map_or("", String::as_str));
}
@@ -0,0 +1,165 @@
//! Contributor-original PDFs exercise optimization across content stream boundaries.
use std::sync::Arc;
use pdfcraft_automation::{Automation, Content};
use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream, write_full};
use serde_json::{Value, json};
const COLORS: [[u8; 3]; 4] = [[220, 30, 40], [20, 190, 60], [30, 60, 220], [230, 190, 20]];
fn fixture(ending: &str, split_operands: bool) -> Vec<u8> {
let mut doc = Document::new_empty();
let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap();
let font = doc.add(Object::Dict(Dict::from_iter([
(b"Type".to_vec(), Object::name("Font")),
(b"Subtype".to_vec(), Object::name("Type1")),
(b"BaseFont".to_vec(), Object::name("Helvetica")),
])));
let mut xobjects = Dict::new();
for (i, color) in COLORS.iter().enumerate() {
let dict = Dict::from_iter([
(b"Type".to_vec(), Object::name("XObject")),
(b"Subtype".to_vec(), Object::name("Image")),
(b"Width".to_vec(), 20.into()),
(b"Height".to_vec(), 5.into()),
(b"BitsPerComponent".to_vec(), 8.into()),
(b"ColorSpace".to_vec(), Object::name("DeviceRGB")),
]);
let image = doc.add(Object::Stream(Stream::flate(dict, &color.repeat(100))));
xobjects.set(format!("Im{i}").into_bytes(), Object::Ref(image));
}
// q and the translation stay active across both pieces. The optional split places
// the final two cm operands and its operator in the next stream.
let first = if split_operands {
"q 1 0 0 1".to_string()
} else {
format!("q 1 0 0 1 10 0 cm\nBT /F1 20 Tf 30 730 Td (FIRST) Tj ET\n% trailing comment{ending}")
};
let mut second = if split_operands {
"10 0 cm\nBT /F1 20 Tf 30 730 Td (FIRST) Tj ET\n".to_string()
} else {
"BT /F1 20 Tf 30 400 Td (HIDDEN) Tj ET\n".to_string()
};
for i in 0..4 {
second.push_str(&format!("q 20 0 0 5 100 {} cm /Im{i} Do Q\n", 200 + (3 - i) * 5));
}
second.push_str("BT /F1 20 Tf 30 100 Td (LAST) Tj ET\nQ\n");
let contents: Vec<Object> =
[first, second].into_iter().map(|piece| Object::Ref(doc.add(Object::Stream(Stream::from_raw(Dict::new(), piece.into_bytes()))))).collect();
let resources = Dict::from_iter([
(b"Font".to_vec(), Object::Dict(Dict::from_iter([(b"F1".to_vec(), Object::Ref(font))]))),
(b"XObject".to_vec(), Object::Dict(xobjects)),
]);
let page = doc.add(Object::Dict(Dict::from_iter([
(b"Type".to_vec(), Object::name("Page")),
(b"Parent".to_vec(), Object::Ref(pages)),
(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()])),
(b"Resources".to_vec(), Object::Dict(resources)),
(b"Contents".to_vec(), Object::Array(contents)),
])));
doc.update_dict(pages, |dict| {
dict.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)]));
dict.set(b"Count".to_vec(), Object::Int(1));
})
.unwrap();
write_full(&doc, &SaveOptions::default()).unwrap()
}
fn call(a: &mut Automation, tool: &str, args: Value) -> Value {
let result = a.call(tool, &args).unwrap();
let Content::Json(value) = &result[0] else { panic!("expected JSON from {tool}") };
value.clone()
}
fn check_page(a: &mut Automation, doc: u64, hidden: bool, phase: &str) {
let text = call(a, "text_extract", json!({"doc": doc}));
let words: Vec<_> = text["pages"][0]["text"].as_str().unwrap().split_whitespace().collect();
assert_eq!(words, if hidden { vec!["FIRST", "HIDDEN", "LAST"] } else { vec!["FIRST", "LAST"] }, "{phase} text");
let rendered = a.call("page_render", &json!({"doc": doc, "page": 1, "dpi": 72})).unwrap();
let Content::Png { data, width, height } = &rendered[0] else { panic!("expected page PNG") };
assert_eq!((*width, *height), (600, 800));
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();
let channels = info.color_type.samples();
assert!(channels >= 3);
let pixel = |x: usize, y: usize| &pixels[(y * 600 + x) * channels..(y * 600 + x) * channels + 3];
let dark = |left, top, right, bottom| {
(top..bottom).flat_map(|y| (left..right).map(move |x| (x, y))).filter(|&(x, y)| pixel(x, y).iter().any(|v| *v < 128)).count()
};
assert!(dark(35, 45, 130, 75) > 30, "FIRST must draw at its translated position");
assert!(dark(35, 675, 130, 705) > 30, "LAST must draw at its translated position");
assert_eq!(dark(35, 375, 150, 405) > 0, hidden, "HIDDEN region must match the real comment ending");
for (row, color) in COLORS.iter().enumerate() {
for y in 581 + row * 5..584 + row * 5 {
for x in 112..128 {
assert_eq!(pixel(x, y), color, "strip {row}, pixel {x},{y}");
}
}
}
assert_eq!(pixel(105, 590), [255, 255, 255], "the cross-piece translation stays active");
}
fn check_saved_images(bytes: Vec<u8>, merged: bool) {
let doc = Document::open(Arc::new(bytes)).unwrap();
let root = doc.get(doc.root().unwrap());
let pages = doc.get(root.as_dict().unwrap().reference(b"Pages").unwrap());
let kids = pages.as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap();
let page = doc.resolve(&kids[0]);
let resources = doc.resolve(page.as_dict().unwrap().get(b"Resources").unwrap());
let xobjects = doc.resolve(resources.as_dict().unwrap().get(b"XObject").unwrap());
let images: Vec<_> = xobjects.as_dict().unwrap().iter().map(|(_, value)| doc.resolve(value)).collect();
assert_eq!(images.len(), if merged { 1 } else { 4 });
if merged {
let Object::Stream(image) = &*images[0] else { panic!("expected saved image") };
assert_eq!((image.dict.int(b"Width"), image.dict.int(b"Height")), (Some(20), Some(20)));
let expected: Vec<_> = COLORS.iter().flat_map(|color| color.repeat(100)).collect();
assert_eq!(image.decoded().unwrap(), expected, "saved RGB rows keep their strip order");
}
}
fn roundtrip(ending: &str, split_operands: bool, merge: bool) {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("input.pdf"), fixture(ending, split_operands)).unwrap();
let mut a = Automation::new().with_root(dir.path()).unwrap();
let doc = call(&mut a, "doc_open", json!({"path": "input.pdf"}))["doc"].as_u64().unwrap();
let hidden = !ending.is_empty() && !split_operands;
check_page(&mut a, doc, hidden, "original");
let report = call(
&mut a,
"doc_optimize",
json!({
"doc": doc, "path": "output.pdf", "merge_fragments": merge,
"color": {"downsample": false, "compression": "retain"},
"gray": {"downsample": false, "compression": "retain"}
}),
);
assert_eq!(report["fragments_merged"], if merge { 4 } else { 0 });
assert_eq!(report["merged_images"], if merge { 1 } else { 0 });
let reopened = call(&mut a, "doc_open", json!({"path": "output.pdf"}))["doc"].as_u64().unwrap();
check_page(&mut a, reopened, hidden, "reopened");
check_saved_images(std::fs::read(dir.path().join("output.pdf")).unwrap(), merge);
}
#[test]
fn fragment_merge_preserves_a_comment_across_content_pieces() {
roundtrip("", false, true);
}
#[test]
fn fragment_merge_preserves_explicit_comment_line_endings() {
for ending in ["\n", "\r", "\r\n"] {
roundtrip(ending, false, true);
}
}
#[test]
fn fragment_merge_preserves_split_operands_and_graphics_state() {
roundtrip("", true, true);
}
#[test]
fn disabled_fragment_merge_preserves_content_pieces() {
roundtrip("", false, false);
}
+65 -7
View File
@@ -219,19 +219,40 @@ fn inline_image(data: &[u8], pos: usize) -> Result<(Dict, Vec<u8>, usize), usize
return Ok((dict, Vec::new(), data.len()));
}
// Without an `EI` after it, the image runs to the end of the stream.
return Err(end_of_image(data, at).map_or(data.len(), |(_, end)| end));
return Err(end_of_image(data, at, b"").map_or(data.len(), |(_, end)| end));
};
dict.set(k, v);
}
// One whitespace byte separates ID from the data.
let begin = (lx.pos + 1).min(data.len());
let end = unfiltered_image_end(data, begin, &dict).or_else(|| end_of_image(data, begin));
let end = unfiltered_image_end(data, begin, &dict)
.or_else(|| declared_image_end(data, begin, &dict))
.or_else(|| image_data_end(data, begin, filter_eod(&dict)));
Ok(match end {
Some((end_data, end)) => (dict, data[begin..end_data].to_vec(), end),
None => (dict, data[begin..].to_vec(), data.len()),
})
}
/// The end-of-data marker (`>` for ASCIIHex, `~>` for ASCII85) of the outer stream filter, if any.
fn filter_eod(dict: &Dict) -> &'static [u8] {
for key in [b"Filter".as_slice(), b"F"] {
if let Some(filter) = dict.get(key) {
let name = match filter {
Object::Name(name) => Some(name.as_slice()),
Object::Array(filters) => filters.first().and_then(Object::as_name),
_ => None,
};
return match name {
Some(b"ASCIIHexDecode" | b"AHx") => b">",
Some(b"ASCII85Decode" | b"A85") => b"~>",
_ => b"",
};
}
}
b""
}
/// An unfiltered image's samples determine its length, including byte padding per row.
/// Binary samples may themselves contain whitespace + `EI` + whitespace, so use the known
/// length when the actual terminator follows it. Unknown or damaged layouts keep the heuristic.
@@ -293,23 +314,60 @@ fn unfiltered_image_end(data: &[u8], begin: usize, dict: &Dict) -> Option<(usize
let row = width.checked_mul(components)?.checked_mul(bits)?.div_ceil(8);
let end_data = begin.checked_add(row.checked_mul(height)?)?;
let after = data.get(end_data..)?;
if !after.first().is_some_and(|b| is_ws(*b)) {
let separator = after.iter().position(|b| !is_ws(*b))?;
if separator == 0 && data.get(end_data.checked_sub(1)?).is_some_and(|b| is_ws(*b)) {
return None;
}
let separator = after.iter().position(|b| !is_ws(*b))?;
let ei = end_data.checked_add(separator)?;
let end = ei.checked_add(2)?;
if data.get(ei..end) == Some(b"EI") && data.get(end).is_none_or(|b| is_ws(*b) || is_delim(*b)) { Some((end_data, end)) } else { None }
}
/// The length declared by `/L` (or `/Length`, ISO 32000-2 §8.9.7), used when `EI` follows the
/// declared bytes after optional whitespace. A declared length without that `EI` is ignored.
fn declared_image_end(data: &[u8], begin: usize, dict: &Dict) -> Option<(usize, usize)> {
let length = dict.get(b"L").or_else(|| dict.get(b"Length"))?.as_int()?;
let end_data = begin.checked_add(usize::try_from(length).ok()?)?;
let after = data.get(end_data..)?;
let ei = end_data.checked_add(after.iter().position(|b| !is_ws(*b))?)?;
let end = ei.checked_add(2)?;
if data.get(ei..end) == Some(b"EI") && data.get(end).is_none_or(|b| is_ws(*b) || is_delim(*b)) { Some((end_data, end)) } else { None }
}
/// Where inline image data of unknown length ends. A candidate `EI` followed by binary bytes before
/// the next `EI` lies inside the (compressed) data, unless another inline image starts first.
fn image_data_end(data: &[u8], begin: usize, eod: &[u8]) -> Option<(usize, usize)> {
let mut candidate = end_of_image(data, begin, eod)?;
loop {
let (end_data, end) = candidate;
let Some(next) = end_of_image(data, end, eod) else {
return Some(candidate);
};
let between = data.get(end..next.0).unwrap_or_default();
if !between.iter().any(|b| !b.is_ascii()) || starts_inline_image(between) {
return Some((end_data, end));
}
candidate = next;
}
}
/// Whether `bytes` hold a whitespace-delimited `BI` operator.
fn starts_inline_image(bytes: &[u8]) -> bool {
bytes
.windows(2)
.enumerate()
.any(|(i, pair)| pair == b"BI" && (i == 0 || is_ws(bytes[i - 1])) && bytes.get(i + 2).is_none_or(|b| is_ws(*b) || is_delim(*b)))
}
/// Where inline image data starting at `begin` ends: (end of the data, position after `EI`). The
/// data ends at whitespace + `EI` + (whitespace, a delimiter or the end).
fn end_of_image(data: &[u8], begin: usize) -> Option<(usize, usize)> {
/// data ends at whitespace (or `eod`: `>` for ASCIIHex, `~>` for ASCII85) + `EI` + (whitespace,
/// a delimiter or the end).
fn end_of_image(data: &[u8], begin: usize, eod: &[u8]) -> Option<(usize, usize)> {
let mut i = begin;
while i + 2 <= data.len() {
if data[i] == b'E'
&& data.get(i + 1) == Some(&b'I')
&& (i == begin || is_ws(data[i - 1]))
&& (i == begin || is_ws(data[i - 1]) || (!eod.is_empty() && data[begin..i].ends_with(eod)))
&& data.get(i + 2).is_none_or(|b| is_ws(*b) || is_delim(*b))
{
let end_data = if i > begin && is_ws(data[i - 1]) { i - 1 } else { i };
+71
View File
@@ -151,6 +151,32 @@ fn filtered_unknown_and_damaged_inline_images_keep_the_existing_fallback() {
}
}
/// A zlib stream (one stored block) for 64 DeviceGray samples whose bytes hold " EI\n" at offset 41,
/// the way a stored deflate block copies incompressible samples verbatim (#3579).
const FLATE_WITH_EI: &str = "789c014000bfffed746159d50788f7ea604984cd08e00b4c8983207f56fe82c77ef8dfb8b9a3a586382045490a3120\
31206c2025630aaa4353e6813e233b96539c18bc3a2c5009e9d51b81";
#[test]
fn flate_inline_image_is_not_cut_at_an_ei_inside_its_compressed_bytes() {
let flate: Vec<u8> = (0..FLATE_WITH_EI.len()).step_by(2).map(|i| u8::from_str_radix(&FLATE_WITH_EI[i..i + 2], 16).unwrap()).collect();
assert_eq!(flate.len(), 75);
assert_eq!(&flate[41..45], b" EI\n");
for parameters in [b"/W 64 /H 1 /BPC 8 /CS /G /F /Fl".as_slice(), b"/W 64 /H 1 /BPC 8 /CS /G /F /Fl /L 75"] {
let mut src = b"q 200 0 0 200 100 300 cm BI ".to_vec();
src.extend_from_slice(parameters);
src.extend_from_slice(b" ID ");
src.extend_from_slice(&flate);
src.extend_from_slice(b"\nEI Q BT /F1 24 Tf 72 200 Td (AFTER) Tj ET");
let parsed = parse(&src);
let label = String::from_utf8_lossy(parameters);
assert_eq!(parsed.ops[2].inline.as_ref().unwrap().1.len(), 75, "{label}");
let names: Vec<&[u8]> = parsed.ops.iter().map(|o| o.op.as_slice()).collect();
assert_eq!(names, [&b"q"[..], b"cm", b"BI", b"Q", b"BT", b"Tf", b"Td", b"Tj", b"ET"], "{label}");
assert_eq!(parsed.skipped, 0, "{label}");
assert_eq!(parse(&serialize_ops(&parsed.ops)).ops[2].inline, parsed.ops[2].inline, "{label}");
}
}
fn inline_pdf(content: &[u8], object_streams: bool) -> Vec<u8> {
use pdfcraft_cos::{Document, SaveOptions, Stream, write_full};
@@ -388,3 +414,48 @@ fn piece_lookup_preserves_exact_boundary_and_empty_piece_mapping() {
assert_eq!(empty.piece_of(0), 0);
assert_eq!(empty.piece_of(usize::MAX), 0);
}
#[test]
fn inline_image_terminator_without_preceding_whitespace() {
let cases: &[(&[u8], &[u8])] = &[
(b"BI /W 4 /H 1 /BPC 8 /CS /G /F /AHx ID 2060A0E0>EI Q BT /F1 18 Tf (After) Tj ET", b"2060A0E0>"),
(b"BI /W 4 /H 1 /BPC 8 /CS /G /Filter /ASCIIHexDecode ID 2060A0E0>EI Q BT /F1 18 Tf (After) Tj ET", b"2060A0E0>"),
(b"BI /W 4 /H 1 /BPC 8 /CS /G /F [/AHx] ID 2060A0E0>EI Q BT /F1 18 Tf (After) Tj ET", b"2060A0E0>"),
(b"BI /W 4 /H 1 /BPC 8 /CS /G /F [/AHx /RL] ID 2060A0E0>EI Q BT /F1 18 Tf (After) Tj ET", b"2060A0E0>"),
(b"BI /W 4 /H 1 /BPC 8 /CS /G /F /A85 ID 5s,^g~>EI Q BT /F1 18 Tf (After) Tj ET", b"5s,^g~>"),
(b"BI /W 4 /H 1 /BPC 8 /CS /G /Filter /ASCII85Decode ID 5s,^g~>EI Q BT /F1 18 Tf (After) Tj ET", b"5s,^g~>"),
(b"BI /W 4 /H 1 /BPC 8 /CS /G /F [/A85 /RL] ID 5s,^g~>EI Q BT /F1 18 Tf (After) Tj ET", b"5s,^g~>"),
(b"BI /W 4 /H 1 /BPC 8 /CS /G ID ab()EI Q BT /F1 18 Tf (After) Tj ET", b"ab()"),
(b"BI /W 4 /H 1 /BPC 8 /CS /G ID ab[]EI Q BT /F1 18 Tf (After) Tj ET", b"ab[]"),
(b"BI /W 4 /H 1 /BPC 8 /CS /G ID \x20\x60\xa0\xe0EI Q BT /F1 18 Tf (After) Tj ET", b"\x20\x60\xa0\xe0"),
];
for (src, expected_data) in cases {
let p = parse(src);
assert_eq!(p.skipped, 0, "stream should have 0 skipped bytes: {}", String::from_utf8_lossy(src));
let names: Vec<&[u8]> = p.ops.iter().map(|o| o.op.as_slice()).collect();
assert_eq!(
names,
[&b"BI"[..], b"Q", b"BT", b"Tf", b"Tj", b"ET"],
"operators after inline image must be preserved: {}",
String::from_utf8_lossy(src)
);
let (_, img) = p.ops[0].inline.as_ref().unwrap();
assert_eq!(img, expected_data, "inline image data must match: {}", String::from_utf8_lossy(src));
}
}
#[test]
fn ascii85_inline_image_does_not_terminate_at_gt_ei_inside_payload() {
for src in [
b"BI /W 4 /H 1 /BPC 8 /CS /G /F /A85 ID !!!!>EI !!~>\nEI Q BT /F1 18 Tf (After) Tj ET".as_slice(),
b"BI /W 4 /H 1 /BPC 8 /CS /G /F [/A85 /AHx] ID !!!!>EI !!~>\nEI Q BT /F1 18 Tf (After) Tj ET".as_slice(),
] {
let p = parse(src);
assert_eq!(p.skipped, 0);
let names: Vec<&[u8]> = p.ops.iter().map(|o| o.op.as_slice()).collect();
assert_eq!(names, [&b"BI"[..], b"Q", b"BT", b"Tf", b"Tj", b"ET"]);
let (_, img) = p.ops[0].inline.as_ref().unwrap();
assert_eq!(img, b"!!!!>EI !!~>");
}
}
+639 -68
View File
@@ -268,6 +268,20 @@ impl XrefIndex {
}
}
/// Every entry, newest section first, as the materialized reader keeps them.
fn to_map(&self, file: &[u8]) -> BTreeMap<u32, XrefEntry> {
match self {
Self::Materialized(entries) => entries.as_ref().clone(),
Self::Compact { revisions, .. } => {
let mut numbers = Vec::new();
for revision in revisions {
revision.extend_numbers(&mut numbers);
}
numbers.into_iter().filter_map(|num| self.lookup(num, file).map(|entry| (num, entry))).collect()
}
}
}
fn max_num(&self) -> u32 {
match self {
Self::Compact { max_num, .. } => *max_num,
@@ -317,11 +331,22 @@ pub struct Revision {
#[derive(Clone, Debug)]
struct ObjStm {
/// (object number, byte offset within `data`).
index: Vec<(u32, usize)>,
/// One slot per member in stream order, since cross-reference entries name a member by its
/// position: (object number, byte offset within `data`), or `None` for a member whose number
/// or offset is unusable ([`objstm_member`]).
index: Vec<Option<(u32, usize)>>,
data: Arc<Vec<u8>>,
}
/// Where member `num` of an object stream starts: its `offset` past `/First` (`first`), when the
/// number is an object number and the start lies inside the decoded `len` bytes. Checked on every
/// target, so a 32-bit build neither truncates nor wraps a large `/First` or offset (#1815).
fn objstm_member(num: i64, offset: i64, first: Option<usize>, len: usize) -> Option<(u32, usize)> {
let num = u32::try_from(num).ok()?;
let start = first?.checked_add(usize::try_from(offset).ok()?)?;
(start < len).then_some((num, start))
}
/// Where an overlay slot came from.
#[derive(Clone, Debug, PartialEq)]
enum Slot {
@@ -386,6 +411,10 @@ pub struct Document {
revisions: Arc<Vec<Revision>>,
/// See [`Self::xref_oddities`].
xref_oddities: Arc<Vec<(u64, u32)>>,
/// Where the file's object headers are, as (offset, generation) of the last header of each
/// number. Filled only when the cross-reference data proved inconsistent; see
/// `fill_xref_gaps`.
located: Arc<BTreeMap<u32, (u64, u16)>>,
repair_log: Arc<Vec<String>>,
cache: Arc<Mutex<HashMap<u32, Arc<Object>>>>,
objstms: Arc<Mutex<HashMap<u32, Arc<ObjStm>>>>,
@@ -435,7 +464,7 @@ impl ObjectReader {
let bytes = streams.values().fold(0usize, |bytes, stream| {
bytes
.saturating_add(stream.data.capacity())
.saturating_add(stream.index.capacity().saturating_mul(std::mem::size_of::<(u32, usize)>()))
.saturating_add(stream.index.capacity().saturating_mul(std::mem::size_of::<Option<(u32, usize)>>()))
});
if bytes > Self::STREAM_BYTES || streams.len() >= Self::OBJECTS {
// Retain one oversized active stream so its next object does not require
@@ -508,6 +537,7 @@ impl Document {
trailer: Dict::new(),
revisions: Arc::new(Vec::new()),
xref_oddities: Arc::default(),
located: Arc::default(),
repair_log: Arc::new(Vec::new()),
cache: Arc::default(),
objstms: Arc::default(),
@@ -589,6 +619,7 @@ impl Document {
trailer: Dict::new(),
revisions: Arc::new(Vec::new()),
xref_oddities: Arc::default(),
located: Arc::default(),
repair_log: Arc::new(Vec::new()),
cache: Arc::default(),
objstms: Arc::default(),
@@ -616,6 +647,7 @@ impl Document {
doc.trailer = trailer;
doc.revisions = Arc::new(revisions);
doc.xref_oddities = Arc::new(oddities);
doc.fill_xref_gaps(&mut log);
// Authenticate before checking the catalog: it may sit in an encrypted object
// stream, which reads as garbage until the key is known.
if let Some(enc) = doc.trailer.get(b"Encrypt").cloned() {
@@ -950,9 +982,9 @@ impl Document {
// Offsets may count from a header that does not start the file.
usize::try_from(offset).is_ok_and(|off| at(off) || (self.header_offset != 0 && at(off.saturating_add(self.header_offset))))
}
Some(XrefEntry::InStream { stream, index }) => {
self.objstm(stream).is_ok_and(|stm| usize::try_from(index).ok().and_then(|i| stm.index.get(i)).is_some_and(|(n, _)| *n == num))
}
Some(XrefEntry::InStream { stream, index }) => self
.objstm(stream)
.is_ok_and(|stm| usize::try_from(index).ok().and_then(|i| stm.index.get(i)).is_some_and(|m| m.is_some_and(|(n, _)| n == num))),
}
}
@@ -1065,7 +1097,7 @@ impl Document {
let bytes = streams.values().fold(0usize, |bytes, stream| {
bytes
.saturating_add(stream.data.capacity())
.saturating_add(stream.index.capacity().saturating_mul(std::mem::size_of::<(u32, usize)>()))
.saturating_add(stream.index.capacity().saturating_mul(std::mem::size_of::<Option<(u32, usize)>>()))
});
bytes > Self::EDITOR_OBJSTM_BYTES || streams.len() > Self::EDITOR_OBJSTM_OBJECTS
};
@@ -1151,19 +1183,15 @@ impl Document {
// Offsets relative to a shifted header, or simply wrong: try both fixes.
at(off)
.or_else(|| if self.header_offset == 0 { None } else { at(off.saturating_add(self.header_offset)) })
.or_else(|| self.located.get(&num).and_then(|(o, _)| usize::try_from(*o).ok()).and_then(at))
.or_else(|| self.scan_for(num).map(|(id, o)| self.decrypted(id, o)))
.ok_or(CosError::MissingObject(num))
}
Some(XrefEntry::InStream { stream, index }) => {
let stm = self.objstm(stream)?;
let (n, off) = stm
.index
.get(index as usize)
.copied()
.or_else(|| stm.index.iter().find(|(n, _)| *n == num).copied())
.ok_or(CosError::MissingObject(num))?;
let off =
if n == num { off } else { stm.index.iter().find(|(n, _)| *n == num).map(|(_, o)| *o).ok_or(CosError::MissingObject(num))? };
// The member at the entry's position, else wherever the stream lists `num`.
let at = usize::try_from(index).ok().and_then(|i| stm.index.get(i)).copied().flatten().filter(|(n, _)| *n == num);
let (_, off) = at.or_else(|| stm.index.iter().flatten().find(|(n, _)| *n == num).copied()).ok_or(CosError::MissingObject(num))?;
Lexer::new(&stm.data, off).object()
}
}
@@ -1187,13 +1215,13 @@ impl Document {
};
let Object::Stream(s) = &*loaded else { return Err(CosError::MissingObject(num)) };
let n = s.dict.int(b"N").unwrap_or(0).clamp(0, 1_000_000) as usize;
let first = s.dict.int(b"First").unwrap_or(0).max(0) as usize;
let first = usize::try_from(s.dict.int(b"First").unwrap_or(0).max(0)).ok();
let data = s.decoded_within(self.stream_limit)?;
let mut lx = Lexer::new(&data, 0);
let mut index = Vec::with_capacity(n);
for _ in 0..n {
let (Some(Object::Int(onum)), Some(Object::Int(off))) = (lx.object().ok(), lx.object().ok()) else { break };
index.push((onum.max(0) as u32, first + off.max(0) as usize));
index.push(objstm_member(onum, off, first, data.len()));
}
let stm = Arc::new(ObjStm { index, data: Arc::new(data) });
// Same bound as the object cache: decoded object streams can be megabytes each.
@@ -1476,7 +1504,7 @@ impl Document {
}
}
sections.push(XrefRevisionSection { primary, hybrid });
next = sect_trailer.int(b"Prev").filter(|p| *p >= 0).map(|p| p as u64);
next = prev_offset(data, &sect_trailer, off, |num| sections.iter().find_map(|s| s.lookup(num, data)), log);
if trailer.is_none() {
trailer = Some(sect_trailer);
}
@@ -1526,7 +1554,7 @@ impl Document {
if !valid_xref_range(start, count) || count > 10_000_000 {
return Err(CosError::Syntax { offset: lx.pos, detail: "invalid xref object range".into() });
}
let shifted = subsection_start(data, lx.pos, start, count);
let shifted = subsection_start(data, self.header_offset, lx.pos, start, count);
renumbered |= shifted != start;
let start = shifted;
let Some(next_rows) = usize::try_from(count).ok().and_then(|count| row_count.checked_add(count)) else { return Ok(None) };
@@ -1764,7 +1792,7 @@ impl Document {
for (num, e) in sect {
entries.entry(num).or_insert(e);
}
next = sect_trailer.int(b"Prev").filter(|p| *p >= 0).map(|p| p as u64);
next = prev_offset(data, &sect_trailer, off, |num| entries.get(&num).copied(), log);
if trailer.is_none() {
trailer = Some(sect_trailer);
}
@@ -1802,7 +1830,7 @@ impl Document {
if !valid_xref_range(start, count) || count > 10_000_000 {
return Err(CosError::Syntax { offset: lx.pos, detail: "invalid xref object range".into() });
}
let shifted = subsection_start(data, lx.pos, start, count);
let shifted = subsection_start(data, self.header_offset, lx.pos, start, count);
if shifted != start {
log.push(format!("xref subsection at {off} is numbered from 1 but begins with object 0's entry; read from 0"));
}
@@ -1906,32 +1934,72 @@ impl Document {
Ok((s.dict.clone(), true))
}
/// Cross-reference data that leaves out objects the file contains: a subsection numbered one
/// too low (object 0 in use, the last object without an entry), or an in-use entry at offset 0
/// that nothing else replaces. (A number no section lists is not evidence: it is a null
/// object, §7.3.10, and valid files have them below an oversized `/Size`.) Viewers rebuild
/// the table and show these objects; reading them as null would drop them from every copy.
/// When one of those shows, the file is scanned once for object headers, and wrong offsets
/// are looked up there (`load`). Only the numbers the damage leaves out take the last
/// header's location: one whose entry was set aside, or the last object of a subsection
/// numbered one too low. Any other unlisted number stays null, and existing
/// entries are never replaced: an object freed by an update stays freed.
fn fill_xref_gaps(&mut self, log: &mut Vec<String>) {
let entries = &self.entries;
let shifted = matches!(entries.lookup(0, &self.data), Some(XrefEntry::InFile { .. }));
let set_aside = self.xref_oddities.iter().any(|(_, num)| entries.lookup(*num, &self.data).is_none());
if !(shifted || set_aside) {
return;
}
let mut located = BTreeMap::new();
for (num, offset, generation, _) in object_headers(&self.data) {
located.insert(num, (offset, generation));
}
let mut filled = entries.to_map(&self.data);
// A subsection `0 N` whose rows are those of objects 1 to N leaves out object N, whatever
// newer sections say about its neighbours.
let past_shift: HashSet<u32> = if shifted {
self.revisions
.iter()
.filter(|r| !r.is_stream)
.filter_map(|r| usize::try_from(r.xref_offset).ok().map(|o| if r.header_relative { o.saturating_add(self.header_offset) } else { o }))
.filter_map(|at| shifted_subsection_end(&self.data, at))
.collect()
} else {
HashSet::new()
};
let odd: HashSet<u32> = self.xref_oddities.iter().map(|(_, n)| *n).collect();
let mut found = 0usize;
for (&num, &(offset, generation)) in &located {
let left_out = past_shift.contains(&num) || odd.contains(&num);
if num != 0 && !filled.contains_key(&num) && left_out {
filled.insert(num, XrefEntry::InFile { offset, generation });
found += 1;
}
}
if found > 0 {
log.push(format!("the cross-reference data leaves out {found} objects the file contains; located them by their object headers"));
self.entries = Arc::new(XrefIndex::Materialized(Arc::new(filled)));
}
self.located = Arc::new(located);
}
/// Rebuild the cross-reference index by scanning for `N G obj` headers (§7.5.4 recovery).
fn reconstruct(&mut self, log: &mut Vec<String>) -> Result<(), CosError> {
let data = self.data.clone();
let mut entries: BTreeMap<u32, XrefEntry> = BTreeMap::new();
let mut i = 0;
let mut objstms = Vec::new();
while i < data.len() {
// Object headers start at a line start (or after whitespace).
let at_start = i == 0 || is_whitespace(data[i - 1]) || data[i - 1] == b'>';
if at_start && data[i].is_ascii_digit() {
let mut lx = Lexer::new(&data, i);
let n = lx.token();
lx.skip_ws();
let g = lx.token();
if !n.is_empty() && !g.is_empty() && n.iter().all(u8::is_ascii_digit) && g.iter().all(u8::is_ascii_digit) && lx.eat_keyword(b"obj") {
let num = std::str::from_utf8(n).ok().and_then(|s| s.parse::<u32>().ok());
let generation = std::str::from_utf8(g).ok().and_then(|s| s.parse::<u16>().ok()).unwrap_or(0);
if let Some(num) = num {
entries.insert(num, XrefEntry::InFile { offset: i as u64, generation });
objstms.push(num);
}
i = lx.pos;
continue;
}
// Offsets of objects that may be cross-reference streams: their dictionaries are the
// trailers of files that have no `trailer` keyword (§7.5.8).
let mut xref_streams = Vec::new();
for (num, offset, generation, body) in object_headers(&data) {
entries.insert(num, XrefEntry::InFile { offset, generation });
objstms.push(num);
if find(&data, b"/XRef", body, body.saturating_add(1024)).is_some()
&& let Ok(at) = usize::try_from(offset)
{
xref_streams.push(at);
}
i += 1;
}
if entries.is_empty() {
return Err(CosError::Syntax { offset: 0, detail: "no objects found".into() });
@@ -1945,28 +2013,54 @@ impl Document {
&& s.dict.name(b"Type") == Some(b"ObjStm")
&& let Ok(stm) = self.objstm(num)
{
for (idx, (inner, _)) in stm.index.iter().enumerate() {
entries.entry(*inner).or_insert(XrefEntry::InStream { stream: num, index: idx as u32 });
for (idx, member) in stm.index.iter().enumerate() {
if let Some((inner, _)) = member {
entries.entry(*inner).or_insert(XrefEntry::InStream { stream: num, index: idx as u32 });
}
}
}
}
let object_count = entries.len();
self.entries = Arc::new(XrefIndex::Materialized(Arc::new(entries)));
self.cache.lock().map_err(|_| CosError::Poisoned)?.clear();
// Trailer: the last `trailer` dictionary with a usable /Root, else the catalog itself.
let mut trailer = None;
// Trailer: the last `trailer` dictionary or cross-reference stream dictionary with a
// usable /Root (last in the file, whichever kind), else the catalog itself. One whose
// /Root is a catalog (as `root_is_catalog` judges it) beats one whose /Root is another
// dictionary, such as /Info, wherever it is; an FDF's /Root (no /Type, no /Pages) still
// counts when nothing better is there.
// A file whose only trailers are cross-reference streams (and whose `startxref` is gone
// or points nowhere) keeps its /Info, /ID and /Encrypt this way.
let rank = |at: usize, d: &Dict| {
let root = self.try_get(d.reference(b"Root")?.num).ok()?;
let Object::Dict(c) = &*root else { return None };
Some((c.name(b"Type") == Some(b"Catalog") || c.contains(b"Pages"), at))
};
let mut trailer: Option<((bool, usize), Dict)> = None;
let mut from = 0;
while let Some(p) = find(&data, b"trailer", from, data.len()) {
from = p + 7;
let mut lx = Lexer::new(&data, from);
if let Ok(Object::Dict(d)) = lx.object()
&& d.reference(b"Root").is_some_and(|r| matches!(self.try_get(r.num).as_deref(), Ok(Object::Dict(_))))
&& let Some(r) = rank(p, &d)
&& trailer.as_ref().is_none_or(|(best, _)| r > *best)
{
trailer = Some(d);
trailer = Some((r, d));
}
}
for at in xref_streams {
if trailer.as_ref().is_some_and(|((catalog, p), _)| *catalog && *p > at) {
continue;
}
if let Ok((_, Object::Stream(s))) = parse_indirect_shared(&data, at, &|_| None)
&& s.dict.name(b"Type") == Some(b"XRef")
&& let Some(r) = rank(at, &s.dict)
&& trailer.as_ref().is_none_or(|(best, _)| r > *best)
{
trailer = Some((r, s.dict));
}
}
let trailer = match trailer {
Some(t) => t,
Some((_, t)) => t,
None => {
let catalog = self
.entries
@@ -1980,7 +2074,9 @@ impl Document {
}
};
let mut trailer = trailer;
for k in [&b"Prev"[..], b"XRefStm"] {
// What a cross-reference stream's dictionary carries for itself goes, as the chain
// readers drop it: a save writes its own.
for k in [&b"Prev"[..], b"XRefStm", b"Type", b"W", b"Index", b"Filter", b"DecodeParms", b"Length"] {
trailer.remove(k);
}
self.trailer = trailer;
@@ -1990,6 +2086,96 @@ impl Document {
}
}
/// For a classic table at `at` whose first subsection is `0 N` and opens with an in-use row
/// (the rows of objects 1 to N), the number N its rows really end at.
fn shifted_subsection_end(data: &[u8], at: usize) -> Option<u32> {
let mut lx = Lexer::new(data, at);
if !lx.eat_keyword(b"xref") {
return None;
}
let (Ok(Object::Int(0)), Ok(Object::Int(count))) = (lx.object(), lx.object()) else { return None };
lx.skip_ws();
let offset = std::str::from_utf8(lx.token()).ok().and_then(|s| s.parse::<u64>().ok());
lx.skip_ws();
lx.token();
lx.skip_ws();
match (offset, lx.token()) {
(Some(offset), b"n") if offset != 0 => u32::try_from(count).ok().filter(|n| *n > 0),
_ => None,
}
}
/// Every `N G obj` header in `data`, in file order, as (number, offset, generation, position
/// after `obj`).
fn object_headers(data: &[u8]) -> Vec<(u32, u64, u16, usize)> {
let mut headers = Vec::new();
let mut i = 0;
while i < data.len() {
// Object headers start at a line start (or after whitespace).
let at_start = i == 0 || is_whitespace(data[i - 1]) || data[i - 1] == b'>';
if at_start && data[i].is_ascii_digit() {
let mut lx = Lexer::new(data, i);
let n = lx.token();
lx.skip_ws();
let g = lx.token();
if !n.is_empty() && !g.is_empty() && n.iter().all(u8::is_ascii_digit) && g.iter().all(u8::is_ascii_digit) && lx.eat_keyword(b"obj") {
let num = std::str::from_utf8(n).ok().and_then(|s| s.parse::<u32>().ok());
let generation = std::str::from_utf8(g).ok().and_then(|s| s.parse::<u16>().ok()).unwrap_or(0);
if let Some(num) = num {
headers.push((num, i as u64, generation, lx.pos));
}
i = lx.pos;
continue;
}
}
i += 1;
}
headers
}
/// The offset of the section before the one whose trailer this is (`/Prev`), if any. Some writers
/// give it as a reference, `N 0 R`. When the sections read so far (`lookup`) place that object
/// and it holds an offset (iText's `indirectPrev.pdf`), that is the offset; otherwise, as the
/// tiff2pdf files in JHOVE's PDF-HUL-157 cases need, the sections before are at N. Either way the
/// reference is noted. Any other value that is not an offset is noted and ends the chain.
fn prev_offset(data: &[u8], trailer: &Dict, off: u64, lookup: impl Fn(u32) -> Option<XrefEntry>, log: &mut Vec<String>) -> Option<u64> {
match trailer.get(b"Prev") {
Some(Object::Int(p)) if *p >= 0 => Some(*p as u64),
Some(Object::Ref(r)) => {
let held = match lookup(r.num) {
Some(XrefEntry::InFile { offset, generation }) if generation == r.generation => {
usize::try_from(offset).ok().and_then(|at| parse_indirect(data, at, &|_| None).ok()).and_then(|(at, value)| match value {
Object::Int(p) if at == *r && p >= 0 => Some(p as u64),
_ => None,
})
}
_ => None,
};
match held {
Some(p) => {
log.push(format!(
"the trailer at {off} gives /Prev as the reference {} {} R; reading the offset {p} it holds",
r.num, r.generation
));
Some(p)
}
None => {
log.push(format!(
"the trailer at {off} gives /Prev as the reference {} {} R; reading it as offset {}",
r.num, r.generation, r.num
));
Some(u64::from(r.num))
}
}
}
Some(_) => {
log.push(format!("the trailer at {off} has a /Prev that is not an offset; the sections before it are unreachable"));
None
}
None => None,
}
}
pub(crate) fn find(hay: &[u8], needle: &[u8], from: usize, to: usize) -> Option<usize> {
let to = to.min(hay.len());
if from >= to || needle.is_empty() || to - from < needle.len() {
@@ -2020,9 +2206,10 @@ fn generated_id(seed: &[u8; 32]) -> Vec<u8> {
/// The object number a classic xref subsection really starts at. A common producer bug heads the
/// table `1 n` and then writes object 0's free entry (`0000000000 65535 f`) as its first row, so
/// every row would be read one number too high and object 1 as free. Such a subsection is read
/// from 0 when its second row, read that way, locates an object that is indeed numbered 1. `rows`
/// is the position just after the subsection header.
fn subsection_start(data: &[u8], rows: usize, start: i64, count: i64) -> i64 {
/// from 0 when its second row, read that way, locates an object that is indeed numbered 1, at
/// that offset or, as `load` reads offsets, relative to a header shifted by `header_offset`.
/// `rows` is the position just after the subsection header.
fn subsection_start(data: &[u8], header_offset: usize, rows: usize, start: i64, count: i64) -> i64 {
if start != 1 || count < 2 {
return start;
}
@@ -2037,12 +2224,15 @@ fn subsection_start(data: &[u8], rows: usize, start: i64, count: i64) -> i64 {
};
let (Some(0), Some(65535), true) = row() else { return start };
let (Some(second), _, false) = row() else { return start };
let Some(at) = usize::try_from(second).ok().filter(|at| *at < data.len()) else { return start };
let mut obj = Lexer::new(data, at);
match (obj.object(), obj.object()) {
(Ok(Object::Int(1)), Ok(Object::Int(_))) if obj.eat_keyword(b"obj") => 0,
_ => start,
}
let object_one = |at: usize| {
if at >= data.len() {
return false;
}
let mut obj = Lexer::new(data, at);
matches!((obj.object(), obj.object()), (Ok(Object::Int(1)), Ok(Object::Int(_)))) && obj.eat_keyword(b"obj")
};
let Ok(second) = usize::try_from(second) else { return start };
if object_one(second) || (header_offset != 0 && object_one(second.saturating_add(header_offset))) { 0 } else { start }
}
/// Object numbers must fit the representation used by references and the xref map.
@@ -2297,6 +2487,221 @@ endobj
assert!(!doc.repair_log().iter().any(|l| l.contains("numbered from 1")));
}
/// A one-page scan: the page draws image 5 with content stream 4.
fn scanned_page() -> Vec<u8> {
build(
&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 10 10] /Resources << /XObject << /Im0 5 0 R >> >> /Contents 4 0 R >>",
"<< /Length 21 >>\nstream\n10 0 0 10 0 0 cm /Im0 Do\nendstream",
"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\nA\nendstream",
],
"/Root 1 0 R",
)
}
/// `bytes` with its cross-reference table replaced by `table` (the lines after `xref`).
fn with_table(bytes: &[u8], table: &[String], size: usize) -> Vec<u8> {
let text = String::from_utf8_lossy(bytes).into_owned();
let at = text.rfind("\nxref\n").unwrap() + 1;
let mut out = bytes[..at].to_vec();
out.extend_from_slice(format!("xref\n{}\ntrailer\n<< /Size {size} /Root 1 0 R >>\nstartxref\n{at}\n%%EOF\n", table.join("\n")).as_bytes());
out
}
/// The table's entry lines, object 0 first.
fn table_entries(bytes: &[u8]) -> Vec<String> {
let text = String::from_utf8_lossy(bytes).into_owned();
let at = text.rfind("\nxref\n").unwrap() + 1;
text[at..].lines().skip(2).take_while(|l| !l.starts_with("trailer")).map(str::to_string).collect()
}
/// The classic off-by-one: the subsection says it starts at 0 but lists objects 1 to 5, so
/// the last object has no entry. Viewers rebuild the table and show the page; a reader that
/// took the missing entry for a null object dropped the image when pages were copied (#1628).
#[test]
fn an_off_by_one_xref_subsection_still_finds_the_last_object() {
let base = scanned_page();
let entries = table_entries(&base);
let mut table = vec!["0 5".to_string()];
table.extend(entries[1..6].iter().cloned());
let doc = Document::open(Arc::new(with_table(&base, &table, 5))).unwrap();
let image = doc.get(ObjRef::new(5, 0));
assert_eq!(image.as_dict().and_then(|d| d.name(b"Subtype")), Some(&b"Image"[..]), "{image:?}");
assert!(matches!(&*image, Object::Stream(_)));
assert!(matches!(&*doc.get(ObjRef::new(4, 0)), Object::Stream(s) if &s.raw[..] == b"10 0 0 10 0 0 cm /Im0 Do"));
assert!(doc.repair_log().iter().any(|l| l.contains("object headers")), "{:?}", doc.repair_log());
}
/// An "in use" entry at offset 0 says nothing about where the object is: the object is
/// found by its header, and the repair is recorded.
#[test]
fn an_object_listed_at_offset_zero_is_found_by_its_header() {
let base = scanned_page();
for num in [4usize, 5] {
let mut table = vec!["0 6".to_string()];
table.extend(table_entries(&base));
table[num + 1] = "0000000000 00000 n ".into();
let doc = Document::open(Arc::new(with_table(&base, &table, 6))).unwrap();
assert!(matches!(&*doc.get(ObjRef::new(num as u32, 0)), Object::Stream(_)), "object {num}");
assert!(doc.repair_log().iter().any(|l| l.contains("object headers")), "object {num}: {:?}", doc.repair_log());
}
}
/// A valid table that simply does not list every number below `/Size` is not repaired: an
/// unlisted number is a null object (§7.3.10), even when an old body with that number is
/// still in the file.
#[test]
fn a_consistent_table_with_unlisted_numbers_is_taken_as_it_is() {
let base = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "(three)"], "/Root 1 0 R");
let at = String::from_utf8_lossy(&base).rfind("\nxref\n").unwrap() + 1;
let mut bytes = base[..at].to_vec();
bytes.extend_from_slice(b"4 0 obj\n(stale)\nendobj\n");
let mut table = vec!["0 4".to_string()];
table.extend(table_entries(&base));
let doc = Document::open(Arc::new(with_table(&[&bytes[..], &base[at..]].concat(), &table, 6))).unwrap();
assert!(doc.repair_log().is_empty(), "{:?}", doc.repair_log());
assert_eq!(*doc.get(ObjRef::new(4, 0)), Object::Null);
assert!(matches!(&*doc.get(ObjRef::new(3, 0)), Object::String(_)));
}
/// Filling the gaps of a damaged table never brings back an object that an incremental
/// update freed: its body is still in the file, but the newer section says it is gone.
#[test]
fn an_object_freed_by_an_update_stays_freed_when_the_table_is_repaired() {
let base = scanned_page();
// The original table lists the content stream at offset 0, so the gaps get filled.
let mut table = vec!["0 6".to_string()];
table.extend(table_entries(&base));
table[5] = "0000000000 00000 n ".into();
let mut bytes = with_table(&base, &table, 6);
let prev = String::from_utf8_lossy(&bytes).rfind("\nxref\n").unwrap() + 1;
let section = bytes.len();
bytes.extend_from_slice(
format!("xref\n5 1\n0000000000 00001 f \ntrailer\n<< /Size 6 /Root 1 0 R /Prev {prev} >>\nstartxref\n{section}\n%%EOF\n").as_bytes(),
);
let doc = Document::open(Arc::new(bytes)).unwrap();
assert!(matches!(&*doc.get(ObjRef::new(4, 0)), Object::Stream(_)), "the gap is filled");
assert_eq!(*doc.get(ObjRef::new(5, 0)), Object::Null, "the freed image stays freed");
assert_eq!(doc.xref_entry(5), Some(XrefEntry::Free { next_generation: 1 }));
}
/// Repairing a damaged table fills only the numbers the damage leaves out: an old body of a
/// number no section lists, under an oversized `/Size`, stays a null object.
#[test]
fn a_repaired_table_leaves_an_unlisted_number_null() {
let base = scanned_page();
let at = String::from_utf8_lossy(&base).rfind("\nxref\n").unwrap() + 1;
let entries = table_entries(&base);
let mut offset_zero = vec!["0 6".to_string()];
offset_zero.extend(entries.iter().cloned());
offset_zero[5] = "0000000000 00000 n ".into();
let mut off_by_one = vec!["0 5".to_string()];
off_by_one.extend(entries[1..6].iter().cloned());
for (name, table, found) in [("offset 0", offset_zero, 4), ("off by one", off_by_one, 5)] {
let bytes = [&base[..at], b"7 0 obj\n(stale)\nendobj\n", &base[at..]].concat();
let doc = Document::open(Arc::new(with_table(&bytes, &table, 9))).unwrap();
assert!(matches!(&*doc.get(ObjRef::new(found, 0)), Object::Stream(_)), "{name}: the gap is filled");
assert_eq!(*doc.get(ObjRef::new(7, 0)), Object::Null, "{name}: {:?}", doc.repair_log());
assert_eq!(doc.xref_entry(7), None, "{name}");
}
}
/// The last object of a subsection numbered one too low is filled from that subsection,
/// whatever later happens to the row before it: an incremental save that rewrites object 4,
/// or object 4's own row at offset 0.
#[test]
fn the_last_object_of_a_shifted_subsection_is_filled_whatever_its_neighbour_says() {
let base = scanned_page();
let entries = table_entries(&base);
let mut off_by_one = vec!["0 5".to_string()];
off_by_one.extend(entries[1..6].iter().cloned());
let shifted = with_table(&base, &off_by_one, 5);
let prev = String::from_utf8_lossy(&shifted).rfind("\nxref\n").unwrap() + 1;
let mut updated = shifted.clone();
let body = updated.len();
updated.extend_from_slice(b"4 0 obj\n<< /Length 21 >>\nstream\n10 0 0 10 0 0 cm /Im0 Do\nendstream\nendobj\n");
let section = updated.len();
updated.extend_from_slice(
format!("xref\n4 1\n{body:010} 00000 n \ntrailer\n<< /Size 6 /Root 1 0 R /Prev {prev} >>\nstartxref\n{section}\n%%EOF\n").as_bytes(),
);
let mut neighbour_at_zero = off_by_one.clone();
neighbour_at_zero[5] = "0000000000 00000 n ".into();
for (name, bytes) in [("updated", updated), ("row 4 at offset 0", with_table(&base, &neighbour_at_zero, 5))] {
let doc = Document::open(Arc::new(bytes)).unwrap();
let image = doc.get(ObjRef::new(5, 0));
assert_eq!(image.as_dict().and_then(|d| d.name(b"Subtype")), Some(&b"Image"[..]), "{name}: {:?}", doc.repair_log());
}
}
/// The image the first page draws as `/Im1`, followed through the page tree.
fn first_page_image(doc: &Document) -> Arc<Object> {
let catalog = doc.get(doc.root().unwrap());
let pages = doc.resolve(catalog.as_dict().unwrap().get(b"Pages").unwrap());
let page = doc.resolve(&pages.as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap()[0]);
let resources = doc.resolve(page.as_dict().unwrap().get(b"Resources").unwrap());
let xobjects = doc.resolve(resources.as_dict().unwrap().get(b"XObject").unwrap());
doc.resolve(xobjects.as_dict().unwrap().get(b"Im1").unwrap())
}
/// The opposite off-by-one (#1628): the subsection says it starts at 1, but its first entry
/// is object 0's free-list head, so every entry lands one number too high and object 1, the
/// image, reads as free. The offset the table gives object 2 holds `1 0 obj`, so the
/// subsection is read from 0, and the image survives a full save.
#[test]
fn a_subsection_from_one_that_opens_with_the_free_list_head_is_read_from_zero() {
let base = build(
&[
"<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length 12 >>\nstream\nABCDEFGHIJKL\nendstream",
"<< /Length 29 >>\nstream\nq 72 0 0 72 0 0 cm /Im1 Do Q\nendstream",
"<< /Type /Page /Parent 4 0 R /MediaBox [0 0 72 72] /Resources << /XObject << /Im1 1 0 R >> >> /Contents 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Catalog /Pages 4 0 R >>",
"<< /Subject (synthetic) >>",
],
"/Root 5 0 R /Info 6 0 R",
);
// /Size 7 is one short of the shifted numbers; /Size 8 covers them all. Junk before the
// header moves every object past the offsets the table gives; it is not a comment, which
// the lexer would skip on its way to `1 0 obj` anyway.
for (size, junk) in [(7, ""), (8, ""), (7, "Content-Type: application/pdf\n\n")] {
let text = String::from_utf8_lossy(&base).replace("\nxref\n0 7\n", "\nxref\n1 7\n").replace("/Size 7 ", &format!("/Size {size} "));
let doc = Document::open(Arc::new(format!("{junk}{text}").into_bytes())).unwrap();
let image = first_page_image(&doc);
assert_eq!(image.as_dict().and_then(|d| d.name(b"Subtype")), Some(&b"Image"[..]), "/Size {size} {junk:?}: {image:?}");
assert_eq!(doc.xref_entry(0), Some(XrefEntry::Free { next_generation: 65535 }), "/Size {size} {junk:?}");
assert!(doc.repair_log().iter().any(|l| l.contains("from 0")), "/Size {size} {junk:?}: {:?}", doc.repair_log());
let saved = Document::open(Arc::new(crate::write_full(&doc, &crate::SaveOptions::default()).unwrap())).unwrap();
let image = first_page_image(&saved);
assert!(matches!(&*image, Object::Stream(s) if &s.raw[..] == b"ABCDEFGHIJKL"), "/Size {size} {junk:?}: {image:?}");
}
}
/// Object 1 really freed: the subsection from 1 opens with a free entry, but the next entry's
/// offset holds object 2, not object 1. The table is taken as it is, and the old body of
/// object 1 still in the file stays freed.
#[test]
fn a_subsection_from_one_whose_next_offset_holds_object_two_is_taken_as_it_is() {
let base = build(&["(freed)", "<< /Type /Catalog /Pages 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 2 0 R");
let entries = table_entries(&base);
let mut table = vec!["1 3".to_string(), "0000000000 65535 f ".to_string()];
table.extend(entries[2..].iter().cloned());
let text = String::from_utf8_lossy(&base).into_owned();
let at = text.rfind("\nxref\n").unwrap() + 1;
for junk in ["", "Content-Type: application/pdf\n\n"] {
let mut bytes = junk.as_bytes().to_vec();
bytes.extend_from_slice(&base[..at]);
bytes.extend_from_slice(format!("xref\n{}\ntrailer\n<< /Size 4 /Root 2 0 R >>\nstartxref\n{at}\n%%EOF\n", table.join("\n")).as_bytes());
let doc = Document::open(Arc::new(bytes)).unwrap();
assert_eq!(doc.xref_entry(1), Some(XrefEntry::Free { next_generation: 65535 }), "{junk:?}");
assert_eq!(*doc.get(ObjRef::new(1, 0)), Object::Null, "{junk:?}: the freed object stays freed");
assert!(matches!(&*doc.get(ObjRef::new(2, 0)), Object::Dict(d) if d.name(b"Type") == Some(&b"Catalog"[..])), "{junk:?}");
assert!(!doc.repair_log().iter().any(|l| l.contains("from 0")), "{junk:?}: {:?}", doc.repair_log());
assert!(!junk.is_empty() || doc.repair_log().is_empty(), "{:?}", doc.repair_log());
}
}
#[test]
fn opening_an_exhausted_classic_xref_repairs_and_allocates() {
// The extra in-use row need not be loaded to exhaust the next object number.
@@ -2574,7 +2979,7 @@ endobj
assert!(reader.document.cache.lock().unwrap().len() < ObjectReader::OBJECTS);
let decoded = reader.document.objstms.lock().unwrap();
assert!(decoded.len() < ObjectReader::OBJECTS);
let bytes: usize = decoded.values().map(|s| s.data.capacity() + s.index.capacity() * std::mem::size_of::<(u32, usize)>()).sum();
let bytes: usize = decoded.values().map(|s| s.data.capacity() + s.index.capacity() * std::mem::size_of::<Option<(u32, usize)>>()).sum();
assert!(bytes <= ObjectReader::STREAM_BYTES || decoded.len() == 1, "only one oversized stream may remain");
}
drop(reader);
@@ -2695,6 +3100,16 @@ endobj
/// says it lives in object stream 4.
#[test]
fn hybrid_reference_files_read_the_xref_stream_too() {
let doc = Document::open(Arc::new(object_stream_file("3 0 ", "<< /Hidden (yes) >>", 0))).unwrap();
assert!(doc.repair_log().is_empty(), "{:?}", doc.repair_log());
assert_compact_matches_materialized(&doc);
assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("yes"));
}
/// A hybrid-reference file whose classic table leaves object 3 out, and whose `/XRefStm`
/// stream puts it at position `member` of object stream 4, which holds `header` (the number
/// and offset pairs) and then `members`.
fn object_stream_file(header: &str, members: &str, member: u8) -> Vec<u8> {
let mut out = b"%PDF-1.5\n".to_vec();
let mut offs = [0usize; 6];
let mut obj = |out: &mut Vec<u8>, n: usize, body: &[u8]| {
@@ -2705,12 +3120,13 @@ endobj
};
obj(&mut out, 1, b"<< /Type /Catalog /Pages 2 0 R >>");
obj(&mut out, 2, b"<< /Type /Pages /Kids [] /Count 0 /Extra 3 0 R >>");
let inner = b"3 0 << /Hidden (yes) >>";
let mut stm = format!("<< /Type /ObjStm /N 1 /First 4 /Length {} >>\nstream\n", inner.len()).into_bytes();
stm.extend_from_slice(inner);
let count = header.split_whitespace().count() / 2;
let inner = format!("{header}{members}");
let mut stm = format!("<< /Type /ObjStm /N {count} /First {} /Length {} >>\nstream\n", header.len(), inner.len()).into_bytes();
stm.extend_from_slice(inner.as_bytes());
stm.extend_from_slice(b"\nendstream");
obj(&mut out, 4, &stm);
let data = [2u8, 0, 4, 0];
let data = [2u8, 0, 4, member];
let mut xs = format!("<< /Type /XRef /Size 6 /W [1 2 1] /Index [3 1] /Length {} >>\nstream\n", data.len()).into_bytes();
xs.extend_from_slice(&data);
xs.extend_from_slice(b"\nendstream");
@@ -2722,11 +3138,47 @@ endobj
out.extend_from_slice(line.as_bytes());
}
out.extend_from_slice(format!("trailer << /Size 6 /Root 1 0 R /XRefStm {} >>\nstartxref\n{table}\n%%EOF\n", offs[5]).as_bytes());
let doc = Document::open(Arc::new(out)).unwrap();
assert!(doc.repair_log().is_empty(), "{:?}", doc.repair_log());
assert_compact_matches_materialized(&doc);
let hidden = doc.get(ObjRef::new(3, 0));
assert_eq!(hidden.as_dict().and_then(|d| d.get(b"Hidden").and_then(|h| h.as_string().map(|s| s.to_text()))), Some("yes".into()));
out
}
fn text_of(doc: &Document, num: u32, key: &[u8]) -> Option<String> {
doc.get(ObjRef::new(num, 0)).as_dict().and_then(|d| d.get(key).and_then(|v| v.as_string().map(|s| s.to_text())))
}
/// An object stream member numbered 2^32 + 3 is not object 3 (#1815): the number used to be
/// cast to `u32`, which dropped the high bits.
#[test]
fn object_stream_numbers_beyond_u32_are_not_truncated() {
let control = Document::open(Arc::new(object_stream_file("3 0 ", "<< /Member (yes) >>", 0))).unwrap();
assert_eq!(text_of(&control, 3, b"Member").as_deref(), Some("yes"));
let doc = Document::open(Arc::new(object_stream_file("4294967299 0 ", "<< /Member (yes) >>", 0))).unwrap();
assert!(matches!(*doc.get(ObjRef::new(3, 0)), Object::Null), "{:?}", doc.get(ObjRef::new(3, 0)));
assert!(!doc.xref_entry_is_exact(3));
}
/// Member starts are checked rather than cast, so they cannot wrap on any target (#1815).
#[test]
fn object_stream_member_starts_are_checked() {
assert_eq!(objstm_member(3, 4, Some(10), 20), Some((3, 14)));
assert_eq!(objstm_member(3, 9, Some(10), 20), Some((3, 19)), "the last byte");
assert_eq!(objstm_member(3, 10, Some(10), 20), None, "at the end of the data");
assert_eq!(objstm_member(3, 1, Some(usize::MAX), 20), None, "/First plus the offset overflows");
assert_eq!(objstm_member(3, i64::MAX, Some(10), 20), None);
assert_eq!(objstm_member(3, -1, Some(10), 20), None);
assert_eq!(objstm_member(3, 0, None, 20), None, "/First does not fit in usize");
assert_eq!(objstm_member(i64::from(u32::MAX), 0, Some(0), 1), Some((u32::MAX, 0)));
assert_eq!(objstm_member(i64::from(u32::MAX) + 1, 0, Some(0), 1), None);
assert_eq!(objstm_member(-1, 0, Some(0), 1), None);
}
/// A member at a negative offset is unusable, not the stream's first member (#1815): the
/// offset used to be clamped to zero, which read the object at `/First`.
#[test]
fn object_stream_negative_offsets_are_refused() {
let doc = Document::open(Arc::new(object_stream_file("7 0 3 -1 ", "<< /First (yes) >>", 1))).unwrap();
assert!(matches!(*doc.get(ObjRef::new(3, 0)), Object::Null), "{:?}", doc.get(ObjRef::new(3, 0)));
let control = Document::open(Arc::new(object_stream_file("7 0 3 19 ", "<< /First (yes) >> << /Second (yes) >>", 1))).unwrap();
assert_eq!(text_of(&control, 3, b"Second").as_deref(), Some("yes"));
}
#[test]
@@ -2834,6 +3286,125 @@ endobj
}
}
/// A trailer whose /Prev is written as the reference `N 0 R` (tiff2pdf did this; the files
/// are JHOVE's PDF-HUL-157 cases, #2366) used to end the cross-reference chain silently, so
/// every object of the earlier sections read as null: a four-page file showed one page, and
/// a full save wrote the three missing kids as null. The offset is read from the reference.
#[test]
fn a_prev_written_as_a_reference_still_reaches_the_earlier_sections() {
let base = build(
&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", "<< /Type /Page /Parent 2 0 R >>"],
"/Root 1 0 R",
);
let prev = String::from_utf8_lossy(&base).rfind("xref\n0 4").unwrap();
let update = |prev_entry: &str| {
let mut bytes = base.clone();
let at = bytes.len();
bytes.extend_from_slice(b"4 0 obj\n<< /Title (Update) >>\nendobj\n");
let xref = bytes.len();
bytes.extend_from_slice(
format!(
"xref\n4 1\n{at:010} 00000 n \ntrailer\n<< /Size 5 /Root 1 0 R /Info 4 0 R /Prev {prev_entry} >>\nstartxref\n{xref}\n%%EOF\n"
)
.as_bytes(),
);
Document::open(Arc::new(bytes)).unwrap()
};
let good = update(&prev.to_string());
let bad = update(&format!("{prev} 0 R"));
assert!(good.repair_log().is_empty(), "{:?}", good.repair_log());
assert_eq!(bad.revisions().len(), 2, "{:?}", bad.repair_log());
assert_eq!(bad.repair_log().len(), 1, "a note about the /Prev: {:?}", bad.repair_log());
assert_eq!(bad.xref_entry(3), good.xref_entry(3), "the page of the first section is reached");
assert_eq!(bad.get(ObjRef::new(3, 0)).as_dict().and_then(|d| d.name(b"Type")), Some(&b"Page"[..]));
assert_eq!(
bad.get(ObjRef::new(4, 0)).as_dict().and_then(|d| d.get(b"Title").and_then(|t| t.as_string().map(|s| s.to_text()))),
Some("Update".into())
);
}
/// iText's `indirectPrev.pdf` in miniature: the update's trailer gives `/Prev 4 0 R`, and
/// object 4, listed in that update, holds the offset of the first section. Read as offset 4
/// (inside the header), the chain broke and the file was rebuilt without its revisions.
#[test]
fn a_prev_reference_to_an_object_holding_the_offset_reads_that_offset() {
let mut bytes = build(
&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", "<< /Type /Page /Parent 2 0 R >>"],
"/Root 1 0 R",
);
let prev = String::from_utf8_lossy(&bytes).rfind("xref\n0 4").unwrap();
let mut at = Vec::new();
for (num, body) in
[(2, "<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 >>".to_string()), (4, prev.to_string()), (5, "<< /Type /Page /Parent 2 0 R >>".into())]
{
at.push(bytes.len());
bytes.extend_from_slice(format!("{num} 0 obj\n{body}\nendobj\n").as_bytes());
}
let xref = bytes.len();
bytes.extend_from_slice(
format!(
"xref\n2 1\n{:010} 00000 n \n4 2\n{:010} 00000 n \n{:010} 00000 n \ntrailer\n<< /Size 6 /Root 1 0 R /Prev 4 0 R >>\nstartxref\n{xref}\n%%EOF\n",
at[0], at[1], at[2]
)
.as_bytes(),
);
let doc = Document::open(Arc::new(bytes)).unwrap();
assert_eq!(doc.revisions().len(), 2, "{:?}", doc.repair_log());
assert_eq!(doc.repair_log().len(), 1, "a note about the /Prev, and no rebuild: {:?}", doc.repair_log());
assert!(doc.repair_log()[0].contains(&format!("the offset {prev}")), "{:?}", doc.repair_log());
assert_eq!(doc.get(ObjRef::new(3, 0)).as_dict().and_then(|d| d.name(b"Type")), Some(&b"Page"[..]));
assert_compact_matches_materialized(&doc);
}
/// A file whose only trailers are cross-reference stream dictionaries, with its `startxref`
/// out of reach (here a second file appended after it, cut off before its own
/// cross-reference data, as in #2365), is rebuilt from its object headers. The rebuild used
/// to look only for the `trailer` keyword, so /Info and /ID were lost, and a full save after
/// editing one Info field dropped every other.
#[test]
fn reconstruction_takes_the_trailer_from_a_cross_reference_stream() {
let table = build(
&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Title (Kept) /Creator (Me) >>"],
"/Root 1 0 R /Info 3 0 R",
);
let packed = crate::write_full(&Document::open(Arc::new(table)).unwrap(), &crate::SaveOptions::default()).unwrap();
let text = String::from_utf8_lossy(&packed);
assert!(text.contains("/XRef") && !text.contains("trailer"), "a cross-reference stream is the only trailer");
let mut bytes = packed.clone();
let appended = format!("%PDF-1.3\n90 0 obj\n<< /Length 5000 >>\nstream\n{}\nendstream\nendobj\n", "x".repeat(5000));
bytes.extend_from_slice(appended.as_bytes());
let doc = Document::open(Arc::new(bytes)).unwrap();
assert!(doc.revisions().is_empty(), "rebuilt: {:?}", doc.repair_log());
let info = doc.trailer().reference(b"Info").expect("the cross-reference stream's /Info");
let field = |d: &Document, key: &[u8]| d.get(info).as_dict().unwrap().get(key).unwrap().as_string().unwrap().bytes.clone();
assert_eq!(field(&doc, b"Title"), b"Kept");
assert!(doc.trailer().get(b"ID").is_some(), "and its /ID");
let mut edited = doc.clone();
edited.update_dict(info, |d| d.set(b"Subject".to_vec(), Object::String(crate::PdfString::literal("new")))).unwrap();
let saved = crate::write_full(&edited, &crate::SaveOptions::default()).unwrap();
let back = Document::open(Arc::new(saved)).unwrap();
assert!(back.repair_log().is_empty(), "{:?}", back.repair_log());
let info = back.trailer().reference(b"Info").expect("the full save keeps /Info");
let field = |key: &[u8]| back.get(info).as_dict().unwrap().get(key).unwrap().as_string().unwrap().bytes.clone();
assert_eq!((field(b"Title"), field(b"Creator"), field(b"Subject")), (b"Kept".to_vec(), b"Me".to_vec(), b"new".to_vec()));
}
/// A rebuild prefers a trailer whose /Root is the catalog (as `root_is_catalog` judges it): a
/// later cross-reference stream whose /Root points at the Info dictionary does not replace an
/// earlier good trailer, or a full save would write the Info dictionary as /Root.
#[test]
fn reconstruction_skips_a_trailer_whose_root_is_not_the_catalog() {
let mut bytes =
build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Title (Kept) >>"], "/Root 1 0 R /Info 3 0 R");
bytes.extend_from_slice(b"4 0 obj\n<< /Type /XRef /Size 5 /W [1 1 1] /Root 3 0 R /Length 3 >>\nstream\nabc\nendstream\nendobj\n");
let appended = format!("%PDF-1.3\n90 0 obj\n<< /Length 5000 >>\nstream\n{}\nendstream\nendobj\n", "x".repeat(5000));
bytes.extend_from_slice(appended.as_bytes());
let doc = Document::open(Arc::new(bytes)).unwrap();
assert!(doc.repair_log().iter().any(|l| l.contains("rebuilt")), "{:?}", doc.repair_log());
assert_eq!(doc.trailer().reference(b"Root").map(|r| r.num), Some(1), "the catalog, not the Info dictionary");
assert_eq!(doc.trailer().reference(b"Info").map(|r| r.num), Some(3));
}
#[test]
fn damaged_xref_is_reconstructed() {
let mut bytes = build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>"], "/Root 1 0 R");
+34 -4
View File
@@ -49,8 +49,10 @@ fn write_real(r: f64, out: &mut Vec<u8>) {
out.push(b'0');
return;
}
if r.fract() == 0.0 && r.abs() < 1e15 {
let _ = write!(out, "{}", r as i64);
// A whole number is written as an integer only within the integer range Annex C gives
// (±2³¹−1), so `4e9` or `1e30` stays a real token for readers with 32-bit integers.
if r.fract() == 0.0 && r.abs() <= f64::from(i32::MAX) {
let _ = write!(out, "{}", r as i32);
return;
}
// Plain decimal notation (§7.3.3 has no exponents). Rust's `Display` for f64 prints the
@@ -58,6 +60,10 @@ fn write_real(r: f64, out: &mut Vec<u8>) {
// round-trip exactly (rounding to a fixed number of places visibly shifted shading colours).
let s = format!("{r}");
out.extend_from_slice(if s == "-0" { b"0" } else { s.as_bytes() });
// A whole number past that range prints without a point; the trailing `.` keeps it a real.
if !s.contains('.') && s != "-0" {
out.push(b'.');
}
}
fn write_name(n: &[u8], out: &mut Vec<u8>) {
@@ -697,14 +703,38 @@ mod tests {
}
}
#[test]
fn whole_reals_past_the_integer_limit_stay_reals() {
for (r, text) in [
(2_147_483_647.0, "2147483647"),
(-2_147_483_647.0, "-2147483647"),
(2_147_483_648.0, "2147483648."),
(4e9, "4000000000."),
(-4e9, "-4000000000."),
(1e30, "1000000000000000000000000000000."),
(595.0, "595"),
(-0.0, "0"),
] {
let mut out = Vec::new();
serialize(&Object::Real(r), &mut out);
assert_eq!(String::from_utf8_lossy(&out), text, "{r}");
let back = roundtrip(&Object::Real(r));
if r.abs() > f64::from(i32::MAX) {
assert_eq!(back, Object::Real(r), "{r} must read back as a real");
}
}
}
proptest! {
#[test]
fn any_finite_real_round_trips(r in proptest::num::f64::NORMAL | proptest::num::f64::SUBNORMAL | proptest::num::f64::ZERO) {
prop_assume!(r.abs() < 1e15 || r.fract() == 0.0);
let back = roundtrip(&Object::Real(r));
match back {
Object::Real(b) => prop_assert_eq!(b, r),
Object::Int(i) => prop_assert_eq!(i as f64, r),
Object::Int(i) => {
prop_assert!(i.unsigned_abs() <= i32::MAX as u64, "{r} came back as integer {i}");
prop_assert_eq!(i as f64, r);
}
other => prop_assert!(false, "unexpected {other:?}"),
}
}
+464
View File
@@ -0,0 +1,464 @@
//! Property tests for `pdfcraft-cos` save fidelity — proptest coverage of two areas raised in
//! #1187. **This does not close #1187.** That issue asks for five Hegel properties; this adds
//! save/reopen and edit-history coverage using `proptest`, which is already a dev-dependency of this
//! crate (`Cargo.toml:20`), so no new dependency is needed to review it. Left for follow-ups:
//! #1187's reference-following comparison, its snapshot/undo state machine, the password property (the
//! one whose pilot found #1185) and its damaged-file byte splices. The object-level serialise → parse
//! round trip it also lists already exists inline in `src/writer.rs`.
//!
//! Covered:
//!
//! 1. **A full save keeps every value it wrote into the catalog**, in both output styles (objects
//! packed into object streams, §7.5.7; the classic cross-reference table otherwise), keeps the
//! catalog's own structure intact (`/Type /Catalog`, and a `/Pages` node whose `/Kids` array and
//! `/Count` agree — the fixture's tree is empty, so this pins shape and agreement, not rendered
//! pages), preserves dictionary
//! key order (which `README.md:75` promises is byte-stable), emits spec-shaped §7.5.4 cross-reference
//! rows, and reopens with nothing to repair.
//! 2. **An incremental save is a strict append**: the original bytes come out untouched at the front
//! (§7.5.6), the file keeps two revisions whose first still points at the base cross-reference and
//! opens standalone, the edit is visible while every existing value stays put in order, and it
//! reopens with nothing to repair.
//! 3. **A write/delete sequence equals a plain map**, checked after every step and again once saved and
//! reopened. A deleted key must stay gone — not return its old value, not read as null either.
//! Deletes are generated against keys that were just written, so the delete path cannot be skipped.
//!
//! ## Scope, stated honestly
//! Generated values are pure data — no streams and no indirect references — so this says nothing about
//! stream payloads or the renumbering a full save legitimately performs. Strings are compared as bytes,
//! so literal ↔ hexadecimal re-emission is not treated as a change (§7.3.6); the *choice* of spelling
//! is therefore not asserted, and `PdfString.hex` is not pinned here. Dictionary keys at the catalog
//! level *are* compared in order, so reversing them fails; nested dictionary order inside a value is
//! sorted away by [`canon`] and is not pinned. Generated reals stay within ±1e6: `write_real`
//! (`src/writer.rs:46`) turns a non-finite real into `0` and an integral real below 1e15 into integer
//! syntax, so [`canon`] applies the writer's documented normalisation to **both** sides rather than
//! forgiving a mismatch afterwards.
use std::sync::Arc;
use proptest::prelude::*;
use proptest::test_runner::TestCaseError;
use pdfcraft_cos::{Dict, Document, Object, PdfString, SaveOptions, write_full, write_incremental};
/// The prefix every generated key carries. `Document::new_empty` puts `/Type` and `/Pages` in the
/// catalog; property 1 asserts those two structurally rather than ignoring them.
const KEY: &str = "px";
/// Generated keys go into the catalog in this deliberately unsorted sequence. Ascending insertion would make
/// "preserves dictionary key order" and "every dictionary sorted by key" the same observable, so a writer
/// that silently sorted would rewrite every saved byte and stay green. This scramble catches sorting of the
/// keys it names; it does NOT on its own pin where structural entries like `/Type` sit, because `saved`
/// filters to the `px` prefix - that gap is closed separately by [`catalog_keys`] asserting the full
/// sequence. Nested dictionary order is deliberately not pinned (see `canon`). Every permutation of the
/// generated keys is NOT drawn here either: one fixed order per case, chosen to make sorting detectable.
const INSERTION_ORDER: [usize; 8] = [5, 0, 7, 2, 6, 1, 4, 3];
/// A value reduced to a comparable spelling, with the writer's documented normalisations applied.
fn canon(o: &Object) -> String {
match o {
Object::Null => "null".into(),
Object::Bool(b) => format!("b{b}"),
Object::Int(i) => format!("i{i}"),
Object::Real(r) => {
if !r.is_finite() {
"i0".into()
} else if r.fract() == 0.0 && r.abs() < 1e15 {
format!("i{}", *r as i64)
} else {
format!("r{r}")
}
}
Object::Name(n) => format!("n{}", String::from_utf8_lossy(n)),
// Bytes, because a literal may be re-emitted as hexadecimal or the reverse; both spellings
// denote the same bytes (§7.3.6). The `hex` flag itself is deliberately not compared — see
// the scope note at the top.
Object::String(s) => format!("s{}", s.bytes.iter().map(|b| format!("{b:02x}")).collect::<String>()),
Object::Array(a) => format!("[{}]", a.iter().map(canon).collect::<Vec<_>>().join(",")),
Object::Dict(d) => {
// Sorted inside a value: nested key order is not pinned here. Catalog-level order is, and
// it is asserted in iteration order by `saved`.
let mut pairs: Vec<(String, String)> = d.iter().map(|(k, v)| (String::from_utf8_lossy(k).into_owned(), canon(v))).collect();
pairs.sort();
format!("{{{}}}", pairs.iter().map(|(k, v)| format!("{k}=>{v}")).collect::<Vec<_>>().join(","))
}
// Unreachable under the generators below, which build no streams and no references. Kept total
// so this compiles if someone widens them — at which point it should compare decoded bytes and
// resolved targets instead of a constant.
Object::Stream(_) => "stream".into(),
Object::Ref(r) => format!("ref{r}"),
}
}
fn leaf() -> impl Strategy<Value = Object> {
prop_oneof![
Just(Object::Null),
any::<bool>().prop_map(Object::Bool),
(-1_000_000i64..1_000_000i64).prop_map(Object::Int),
(-1e6f64..1e6f64).prop_map(Object::Real),
// Non-finite reals, drawn explicitly. `write_real` (`src/writer.rs:46`) replaces them with
// `0`, and [`canon`] spells both sides that way, so this is what makes the normalisation
// described in the module header actually exercised rather than merely asserted in prose.
prop::sample::select(vec![f64::NAN, f64::INFINITY, f64::NEG_INFINITY]).prop_map(Object::Real),
// Raw bytes, including ones a literal string must escape and the NUL byte.
prop::collection::vec(any::<u8>(), 0..12).prop_map(|b| Object::String(PdfString::literal(b))),
"[A-Za-z][A-Za-z0-9]{0,6}".prop_map(|s| Object::name(&s)),
]
}
/// Nested values to a bounded depth, so generation cannot recurse without limit.
fn value(depth: usize) -> BoxedStrategy<Object> {
if depth == 0 {
return leaf().boxed();
}
prop_oneof![
leaf().boxed(),
prop::collection::vec(value(depth - 1), 0..5).prop_map(Object::Array).boxed(),
prop::collection::btree_map("[a-z]{1,4}", value(depth - 1), 0..5)
.prop_map(|m| {
let mut d = Dict::new();
for (k, v) in m {
d.set(k.into_bytes(), v);
}
Object::Dict(d)
})
.boxed(),
]
.boxed()
}
fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
hay.windows(needle.len()).position(|w| w == needle)
}
/// Every catalog key, in the dictionary's own order - structural entries included. `saved` filters to
/// `px*`, which is why sorting of generated keys is visible there but `/Type` moving to the end was not;
/// this covers that. Read from memory before a save and from the parsed file afterwards, so the two sides
/// share only `Dict` iteration, not the serialiser under test.
fn catalog_keys(doc: &Document) -> Vec<String> {
let root = match doc.root() {
Some(r) => r,
None => return Vec::new(),
};
match doc.get(root).as_dict() {
Some(d) => d.iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).collect(),
None => Vec::new(),
}
}
/// The catalog's `px*` entries in **iteration order**, so a reordered save fails.
fn saved(doc: &Document) -> Result<Vec<(String, String)>, TestCaseError> {
let root = doc.root().ok_or_else(|| TestCaseError::fail("no catalog in the saved file"))?;
let cat = doc.get(root);
let d = match cat.as_dict() {
Some(d) => d,
None => return Err(TestCaseError::fail("catalog is not a dictionary")),
};
Ok(d.iter().filter(|(k, _)| k.starts_with(KEY.as_bytes())).map(|(k, v)| (String::from_utf8_lossy(k).into_owned(), canon(v))).collect())
}
/// The catalog's structure, which a reader needs whether or not it holds our test keys.
fn assert_catalog_structure(doc: &Document, style: &str) -> Result<(), TestCaseError> {
let root = doc.root().ok_or_else(|| TestCaseError::fail(format!("no catalog after the {style} save")))?;
let cat = doc.get(root);
let d = match cat.as_dict() {
Some(d) => d,
None => return Err(TestCaseError::fail(format!("catalog is not a dictionary after the {style} save"))),
};
prop_assert_eq!(d.name(b"Type"), Some(&b"Catalog"[..]), "catalog /Type changed on the {} save", style);
let pages = match d.get(b"Pages") {
Some(p) => p,
None => return Err(TestCaseError::fail(format!("catalog lost /Pages on the {style} save"))),
};
let resolved = doc.resolve(pages);
let pd = match resolved.as_dict() {
Some(d) => d,
None => return Err(TestCaseError::fail(format!("/Pages does not resolve to a dictionary on the {style} save"))),
};
prop_assert_eq!(pd.name(b"Type"), Some(&b"Pages"[..]), "/Pages /Type changed on the {} save", style);
let kids = match pd.get(b"Kids").and_then(Object::as_array) {
Some(k) => k,
None => return Err(TestCaseError::fail(format!("/Pages /Kids is missing or not an array on the {style} save"))),
};
// /Count has to agree with /Kids. A page tree whose count lies still opens, then misnumbers every
// page downstream, so checking presence alone would not be enough. This fixture's tree is empty, so
// what is pinned here is shape and agreement — not the existence of rendered pages.
prop_assert_eq!(pd.int(b"Count"), Some(kids.len() as i64), "/Pages /Count disagrees with its {} kids on the {} save", kids.len(), style);
Ok(())
}
/// §7.5.4: every entry of a classic cross-reference table is exactly 20 bytes, closed by a two-byte
/// end of line. Unpadded offsets still open in lenient readers and corrupt strict ones, so the byte
/// shape is asserted rather than inferred from a successful reopen.
fn assert_classic_xref_rows(bytes: &[u8]) -> Result<(), TestCaseError> {
// Anchored on the file's own startxref (§7.5.3) rather than the first "xref" text in the buffer:
// a generated document name could otherwise point the scan at prose. A startxref that does not land
// on the `xref` keyword means a cross-reference stream, which has no table to check here.
let Some(xoff) = startxref(bytes) else { return Err(TestCaseError::fail("no readable startxref in a classic-table save")) };
let x = xoff as usize;
if bytes.get(x..x + 4) != Some(&b"xref"[..]) {
return Ok(());
}
let nl = match bytes[x..].iter().position(|b| *b == b'\n') {
Some(n) => n,
None => return Err(TestCaseError::fail("xref keyword not followed by a newline")),
};
let rest = &bytes[x + nl + 1..];
let end = match find(rest, b"trailer") {
Some(e) => e,
None => return Err(TestCaseError::fail("cross-reference table with no trailer")),
};
let mut rows = 0usize;
let mut expected_rows = 0usize;
for line in rest[..end].split(|b| *b == b'\n') {
if line.is_empty() {
continue;
}
// A CR before the line feed belongs to the two-byte terminator (§7.5.4), not to the keyword:
// stripped here so a CR+LF row is still recognised as a legal row rather than reported (wrongly)
// as "no cross-reference rows found". Producers do emit CR+LF terminators.
//
// What catches what: classification keys off the keyword, so a row whose keyword is garbage never
// reaches the width and length asserts below. Those rows are named by the `else` arm at the end
// of this loop; the row-count compare after it is only a backstop. The length assert is not the
// safety net for malformed keywords, and this comment used to imply that it was.
let fields: Vec<&[u8]> = line
.split(|b| *b == b' ')
.filter(|f| !f.is_empty())
.map(|f| match f.last() {
Some(b'\r') => &f[..f.len() - 1],
_ => f,
})
.collect();
if fields.len() == 2 && fields.iter().all(|f| f.iter().all(u8::is_ascii_digit)) {
// `<first> <count>` subsection header.
expected_rows += std::str::from_utf8(fields[1]).ok().and_then(|s| s.parse::<usize>().ok()).unwrap_or(0);
continue;
}
if fields.len() == 3 && matches!(fields[2], b"n" | b"f") {
// Classified by keyword, not by width: an unpadded row must still be recognised as a row, so
// the failure names the real crime (wrong width) instead of the misleading "no rows found".
// Both keywords are legal — `n` in use, `f` free (the head of the free chain carries
// generation 65535). §7.5.4 fixes each field's width and the whole entry at 20 bytes.
prop_assert_eq!(
fields[0].len(),
10,
"a cross-reference offset must be 10 digits (§7.5.4), got {:?} in row {:?}",
String::from_utf8_lossy(fields[0]),
String::from_utf8_lossy(line)
);
prop_assert_eq!(
fields[1].len(),
5,
"a cross-reference generation must be 5 digits (§7.5.4), got {:?} in row {:?}",
String::from_utf8_lossy(fields[1]),
String::from_utf8_lossy(line)
);
// Exactly 20 bytes per entry in the file (§7.5.4). Both legal terminators land on 19 bytes
// for the line as split here: SP+LF, which is what this writer emits today, and CR+LF, whose
// terminator is the pair itself. A 21-byte entry (SP+CRLF, or a doubled CR) fails here.
prop_assert_eq!(
line.len(),
19,
"a cross-reference row is {} bytes; §7.5.4 wants exactly 20 in the file (SP+LF or CR+LF): {:?}",
line.len(),
String::from_utf8_lossy(line)
);
rows += 1;
} else {
// Inside the table region, a line is either a subsection header or an entry. Anything else is
// named here with its bytes quoted, instead of surfacing later as a bare row-count mismatch
// ("no cross-reference rows found") that blames the table for one bad line.
prop_assert_eq!(
fields.len(),
3,
"unrecognised line in the cross-reference table, want `<offset> <generation> n|f`: {:?}",
String::from_utf8_lossy(line)
);
prop_assert!(
matches!(fields[2], b"n" | b"f"),
"illegal cross-reference keyword {:?} in row {:?}",
String::from_utf8_lossy(fields[2]),
String::from_utf8_lossy(line)
);
}
}
prop_assert!(rows > 0, "no cross-reference rows found in a classic-table save");
prop_assert_eq!(rows, expected_rows, "the subsection headers promised {} rows, the table holds {}", expected_rows, rows);
Ok(())
}
/// The `startxref` value of a file, which the next incremental update must point back at via `/Prev`.
/// The keyword is followed by whitespace, then the offset (§7.5.3), so leading whitespace is skipped.
fn startxref(bytes: &[u8]) -> Option<u64> {
let at = find(bytes, b"startxref")?;
let tail = &bytes[at + 9..];
let digits: String = tail.iter().skip_while(|b| b.is_ascii_whitespace()).take_while(|b| b.is_ascii_digit()).map(|b| char::from(*b)).collect();
digits.parse().ok()
}
proptest! {
// 256 cases per property; the suite runs in well under a second, so coverage is free here.
#![proptest_config(ProptestConfig::with_cases(256))]
/// 1. A full save keeps the values it wrote, their order, the catalog's structure and the shape of
/// its cross-reference table — in both output styles — and reopens with nothing to repair.
#[test]
fn full_save_keeps_the_catalog_intact(map in prop::collection::btree_map(0usize..8, value(3), 1..6)) {
let mut doc = Document::new_empty();
let root = doc.root().expect("an empty document has a catalog");
doc.update_dict(root, |d| {
for i in INSERTION_ORDER.into_iter().filter(|&i| map.contains_key(&i)) {
d.set(format!("{KEY}{i}").into_bytes(), map[&i].clone());
}
})
.map_err(|e| TestCaseError::fail(format!("cannot write the generated catalog: {e}")))?;
// Insertion order is this scrambled sequence filtered to the drawn keys: what must come back.
let expected: Vec<(String, String)> = INSERTION_ORDER.into_iter().filter(|&i| map.contains_key(&i)).map(|i| (format!("{KEY}{i}"), canon(&map[&i]))).collect();
// Full key sequence taken from memory BEFORE any byte is written - /Type and /Pages included.
let order_before = catalog_keys(&doc);
// Right before any save, or the later assertion would prove nothing about saving.
prop_assert_eq!(saved(&doc)?, expected.clone(), "the in-memory catalog is wrong before saving");
for object_streams in [true, false] {
let style: &str = if object_streams { "object-stream" } else { "classic-table" };
let opts = SaveOptions { object_streams, ..SaveOptions::default() };
let bytes = write_full(&doc, &opts).map_err(|e| TestCaseError::fail(format!("full save failed: {e}")))?;
if !object_streams {
assert_classic_xref_rows(&bytes).map_err(|e| TestCaseError::fail(format!("{style}: {e}")))?;
}
let again = Document::open(Arc::new(bytes)).map_err(|e| TestCaseError::fail(format!("our own full save must reopen: {e}")))?;
prop_assert!(again.repair_log().is_empty(), "a file we wrote must not need repair ({}); log: {:?}", style, again.repair_log());
// Values *and* order: README.md promises dictionaries keep their key order.
prop_assert_eq!(saved(&again)?, expected.clone(), "the catalog changed on a {} save (values or order)", style);
// Structural position too: /Type and /Pages must not drift to the end or swap places.
prop_assert_eq!(catalog_keys(&again), order_before.clone(), "the catalog's full key sequence changed on a {} save", style);
assert_catalog_structure(&again, style)?;
}
}
/// 2. An incremental save leaves the original bytes untouched, chains back through `/Prev`, and
/// reopens carrying every intended value plus the edit. The base is built **both** ways -
/// cross-reference stream and classic table - because the two are serialised by different code, and a
/// path that never runs cannot fail here. Expected contents are computed from the values we put in,
/// not read back out of the writer: comparing against our own earlier output would pass a writer that
/// is consistently wrong, which is the one thing a fidelity test must not do.
#[test]
fn incremental_save_appends_without_touching_the_original(map in prop::collection::btree_map(0usize..8, value(2), 1..5), marker in -1_000_000i64..1_000_000i64) {
let mut doc = Document::new_empty();
let root = doc.root().expect("an empty document has a catalog");
doc.update_dict(root, |d| {
for i in INSERTION_ORDER.into_iter().filter(|&i| map.contains_key(&i)) {
d.set(format!("{KEY}{i}").into_bytes(), map[&i].clone());
}
})
.map_err(|e| TestCaseError::fail(format!("cannot write the generated catalog: {e}")))?;
// Our own record of what the catalog should hold, derived from the inputs before any byte is
// written. The edit key joins it below; order follows INSERTION_ORDER, not sorted order (§7.5.4).
let intended: Vec<(String, String)> = INSERTION_ORDER.into_iter().filter(|&i| map.contains_key(&i)).map(|i| (format!("{KEY}{i}"), canon(&map[&i]))).collect();
for object_streams in [true, false] {
let style: &str = if object_streams { "object-stream" } else { "classic-table" };
let opts = SaveOptions { object_streams, ..SaveOptions::default() };
let base = write_full(&doc, &opts).map_err(|e| TestCaseError::fail(format!("{}: full save failed: {e}", style)))?;
let base_xref = startxref(&base).ok_or_else(|| TestCaseError::fail(format!("{}: the base file has no readable startxref", style)))?;
let mut again = Document::open(Arc::new(base.clone())).map_err(|e| TestCaseError::fail(format!("{}: the saved file must reopen: {e}", style)))?;
// Independent of the writer's own readings: the reopen must show what we intended to write.
prop_assert_eq!(saved(&again)?, intended.clone(), "{}: the base catalog differs from what we put in", style);
let root2 = again.root().expect("the reopened document has a catalog");
again.update_dict(root2, |d| d.set(b"pxMark".to_vec(), Object::Int(marker))).map_err(|e| TestCaseError::fail(format!("{}: cannot apply the incremental edit: {e}", style)))?;
let bytes = write_incremental(&again, &opts).map_err(|e| TestCaseError::fail(format!("{}: incremental save failed: {e}", style)))?;
prop_assert!(bytes.len() > base.len(), "{}: an incremental save must add bytes ({} over a base of {})", style, bytes.len(), base.len());
prop_assert_eq!(bytes.get(..base.len()), Some(&base[..]), "{}: an incremental save must not rewrite the original bytes", style);
let out = Document::open(Arc::new(bytes.clone())).map_err(|e| TestCaseError::fail(format!("{}: the incremental file must reopen: {e}", style)))?;
prop_assert!(out.repair_log().is_empty(), "{}: an increment we wrote must not need repair; log: {:?}", style, out.repair_log());
// The revision chain in the crate's own terms (Revision { xref_offset, is_stream }): two
// revisions, the first still pointing at the base cross-reference. Without that link no other
// reader can reach the earlier revision's objects, which is the classic incremental corruption.
// /Prev sits in the trailer for a table base and inside the cross-reference stream for a
// stream base, so it is not read here; `revisions` covers both (§7.5.3-7.5.6).
let revs = out.revisions();
prop_assert_eq!(revs.len(), 2, "{}: an incremental save must leave two revisions: {:?}", style, revs);
prop_assert_eq!(revs[0].xref_offset, base_xref, "{}: the first revision must still point at the base xref {}", style, base_xref);
prop_assert!(revs[1].xref_offset > base_xref, "{}: the second revision must sit after the first: {:?}", style, revs);
let ends = out.revision_ends();
prop_assert_eq!(ends.last().copied(), Some(bytes.len()), "{}: the last revision must run to the end of the file: {:?}", style, ends);
// The base revision opens on its own. "Nothing was rewritten" buys the user this: cut the file
// at the append point and the earlier document still reads, with our intended values.
if let Some(end0) = ends.first().copied() {
let before_doc = Document::open(Arc::new(bytes[..end0].to_vec())).map_err(|e| TestCaseError::fail(format!("{}: the base revision must open alone: {e}", style)))?;
prop_assert_eq!(saved(&before_doc)?, intended.clone(), "{}: the base revision lost or changed values when opened without the increment", style);
}
assert_catalog_structure(&out, style)?;
let mut expected = intended.clone();
expected.push(("pxMark".into(), canon(&Object::Int(marker))));
prop_assert_eq!(saved(&out)?, expected.clone(), "{}: the increment lost a value, reordered the catalog, or dropped the edit", style);
}
}
/// 3. Writes then deletes against a plain ordered model, checked after every step and again after
/// save and reopen. Deletes always target a key that exists, so the delete path is never empty.
#[test]
fn edit_sequence_equals_a_plain_map(plan in prop::collection::btree_map(0usize..8, value(2), 1..6).prop_flat_map(|sets| {
let keys: Vec<usize> = sets.keys().copied().collect();
(Just(sets), prop::collection::vec(0usize..keys.len(), 1..=keys.len())).prop_map(move |(s, picks)| {
let mut dels: Vec<usize> = picks.into_iter().map(|i| keys[i]).collect();
// Deduplicated: the same key drawn twice would make the second delete target a key that is
// already gone, which is legal PDF but not what this property means to assert. `picks` draws
// 1..=keys.len() over a non-empty key set, so `dels` cannot come back empty — the guarantee
// lives in the generator, not in an assume that could never fire.
dels.sort_unstable();
dels.dedup();
(s, dels)
})
})) {
let (sets, deletes) = plan;
let mut doc = Document::new_empty();
let root = doc.root().expect("an empty document has a catalog");
let mut model: Vec<(String, String)> = Vec::new();
for (i, o) in &sets {
let key = format!("{KEY}{i}");
doc.update_dict(root, |d| d.set(key.clone().into_bytes(), o.clone())).map_err(|e| TestCaseError::fail(format!("cannot write {key}: {e}")))?;
model.push((key.clone(), canon(o)));
prop_assert_eq!(saved(&doc)?, model.clone(), "after writing {} the catalog is wrong before saving", key);
}
for i in &deletes {
let key = format!("{KEY}{i}");
prop_assert!(model.iter().any(|(k, _)| k == &key), "the generator tried to delete a key that was never written: {}", key);
doc.update_dict(root, |d| {
d.remove(key.as_bytes());
})
.map_err(|e| TestCaseError::fail(format!("cannot delete {key}: {e}")))?;
model.retain(|(k, _)| k != &key);
prop_assert_eq!(saved(&doc)?, model.clone(), "after deleting {} it is still reachable", key);
}
let bytes = write_full(&doc, &SaveOptions::default()).map_err(|e| TestCaseError::fail(format!("full save failed: {e}")))?;
let again = Document::open(Arc::new(bytes)).map_err(|e| TestCaseError::fail(format!("our own save must reopen: {e}")))?;
let got = saved(&again)?;
prop_assert_eq!(got.clone(), model.clone(), "the reopened catalog differs from the edit history (values or order)");
for i in 0..8usize {
let key = format!("{KEY}{i}");
if !model.iter().any(|(k, _)| k == &key) {
prop_assert!(!got.iter().any(|(g, _)| *g == key), "a deleted key came back after save+reopen: {}", key);
}
}
}
}
+326
View File
@@ -0,0 +1,326 @@
//! Encryption numbers from an **outside implementation**. Six of the seven rows below are qpdf's own,
//! written by qpdf core 12.2.0 through pikepdf 9.11, and their `/O`, `/U`, `/OE`, `/UE`, `/Perms`
//! and `/ID` values were copied out of those files rather than computed here. The seventh row,
//! `crafted-r3-40bit-hand-derived`, is ours and says so where it is defined - no producer we can run
//! here writes revision 3 at 40 bits (qpdf core 12.2.0 emits `/Length 128` at `R=3`, measured below),
//! so nothing outside computed that one either.
//!
//! That distinction is the whole point. `create()` and `open()` share one derivation, so a wrong-but
//! self-consistent implementation round-trips cleanly against itself: across two review rounds we
//! broke the key derivation several ways and the unit suite stayed green (deleting Algorithm 2 step f
//! leaves all 17 unit tests passing while our rows fail). Quoting an outsider's bytes
//! breaks the circle - `open()` recomputes `/U` (or the revision 6 hash chain) and compares it to what
//! was stored, so a derivation that diverges cannot authenticate at all.
//!
//! Revisions covered: 2 (export-grade RC4, where the five-byte key makes a truncation error visible that
//! every pre-existing R3/R4 vector in this repository hides as a no-op), 3, and 4 and 6 each with metadata
//! encrypted *and* plain. For revision 4 that pairing is the only outside view of Algorithm 2 step
//! f's four `FF` bytes; revision 6 shows the same flag differently - its `/U` differs between the two
//! metadata variants, which is measured here rather than attributed to a named algorithm step. Only the
//! numbers are committed - never the PDFs, and no generator script that lives outside
//! this repository. To re-derive the six qpdf rows, take one blank 200x200 page and save it with
//! pikepdf 9.11 (qpdf core 12.2.0) using `Encryption(owner=OWNER, user=USER,`
//! `allow=Permissions(extract=False), R=<revision>, aes=…, metadata=…)` plus `save(..., static_id=True)`,
//! then read `/Encrypt` back. Below revision 4 both flags are forced rather than chosen, because pikepdf
//! refuses AES and refuses encrypted metadata there - so those two rows need `aes=False, metadata=False`,
//! while revisions 4 and 6 take `aes=True` with the case's own metadata flag. Run to run, every quoted
//! field of those rows reproduces at revisions 2-4, and whole files too for revisions 2 and 3; a
//! revision 4 file falls short of that by one stream, because `/CFM /AESV2` prepends a fresh random IV.
//! Revision 6 reproduces structurally only, its salts being new each run. What stays checked rather than
//! trusted - `open()` recovers the user password from `/O` and verifies `/U` at revisions 2-4
//! (Algorithm 7), matches both stored hashes at revision 6 and unwraps `/OE`/`/UE` into the file key
//! there; this file then decrypts `/Perms` with that key, which authentication itself never reads. One
//! thing no assertion here verifies is the crypt-filter names: renaming a row's `/StmF` or replacing its
//! filter set leaves all three tests green, so those are held to review rather than to a test. For how
//! this repository treats qpdf as an oracle, see `crates/oracle/README.md`.
use pdfcraft_crypt::{Auth, EncryptDict, Method, SecurityHandler, aes_cbc_decrypt};
const USER: &str = "open pw";
const OWNER: &str = "owner pw";
/// One file qpdf wrote. Bytes are held as hex so the table stays a `const`: the literals on screen are
/// then exactly what qpdf emitted, with no runtime step in between to trust.
struct Case {
label: &'static str,
v: i64,
r: i64,
length_bits: i64,
p: i32,
encrypt_metadata: bool,
id0_hex: &'static str,
o_hex: &'static str,
u_hex: &'static str,
oe_hex: &'static str,
ue_hex: &'static str,
perms_hex: &'static str,
crypt_filters: fn() -> Vec<(Vec<u8>, Method)>,
stm_f: &'static str,
str_f: &'static str,
key_len_bytes: usize,
}
impl Case {
fn dict(&self) -> EncryptDict {
EncryptDict {
filter: b"Standard".to_vec(),
v: self.v,
r: self.r,
length_bits: self.length_bits,
o: hex(self.o_hex),
u: hex(self.u_hex),
oe: hex(self.oe_hex),
ue: hex(self.ue_hex),
perms: hex(self.perms_hex),
p: self.p,
encrypt_metadata: self.encrypt_metadata,
crypt_filters: (self.crypt_filters)(),
stm_f: self.stm_f.as_bytes().to_vec(),
str_f: self.str_f.as_bytes().to_vec(),
ef_f: Vec::new(),
}
}
fn id0(&self) -> Vec<u8> {
hex(self.id0_hex)
}
}
fn hex(s: &str) -> Vec<u8> {
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect()
}
fn no_filters() -> Vec<(Vec<u8>, Method)> {
Vec::new()
}
fn aes128_filter() -> Vec<(Vec<u8>, Method)> {
vec![(b"StdCF".to_vec(), Method::Aes128)]
}
fn aes256_filter() -> Vec<(Vec<u8>, Method)> {
vec![(b"StdCF".to_vec(), Method::Aes256)]
}
/// `/ID[0]` is shared by every case because qpdf wrote them with `static_id`.
const CASES: &[Case] = &[
Case {
label: "r2-40bit",
v: 1,
r: 2,
length_bits: 40,
p: -28,
encrypt_metadata: true,
id0_hex: "31415926535897932384626433832795",
o_hex: "166a894580aae60ae93fa9d83fc576f26f13dbe4574ce6c57497bec3ece7f125",
u_hex: "69ff2fb98261fd094174f93f0388859f356080ca486a38ce2d5ac23b98f5f89c",
oe_hex: "",
ue_hex: "",
perms_hex: "",
crypt_filters: no_filters,
stm_f: "",
str_f: "",
key_len_bytes: 5,
},
Case {
label: "r3-128rc4",
v: 2,
r: 3,
length_bits: 128,
p: -1044,
encrypt_metadata: true,
id0_hex: "31415926535897932384626433832795",
o_hex: "2910182a30a271d410d8dada035cbd77c0b57a683bd55cd1cd9eee35a2b64848",
u_hex: "1b4b8fb3fd213b4eadc142ab9f385fc80122456a91bae5134273a6db134c87c4",
oe_hex: "",
ue_hex: "",
perms_hex: "",
crypt_filters: no_filters,
stm_f: "",
str_f: "",
key_len_bytes: 16,
},
Case {
label: "r4-aes128-meta-enc",
v: 4,
r: 4,
length_bits: 128,
p: -1044,
encrypt_metadata: true,
id0_hex: "31415926535897932384626433832795",
o_hex: "2910182a30a271d410d8dada035cbd77c0b57a683bd55cd1cd9eee35a2b64848",
u_hex: "1b4b8fb3fd213b4eadc142ab9f385fc80122456a91bae5134273a6db134c87c4",
oe_hex: "",
ue_hex: "",
perms_hex: "",
crypt_filters: aes128_filter,
stm_f: "StdCF",
str_f: "StdCF",
key_len_bytes: 16,
},
Case {
label: "r4-aes128-meta-plain",
v: 4,
r: 4,
length_bits: 128,
p: -1044,
encrypt_metadata: false,
id0_hex: "31415926535897932384626433832795",
o_hex: "2910182a30a271d410d8dada035cbd77c0b57a683bd55cd1cd9eee35a2b64848",
u_hex: "d6d363fc817815701f0242a581cb39e60122456a91bae5134273a6db134c87c4",
oe_hex: "",
ue_hex: "",
perms_hex: "",
crypt_filters: aes128_filter,
stm_f: "StdCF",
str_f: "StdCF",
key_len_bytes: 16,
},
Case {
label: "r6-aes256-meta-enc",
v: 5,
r: 6,
length_bits: 256,
p: -1044,
encrypt_metadata: true,
id0_hex: "31415926535897932384626433832795",
o_hex: "1c791d66227fdfd7c1d225eb81fb3408d8bcfac1a0ff71cc57b10eb29029e5f6965cccb6b94e461d9da354a9dbd6eea7",
u_hex: "64ffbf79168ed7b325267cdba8dae211d4c2c5ebdffd339c7ac36c33e26f23b7f478c60ae42d50b5d20ceac56d659151",
oe_hex: "d2f6ef0bcc6ce93228be760c7eee8f4259af7a6044a4c4e04a0d48ab0893ef1e",
ue_hex: "963035aa24fb009e6d26b3cffd31492ffe6f4e563e4dcae8d7b7e3203c4a2f56",
perms_hex: "5675a86560873d2532e856a37045247e",
crypt_filters: aes256_filter,
stm_f: "StdCF",
str_f: "StdCF",
key_len_bytes: 32,
},
Case {
label: "r6-aes256-meta-plain",
v: 5,
r: 6,
length_bits: 256,
p: -1044,
encrypt_metadata: false,
id0_hex: "31415926535897932384626433832795",
o_hex: "e227e586c4dc5a362c7271d960565cf3a37145e33af5bf5a08c73b52a1d819e152c737176dc08774e400798d6534e95e",
u_hex: "dbb84b847bb2d80865cc3c5a7c751d4c659d2c96ed1750f8be6f9c3b93fe3d11f494b43e5f17a21c85f5a4b171055afb",
oe_hex: "8c31f2392bc2a093850269a3404cbe01cea38e5e0fe308fe8ac50f5222cff103",
ue_hex: "2b0c44e4bddb3c9c01431b4ed3c7da42dbd1ef47e610d626ec2124535a0a4469",
perms_hex: "adbb69fb9fffde31ba90aaaf5eb4bdaf",
crypt_filters: aes256_filter,
stm_f: "StdCF",
str_f: "StdCF",
key_len_bytes: 32,
},
// HAND-DERIVED, not produced by qpdf: an independent re-implementation of ISO Algorithms 2/3
// (n = 5) computed this pair, cross-validated by having the same routine reproduce qpdf's
// n = 16 `/O` byte for byte. It exists because no producer we can run here emits R3 at 40 bits -
// qpdf core 12.2.0 writes `/Length 128` there, and this crate's own create() maps Rc4_40 to revision 2 - so
// the only route we have to `key_len` = 5 with the derivation loop RUNNING (r >= 3) is to craft
// it by hand. With that combination live, the loop's truncation stops being a no-op: mutating
// `Md5::digest(&digest[..n])` to hash the whole digest makes this password stop authenticating.
Case {
label: "crafted-r3-40bit-hand-derived",
v: 2,
r: 3,
length_bits: 40,
p: -1044,
encrypt_metadata: true,
id0_hex: "31415926535897932384626433832795",
o_hex: "50e50251fd20acb28ba50013142d19a47249d06b977d28b63cb3ce9d56fb5d50",
u_hex: "000756f24fe648256830aec6155ca6f100000000000000000000000000000000",
oe_hex: "",
ue_hex: "",
perms_hex: "",
crypt_filters: no_filters,
stm_f: "",
str_f: "",
key_len_bytes: 5,
},
];
#[test]
fn outside_derived_dictionaries_authenticate_with_the_intended_role() {
for case in CASES {
let d = case.dict();
let id0 = case.id0();
// The user password must open as a user. Owners are tried first, so an `Owner` here would mean
// the two passwords had collapsed onto one set of bytes - on these spellings they have not.
let user = SecurityHandler::open(d.clone(), &id0, Some(USER))
.unwrap_or_else(|e| panic!("{}: qpdf's user password was refused by our reader: {e}", case.label));
assert_eq!(user.auth(), Auth::User, "{}", case.label);
// The owner password must open as owner: revisions 2-4 recover the user password from `/O`
// (Algorithm 7), revision 6 hashes against qpdf's own `/O`.
let owner =
SecurityHandler::open(d.clone(), &id0, Some(OWNER)).unwrap_or_else(|e| panic!("{}: qpdf's owner password was refused: {e}", case.label));
assert_eq!(owner.auth(), Auth::Owner, "{}", case.label);
// A wrong password may never open it; and the key must be exactly as wide as the revision
// promises - those five bytes are what makes a revision 2 truncation detectable at all.
assert!(SecurityHandler::open(d.clone(), &id0, Some("not the password")).is_err(), "{}: a wrong password opened", case.label);
assert_eq!(user.file_key().len(), case.key_len_bytes, "{}: file key width", case.label);
assert_eq!(owner.file_key(), user.file_key(), "{}: whichever password opens it, one key", case.label);
// A user session must not claim owner rights. We deliberately do NOT assert individual
// permission flags here: which bit means "extract" moves between revision 2 and revisions 4
// (qpdf wrote `/P = -28` for the export-grade file, where our accessor's bit-10 reading of
// "extract for accessibility" is a reserved field), and pinning that mapping belongs with the
// permissions tests, not with this oracle. What matters here is only that authentication, role
// and key width agree with what an outside implementation produced.
assert!(!user.permissions().owner, "{}: a user session claimed owner rights", case.label);
}
}
#[test]
fn metadata_flag_from_outside_changes_the_file_key_it_certifies() {
// `/EncryptMetadata` false folds four set bytes into a revision 4 key (Algorithm 2, step f) and
// changes what revision 6 stores in `/U`. qpdf wrote both variants of each, so an implementation
// that ignored the flag would give identical keys. Internally this is no longer untested -
// `tests/proptests.rs` from #2358 varies the flag and asserts the keys differ - but internal tests
// still share our derivation, so what these rows add is a second implementation's own numbers.
for (enc_label, plain_label) in [("r4-aes128-meta-enc", "r4-aes128-meta-plain"), ("r6-aes256-meta-enc", "r6-aes256-meta-plain")] {
let a = CASES.iter().find(|c| c.label == enc_label).expect("encrypted-metadata fixture present");
let b = CASES.iter().find(|c| c.label == plain_label).expect("plain-metadata fixture present");
let ha = SecurityHandler::open(a.dict(), &a.id0(), Some(USER)).unwrap_or_else(|e| panic!("{enc_label}: refused by our reader: {e}"));
let hb = SecurityHandler::open(b.dict(), &b.id0(), Some(USER))
.unwrap_or_else(|e| panic!("{plain_label}: refused by our reader - is step f being ignored? {e}"));
assert_ne!(ha.file_key(), hb.file_key(), "{enc_label} and {plain_label} must not share a key: the metadata flag is hashed in");
assert!(ha.encrypts_metadata(), "{enc_label} reports encrypted metadata");
assert!(!hb.encrypts_metadata(), "{plain_label} reports plain metadata, as qpdf wrote it");
}
// The outsider's numbers must differ across revisions; equality here would mean a transcription
// error, which would quietly void every assertion above.
let r2 = CASES.iter().find(|c| c.r == 2).unwrap();
let r3 = CASES.iter().find(|c| c.r == 3).unwrap();
assert_ne!(hex(r2.u_hex), hex(r3.u_hex), "revision 2 and revision 3 /U differ because the key width differs");
assert_eq!(r2.u_hex.len(), 64, "revision 2 /U is RC4 over the padding: 32 bytes");
assert_eq!(r3.u_hex.len(), 64, "revision 3 /U is 32 bytes: sixteen derived, plus padding qpdf fills (our crafted row leaves it zero)");
}
#[test]
fn qpdf_perms_decode_to_the_shape_both_implementations_agree_on() {
// `/Perms` is the one value in this dictionary that no test here verified before: `cos` writes it
// out and reads it back, but nothing decrypts it. Under qpdf's own bytes we can - `/P`
// little-endian, four set bytes, `T`/`F`, the `adb` sentinel (Algorithm 10 here; vendored lopdf
// checks the same fields as its Algorithm 13, though only three bytes of `/P`).
let mut checked = 0;
for case in CASES.iter().filter(|c| !c.perms_hex.is_empty()) {
let h = SecurityHandler::open(case.dict(), &case.id0(), Some(USER))
.unwrap_or_else(|e| panic!("{}: refused before /Perms could be read: {e}", case.label));
let open = aes_cbc_decrypt(h.file_key(), &[0u8; 16], &hex(case.perms_hex), false);
assert_eq!(open.len(), 16, "{}: /Perms is one encrypted block", case.label);
assert_eq!(&open[..4], &case.p.to_le_bytes(), "{}: /P must round trip against qpdf's bytes", case.label);
assert_eq!(&open[4..8], &[0xFF; 4], "{}: the four bytes after /P are set", case.label);
assert_eq!(open[8], if case.encrypt_metadata { b'T' } else { b'F' }, "{}: metadata byte as qpdf wrote it", case.label);
assert_eq!(&open[9..12], b"adb", "{}: sentinel from qpdf's bytes", case.label);
assert_ne!(&open[12..16], &[0u8; 4], "{}: qpdf's filler is not frozen at zero", case.label);
checked += 1;
}
assert!(checked >= 2, "expected /Perms on both revision 6 cases; saw {checked}");
}
+201
View File
@@ -0,0 +1,201 @@
//! A page's background colour: the colour the page is painted with before anything else, as
//! Edit a PDF ▸ Background and many producers do it, for exports that keep a page colour (#2992).
use pdfcraft_content::{Matrix, Op, Pieces};
use pdfcraft_cos::{Dict, Document};
use crate::{EditError, Rgb};
/// How deep `q` may nest while looking for the background (deeper saves are not tracked).
const MAX_SAVES: usize = 64;
/// How many clipping rectangles are kept; past that the clip counts as unknown.
const MAX_CLIPS: usize = 64;
/// A rectangle as it lands on the page: its four corners in page space, in order. Under a
/// rotating or shearing CTM it is a parallelogram, so its bounding box says too much (#2994).
type Quad = [(f64, f64); 4];
/// The colour space a fill colour is given in.
#[derive(Clone, Copy, PartialEq)]
enum Space {
Gray,
Rgb,
Cmyk,
/// Anything else (Separation, ICC, patterns…): no plain colour can be read from it.
Other,
}
#[derive(Clone)]
struct State {
ctm: Matrix,
space: Space,
fill: Option<Rgb>,
/// Fill opacity (`ca`), and whether a soft mask or a blend mode makes the fill other than a
/// plain paint.
alpha: f64,
plain: bool,
/// The clip, as the rectangles it is the intersection of, in page space; `None` when a clip
/// that isn't one rectangle was set (its area can't be told).
clip: Option<Vec<Quad>>,
}
/// The page's background colour: the colour of the first thing its content paints, when that is
/// an opaque fill, in a gray, RGB or CMYK colour, of a path with a rectangle that covers the
/// whole visible page (and no clip keeps it from doing so). `None` for a page that paints anything else
/// first, or nothing. Forms and later content are not looked at.
pub fn page_background(doc: &Document, page: usize) -> Result<Option<Rgb>, EditError> {
let pages = pdfcraft_model::pages(doc);
let p = pages.get(page).ok_or(EditError::NoSuchPage(page))?;
let crop = p.crop(doc);
let resources = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let gstates = resources.get(b"ExtGState").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).unwrap_or_default();
let streams = crate::text::content_streams(doc, &p.dict);
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
let ops = pieces.parse().ops;
let mut st = State { ctm: Matrix::IDENTITY, space: Space::Gray, fill: Some([0.0; 3]), alpha: 1.0, plain: true, clip: Some(Vec::new()) };
let mut saved: Vec<State> = Vec::new();
let mut lost_saves = 0usize;
// The path being built: its rectangles in page space, and whether it has anything else.
// Each rectangle of the current path: its box on the page, and whether it winds
// counterclockwise there (its width and height and the CTM's determinant agree in sign).
let mut rects: Vec<(Quad, bool)> = Vec::new();
let mut curved = false;
let mut clip_pending = false;
for op in &ops {
match op.op.as_slice() {
b"q" => {
if saved.len() < MAX_SAVES {
saved.push(st.clone());
} else {
lost_saves = lost_saves.saturating_add(1);
}
}
b"Q" => {
if lost_saves > 0 {
lost_saves -= 1;
} else if let Some(s) = saved.pop() {
st = s;
}
}
b"cm" => {
if let Some(m) = Matrix::from_operands(&op.operands) {
st.ctm = m.then(&st.ctm);
}
}
b"g" => (st.space, st.fill) = (Space::Gray, op.nums::<1>().map(|[g]| [g; 3])),
b"rg" => (st.space, st.fill) = (Space::Rgb, op.nums::<3>()),
b"k" => (st.space, st.fill) = (Space::Cmyk, op.nums::<4>().map(cmyk)),
b"cs" => {
st.space = match op.name(0) {
Some(b"DeviceGray" | b"G") => Space::Gray,
Some(b"DeviceRGB" | b"RGB") => Space::Rgb,
Some(b"DeviceCMYK" | b"CMYK") => Space::Cmyk,
_ => Space::Other,
};
st.fill = None;
}
b"sc" | b"scn" => st.fill = colour(st.space, op),
b"gs" => {
if let Some(g) = op.name(0).and_then(|n| gstates.get(n)).map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()) {
apply_gstate(doc, &g, &mut st);
}
}
b"re" => match op.nums::<4>() {
Some([x, y, w, h]) => {
let [a, b, c, d, _, _] = st.ctm.0;
rects.push((quad(&st.ctm, [x, y, x + w, y + h]), (w * h) * (a * d - b * c) > 0.0));
}
None => curved = true,
},
b"m" | b"l" | b"c" | b"v" | b"y" | b"h" => curved = true,
b"W" | b"W*" => clip_pending = true,
b"n" => {
if clip_pending {
st.clip = match (st.clip.take(), rects.as_slice(), curved) {
(Some(mut c), [(r, _)], false) if c.len() < MAX_CLIPS => {
c.push(*r);
Some(c)
}
_ => None,
};
}
(rects, curved, clip_pending) = (Vec::new(), false, false);
}
b"f" | b"F" | b"f*" | b"B" | b"B*" => {
let opaque = st.plain && st.alpha >= 0.999;
// A second rectangle can cut a hole in the one that covers the page: under the
// even-odd rule any second one, under nonzero one wound the other way (a
// frame-style page border).
let even_odd = matches!(op.op.as_slice(), b"f*" | b"B*");
let solid = match rects.as_slice() {
[_] => true,
[(_, first), rest @ ..] => !even_odd && rest.iter().all(|(_, ccw)| ccw == first),
[] => false,
};
let covers = !curved
&& solid
&& st.clip.as_ref().is_some_and(|c| c.iter().all(|q| covers(q, &crop)))
&& rects.iter().any(|(r, _)| covers(r, &crop));
return Ok(st.fill.filter(|_| opaque && covers && !clip_pending).map(|c| c.map(|v| v.clamp(0.0, 1.0))));
}
// Anything else painted first: no page background.
b"S" | b"s" | b"b" | b"b*" | b"Tj" | b"TJ" | b"'" | b"\"" | b"Do" | b"sh" | b"BI" => return Ok(None),
_ => {}
}
}
Ok(None)
}
/// CMYK to RGB, as a plain conversion without colour management.
fn cmyk([c, m, y, k]: [f64; 4]) -> Rgb {
[(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)]
}
/// The colour `sc`/`scn` sets in `space`, if it is a plain one.
fn colour(space: Space, op: &Op) -> Option<Rgb> {
match (space, op.operands.len()) {
(Space::Gray, 1) => op.nums::<1>().map(|[g]| [g; 3]),
(Space::Rgb, 3) => op.nums::<3>(),
(Space::Cmyk, 4) => op.nums::<4>().map(cmyk),
_ => None,
}
}
/// The fill opacity, soft mask and blend mode an ExtGState sets.
fn apply_gstate(doc: &Document, g: &Dict, st: &mut State) {
if let Some(ca) = g.get(b"ca").and_then(|v| doc.resolve(v).as_f64()) {
st.alpha = if ca.is_finite() { ca } else { 0.0 };
}
if let Some(mask) = g.get(b"SMask") {
st.plain &= doc.resolve(mask).as_name() == Some(b"None");
}
if let Some(bm) = g.get(b"BM") {
let normal = |n: Option<&[u8]>| matches!(n, Some(b"Normal" | b"Compatible"));
st.plain &= match &*doc.resolve(bm) {
pdfcraft_cos::Object::Array(a) => a.first().is_some_and(|n| normal(doc.resolve(n).as_name())),
other => normal(other.as_name()),
};
}
}
/// The page-space corners of a user-space rectangle under `ctm`, in order around it.
fn quad(ctm: &Matrix, r: [f64; 4]) -> Quad {
[ctm.apply(r[0], r[1]), ctm.apply(r[2], r[1]), ctm.apply(r[2], r[3]), ctm.apply(r[0], r[3])]
}
/// Whether `outer` covers `page`, give or take half a percent of its size: each corner of the
/// page, pulled in by that much, lies inside the parallelogram `outer`.
fn covers(outer: &Quad, page: &[f64; 4]) -> bool {
let (w, h) = ((page[2] - page[0]).abs(), (page[3] - page[1]).abs());
let (tx, ty) = (w * 0.005, h * 0.005);
let (x0, y0, x1, y1) = (page[0].min(page[2]) + tx, page[1].min(page[3]) + ty, page[0].max(page[2]) - tx, page[1].max(page[3]) - ty);
let cross = |a: (f64, f64), b: (f64, f64), p: (f64, f64)| (b.0 - a.0) * (p.1 - a.1) - (b.1 - a.1) * (p.0 - a.0);
// Twice the signed area: its sign says which way the corners run.
let area: f64 = (0..4).map(|i| cross((0.0, 0.0), outer[i], outer[(i + 1) % 4])).sum();
if !area.is_finite() || area.abs() < f64::EPSILON || outer.iter().any(|p| !(p.0.is_finite() && p.1.is_finite())) {
return false;
}
[(x0, y0), (x1, y0), (x1, y1), (x0, y1)].iter().all(|&p| (0..4).all(|i| cross(outer[i], outer[(i + 1) % 4], p) * area.signum() >= 0.0))
}
+32 -4
View File
@@ -165,12 +165,19 @@ impl ImageState {
}
}
b"l" => {
if let Some([x, y]) = op.nums::<2>() {
// A segment before any m (or re) has no subpath to extend: BezPath's line_to and
// friends panic on an empty path by contract, so the segment is skipped, as the
// renderer, MuPDF and pdftotext do (#3581).
if let Some([x, y]) = op.nums::<2>()
&& self.path.elements().last().is_some()
{
self.path.line_to(self.point(x, y));
}
}
b"c" => {
if let Some([x1, y1, x2, y2, x3, y3]) = op.nums::<6>() {
if let Some([x1, y1, x2, y2, x3, y3]) = op.nums::<6>()
&& self.path.elements().last().is_some()
{
self.path.curve_to(self.point(x1, y1), self.point(x2, y2), self.point(x3, y3));
}
}
@@ -182,12 +189,19 @@ impl ImageState {
}
}
b"y" => {
if let Some([x1, y1, x2, y2]) = op.nums::<4>() {
if let Some([x1, y1, x2, y2]) = op.nums::<4>()
&& self.path.elements().last().is_some()
{
let end = self.point(x2, y2);
self.path.curve_to(self.point(x1, y1), end, end);
}
}
b"h" => self.path.close_path(),
b"h" => {
// ClosePath with no subpath panics by the same contract (#3581).
if self.path.elements().last().is_some() {
self.path.close_path();
}
}
b"re" => {
if let Some([x, y, w, h]) = op.nums::<4>() {
self.path.move_to(self.point(x, y));
@@ -409,6 +423,20 @@ pub fn turn_about_centre(rect: [f64; 4], quarters: i32, flip_h: bool, flip_v: bo
[a, b, c, d, cx - a * cx - c * cy, cy - b * cx - d * cy]
}
/// Turn the image `degrees` clockwise (as drawn, whatever the page's `/Rotate`) about the centre
/// of `rect`. `None` for a non-finite angle; whole turns of 360 are the identity.
pub fn turn_by_degrees(rect: [f64; 4], degrees: f64) -> Option<[f64; 6]> {
if !degrees.is_finite() {
return None;
}
let (cx, cy) = ((rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0);
let t = (degrees % 360.0).to_radians();
let (s, c) = t.sin_cos();
// Clockwise in a y-up space: (x, y) -> (x cos t + y sin t, -x sin t + y cos t).
let (a, b, cc, d) = (c, -s, s, c);
Some([a, b, cc, d, cx - a * cx - cc * cy, cy - b * cx - d * cy])
}
/// For a `Do` at `op` that sits alone in `q re W n cm Do Q`: the index of the `re` and the `cm` matrix.
/// That clip belongs to this image only, so it may move with it. A `cm` this move put before the
/// `re` earlier (`q cm re W n cm Do Q`) keeps the pattern, so the image can be moved again.
+156 -22
View File
@@ -2,8 +2,9 @@
//! Watermark, Background and Bates numbering (execution plan M7.6).
//!
//! **How marks are stored.** Each mark is one content stream appended to (or, for content behind
//! the page, prepended to) the page's `/Contents`, drawn in display space (after `/Rotate`) and
//! wrapped in a marked-content artifact (§14.8.2.2) so readers and screen readers skip it:
//! the page, inserted after the backgrounds at the start of) the page's `/Contents`, drawn in
//! display space (after `/Rotate`) and wrapped in a marked-content artifact (§14.8.2.2) so
//! readers and screen readers skip it:
//! `/Artifact <</Type /Pagination /Subtype /Header /PCMark /HeaderFooter>> BDC … EMC`. The
//! `/PCMark` entry (also in the stream dictionary) names the kind of mark, which is how Update and
//! Remove find them again without decompressing page content. When
@@ -11,7 +12,8 @@
//! so its graphics state cannot reach the mark. The closing stream also ends known unfinished
//! marked-content sequences before the new mark.
//!
//! Text uses standard Helvetica (WinAnsi) added to the page resources as `/PCHelv`.
//! Text uses standard Helvetica (WinAnsi) added to the page resources as `/PCHelv`; headers and
//! footers may use standard Times (`/PCTimes`) or Courier (`/PCCour`) instead (#3754).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
@@ -55,9 +57,11 @@ pub struct HeaderFooter {
/// Left, centre and right header text, then left, centre and right footer text. Tokens:
/// `<<1>>` page number, `<<n>>` page count, `<<1 of n>>`, `<<1/n>>`, `<<Page 1>>`,
/// `<<Page 1 of n>>`, dates `<<m/d/yyyy>>`, `<<mm/dd/yyyy>>`, `<<d/m/yyyy>>`, `<<yyyy-mm-dd>>`,
/// `<<m/d/yy>>`, `<<mmmm d, yyyy>>`, and Bates numbers `<<Bates Number#6#1#PRE#SUF>>`
/// `<<yyyy/mm/dd>>`, `<<yyyy/m/d>>`, `<<m/d/yy>>`, `<<mmmm d, yyyy>>`, and Bates numbers `<<Bates Number#6#1#PRE#SUF>>`
/// (digits, start, prefix, suffix).
pub text: [String; 6],
/// The standard face the text is drawn in (#3754): Helvetica (the default), Times or Courier.
pub font: added::Family,
pub font_size: f64,
pub color: Rgb,
pub underline: bool,
@@ -65,12 +69,25 @@ pub struct HeaderFooter {
pub margins: [f64; 4],
/// The number `<<1>>` shows on the first page of the range.
pub start_number: u32,
/// Shrink the page content so the header and footer don't cover it (Acrobat's Appearance
/// Options): the original content is scaled down evenly into the band the header and footer
/// leave free, centred across the page. Remove and Update take the scaling away again.
pub shrink: bool,
}
impl Default for HeaderFooter {
/// Acrobat's defaults: 8 pt black, margins 0.5 in top and bottom, 1 in left and right.
fn default() -> Self {
Self { text: Default::default(), font_size: 8.0, color: [0.0; 3], underline: false, margins: [36.0, 36.0, 72.0, 72.0], start_number: 1 }
Self {
text: Default::default(),
font: added::Family::Helvetica,
font_size: 8.0,
color: [0.0; 3],
underline: false,
margins: [36.0, 36.0, 72.0, 72.0],
start_number: 1,
shrink: false,
}
}
}
@@ -83,6 +100,42 @@ impl HeaderFooter {
pub fn first_undrawable(&self) -> Option<char> {
self.text.iter().flat_map(|t| t.lines()).find_map(pdfcraft_fonts::first_non_win_ansi)
}
/// The room the header and the footer take at the top and the bottom of a page, in points:
/// the margin, the lines of the tallest box, and a clearance of half the font size. A side
/// without text takes none. This is the room [`HeaderFooter::shrink`] keeps free.
pub fn bands(&self) -> (f64, f64) {
let size = self.font_size;
let line_h = size * 1.2;
let lines = |boxes: &[String]| boxes.iter().filter(|t| !t.trim().is_empty()).map(|t| t.lines().count().max(1)).max();
let (header, footer) = self.text.split_at(3);
// Header lines hang below the top margin (a full line each); the footer's last line sits
// on the bottom margin, so only its capitals (0.8 of the size) rise above it.
let top = lines(header).map_or(0.0, |n| self.margins[0] + size + (n - 1) as f64 * line_h + size / 2.0);
let bottom = lines(footer).map_or(0.0, |n| self.margins[1] + size * 0.8 + (n - 1) as f64 * line_h + size / 2.0);
(top, bottom)
}
}
/// The `cm` that shrinks a page's original content (user space) into the band `hf` leaves free,
/// for a page shown `size` (display space) with view matrix `view` (display to user space): an
/// even scale, centred across, the content's bottom on the footer's band.
fn shrink_matrix(hf: &HeaderFooter, size: (f64, f64), view: [f64; 6]) -> Result<[f64; 6], EditError> {
let (w, h) = size;
let (top, bottom) = hf.bands();
let free = h - top - bottom;
if !(free.is_finite() && free >= 1.0 && w.is_finite() && w > 0.0) {
return Err(EditError::Invalid("the header and footer margins leave no room for the page content to shrink into".into()));
}
let k = free / h;
let v = kurbo::Affine::new(view);
let det = v.determinant();
if !(det.is_finite() && det.abs() > 1e-12) {
return Err(EditError::Invalid("the page can't be shrunk: its view matrix is degenerate".into()));
}
let shrink = kurbo::Affine::new([k, 0.0, 0.0, k, (w - w * k) / 2.0, bottom]);
// User space to display space, shrink there, and back.
Ok((v * shrink * v.inverse()).as_coeffs())
}
/// A picture for a background or watermark (Acrobat: Source ▸ File): an XObject already in
@@ -245,6 +298,8 @@ pub fn expand(template: &str, page_number: u32, count: usize, bates: u64, cx: &C
"d/m/yyyy" => format!("{d}/{m}/{y}"),
"dd/mm/yyyy" => format!("{d:02}/{m:02}/{y}"),
"yyyy-mm-dd" => format!("{y}-{m:02}-{d:02}"),
"yyyy/mm/dd" => format!("{y}/{m:02}/{d:02}"),
"yyyy/m/d" => format!("{y}/{m}/{d}"),
"mmmm d, yyyy" => format!("{} {d}, {y}", MONTHS[(m.clamp(1, 12) - 1) as usize]),
t if t.starts_with("Bates Number") => {
// Bates Number#digits#start#prefix#suffix (start is applied by the caller).
@@ -297,19 +352,57 @@ fn contents(doc: &mut Document, page: &pdfcraft_model::Page) -> Result<Vec<Objec
/// Add the font (and an opacity state) to the page's own resources.
fn add_resources(doc: &mut Document, page: &pdfcraft_model::Page, opacity: Option<f64>, content: Option<&[u8]>) -> Result<(), EditError> {
let mut res = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let mut fonts = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
let mut faces: Vec<(Vec<u8>, &str)> = Vec::new();
for (name, base) in [(&b"PCHelv"[..], "Helvetica"), (b"PCTimes", "Times-Roman"), (b"PCCour", "Courier")] {
// Helvetica always (marks use it); the others only when the content names them.
if name != b"PCHelv" && !content.is_some_and(|c| c.windows(name.len() + 1).any(|w| w[0] == b'/' && &w[1..] == name)) {
continue;
}
faces.push((name.to_vec(), base));
}
add_page_resources(doc, page, opacity, &faces)
}
/// The fonts a page draws with: its own resources' or the ones it inherits.
fn page_fonts(doc: &Document, page: &pdfcraft_model::Page) -> Dict {
let res = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default()
}
/// A name in `fonts` for the standard face `base` that can't take over a font the page already
/// draws with: `preferred` when it is free or already names exactly the dictionary
/// [`add_page_resources`] writes for that face, otherwise the first such `preferred_1`,
/// `preferred_2`… (one of the first `fonts.len() + 1` always is). A font with anything more
/// (`/ToUnicode`, `/Widths`…) is someone else's, even in the same face.
fn free_font_name(doc: &Document, fonts: &Dict, preferred: &str, base: &str) -> String {
let usable = |name: &str| match fonts.get(name.as_bytes()) {
None => true,
Some(font) => doc.resolve(font).as_dict().is_some_and(|d| {
d.len() == 4
&& d.name(b"Type") == Some(b"Font")
&& d.name(b"Subtype") == Some(b"Type1")
&& d.name(b"BaseFont") == Some(base.as_bytes())
&& d.name(b"Encoding") == Some(b"WinAnsiEncoding")
}),
};
if usable(preferred) {
return preferred.to_string();
}
(1..=fonts.len().saturating_add(1)).map(|k| format!("{preferred}_{k}")).find(|name| usable(name)).unwrap_or_else(|| preferred.to_string())
}
/// Add the standard `faces` (resource name, base font) and an opacity state to the page's own
/// resources.
fn add_page_resources(doc: &mut Document, page: &pdfcraft_model::Page, opacity: Option<f64>, faces: &[(Vec<u8>, &str)]) -> Result<(), EditError> {
let mut res = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let mut fonts = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
for (name, base) in faces {
let mut font = Dict::new();
font.set(b"Type".to_vec(), Object::name("Font"));
font.set(b"Subtype".to_vec(), Object::name("Type1"));
font.set(b"BaseFont".to_vec(), Object::name(base));
font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
fonts.set(name.to_vec(), Object::Dict(font));
fonts.set(name.clone(), Object::Dict(font));
}
res.set(b"Font".to_vec(), Object::Dict(fonts));
if let Some(o) = opacity {
@@ -505,8 +598,8 @@ fn isolation(doc: &Document, list: &[Object]) -> Isolation {
Isolation::Clean { suffix: (dirty || comment_open || marked != 0).then_some(split), wrappers, marked }
}
/// Put a mark's content on a page: behind (prepended) or on top (appended, after wrapping the
/// original content in q/Q).
/// Put a mark's content on a page: behind (with the behind-marks at the start) or on top
/// (appended, after wrapping the original content in q/Q).
fn place(doc: &mut Document, page: &pdfcraft_model::Page, kind: MarkKind, content: Vec<u8>, behind: bool) -> Result<(), EditError> {
place_tagged(doc, page, kind.tag(), content, behind)
}
@@ -518,6 +611,9 @@ fn place(doc: &mut Document, page: &pdfcraft_model::Page, kind: MarkKind, conten
/// sequences are closed at the end too, so hidden optional content cannot hide the new mark.
fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, content: Vec<u8>, behind: bool) -> Result<(), EditError> {
let mut list = contents(doc, page)?;
// The behind-marks at the start: backgrounds in the order added, then watermarks.
let behind_kinds = [MarkKind::Background.tag(), MarkKind::Watermark.tag()];
let behind_marks = list.iter().take_while(|o| stream_tag(doc, o).is_some_and(|t| behind_kinds.contains(&t.as_str()))).count();
let wrap = if behind {
None
} else {
@@ -526,16 +622,17 @@ fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, cont
// Content that can't be read precisely is wrapped whole, as before: after the
// behind-marks at the start. A `q` it leaves open is closed at its end, where
// closing it changes nothing already drawn.
Isolation::Unknown { unclosed, marked } => {
let behind_kinds = [MarkKind::Background.tag(), MarkKind::Watermark.tag()];
let split = list.iter().position(|o| !stream_tag(doc, o).is_some_and(|t| behind_kinds.contains(&t.as_str()))).unwrap_or(list.len());
(split < list.len()).then_some((split, unclosed, marked))
}
Isolation::Unknown { unclosed, marked } => (behind_marks < list.len()).then_some((behind_marks, unclosed, marked)),
}
};
let mark = Object::Ref(doc.add(Object::Stream(Stream::flate(tagged(tag), &content))));
if behind {
list.insert(0, mark);
// Right after the backgrounds already there, so a new background is painted over the
// older ones (#3591) and a watermark over all of them. Not after the watermarks: on a
// page with no content, one on top is at the start too and can't be told apart, so a
// newer watermark behind goes under the older ones.
let at = list.iter().take(behind_marks).rposition(|o| stream_tag(doc, o).as_deref() == Some(MarkKind::Background.tag())).map_or(0, |i| i + 1);
list.insert(at, mark);
} else {
if let Some((split, unclosed, marked)) = wrap {
let mut close_bytes = b"\nEMC".repeat(marked);
@@ -552,6 +649,18 @@ fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, cont
Ok(())
}
/// Put content tagged `tag` first inside the q/Q wrapper, so it applies to the page's original
/// content and nothing else (a header's shrink). A page without a wrapper has no original content
/// on it and is left alone.
fn place_inside_wrapper(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, content: Vec<u8>) -> Result<(), EditError> {
let mut list = contents(doc, page)?;
let Some(open) = list.iter().position(|o| stream_tag(doc, o).as_deref() == Some("Wrap")) else { return Ok(()) };
let stream = Object::Ref(doc.add(Object::Stream(Stream::flate(tagged(tag), &content))));
list.insert(open + 1, stream);
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list)))?;
Ok(())
}
/// Draw `content` on top of a page (0-based) as permanent page content, with standard Helvetica
/// available as `/PCHelv`. The stream is tagged `tag` (not a mark kind, so Remove never takes
/// it away); the original content is wrapped in q/Q first. Used for applied redaction boxes.
@@ -632,6 +741,11 @@ pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter,
u64::MAX
)));
}
// Shrinking needs room on every page: refuse before anything is drawn or replaced.
let shrinks = pages
.iter()
.map(|&i| hf.shrink.then(|| shrink_matrix(hf, all[i].display_size(doc), all[i].view_matrix(doc))).transpose())
.collect::<Result<Vec<_>, _>>()?;
if replace {
remove_marks(doc, pages, MarkKind::HeaderFooter)?;
}
@@ -645,7 +759,16 @@ pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter,
let bates = bates0.checked_add(k as u64).ok_or_else(|| EditError::Invalid("the Bates numbers are too large".into()))?;
let size = hf.font_size;
let [top, bottom, left, right] = hf.margins;
let mut body: Vec<u8> = format!("BT\n/PCHelv {} Tf\n{}\n", n(size), rgb(hf.color)).into_bytes();
// The page resource the face is added under: the usual name, unless the page already
// draws another font by it, which must not change under the header.
let preferred = match hf.font {
added::Family::Helvetica => "PCHelv",
added::Family::Times => "PCTimes",
added::Family::Courier => "PCCour",
};
let base = hf.font.base_font(false, false);
let resource = free_font_name(doc, &page_fonts(doc, page), preferred, base);
let mut body: Vec<u8> = format!("BT\n/{resource} {} Tf\n{}\n", n(size), rgb(hf.color)).into_bytes();
let mut underlines = String::new();
for (slot, template) in hf.text.iter().enumerate() {
if template.trim().is_empty() {
@@ -655,7 +778,8 @@ pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter,
let header = slot < 3;
let line_h = size * 1.2;
for (li, line) in lines.iter().enumerate() {
let tw = helvetica_width(line, size);
// Centred and right-aligned text is placed by its width in the chosen face.
let tw = hf.font.width(line, size, false, false);
let x = match slot % 3 {
0 => left,
1 => (w - tw) / 2.0,
@@ -676,9 +800,17 @@ pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter,
let mut content = begin(MarkKind::HeaderFooter, "Header", page.view_matrix(doc)).into_bytes();
content.extend(body);
content.extend_from_slice(END.as_bytes());
add_resources(doc, page, None, None)?;
// A name already there is that very font (see `free_font_name`): left as it is.
if !page_fonts(doc, page).contains(resource.as_bytes()) {
add_page_resources(doc, page, None, &[(resource.into_bytes(), base)])?;
}
let page = &page_list(doc)[i];
place(doc, page, MarkKind::HeaderFooter, content, false)?;
if let Some(Some(m)) = shrinks.get(k) {
let cm = format!("{} cm\n", m.map(matrix_number).join(" "));
let page = &page_list(doc)[i];
place_inside_wrapper(doc, page, MarkKind::HeaderFooter.tag(), cm.into_bytes())?;
}
}
Ok(())
}
@@ -847,20 +979,22 @@ pub fn marks_present(doc: &Document) -> Vec<MarkKind> {
added::scan_markers(doc).marks
}
mod background;
pub use background::page_background;
pub mod objects;
pub use objects::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget, editable_objects, move_objects};
mod flatten;
pub use flatten::{flatten, flatten_fill_sign};
pub mod images;
pub use images::{ImageChange, PageImage, change_image, page_images, reading_images, rect_to_rect, turn_about_centre};
pub use images::{ImageChange, PageImage, change_image, page_images, reading_images, rect_to_rect, turn_about_centre, turn_by_degrees};
pub mod bidi;
pub mod indic;
mod linebreak;
pub mod text;
pub use text::{
BlockStyle, LineEdit, MAX_FORM_DEPTH, MAX_FORM_VISITS, RangeStyle, ReadingBlocks, TextBlock, TextLine, clear_block, reading_blocks,
reading_blocks_checked, replace_block, replace_line, rewrite_block, text_blocks, text_lines,
BlockKey, BlockStyle, LineEdit, MAX_FORM_DEPTH, MAX_FORM_VISITS, RangeStyle, ReadingBlocks, TextBlock, TextLine, TextSession, block_overlapping,
clear_block, reading_blocks, reading_blocks_checked, replace_block, replace_line, rewrite_block, text_blocks, text_lines,
};
pub mod added;
pub use added::{
+1146 -9
View File
File diff suppressed because it is too large Load Diff
+581 -80
View File
File diff suppressed because it is too large Load Diff
+215 -28
View File
@@ -6,6 +6,7 @@ fn same_plane(a: &TextLine, b: &TextLine) -> bool {
let at = Matrix(a.origin.tm).then(&Matrix(a.origin.ctm));
let bt = Matrix(b.origin.tm).then(&Matrix(b.origin.ctm));
a.scope == b.scope
&& a.paint == b.paint
&& [0, 2].iter().all(|axis| {
let ax = at.0.get(*axis).copied().unwrap_or(0.0);
let ay = at.0.get(axis + 1).copied().unwrap_or(0.0);
@@ -27,14 +28,105 @@ fn frame_row_rect(row: &[usize], lines: &[TextLine]) -> Option<[f64; 4]> {
row.iter().filter_map(|i| lines.get(*i)).map(TextLine::frame_rect).reduce(union)
}
/// The left edge the rest of the page's text sits on, ignoring the rows of `block`. A single row
/// that does not share it is not merely an indented or full-width left-aligned line, so its own
/// centre is the only remaining explanation for where it sits.
fn neighbour_left(lines: &[TextLine], block: &TextBlock) -> Option<f64> {
let mut edges: HashMap<i64, usize> = HashMap::new();
for (i, line) in lines.iter().enumerate() {
if block.lines.contains(&i) {
continue;
}
// Quarter-point buckets: equal edges found through different runs still agree.
*edges.entry((line.rect[0] * 4.0).round() as i64).or_insert(0) += 1;
}
let (best, _) = edges.into_iter().max_by_key(|(_, count)| *count)?;
Some(best as f64 / 4.0)
}
/// Whether a block reads as centred, and so must keep its centre when it grows or wraps.
///
/// Several ragged rows whose left edges disagree while their centres agree are centred. A one row
/// block cannot say that: all of its left edges are the same, so the ragged test never fires and a
/// centred title would be taken for left-aligned and grow to the right (#2581). Fall back to the
/// page instead — a lone row sitting on the page's own centre, and not on the edge the rest of the
/// text uses, is a centred title. A full-width left-aligned line still lines up with that edge, so
/// it is not mistaken for one.
pub(super) fn is_centred(block: &TextBlock, lines: &[TextLine], page_centre: f64, size: f64) -> bool {
let centre = (block.rect[0] + block.rect[2]) / 2.0;
let lefts: Vec<_> = block.rows.iter().filter_map(|r| row_rect(r, lines).map(|r| r[0])).collect();
if block.rows.len() > 1 {
let left_span = lefts.iter().copied().fold(f64::NEG_INFINITY, f64::max) - lefts.iter().copied().fold(f64::INFINITY, f64::min);
return left_span > size * 0.5
&& block.rows.iter().all(|row| row_rect(row, lines).is_some_and(|r| ((r[0] + r[2]) / 2.0 - centre).abs() < size));
}
lefts
.first()
.is_some_and(|left| (centre - page_centre).abs() < size && neighbour_left(lines, block).is_none_or(|edge| (*left - edge).abs() > size * 0.5))
}
pub(super) fn group_blocks(lines: &[TextLine]) -> Vec<TextBlock> {
group_layout(lines, true)
}
/// Exporters currently represent a block with one style. Keep their style boundaries until
/// they support rich runs, while edit selection may encompass the complete visual region.
/// Dotted-leader entries stay on one row and use the title run's style.
pub(super) fn group_reading_blocks(lines: &[TextLine]) -> Vec<TextBlock> {
group_layout(lines, false)
let mut blocks = Vec::new();
let mut start = 0;
let mut offset = 0;
for row in lines.chunk_by(|a, b| same_plane(a, b) && (a.origin.baseline - b.origin.baseline).abs() < a.size.min(b.size) * 0.2) {
let mut text = String::new();
for (i, line) in row.iter().enumerate() {
if !text.ends_with(char::is_whitespace)
&& !line.text.starts_with(char::is_whitespace)
&& i.checked_sub(1).and_then(|i| row.get(i)).is_some_and(|prev| line.rect[0] - prev.rect[2] > line.size * 0.12)
{
text.push(' ');
}
text.push_str(&line.text);
}
if leader_entry(&text)
&& let Some(first) = row.first()
{
// Tab leaders are a single reading row, even when the title, dots and page
// number are separate text objects far apart. Do not merge neighbouring entries.
blocks.extend(group_layout(lines.get(start..offset).unwrap_or_default(), false));
let mut line = first.clone();
line.text = text;
line.rect = row.iter().map(|line| line.rect).reduce(union).unwrap_or(first.rect);
blocks.extend(group_layout(std::slice::from_ref(&line), false));
start = offset + row.len();
}
offset += row.len();
}
blocks.extend(group_layout(lines.get(start..).unwrap_or_default(), false));
blocks
}
/// Dotted leaders end in a page number, with a title before the dots. A dot in an article
/// number, or an ellipsis in ordinary prose, is not enough to make a contents entry.
fn leader_entry(text: &str) -> bool {
let text = text.trim_end();
let prefix = text.trim_end_matches(|c: char| {
c.is_ascii_digit() || matches!(c, 'i' | 'v' | 'x' | 'l' | 'c' | 'd' | 'm' | 'I' | 'V' | 'X' | 'L' | 'C' | 'D' | 'M')
});
if prefix.len() == text.len() {
return false;
}
let title = prefix.trim_end_matches(|c: char| c.is_whitespace() || matches!(c, '.' | '…'));
let dots = prefix
.get(title.len()..)
.unwrap_or_default()
.chars()
.map(|c| match c {
'.' => 1,
'…' => 3,
_ => 0,
})
.sum::<usize>();
dots >= 3 && title.chars().any(char::is_alphabetic)
}
/// Text-space units → user space along the baseline. It is the vertical scale ([`Origin::k`])
@@ -51,8 +143,11 @@ fn invisible(line: &TextLine) -> bool {
}
fn same_style(a: &TextLine, b: &TextLine) -> bool {
same_font(a, b.font.as_bytes(), b.face.as_deref())
&& a.base_font == b.base_font
same_font(a, b.font.as_bytes(), &b.base_font, b.face.as_deref())
// With no `/BaseFont` (or only a subset tag) there is no name to compare: the resource or
// fallback face `same_font` matched is the whole identity, so keep the lines together.
&& (same_base_font(&a.base_font, &b.base_font)
|| (strip_subset_tag(&a.base_font).is_empty() && strip_subset_tag(&b.base_font).is_empty()))
&& a.bold == b.bold
&& a.italic == b.italic
&& a.color == b.color
@@ -60,6 +155,39 @@ fn same_style(a: &TextLine, b: &TextLine) -> bool {
&& (a.size - b.size).abs() < 0.01
}
/// Does a row open with a list marker? A bullet or a dash, or an ordered marker — digits (or a
/// single letter) followed by `.` or `)` and then a space or the row's end, so prose such as
/// `1.5` or `Dr. Smith` is left alone.
fn opens_with_marker(text: &str) -> bool {
let Some(first) = text.chars().next() else { return false };
if matches!(first, '•' | '◦' | '▪' | '‣' | '⁃' | '*' | '-' | '–' | '—') {
return true;
}
if !first.is_ascii_digit() && !first.is_ascii_alphabetic() {
return false;
}
let digits = text.chars().take_while(|c| c.is_ascii_digit()).count();
let letters = text.chars().take_while(|c| c.is_ascii_alphabetic()).count();
// Both runs are ASCII characters, so their length in characters is their length in bytes:
// the slice below starts on a character boundary.
let rest = if digits > 0 {
&text[digits..]
} else if letters == 1 {
&text[1..]
} else {
return false;
};
let mut after = rest.chars();
matches!(after.next(), Some('.' | ')')) && after.next().is_none_or(|c| c.is_whitespace())
}
/// A row that opens with a list marker opens its own item: joining it to the row above is what
/// let an edit of one paragraph delete every item after it (#2564). The other error, splitting
/// an ordinary paragraph, costs one more paragraph to click and never any text — so err this way.
fn starts_list_item(row: &[usize], lines: &[TextLine]) -> bool {
row.iter().filter_map(|i| lines.get(*i)).map(|l| l.text.trim_start()).find(|t| !t.is_empty()).is_some_and(opens_with_marker)
}
fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
let mut rows: Vec<Vec<usize>> = Vec::new();
for (i, line) in lines.iter().enumerate() {
@@ -71,7 +199,18 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
scanned
&& (mixed || same_style(prev, line))
&& same_plane(prev, line)
&& (prev.frame_origin().1 - line.frame_origin().1).abs() < prev.size.min(line.size) * 0.2
// A superscript or subscript run (a footnote marker, say) sits on the same visual
// row as the text around it, raised or lowered by `Ts` from the body baseline
// (#3252). That shift is typically up to half the body size, not the marker's own
// smaller size — but the wider tolerance only applies between a run and a
// distinctly smaller or larger one, with no horizontal overlap (`gap >= 0.0`):
// two equal-size runs can land within it too, at a baseline shift tight columns
// or compressed text (`Tz`) can produce between genuinely separate rows, and the
// original 20% tolerance is what keeps those apart.
&& ((prev.frame_origin().1 - line.frame_origin().1).abs() < prev.size.min(line.size) * 0.2
|| (gap >= 0.0
&& prev.size.min(line.size) < prev.size.max(line.size) * 0.9
&& (prev.frame_origin().1 - line.frame_origin().1).abs() < prev.size.max(line.size) * 0.5))
&& gap >= -prev.size.min(line.size) * 0.2
&& gap < prev.size.max(line.size) * 1.5
// Separate same-style text objects that merely touch or overlap, with no space
@@ -94,17 +233,34 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
let Some(first) = row.first().and_then(|i| lines.get(*i)) else { continue };
let (Some(rect), Some(frame)) = (row_rect(&row, lines), frame_row_rect(&row, lines)) else { continue };
let joins = blocks.last().is_some_and(|block| {
// A bullet of its own opens a list item (#2564): such a row is a new paragraph, and
// letting it join is what made an edit of the row above delete this item and the ones
// after it.
if starts_list_item(&row, lines) {
return false;
}
let Some(prev_row) = block.rows.last() else { return false };
let Some(prev) = prev_row.first().and_then(|i| lines.get(*i)) else { return false };
let Some(pr) = frame_row_rect(prev_row, lines) else { return false };
let gap = prev.frame_origin().1 - first.frame_origin().1;
let tol = prev.size.max(first.size).max(1.0);
let aligned =
(pr[0] - frame[0]).abs() < tol || ((pr[0] + pr[2] - frame[0] - frame[2]) / 2.0).abs() < tol || (pr[2] - frame[2]).abs() < tol;
// The block opens with a list marker and this row sits further right: the item's text
// is at a hanging indent, so the row it wrapped onto continues it (#2564). A row at
// the block's own left edge is already covered by the match on `pr[0]`. Both boxes are
// in the frame, as the rest of this comparison is, so turned text matches too.
let hanging = frame[0] > pr[0]
&& frame[0] - pr[0] <= tol * 3.0
&& block.lines.first().and_then(|&i| lines.get(i)).is_some_and(|l| opens_with_marker(l.text.trim_start()));
let aligned = (pr[0] - frame[0]).abs() < tol
|| ((pr[0] + pr[2] - frame[0] - frame[2]) / 2.0).abs() < tol
|| (pr[2] - frame[2]).abs() < tol
|| hanging;
// OCR lines stay apart: the scan fixes where each one is, so they never rewrap together.
(!mixed || !(invisible(prev) || invisible(first)))
&& (mixed || same_style(prev, first))
&& same_plane(prev, first)
// The rewrite sets every row in the first one's horizontal scale.
&& (prev.origin.state.scale - first.origin.state.scale).abs() < 1e-6
&& aligned
&& first.size.min(prev.size) >= first.size.max(prev.size) * 0.4
&& gap > first.size.min(prev.size) * 0.8
@@ -685,12 +841,8 @@ pub(super) fn rewrite_styled_block(
.ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it cannot be edited in".into()))?;
let (tx, ty) = back.apply(dx, dy);
block_ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(tx), n(ty)]));
let centre = (block.rect[0] + block.rect[2]) / 2.0;
let lefts: Vec<_> = block.rows.iter().filter_map(|r| row_rect(r, lines).map(|r| r[0])).collect();
let left_span = lefts.iter().copied().fold(f64::NEG_INFINITY, f64::max) - lefts.iter().copied().fold(f64::INFINITY, f64::min);
let centered = block.rows.len() > 1
&& left_span > first.size * 0.5
&& block.rows.iter().all(|row| row_rect(row, lines).is_some_and(|r| ((r[0] + r[2]) / 2.0 - centre).abs() < first.size));
let crop = p.crop(doc);
let centered = is_centred(block, lines, (crop[0] + crop[2]) / 2.0, first.size);
let source_leading = block
.rows
.windows(2)
@@ -764,24 +916,59 @@ pub(super) fn rewrite_styled_block(
properties.set(b"ActualText".to_vec(), Object::String(PdfString::text(&string)));
block_ops.push(Op::new("BDC", vec![Object::name("Span"), Object::Dict(properties)]));
state.font = Some((resource.clone(), size_of(source, item.1)));
// Absolute glyph matrices preserve marks and contextual positions; font-program
// codes are separate from Unicode and ToUnicode/ActualText keep logical text.
// One text object for the run: each glyph is placed where shaping put it by a TJ
// adjustment from where the glyph before it ends (its advance in the embedded
// font's widths), and raised by the text rise for a mark set above or below
// (ISO 32000-1 §9.4.3, §9.3.7). Font-program codes are separate from Unicode;
// ToUnicode and ActualText keep the logical text.
let size = size_of(source, item.1);
let mut extra = 0.0;
for (i, glyph) in shaped.glyphs.iter().enumerate() {
let mut matrix = tm;
let x = (glyph.position[0] * size + extra) * state.scale;
let y = glyph.position[1] * size;
matrix[4] += matrix[0] * x + matrix[2] * y;
matrix[5] += matrix[1] * x + matrix[3] * y;
let code = u16::try_from(i + 1).map_err(|_| EditError::Invalid("shaped glyph limit".into()))?;
block_ops.push(Op::new("BT", vec![]));
block_ops.extend(state.ops());
block_ops.push(Op::new("Tm", matrix.map(n).to_vec()));
block_ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(code.to_be_bytes().to_vec()))]));
block_ops.push(Op::new("ET", vec![]));
extra += state.char_spacing * glyph.text.chars().count() as f64 + state.word_spacing * glyph.text.matches(' ').count() as f64;
let per_unit = 1000.0 / size;
if !per_unit.is_finite() {
return Err(EditError::Invalid("the shaped text has no size".into()));
}
let shown = fonts
.get(resource.as_bytes())
.and_then(|f| doc.resolve(f).as_dict().cloned())
.map(|d| Metrics::from_dict(doc, &d))
.ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?;
// Spacing is part of each glyph's position, so the run itself has none.
let (char_spacing, word_spacing, rise) = (state.char_spacing, state.word_spacing, state.rise);
state.char_spacing = 0.0;
state.word_spacing = 0.0;
block_ops.push(Op::new("BT", vec![]));
block_ops.extend(state.ops());
block_ops.push(Op::new("Tm", tm.iter().map(|v| n(*v)).collect()));
// `pen`: where the next glyph would start, in thousandths of the font size.
let (mut pen, mut extra, mut raised) = (0.0, 0.0, rise);
let mut shown_glyphs: Vec<Object> = Vec::new();
for (i, glyph) in shaped.glyphs.iter().enumerate() {
let target = (glyph.position[0] * size + extra) * per_unit;
let glyph_rise = rise + glyph.position[1] * size;
if (glyph_rise - raised).abs() > 1e-9 || shown_glyphs.len() >= 4096 {
if !shown_glyphs.is_empty() {
block_ops.push(Op::new("TJ", vec![Object::Array(std::mem::take(&mut shown_glyphs))]));
}
if (glyph_rise - raised).abs() > 1e-9 {
block_ops.push(Op::new("Ts", vec![n(glyph_rise)]));
raised = glyph_rise;
}
}
if (target - pen).abs() > 1e-6 {
shown_glyphs.push(n(pen - target));
}
let code = u16::try_from(i + 1).map_err(|_| EditError::Invalid("shaped glyph limit".into()))?;
shown_glyphs.push(Object::String(PdfString::literal(code.to_be_bytes().to_vec())));
pen = target + shown.width(u32::from(code)) * 1000.0;
extra += char_spacing * glyph.text.chars().count() as f64 + word_spacing * glyph.text.matches(' ').count() as f64;
}
if !shown_glyphs.is_empty() {
block_ops.push(Op::new("TJ", vec![Object::Array(shown_glyphs)]));
}
if (raised - rise).abs() > 1e-9 {
block_ops.push(Op::new("Ts", vec![n(rise)]));
}
block_ops.push(Op::new("ET", vec![]));
(state.char_spacing, state.word_spacing) = (char_spacing, word_spacing);
block_ops.push(Op::new("EMC", vec![]));
segment_width = (shaped.advance * size + extra) * state.scale * source.origin.k;
} else {
+69
View File
@@ -0,0 +1,69 @@
//! A text editing session for one paragraph (#2512). Opening it reads the page once; each draft
//! is laid out from that reading, on a copy of the document as it was opened, so no draft sees
//! another one's output; the commit writes into the document it was opened on, and refuses if
//! that document has changed since.
use super::*;
/// One paragraph being edited: the document as it was opened, and the page's lines then.
pub struct TextSession {
base: Document,
base_state: pdfcraft_cos::ReadState,
page: usize,
block: usize,
lines: Vec<TextLine>,
source: TextBlock,
}
impl TextSession {
/// Opens paragraph `block` on `page` (both 0-based) of `doc`.
pub fn open(doc: &Document, page: usize, block: usize) -> Result<Self, EditError> {
let lines = text_lines(doc, page)?;
let source = group_blocks(&lines)
.get(block)
.cloned()
.ok_or_else(|| EditError::Invalid(format!("page {} has no paragraph {}", page + 1, block + 1)))?;
Ok(Self { base: doc.clone(), base_state: doc.read_state(), page, block, lines, source })
}
/// The paragraph as it was when the session opened.
pub fn source(&self) -> &TextBlock {
&self.source
}
pub fn page(&self) -> usize {
self.page
}
pub fn block(&self) -> usize {
self.block
}
/// Whether `doc` is still the document the session was opened on (nothing applied since).
pub fn is_current(&self, doc: &Document) -> bool {
self.base_state.matches(doc)
}
/// Lays a draft out without writing it: the rewrite Apply runs, on a copy of the opened document.
pub fn layout(&self, text: &str, style: &BlockStyle) -> Result<TextEditLayout, EditError> {
layout_draft(&self.base, self.page, &self.lines, &self.source, self.block, text, style)
}
/// Writes the draft into `doc`, which must still be the document the session was opened on.
pub fn commit(&self, doc: &mut Document, text: Option<&str>, style: &BlockStyle) -> Result<LineEdit, EditError> {
self.commit_with_limit(doc, text, style, MAX_TYPE3_GLYPHS)
}
pub(crate) fn commit_with_limit(
&self,
doc: &mut Document,
text: Option<&str>,
style: &BlockStyle,
max_glyphs: usize,
) -> Result<LineEdit, EditError> {
if !self.is_current(doc) {
return Err(EditError::Invalid("the paragraph changed while it was being edited".into()));
}
rewrite_block_from(doc, self.page, self.lines.clone(), self.block, text, style, max_glyphs)
}
}
+3
View File
@@ -51,3 +51,6 @@ unicode-segmentation = "1.13"
# Already in the graph through lopdf; used only for the local UTC offset ("today"). The workspace
# entry is std-only; this target adds `clock` for `chrono::Local::now`.
chrono = { workspace = true, features = ["clock"] }
[dev-dependencies]
pdfcraft-testkit = { workspace = true }
+2
View File
@@ -347,6 +347,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
item("Field properties", "settings-2", "form.field.properties", Ready),
item("Tab order by rows", "rows-3", "form.tab_order.row", Ready),
item("Tab order by columns", "columns-3", "form.tab_order.column", Ready),
item("Calculation order", "list-ordered", "form.calculation_order", Ready),
item("Flatten form fields", "layers", "form.flatten", Ready),
item("Import data", "file-input", "form.import_data", Ready),
item("Export data", "file-output", "form.export_data", Ready),
@@ -356,6 +357,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
title: "Add form components",
items: &[
item("Text field", "text-cursor-input", "form.add.text", Ready),
item("Text field table", "grid-3x3", "form.add.text_table", Ready),
item("Checkbox", "check-circle-2", "form.add.checkbox", Ready),
item("Radio button", "circle", "form.add.radio", Ready),
item("Drop-down list", "chevron-down", "form.add.combo", Ready),
+7 -1
View File
@@ -26,6 +26,8 @@ pub enum Needs {
Annotate,
/// The open document's security allows copying text and images out of it.
Copying,
/// The open document's security allows printing (low resolution counts).
Printing,
/// The document has form fields and its security allows filling them in.
FillForms,
/// The document has comments and allows changes.
@@ -188,7 +190,7 @@ pub const COMMANDS: &[CommandSpec] = &[
c("file.close", "Close file", FILE, Some(Shortcut::cmd("W")), Document, "x"),
c("file.close_all", "Close all", FILE, Some(Shortcut::cmd_shift("W")), Document, "x"),
c("file.revert", "Revert", FILE, None, Undo, "rotate-ccw"),
c("print.dialog", "Print…", FILE, Some(Shortcut::cmd("P")), Document, "printer"),
c("print.dialog", "Print…", FILE, Some(Shortcut::cmd("P")), Printing, "printer"),
c("file.properties", "Document properties…", FILE, Some(Shortcut::cmd("D")), Document, "info"),
c("file.word_count", "Word count…", FILE, None, Copying, "hash"),
ct("edit.undo", "Undo", EDIT, Some(Shortcut::cmd("Z")), Undo, "undo-2"),
@@ -202,6 +204,7 @@ pub const COMMANDS: &[CommandSpec] = &[
c("view.layout.single", "Single page", None, None, Document, "file-text"),
c("view.layout.two_up", "Two-page view", None, None, Document, "columns-2"),
c("view.continuous", "Continuous scrolling", None, None, Document, "arrow-up-down"),
c("view.page_gaps", "Show gaps between pages", None, None, Document, "arrow-up-down"),
c("view.layout.cover", "Show cover page in two-page view", None, None, TwoPageView, "bookmark"),
// Acrobat's view modes, a page display and a zoom at once (the rail's Page display menu).
c("view.fit_width_scrolling", "Fit to width scrolling", None, None, Document, "arrow-left-right"),
@@ -275,6 +278,7 @@ pub const COMMANDS: &[CommandSpec] = &[
c("form.tab_order.row", "Tab order: by rows", None, None, HasFields, "rows-3"),
c("form.tab_order.column", "Tab order: by columns", None, None, HasFields, "columns-3"),
c("form.tab_order.structure", "Tab order: by document structure", None, None, HasFields, "list"),
c("form.calculation_order", "Calculation order", None, None, Document, "list-ordered"),
c("edit.text", "Add text", None, None, Modification, "type"),
c("edit.edit_text", "Edit text & images", None, None, Modification, "text-cursor-input"),
c("edit.link", "Add or edit links", None, None, Modification, "link-2"),
@@ -292,6 +296,7 @@ pub const COMMANDS: &[CommandSpec] = &[
c("redact.sanitize", "Sanitize document…", None, None, Modification, "sparkles"),
c("form.field.properties", "Field properties…", None, None, HasFields, "settings-2"),
c("form.add.text", "Add a text field", None, None, Modification, "text-cursor-input"),
c("form.add.text_table", "Add text field table", None, None, Modification, "grid-3x3"),
c("form.add.checkbox", "Add a checkbox", None, None, Modification, "check-circle-2"),
c("form.add.radio", "Add a radio button", None, None, Modification, "circle"),
c("form.add.combo", "Add a drop-down list", None, None, Modification, "chevron-down"),
@@ -398,6 +403,7 @@ pub fn is_enabled(spec: &CommandSpec, session: &Session, active: Option<DocId>)
Modification => doc.is_some_and(|d| d.allows_modification()),
Annotate => doc.is_some_and(|d| d.allows_annotation()),
Copying => doc.is_some_and(|d| d.allows_copying()),
Printing => doc.is_some_and(|d| d.allows_printing()),
FillForms => doc.is_some_and(|d| d.allows_form_filling() && !d.form.is_empty()),
Marks(k) => doc.is_some_and(|d| d.allows_modification() && d.marks().contains(&k)),
HasComments => doc.is_some_and(|d| d.allows_modification() && !d.info.annotations.is_empty()),
+3 -1
View File
@@ -228,7 +228,9 @@ impl crate::Document {
})
})
.collect();
pdfcraft_export::Page { width: info.width as f64, height: info.height as f64, blocks, images }
// The page colour (Edit a PDF ▸ Background, a producer's page fill) for formats that keep one.
let background = pdfcraft_edit::page_background(cos, i).ok().flatten();
pdfcraft_export::Page { width: info.width as f64, height: info.height as f64, blocks, images, background }
})
.collect();
(pages, truncated)
+232 -2
View File
@@ -13,7 +13,7 @@ use crate::Session;
pub const DEFAULT_DATE_FORMAT: &str = "m/d/yyyy";
/// Ready-made formats for Preferences ▸ Date format. Any other valid pattern works too.
pub const DATE_FORMATS: [&str; 12] = [
pub const DATE_FORMATS: [&str; 14] = [
"m/d/yyyy",
"mm/dd/yyyy",
"d/m/yyyy",
@@ -21,6 +21,9 @@ pub const DATE_FORMATS: [&str; 12] = [
"dd.mm.yyyy",
"yyyy-mm-dd",
"yyyy.mm.dd.",
// Year first with slashes, as Taiwan, Japan and China write it (Windows' zh-TW short date).
"yyyy/mm/dd",
"yyyy/m/d",
"d mmm yyyy",
"d mmmm yyyy",
"mmm d, yyyy",
@@ -54,9 +57,13 @@ const ZH_DAYS: [&str; 7] = ["星期日", "星期一", "星期二", "星期三",
const AR_MONTHS: [&str; 12] = ["يناير", "فبراير", "مارس", "أبريل", "مايو", "يونيو", "يوليو", "أغسطس", "سبتمبر", "أكتوبر", "نوفمبر", "ديسمبر"];
const AR_DAYS: [&str; 7] = ["الأحد", "الاثنين", "الثلاثاء", "الأربعاء", "الخميس", "الجمعة", "السبت"];
const CKB_MONTHS: [&str; 12] =
["کانوونی دووەم", "شوبات", "ئازار", "نیسان", "ئایار", "حوزەیران", "تەممووز", "ئاب", "ئەیلوول", "تشرینی یەکەم", "تشرینی دووەم", "کانوونی یەکەم"];
const CKB_DAYS: [&str; 7] = ["یەکشەممە", "دووشەممە", "سێشەممە", "چوارشەممە", "پێنجشەممە", "هەینی", "شەممە"];
/// The languages month and weekday names can be written in (Preferences ▸ Date format ▸ Language):
/// the interface languages, in the same order and with the same names.
pub static DATE_LANGUAGES: [DateLanguage; 23] = [
pub static DATE_LANGUAGES: [DateLanguage; 34] = [
DateLanguage {
code: "en",
name: "English",
@@ -250,6 +257,16 @@ pub static DATE_LANGUAGES: [DateLanguage; 23] = [
days: AR_DAYS,
days_short: AR_DAYS,
},
// Central Kurdish (Sorani) has no shorter written forms of its month and weekday names either.
DateLanguage {
code: "ckb",
name: "کوردیی ناوەندی",
months: CKB_MONTHS,
months_with_day: None,
months_short: CKB_MONTHS,
days: CKB_DAYS,
days_short: CKB_DAYS,
},
DateLanguage {
code: "pl",
name: "Polski",
@@ -360,6 +377,17 @@ pub static DATE_LANGUAGES: [DateLanguage; 23] = [
days: ["sekmadienis", "pirmadienis", "antradienis", "trečiadienis", "ketvirtadienis", "penktadienis", "šeštadienis"],
days_short: ["sk", "pr", "an", "tr", "kt", "pn", "št"],
},
// Indonesian. Month and weekday names are the same with and after a day number, so
// `months_with_day` is `None`.
DateLanguage {
code: "id",
name: "Bahasa Indonesia",
months: ["Januari", "Februari", "Maret", "April", "Mei", "Juni", "Juli", "Agustus", "September", "Oktober", "November", "Desember"],
months_with_day: None,
months_short: ["Jan", "Feb", "Mar", "Apr", "Mei", "Jun", "Jul", "Agu", "Sep", "Okt", "Nov", "Des"],
days: ["Minggu", "Senin", "Selasa", "Rabu", "Kamis", "Jumat", "Sabtu"],
days_short: ["Min", "Sen", "Sel", "Rab", "Kam", "Jum", "Sab"],
},
// Vietnamese: numeric month forms (`tháng 1` with a day, `Tháng 1` standing alone).
// Fill & Sign still refuses any format whose final text fails the existing WinAnsi check
// (for example weekday names); `dd.mm.yyyy` remains writable.
@@ -398,6 +426,144 @@ pub static DATE_LANGUAGES: [DateLanguage; 23] = [
days: ["Chủ Nhật", "Thứ Hai", "Thứ Ba", "Thứ Tư", "Thứ Năm", "Thứ Sáu", "Thứ Bảy"],
days_short: ["CN", "T2", "T3", "T4", "T5", "T6", "T7"],
},
DateLanguage {
code: "fi",
name: "Suomi",
months: [
"tammikuu",
"helmikuu",
"maaliskuu",
"huhtikuu",
"toukokuu",
"kesäkuu",
"heinäkuu",
"elokuu",
"syyskuu",
"lokakuu",
"marraskuu",
"joulukuu",
],
// After a day number the month takes the partitive: `14. lokakuuta 2026`.
months_with_day: Some([
"tammikuuta",
"helmikuuta",
"maaliskuuta",
"huhtikuuta",
"toukokuuta",
"kesäkuuta",
"heinäkuuta",
"elokuuta",
"syyskuuta",
"lokakuuta",
"marraskuuta",
"joulukuuta",
]),
months_short: ["tammi", "helmi", "maalis", "huhti", "touko", "kesä", "heinä", "elo", "syys", "loka", "marras", "joulu"],
days: ["sunnuntai", "maanantai", "tiistai", "keskiviikko", "torstai", "perjantai", "lauantai"],
days_short: ["su", "ma", "ti", "ke", "to", "pe", "la"],
},
DateLanguage {
code: "sq",
name: "Shqip",
months: ["janar", "shkurt", "mars", "prill", "maj", "qershor", "korrik", "gusht", "shtator", "tetor", "nëntor", "dhjetor"],
months_with_day: None,
months_short: ["jan", "shk", "mar", "pri", "maj", "qer", "korr", "gush", "sht", "tet", "nën", "dhj"],
days: ["e diel", "e hënë", "e martë", "e mërkurë", "e enjte", "e premte", "e shtunë"],
days_short: ["die", "hën", "mar", "mër", "enj", "pre", "sht"],
},
DateLanguage {
code: "th",
name: "ไทย",
months: ["มกราคม", "กุมภาพันธ์", "มีนาคม", "เมษายน", "พฤษภาคม", "มิถุนายน", "กรกฎาคม", "สิงหาคม", "กันยายน", "ตุลาคม", "พฤศจิกายน", "ธันวาคม"],
months_with_day: None,
months_short: ["ม.ค.", "ก.พ.", "มี.ค.", "เม.ย.", "พ.ค.", "มิ.ย.", "ก.ค.", "ส.ค.", "ก.ย.", "ต.ค.", "พ.ย.", "ธ.ค."],
days: ["วันอาทิตย์", "วันจันทร์", "วันอังคาร", "วันพุธ", "วันพฤหัสบดี", "วันศุกร์", "วันเสาร์"],
days_short: ["อา.", "จ.", "อ.", "พ.", "พฤ.", "ศ.", "ส."],
},
DateLanguage {
code: "ca",
name: "Català",
months: ["gener", "febrer", "març", "abril", "maig", "juny", "juliol", "agost", "setembre", "octubre", "novembre", "desembre"],
// After a day the month carries its own preposition, elided before a vowel: `7 d'abril`.
months_with_day: Some([
"de gener",
"de febrer",
"de març",
"d'abril",
"de maig",
"de juny",
"de juliol",
"d'agost",
"de setembre",
"d'octubre",
"de novembre",
"de desembre",
]),
months_short: ["gen.", "febr.", "març", "abr.", "maig", "juny", "jul.", "ag.", "set.", "oct.", "nov.", "des."],
days: ["diumenge", "dilluns", "dimarts", "dimecres", "dijous", "divendres", "dissabte"],
days_short: ["dg.", "dl.", "dt.", "dx.", "dj.", "dv.", "ds."],
},
// Romanian: month and weekday names are lowercase, also standing alone (`noiembrie 2023`).
DateLanguage {
code: "ro",
name: "Română",
months: [
"ianuarie",
"februarie",
"martie",
"aprilie",
"mai",
"iunie",
"iulie",
"august",
"septembrie",
"octombrie",
"noiembrie",
"decembrie",
],
months_with_day: None,
months_short: ["ian.", "feb.", "mar.", "apr.", "mai", "iun.", "iul.", "aug.", "sept.", "oct.", "nov.", "dec."],
days: ["duminică", "luni", "marți", "miercuri", "joi", "vineri", "sâmbătă"],
days_short: ["dum.", "lun.", "mar.", "mie.", "joi", "vin.", "sâm."],
},
DateLanguage {
code: "lo",
name: "ພາສາລາວ",
months: ["ມັງກອນ", "ກຸມພາ", "ມີນາ", "ເມສາ", "ພຶດສະພາ", "ມິຖຸນາ", "ກໍລະກົດ", "ສິງຫາ", "ກັນຍາ", "ຕຸລາ", "ພະຈິກ", "ທັນວາ"],
months_with_day: None,
months_short: ["ມ.ກ.", "ກ.ພ.", "ມ.ນ.", "ມ.ສ.", "ພ.ພ.", "ມິ.ຖ.", "ກ.ລ.", "ສ.ຫ.", "ກ.ຍ.", "ຕ.ລ.", "ພ.ຈ.", "ທ.ວ."],
days: ["ວັນອາທິດ", "ວັນຈັນ", "ວັນອັງຄານ", "ວັນພຸດ", "ວັນພະຫັດ", "ວັນສຸກ", "ວັນເສົາ"],
days_short: ["ອາທິດ", "ຈັນ", "ອັງຄານ", "ພຸດ", "ພະຫັດ", "ສຸກ", "ເສົາ"],
},
// Hindi (Devanagari). Month and weekday names are the same with and after a day number.
DateLanguage {
code: "hi",
name: "हिन्दी",
months: ["जनवरी", "फ़रवरी", "मार्च", "अप्रैल", "मई", "जून", "जुलाई", "अगस्त", "सितंबर", "अक्तूबर", "नवंबर", "दिसंबर"],
months_with_day: None,
months_short: ["जन॰", "फ़र॰", "मार्च", "अप्रैल", "मई", "जून", "जुला॰", "अग॰", "सित॰", "अक्तू॰", "नव॰", "दिस॰"],
days: ["रविवार", "सोमवार", "मंगलवार", "बुधवार", "गुरुवार", "शुक्रवार", "शनिवार"],
days_short: ["रवि", "सोम", "मंगल", "बुध", "गुरु", "शुक्र", "शनि"],
},
// Korean: months are numbered (`3월`) and weekdays end in 요일; no names change after a day.
DateLanguage {
code: "ko",
name: "한국어",
months: ["1월", "2월", "3월", "4월", "5월", "6월", "7월", "8월", "9월", "10월", "11월", "12월"],
months_with_day: None,
months_short: ["1월", "2월", "3월", "4월", "5월", "6월", "7월", "8월", "9월", "10월", "11월", "12월"],
days: ["일요일", "월요일", "화요일", "수요일", "목요일", "금요일", "토요일"],
days_short: ["일", "월", "화", "수", "목", "금", "토"],
},
DateLanguage {
code: "nb",
name: "Norsk bokmål",
months: ["januar", "februar", "mars", "april", "mai", "juni", "juli", "august", "september", "oktober", "november", "desember"],
months_with_day: None,
months_short: ["jan.", "feb.", "mar.", "apr.", "mai", "jun.", "jul.", "aug.", "sep.", "okt.", "nov.", "des."],
days: ["søndag", "mandag", "tirsdag", "onsdag", "torsdag", "fredag", "lørdag"],
days_short: ["søn.", "man.", "tir.", "ons.", "tor.", "fre.", "lør."],
},
];
/// The date language for `code` (any case), or `None` when there's none.
@@ -604,6 +770,8 @@ mod tests {
"07.03.2026",
"2026-03-07",
"2026.03.07.",
"2026/03/07",
"2026/3/7",
"7 Mar 2026",
"7 March 2026",
"Mar 7, 2026",
@@ -701,6 +869,10 @@ mod tests {
assert_eq!(in_lang("yyyy\\年mmmmd\\日 dddd", "ja"), "2026年3月7日 土曜日");
assert_eq!(in_lang("yyyy\\年mmmmd\\日 dddd", "zh-hans"), "2026年三月7日 星期六");
assert_eq!(in_lang("ddd", "zh-hant"), "週六");
assert_eq!(in_lang("dddd, d mmmm yyyy", "sq"), "e shtunë, 7 mars 2026");
assert_eq!(in_lang("ddd d mmm", "sq"), "sht 7 mar");
assert_eq!(in_lang("dddd d. mmmm yyyy", "nb"), "lørdag 7. mars 2026");
assert_eq!(in_lang("ddd d. mmm", "nb"), "lør. 7. mar.");
assert_eq!(date_language("PT-BR").map(|l| l.code), Some("pt-br"));
assert!(date_language("xx").is_none());
for l in &DATE_LANGUAGES {
@@ -718,6 +890,46 @@ mod tests {
assert_eq!(in_lang("dddd, mmmm", "cs"), "sobota, březen");
}
/// Indonesian month and weekday names are the same with and after a day number. (Vietnamese
/// has its own test upstream: its numeric month forms are writable, weekday names are not.)
#[test]
fn indonesian_month_and_weekday_names() {
assert_eq!(in_lang("d mmmm yyyy", "id"), "7 Maret 2026");
assert_eq!(in_lang("mmmm yyyy", "id"), "Maret 2026");
assert_eq!(in_lang("dddd, d mmmm yyyy", "id"), "Sabtu, 7 Maret 2026");
// Indonesian month names are Latin, so they reach a PDF.
let mut s = Session::new().with_clock(|| 1_700_000_000);
s.set_date_language(Some("id")).unwrap();
assert_eq!(s.today_text_for_pdf(Some("d mmmm yyyy"), None).as_deref(), Ok("14 November 2023"));
}
/// Hindi and Korean names are the same with and after a day number. Neither is Western
/// European, so the date-text path refuses them with what to use instead.
#[test]
fn hindi_and_korean_month_and_weekday_names() {
assert_eq!(in_lang("d mmmm yyyy", "hi"), "7 मार्च 2026");
assert_eq!(in_lang("dddd, mmmm", "hi"), "शनिवार, मार्च");
assert_eq!(in_lang("ddd, d mmm", "hi"), "शनि, 7 मार्च");
assert_eq!(in_lang("yyyy\\년 mmmm d\\일 dddd", "ko"), "2026년 3월 7일 토요일");
assert_eq!(in_lang("ddd", "ko"), "토");
let mut s = Session::new().with_clock(|| 1_700_000_000);
for code in ["hi", "ko"] {
s.set_date_language(Some(code)).unwrap();
assert!(s.today_text_for_pdf(Some("d mmmm yyyy"), None).unwrap_err().contains("dd.mm.yyyy"), "{code}");
}
}
#[test]
fn catalan_months_elide_the_preposition_after_a_day() {
assert_eq!(in_lang("dddd d mmmm \\de yyyy", "ca"), "dissabte 7 de març de 2026");
assert_eq!(in_lang("mmmm yyyy", "ca"), "març 2026");
let ca = date_language("ca").unwrap();
assert_eq!(format_day("d mmmm \\de yyyy", (2026, 4, 23), ca), "23 d'abril de 2026");
assert_eq!(format_day("d mmmm", (2026, 8, 1), ca), "1 d'agost");
assert_eq!(format_day("d mmmm", (2026, 10, 8), ca), "8 d'octubre");
assert_eq!(format_day("mmmm", (2026, 10, 8), ca), "octubre", "no preposition without a day");
}
#[test]
fn the_session_writes_names_in_its_date_language() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
@@ -741,6 +953,23 @@ mod tests {
assert_eq!(s.date_language(), None);
}
#[test]
fn romanian_month_and_weekday_names() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
s.set_date_language(Some("ro")).unwrap();
assert_eq!(s.date_language(), Some("ro"));
s.set_date_format("d mmmm yyyy").unwrap();
assert_eq!(s.today_text(None, None).as_deref(), Ok("14 noiembrie 2023"));
s.set_date_format("mmmm yyyy").unwrap();
assert_eq!(s.today_text(None, None).as_deref(), Ok("noiembrie 2023"));
s.set_date_format("ddd, d mmm").unwrap();
assert_eq!(s.today_text(None, None).as_deref(), Ok("mar., 14 nov."));
// `14 noiembrie 2023` is WinAnsi; the weekday `marți` (ț, comma below) is not, so Fill & Sign refuses that format.
assert_eq!(s.today_text_for_pdf(Some("d mmmm yyyy"), Some("ro")).as_deref(), Ok("14 noiembrie 2023"));
let e = s.today_text_for_pdf(Some("dddd d mmmm yyyy"), Some("ro")).unwrap_err();
assert!(e.contains("can't be written into the PDF yet"), "{e}");
}
#[test]
fn vietnamese_month_and_weekday_names() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
@@ -765,6 +994,7 @@ mod tests {
assert_eq!(unwritable("8. října 2026"), "ř");
assert_eq!(unwritable("2026年10月8日"), "年月日");
assert_eq!(unwritable("sábado, 7 de março – 2026?"), "", "Western European, dashes and ? are fine");
assert_eq!(unwritable("lørdag 7. mars, søn. 8. mai (æøå ÆØÅ)"), "", "Norwegian letters are Western European");
let mut s = Session::new().with_clock(|| 1_700_000_000);
s.set_date_format("d. mmmm yyyy").unwrap();
assert_eq!(s.today_text_for_pdf(None, Some("es")).as_deref(), Ok("14. noviembre 2023"));
+42 -15
View File
@@ -147,15 +147,7 @@ pub fn encode_image(width: u32, height: u32, premultiplied: &[u8], format: Image
/// (4.46 → 4.47 MB), a text page at 220 dpi 70 → 40 ms (0.85 → 0.89 MB), and an annual
/// report page at 150 dpi 179 → 64 ms (1.41 → 1.38 MB).
pub fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Result<Vec<u8>, String> {
// Checked here, not left to the encoder: `as_chunks` below drops a partial trailing pixel,
// and the opaque path would write the rest as a valid image.
let expected = usize::try_from(width).ok().zip(usize::try_from(height).ok()).and_then(|(w, h)| w.checked_mul(h)?.checked_mul(4));
if expected != Some(premultiplied.len()) {
return Err(match expected {
Some(n) => format!("a {width}×{height} raster is {n} bytes of RGBA, got {}", premultiplied.len()),
None => format!("a {width}×{height} raster is too large"),
});
}
check_raster(width, height, premultiplied)?;
let pixels = premultiplied.as_chunks::<4>().0;
let opaque = pixels.iter().all(|px| px[3] == 255);
let (color, data) = if opaque {
@@ -213,6 +205,22 @@ impl ImageFormat {
}
}
/// Checks that `premultiplied` is exactly `width × height` RGBA pixels, and at least one,
/// before any encoder sees it: `as_chunks` drops a partial trailing pixel, so the rest would be
/// written as a valid image, and the JPEG and TIFF encoders panic on a buffer that doesn't fit
/// the size.
fn check_raster(width: u32, height: u32, premultiplied: &[u8]) -> Result<(), String> {
if width == 0 || height == 0 {
return Err(format!("a {width}×{height} raster has no pixels"));
}
let expected = usize::try_from(width).ok().zip(usize::try_from(height).ok()).and_then(|(w, h)| w.checked_mul(h)?.checked_mul(4));
match expected {
Some(n) if n == premultiplied.len() => Ok(()),
Some(n) => Err(format!("a {width}×{height} raster is {n} bytes of RGBA, got {}", premultiplied.len())),
None => Err(format!("a {width}×{height} raster is too large")),
}
}
/// Premultiplied RGBA composited over white → RGB.
fn over_white(premultiplied: &[u8]) -> Vec<u8> {
premultiplied
@@ -230,6 +238,7 @@ fn over_white(premultiplied: &[u8]) -> Vec<u8> {
pub fn encode_jpeg(width: u32, height: u32, premultiplied: &[u8], quality: u8, dpi: f64) -> Result<Vec<u8>, String> {
use image::ImageEncoder;
use image::codecs::jpeg::{JpegEncoder, PixelDensity};
check_raster(width, height, premultiplied)?;
let rgb = over_white(premultiplied);
let mut out = Vec::new();
let mut enc = JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100));
@@ -246,6 +255,7 @@ pub fn encode_tiff(width: u32, height: u32, premultiplied: &[u8], dpi: f64) -> R
use tiff::encoder::{Compression, Rational, TiffEncoder, colortype::RGB8};
use tiff::tags::{ResolutionUnit, Tag};
let err = |e: tiff::TiffError| e.to_string();
check_raster(width, height, premultiplied)?;
let rgb = over_white(premultiplied);
let mut out = std::io::Cursor::new(Vec::new());
let mut tiff = TiffEncoder::new(&mut out).map_err(err)?.with_compression(Compression::Lzw);
@@ -287,8 +297,10 @@ mod image_name_tests {
}
#[cfg(test)]
mod png_tests {
use super::encode_png;
mod raster_tests {
use super::{ImageFormat, encode_image, encode_png};
const FORMATS: [ImageFormat; 3] = [ImageFormat::Png, ImageFormat::Jpeg { quality: 90 }, ImageFormat::Tiff];
/// The colour type and pixels of a PNG.
fn decode(png: &[u8]) -> (png::ColorType, Vec<u8>) {
@@ -318,8 +330,9 @@ mod png_tests {
assert_eq!(pixels, [128, 0, 64, 128, 0, 0, 0, 0, 10, 20, 30, 255]);
}
/// A buffer that is not exactly `width × height` RGBA pixels is an error, opaque or not. The
/// opaque path used to drop a partial trailing pixel and write the rest as a valid image.
/// A buffer that is not exactly `width × height` RGBA pixels is an error in every format,
/// opaque or not. A partial trailing pixel used to be dropped and the rest written as a
/// valid image, and a buffer too short for its size panicked in the JPEG and TIFF encoders.
#[test]
fn rasters_of_the_wrong_length_are_rejected() {
let cases: [(&str, u32, u32, &[u8]); 7] = [
@@ -331,8 +344,22 @@ mod png_tests {
("empty", 1, 1, &[]),
("a size whose byte count overflows", u32::MAX, u32::MAX, &[10, 20, 30, 255]),
];
for (what, width, height, data) in cases {
assert!(encode_png(width, height, data).is_err(), "{what}");
for format in FORMATS {
for (what, width, height, data) in cases {
assert!(encode_image(width, height, data, format, 72.0).is_err(), "{}, {what}", format.label());
}
}
}
/// A raster of no pixels is the right length (no bytes), and refused before any encoder sees
/// it, in every format.
#[test]
fn rasters_of_no_pixels_are_an_error() {
for format in FORMATS {
for (width, height) in [(0, 0), (0, 3), (3, 0)] {
let refused = encode_image(width, height, &[], format, 72.0).err();
assert_eq!(refused, Some(format!("a {width}×{height} raster has no pixels")), "{}", format.label());
}
}
}
}
+131
View File
@@ -0,0 +1,131 @@
//! Prepare a form ▸ Text field table: one undoable edit adding a [`FieldTable`]'s cells as text
//! fields. The table is laid out and named in `pdfcraft_forms::table`; this maps its cells from
//! view space onto the page and refuses, before anything is added, what `add_field` would refuse.
pub use pdfcraft_forms::table::*;
use pdfcraft_forms::MIN_FIELD_SIDE;
use pdfcraft_render::PageInfo;
use crate::{Edit, FormField, NewField};
/// The edit's name in the history ("Undo Add text field table"), one of [`crate::BATCH_LABELS`].
pub const LABEL: &str = "Add text field table";
/// The edit adding `table`'s cells to `page` (0-based, shown as `info`) of a document whose
/// fields are `form`. Refused, with nothing added, when a cell would be too small on the page or
/// its name can't be used.
pub fn edit(table: &FieldTable, page: usize, info: &PageInfo, form: &[FormField]) -> Result<Edit, TableError> {
let cells = table.cells_for(form.iter().map(|f| f.name.as_str()))?;
// A page turned a quarter runs down the screen along user space's x.
let sideways = matches!(info.rotation, 90 | 270);
let mut edits = Vec::with_capacity(cells.len());
for cell in cells {
let [x0, y0, x1, y1] = cell.rect;
let rect = info.view_rect_to_user([x0 as f32, y0 as f32, x1 as f32, y1 as f32]).map(f64::from);
// A corner past `f32`'s range comes back infinite.
if !rect.iter().all(|v| v.is_finite()) {
return Err(TableError::Numbers);
}
// `add_field`'s own test, after the page's scale (`/UserUnit`) and rounding to `f32`.
let (across, down) = if sideways { (rect[3] - rect[1], rect[2] - rect[0]) } else { (rect[2] - rect[0], rect[3] - rect[1]) };
if down < MIN_FIELD_SIDE {
return Err(TableError::RowsTooShort);
}
if across < MIN_FIELD_SIDE {
return Err(TableError::ColumnTooNarrow);
}
edits.push(Edit::AddField { page, rect, kind: NewField::Text { multiline: false }, name: Some(cell.name) });
}
Ok(Edit::Batch { label: LABEL.to_owned(), edits })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Session;
fn page(width: f32, height: f32, crop: [f32; 4], rotation: u16) -> PageInfo {
PageInfo { width, height, label: "1".into(), crop, rotation }
}
fn grid(rect: [f64; 4], rows: usize, patterns: &[&str]) -> FieldTable {
let mut t = FieldTable::new(rect, "unused");
t.rows = rows;
t.columns = patterns.iter().map(|p| TableColumn { pattern: (*p).to_owned(), width: 1.0 }).collect();
t
}
#[test]
fn a_table_is_added_and_undone_as_one_step() {
let mut s = Session::new();
let bytes = s.create_blank(300.0, 400.0, 1).unwrap();
let id = s.open("t.pdf", None, bytes, None).unwrap();
// 200 × 100 points from (50, 40) down the page as displayed: two rows of two.
let table = grid([50.0, 40.0, 250.0, 140.0], 2, &["desc_N", "price_N"]);
let add = |s: &Session| {
let doc = s.get(id).unwrap();
edit(&table, 0, &doc.info.pages[0], &doc.form)
};
s.apply(id, add(&s).unwrap()).unwrap();
let doc = s.get(id).unwrap();
let rect = |name: &str| doc.form.iter().find(|f| f.name == name).map(|f| f.widgets[0].rect);
// User space has y up: the first row is the higher one.
assert_eq!(rect("desc_1"), Some([50.0, 310.0, 150.0, 360.0]));
assert_eq!(rect("price_1"), Some([150.0, 310.0, 250.0, 360.0]));
assert_eq!(rect("desc_2"), Some([50.0, 260.0, 150.0, 310.0]));
assert_eq!(rect("price_2"), Some([150.0, 260.0, 250.0, 310.0]));
// Laid out again over the same names, it is refused before anything is added.
assert_eq!(add(&s).err(), Some(TableError::Taken("desc_1".into())));
assert_eq!(s.undo(id).unwrap().to_string(), LABEL);
assert!(s.get(id).unwrap().form.is_empty(), "one undo takes the whole table back");
}
#[test]
fn rows_run_down_a_turned_page_as_it_is_shown() {
let mut s = Session::new();
let bytes = s.create_blank(300.0, 400.0, 1).unwrap();
let id = s.open("t.pdf", None, bytes, None).unwrap();
s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
let doc = s.get(id).unwrap();
let info = doc.info.pages[0].clone();
assert_eq!((info.rotation, info.width, info.height), (90, 400.0, 300.0));
let table = grid([40.0, 30.0, 240.0, 130.0], 2, &["a_N", "b_N"]);
s.apply(id, edit(&table, 0, &info, &doc.form).unwrap()).unwrap();
// Each field, taken back to the page as shown, is where its cell was laid out.
let doc = s.get(id).unwrap();
for cell in table.cells().unwrap() {
let r = doc.form.iter().find(|f| f.name == cell.name).unwrap().widgets[0].rect;
let (a, b) = (info.user_to_view(r[0] as f32, r[1] as f32), info.user_to_view(r[2] as f32, r[3] as f32));
let shown = [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])].map(f64::from);
assert!(shown.iter().zip(cell.rect).all(|(s, c)| (s - c).abs() < 1e-3), "{}: {shown:?} vs {:?}", cell.name, cell.rect);
}
}
#[test]
fn a_cell_too_small_on_the_page_is_refused_along_the_right_side() {
// Half a point of user space for each point shown across, a whole one down: a quarter
// turn puts "across" on user space's y.
let info = page(200.0, 100.0, [0.0, 0.0, 100.0, 100.0], 90);
let narrow = grid([0.0, 0.0, 12.0, 12.0], 2, &["a_N", "b_N"]);
assert!(narrow.cells().is_ok(), "6 × 6 shown");
assert_eq!(edit(&narrow, 0, &info, &[]).err(), Some(TableError::ColumnTooNarrow));
let unturned = page(100.0, 200.0, [0.0, 0.0, 100.0, 100.0], 0);
assert_eq!(edit(&narrow, 0, &unturned, &[]).err(), Some(TableError::RowsTooShort));
let roomy = page(100.0, 100.0, [0.0, 0.0, 100.0, 100.0], 90);
assert!(edit(&narrow, 0, &roomy, &[]).is_ok());
}
/// A corner finite as an `f64` but past `f32`'s range lays out, then comes back from the page's
/// `f32` mapping infinite: one such corner leaves a side infinite, not NaN, and the cell used to
/// pass, for `add_field` to refuse it once the edit was applied.
#[test]
fn a_table_past_the_pages_number_range_is_refused_before_anything_is_added() {
let huge = grid([0.0, 0.0, 1e39, 100.0], 2, &["a_N"]);
assert!(huge.cells().is_ok(), "finite as laid out");
for rotation in [0, 90] {
let info = page(300.0, 400.0, [0.0, 0.0, 300.0, 400.0], rotation);
assert_eq!(edit(&huge, 0, &info, &[]).err(), Some(TableError::Numbers), "/Rotate {rotation}");
}
}
}
+95 -15
View File
@@ -70,11 +70,15 @@ fn display(doc: &pdfcraft_cos::Document, f: &Field) -> i32 {
/// A form field as scripts see it.
pub fn field_state(doc: &pdfcraft_cos::Document, f: &Field) -> pdfcraft_js::FieldState {
field_state_with_display(f, display(doc, f))
}
fn field_state_with_display(f: &Field, display: i32) -> pdfcraft_js::FieldState {
let mut s = pdfcraft_js::FieldState::new(f.name.clone(), kind(f.kind), f.value.clone());
s.default = f.default.clone();
s.readonly = f.read_only();
s.required = f.has(pdfcraft_forms::flags::REQUIRED);
s.display = display(doc, f);
s.display = display;
s.char_limit = f.max_len;
s.options = match f.kind {
FieldKind::CheckBox | FieldKind::Radio => f.widgets.iter().filter_map(|w| w.on_state.clone()).map(|o| (o.clone(), o)).collect(),
@@ -85,10 +89,19 @@ pub fn field_state(doc: &pdfcraft_cos::Document, f: &Field) -> pdfcraft_js::Fiel
/// The changes a script made, against the fields as they were.
fn changes(before: &[pdfcraft_js::FieldState], after: &[pdfcraft_js::FieldState]) -> Vec<FieldChange> {
if after.is_empty() {
return Vec::new();
}
// Outcomes contain only fields touched by the script. Index the original snapshot once,
// keeping the first match as the old linear lookup did for malformed duplicate names.
let mut by_name = std::collections::HashMap::with_capacity(before.len());
for field in before {
by_name.entry(field.name.as_str()).or_insert(field);
}
after
.iter()
.filter_map(|a| {
let b = before.iter().find(|b| b.name == a.name)?;
let b = by_name.get(a.name.as_str())?;
let c = FieldChange {
name: a.name.clone(),
value: (a.value != b.value).then(|| a.value.clone()),
@@ -106,14 +119,25 @@ pub struct JsRunner {
pub doc: pdfcraft_js::DocInfo,
pub doc_scripts: Vec<String>,
pub output: JsOutput,
/// The fields in the document as the forms code last read them (for `display`).
/// Immutable document snapshot from the start of the edit (for widget `display`).
cos: pdfcraft_cos::Document,
// `cos` is the same immutable pre-edit snapshot for the runner's lifetime. Cache only
// its scalar display readings, not COS objects: large forms exceed the object cache and
// otherwise reparse every widget for every calculation. Values/flags still come from
// the live `fields` snapshot on each call. A new edit gets a new runner and cache.
displays: std::collections::HashMap<pdfcraft_cos::ObjRef, i32>,
}
impl JsRunner {
pub fn new(cos: &pdfcraft_cos::Document, file_name: &str) -> JsRunner {
let doc = pdfcraft_js::DocInfo { file_name: file_name.to_string(), num_pages: pdfcraft_model::pages(cos).len(), page: 0, info: info(cos) };
JsRunner { doc, doc_scripts: pdfcraft_forms::document_scripts(cos), output: JsOutput::default(), cos: cos.clone() }
JsRunner {
doc,
doc_scripts: pdfcraft_forms::document_scripts(cos),
output: JsOutput::default(),
cos: cos.clone(),
displays: std::collections::HashMap::new(),
}
}
}
@@ -130,19 +154,28 @@ fn info(cos: &pdfcraft_cos::Document) -> Vec<(String, String)> {
impl Scripts for JsRunner {
fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field]) -> ScriptResult {
let states: Vec<_> = fields.iter().map(|f| field_state(&self.cos, f)).collect();
let states: Vec<_> = fields
.iter()
.map(|f| {
let display = match f.widgets.first() {
Some(w) => *self.displays.entry(w.obj).or_insert_with(|| display(&self.cos, f)),
None => pdfcraft_js::DISPLAY_VISIBLE,
};
field_state_with_display(f, display)
})
.collect();
let states = std::sync::Arc::new(states);
let ev = pdfcraft_js::Event::field(event.name(), &target.name, value);
let o = pdfcraft_js::run(script, &ev, &self.doc, &states, &self.doc_scripts, Limits::default());
let o = pdfcraft_js::run_shared(script, &ev, &self.doc, std::sync::Arc::clone(&states), &self.doc_scripts, Limits::default());
self.output.absorb(&o);
if o.error.is_some() {
// As in Acrobat, a failing script leaves the value alone (the error goes to the console).
return ScriptResult { rc: true, value: value.to_string(), changes: Vec::new(), message: None };
}
let after: Vec<_> = states.iter().map(|s| o.changed.iter().find(|c| c.name == s.name).unwrap_or(s).clone()).collect();
ScriptResult {
rc: o.rc,
value: o.value.clone(),
changes: changes(&states, &after),
changes: changes(&states, &o.changed),
message: (!o.rc).then(|| o.alerts.last().cloned()).flatten(),
}
}
@@ -192,7 +225,8 @@ impl Session {
// A button PdfCraft generated from an XFA template runs its XFA click script.
if let Some(t) = target
&& let Some(tpl) = doc.xfa_template.clone()
&& let Some(som) = crate::xfa::clickable_som(&cos, &tpl, t)
&& let Some(field) = doc.form.iter().find(|f| f.name == t)
&& let Some(som) = crate::xfa::clickable_som(&cos, &tpl, field)
{
self.apply(id, Edit::XfaEvent { som, activity: "click".into() })?;
let out = self.take_js_output(id);
@@ -213,8 +247,7 @@ impl Session {
None => pdfcraft_js::Event::doc("Console"),
};
let mut o = pdfcraft_js::run(script, &event, &runner.doc, &states, &runner.doc_scripts, Limits::default());
let after: Vec<_> = states.iter().map(|s| o.changed.iter().find(|c| c.name == s.name).unwrap_or(s).clone()).collect();
let changes = changes(&states, &after);
let changes = changes(&states, &o.changed);
let resets: Vec<Vec<String>> = o
.requests
.iter()
@@ -349,7 +382,12 @@ impl Session {
pdfcraft_forms::detect::Kind::CheckBox => (pdfcraft_forms::NewField::CheckBox, None),
pdfcraft_forms::detect::Kind::Comb { cells } => (pdfcraft_forms::NewField::Text { multiline: false }, Some(cells)),
};
let mut props = FieldProps::default();
// The page already draws the line or box the field sits on, so the field gets no
// border or fill of its own: an opaque fill would hide the printed form (#294, #2831).
let mut props = FieldProps {
appearance: Some(pdfcraft_forms::LookPatch { border: Some(None), fill: Some(None), ..Default::default() }),
..Default::default()
};
if turn != 0 {
// A turned field keeps its place: the rectangle is already the one on the page.
props.rotation = Some((0, turn));
@@ -360,11 +398,53 @@ impl Session {
props.flags = vec![(pdfcraft_forms::flags::COMB, true)];
}
edits.push(Edit::AddField { page, rect: c.rect, kind, name: Some(c.name.clone()) });
if turn != 0 || comb.is_some() {
edits.push(Edit::SetFieldProps { name: c.name, props: Box::new(props) });
}
edits.push(Edit::SetFieldProps { name: c.name, props: Box::new(props) });
}
self.apply(id, Edit::Batch { label: "Detect form fields".into(), edits })?;
Ok(names)
}
}
#[cfg(test)]
mod review_tests {
use super::*;
#[test]
fn duplicate_field_names_diff_against_first_original() {
let first = pdfcraft_js::FieldState::new("duplicate", pdfcraft_js::FieldType::Text, vec!["first".into()]);
let mut second = first.clone();
second.value = vec!["second".into()];
second.display = pdfcraft_js::DISPLAY_HIDDEN;
let before = vec![first.clone(), second.clone()];
assert!(changes(&before, std::slice::from_ref(&first)).is_empty());
let actual = changes(&before, std::slice::from_ref(&second));
assert_eq!(
actual,
vec![FieldChange {
name: "duplicate".into(),
value: Some(second.value),
display: Some(pdfcraft_js::DISPLAY_HIDDEN),
..Default::default()
}]
);
let outcome = pdfcraft_js::run_shared(
"event.value = getField('duplicate').value; getField('duplicate').value = 'updated';",
&pdfcraft_js::Event::field("Calculate", "duplicate", ""),
&pdfcraft_js::DocInfo::default(),
std::sync::Arc::new(before.clone()),
&[],
Limits::default(),
);
assert_eq!(outcome.error, None);
assert_eq!(outcome.value, "first");
assert_eq!(outcome.changed.len(), 1);
assert_eq!(
changes(&before, &outcome.changed),
vec![FieldChange { name: "duplicate".into(), value: Some(vec!["updated".into()]), ..Default::default() }]
);
assert!(changes(&before, &[]).is_empty());
let mut missing = first;
missing.name = "missing".into();
assert!(changes(&before, &[missing]).is_empty());
}
}
+215 -25
View File
@@ -24,6 +24,7 @@ pub mod compare;
mod copy_image;
pub mod dates;
pub mod export;
pub mod field_table;
pub mod js;
pub mod links;
pub mod ocr;
@@ -34,6 +35,7 @@ mod scan_colours;
mod scan_fields;
pub mod signature_image;
pub mod signature_scan;
mod stamp_preview;
pub mod text_fonts;
mod text_preview;
pub mod word_count;
@@ -41,12 +43,14 @@ pub mod xfa;
pub use signature_image::{ImageSignaturePreview, SignatureImage};
pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn};
pub use stamp_preview::StampPreview;
pub use text_preview::TextEditPreview;
pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, Transition, TransitionSpec, split_ranges};
/// One file produced by a split: (1-based first page, last page, PDF bytes).
pub use pdfcraft_organize::LabelStyle;
pub use pdfcraft_organize::Standard;
pub use pdfcraft_organize::view::{InitialView, Layout as InitialLayout, Magnification, Navigation};
pub use pdfcraft_cos::Algorithm;
@@ -56,14 +60,14 @@ pub use pdfcraft_edit::{
Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark,
};
pub use pdfcraft_forms::{
BorderStyle, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont, FieldKind as FormFieldKind, FieldProps, FieldValue,
Look as FieldLook, LookPatch as FieldLookPatch, NewField, TabOrder, Trigger as FieldTrigger, Widget as FormWidget, af as form_scripts,
appearance as form_appearance, appearance::auto_text_size_by as field_auto_text_size_by, calculations_changed as form_calculations_changed,
flags as field_flags,
BorderStyle, CalculatedField, CalculationIssue, CalculationOrder, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont,
FieldKind as FormFieldKind, FieldProps, FieldValue, Look as FieldLook, LookPatch as FieldLookPatch, NewField, TabOrder, Trigger as FieldTrigger,
Widget as FormWidget, af as form_scripts, appearance as form_appearance, appearance::auto_text_size_by as field_auto_text_size_by,
calculations_changed as form_calculations_changed, flags as field_flags,
};
pub use pdfcraft_a11y as a11y;
pub use pdfcraft_edit::{BlockStyle, PageImage, RangeStyle, TextBlock, TextLine, first_undrawable};
pub use pdfcraft_edit::{BlockKey, BlockStyle, PageImage, RangeStyle, TextBlock, TextLine, block_overlapping, first_undrawable};
pub use pdfcraft_measure as measure;
pub use pdfcraft_xfa::Report as XfaLayout;
@@ -76,6 +80,8 @@ pub enum ImageEdit {
Move([f64; 4]),
/// Quarter turns clockwise about its centre.
Rotate(i32),
/// Any angle in degrees clockwise about its centre (the unit is degrees, not quarter turns).
RotateBy(f64),
Flip {
horizontal: bool,
},
@@ -171,6 +177,23 @@ use std::sync::{Arc, OnceLock};
#[cfg(test)]
thread_local! {
static SECONDARY_SCAN_COUNTS: std::cell::Cell<(usize, usize)> = const { std::cell::Cell::new((0, 0)) };
static REFRESH_PANICS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
#[cfg(test)]
fn panic_on_refresh(count: usize) {
REFRESH_PANICS.with(|n| n.set(count));
}
#[cfg(test)]
fn check_refresh_panic() {
REFRESH_PANICS.with(|n| {
let rem = n.get();
if rem > 0 {
n.set(rem - 1);
panic!("injected refresh panic");
}
});
}
#[cfg(test)]
@@ -240,7 +263,7 @@ enum Scope {
/// Edits during which field scripts run.
fn uses_scripts(edit: &Edit) -> bool {
match edit {
Edit::SetFieldValue { .. } | Edit::ApplyScriptChanges { .. } | Edit::SetFieldScript { .. } => true,
Edit::SetFieldValue { .. } | Edit::ApplyScriptChanges { .. } | Edit::SetFieldScript { .. } | Edit::SetCalculationOrder { .. } => true,
Edit::Batch { edits, .. } => edits.iter().any(uses_scripts),
_ => false,
}
@@ -263,6 +286,7 @@ fn scope_of(edit: &Edit) -> Scope {
| Edit::ReplaceText { .. }
| Edit::EraseInk { .. }
| Edit::MoveAnnotation { .. }
| Edit::ReshapeAnnotation { .. }
| Edit::ResizeAnnotation { .. }
| Edit::StyleAnnotation { .. }
| Edit::FillAnnotation { .. }
@@ -317,6 +341,10 @@ struct Editor {
/// runs (content edits leave every annotation dictionary alone). Cleared on any refresh
/// that may change annotations; see [`AppearanceCache`].
appearances: AppearanceCache,
/// Display appearances drawn for form fields the file leaves to the viewer (#3470), reused
/// for each widget while the objects it was drawn from are unchanged; see
/// [`pdfcraft_forms::FieldAppearanceCache`].
field_appearances: pdfcraft_forms::FieldAppearanceCache,
/// The text blocks last computed for one page, reused while the document is unchanged.
text_blocks: TextBlocksCache,
}
@@ -585,6 +613,12 @@ impl Document {
self.editor.as_ref().and_then(|e| pdfcraft_edit::text_lines(&e.cos, page).ok()).unwrap_or_default()
}
/// The form's calculation order: its calculated fields in the order they calculate, what each
/// formula reads, what is wrong with the order, and the order Fix order writes.
pub fn calculation_order(&self) -> CalculationOrder {
self.editor.as_ref().map(|e| pdfcraft_forms::calculation_order(&e.cos, &self.form)).unwrap_or_default()
}
/// Add alternate text: the figures, in document order.
pub fn figures(&self) -> Vec<a11y::Figure> {
self.editor.as_ref().map(|e| a11y::figures(&e.cos)).unwrap_or_default()
@@ -877,23 +911,31 @@ fn is_placeholder_title(title: &str) -> bool {
address || t == "untitled"
}
/// The working file plus appearances for comments that have none. Signature artwork
/// is preserved; validation badges belong to the viewer and never enter these bytes,
/// which image/text exports and Print as Image also consume.
/// The working file plus appearances for comments that have none, and for form fields the file
/// leaves to the viewer (#3470). Signature artwork is preserved; validation badges belong to the
/// viewer and never enter these bytes, which image/text exports and Print as Image also consume.
fn display_bytes(editor: Option<&mut Editor>, bytes: &Arc<Vec<u8>>) -> Arc<Vec<u8>> {
let Some(e) = editor else { return bytes.clone() };
let Some(display) = pdfcraft_annot::with_missing_appearances_cached(&e.cos, &mut e.appearances) else {
let comments = pdfcraft_annot::with_missing_appearances_cached(&e.cos, &mut e.appearances);
let fields = pdfcraft_forms::with_field_appearances_cached(comments.as_ref().unwrap_or(&e.cos), &mut e.field_appearances);
let Some(display) = fields.or(comments) else {
return bytes.clone();
};
match write_incremental(&display, &SaveOptions::default()) {
Ok(written) => Arc::new(written),
Err(err) => {
e.cos.note_repair(format!("missing comment appearances could not be drawn ({err})"));
e.cos.note_repair(format!("missing comment or field appearances could not be drawn ({err})"));
bytes.clone()
}
}
}
/// `comments` (the display copy with comment appearances, if it needed any; else the document
/// itself) with its form fields drawn from their values where the file leaves that to the viewer.
fn with_field_appearances(comments: Option<pdfcraft_cos::Document>, doc: &pdfcraft_cos::Document) -> Option<pdfcraft_cos::Document> {
pdfcraft_forms::with_field_appearances(comments.as_ref().unwrap_or(doc)).or(comments)
}
/// The reader shows the same pages in `a` and `b`: count, displayed size and rotation.
fn same_pages(a: &[pdfcraft_render::PageInfo], b: &[pdfcraft_render::PageInfo]) -> bool {
a.len() == b.len()
@@ -901,14 +943,14 @@ fn same_pages(a: &[pdfcraft_render::PageInfo], b: &[pdfcraft_render::PageInfo])
}
/// The copy PDFCraft displays when opening `bytes`: with appearances drawn, in memory only, for
/// comments that have none. Render paths outside the app, such as the CLI, use this so their page
/// comments that have none and form fields the file leaves to the viewer. Render paths outside the app, such as the CLI, use this so their page
/// images agree with the app and independent viewers. It never changes the source document, and an
/// unparseable file, a write failure or a parser panic falls back to the original bytes.
pub fn display_bytes_for(bytes: Arc<Vec<u8>>, password: Option<&str>) -> Arc<Vec<u8>> {
guard(|| {
pdfcraft_cos::Document::open_with_password(bytes.clone(), password)
.ok()
.and_then(|cos| pdfcraft_annot::with_missing_appearances(&cos))
.and_then(|cos| with_field_appearances(pdfcraft_annot::with_missing_appearances(&cos), &cos))
.and_then(|display| write_incremental(&display, &SaveOptions::default()).ok())
.map(Arc::new)
})
@@ -917,6 +959,24 @@ pub fn display_bytes_for(bytes: Arc<Vec<u8>>, password: Option<&str>) -> Arc<Vec
.unwrap_or(bytes)
}
/// The password to give the renderer (`inspect`, `PageRenderer`) for `bytes` opened with
/// `password`. The renderer authenticates only the user password of R2–R4 files, so for their
/// owner password this is the user password that owner authentication recovers; otherwise it is
/// `password` itself. Render paths outside a [`Session`], such as the CLI, use this so the owner
/// password works there as it does in `doc_open`. A file that doesn't open with `password` keeps
/// it, so the renderer reports the same error it always did.
pub fn render_password_for(bytes: Arc<Vec<u8>>, password: Option<&str>) -> Option<String> {
let password = password?;
let recovered =
guard(|| pdfcraft_cos::Document::open_with_password(bytes, Some(password)).ok().and_then(|d| recovered_render_password(&d))).ok().flatten();
Some(recovered.unwrap_or_else(|| password.to_owned()))
}
/// The user password owner authentication recovered from an R2–R4 `cos`, as the renderer takes it.
fn recovered_render_password(cos: &pdfcraft_cos::Document) -> Option<String> {
cos.security().and_then(|s| s.recovered_user_password()).map(|user| renderer_password(&user))
}
/// Fails when the renderer still draws text under an applied redaction mark.
///
/// Redaction decides what lies under a mark from the positions its own interpreter computes, and
@@ -1322,11 +1382,16 @@ pub enum Edit {
field_conflicts: pdfcraft_organize::FieldConflicts,
},
/// Add a bookmark to `page` as child `index` of the bookmark at `parent` (`[]` = top level).
/// It goes to the user-space point (`left`, `top`) of the page at the reader's zoom, and
/// either may be `None` to keep the reader's position that way (both `None` is the page top).
/// A coordinate that is not finite fails the edit.
AddBookmark {
parent: Vec<usize>,
index: usize,
title: String,
page: usize,
left: Option<f64>,
top: Option<f64>,
},
RenameBookmark {
path: Vec<usize>,
@@ -1436,6 +1501,13 @@ pub enum Edit {
dx: f64,
dy: f64,
},
/// Reshape a line, connected lines, a polygon or a cloud: its points in user space (a line's
/// two ends, the others' corners).
ReshapeAnnotation {
page: usize,
index: usize,
points: Vec<[f64; 2]>,
},
/// Resize a rectangle, oval or text box.
ResizeAnnotation {
page: usize,
@@ -1561,6 +1633,10 @@ pub enum Edit {
ConvertPdfA {
level: pdfcraft_preflight::Level,
},
/// Remove what identifies the document as meeting `standard` (PDF/A or PDF/X).
RemoveStandardId {
standard: Standard,
},
/// What a script (button or console) changed in form fields: values, read-only, required
/// and visibility.
ApplyScriptChanges {
@@ -1617,6 +1693,11 @@ pub enum Edit {
name: String,
earlier: bool,
},
/// Set the calculation order: these calculated fields first, in this order, then the others
/// in their current order (see [`Document::calculation_order`]). The form recalculates.
SetCalculationOrder {
names: Vec<String>,
},
/// Add a header and footer (with `replace`, existing ones on those pages go first).
AddHeaderFooter {
pages: Vec<usize>,
@@ -1876,6 +1957,7 @@ pub const EDIT_LABELS: &[&str] = &[
"Lock comment",
"Unlock comment",
"Move comment",
"Reshape comment",
"Resize comment",
"Change comment properties",
"Fill in {name}",
@@ -1886,6 +1968,7 @@ pub const EDIT_LABELS: &[&str] = &[
"Delete field",
"Duplicate field",
"Set tab order",
"Set calculation order",
"Change initial view",
"Set alternate text",
"Mark figure as decorative",
@@ -1893,6 +1976,7 @@ pub const EDIT_LABELS: &[&str] = &[
"Run JavaScript",
"Run form script",
"Save as {level}",
"Remove {standard} identification",
"Edit script of {name}",
"Delete document JavaScript",
"Edit document JavaScript",
@@ -1940,7 +2024,8 @@ pub const EDIT_LABELS: &[&str] = &[
/// The names of the batches built below L7, which an interface shows in the same places as
/// [`EDIT_LABELS`] and so translates the same way. A batch built in the interface itself arrives
/// already worded in the interface's language.
pub const BATCH_LABELS: &[&str] = &["Set document title", "Mark differences", "Detect form fields", "Run JavaScript", "Recognize text"];
pub const BATCH_LABELS: &[&str] =
&["Set document title", "Mark differences", "Detect form fields", "Run JavaScript", "Recognize text", field_table::LABEL];
impl Edit {
/// Label for the Edit menu and history ("Undo Rotate pages").
@@ -2000,6 +2085,7 @@ impl Edit {
Edit::LockAnnotation { locked: true, .. } => Label::of("Lock comment"),
Edit::LockAnnotation { .. } => Label::of("Unlock comment"),
Edit::MoveAnnotation { .. } => Label::of("Move comment"),
Edit::ReshapeAnnotation { .. } => Label::of("Reshape comment"),
Edit::ResizeAnnotation { .. } => Label::of("Resize comment"),
Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } | Edit::FontSizeAnnotation { .. } | Edit::SetAnnotationInfo { .. } => {
Label::of("Change comment properties")
@@ -2012,6 +2098,7 @@ impl Edit {
Edit::DeleteField { .. } => Label::of("Delete field"),
Edit::DuplicateField { .. } => Label::of("Duplicate field"),
Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } => Label::of("Set tab order"),
Edit::SetCalculationOrder { .. } => Label::of("Set calculation order"),
Edit::SetInitialView(_) => Label::of("Change initial view"),
Edit::AutoTagText => Label::of("Automatically tag text"),
Edit::SetAltText { .. } => Label::of("Set alternate text"),
@@ -2025,6 +2112,7 @@ impl Edit {
Edit::XfaEvent { .. } => Label::of("Run form script"),
// The level is the standard's own name ("PDF/A-2b"), so it goes in as it stands.
Edit::ConvertPdfA { level } => Label::with("Save as {level}", level.label()),
Edit::RemoveStandardId { standard } => Label::with("Remove {standard} identification", standard.label()),
Edit::SetFieldScript { name, .. } => Label::with("Edit script of {name}", name.as_str()),
Edit::SetDocumentScript { script: None, .. } => Label::of("Delete document JavaScript"),
Edit::SetDocumentScript { .. } => Label::of("Edit document JavaScript"),
@@ -2033,7 +2121,7 @@ impl Edit {
Edit::MoveObjects { objects, .. } => plural("Move object", "Move objects", objects.len()),
Edit::EditPageImage { change, .. } => Label::of(match change {
ImageEdit::Move(_) => "Move image",
ImageEdit::Rotate(_) => "Rotate image",
ImageEdit::Rotate(_) | ImageEdit::RotateBy(_) => "Rotate image",
ImageEdit::Crop(_) => "Crop image",
ImageEdit::Flip { .. } => "Flip image",
ImageEdit::Replace { .. } => "Replace image",
@@ -2155,6 +2243,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed
| Edit::EraseInk { .. }
| Edit::LockAnnotation { .. }
| Edit::MoveAnnotation { .. }
| Edit::ReshapeAnnotation { .. }
| Edit::ResizeAnnotation { .. }
| Edit::StyleAnnotation { .. }
| Edit::FillAnnotation { .. }
@@ -2209,6 +2298,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed
| Edit::DuplicateField { .. }
| Edit::SetTabOrder { .. }
| Edit::MoveInTabOrder { .. }
| Edit::SetCalculationOrder { .. }
| Edit::SetInitialView(_)
| Edit::AutoTagText
| Edit::SetAltText { .. }
@@ -2221,6 +2311,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed
| Edit::SetDocumentScript { .. }
| Edit::SetFieldScript { .. }
| Edit::ConvertPdfA { .. }
| Edit::RemoveStandardId { .. }
| Edit::EditTextLine { .. }
| Edit::EditTextBlock { .. }
| Edit::RegisterTextFont { .. }
@@ -2356,8 +2447,8 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
let outcome = pdfcraft_organize::insert_file_pages(doc, &src, &title, &pages, *at, *field_conflicts)?;
cx.renamed_fields = outcome.renamed_fields;
}
Edit::AddBookmark { parent, index, title, page } => {
pdfcraft_organize::add_bookmark(doc, parent, *index, title, *page)?;
Edit::AddBookmark { parent, index, title, page, left, top } => {
pdfcraft_organize::add_bookmark_at(doc, parent, *index, title, *page, *left, *top)?;
}
Edit::RenameBookmark { path, title } => pdfcraft_organize::rename_bookmark(doc, path, title)?,
Edit::DeleteBookmark { path } => pdfcraft_organize::delete_bookmark(doc, path)?,
@@ -2420,6 +2511,7 @@ 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::ReshapeAnnotation { page, index, points } => pdfcraft_annot::set_points(doc, *page, *index, points, &cx.meta())?,
Edit::ResizeAnnotation { page, index, rect } => pdfcraft_annot::set_rect(doc, *page, *index, *rect, &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())?;
@@ -2460,7 +2552,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
&& pdfcraft_forms::fields(doc).iter().any(|f| f.name == *name && f.button == Some(pdfcraft_forms::af::ButtonAction::ImportIcon));
if image_field {
let Some(content_type) = image_content_type(image) else {
return Err(EditError::Invalid(format!("{name}: an XFA form keeps a PNG, JPEG or GIF picture; choose one of those")));
return Err(EditError::Invalid(format!("{name}: an XFA form keeps a PNG, JPEG, GIF, BMP or TIFF picture; choose one of those")));
};
pdfcraft_forms::set_button_icon(doc, name, img, px)?;
let som = pdfcraft_xfa::fields_by_som(doc).into_iter().find(|(_, n)| n == name).map(|(s, _)| s).unwrap_or_else(|| name.clone());
@@ -2497,6 +2589,14 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
Edit::DeleteField { name } => pdfcraft_forms::delete_field(doc, name)?,
Edit::SetTabOrder { pages, order } => pdfcraft_forms::set_tab_order(doc, pages, *order)?,
Edit::MoveInTabOrder { name, earlier } => pdfcraft_forms::move_in_tab_order(doc, name, *earlier)?,
// As after a script edit: the values the new order gives show at once.
Edit::SetCalculationOrder { names } => {
pdfcraft_forms::set_calculation_order(doc, names)?;
match cx.js.as_mut() {
Some(js) => pdfcraft_forms::recalculate_with(doc, js)?,
None => pdfcraft_forms::recalculate(doc)?,
};
}
Edit::SetInitialView(v) => pdfcraft_organize::set_initial_view(doc, v)?,
Edit::AutoTagText => {
a11y::autotag_text(doc).map_err(|e| EditError::Accessibility(e.to_string()))?;
@@ -2527,6 +2627,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
Edit::ConvertPdfA { level } => {
pdfcraft_preflight::convert(doc, *level).map_err(|e| EditError::Invalid(e.to_string()))?;
}
Edit::RemoveStandardId { standard } => pdfcraft_organize::remove_standard_id(doc, *standard)?,
Edit::SetDocumentScript { name, script } => pdfcraft_forms::set_document_script(doc, name, script.as_deref())?,
Edit::AddOcrText { page, words } => {
pdfcraft_edit::stamp(doc, *page, "OCR", pdfcraft_ocr::text_layer(words))?;
@@ -2541,6 +2642,11 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
ImageEdit::Rotate(q) => {
pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(img.visible_rect.unwrap_or(img.rect), *q, false, false))
}
ImageEdit::RotateBy(degrees) => {
let t = pdfcraft_edit::turn_by_degrees(img.visible_rect.unwrap_or(img.rect), *degrees)
.ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid("the rotation angle must be a finite number".into())))?;
pdfcraft_edit::ImageChange::Transform(t)
}
ImageEdit::Flip { horizontal } => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(
img.visible_rect.unwrap_or(img.rect),
0,
@@ -2749,6 +2855,7 @@ fn comment_list(doc: &pdfcraft_cos::Document) -> Vec<pdfcraft_render::Annotation
locked: s.locked,
intent: s.intent,
fill_sign: s.fill_sign,
points: s.points,
})
.collect()
}
@@ -3170,6 +3277,8 @@ fn image_content_type(bytes: &[u8]) -> Option<&'static str> {
pdfcraft_xfa::image::Kind::Jpeg => Some("image/jpeg"),
pdfcraft_xfa::image::Kind::Png => Some("image/png"),
pdfcraft_xfa::image::Kind::Gif => Some("image/gif"),
pdfcraft_xfa::image::Kind::Bmp => Some("image/bmp"),
pdfcraft_xfa::image::Kind::Tiff => Some("image/tiff"),
}
}
@@ -3382,10 +3491,10 @@ impl Session {
Ok(info) => (info, password.map(str::to_owned)),
Err(OpenError::WrongPassword) => {
let user = match &cos {
Ok(Ok(d)) => d.security().and_then(|s| s.recovered_user_password()),
Ok(Ok(d)) => recovered_render_password(d),
_ => None,
};
let user = renderer_password(&user.ok_or(OpenError::WrongPassword)?);
let user = user.ok_or(OpenError::WrongPassword)?;
(inspect(bytes.clone(), Some(&user))?, Some(user))
}
Err(e) => return Err(e),
@@ -3570,6 +3679,7 @@ impl Session {
redo: Vec::new(),
keys,
appearances: AppearanceCache::default(),
field_appearances: pdfcraft_forms::FieldAppearanceCache::default(),
text_blocks: TextBlocksCache::default(),
}),
None,
@@ -3645,6 +3755,7 @@ impl Session {
doc.edit_notices.clear();
let name = doc.name.clone();
let is_xfa = doc.info.xfa.is_some();
let mut scope = scope_of(&edit);
let mut cx = EditCtx::new(now, doc.generation ^ (id.0 << 48));
if !js_off {
cx.xfa = doc.xfa_template.clone();
@@ -3764,6 +3875,18 @@ impl Session {
xfa_notes = guard(|| xfa_sync_datasets(&mut next, tpl, datasets))
.unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})")))
.map_err(EditError::Write)?;
// Rebuilding from stale data would discard accepted widget values.
if xfa_notes.is_empty() {
let reflow = guard(|| xfa::relayout_changed_text(&mut next, tpl, &doc.form))
.unwrap_or_else(|m| Err(format!("laying out the XFA text failed unexpectedly ({m})")))
.map_err(EditError::Write)?;
if let Some(warnings) = reflow {
xfa_notes.extend(warnings);
// Layout replaces pages and widgets, even if the page count is unchanged.
// Undo/redo must rebuild these too instead of refreshing only field values.
scope = Scope::Full;
}
}
}
// Signed documents are only ever saved incrementally: an edit that needs a full rewrite
// (applying redactions, changing security, sanitizing) would invalidate the signatures.
@@ -3782,7 +3905,6 @@ impl Session {
let previous = std::mem::replace(&mut editor.cos, next);
let keys = keys_after(&edit).unwrap_or_else(|| editor.keys.clone());
let previous_keys = std::mem::replace(&mut editor.keys, keys);
let scope = scope_of(&edit);
editor.undo.push((label, previous, previous_keys, scope));
if editor.undo.len() > MAX_UNDO {
editor.undo.remove(0);
@@ -3831,14 +3953,28 @@ impl Session {
pub fn undo(&mut self, id: DocId) -> Result<Label, EditError> {
let doc = self.doc_mut(id)?;
doc.edit_notices.clear();
let editor = doc.editor.as_mut().ok_or(EditError::NothingToUndo)?;
let (label, prev, keys, scope) = editor.undo.pop().ok_or(EditError::NothingToUndo)?;
let current = std::mem::replace(&mut editor.cos, prev);
let current_keys = std::mem::replace(&mut editor.keys, keys);
editor.redo.push((label.clone(), current, current_keys, scope));
Self::adopt_keys(doc);
Self::refresh_scoped(doc, scope).inspect_err(|_| Self::show_released(doc))?;
let refreshed = guard(|| Self::refresh_scoped(doc, scope)).unwrap_or_else(|m| Err(EditError::Reopen(m)));
if let Err(e) = refreshed {
if let Some(ed) = doc.editor.as_mut()
&& let Some((label, current, current_keys, scope)) = ed.redo.pop()
{
let prev = std::mem::replace(&mut ed.cos, current);
let keys = std::mem::replace(&mut ed.keys, current_keys);
ed.undo.push((label, prev, keys, scope));
}
Self::adopt_keys(doc);
if !matches!(guard(|| Self::refresh(doc)), Ok(Ok(()))) {
Self::show_released(doc);
}
return Err(e);
}
doc.edit_notices.clear();
Self::sync_dirty(doc);
doc.generation += 1;
Ok(label)
@@ -3846,14 +3982,28 @@ impl Session {
pub fn redo(&mut self, id: DocId) -> Result<Label, EditError> {
let doc = self.doc_mut(id)?;
doc.edit_notices.clear();
let editor = doc.editor.as_mut().ok_or(EditError::NothingToRedo)?;
let (label, next, keys, scope) = editor.redo.pop().ok_or(EditError::NothingToRedo)?;
let current = std::mem::replace(&mut editor.cos, next);
let current_keys = std::mem::replace(&mut editor.keys, keys);
editor.undo.push((label.clone(), current, current_keys, scope));
Self::adopt_keys(doc);
Self::refresh_scoped(doc, scope).inspect_err(|_| Self::show_released(doc))?;
let refreshed = guard(|| Self::refresh_scoped(doc, scope)).unwrap_or_else(|m| Err(EditError::Reopen(m)));
if let Err(e) = refreshed {
if let Some(ed) = doc.editor.as_mut()
&& let Some((label, current, current_keys, scope)) = ed.undo.pop()
{
let next = std::mem::replace(&mut ed.cos, current);
let keys = std::mem::replace(&mut ed.keys, current_keys);
ed.redo.push((label, next, keys, scope));
}
Self::adopt_keys(doc);
if !matches!(guard(|| Self::refresh(doc)), Ok(Ok(()))) {
Self::show_released(doc);
}
return Err(e);
}
doc.edit_notices.clear();
Self::sync_dirty(doc);
doc.generation += 1;
Ok(label)
@@ -3915,6 +4065,8 @@ impl Session {
doc.info.producer = info("Producer");
doc.info.custom = pdfcraft_organize::custom_info(&editor.cos);
doc.info.file_size = bytes.len();
#[cfg(test)]
check_refresh_panic();
if !doc.signatures.is_empty() {
let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache);
doc.signatures = Arc::new(signatures);
@@ -4028,6 +4180,8 @@ impl Session {
doc.renderer = RenderPool::new_inline(Arc::new(Vec::new()), doc.config.clone());
doc.display = original.clone();
doc.bytes = original;
#[cfg(test)]
check_refresh_panic();
}
/// After a refresh failed: draw the display bytes it left (the original, once released),
@@ -4237,6 +4391,15 @@ impl Session {
let at = self.docs.iter().position(|d| d.id == new_id).ok_or_else(|| OpenError::Invalid("the new version was not kept".into()))?;
let mut doc = self.docs.remove(at);
doc.id = id;
// How the document is shown is the reader's, not the file's: reading colours and hidden
// comments stay as they were (#3443). `open` built the renderer with the defaults.
if let Some(old) = self.docs.get(old)
&& (doc.config.reading_colors != old.config.reading_colors || doc.config.hide_comments != old.config.hide_comments)
{
doc.config.reading_colors = old.config.reading_colors;
doc.config.hide_comments = old.config.hide_comments;
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
}
// Caches keyed by the edit generation (edit lines, images, measurements) must see a new
// one; there is nothing new to autosave.
doc.generation = generation + 1;
@@ -4728,6 +4891,11 @@ impl Session {
write_full(doc, &opts).map(Arc::new).map_err(|e| EditError::Write(e.to_string()))
}
/// Whether a changed document still needs a recovery snapshot. This does not consume it.
pub fn has_pending_autosave(&self) -> bool {
self.docs.iter().any(|d| d.dirty && d.generation != d.snapshot_generation)
}
/// Documents with unsaved changes made since the last call: their current working file,
/// for crash recovery. Encrypted documents stay encrypted in the snapshot.
pub fn autosave_snapshots(&mut self) -> Vec<RecoverySnapshot> {
@@ -4788,6 +4956,22 @@ impl Session {
true
}
/// Show each layer as the file opens (Acrobat's Layers ▸ Reset to Initial Visibility). Returns
/// `true` if any layer changed; callers must drop cached rasters and text for the document,
/// as for [`Self::set_layer_visible`].
pub fn reset_layers(&mut self, id: DocId) -> bool {
let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false };
let mut changed = false;
for l in &mut doc.info.layers {
changed |= l.visible != l.initial;
l.visible = l.initial;
}
if changed {
use_layer_choices(doc);
}
changed
}
/// Hide or show every comment on the page (Acrobat's Comments ▸ Hide all comments). Form
/// fields and links still draw. Returns whether anything changed.
pub fn set_hide_comments(&mut self, id: DocId, hide: bool) -> bool {
@@ -4819,6 +5003,12 @@ impl Session {
Some(self.get(id)?.config.reading_colors)
}
/// How the document's pages are drawn for viewing: its password and view settings (layer
/// visibility, hidden comments, reading colours). `None` when no such document is open.
pub fn render_config(&self, id: DocId) -> Option<RenderConfig> {
Some(self.get(id)?.config.clone())
}
/// Acrobat's Summarize Comments ("Comments only" layout): a new PDF listing every comment
/// with its number, author, type, date, text and replies, grouped by page.
pub fn summarize_comments(&self, id: DocId, sort: SummarySort) -> Result<Arc<Vec<u8>>, EditError> {
+83
View File
@@ -0,0 +1,83 @@
//! A stamp while it is moved or resized: the page without it, and the stamp alone.
use std::sync::Arc;
use pdfcraft_cos::{SaveOptions, write_incremental};
use pdfcraft_render::{PageRenderer, RenderConfig, RenderPool, RenderRequest, RenderedPage};
use crate::{Document, guard};
/// Read-only layers for interactive movement and resizing: the page without the stamp, and the
/// stamp alone on a transparent page. Moving or resizing a stamp only changes its `/Rect`, which
/// its appearance is stretched to fit, so these two show the result before it is applied.
/// Preparing them never changes the working bytes, dirty flag or undo history.
pub struct StampPreview {
page: usize,
background: Arc<Vec<u8>>,
stamp: Arc<Vec<u8>>,
config: RenderConfig,
}
impl StampPreview {
/// One worker for the page beneath the stamp.
pub fn background_renderer(&self) -> RenderPool {
RenderPool::new(self.background.clone(), 1, self.config.clone())
}
/// One worker for the stamp alone: its page has no content, no other annotations and no
/// background, so only the stamp's own pixels are drawn.
pub fn stamp_renderer(&self) -> RenderPool {
RenderPool::new(self.stamp.clone(), 1, self.stamp_config())
}
/// The page beneath the stamp, rendered synchronously with the renderer's normal pixel limits.
pub fn render_background(&self, scale: f32) -> Result<RenderedPage, String> {
self.render(&self.background, self.config.clone(), scale)
}
/// The stamp alone on a transparent page, rendered like [`Self::render_background`].
pub fn render_stamp(&self, scale: f32) -> Result<RenderedPage, String> {
self.render(&self.stamp, self.stamp_config(), scale)
}
fn stamp_config(&self) -> RenderConfig {
RenderConfig { transparent: true, ..self.config.clone() }
}
fn render(&self, bytes: &Arc<Vec<u8>>, config: RenderConfig, scale: f32) -> Result<RenderedPage, String> {
if !scale.is_finite() || scale <= 0.0 {
return Err("preview scale must be finite and positive".into());
}
guard(|| {
let out = PageRenderer::new(bytes.clone(), config).render(RenderRequest { page: self.page, scale, ..Default::default() });
if let Some(e) = &out.error {
return Err(e.clone());
}
Ok(out)
})?
}
}
impl Document {
/// The layers that show the stamp at `(page, index)` moved or resized as it is dragged.
/// `None` for an annotation that isn't a stamp, or one without an appearance to draw.
pub fn stamp_preview(&self, page: usize, index: usize) -> Result<Option<StampPreview>, String> {
guard(|| {
let editor = self.editor.as_ref().ok_or("the document can't be read")?;
let is_stamp = self.info.annotations.iter().any(|a| a.page == page && a.index == index && a.subtype == "Stamp");
if !is_stamp {
return Ok(None);
}
let mut stamp = editor.cos.clone();
if !pdfcraft_annot::isolate_annotation(&mut stamp, page, index).map_err(|e| e.to_string())? {
return Ok(None);
}
let mut background = editor.cos.clone();
pdfcraft_annot::delete_annotation(&mut background, page, index).map_err(|e| e.to_string())?;
// The page beneath as it is displayed: comments without an appearance get theirs.
let background = pdfcraft_annot::with_missing_appearances(&background).unwrap_or(background);
let write = |cos: &pdfcraft_cos::Document| write_incremental(cos, &SaveOptions::default()).map(Arc::new).map_err(|e| e.to_string());
Ok(Some(StampPreview { page, background: write(&background)?, stamp: write(&stamp)?, config: self.config.clone() }))
})?
}
}
+1006 -12
View File
File diff suppressed because it is too large Load Diff
+55 -5
View File
@@ -129,6 +129,7 @@ pub(crate) struct XfaRun<'a> {
pub ran_away: bool,
}
/// The live form node `n`, with its descendants, as the script engine's snapshot of it.
fn to_js(n: &FormNode) -> XfaNode {
XfaNode {
name: n.name.clone(),
@@ -149,6 +150,7 @@ fn to_js(n: &FormNode) -> XfaNode {
occur_max: n.occur_max,
index: n.index,
children: n.children.iter().map(to_js).collect(),
formatted: n.display_picture.as_deref().and_then(|p| pdfcraft_xfa::formatted_value(p, &n.value)),
}
}
@@ -669,9 +671,12 @@ impl<'a, 'b> Runner<'a, 'b> {
for n in &o.notes {
self.error(format!("{} ({}): {n}", ev.som, ev.activity));
}
// A script that fails keeps what it changed before the error, as JavaScript does: forms
// often trip over an object an earlier version had, late in a script whose first lines
// did the work (opening the next fields, showing a subform). An abandoned script, or
// one the engine failed, reports no changes.
if let Some(e) = &o.error {
self.error(format!("{} ({}): {e}", ev.som, ev.activity));
return Ok(Some(o));
}
if self.apply_effects(ev, &o.effects)? {
self.relayout()?;
@@ -764,6 +769,49 @@ fn collect_fields(n: &FormNode, out: &mut Vec<(String, String, String, String)>,
}
}
/// Reflow a laid-out XFA form when an edited multiline field can grow. The datasets must
/// already hold the final values, including calculation results. Fixed-height fields keep
/// their existing geometry; forms without scripts parse their template only when needed.
/// `Some` means pages/widgets were rebuilt and the caller must fully refresh its snapshot.
pub(crate) fn relayout_changed_text(
doc: &mut pdfcraft_cos::Document,
cached: Option<&Template>,
before: &[pdfcraft_forms::Field],
) -> Result<Option<Vec<String>>, String> {
if pdfcraft_xfa::existing_layout(doc).is_none() {
return Ok(None);
}
let current = &*doc;
let mut changed = before
.iter()
.filter(|f| f.kind == pdfcraft_forms::FieldKind::Text && f.has(pdfcraft_forms::flags::MULTILINE))
.filter_map(|f| {
let object = current.get(f.obj);
let dict = object.as_dict()?;
let value = dict.get(b"V").and_then(|v| current.resolve(v).as_string().map(|s| s.to_text())).unwrap_or_default();
if f.value.first().map_or("", String::as_str) == value {
return None;
}
dict.get(pdfcraft_xfa::SOM_KEY).and_then(|s| current.resolve(s).as_string().map(|s| s.to_text()))
})
.peekable();
if changed.peek().is_none() {
return Ok(None);
}
let template = match cached {
Some(tpl) => std::borrow::Cow::Borrowed(tpl),
None => std::borrow::Cow::Owned(pdfcraft_xfa::template_of(current).map_err(|e| e.to_string())?),
};
let reflow = changed.any(|som| pdfcraft_xfa::field_grows_with_text(&template, &som));
drop(changed);
if !reflow {
return Ok(None);
}
let report = pdfcraft_xfa::rerender(doc, &template).map_err(|e| e.to_string())?;
crate::xfa_redraw(doc)?;
Ok(Some(report.warnings))
}
/// On open: initialize scripts, then calculations. Returns what they changed.
pub(crate) fn on_open(doc: &mut pdfcraft_cos::Document, tpl: &Template, run: &mut XfaRun) -> Result<Changes, String> {
let mut r = Runner::new(doc, tpl, run, OPEN_BUDGET_MS);
@@ -826,10 +874,12 @@ pub(crate) fn on_event(doc: &mut pdfcraft_cos::Document, tpl: &Template, som: &s
r.finish().map(|_| ())
}
/// The SOM path of field `name` when PdfCraft generated it from an XFA template and the
/// template has a click script for it (a button with a native action has none).
pub(crate) fn clickable_som(doc: &pdfcraft_cos::Document, tpl: &Template, name: &str) -> Option<String> {
let som = pdfcraft_xfa::fields_by_som(doc).into_iter().find(|(_, f)| f == name).map(|(s, _)| s)?;
/// The SOM path of a generated field whose template has a click script.
pub(crate) fn clickable_som(doc: &pdfcraft_cos::Document, tpl: &Template, field: &pdfcraft_forms::Field) -> Option<String> {
// Page-master copies have distinct PDF field names but share a SOM path. Reversing
// fields_by_som would lose every copy except the last: use the clicked field itself.
let object = doc.get(field.obj);
let som = object.as_dict()?.get(pdfcraft_xfa::SOM_KEY).map(|s| doc.resolve(s))?.as_string()?.to_text();
let live = pdfcraft_xfa::form_tree(tpl, pdfcraft_xfa::data_of(doc).as_ref(), &pdfcraft_xfa::overrides(doc));
live.events.iter().any(|e| e.som == som && e.activity == "click").then_some(som)
}
+57 -3
View File
@@ -49,6 +49,9 @@ pub struct Page {
pub height: f64,
pub blocks: Vec<Block>,
pub images: Vec<Image>,
/// The colour the whole page is painted before its content (a page background), as sRGB
/// components in 0..=1; `None` for a page with no background of its own.
pub background: Option<[f64; 3]>,
}
/// One cell of a detected table. `span` is how many grid columns the cell covers (a header
@@ -662,6 +665,14 @@ fn section(page: &Page) -> String {
)
}
/// The page colour of a Word document: Word has one background for the whole document, so it
/// is written only when every page has the same, non-white background (#2992).
fn word_background(pages: &[Page]) -> Option<String> {
let first = rgb8(pages.first()?.background?);
let same = pages.iter().all(|p| p.background.map(rgb8) == Some(first));
(same && first != [255, 255, 255]).then(|| format!("{:02X}{:02X}{:02X}", first[0], first[1], first[2]))
}
/// A Word document (.docx, Office Open XML). Ordinary pages flow as paragraphs and tables;
/// pages with rotated text, or text that overlaps artwork, use floating shapes to retain their layout.
pub fn docx(pages: &[Page], title: &str) -> Vec<u8> {
@@ -728,10 +739,13 @@ pub fn docx(pages: &[Page], title: &str) -> Vec<u8> {
}
after_table = false;
}
// Word shows a document background only when its settings say so (`settings.xml` below).
let page_colour = word_background(pages);
let background = page_colour.as_deref().map(|c| format!("<w:background w:color=\"{c}\"/>")).unwrap_or_default();
let doc = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n\
<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\" \
xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing\" xmlns:v=\"urn:schemas-microsoft-com:vml\" xmlns:w10=\"urn:schemas-microsoft-com:office:word\"><w:body>{body}\
xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing\" xmlns:v=\"urn:schemas-microsoft-com:vml\" xmlns:w10=\"urn:schemas-microsoft-com:office:word\">{background}<w:body>{body}\
</w:body></w:document>"
);
let mut types = String::from(
@@ -742,6 +756,11 @@ xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawin
<Override PartName=\"/word/styles.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml\"/>\
<Override PartName=\"/docProps/core.xml\" ContentType=\"application/vnd.openxmlformats-package.core-properties+xml\"/>",
);
if page_colour.is_some() {
types.push_str(
"<Override PartName=\"/word/settings.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml\"/>",
);
}
types.push_str("</Types>");
let rels = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">\
<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\" Target=\"word/document.xml\"/>\
@@ -756,6 +775,9 @@ xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawin
i + 1
));
}
if page_colour.is_some() {
doc_rels.push_str("<Relationship Id=\"rIdSettings\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings\" Target=\"settings.xml\"/>");
}
doc_rels.push_str("</Relationships>");
let styles = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<w:styles xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\">\
<w:style w:type=\"paragraph\" w:default=\"1\" w:styleId=\"Normal\"><w:name w:val=\"Normal\"/><w:pPr><w:spacing w:after=\"120\"/></w:pPr></w:style>\
@@ -773,6 +795,9 @@ xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawin
z.add("word/document.xml", doc.as_bytes(), true);
z.add("word/styles.xml", styles.as_bytes(), true);
z.add("word/_rels/document.xml.rels", doc_rels.as_bytes(), true);
if page_colour.is_some() {
z.add("word/settings.xml", b"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<w:settings xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:displayBackgroundShape/></w:settings>", true);
}
for (name, im) in &media {
z.add(&format!("word/media/{name}"), &im.bytes, false);
}
@@ -912,6 +937,7 @@ mod tests {
size: [200.0, 150.0],
rotation: 0.0,
}],
background: None,
}
}
@@ -1007,6 +1033,30 @@ mod tests {
assert_eq!(xml.matches(">Meter</w:t>").count(), 2);
}
/// #2992: pages that share a background colour get it as the Word document's page colour
/// (with the setting that makes Word show it); differing or white backgrounds don't.
#[test]
fn docx_keeps_a_shared_page_background() {
let teal = Page { background: Some([0.0, 0.5, 0.5]), ..page() };
let zip = docx(&[teal.clone(), teal.clone()], "Teal");
let xml = part(&zip, "word/document.xml");
assert!(xml.contains("<w:background w:color=\"008080\"/><w:body>"), "{xml}");
assert!(part(&zip, "word/settings.xml").contains("<w:displayBackgroundShape/>"));
assert!(part(&zip, "[Content_Types].xml").contains("/word/settings.xml"));
assert!(part(&zip, "word/_rels/document.xml.rels").contains("Target=\"settings.xml\""));
let other = Page { background: Some([1.0, 0.0, 0.0]), ..page() };
for (pages, why) in [
(vec![teal.clone(), other], "different colours"),
(vec![teal.clone(), page()], "a page without one"),
(vec![Page { background: Some([1.0; 3]), ..page() }], "white"),
(vec![page()], "none"),
] {
let zip = docx(&pages, "Plain");
assert!(!part(&zip, "word/document.xml").contains("<w:background"), "{why}");
assert!(!zip.windows(b"word/settings.xml".len()).any(|w| w == b"word/settings.xml"), "{why}");
}
}
/// One part of a package written by [`Zip`], inflated.
pub(crate) fn part(zip: &[u8], name: &str) -> String {
let at = |i: usize, n: usize| zip[i..i + n].iter().rev().fold(0usize, |v, b| v << 8 | *b as usize);
@@ -1134,6 +1184,7 @@ mod tests {
},
],
images: Vec::new(),
background: None,
}
}
@@ -1214,6 +1265,7 @@ mod tests {
})
.collect(),
images: Vec::new(),
background: None,
};
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");
@@ -1248,6 +1300,7 @@ mod tests {
block("conducted during a WGP tournament.", 164.3, 502.0, 352.5, 516.6),
],
images: Vec::new(),
background: None,
};
let h = html(std::slice::from_ref(&p), "Regulations");
assert!(!h.contains("<table>"), "running text became a table: {h}");
@@ -1267,7 +1320,7 @@ mod tests {
blocks.push(block(lead, 72.0, y, 211.0, y + 12.0));
blocks.push(blue("https://example.org/event", 212.5, y, 360.0, y + 12.0));
}
let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new() };
let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None };
let h = html(std::slice::from_ref(&p), "Event");
assert!(!h.contains("<table>"), "sentences became a table: {h}");
assert_eq!(h.matches("https://example.org/event").count(), 3, "{h}");
@@ -1284,7 +1337,7 @@ mod tests {
blocks.push(Block { size: 8.0, ..block(sup, 106.0, y + 5.0, 113.0, y + 13.0) });
blocks.push(block(&format!("{},000", 30 - 5 * n), 300.0, y, 340.0, y + 12.0));
}
let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new() };
let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None };
let h = html(std::slice::from_ref(&p), "Prizes");
assert!(h.contains("<table>"), "the prize table was lost: {h}");
assert!(h.contains("30,000") && h.contains("15,000"), "{h}");
@@ -1311,6 +1364,7 @@ mod tests {
para("Second right block, still a tall flowing paragraph rather than a cell.", 320.0, 400.0),
],
images: Vec::new(),
background: None,
};
let h = html(&[p], "Paper");
assert!(!h.contains("<table>"), "tall two-column text must stay paragraphs: {h}");
+4 -1
View File
@@ -396,6 +396,7 @@ mod tests {
size: [228.0, 150.0],
rotation: 0.0,
}],
background: None,
}
}
@@ -522,6 +523,7 @@ mod tests {
cell("100%", 200.0, 320.0),
],
images: Vec::new(),
background: None,
};
let bytes = pptx(&[sample_page(), table_page], "Two");
let parts = entries(&bytes);
@@ -619,7 +621,7 @@ mod tests {
let images = (0..40)
.map(|k| Image { ext: "png", bytes: vec![0; 4], rect: [0.0, -1e300, 1e-300, 1e300 - k as f64], size: [1.0, 1.0], rotation: 0.0 })
.collect();
let p = Page { width: f64::MAX, height: f64::NAN, blocks, images };
let p = Page { width: f64::MAX, height: f64::NAN, blocks, images, background: None };
let bytes = pptx(&[p], "Hostile");
let slide = part(&bytes, "ppt/slides/slide1.xml");
assert!(slide.contains("Title") && slide.contains("<p:pic>"), "the slide is still written");
@@ -698,6 +700,7 @@ mod tests {
cell("100%", 200.0, 320.0, 100.0, 12.0),
],
images: Vec::new(),
background: None,
};
let bytes = pptx(&[p], "Table Check");
let slide_xml = part(&bytes, "ppt/slides/slide1.xml");
+10 -4
View File
@@ -1,8 +1,8 @@
//! Embeds the fonts of the optional craft-fonts build input (`CRAFT_FONTS_DIR`) as `CRAFT_FONTS`.
//! The recipe is craft-fonts' `docs/integration.md`; unset, `CRAFT_FONTS` is empty. It only reads
//! the local checkout (no network). On wasm32 it embeds only the UI faces (BIZ UDPGothic Regular
//! and any `Arab`, `Telu`, `Hebr` or `Thai` face), to keep the web build within hosting limits (Cloudflare Pages:
//! 25 MiB per file).
//! and any `Arab`, `Telu`, `Hebr`, `Thai` or `Deva` face), to keep the web build within hosting
//! limits (Cloudflare Pages: 25 MiB per file).
use std::fmt::Write as _;
use std::path::PathBuf;
@@ -55,8 +55,14 @@ fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
let telugu = scripts.split(',').any(|s| s.trim() == "Telu");
// And the Hebrew ones (small), for Hebrew text and Edit text on the web.
let hebrew = scripts.split(',').any(|s| s.trim() == "Hebr");
let web_face =
(*family == "BIZ UDPGothic" && *style == "Regular") || arabic || telugu || hebrew || scripts.split(',').any(|s| s.trim() == "Thai");
// Devanagari too, for the Hindi interface (one small face).
let devanagari = scripts.split(',').any(|s| s.trim() == "Deva");
let web_face = (*family == "BIZ UDPGothic" && *style == "Regular")
|| arabic
|| telugu
|| hebrew
|| scripts.split(',').any(|s| s.trim() == "Thai")
|| devanagari;
if wasm && !web_face {
continue;
}
+46
View File
@@ -75,6 +75,20 @@ pub fn document_thai_font() -> Option<&'static CraftFont> {
ui_thai_fonts().into_iter().next()
}
/// The `Deva` craft-fonts faces for Devanagari interface text (the Hindi catalog, file names,
/// document titles), in manifest order. Empty when built without craft-fonts or when its revision
/// has no Devanagari face; a desktop build then falls back to one font already installed on the
/// machine (`SYSTEM_DEVANAGARI_FALLBACK`), and the web build shows replacement boxes.
pub fn ui_devanagari_fonts() -> Vec<&'static CraftFont> {
CRAFT_FONTS.iter().filter(|f| f.covers("Deva")).collect()
}
/// The face for Devanagari text written into PDFs: the first `Deva` face. `None` when built
/// without craft-fonts or when its revision has no Devanagari face.
pub fn document_devanagari_font() -> Option<&'static CraftFont> {
ui_devanagari_fonts().into_iter().next()
}
/// The `Hebr` craft-fonts faces for Hebrew interface text (file names, document titles, text
/// being edited), in manifest order. Empty when built without craft-fonts or when it has no
/// Hebrew face.
@@ -138,6 +152,24 @@ pub fn document_japanese_fonts_for_style(serif: bool, bold: bool) -> Vec<&'stati
document_faces(CRAFT_FONTS, serif, bold)
}
/// The scripts the bundled Inter faces are for.
static LATIN_SCRIPTS: &[&str] = &["Latn", "Cyrl", "Grek"];
/// The bundled Latin document faces (`assets/fonts`, OFL): every build has them, unlike
/// craft-fonts. The Japanese faces carry only part of the Cyrillic (`привет` yes, Uzbek `қизил`
/// and Kazakh `әже` no), so letters like қ, ғ, ҳ, ә, ө, ү, ұ and һ have no face at all without
/// these.
static INTER_REGULAR_FACE: CraftFont =
CraftFont { family: "Inter", style: "Regular", scripts: LATIN_SCRIPTS, bytes: include_bytes!("../../../assets/fonts/Inter-Regular.ttf") };
static INTER_SEMIBOLD_FACE: CraftFont =
CraftFont { family: "Inter", style: "SemiBold", scripts: LATIN_SCRIPTS, bytes: include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf") };
/// The face for Latin, Greek and Cyrillic text written into PDFs: the bundled Inter Regular, or
/// SemiBold for bold text. Present in every build, crafted ones included.
pub fn document_latin_fonts(bold: bool) -> Vec<&'static CraftFont> {
vec![if bold { &INTER_SEMIBOLD_FACE } else { &INTER_REGULAR_FACE }]
}
fn document_face(faces: &[CraftFont], serif: bool, bold: bool) -> Option<&CraftFont> {
document_faces(faces, serif, bold).into_iter().next()
}
@@ -296,6 +328,20 @@ mod tests {
assert_eq!(document_hebrew_font(false).is_some(), CRAFT_FONTS.iter().any(|f| f.covers("Hebr")));
}
/// The bundled Inter faces carry the Latin, Greek and Cyrillic letters the craft-fonts faces
/// lack: Uzbek қ ғ ҳ, Kazakh ә ө ү ұ һ, Ukrainian ї є ґ, Greek. This one runs with or without
/// CRAFT_FONTS_DIR (#2593).
#[test]
fn latin_faces_carry_the_letters_the_japanese_faces_lack() {
for face in document_latin_fonts(false).into_iter().chain(document_latin_fonts(true)) {
for ch in ['қ', 'ғ', 'ҳ', 'ә', 'ө', 'ү', 'ұ', 'һ', 'ї', 'є', 'ґ', 'ё', 'α', 'β', 'γ', '≥', '≈'] {
assert!(crate::japanese_glyph_from(face, ch).is_ok(), "{} has no {ch}", face.name());
}
}
assert_eq!(document_latin_fonts(false)[0].style, "Regular");
assert_eq!(document_latin_fonts(true)[0].style, "SemiBold");
}
#[test]
fn craft_fonts_are_optional_and_consistent() {
// Holds with or without CRAFT_FONTS_DIR.
+35 -5
View File
@@ -18,9 +18,9 @@ pub mod source_shaping;
mod std14;
mod std14_metrics;
pub use craft::{
CRAFT_FONTS, CraftFont, SHIPPORI_MINCHO, document_arabic_font, document_hebrew_font, document_japanese_font, document_japanese_font_for_style,
document_japanese_fonts_for_style, document_thai_font, ui_arabic_fonts, ui_chinese_fonts, ui_cjk_fonts, ui_hebrew_fonts, ui_japanese_fonts,
ui_telugu_fonts, ui_thai_fonts,
CRAFT_FONTS, CraftFont, SHIPPORI_MINCHO, document_arabic_font, document_devanagari_font, document_hebrew_font, document_japanese_font,
document_japanese_font_for_style, document_japanese_fonts_for_style, document_latin_fonts, document_thai_font, ui_arabic_fonts, ui_chinese_fonts,
ui_cjk_fonts, ui_devanagari_fonts, ui_hebrew_fonts, ui_japanese_fonts, ui_telugu_fonts, ui_thai_fonts,
};
pub use script::{
GlyphError, GlyphOutline, INTER_REGULAR, MAX_SIGNATURE_CHARS, ScriptOutline, japanese_glyph, japanese_glyph_from, script_missing, script_outline,
@@ -51,6 +51,18 @@ pub fn cjk_width(s: &str, size: f64) -> f64 {
.sum()
}
/// Approximate advance of `s` in Inter Regular at `size` points.
pub fn inter_width(s: &str, size: f64) -> f64 {
use skrifa::MetadataProvider as _;
let Ok(font) = skrifa::FontRef::new(INTER_REGULAR) else { return helvetica_width(s, size) };
let loc = skrifa::instance::LocationRef::default();
let upem = font.metrics(skrifa::instance::Size::unscaled(), loc).units_per_em.max(1) as f64;
let scale = size / upem;
let charmap = font.charmap();
let advances = font.glyph_metrics(skrifa::instance::Size::unscaled(), loc);
s.chars().map(|c| charmap.map(c).and_then(|gid| advances.advance_width(gid)).unwrap_or(0.0) as f64 * scale).sum()
}
/// East Asian wide and full-width characters (Unicode UAX #11 W and F, by block).
fn is_wide(c: char) -> bool {
matches!(c,
@@ -69,7 +81,7 @@ fn is_wide(c: char) -> bool {
)
}
/// Greedy line breaking within `width` points (paragraphs split on newlines; words longer
/// Greedy line breaking within `width` points (paragraphs split on line breaks, where CRLF counts as one; 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)
@@ -78,7 +90,8 @@ pub fn wrap(text: &str, size: f64, width: f64) -> Vec<String> {
/// [`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']) {
// A CRLF pair is one break, a lone CR or LF is one break each.
for para in text.replace("\r\n", "\n").split(['\n', '\r']) {
let mut line = String::new();
// No word is on the line yet; an empty word (a leading space) still starts it.
let mut fresh = true;
@@ -277,6 +290,15 @@ mod tests {
assert_eq!(broken, wrap(" abcdefghijklmnopqrstuvwxyz", 11.0, 40.0));
}
#[test]
fn crlf_is_one_line_break() {
assert_eq!(wrap("a\r\nb", 12.0, 100.0), ["a", "b"]);
assert_eq!(wrap("a\rb", 12.0, 100.0), ["a", "b"]);
assert_eq!(wrap("a\nb", 12.0, 100.0), ["a", "b"]);
assert_eq!(wrap("a\r\n\r\nb", 12.0, 100.0), ["a", "", "b"]);
assert_eq!(wrap("a\n\rb", 12.0, 100.0), ["a", "", "b"]);
}
#[test]
fn win_ansi_encodability_matches_the_encoder() {
for c in ['a', 'é', 'ÿ', '€', '—', '™', '\t', '?'] {
@@ -313,6 +335,14 @@ mod tests {
assert_eq!(undrawn_mark(&d), None);
}
#[test]
fn inter_width_measures_latin_extended_and_ascii() {
assert!(inter_width("Luka Petravić", 12.0) > 0.0);
assert_eq!(inter_width("", 12.0), 0.0);
let w = inter_width("čžšć", 10.0);
assert!(w > 10.0 && w < 100.0);
}
#[test]
fn widths_wrap_and_encode() {
assert!(helvetica_width("MMMM", 10.0) > helvetica_width("iiii", 10.0) * 2.0);
+114 -6
View File
@@ -807,7 +807,7 @@ impl Metrics {
match self.std14 {
// A font with no /Widths is one of the standard 14; use its published metrics.
// Codes the font's encoding leaves undefined fall back to /MissingWidth.
Some(f) => match u8::try_from(code).map(|c| f.width(c)).unwrap_or(0.0) {
Some(f) => match u8::try_from(self.std14_code(f, code)).map(|c| f.width(c)).unwrap_or(0.0) {
0.0 => self.default * self.scale,
w => w / 1000.0,
},
@@ -815,6 +815,17 @@ impl Metrics {
}
}
/// Where the standard-14 table, indexed by WinAnsi, holds the glyph `code` draws in this font:
/// its encoding (base and `/Differences`) names the glyph, so a remapped code is measured as
/// the glyph it now shows (#2894). Symbol and ZapfDingbats are indexed by their own encodings,
/// and a glyph WinAnsi has no code for keeps the code itself.
fn std14_code(&self, face: crate::Std14, code: u32) -> u32 {
if matches!(face, crate::Std14::Symbol | crate::Std14::ZapfDingbats) {
return code;
}
self.unicode.encoding.get(&code).and_then(|t| only_char(t)).and_then(crate::win_ansi_byte).map_or(code, u32::from)
}
/// Word spacing applies to the single-byte code 32 (§9.3.3).
pub fn is_space(&self, code: u32, len: usize) -> bool {
code == 32 && len == 1
@@ -1143,7 +1154,8 @@ pub fn glyph_unicode(name: &str) -> Option<char> {
chars.next().filter(|_| chars.len() == 0)
}
/// Code → Unicode for a font: its ToUnicode CMap, else (simple fonts) its encoding.
/// Code → Unicode for a font: its ToUnicode CMap (for a composite font, also the name
/// `/Identity-H` or `/Identity-V`, read as code = Unicode), else (simple fonts) its encoding.
fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode {
let mut out = Unicode::default();
if !composite {
@@ -1206,14 +1218,44 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode {
}
}
}
if let Some(Object::Stream(s)) = font.get(b"ToUnicode").map(|t| doc.resolve(t)).as_deref()
&& let Ok(data) = s.decoded()
{
parse_to_unicode(&data, &mut out);
match font.get(b"ToUnicode").map(|t| doc.resolve(t)).as_deref() {
Some(Object::Stream(s)) => {
if let Ok(data) = s.decoded() {
parse_to_unicode(&data, &mut out);
}
}
// `/ToUnicode /Identity-H` is not a stream, as ISO 32000-2 §9.10.3 requires, but CAD
// exporters (WSCAD among them) write it for composite fonts whose two-byte codes are the
// UTF-16 code units. Acrobat shows them as code = Unicode, and so does the renderer's
// text layer; without it the text could be seen but not edited or found. The same rule
// (code = Unicode, no controls or lone surrogates) is in the vendored renderer,
// `Type0Font::char_code_to_unicode` in vendor/hayro-interpret/src/font/cid.rs: keep both alike.
Some(Object::Name(n)) if composite && matches!(n.as_slice(), b"Identity-H" | b"Identity-V") => {
parse_to_unicode(&identity_to_unicode(), &mut out);
}
_ => {}
}
out
}
/// A ToUnicode CMap mapping each two-byte code to the same UTF-16 code unit, leaving out the
/// surrogates (which are not characters on their own) and the controls (C0, DEL and C1, as the
/// renderer's text layer does). No range crosses a last-byte boundary (§9.10.3).
fn identity_to_unicode() -> Vec<u8> {
let mut ranges = vec![(0x20u32, 0x7Eu32), (0xA0, 0xFF)];
ranges.extend((0x01u32..=0xFF).filter(|h| !(0xD8..=0xDF).contains(h)).map(|h| (h << 8, (h << 8) | 0xFF)));
let mut cmap = String::from("1 begincodespacerange <0000> <FFFF> endcodespacerange\n");
// At most 100 entries per section (§9.7.5.2 limits for CMap files).
for chunk in ranges.chunks(100) {
cmap.push_str(&format!("{} beginbfrange\n", chunk.len()));
for (lo, hi) in chunk {
cmap.push_str(&format!("<{lo:04X}> <{hi:04X}> <{lo:04X}>\n"));
}
cmap.push_str("endbfrange\n");
}
cmap.into_bytes()
}
#[cfg(test)]
mod tests {
use pdfcraft_cos::{Dict, Document, Object, Stream};
@@ -1402,6 +1444,37 @@ mod tests {
assert_eq!(m.encode("Ω").as_deref(), Some(&b"B"[..]));
}
/// A standard-14 font with no `/Widths` is measured by the glyph each code draws, not by the
/// glyph WinAnsi gives that code (#2894); `/Widths`, when present, still go by code.
#[test]
fn standard_fonts_without_widths_measure_the_glyph_their_differences_name() {
let mut doc = Document::new_empty();
let mut enc = Dict::new();
enc.set(b"BaseEncoding".to_vec(), Object::name("WinAnsiEncoding"));
let diffs =
vec![Object::Int(65), Object::name("W"), Object::Int(87), Object::name("A"), Object::name("uni03A9"), Object::name("not.a.glyph")];
enc.set(b"Differences".to_vec(), Object::Array(diffs));
let f = font(
&mut doc,
vec![("Subtype", Object::name("Type1")), ("BaseFont", Object::name("Helvetica")), ("Encoding", Object::Dict(enc.clone()))],
);
let m = Metrics::from_dict(&doc, &f);
assert_eq!((m.width(65), m.width(87)), (0.944, 0.667), "A and W swap their widths");
// Omega has no WinAnsi code and the last name reads as nothing: both keep their code's width.
assert_eq!((m.width(88), m.width(89)), (0.667, 0.667));
let f = font(
&mut doc,
vec![
("Subtype", Object::name("Type1")),
("BaseFont", Object::name("Helvetica")),
("FirstChar", Object::Int(87)),
("Widths", Object::Array(vec![Object::Int(500)])),
("Encoding", Object::Dict(enc)),
],
);
assert_eq!(Metrics::from_dict(&doc, &f).width(87), 0.5, "/Widths go by code");
}
#[test]
fn subset_fonts_only_encode_the_glyphs_they_have() {
let mut doc = Document::new_empty();
@@ -1489,6 +1562,41 @@ mod tests {
assert_eq!(m.decode(&[0, 1, 0, 7, 0, 2]), "M á");
}
/// WSCAD writes `/ToUnicode /Identity-H` (a name, not a CMap stream) on composite fonts whose
/// codes are UTF-16 code units: the codes read as the same Unicode, as in Acrobat. Controls
/// and lone surrogates map to nothing, and a simple font's name is not a ToUnicode map.
#[test]
fn identity_to_unicode_names_read_codes_as_unicode() {
let doc = Document::new_empty();
for name in ["Identity-H", "Identity-V"] {
let f = font(
&mut Document::new_empty(),
vec![
("Subtype", Object::name("Type0")),
("BaseFont", Object::name("RobotoMono-Regular")),
("Encoding", Object::name("Identity-H")),
("DescendantFonts", Object::Array(vec![Object::Dict(Dict::new())])),
("ToUnicode", Object::name(name)),
],
);
let m = Metrics::from_dict(&doc, &f);
assert_eq!(m.decode(&[0, b'Z', 0, 0xFC, 0, b'r', 0x20, 0xAC, 0x4E, 0x2D]), "Zür€中", "{name}");
assert_eq!(m.text_of(0x0001), None);
assert_eq!(m.text_of(0x007F), None, "DEL is a control");
assert_eq!(m.text_of(0x0085), None, "a C1 control");
assert_eq!(m.text_of(0x00A0).as_deref(), Some("\u{A0}"), "the range resumes after C1");
assert_eq!(m.text_of(0x007E).as_deref(), Some("~"));
assert_eq!(m.text_of(0xD800), None);
assert_eq!(m.text_of(0xFFFD).as_deref(), Some("\u{FFFD}"));
}
let simple = font(
&mut Document::new_empty(),
vec![("Subtype", Object::name("TrueType")), ("BaseFont", Object::name("Arial")), ("ToUnicode", Object::name("Identity-H"))],
);
let m = Metrics::from_dict(&doc, &simple);
assert_eq!(m.text_of(0x41).as_deref(), Some("A"), "the encoding still applies");
}
/// Chrome's subsets leave the glyphs of `/DW` width out of `/W` (#1443): "s" (CID 0x56) is
/// still in the font, but nothing is when the font gives no default width to point at.
#[test]
+7 -1
View File
@@ -81,7 +81,7 @@ impl Std14 {
if has("times") {
return Some((times(), Match::Alias));
}
if has("serif") || has("roman") {
if (has("serif") && !has("sans")) || has("roman") {
return Some((times(), Match::FamilyHint));
}
if has("helvetica") || has("arial") {
@@ -125,6 +125,12 @@ impl Std14 {
mod tests {
use super::*;
#[test]
fn sans_serif_names_are_not_mapped_to_times() {
assert!(!matches!(Std14::recognise("SansSerif"), Some((Std14::TimesRoman, _))));
assert_eq!(Std14::recognise("Arial-SansSerif"), Some((Std14::Helvetica, Match::Alias)));
}
/// Widths from the published Core14 metrics, typed in here by hand so the table is checked
/// against the specification rather than against whatever produced it.
#[test]
+24
View File
@@ -25,6 +25,14 @@ set_props(&mut doc, "email", &FieldProps {
..Default::default()
})?;
delete_field(&mut doc, "email")?;
// A table of text fields (view space, y down), laid out and named before any of it is added:
// refused whole for a cell under MIN_FIELD_SIDE or a name with a period, repeated or taken.
let names = all.iter().map(|f| f.name.as_str());
let base = table::free_base(names.clone()); // "Table1", or the next one free
let mut grid = table::FieldTable::new([40.0, 240.0, 240.0, 360.0], &base);
grid.set_columns(2, &base);
let cells: Vec<table::Cell> = grid.cells_for(names)?; // "Table1_1", "Table1_c2_1", …
```
## Behaviour
@@ -42,8 +50,16 @@ delete_field(&mut doc, "email")?;
that have them. Not in a document that declares PDF/A, which forbids that flag (`declares_pdfa`).
`undrawn_fields` lists the marked fields; the engine reports each edit that draws one, and
flattening refuses to make that `?` permanent.
- Displaying a form that leaves its appearances to the viewer (`/NeedAppearances`, or a text or
choice widget with no `/AP /N`): `with_field_appearances` returns a copy with those widgets
drawn from their values, as typing them would draw them; the engine shows and prints that copy
and saves the file as it was (#3470). `with_field_appearances_cached` keeps each widget's
drawing while the objects it was drawn from are the same (`FieldAppearanceCache`), so typing in
one field redraws that field, not the form.
- Check boxes and radio buttons switch `/V` and each widget's `/AS`. Widgets with no appearances
get PdfCraft's own (a drawn check mark or dot; no symbol font needed).
- Imported `/MaxLen 0` is treated as unlimited (also clearing an inherited limit), while the
original entry is preserved. Positive limits still apply.
- Values are validated (options, `MaxLen`, single vs multi-select, NoToggleToOff, read-only) and a
failed call changes nothing.
- `FieldProps::appearance` applies a `LookPatch` independently to each widget. `None` keeps
@@ -63,6 +79,14 @@ delete_field(&mut doc, "email")?;
script stream that isn't UTF-8, other events' scripts and a name another field still has are
left alone. `calculations_changed` compares two readings of a form to say which formulas an
edit rewrote.
- The calculation order (`/CO`, §12.7.3): `calculation_order` lists the calculated fields in the
order `recalculate` runs them, with the fields each formula reads (`Calculate::reads`,
`af::calculation_reads`; names built at run time aren't known) and what is wrong with the
order: a field reading one that calculates later, a loop, a field `/CO` leaves out, and `/CO`
entries that calculate nothing. `CalculationOrder::fixed` is Fix order: a stable topological
sort that keeps the current order wherever it can and leaves loops as they are.
`set_calculation_order` writes `/CO` afresh. A field that gets a calculation joins the end of
the order; one whose calculation changes keeps its place.
- `Widget::caption` reads a push button's normal label (`/MK /CA`, including indirect values).
`FieldProps { caption: Some((widget_index, text)), ..Default::default() }` changes or clears
that label, independently of the field name. The index is 0-based. A label-only edit redraws
+76 -1
View File
@@ -197,6 +197,18 @@ impl Parser<'_> {
b'n' => out.push(b'\n'),
b'r' => out.push(b'\r'),
b't' => out.push(b'\t'),
b'b' => out.push(0x08),
b'f' => out.push(0x0C),
b'v' => out.push(0x0B),
b'0' => out.push(0),
b'x' => {
let digits = self.s.get(self.i..self.i.checked_add(2)?)?;
let code = hex4(&[b'0', b'0', digits[0], digits[1]])?;
self.i += 2;
let ch = char::from_u32(u32::from(code)).unwrap_or('\u{FFFD}');
let mut encoded = [0u8; 4];
out.extend_from_slice(ch.encode_utf8(&mut encoded).as_bytes());
}
b'u' => {
let code = hex4(self.s.get(self.i..self.i.checked_add(4)?)?)?;
self.i += 4;
@@ -698,6 +710,34 @@ pub fn rename_in_calculation(js: &str, old: &str, new: &str) -> Option<String> {
(got == expected).then_some(out)
}
/// The fields a Calculate script reads by name (see [`calculation_sites`]), each once, in the
/// order it first names them.
pub fn calculation_reads(js: &str) -> Vec<String> {
let sites = calculation_sites(js);
unique(sites.iter().flat_map(|(_, site)| site.names()).map(str::to_owned))
}
impl Calculate {
/// The fields it reads, each once, in the order it first names them. A name also stands for
/// the fields under it (`row` for `row.0` and `row.1`), as [`calculate`] reads them.
pub fn reads(&self) -> Vec<String> {
match self {
Calculate::None => Vec::new(),
Calculate::Simple { fields, .. } => unique(fields.iter().cloned()),
Calculate::Notation(expr) => unique(notation_tokens(expr).into_iter().filter_map(|(_, token)| match token {
Notation::Name(name) => Some(name),
_ => None,
})),
}
}
}
/// `names` without repeats, in order.
fn unique(names: impl IntoIterator<Item = String>) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
names.into_iter().filter(|n| seen.insert(n.clone())).collect()
}
// ── running them ────────────────────────────────────────────────────────────────────────────
/// AFMakeNumber: a number from typed text (currency symbols, group separators, spaces and a
@@ -884,7 +924,8 @@ pub fn parse_date(text: &str, fmt: &str) -> Option<DateTime> {
// A bare trailing "p" ("5:30p") only counts when it follows a digit, not a word like "Sep".
let bare_p = lower.strip_suffix('p').is_some_and(|rest| rest.trim_end().ends_with(|c: char| c.is_ascii_digit()));
let pm = lower.contains("pm") || bare_p;
let am = lower.contains("am");
let bare_a = lower.strip_suffix('a').is_some_and(|rest| rest.trim_end().ends_with(|c: char| c.is_ascii_digit()));
let am = lower.contains("am") || bare_a;
// Tokens: runs of digits (kept as text, so a run can be split between fields typed without
// separators) and month names.
let mut nums: VecDeque<String> = VecDeque::new();
@@ -1386,6 +1427,15 @@ pub fn calculate(c: &Calculate, values: &dyn Fn(&str) -> Vec<String>) -> Option<
mod tests {
use super::*;
#[test]
fn javascript_escapes_are_decoded() {
assert_eq!(
parse_format("AFNumber_Format(2, 0, 0, 0, \"\\x24\", true);"),
Some(Format::Number { decimals: 2, sep: 0, neg: 0, currency: "$".into(), prepend: true })
);
assert_eq!(parse_format("AFDate_FormatEx(\"\\b\\f\\v\\0\\q\");"), Some(Format::Date("\u{8}\u{c}\u{b}\0q".into())));
}
#[test]
fn scripts_are_recognised_and_written_back() {
assert_eq!(
@@ -1528,6 +1578,31 @@ mod tests {
let back = renamed.as_deref().and_then(|r| rename_in_calculation(r, &new, &old));
proptest::prop_assert_eq!(back, Some(js));
}
/// What a formula reads is the same whether it is read from the tab's settings or from
/// the script they are written as.
#[test]
fn a_formula_reads_the_same_fields_as_its_script(names in proptest::collection::vec("\\PC{1,8}", 1..5), sum in proptest::bool::ANY) {
proptest::prop_assume!(!names.iter().any(|n| n.contains("VCALC")));
let c = if sum {
Calculate::Simple { op: CalcOp::Product, fields: names.clone() }
} else {
Calculate::Notation(names.iter().map(|n| notation_name(n)).collect::<Vec<_>>().join(" + "))
};
let js = calculate_js(&c).unwrap();
proptest::prop_assert_eq!(calculation_reads(&js), c.reads());
proptest::prop_assert_eq!(c.reads(), unique(names));
}
}
#[test]
fn a_calculation_reads_each_named_field_once() {
assert_eq!(Calculate::Simple { op: CalcOp::Sum, fields: vec!["a".into(), "b".into(), "a".into()] }.reads(), ["a", "b"]);
assert_eq!(Calculate::Notation("(Unit\\ Price + 2) * qty / qty".into()).reads(), ["Unit Price", "qty"]);
assert_eq!(Calculate::None.reads(), Vec::<String>::new());
let script = "var t = getField(\"sub\").value; // getField(\"x\")\nevent.value = t + getField(\"tax\").value + getField(\"row\" + 1).value;";
assert_eq!(calculation_reads(script), ["sub", "tax"], "names built at run time aren't known");
assert_eq!(calculation_reads("AFSimple_Calculate(\"SUM\", \"a, b\");"), ["a", "b"]);
}
#[test]
+45 -17
View File
@@ -13,8 +13,9 @@
//! Acrobat keeps a value its field font can't show: the value itself is stored as typed,
//! [`field_appearance_drawn`] returns the first such character, and the caller marks the widget
//! (`pdfcraft_fonts::UNDRAWN_MARK`) and asks other viewers to redraw the field
//! (`/NeedAppearances`). Widths use the approximate metrics of `pdfcraft-fonts` (Helvetica, or one
//! em per full-width character in composite fonts).
//! (`/NeedAppearances`). Widths are the field font's own (its `/Widths`, or the standard-14
//! metrics), Helvetica's when Helvetica stands in, and 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
@@ -191,7 +192,12 @@ impl FontText {
fn width(&self, s: &str, size: f64) -> f64 {
match self {
Self::WinAnsi | Self::Simple(_) => helvetica_width(s, size),
Self::WinAnsi => helvetica_width(s, size),
// The font's `/Widths`, or its standard-14 metrics (Courier, Times) when it has none.
Self::Simple(metrics) => match metrics.encode(&s.replace(['\t', '\r', '\n'], " ")) {
Some(bytes) => metrics.codes(&bytes).iter().map(|&(code, _)| metrics.width(code)).sum::<f64>() * size,
None => helvetica_width(s, size),
},
Self::Unicode(_) => cjk_width(s, size),
}
}
@@ -220,6 +226,28 @@ fn dr_font(doc: &Document, name: &str) -> Option<(Object, FontText)> {
Some((entry, FontText::Simple(Box::new(Metrics::from_dict(doc, fd)))))
}
/// A push button's caption, centered in a `width` × `height` box, in the font, size and color its
/// `/DA` asks for: the font from the form's `/DR`, else Helvetica, and the size `auto` when the
/// `/DA` leaves it to the viewer (0). Returns the content operators, the font resource (name and
/// object), and the first character drawn as `?` because the font can't show it.
pub(crate) fn button_caption(doc: &Document, da: &str, caption: &str, width: f64, height: f64, auto: f64) -> (Vec<u8>, String, Object, Option<char>) {
let da = parse_da(da);
let (name, font, enc) = match dr_font(doc, &da.font).filter(|(_, enc)| enc.can_encode(caption)) {
Some((o, enc)) => (da.font.clone(), o, enc),
None => ("Helv".to_string(), helvetica(), FontText::WinAnsi),
};
let missing = enc.first_unencodable(caption);
let size = if da.size > 0.0 { da.size } else { auto };
let tw = enc.width(caption, size);
let mut out =
format!("BT /{name} {size:.2} Tf {} 1 0 0 1 {:.3} {:.3} Tm ", da.color, (width - tw) / 2.0, (height - size * 0.7) / 2.0).into_bytes();
out.extend(literal(&enc.encode(caption)));
out.extend_from_slice(b" Tj ET\n");
(out, name, font, missing)
}
/// A non-embedded Helvetica font dictionary in WinAnsi encoding, the fallback when the form's
/// `/DR` has no font that can show the text.
fn helvetica() -> Object {
let mut f = Dict::new();
f.set(b"Type".to_vec(), Object::name("Font"));
@@ -542,20 +570,20 @@ pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[S
}
}
}
let mut content = c.into_bytes();
content.extend(
format!(
"/Tx BMC\nq\n{} {} {} {} re W n\nBT\n/{} {} Tf\n{}\n",
n(bw),
n(bw),
n(width - 2.0 * bw),
n(height - 2.0 * bw),
font_name,
n(size),
da.color
)
.bytes(),
);
// Acrobat regenerates only what is inside /Tx BMC when it redraws a field (e.g. on focus
// changes), discarding what came before it (issues #1613, #633). The background and border
// therefore go inside the marked-content block too, so a redraw keeps them with the text.
let mut content = format!(
"/Tx BMC\nq\n{c}{} {} {} {} re W n\nBT\n/{} {} Tf\n{}\n",
n(bw),
n(bw),
n(width - 2.0 * bw),
n(height - 2.0 * bw),
font_name,
n(size),
da.color
)
.into_bytes();
content.extend(body);
content.extend_from_slice(b"ET\nQ\nEMC\n");
let mut fonts = Dict::new();
+31 -46
View File
@@ -7,10 +7,12 @@
//! immediately so every viewer shows the empty field.
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
use pdfcraft_fonts::{helvetica_width, literal, win_ansi};
use crate::{Field, FieldKind, FormError, Widget, appearance, fields, flags, page_refs};
/// The smallest width and height of a field's widget, in points: a smaller one is refused.
pub const MIN_FIELD_SIDE: f64 = 4.0;
/// The kind of field to add.
#[derive(Clone, Debug, PartialEq)]
pub enum NewField {
@@ -515,7 +517,7 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel
let pages = page_refs(doc);
let page_ref = *pages.get(page).ok_or_else(|| FormError::Invalid(format!("page {} does not exist", page + 1)))?;
let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])];
if !rect.iter().all(|v| v.is_finite()) || rect[2] - rect[0] < 4.0 || rect[3] - rect[1] < 4.0 {
if !rect.iter().all(|v| v.is_finite()) || rect[2] - rect[0] < MIN_FIELD_SIDE || rect[3] - rect[1] < MIN_FIELD_SIDE {
return invalid("the field is too small");
}
let existing = fields(doc);
@@ -659,6 +661,7 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel
tab: usize::MAX,
locked: false,
hidden: false,
read_only: false,
rotation: 0,
caption: None,
list: None,
@@ -684,6 +687,9 @@ pub fn redraw_field(doc: &mut Document, name: &str) -> Result<(), FormError> {
redraw_widgets(doc, name, None)
}
/// `redraw_field` for the widget at index `only` alone (so a caption-only edit leaves the field's
/// other widgets' appearances as they are), or for every widget when `only` is `None`. Fails with
/// `FormError::NoSuchField` when no field is named `name`.
fn redraw_widgets(doc: &mut Document, name: &str, only: Option<usize>) -> Result<(), FormError> {
let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?;
let mut undrawn = false;
@@ -706,7 +712,7 @@ fn redraw_widgets(doc: &mut Document, name: &str, only: Option<usize>) -> Result
appearance::check_box_states(doc, w, f.kind, &on)
}
FieldKind::PushButton => {
let (s, m) = button_appearance(doc, w);
let (s, m) = button_appearance(doc, w, &f.da);
missing = m;
let r = doc.add(Object::Stream(s));
let mut d = Dict::new();
@@ -765,9 +771,11 @@ fn form_stream(place: crate::appearance::Placement, content: Vec<u8>, resources:
}
/// 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`). The caption is drawn in Helvetica,
/// with the first character it can't draw (drawn as `?`), for the caller to mark and report.
fn button_appearance(doc: &Document, w: &Widget) -> (Stream, Option<char>) {
/// centred caption (unless the layout is icon only, `/TP 1`). The caption uses the font, size and
/// color its `/DA` asks for (the widget's, else `field_da`, the field's own or inherited one).
/// Also returns the first caption character the font can't draw (drawn as `?`), for the caller to
/// mark and report.
fn button_appearance(doc: &Document, w: &Widget, field_da: &str) -> (Stream, Option<char>) {
let (mut c, place) = frame_only(doc, w);
let (width, height) = (place.layout_w, place.layout_h);
let wobj = doc.get(w.obj);
@@ -792,21 +800,15 @@ fn button_appearance(doc: &Document, w: &Widget) -> (Stream, Option<char>) {
}
}
let mut missing = None;
if !icon_only && !caption.is_empty() {
missing = pdfcraft_fonts::first_undrawable(&caption);
let size = ((height - 4.0) * 0.6).clamp(4.0, 14.0);
let tw = helvetica_width(&caption, size);
content.extend(format!("BT /Helv {size:.2} Tf 0 g 1 0 0 1 {:.3} {:.3} Tm ", (width - tw) / 2.0, (height - size * 0.7) / 2.0).bytes());
content.extend(literal(&win_ansi(&caption)));
content.extend_from_slice(b" Tj ET\n");
}
let mut font = Dict::new();
font.set(b"Type".to_vec(), Object::name("Font"));
font.set(b"Subtype".to_vec(), Object::name("Type1"));
font.set(b"BaseFont".to_vec(), Object::name("Helvetica"));
font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
let mut fonts = Dict::new();
fonts.set(b"Helv".to_vec(), Object::Dict(font));
if !icon_only && !caption.is_empty() {
let da = wobj.as_dict().and_then(|d| d.get(b"DA")).and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text()));
let auto = ((height - 4.0) * 0.6).clamp(4.0, 14.0);
let (ops, name, font, m) = appearance::button_caption(doc, da.as_deref().unwrap_or(field_da), &caption, width, height, auto);
content.extend(ops);
fonts.set(name.into_bytes(), font);
missing = m;
}
let mut res = Dict::new();
res.set(b"Font".to_vec(), Object::Dict(fonts));
if !xobjects.is_empty() {
@@ -856,7 +858,7 @@ fn swapped_around_center(r: [f64; 4]) -> Option<[f64; 4]> {
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)
(out.iter().all(|v| v.is_finite()) && out[2] - out[0] >= MIN_FIELD_SIDE && out[3] - out[1] >= MIN_FIELD_SIDE).then_some(out)
}
/// Change a field's properties (General and Options tabs) and redraw it.
@@ -920,12 +922,14 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
if n.is_empty() || n.contains('.') {
return invalid("field names can't be empty or contain a period");
}
if all.iter().any(|x| x.name == n) {
return invalid(format!("a field named {n:?} already exists"));
}
// Rename the terminal part (the field's own /T).
// Rename the terminal part (the field's own /T). The parents' part stays, so the name that
// must be free is the full one: `MASTER.a` renamed to `b` takes `MASTER.b` (ISO 32000-2
// §12.7.4.2). The field itself is no clash when it keeps its own name.
let prefix = f.name.rsplit_once('.').map(|(p, _)| format!("{p}.")).unwrap_or_default();
new_name = format!("{prefix}{n}");
if all.iter().any(|x| x.obj != f.obj && x.name == new_name) {
return invalid(format!("a field named {new_name:?} already exists"));
}
doc.update_dict(f.obj, |d| d.set(b"T".to_vec(), PdfString::text(n)))?;
// Calculations read the field by its new name, unless another field keeps the old one.
if all.iter().filter(|x| x.name == f.name).count() == 1 {
@@ -1097,7 +1101,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
if let Some((wi, r)) = props.rect {
let w = f.widgets.get(wi).ok_or_else(|| FormError::Invalid(format!("{name} has no widget {}", wi + 1)))?;
let r = [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])];
if !r.iter().all(|v| v.is_finite()) || r[2] - r[0] < 4.0 || r[3] - r[1] < 4.0 {
if !r.iter().all(|v| v.is_finite()) || r[2] - r[0] < MIN_FIELD_SIDE || r[3] - r[1] < MIN_FIELD_SIDE {
return invalid("the field is too small");
}
doc.update_dict(w.obj, |d| d.set(b"Rect".to_vec(), Object::Array(r.iter().map(|v| Object::Real(*v)).collect())))?;
@@ -1140,26 +1144,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
if let Some(js) = crate::af::calculate_js(c) {
aa.set(b"C".to_vec(), action(&js));
}
// The calculation order lists calculated fields.
let af_ref = ensure_form(doc)?;
let mut co: Vec<Object> = doc
.get(af_ref)
.as_dict()
.and_then(|d| d.get(b"CO").cloned())
.map(|c| doc.resolve(&c))
.and_then(|c| c.as_array().cloned())
.unwrap_or_default();
co.retain(|o| o.as_ref() != Some(f.obj));
if *c != crate::af::Calculate::None {
co.push(Object::Ref(f.obj));
}
doc.update_dict(af_ref, |d| {
if co.is_empty() {
d.remove(b"CO");
} else {
d.set(b"CO".to_vec(), Object::Array(co));
}
})?;
crate::calc_order::keep_listed(doc, f.obj, *c != crate::af::Calculate::None)?;
}
doc.update_dict(f.obj, |d| {
if aa.is_empty() {
+429
View File
@@ -0,0 +1,429 @@
//! The calculation order (`/CO`, ISO 32000-2 §12.7.3): the calculated fields in the order they
//! recalculate, the fields each one's formula reads, and what is wrong with that order.
use std::cmp::Reverse;
use std::collections::{BinaryHeap, HashMap, HashSet, VecDeque};
use pdfcraft_cos::{Document, ObjRef, Object};
use crate::{Field, FormError, acroform, fields};
/// At most this many reads between calculated fields are checked. A group name stands for every
/// field under it, so a form could otherwise make a check build billions of them.
const MAX_LINKS: usize = 1 << 20;
/// A calculated field, at its place in the calculation order.
#[derive(Clone, Debug, PartialEq)]
pub struct CalculatedField {
/// The field's full name.
pub name: String,
/// The fields its formula reads, each once, as the formula names them: a name also stands for
/// the fields under it. A script's names built at run time (`getField("row" + i)`) aren't here.
pub reads: Vec<String>,
/// `/CO` lists it. A field it leaves out calculates after the listed ones.
pub listed: bool,
/// What is wrong with its place in the order.
pub issues: Vec<CalculationIssue>,
}
/// Something wrong with a calculated field's place in the calculation order.
#[derive(Clone, Debug, PartialEq)]
pub enum CalculationIssue {
/// It reads `field`, which calculates after it, so it uses that field's previous value.
RunsTooEarly { field: String },
/// Its formula reads its own result: it reads `fields`, which read it back (itself, when it
/// reads itself directly). No order is right for a loop: one of the formulas has to change.
Cycle { fields: Vec<String> },
/// `/CO` leaves it out. PdfCraft calculates it after the listed fields, but other PDF viewers
/// may not calculate it at all.
NotInOrder,
}
/// A form's calculation order and what is wrong with it.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct CalculationOrder {
/// The calculated fields in the order [`crate::recalculate`] runs them: as `/CO` lists them,
/// then the ones it leaves out, in field order.
pub fields: Vec<CalculatedField>,
/// `/CO` entries that calculate nothing: a field without a calculation, a second entry for a
/// field, or an object that isn't a field (written as `12 0 R`).
pub extra: Vec<String>,
/// Fix order's order, as indexes into `fields`.
fixed: Vec<usize>,
}
impl CalculationOrder {
/// The names in the order Fix order writes: each field after the calculated fields it reads,
/// keeping the current order wherever that allows. Reads within a loop are left as they are.
pub fn fixed(&self) -> Vec<String> {
self.fixed.iter().filter_map(|&i| self.fields.get(i)).map(|f| f.name.clone()).collect()
}
/// Whether Fix order changes anything: a field moves, joins `/CO`, or an extra entry goes.
pub fn fixable(&self) -> bool {
!self.extra.is_empty() || self.fields.iter().any(|f| !f.listed) || self.fixed.iter().enumerate().any(|(k, &i)| k != i)
}
}
/// Whether a field has a calculation: a Calculate tab formula or a custom Calculate script.
pub(crate) fn is_calculated(f: &Field) -> bool {
f.actions.calculate != crate::af::Calculate::None || f.actions.scripts.calculate.is_some()
}
/// The entries of `/CO`, as written.
pub(crate) fn co_entries(doc: &Document) -> Vec<Object> {
acroform(doc).and_then(|af| af.get(b"CO").map(|c| doc.resolve(c)).and_then(|c| c.as_array().cloned())).unwrap_or_default()
}
/// Keep `/CO` in step with whether field `obj` calculates: a field that gets a calculation joins
/// the end of the order, one that loses it leaves, and one whose calculation changes keeps its
/// place, so editing a formula doesn't undo an order set by hand.
pub(crate) fn keep_listed(doc: &mut Document, obj: ObjRef, calculates: bool) -> Result<(), FormError> {
let mut co = co_entries(doc);
match (co.iter().any(|o| o.as_ref() == Some(obj)), calculates) {
(false, true) => co.push(Object::Ref(obj)),
(true, false) => co.retain(|o| o.as_ref() != Some(obj)),
_ => return Ok(()),
}
write_co(doc, co)
}
/// Write `/CO` (none when `co` is empty), creating the form if needed.
fn write_co(doc: &mut Document, co: Vec<Object>) -> Result<(), FormError> {
let af_ref = crate::author::ensure_form(doc)?;
doc.update_dict(af_ref, |d| {
if co.is_empty() {
d.remove(b"CO");
} else {
d.set(b"CO".to_vec(), Object::Array(co));
}
})?;
Ok(())
}
/// The calculated fields in the order they recalculate, as indexes into `fields` paired with
/// whether `/CO` lists them, and the `/CO` entries that calculate nothing.
pub(crate) fn run_order(doc: &Document, fields: &[Field]) -> (Vec<(usize, bool)>, Vec<String>) {
let mut by_obj: HashMap<ObjRef, usize> = HashMap::with_capacity(fields.len());
for (i, f) in fields.iter().enumerate() {
by_obj.entry(f.obj).or_insert(i);
}
let mut placed = HashSet::new();
let (mut run, mut extra) = (Vec::new(), Vec::new());
for entry in co_entries(doc) {
let field = entry.as_ref().and_then(|r| by_obj.get(&r).copied()).and_then(|i| Some((i, fields.get(i)?)));
match field {
Some((i, f)) if is_calculated(f) && placed.insert(i) => run.push((i, true)),
Some((_, f)) => extra.push(f.name.clone()),
None => extra.push(entry.as_ref().map_or_else(|| "not a reference".to_owned(), |r| r.to_string())),
}
}
run.extend(fields.iter().enumerate().filter(|(i, f)| is_calculated(f) && !placed.contains(i)).map(|(i, _)| (i, false)));
(run, extra)
}
/// The form's calculation order (`fields` as [`fields`] reads them), with each calculated field's
/// reads and issues, and Fix order's order.
pub fn calculation_order(doc: &Document, fields: &[Field]) -> CalculationOrder {
let (run, extra) = run_order(doc, fields);
let calculated: Vec<(&Field, bool)> = run.iter().filter_map(|&(i, listed)| Some((fields.get(i)?, listed))).collect();
let reads: Vec<Vec<String>> = calculated
.iter()
.map(|(f, _)| {
let mut names = f.actions.calculate.reads();
if let Some(js) = &f.actions.scripts.calculate {
for name in crate::af::calculation_reads(js) {
if !names.contains(&name) {
names.push(name);
}
}
}
names
})
.collect();
let links = links(calculated.iter().map(|(f, _)| f.name.as_str()), &reads);
let component = components(&links);
let name = |k: usize| calculated.get(k).map(|(f, _)| f.name.clone()).unwrap_or_default();
let mut out = Vec::with_capacity(calculated.len());
for (k, ((f, listed), reads)) in calculated.iter().zip(reads).enumerate() {
let own = component.get(k).copied();
let mut issues = Vec::new();
let read = links.get(k).map(Vec::as_slice).unwrap_or_default();
for &j in read {
if j > k && component.get(j).copied() != own {
issues.push(CalculationIssue::RunsTooEarly { field: name(j) });
}
}
// Only the loop's fields it reads itself are named, so a loop through thousands of fields
// isn't listed again for each of them.
let looped: Vec<String> = read.iter().filter(|&&j| component.get(j).copied() == own).map(|&j| name(j)).collect();
if !looped.is_empty() {
issues.push(CalculationIssue::Cycle { fields: looped });
}
if !listed {
issues.push(CalculationIssue::NotInOrder);
}
out.push(CalculatedField { name: f.name.clone(), reads, listed: *listed, issues });
}
let fixed = fixed_order(&links, &component);
CalculationOrder { fields: out, extra, fixed }
}
/// For each calculated field (by its place in `names`), the places of the calculated fields it
/// reads, sorted. A read name finds the field of that name and the fields under it, as
/// [`crate::recalculate`] looks values up.
fn links<'a>(names: impl Iterator<Item = &'a str>, reads: &[Vec<String>]) -> Vec<Vec<usize>> {
let mut sorted: Vec<(&str, usize)> = names.enumerate().map(|(k, n)| (n, k)).collect();
sorted.sort_unstable();
let mut budget = MAX_LINKS;
reads
.iter()
.map(|names| {
let mut out = Vec::new();
for name in names {
let prefix = format!("{name}.");
let exact = sorted.partition_point(|(n, _)| *n < name.as_str());
let under = sorted.partition_point(|(n, _)| *n < prefix.as_str());
let same = sorted.get(exact..).unwrap_or_default().iter().take_while(|(n, _)| n == name);
let below = sorted.get(under..).unwrap_or_default().iter().take_while(|(n, _)| n.starts_with(&prefix));
for &(_, k) in same.chain(below) {
if budget == 0 {
break;
}
budget -= 1;
out.push(k);
}
}
out.sort_unstable();
out.dedup();
out
})
.collect()
}
/// The strongly connected components of `links` (Tarjan's algorithm, without recursion): one
/// component number per node. Fields in one component read each other in a loop.
fn components(links: &[Vec<usize>]) -> Vec<usize> {
const UNSEEN: usize = usize::MAX;
let n = links.len();
let (mut index, mut low, mut component) = (vec![UNSEEN; n], vec![0; n], vec![UNSEEN; n]);
let mut on_stack = vec![false; n];
let (mut stack, mut work): (Vec<usize>, Vec<(usize, usize)>) = (Vec::new(), Vec::new());
let (mut next, mut count) = (0, 0);
// Every index below is a node: `links` only holds places below `n` (see `links`).
for root in 0..n {
if index[root] != UNSEEN {
continue;
}
work.push((root, 0));
while let Some((v, e)) = work.pop() {
if e == 0 {
index[v] = next;
low[v] = next;
next += 1;
stack.push(v);
on_stack[v] = true;
}
if let Some(&w) = links[v].get(e) {
work.push((v, e + 1));
if index[w] == UNSEEN {
work.push((w, 0));
} else if on_stack[w] {
low[v] = low[v].min(index[w]);
}
continue;
}
if low[v] == index[v] {
while let Some(w) = stack.pop() {
on_stack[w] = false;
component[w] = count;
if w == v {
break;
}
}
count += 1;
}
if let Some(&(parent, _)) = work.last() {
low[parent] = low[parent].min(low[v]);
}
}
}
component
}
/// Fix order: every node after the nodes it reads in other components, the earliest ready node
/// first, so an order that is already right stays as it is and otherwise only what must move
/// moves. Reads within a component (a loop) are left out: no order satisfies them.
fn fixed_order(links: &[Vec<usize>], component: &[usize]) -> Vec<usize> {
let n = links.len();
let mut waits = vec![0usize; n];
let mut readers: Vec<Vec<usize>> = vec![Vec::new(); n];
for (k, read) in links.iter().enumerate() {
for &j in read {
if component.get(j) != component.get(k)
&& let (Some(w), Some(r)) = (waits.get_mut(k), readers.get_mut(j))
{
*w += 1;
r.push(k);
}
}
}
let mut ready: BinaryHeap<Reverse<usize>> = waits.iter().enumerate().filter(|(_, w)| **w == 0).map(|(k, _)| Reverse(k)).collect();
let mut out = Vec::with_capacity(n);
while let Some(Reverse(k)) = ready.pop() {
out.push(k);
for &r in readers.get(k).map(Vec::as_slice).unwrap_or_default() {
if let Some(w) = waits.get_mut(r) {
*w = w.saturating_sub(1);
if *w == 0 {
ready.push(Reverse(r));
}
}
}
}
// Without the reads inside loops no loop is left, so every node is placed; kept total anyway.
if out.len() < n {
let placed: HashSet<usize> = out.iter().copied().collect();
out.extend((0..n).filter(|k| !placed.contains(k)));
}
out
}
/// Set the calculation order (Acrobat: Set Field Calculation Order): `names` calculate first, in
/// that order, then the calculated fields left out, in their current order. `/CO` is written
/// afresh, so it lists every calculated field once and nothing else.
///
/// Two calculated fields can share a full name in a malformed form, and the order then names
/// both: a repeated name stands for the next of them in the current order, so the current order
/// sets back unchanged.
pub fn set_calculation_order(doc: &mut Document, names: &[String]) -> Result<(), FormError> {
let all = fields(doc);
let (run, _) = run_order(doc, &all);
let mut by_name: HashMap<&str, VecDeque<usize>> = HashMap::with_capacity(run.len());
for &(i, _) in &run {
if let Some(f) = all.get(i) {
by_name.entry(f.name.as_str()).or_default().push_back(i);
}
}
let mut chosen = HashSet::new();
let mut co = Vec::with_capacity(names.len());
for name in names {
let Some(next) = by_name.get_mut(name.as_str()) else {
return Err(if all.iter().any(|f| f.name == *name) {
FormError::Invalid(format!("{name} has no calculation"))
} else {
FormError::NoSuchField(name.clone())
});
};
let i = next.pop_front().ok_or_else(|| FormError::Invalid(format!("{name} is in the order twice")))?;
if let Some(f) = all.get(i) {
chosen.insert(i);
co.push(Object::Ref(f.obj));
}
}
co.extend(run.iter().filter(|(i, _)| !chosen.contains(i)).filter_map(|&(i, _)| all.get(i)).map(|f| Object::Ref(f.obj)));
write_co(doc, co)
}
#[cfg(test)]
mod tests {
use super::*;
/// Fix order on `links` (node `k` reads `links[k]`), as places.
fn fix(links: &[Vec<usize>]) -> Vec<usize> {
fixed_order(links, &components(links))
}
#[test]
fn loops_are_found_without_recursion() {
// 0 ↔ 1, 2 reads itself, 3 reads 0, 4 alone.
let c = components(&[vec![1], vec![0], vec![2], vec![0], vec![]]);
assert_eq!(c[0], c[1]);
assert!(c[2] != c[0] && c[3] != c[0] && c[4] != c[0] && c[3] != c[4]);
// A chain a million fields long is one pass, not a million nested calls.
let n = 1_000_000;
let chain: Vec<Vec<usize>> = (0..n).map(|k| if k + 1 < n { vec![k + 1] } else { vec![0] }).collect();
let c = components(&chain);
assert!(c.iter().all(|x| *x == c[0]), "one loop through every field");
}
#[test]
fn fix_order_moves_only_what_must_move() {
// Already right: unchanged.
assert_eq!(fix(&[vec![], vec![0], vec![1], vec![]]), [0, 1, 2, 3]);
// 0 reads 2: it moves after 2, the rest keep their order.
assert_eq!(fix(&[vec![2], vec![], vec![], vec![]]), [1, 2, 0, 3]);
// A chain written backwards comes out forwards.
assert_eq!(fix(&[vec![1], vec![2], vec![]]), [2, 1, 0]);
// A loop (0 ↔ 1) keeps its order; 2, which reads it, goes after it.
assert_eq!(fix(&[vec![1], vec![0], vec![]]), [0, 1, 2]);
assert_eq!(fix(&[vec![], vec![2], vec![1]]), [0, 1, 2]);
assert_eq!(fix(&[vec![1, 2], vec![0], vec![]]), [1, 2, 0], "a loop member still waits for what it reads outside the loop");
// Reading itself is a loop, not a reason to move.
assert_eq!(fix(&[vec![0], vec![]]), [0, 1]);
}
#[test]
fn a_name_finds_the_fields_under_it() {
let names = ["row.1", "row", "row.10", "rows", "row-1", "total"];
let reads = vec![vec![], vec![], vec![], vec![], vec![], vec!["row".to_owned(), "missing".to_owned(), "row".to_owned()]];
assert_eq!(links(names.into_iter(), &reads)[5], [0, 1, 2], "row, row.1 and row.10; not rows or row-1");
}
/// Two thousand formulas each reading a group of two thousand fields would be four million
/// reads; the checks stop at their cap.
#[test]
fn reads_of_a_large_group_are_capped() {
let n = 2000;
let names: Vec<String> = (0..n).map(|i| format!("g.{i}")).chain((0..n).map(|i| format!("r{i}"))).collect();
let reads: Vec<Vec<String>> = (0..2 * n).map(|i| if i < n { Vec::new() } else { vec!["g".to_owned()] }).collect();
let total: usize = links(names.iter().map(String::as_str), &reads).iter().map(Vec::len).sum();
assert_eq!(total, MAX_LINKS);
}
proptest::proptest! {
/// Fix order is a reordering of the fields after which none reads a field that calculates
/// later, except within a loop; an order that is already right comes back unchanged.
#[test]
fn fix_order_leaves_nothing_too_early(links in proptest::collection::vec(proptest::collection::vec(0usize..12, 0..4), 1..12)) {
let n = links.len();
let links: Vec<Vec<usize>> = links.into_iter().map(|mut l| { l.retain(|j| *j < n); l.sort_unstable(); l.dedup(); l }).collect();
let component = components(&links);
// Components are exactly the nodes that reach each other.
let reach = |from: usize| {
let mut seen = vec![false; n];
let mut todo = vec![from];
while let Some(v) = todo.pop() {
for &w in &links[v] {
if !seen[w] { seen[w] = true; todo.push(w); }
}
}
seen
};
let reaches: Vec<Vec<bool>> = (0..n).map(reach).collect();
for a in 0..n {
for b in 0..n {
if a != b {
proptest::prop_assert_eq!(component[a] == component[b], reaches[a][b] && reaches[b][a]);
}
}
}
let fixed = fixed_order(&links, &component);
let mut sorted = fixed.clone();
sorted.sort_unstable();
proptest::prop_assert_eq!(sorted, (0..n).collect::<Vec<_>>(), "a reordering");
let mut at = vec![0; n];
for (place, &k) in fixed.iter().enumerate() {
at[k] = place;
}
for (k, read) in links.iter().enumerate() {
for &j in read {
proptest::prop_assert!(component[j] == component[k] || at[j] < at[k], "{k} reads {j}: {fixed:?}");
}
}
// Fixed once, the order needs no more moves.
let renumbered: Vec<Vec<usize>> = fixed.iter().map(|&k| { let mut l: Vec<usize> = links[k].iter().map(|&j| at[j]).collect(); l.sort_unstable(); l }).collect();
proptest::prop_assert_eq!(fix(&renumbered), (0..n).collect::<Vec<_>>());
}
}
}
+4 -5
View File
@@ -5,7 +5,7 @@
//! [`detect`] is a pure function over the page's words and drawn shapes; [`page_shapes`] reads
//! the shapes from a page's content, [`raster::shapes`] from a picture of the page (a scan).
use pdfcraft_content::{Matrix, parse};
use pdfcraft_content::{Matrix, Pieces};
use pdfcraft_cos::{Document, Object};
pub mod raster;
@@ -136,11 +136,10 @@ pub fn page_shapes(doc: &Document, page: usize) -> Shapes {
},
None => Vec::new(),
};
let mut data = Vec::new();
let mut pieces = Vec::new();
for o in list {
if let Object::Stream(s) = &*doc.resolve(&o) {
data.extend(s.decoded().unwrap_or_default());
data.push(b'\n');
pieces.push(s.decoded().unwrap_or_default());
}
}
let mut ctm = Matrix::IDENTITY;
@@ -149,7 +148,7 @@ pub fn page_shapes(doc: &Document, page: usize) -> Shapes {
let mut subpaths: Vec<DetectionPath> = Vec::new();
let mut path = DetectionPath::default();
let mut cur: Option<[f64; 2]> = None;
for op in parse(&data).ops {
for op in Pieces::join(&pieces).parse().ops {
match op.op.as_slice() {
b"q" => stack.push(ctm),
b"Q" => ctm = stack.pop().unwrap_or(ctm),
+223 -38
View File
@@ -7,6 +7,7 @@
//! ([`appearance`]); check boxes and radio buttons switch `/V` and each widget's `/AS` between
//! the states their appearances already define.
//! - [`reset`]: Acrobat's Clear form (back to `/DV`).
//! - [`table`]: a table of text fields, laid out and named before any of it is added.
//!
//! Not yet: JavaScript actions (format, keystroke, validate, calculate — M6.4/M6.5) and rich text
//! values (`/RV`, which is removed when a value is set so it can't contradict `/V`).
@@ -19,13 +20,16 @@ mod actions;
pub mod af;
pub mod appearance;
mod author;
mod calc_order;
pub mod detect;
mod scripting;
pub mod table;
pub use actions::{FieldAction, Trigger, field_actions, set_field_actions};
pub use author::{
BorderStyle, CheckStyle, FieldFont, FieldProps, Look, LookPatch, NewField, add_field, check_style, delete_field, duplicate_field, look,
redraw_field, set_button_icon, set_props,
BorderStyle, CheckStyle, FieldFont, FieldProps, Look, LookPatch, MIN_FIELD_SIDE, NewField, add_field, check_style, delete_field, duplicate_field,
look, redraw_field, set_button_icon, set_props,
};
pub use calc_order::{CalculatedField, CalculationIssue, CalculationOrder, calculation_order, set_calculation_order};
pub use scripting::{
FieldChange, FieldEvent, NoScripts, ScriptResult, Scripts, apply_script_changes, document_scripts, document_scripts_named, set_document_script,
set_field_script,
@@ -101,6 +105,9 @@ 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,
/// Read-only: the field is, or the widget's own `/Ff` says so (a radio button of a group can
/// be locked on its own, as an XFA exclusion group's layout writes it).
pub read_only: bool,
/// `/MK /R` as 0, 90, 180 or 270 degrees counterclockwise. Anything else is stored as 0.
pub rotation: i64,
/// Push buttons: the normal label (`/MK /CA`), separate from the field's name.
@@ -452,25 +459,54 @@ fn nums(doc: &Document, o: Option<&Object>) -> Option<Vec<f64>> {
pub(crate) fn page_refs(doc: &Document) -> Vec<ObjRef> {
let Some(root) = doc.root() else { return Vec::new() };
let Some(pages) = doc.get(root).as_dict().and_then(|d| d.reference(b"Pages")) else { return Vec::new() };
let mut out = Vec::new();
let mut seen = std::collections::HashSet::new();
let mut stack = vec![pages];
while let Some(node) = stack.pop() {
if !seen.insert(node) || seen.len() > 1_000_000 {
continue;
}
let obj = doc.get(node);
let Some(d) = obj.as_dict() else { continue };
match d.get(b"Kids").map(|k| doc.resolve(k)) {
Some(kids) if d.name(b"Type") != Some(b"Page") => {
if let Some(a) = kids.as_array() {
stack.extend(a.iter().rev().filter_map(|k| k.as_ref()));
}
}
_ => out.push(node),
}
PageWalk::new(doc, pages).collect()
}
/// Depth-first walk over the leaf pages under `top`. It keeps one cursor per open `/Kids` array,
/// never one entry per child, so a child listed many times or shared between nodes is skipped by
/// the seen check without being queued first (`pdfcraft_model::pages` walks the same way).
/// Cycles end at the seen check and at most a million nodes are visited.
struct PageWalk<'a> {
doc: &'a Document,
seen: std::collections::HashSet<ObjRef>,
pending: Vec<(std::sync::Arc<Object>, usize)>,
}
impl<'a> PageWalk<'a> {
fn new(doc: &'a Document, top: ObjRef) -> Self {
let top = std::sync::Arc::new(Object::Array(vec![Object::Ref(top)]));
Self { doc, seen: Default::default(), pending: vec![(top, 0)] }
}
}
impl Iterator for PageWalk<'_> {
type Item = ObjRef;
fn next(&mut self) -> Option<ObjRef> {
while let Some((kids, next)) = self.pending.last_mut() {
let Some(kid) = kids.as_array().and_then(|a| a.get(*next)) else {
self.pending.pop();
continue;
};
let node = kid.as_ref();
*next += 1;
let Some(node) = node else { continue };
if self.seen.len() >= 1_000_000 {
self.pending.clear();
return None;
}
if !self.seen.insert(node) {
continue;
}
let obj = self.doc.get(node);
let Some(d) = obj.as_dict() else { continue };
match d.get(b"Kids").map(|k| self.doc.resolve(k)) {
Some(kids) if d.name(b"Type") != Some(b"Page") => self.pending.push((kids, 0)),
_ => return Some(node),
}
}
None
}
out
}
/// The AcroForm dictionary.
@@ -718,6 +754,8 @@ pub fn set_tab_order(doc: &mut Document, pages: &[usize], order: TabOrder) -> Re
Ok(())
}
/// Read the field at `r` and the fields under it into `out`, with what they inherit from
/// `parent`: kids with a name are fields of their own, kids without are this field's widgets.
fn walk(
doc: &Document,
r: ObjRef,
@@ -754,7 +792,10 @@ fn walk(
inh.q = Some(q);
}
if let Some(m) = d.get(b"MaxLen").and_then(|o| doc.resolve(o).as_int()) {
inh.max_len = usize::try_from(m).ok();
// Compatibility: some writers emit zero for an unlimited text field. Keep /MaxLen
// untouched in the object graph, but expose only positive limits to both UI and API.
// An explicit zero also clears an inherited limit rather than inheriting it again.
inh.max_len = usize::try_from(m).ok().filter(|limit| *limit > 0);
}
let kids: Vec<ObjRef> =
d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default().iter().filter_map(|k| k.as_ref()).collect();
@@ -788,6 +829,7 @@ fn walk(
let wd = wo.as_dict()?;
let rect = nums(doc, wd.get(b"Rect")).filter(|r| r.len() == 4).unwrap_or_else(|| vec![0.0; 4]);
let annot_flags = wd.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0);
let own_ff = if w == r { 0 } else { field_flags(doc, wd).unwrap_or(0) };
let on_state = wd
.get(b"AP")
.map(|ap| doc.resolve(ap))
@@ -803,6 +845,7 @@ fn walk(
tab: usize::MAX,
locked: annot_flags & 128 != 0,
hidden: annot_flags & (2 | 32) != 0,
read_only: (ff | own_ff) & flags::READ_ONLY != 0,
rotation: appearance::mk_rotation(doc, wd),
caption: (kind == FieldKind::PushButton)
.then(|| {
@@ -920,25 +963,14 @@ pub fn recalculate(doc: &mut Document) -> Result<usize, FormError> {
/// [`recalculate`], running custom Calculate (and Format) scripts through `scripts`.
pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result<usize, FormError> {
let mut all = fields(doc);
let calculated = |f: &Field| f.actions.calculate != af::Calculate::None || f.actions.scripts.calculate.is_some();
if !all.iter().any(calculated) {
if !all.iter().any(calc_order::is_calculated) {
return Ok(0);
}
let co: Vec<ObjRef> = acroform(doc)
.and_then(|af| af.get(b"CO").map(|c| doc.resolve(c)).and_then(|c| c.as_array().cloned()))
.unwrap_or_default()
.iter()
.filter_map(Object::as_ref)
.collect();
let mut order: Vec<String> = co.iter().filter_map(|r| all.iter().find(|f| f.obj == *r)).map(|f| f.name.clone()).collect();
for f in &all {
if calculated(f) && !order.contains(&f.name) {
order.push(f.name.clone());
}
}
// By object: two fields can share a name in a malformed form, and each calculates.
let order: Vec<ObjRef> = calc_order::run_order(doc, &all).0.into_iter().filter_map(|(i, _)| all.get(i)).map(|f| f.obj).collect();
let mut changed = 0;
for name in order {
let Some(f) = all.iter().find(|f| f.name == name).cloned() else { continue };
for obj in order {
let Some(f) = all.iter().find(|f| f.obj == obj).cloned() else { continue };
let lookup = |n: &str| -> Vec<String> {
let prefix = format!("{n}.");
all.iter().filter(|x| x.name == n || x.name.starts_with(&prefix)).flat_map(|x| x.value.first().cloned()).collect()
@@ -961,7 +993,7 @@ pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result
if current != v && matches!(f.kind, FieldKind::Text | FieldKind::Combo) {
doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text(&v)))?;
redraw(doc, &f, std::slice::from_ref(&v), scripts)?;
if let Some(target) = all.iter_mut().find(|x| x.name == name) {
if let Some(target) = all.iter_mut().find(|x| x.obj == obj) {
target.value = vec![v];
}
changed += 1;
@@ -1243,6 +1275,159 @@ pub fn need_appearances(doc: &Document) -> bool {
acroform(doc).is_some_and(|af| matches!(af.get(b"NeedAppearances").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))))
}
/// A copy of `doc` in which text and choice fields are drawn from their values, for displaying
/// the document (`None` when no widget needs it): every such widget when the form sets
/// `/NeedAppearances` (ISO 32000-2 Table 224: the writer left the appearances to the viewer),
/// and otherwise each widget without a normal appearance (`/AP /N`). Drawn as typing the value
/// would draw it (#3470). `doc` is not changed: saving writes the file as it was.
pub fn with_field_appearances(doc: &Document) -> Option<Document> {
with_field_appearances_cached(doc, &mut FieldAppearanceCache::default())
}
/// [`with_field_appearances`], reusing the appearances `cache` drew for widgets whose inputs are
/// the same objects as then (see [`FieldAppearanceCache`]).
pub fn with_field_appearances_cached(doc: &Document, cache: &mut FieldAppearanceCache) -> Option<Document> {
let all = need_appearances(doc);
let mut copy: Option<Document> = None;
for f in fields(doc).iter().filter(|f| matches!(f.kind, FieldKind::Text | FieldKind::Combo | FieldKind::List)) {
for w in &f.widgets {
let widget = doc.get(w.obj);
let drawn = widget.as_dict().and_then(|d| d.get(b"AP")).is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N")));
if drawn && !all {
continue;
}
let inputs = appearance_inputs(doc, w);
let stream = match cache.get(w.obj, &inputs) {
Some(stream) => stream,
None => {
let (stream, _) = appearance::field_appearance_drawn(doc, f, w, &f.value, true);
cache.insert(w.obj, inputs, &stream);
stream
}
};
let target = copy.get_or_insert_with(|| doc.clone());
let ap = target.add(Object::Stream(stream));
let mut fresh = Dict::new();
fresh.set(b"N".to_vec(), Object::Ref(ap));
let apd = merged_appearance(target, w.obj, &fresh);
// Fails only when the widget isn't a dictionary; that widget then keeps its own look.
target.update_dict(w.obj, |d| d.set(b"AP".to_vec(), Object::Dict(apd))).ok();
}
}
copy
}
/// The objects a widget's drawn appearance is built from, as the document holds them: the widget
/// and the fields above it (value, `/DA`, `/Q`, flags, options, `/MK`, formatting actions), the
/// form dictionary (`/DA`) and the fonts of its `/DR`, each with the objects its entries refer to
/// (an indirect `/MK` or `/V`). Not the page: a field's rotation is its own `/MK /R`.
fn appearance_inputs(doc: &Document, w: &Widget) -> Vec<std::sync::Arc<Object>> {
// The objects a dictionary's entries refer to. The tree links and the drawn appearance itself
// are not inputs, and `/Kids` and `/Fields` can be long.
let referred = |d: &Dict, inputs: &mut Vec<std::sync::Arc<Object>>| {
for (k, v) in d.iter() {
if !matches!(&k[..], b"Kids" | b"Fields" | b"P" | b"Parent" | b"AP")
&& let Some(r) = v.as_ref()
{
inputs.push(doc.get(r));
}
}
};
let mut inputs = Vec::new();
let mut seen = std::collections::HashSet::new();
let mut node = Some(w.obj);
// The /Parent chain; the seen-set and the cap end a cycle or an absurd depth.
while let Some(r) = node.filter(|r| seen.insert(*r) && seen.len() <= 64) {
let obj = doc.get(r);
if let Some(d) = obj.as_dict() {
node = d.reference(b"Parent");
referred(d, &mut inputs);
} else {
node = None;
}
inputs.push(obj);
}
let Some(root) = doc.root() else { return inputs };
let catalog = doc.get(root);
let form = match catalog.as_dict().and_then(|c| c.reference(b"AcroForm")) {
Some(r) => doc.get(r),
None => catalog,
};
if let Some(f) = form.as_dict() {
referred(f, &mut inputs);
// The fonts of /DR. An indirect /DR or /Font is listed through the entries that refer to
// it; a direct one is part of the object that holds it. `held` only finds them: a copy
// of a direct object is a new one each time, never an input.
let held = |o: &Object| o.as_ref().map_or_else(|| std::sync::Arc::new(o.clone()), |r| doc.get(r));
if let Some(dr) = f.get(b"DR").map(held)
&& let Some(dr) = dr.as_dict()
{
referred(dr, &mut inputs);
if let Some(font) = dr.get(b"Font").map(held)
&& let Some(fonts) = font.as_dict()
{
referred(fonts, &mut inputs);
}
}
}
inputs.push(form);
inputs
}
/// Field appearances drawn for the display copy ([`with_field_appearances_cached`]), so a refresh
/// redraws only the widgets whose inputs changed: a form that leaves every appearance to the
/// viewer otherwise redrew all of its fields on each keystroke in any one of them (#3470). An
/// entry holds the input objects it was drawn from and is used only while each is still the same
/// object (`Arc::ptr_eq`); the document replaces an object it edits, so a changed input is a
/// miss, never a stale hit.
#[derive(Clone, Debug, Default)]
pub struct FieldAppearanceCache {
entries: std::collections::HashMap<ObjRef, (Vec<std::sync::Arc<Object>>, pdfcraft_cos::Stream)>,
bytes: usize,
hits: u64,
builds: u64,
}
impl FieldAppearanceCache {
/// Cached appearance data restarts past this instead of growing without bound.
const MAX_BYTES: usize = 64 << 20;
/// Forget every entry.
pub fn clear(&mut self) {
*self = Self::default();
}
fn get(&mut self, w: ObjRef, inputs: &[std::sync::Arc<Object>]) -> Option<pdfcraft_cos::Stream> {
let (from, stream) = self.entries.get(&w)?;
if from.len() != inputs.len() || !from.iter().zip(inputs).all(|(a, b)| std::sync::Arc::ptr_eq(a, b)) {
return None;
}
self.hits += 1;
Some(stream.clone())
}
fn insert(&mut self, w: ObjRef, inputs: Vec<std::sync::Arc<Object>>, stream: &pdfcraft_cos::Stream) {
self.builds += 1;
if self.bytes.saturating_add(stream.raw.len()) > Self::MAX_BYTES {
self.clear();
}
self.bytes = self.bytes.saturating_add(stream.raw.len());
if let Some((_, old)) = self.entries.insert(w, (inputs, stream.clone())) {
self.bytes = self.bytes.saturating_sub(old.raw.len());
}
}
/// Cache hits so far (reused appearances); for tests.
pub fn hits(&self) -> u64 {
self.hits
}
/// Appearances drawn so far; for tests.
pub fn builds(&self) -> u64 {
self.builds
}
}
/// Whether the document's XMP metadata declares PDF/A conformance (`pdfaid:part`).
pub fn declares_pdfa(doc: &Document) -> bool {
let Some(root) = doc.root() else { return false };
+1 -19
View File
@@ -300,25 +300,7 @@ pub fn set_field_script(doc: &mut Document, name: &str, event: &str, js: Option<
}
})?;
if key == b"C" {
let af_ref = crate::author::ensure_form(doc)?;
let mut co: Vec<Object> = doc
.get(af_ref)
.as_dict()
.and_then(|d| d.get(b"CO").cloned())
.map(|c| doc.resolve(&c))
.and_then(|c| c.as_array().cloned())
.unwrap_or_default();
co.retain(|o| o.as_ref() != Some(f.obj));
if js.is_some() {
co.push(Object::Ref(f.obj));
}
doc.update_dict(af_ref, |d| {
if co.is_empty() {
d.remove(b"CO");
} else {
d.set(b"CO".to_vec(), Object::Array(co));
}
})?;
crate::calc_order::keep_listed(doc, f.obj, js.is_some())?;
}
Ok(())
}
+663
View File
@@ -0,0 +1,663 @@
//! A table of text fields (Prepare a form ▸ Text field table): an area divided into rows and
//! columns, one text field in each cell, every column naming its cells from a pattern.
//!
//! The table is laid out in view space: points from the top-left of the page as displayed, after
//! `/Rotate`, so its rows run across the page as the person sees it whatever the page's rotation.
//! The engine maps each cell into user space and adds them all as one undoable edit.
//!
//! [`FieldTable::cells_for`] refuses a table [`add_field`](crate::add_field) would refuse part of
//! (a cell under [`MIN_FIELD_SIDE`], a name with a period, two cells with one name, a name the
//! form already has), so either all of the table is added or none of it is.
use std::collections::HashSet;
use std::fmt;
use crate::MIN_FIELD_SIDE;
/// The most rows a table can have.
pub const MAX_ROWS: usize = 100;
/// The most columns a table can have.
pub const MAX_COLUMNS: usize = 50;
/// The highest number the first row can count from.
pub const MAX_FIRST_ROW: usize = 99_999;
/// One column of a [`FieldTable`].
#[derive(Clone, Debug, PartialEq)]
pub struct TableColumn {
/// How the column names its cells: see [`cell_name`].
pub pattern: String,
/// The column's share of the table's width, against the other columns' (any positive number).
pub width: f64,
}
/// An area divided into rows and columns of text fields.
#[derive(Clone, Debug, PartialEq)]
pub struct FieldTable {
/// The area in view space, `[x0, y0, x1, y1]` with y down, corners in either order.
pub rect: [f64; 4],
pub rows: usize,
pub columns: Vec<TableColumn>,
/// Points left empty between two rows.
pub row_gap: f64,
/// Points left empty between two columns.
pub column_gap: f64,
/// The number the first row's names count from.
pub first_row: usize,
}
/// One cell of a [`FieldTable`]: where its field goes (view space, `[x0, y0, x1, y1]` with y down)
/// and what it is called.
#[derive(Clone, Debug, PartialEq)]
pub struct Cell {
pub rect: [f64; 4],
pub name: String,
}
impl FieldTable {
/// Four rows in one column filling `rect`, named after `base` (see [`column_pattern`]).
pub fn new(rect: [f64; 4], base: &str) -> Self {
FieldTable {
rect,
rows: 4,
columns: vec![TableColumn { pattern: column_pattern(base, 1), width: 1.0 }],
row_gap: 0.0,
column_gap: 0.0,
first_row: 1,
}
}
/// Make the table `n` columns wide, `n` held to 1 to [`MAX_COLUMNS`]. Columns past `n` are
/// dropped; a new one is named after `base` and is as wide as the others are on average, so
/// the widths already set keep their proportions.
pub fn set_columns(&mut self, n: usize, base: &str) {
let n = n.clamp(1, MAX_COLUMNS);
self.columns.truncate(n);
let sum: f64 = self.columns.iter().map(|c| c.width).sum();
let average = sum / self.columns.len().max(1) as f64;
let width = if average.is_finite() && average > 0.0 { average } else { 1.0 };
while self.columns.len() < n {
let pattern = column_pattern(base, self.columns.len() + 1);
self.columns.push(TableColumn { pattern, width });
}
}
/// Where each cell goes and what it is called, row by row. Refuses a table out of bounds or
/// with a cell under [`MIN_FIELD_SIDE`]; the names are not checked (see [`Self::cells_for`]).
pub fn cells(&self) -> Result<Vec<Cell>, TableError> {
let (rows, cols) = (self.rows, self.columns.len());
if !(1..=MAX_ROWS).contains(&rows) {
return Err(TableError::Rows);
}
if !(1..=MAX_COLUMNS).contains(&cols) {
return Err(TableError::Columns);
}
if self.first_row > MAX_FIRST_ROW {
return Err(TableError::FirstRow);
}
let gaps = [self.row_gap, self.column_gap];
if !self.rect.iter().all(|v| v.is_finite())
|| !gaps.iter().all(|g| g.is_finite() && *g >= 0.0)
|| !self.columns.iter().all(|c| c.width.is_finite() && c.width > 0.0)
{
return Err(TableError::Numbers);
}
let r = self.rect;
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]));
// `rows` and `cols` are at most 100 and 50 from here on, so the counts below can't overflow
// and are exact as floats.
let row_height = (y1 - y0 - self.row_gap * (rows - 1) as f64) / rows as f64;
let room = x1 - x0 - self.column_gap * (cols - 1) as f64;
let total: f64 = self.columns.iter().map(|c| c.width).sum();
// Corners far enough apart overflow to infinity.
if !(row_height.is_finite() && room.is_finite() && total.is_finite()) {
return Err(TableError::Numbers);
}
// Every number from here on is finite: the corners, gaps and widths are, and their sums were
// checked above.
if row_height < MIN_FIELD_SIDE {
return Err(TableError::RowsTooShort);
}
let mut spans = Vec::with_capacity(cols);
// The widths of the columns so far: each edge is worked out from the start, not from the
// last edge, so rounding doesn't add up across the table.
let mut before = 0.0;
for (c, column) in self.columns.iter().enumerate() {
let left = x0 + room * before / total + c as f64 * self.column_gap;
before += column.width;
// The last column ends at the table's edge, not where rounding puts it.
let right = if c + 1 == cols { x1 } else { x0 + room * before / total + c as f64 * self.column_gap };
if right - left < MIN_FIELD_SIDE {
return Err(TableError::ColumnTooNarrow);
}
spans.push((left, right));
}
let mut cells = Vec::with_capacity(rows * cols);
for row in 0..rows {
let top = y0 + row as f64 * (row_height + self.row_gap);
let bottom = if row + 1 == rows { y1 } else { top + row_height };
if bottom - top < MIN_FIELD_SIDE {
return Err(TableError::RowsTooShort);
}
for (c, (&(left, right), column)) in spans.iter().zip(&self.columns).enumerate() {
// At most MAX_FIRST_ROW + MAX_ROWS * MAX_COLUMNS.
let seq = self.first_row + row * cols + c;
let name = cell_name(&column.pattern, self.first_row + row, c + 1, seq);
cells.push(Cell { rect: [left, top, right, bottom], name });
}
}
Ok(cells)
}
/// [`Self::cells`] for a form whose fields are called `existing` (full names), refusing a
/// name with a period, a name two cells share and a name the form already has, as a field
/// or as the parent of one.
pub fn cells_for<'a>(&self, existing: impl IntoIterator<Item = &'a str>) -> Result<Vec<Cell>, TableError> {
let cells = self.cells()?;
// "a.b" also takes "a": a new field of that name would sit beside the parent of "a.b".
let mut taken = HashSet::new();
for name in existing {
taken.insert(name);
taken.extend(name.match_indices('.').filter_map(|(at, _)| name.get(..at)));
}
let mut seen = HashSet::with_capacity(cells.len());
for cell in &cells {
let name = cell.name.as_str();
if name.contains('.') {
return Err(TableError::Period(cell.name.clone()));
}
if !seen.insert(name) {
return Err(TableError::Twice(cell.name.clone()));
}
if taken.contains(name) {
return Err(TableError::Taken(cell.name.clone()));
}
}
Ok(cells)
}
}
/// The pattern a new table's column `column` (counted from 1) starts with: `{base}_N` for the
/// first, `{base}_c2_N`, `{base}_c3_N`, … after it.
pub fn column_pattern(base: &str, column: usize) -> String {
if column <= 1 { format!("{base}_N") } else { format!("{base}_c{column}_N") }
}
/// The first of "Table1", "Table2", … that no field in `existing` (full names) starts with, so a
/// new table's names are free. One pass over the names, however many the form has.
pub fn free_base<'a>(existing: impl IntoIterator<Item = &'a str>) -> String {
// The K of every "TableK" a name starts with: "Table1", "Table1_3" and "Table1.x" take 1, and
// "Table12_1" takes 12, not 1.
let taken: HashSet<u64> = existing
.into_iter()
.filter_map(|name| {
let rest = name.strip_prefix("Table")?;
let digits = rest.get(..rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len()))?;
digits.parse().ok()
})
.collect();
// One of the first `taken.len() + 1` is always free.
let k = (1..=taken.len() as u64 + 1).find(|k| !taken.contains(k)).unwrap_or(1);
format!("Table{k}")
}
/// What may stand on either side of an `N` that is the row number.
const SEPARATORS: [char; 4] = ['_', '-', '/', ':'];
/// The name `pattern` gives the cell in `row` and `column` (`column` counted from 1); `seq` is the
/// cell's number counting along the rows.
///
/// - `{row}`, `{r}`, `{n}` and `#`: the row number; `[row]`, `[r]` and `[n]`: the row number in
/// brackets (`item[N]` → `item[1]`);
/// - `{col}` and `{c}`, `[col]` and `[c]`: the column number;
/// - `{seq}` and `[seq]`: the cell number, counting along the rows from the table's first row
/// number, so a table starting at row 1 numbers its cells 1, 2, 3, …;
/// - with no row or cell number, an `N` or `n` standing alone after `_`, `-`, `/` or `:`
/// (`price_N`, `line-n-total`) is the row number, so the N in `Name` or `NET_N` stays;
/// - with no row or cell number still, the row number goes at the end.
///
/// Braces and brackets match ignoring case. Anything else is kept as written, a period included,
/// though no field name may contain one: [`FieldTable::cells_for`] refuses those.
pub fn cell_name(pattern: &str, row: usize, column: usize, seq: usize) -> String {
let row_number = row.to_string();
let mut out = String::with_capacity(pattern.len() + 8);
// Whether the name has a row or cell number yet.
let mut numbered = false;
let mut rest = pattern.trim();
while let Some(at) = rest.find(['{', '[', '#']) {
// `at` is where an ASCII character starts, so both halves are whole characters.
let (before, from) = rest.split_at(at);
out.push_str(before);
if let Some(after) = from.strip_prefix('#') {
out.push_str(&row_number);
numbered = true;
rest = after;
continue;
}
let (open, close) = if from.starts_with('[') { ('[', ']') } else { ('{', '}') };
let inside = from.get(1..).unwrap_or_default();
let token = inside.split_once(close).and_then(|(word, after)| {
let is = |name: &str| word.eq_ignore_ascii_case(name);
if is("row") || is("r") || is("n") {
Some((row, true, after))
} else if is("seq") {
Some((seq, true, after))
} else if is("col") || is("c") {
Some((column, false, after))
} else {
None
}
});
match token {
Some((n, counts, after)) => {
if open == '[' {
out.push('[');
}
out.push_str(&n.to_string());
if open == '[' {
out.push(']');
}
numbered |= counts;
rest = after;
}
None => {
out.push(open);
rest = inside;
}
}
}
out.push_str(rest);
if numbered {
return out;
}
if out == "N" || out == "n" {
return row_number;
}
let chars: Vec<char> = out.chars().collect();
let mut named = String::with_capacity(out.len() + row_number.len());
for (i, &ch) in chars.iter().enumerate() {
let after_separator = i.checked_sub(1).and_then(|p| chars.get(p)).is_some_and(|c| SEPARATORS.contains(c));
let before_separator = chars.get(i + 1).is_none_or(|c| SEPARATORS.contains(c));
if matches!(ch, 'N' | 'n') && after_separator && before_separator {
named.push_str(&row_number);
numbered = true;
} else {
named.push(ch);
}
}
if !numbered {
named.push_str(&row_number);
}
named
}
/// Why a table can't be added. Each is a mistake a person can make, with the numbers and names
/// apart from the words, so an interface can say it in its own language with [`TableError::said`].
#[derive(Clone, Debug, PartialEq)]
pub enum TableError {
/// Fewer than one row, or more than [`MAX_ROWS`].
Rows,
/// Fewer than one column, or more than [`MAX_COLUMNS`].
Columns,
/// A first row number past [`MAX_FIRST_ROW`].
FirstRow,
/// A corner, gap or width that is not a usable number.
Numbers,
/// The rows come out under [`MIN_FIELD_SIDE`] high.
RowsTooShort,
/// A column comes out under [`MIN_FIELD_SIDE`] wide.
ColumnTooNarrow,
/// A cell's name has a period, which would make it a child of another field.
Period(String),
/// Two cells would have this name.
Twice(String),
/// The form already has a field of this name.
Taken(String),
}
const ROWS: &str = "a table has 1 to {max} rows";
const COLUMNS: &str = "a table has 1 to {max} columns";
const FIRST_ROW: &str = "the first row number can be at most {max}";
const NUMBERS: &str = "the area, the spacing and the column widths must be numbers, the spacing 0 or more and each width more than 0";
const ROWS_TOO_SHORT: &str = "the rows are too short: make the area taller, or use fewer rows or less row spacing";
const COLUMN_TOO_NARROW: &str =
"a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing";
const PERIOD: &str = "field names can't contain a period: {name}";
const TWICE: &str = "two cells would both be named {name}";
const TAKEN: &str = "a field named {name} already exists";
/// Every sentence a [`TableError`] says, in English with its holes still in it, for an interface
/// to translate; `wordings_are_said` keeps the list in step with the errors.
pub const WORDINGS: &[&str] = &[ROWS, COLUMNS, FIRST_ROW, NUMBERS, ROWS_TOO_SHORT, COLUMN_TOO_NARROW, PERIOD, TWICE, TAKEN];
/// How an interface says one of our wordings in its own language, holes and all: given the English
/// in [`WORDINGS`], the same wording in that language. [`in_english`] keeps the English.
pub type Words<'a> = &'a dyn Fn(&str) -> String;
/// The [`Words`] that translate nothing: the wording as it is written here.
pub fn in_english(wording: &str) -> String {
wording.to_owned()
}
impl TableError {
/// This error as one sentence, said in the language `words` translates into. The wording is
/// translated and the numbers and names go in afterwards, in a single pass, so a name with
/// braces in it is never read as a hole. With [`in_english`] this is the error's `Display`.
pub fn said(&self, words: Words) -> String {
let limit = |wording: &str, max: usize| say(&words(wording), &[("{max}", max.to_string())]);
// A name goes in quoted, as `add_field` quotes it.
let named = |wording: &str, name: &str| say(&words(wording), &[("{name}", format!("{name:?}"))]);
match self {
TableError::Rows => limit(ROWS, MAX_ROWS),
TableError::Columns => limit(COLUMNS, MAX_COLUMNS),
TableError::FirstRow => limit(FIRST_ROW, MAX_FIRST_ROW),
TableError::Numbers => words(NUMBERS),
TableError::RowsTooShort => words(ROWS_TOO_SHORT),
TableError::ColumnTooNarrow => words(COLUMN_TOO_NARROW),
TableError::Period(name) => named(PERIOD, name),
TableError::Twice(name) => named(TWICE, name),
TableError::Taken(name) => named(TAKEN, name),
}
}
}
impl fmt::Display for TableError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.said(&in_english))
}
}
impl std::error::Error for TableError {}
/// A wording with its holes filled. A hole is a name in braces, such as `{max}`; what is not in
/// `values` is left as it is written.
fn say(wording: &str, values: &[(&str, String)]) -> String {
let mut out = String::with_capacity(wording.len() + 8);
let mut rest = wording;
while let Some(open) = rest.find('{') {
let (before, from_brace) = rest.split_at(open);
out.push_str(before);
// `find` counts from the brace and `}` is one byte, so this is a char boundary.
let Some(close) = from_brace.find('}') else {
out.push_str(from_brace);
return out;
};
let Some(hole) = from_brace.get(..=close) else { return out + from_brace };
match values.iter().find(|(name, _)| *name == hole) {
Some((_, value)) => out.push_str(value),
None => out.push_str(hole),
}
rest = from_brace.get(close + 1..).unwrap_or_default();
}
out.push_str(rest);
out
}
#[cfg(test)]
mod tests {
use proptest::prelude::*;
use super::*;
fn table(rect: [f64; 4], rows: usize, patterns: &[&str]) -> FieldTable {
FieldTable {
rect,
rows,
columns: patterns.iter().map(|p| TableColumn { pattern: (*p).to_owned(), width: 1.0 }).collect(),
row_gap: 0.0,
column_gap: 0.0,
first_row: 1,
}
}
#[test]
fn a_row_number_goes_where_the_pattern_says() {
assert_eq!(cell_name("fieldCategory_N", 1, 1, 1), "fieldCategory_1");
assert_eq!(cell_name("fieldCategory_N", 10, 1, 10), "fieldCategory_10");
assert_eq!(cell_name("row_{n}", 3, 1, 3), "row_3");
assert_eq!(cell_name("item-#", 5, 1, 5), "item-5");
assert_eq!(cell_name("cell[N]", 2, 1, 2), "cell[2]");
assert_eq!(cell_name("N", 4, 1, 4), "4");
assert_eq!(cell_name("line-n-total", 6, 1, 6), "line-6-total");
assert_eq!(cell_name("a:N/b", 7, 1, 7), "a:7/b");
}
#[test]
fn rows_columns_and_cells_have_their_own_tokens() {
assert_eq!(cell_name("item_{row}_{col}", 2, 3, 5), "item_2_3");
assert_eq!(cell_name("cell_R{row}C{col}", 1, 4, 4), "cell_R1C4");
assert_eq!(cell_name("grid[{r},{c}]", 3, 2, 8), "grid[3,2]");
assert_eq!(cell_name("table_r{r}_c{c}", 4, 5, 20), "table_r4_c5");
assert_eq!(cell_name("cell{seq}", 2, 2, 7), "cell7");
assert_eq!(cell_name("cell[seq]", 2, 2, 7), "cell[7]");
// Braces and brackets match ignoring case.
assert_eq!(cell_name("x{ROW}y{Col}z[SEQ]", 2, 3, 9), "x2y3z[9]");
// A column number alone leaves the row to be found or added.
assert_eq!(cell_name("price{c}_N", 2, 3, 9), "price3_2");
assert_eq!(cell_name("col{c}", 1, 2, 2), "col21");
}
#[test]
fn without_a_row_token_the_row_number_goes_at_the_end() {
assert_eq!(cell_name("", 1, 1, 1), "1");
assert_eq!(cell_name(" ", 3, 1, 3), "3");
assert_eq!(cell_name("test_", 1, 1, 1), "test_1");
assert_eq!(cell_name("Phone_Number", 4, 1, 4), "Phone_Number4");
// What isn't a token is kept as written.
assert_eq!(cell_name("{x}_N", 1, 1, 1), "{x}_1");
assert_eq!(cell_name("{row", 1, 1, 1), "{row1");
assert_eq!(cell_name("a]b[", 2, 1, 2), "a]b[2");
// A period is kept, for `cells_for` to refuse.
assert_eq!(cell_name("test.", 1, 1, 1), "test.1");
}
#[test]
fn an_n_inside_a_word_is_not_the_row_number() {
assert_eq!(cell_name("Name_N", 1, 1, 1), "Name_1");
assert_eq!(cell_name("ItemNumber_N", 2, 1, 2), "ItemNumber_2");
assert_eq!(cell_name("NET_AMOUNT_N", 3, 1, 3), "NET_AMOUNT_3");
assert_eq!(cell_name("CustomerName_{row}", 5, 1, 5), "CustomerName_5");
assert_eq!(cell_name("NOTE_#", 6, 1, 6), "NOTE_6");
assert_eq!(cell_name("BankName[N]", 7, 1, 7), "BankName[7]");
assert_eq!(cell_name("PLAN_N", 8, 1, 8), "PLAN_8");
assert_eq!(cell_name("line_n", 9, 1, 9), "line_9");
assert_eq!(cell_name("Prix_é_N", 1, 1, 1), "Prix_é_1");
assert_eq!(cell_name("名前_N", 2, 1, 2), "名前_2");
}
#[test]
fn a_grid_fills_its_area_with_gaps_between_the_cells() {
let mut t = table([100.0, 100.0, 300.0, 300.0], 2, &["cell_{r}_{c}", "cell_{r}_{c}"]);
t.row_gap = 10.0;
t.column_gap = 20.0;
// Width (200 - 20) / 2 = 90, height (200 - 10) / 2 = 95, rows from the top down.
let cells = t.cells().unwrap();
let got: Vec<_> = cells.iter().map(|c| (c.rect, c.name.as_str())).collect();
assert_eq!(
got,
[
([100.0, 100.0, 190.0, 195.0], "cell_1_1"),
([210.0, 100.0, 300.0, 195.0], "cell_1_2"),
([100.0, 205.0, 190.0, 300.0], "cell_2_1"),
([210.0, 205.0, 300.0, 300.0], "cell_2_2"),
]
);
// Corners in either order are the same area.
t.rect = [300.0, 300.0, 100.0, 100.0];
assert_eq!(t.cells().unwrap(), cells);
}
#[test]
fn columns_share_the_width_by_their_widths() {
let mut t = table([100.0, 100.0, 300.0, 300.0], 2, &["desc_N", "price_N"]);
t.columns[0].width = 3.0;
let cells = t.cells().unwrap();
assert_eq!(cells[0].rect, [100.0, 100.0, 250.0, 200.0]);
assert_eq!(cells[1].rect, [250.0, 100.0, 300.0, 200.0]);
assert_eq!((cells[2].name.as_str(), cells[3].name.as_str()), ("desc_2", "price_2"));
// However the shares round, the last column ends at the table's edge.
let mut t = table([0.0, 0.0, 100.0, 50.0], 1, &["a", "b", "c"]);
t.column_gap = 1.0 / 3.0;
assert_eq!(t.cells().unwrap()[2].rect[2], 100.0);
}
#[test]
fn rows_count_from_the_first_row_and_cells_along_the_rows() {
let mut t = table([0.0, 0.0, 100.0, 100.0], 2, &["a_{seq}_{r}", "b_{seq}_{r}"]);
t.first_row = 10;
let names: Vec<_> = t.cells().unwrap().into_iter().map(|c| c.name).collect();
assert_eq!(names, ["a_10_10", "b_11_10", "a_12_11", "b_13_11"]);
}
#[test]
fn a_table_out_of_bounds_is_refused() {
let ok = table([0.0, 0.0, 200.0, 200.0], 4, &["a_N"]);
assert!(ok.cells().is_ok());
let with = |f: &dyn Fn(&mut FieldTable)| {
let mut t = ok.clone();
f(&mut t);
t.cells()
};
assert_eq!(with(&|t| t.rows = 0), Err(TableError::Rows));
assert_eq!(with(&|t| t.rows = MAX_ROWS + 1), Err(TableError::Rows));
assert_eq!(with(&|t| t.columns.clear()), Err(TableError::Columns));
// 200 points hold 50 columns of exactly 4, and 199 don't.
assert_eq!(with(&|t| t.set_columns(MAX_COLUMNS, "x")).map(|c| c.len()), Ok(4 * MAX_COLUMNS));
assert_eq!(
with(&|t| {
t.rect[2] = 199.0;
t.set_columns(MAX_COLUMNS, "x");
}),
Err(TableError::ColumnTooNarrow)
);
assert_eq!(with(&|t| t.columns = vec![t.columns[0].clone(); MAX_COLUMNS + 1]), Err(TableError::Columns));
assert_eq!(with(&|t| t.first_row = MAX_FIRST_ROW + 1), Err(TableError::FirstRow));
assert_eq!(with(&|t| t.rect[0] = f64::NAN), Err(TableError::Numbers));
assert_eq!(with(&|t| t.rect = [-f64::MAX, 0.0, f64::MAX, 200.0]), Err(TableError::Numbers));
assert_eq!(with(&|t| t.row_gap = -1.0), Err(TableError::Numbers));
assert_eq!(with(&|t| t.column_gap = f64::INFINITY), Err(TableError::Numbers));
assert_eq!(with(&|t| t.columns[0].width = 0.0), Err(TableError::Numbers));
// 200 points hold 50 rows of exactly 4, not 51.
assert_eq!(with(&|t| t.rows = 50).map(|c| c.len()), Ok(50));
assert_eq!(with(&|t| t.rows = 51), Err(TableError::RowsTooShort));
assert_eq!(with(&|t| t.row_gap = 70.0), Err(TableError::RowsTooShort));
assert_eq!(with(&|t| t.rect = [0.0, 0.0, 3.0, 200.0]), Err(TableError::ColumnTooNarrow));
// One column squeezed under 4 points by the others' widths.
assert_eq!(
with(&|t| t.columns = vec![TableColumn { pattern: "a_N".into(), width: 1.0 }, TableColumn { pattern: "b_N".into(), width: 99.0 }]),
Err(TableError::ColumnTooNarrow)
);
}
#[test]
fn names_a_form_would_refuse_are_refused_before_anything_is_added() {
let t = table([0.0, 0.0, 200.0, 200.0], 2, &["a_N", "b_N"]);
assert!(t.cells_for(["c_1", "a_10"]).is_ok());
assert_eq!(t.cells_for(["x", "b_2"]), Err(TableError::Taken("b_2".into())));
// The parent of an existing field is taken too.
assert_eq!(t.cells_for(["a_1.street"]), Err(TableError::Taken("a_1".into())));
let same = table([0.0, 0.0, 200.0, 200.0], 2, &["item_N", "item_N"]);
assert_eq!(same.cells_for([]), Err(TableError::Twice("item_1".into())));
let dotted = table([0.0, 0.0, 200.0, 200.0], 2, &["row.{n}"]);
assert_eq!(dotted.cells_for([]), Err(TableError::Period("row.1".into())));
}
#[test]
fn a_new_table_takes_the_first_free_base() {
assert_eq!(free_base([]), "Table1");
assert_eq!(free_base(["Table1_1", "Table1_c2_4"]), "Table2");
assert_eq!(free_base(["Table1", "Table2.x", "Table3_N"]), "Table4");
// "Table12_1" belongs to Table12, not Table1.
assert_eq!(free_base(["Table12_1", "Tables"]), "Table1");
// As fast for a form with thousands of fields: one pass, not one per candidate.
let many: Vec<String> = (1..=20_000).map(|k| format!("Table{k}_1")).collect();
assert_eq!(free_base(many.iter().map(String::as_str)), "Table20001");
let t = FieldTable::new([0.0, 0.0, 200.0, 200.0], &free_base(["Table1_1"]));
let names: Vec<_> = t.cells_for(["Table1_1"]).unwrap().into_iter().map(|c| c.name).collect();
assert_eq!(names, ["Table2_1", "Table2_2", "Table2_3", "Table2_4"]);
}
#[test]
fn added_columns_keep_the_widths_already_set() {
let mut t = FieldTable::new([0.0, 0.0, 400.0, 100.0], "T");
t.columns[0].width = 2.0;
t.set_columns(3, "T");
let got: Vec<_> = t.columns.iter().map(|c| (c.pattern.as_str(), c.width)).collect();
assert_eq!(got, [("T_N", 2.0), ("T_c2_N", 2.0), ("T_c3_N", 2.0)]);
t.columns[2].width = 4.0;
t.set_columns(4, "T");
assert_eq!(t.columns[3].width, 8.0 / 3.0);
t.set_columns(2, "T");
assert_eq!(t.columns.len(), 2);
// Held to the bounds.
t.set_columns(0, "T");
assert_eq!(t.columns.len(), 1);
t.set_columns(usize::MAX, "T");
assert_eq!(t.columns.len(), MAX_COLUMNS);
}
#[test]
fn wordings_are_said() {
let mistakes = [
TableError::Rows,
TableError::Columns,
TableError::FirstRow,
TableError::Numbers,
TableError::RowsTooShort,
TableError::ColumnTooNarrow,
TableError::Period("a.1".into()),
TableError::Twice("a_1".into()),
TableError::Taken("a_1".into()),
];
assert_eq!(WORDINGS.len(), mistakes.len(), "one wording per mistake");
// Each mistake says one of the wordings: translated to a marker, only the values remain.
for e in &mistakes {
let marked = e.said(&|w| format!("<{}>", WORDINGS.iter().position(|x| *x == w).unwrap_or(usize::MAX)));
assert!(!marked.contains(&usize::MAX.to_string()), "{e:?} says a wording that isn't listed");
assert_eq!(e.said(&in_english), e.to_string());
}
assert_eq!(TableError::Rows.to_string(), "a table has 1 to 100 rows");
assert_eq!(TableError::Taken("a_1".into()).to_string(), "a field named \"a_1\" already exists");
// A name goes in as it stands, braces and all, and is never read as a hole.
let other = |w: &str| if w == TAKEN { "{name} existe ya".to_owned() } else { w.to_owned() };
assert_eq!(TableError::Taken("{max}".into()).said(&other), "\"{max}\" existe ya");
}
proptest! {
/// Any pattern names every cell, and never panics.
#[test]
fn any_pattern_gives_a_name(pattern in "(\\PC|[{}\\[\\]#Nn_:/.-]){0,24}", row in 0usize..200_000, column in 1usize..60, seq in 0usize..300_000) {
let name = cell_name(&pattern, row, column, seq);
prop_assert!(!name.is_empty());
prop_assert_eq!(name.trim(), name.as_str());
}
/// Any table either lays out cells of at least the smallest side inside its area, or says
/// why not.
#[test]
fn any_table_lays_out_or_says_why(
rect in prop::array::uniform4(-2000.0f64..2000.0),
rows in 0usize..120,
widths in prop::collection::vec(0.01f64..100.0, 0..60),
row_gap in -1.0f64..50.0,
column_gap in -1.0f64..50.0,
) {
let t = FieldTable {
rect,
rows,
columns: widths.iter().map(|w| TableColumn { pattern: "a{seq}".into(), width: *w }).collect(),
row_gap,
column_gap,
first_row: 1,
};
if let Ok(cells) = t.cells_for([]) {
prop_assert_eq!(cells.len(), rows * widths.len());
let (x0, y0, x1, y1) = (rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3]));
for c in &cells {
let [l, t, r, b] = c.rect;
prop_assert!(r - l >= MIN_FIELD_SIDE && b - t >= MIN_FIELD_SIDE, "{:?}", c);
prop_assert!(l >= x0 - 1e-6 && r <= x1 + 1e-6 && t >= y0 - 1e-6 && b <= y1 + 1e-6, "{:?}", c);
}
}
}
}
}
+626
View File
@@ -80,6 +80,7 @@ fn field<'a>(all: &'a [Field], name: &str) -> &'a Field {
all.iter().find(|f| f.name == name).unwrap_or_else(|| panic!("no field {name}"))
}
/// The decoded text of widget `w`'s normal (`/N`) appearance stream.
fn ap(doc: &Document, w: &Widget) -> String {
let wd = doc.get(w.obj).as_dict().cloned().unwrap();
let n = wd.get(b"AP").unwrap().as_dict().unwrap().reference(b"N").expect("normal appearance stream");
@@ -87,6 +88,58 @@ fn ap(doc: &Document, w: &Widget) -> String {
String::from_utf8_lossy(&s.decoded().unwrap()).into_owned()
}
/// A push button's caption is drawn in the font, size and color its `/DA` asks for; with an
/// automatic size (0) it is sized to fit the button, in the form's default font and color.
#[test]
fn a_button_caption_is_drawn_in_its_fields_font_size_and_color() {
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 [7 0 R 8 0 R] >>".into(),
"<< /Fields [7 0 R 8 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R /HeBo 6 0 R >> >> >>".into(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>".into(),
// Its /DA asks for 9pt Helvetica Bold in blue.
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (menu) /Ff 65536 /DA (/HeBo 9 Tf 0 0 1 rg) /MK << /CA (Menu) >> /Rect [50 700 140 711] /P 3 0 R >>".into(),
// An automatic size (0): sized to the button, as before.
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (auto) /Ff 65536 /MK << /CA (Auto) >> /Rect [50 600 140 630] /P 3 0 R >>".into(),
];
let mut doc = document(&objs);
redraw_field(&mut doc, "menu").unwrap();
redraw_field(&mut doc, "auto").unwrap();
let all = fields(&doc);
let menu = ap(&doc, &field(&all, "menu").widgets[0]);
assert!(menu.contains("/HeBo 9.00 Tf") && menu.contains("0 0 1 rg"), "{menu}");
let auto = ap(&doc, &field(&all, "auto").widgets[0]);
assert!(auto.contains("/Helv 14.00 Tf") && auto.contains(" 0 g"), "{auto}");
}
/// A button caption in a font whose UCS-2 CMap holds 签 is not reported; once `set_props` adds a
/// character beyond U+FFFF, the caption is still drawn in that font and `undrawn_fields` reports
/// the character.
#[test]
fn a_button_caption_its_fonts_encoding_cant_hold_is_reported() {
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 [7 0 R] >>".into(),
"<< /Fields [7 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R /CN 6 0 R >> >> >>".into(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
"<< /Type /Font /Subtype /Type0 /BaseFont /STSong-Light /Encoding /UniGB-UCS2-H /DescendantFonts [<< /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 4 >> >>] >>".into(),
// A UCS-2 CMap holds 签 but not a character beyond U+FFFF, which is drawn as `?`.
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (go) /Ff 65536 /DA (/CN 9 Tf 0 g) /MK << /CA <FEFF7B7E>> >> /Rect [50 700 140 711] /P 3 0 R >>".into(),
];
let mut doc = document(&objs);
redraw_field(&mut doc, "go").unwrap();
assert!(undrawn_fields(&doc).is_empty(), "签 is drawn");
set_props(&mut doc, "go", &FieldProps { caption: Some((0, "签\u{2000B}".into())), ..Default::default() }).unwrap();
assert!(ap(&doc, &field(&fields(&doc), "go").widgets[0]).contains("/CN 9.00 Tf"));
assert_eq!(undrawn_fields(&doc), [("go".to_string(), '\u{2000B}')]);
}
/// Changing one widget's caption on a push button with two widgets leaves the other widget, the
/// shared `/MK` and the parent's action untouched, keeps the other `/MK` and `/AP` entries, and
/// drops the stale rollover and down appearances, with a direct or indirect caption and either save.
#[test]
fn button_caption_preserves_other_widgets_and_shared_dictionaries() {
for indirect_caption in [false, true] {
@@ -379,6 +432,44 @@ fn text_fields_get_new_appearances() {
assert!(raw.windows(8).any(|x| x == b"(Z\xfcrich)"));
}
/// Acrobat regenerates only the `/Tx BMC` block when it redraws a text field, so background and
/// border must live inside it rather than immediately before it (#1613).
fn marked_content_borders_field(appearance: &str, label: &str) {
let start = appearance.find("/Tx BMC").expect("no /Tx BMC");
let marked = appearance[start..].split_once("EMC").expect("no matching EMC").0;
assert!(marked.contains("1 1 0.9 rg"), "background inside /Tx BMC: {appearance} {label}");
assert!(marked.contains("0 0 0 RG\n1 w\n0.5 0.5 199 19 re S"), "border inside /Tx BMC: {appearance} {label}");
}
#[test]
fn text_and_date_fields_keep_background_and_border_inside_marked_content() {
let mut doc = one_page();
add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, Some("name")).unwrap();
add_field(&mut doc, 0, [50.0, 660.0, 250.0, 680.0], &NewField::Date, Some("due")).unwrap();
let props = || FieldProps {
look: Some(Look {
border: Some([0.0; 3]),
fill: Some([1.0, 1.0, 0.9]),
width: 1.0,
style: BorderStyle::Solid,
text: [0.0, 0.0, 0.0],
font: FieldFont::Helvetica,
}),
..FieldProps::default()
};
set_props(&mut doc, "name", &props()).unwrap();
set_props(&mut doc, "due", &props()).unwrap();
set_value(&mut doc, "name", &FieldValue::Text("Ada".into())).unwrap();
set_value(&mut doc, "due", &FieldValue::Text("10/14/2026".into())).unwrap();
let name_ap = ap(&doc, &field(&fields(&doc), "name").widgets[0]);
let due_ap = ap(&doc, &field(&fields(&doc), "due").widgets[0]);
marked_content_borders_field(&name_ap, "text field");
marked_content_borders_field(&due_ap, "date field");
assert!(name_ap.contains("(Ada) Tj"), "{name_ap}");
assert!(due_ap.contains("(10/14/2026) Tj"), "{due_ap}");
assert!(!need_appearances(&doc), "drawable ASCII fields do not ask other viewers to redraw");
}
#[test]
fn existing_field_font_encoding_keeps_italian_punctuation_visible() {
// ReportLab-style Helvetica: an indirect encoding dictionary with PDFDoc-like Differences.
@@ -466,6 +557,82 @@ fn multiline_wrapping_keeps_encodable_text_without_requiring_ascii_space() {
assert_eq!(metrics.decode(&lines.concat()), value.replace('\n', ""));
}
/// A field drawn in its `/DR` font is laid out with that font's widths, not Helvetica's (#2894):
/// Courier is 0.6 em a character, so 10 pt text holds 94 to a line in a 564 pt box, and a
/// right-aligned value ends at the padding in Courier and in Times.
#[test]
fn reused_standard_fonts_wrap_and_align_with_their_own_widths() {
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 612 792] >>".into(),
"<< /Type /Page /Parent 2 0 R /Annots [7 0 R 8 0 R 9 0 R] >>".into(),
"<< /Fields [7 0 R 8 0 R 9 0 R] /DA (/Cour 10 Tf 0 g) /DR << /Font << /Cour 5 0 R /TiRo 6 0 R >> >> >>".into(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>".into(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Times-Roman /Encoding /WinAnsiEncoding >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (notes) /Rect [21 537 591 722] /Ff 4096 /DA (/Cour 10 Tf 0 g) /MK << /BC [0 0 0] >> /P 3 0 R >>"
.into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (code) /Q 2 /Rect [21 500 221 520] /DA (/Cour 10 Tf 0 g) /P 3 0 R >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (initials) /Q 2 /Rect [21 470 221 490] /DA (/TiRo 10 Tf 0 g) /P 3 0 R >>".into(),
];
let mut doc = document(&objs);
let value = "The quick brown fox jumps over the lazy dog, then it runs into the field and keeps on going well past the right edge of the box. Every line here is plain mixed-case ASCII text in Courier at 10 points.";
set_value(&mut doc, "notes", &FieldValue::Text(value.into())).unwrap();
set_value(&mut doc, "code", &FieldValue::Text("iiii".into())).unwrap();
set_value(&mut doc, "initials", &FieldValue::Text("iiii".into())).unwrap();
let doc = reopen(&doc);
let all = fields(&doc);
let (_, lines) = appearance_text(&doc, &field(&all, "notes").widgets[0], b"Cour");
let lengths: Vec<usize> = lines.iter().map(Vec::len).collect();
assert!(lengths.iter().all(|&l| l <= 94), "every line fits the box in Courier: {lengths:?}");
assert!(lengths[0] > 85, "and the first is not wrapped early: {lengths:?}");
// Where each right-aligned value starts: the box is 200 pt with 2 pt padding, so `iiii` ends
// at 198 and starts 24 pt (Courier) or 11.12 pt (Times) before it, against 8.88 in Helvetica.
let start = |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 tm = pdfcraft_content::parse(&s.decoded().unwrap()).ops.into_iter().find(|op| op.is("Tm")).unwrap();
tm.operands[4].as_f64().unwrap()
};
assert!((start("code") - 174.0).abs() < 1e-3, "Courier: {}", start("code"));
assert!((start("initials") - 186.88).abs() < 1e-3, "Times: {}", start("initials"));
}
/// A standard-14 font whose `/Differences` swap A and W draws `AAAA` with code 87, so it is
/// measured as four A's (0.667 em), not as the W that code has in WinAnsi. The same remap with
/// `/Widths` takes the width the font gives code 87, here 0.5 em.
#[test]
fn remapped_standard_fonts_are_measured_by_the_glyph_each_code_draws() {
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 612 792] >>".into(),
"<< /Type /Page /Parent 2 0 R /Annots [7 0 R 8 0 R] >>".into(),
"<< /Fields [7 0 R 8 0 R] /DA (/Dif 10 Tf 0 g) /DR << /Font << /Dif 5 0 R /DifW 6 0 R >> >> >>".into(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding << /BaseEncoding /WinAnsiEncoding /Differences [65 /W 87 /A] >> >>"
.into(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /FirstChar 87 /LastChar 87 /Widths [500] /Encoding << /BaseEncoding /WinAnsiEncoding /Differences [65 /W 87 /A] >> >>"
.into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (std) /Q 2 /Rect [21 500 221 520] /DA (/Dif 10 Tf 0 g) /P 3 0 R >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (widths) /Q 2 /Rect [21 470 221 490] /DA (/DifW 10 Tf 0 g) /P 3 0 R >>".into(),
];
let mut doc = document(&objs);
set_value(&mut doc, "std", &FieldValue::Text("AAAA".into())).unwrap();
set_value(&mut doc, "widths", &FieldValue::Text("AAAA".into())).unwrap();
let doc = reopen(&doc);
let all = fields(&doc);
assert_eq!(appearance_text(&doc, &field(&all, "std").widgets[0], b"Dif").1, vec![b"WWWW".to_vec()], "A is drawn by code 87");
let start = |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 tm = pdfcraft_content::parse(&s.decoded().unwrap()).ops.into_iter().find(|op| op.is("Tm")).unwrap();
tm.operands[4].as_f64().unwrap()
};
// The text ends at 198 (200 pt box, 2 pt padding): 26.68 pt of A's, or 20 pt by /Widths.
assert!((start("std") - 171.32).abs() < 1e-3, "no /Widths: {}", start("std"));
assert!((start("widths") - 178.0).abs() < 1e-3, "/Widths: {}", start("widths"));
}
#[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
@@ -935,6 +1102,80 @@ fn properties_rename_and_delete() {
assert_eq!(annots.as_array().unwrap().len(), 13 - 3, "the deleted widgets left the page");
}
/// A parent `MASTER` with kids `a` and `b` (each a field merged with its widget) and `e` (a field
/// with two widget kids), and the top-level fields `c` and `d`.
fn dotted_form() -> Document {
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 9 0 R 11 0 R 12 0 R] >>".into(), // 3
"<< /Fields [5 0 R 8 0 R 9 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), // 4
"<< /T (MASTER) /Kids [6 0 R 7 0 R 10 0 R] >>".into(), // 5
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (a) /Parent 5 0 R /V (one) /Rect [50 700 250 720] /P 3 0 R >>".into(), // 6
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (b) /Parent 5 0 R /V (two) /Rect [50 650 250 670] /P 3 0 R >>".into(), // 7
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (c) /V (three) /Rect [50 600 250 620] /P 3 0 R >>".into(), // 8
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (d) /Rect [50 550 250 570] /P 3 0 R >>".into(), // 9
"<< /FT /Tx /T (e) /Parent 5 0 R /V (four) /Kids [11 0 R 12 0 R] >>".into(), // 10
"<< /Type /Annot /Subtype /Widget /Parent 10 0 R /Rect [50 500 250 520] /P 3 0 R >>".into(), // 11
"<< /Type /Annot /Subtype /Widget /Parent 10 0 R /Rect [300 500 500 520] /P 3 0 R >>".into(), // 12
];
document(&objs)
}
fn rename(doc: &mut Document, name: &str, to: &str) -> Result<String, FormError> {
set_props(doc, name, &FieldProps { name: Some(to.into()), ..FieldProps::default() })
}
/// The partial name (`/T`) of object `num`.
fn own_name(doc: &Document, num: u32) -> Option<String> {
doc.get(ObjRef::new(num, 0)).as_dict().unwrap().get(b"T").and_then(|t| text_of(&doc.resolve(t)))
}
#[test]
fn renaming_a_kid_is_checked_against_the_full_name_it_takes() {
let mut doc = dotted_form();
// MASTER.b is taken, though no field's full name is the bare "b". Two fields of one fully
// qualified name are one field and share its value (ISO 32000-2 §12.7.4.2).
let err = rename(&mut doc, "MASTER.a", "b").unwrap_err();
assert_eq!(err, FormError::Invalid("a field named \"MASTER.b\" already exists".into()));
assert_eq!(own_name(&doc, 6).as_deref(), Some("a"), "a refused rename changes nothing");
// The same between a field merged with its widget and a field with widget kids.
assert!(rename(&mut doc, "MASTER.e", "a").is_err());
assert!(rename(&mut doc, "MASTER.a", "e").is_err());
// A kid keeping its own name is no clash with itself.
assert_eq!(rename(&mut doc, "MASTER.b", "b").unwrap(), "MASTER.b");
// The top-level "c" does not take MASTER.c.
assert_eq!(rename(&mut doc, "MASTER.a", "c").unwrap(), "MASTER.c");
assert_eq!(rename(&mut doc, "MASTER.e", "f").unwrap(), "MASTER.f");
let doc = reopen(&doc);
let all = fields(&doc);
let names: Vec<&str> = all.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names, ["MASTER.c", "MASTER.b", "MASTER.f", "c", "d"]);
for (name, value) in [("MASTER.c", "one"), ("MASTER.b", "two"), ("MASTER.f", "four"), ("c", "three")] {
assert_eq!(field(&all, name).value, [value], "{name} keeps its own value");
}
// Only the field's own /T changes: the kids stay under MASTER, a merged kid stays its own
// widget, and the widget kids get no /T (which would make them fields).
let widgets = |name: &str| field(&all, name).widgets.iter().map(|w| w.obj.num).collect::<Vec<_>>();
assert_eq!((field(&all, "MASTER.c").obj, widgets("MASTER.c")), (ObjRef::new(6, 0), vec![6]));
assert_eq!((field(&all, "MASTER.f").obj, widgets("MASTER.f")), (ObjRef::new(10, 0), vec![11, 12]));
let partial: Vec<Option<String>> = [5, 6, 10, 11, 12].into_iter().map(|n| own_name(&doc, n)).collect();
assert_eq!(partial.iter().map(Option::as_deref).collect::<Vec<_>>(), [Some("MASTER"), Some("c"), Some("f"), None, None]);
}
#[test]
fn renaming_a_top_level_field_is_still_checked_against_top_level_names() {
let mut doc = dotted_form();
let err = rename(&mut doc, "c", "d").unwrap_err();
assert_eq!(err, FormError::Invalid("a field named \"d\" already exists".into()));
assert_eq!(own_name(&doc, 8).as_deref(), Some("c"), "a refused rename changes nothing");
// A kid's partial name is no top-level name: MASTER.a leaves "a" free.
assert_eq!(rename(&mut doc, "c", "a").unwrap(), "a");
let doc = reopen(&doc);
let names: Vec<String> = fields(&doc).into_iter().map(|f| f.name).collect();
assert_eq!(names, ["MASTER.a", "MASTER.b", "MASTER.e", "a", "d"]);
}
#[test]
fn deleting_works_with_a_form_dictionary_inside_the_catalog() {
let mut doc = fixture();
@@ -1126,6 +1367,159 @@ fn a_rename_reaches_every_stored_form_of_a_calculation() {
);
}
/// The calculation order is read with what each formula reads and what is wrong with the order;
/// Fix order moves only what must move, and the order it writes is kept across a save.
#[test]
fn the_calculation_order_is_checked_fixed_and_kept() {
use crate::af::{CalcOp, Calculate};
use crate::calc_order::CalculationIssue::{Cycle, RunsTooEarly};
let mut doc = one_page();
let text = NewField::Text { multiline: false };
for (i, name) in ["qty", "price", "shipping", "sub", "total", "grand", "tax"].into_iter().enumerate() {
let y = 700.0 - 40.0 * i as f64;
add_field(&mut doc, 0, [50.0, y, 250.0, y + 20.0], &text, Some(name)).unwrap();
}
let calculate = |c: Calculate| FieldProps { calculate: Some(c), ..FieldProps::default() };
let sum = |names: &[&str]| calculate(Calculate::Simple { op: CalcOp::Sum, fields: names.iter().map(|n| n.to_string()).collect() });
// Each calculation joins the end of the order, so this chain is written backwards.
set_props(&mut doc, "grand", &sum(&["total"])).unwrap();
set_props(&mut doc, "total", &sum(&["sub", "shipping"])).unwrap();
set_props(&mut doc, "sub", &calculate(Calculate::Notation("qty * price".into()))).unwrap();
set_field_script(&mut doc, "tax", "calculate", Some("event.value = getField(\"total\").value * 0.2; // getField(\"grand\")")).unwrap();
for (name, v) in [("qty", "2"), ("price", "10"), ("shipping", "5")] {
set_value(&mut doc, name, &FieldValue::Text(v.into())).unwrap();
}
let value = |doc: &Document, name: &str| field(&fields(doc), name).value.clone();
assert_eq!((value(&doc, "total"), value(&doc, "grand")), (vec!["25".to_string()], vec!["0".to_string()]), "grand runs too early: stale");
let order = calculation_order(&doc, &fields(&doc));
let names: Vec<&str> = order.fields.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names, ["grand", "total", "sub", "tax"]);
let issues: Vec<_> = order.fields.iter().map(|f| f.issues.clone()).collect();
assert_eq!(issues, [vec![RunsTooEarly { field: "total".into() }], vec![RunsTooEarly { field: "sub".into() }], vec![], vec![]]);
assert_eq!(order.fields[1].reads, ["sub", "shipping"]);
assert_eq!(order.fields[3].reads, ["total"], "a name in a comment isn't read");
assert!(order.fields.iter().all(|f| f.listed) && order.extra.is_empty());
assert!(order.fixable());
assert_eq!(order.fixed(), ["sub", "total", "grand", "tax"]);
set_calculation_order(&mut doc, &order.fixed()).unwrap();
recalculate(&mut doc).unwrap();
assert_eq!((value(&doc, "total"), value(&doc, "grand")), (vec!["25".to_string()], vec!["25".to_string()]));
let mut doc = reopen(&doc);
let order = calculation_order(&doc, &fields(&doc));
assert_eq!(order.fields.iter().map(|f| f.name.as_str()).collect::<Vec<_>>(), ["sub", "total", "grand", "tax"], "kept across a save");
assert!(order.fields.iter().all(|f| f.issues.is_empty()) && !order.fixable());
// Names given first, the others after them in their order.
set_calculation_order(&mut doc, &["tax".into()]).unwrap();
let names = |doc: &Document| calculation_order(doc, &fields(doc)).fields.into_iter().map(|f| f.name).collect::<Vec<_>>();
assert_eq!(names(&doc), ["tax", "sub", "total", "grand"]);
for (bad, error) in [
(vec!["qty"], FormError::Invalid("qty has no calculation".into())),
(vec!["nope"], FormError::NoSuchField("nope".into())),
(vec!["sub", "sub"], FormError::Invalid("sub is in the order twice".into())),
] {
let bad: Vec<String> = bad.into_iter().map(String::from).collect();
assert_eq!(set_calculation_order(&mut doc, &bad), Err(error));
}
assert_eq!(names(&doc), ["tax", "sub", "total", "grand"], "a refused order changes nothing");
// A loop: sub now reads grand, which reads total, which reads sub. Its new formula keeps its
// place in the order. Fix order can't fix a loop, and leaves it in its order.
set_props(&mut doc, "sub", &calculate(Calculate::Notation("qty * price + grand".into()))).unwrap();
let order = calculation_order(&doc, &fields(&doc));
let issues: Vec<(&str, &[_])> = order.fields.iter().map(|f| (f.name.as_str(), f.issues.as_slice())).collect();
assert_eq!(issues[0], ("tax", &[RunsTooEarly { field: "total".into() }][..]));
assert_eq!(
issues[1..],
[
("sub", &[Cycle { fields: vec!["grand".into()] }][..]),
("total", &[Cycle { fields: vec!["sub".into()] }][..]),
("grand", &[Cycle { fields: vec!["total".into()] }][..]),
]
);
assert_eq!(order.fixed(), ["sub", "total", "tax", "grand"], "only tax moves, to just after total");
}
/// `/CO` as other tools leave it: a calculated field missing, a field without a calculation, an
/// entry repeated, a reference to nothing and a number. The order reads around them, and setting
/// the order writes `/CO` afresh.
#[test]
fn a_calculation_order_with_gaps_and_extra_entries() {
let widget = |name: &str, y: u32, rest: &str| {
format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [50 {y} 250 {}] /P 3 0 R {rest} >>", y + 20)
};
let sum = |names: &str| format!("/AA << /C << /S /JavaScript /JS (AFSimple_Calculate\\(\"SUM\", \"{names}\"\\);) >> >>");
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 6 0 R 7 0 R 8 0 R] >>".into(),
"<< /Fields [5 0 R 6 0 R 7 0 R 8 0 R] /DA (/Helv 0 Tf 0 g) /CO [6 0 R 5 0 R 6 0 R 99 0 R 12] >>".into(),
widget("a", 700, "/V (3)"),
widget("listed", 660, &sum("a")),
widget("unlisted", 620, &sum("listed")),
widget("b", 580, "/V (4)"),
];
let mut doc = document(&objs);
let order = calculation_order(&doc, &fields(&doc));
let rows: Vec<(&str, bool, &[_])> = order.fields.iter().map(|f| (f.name.as_str(), f.listed, f.issues.as_slice())).collect();
assert_eq!(rows, [("listed", true, &[][..]), ("unlisted", false, &[crate::calc_order::CalculationIssue::NotInOrder][..])]);
assert_eq!(order.extra, ["a", "listed", "99 0 R", "not a reference"]);
assert!(order.fixable());
assert_eq!(order.fixed(), ["listed", "unlisted"]);
assert_eq!(recalculate(&mut doc), Ok(2), "the unlisted field calculates too, after the listed one");
assert_eq!(field(&fields(&doc), "unlisted").value, ["3"]);
set_calculation_order(&mut doc, &[]).unwrap();
let doc = reopen(&doc);
let co = acroform(&doc).and_then(|af| af.get(b"CO").cloned()).unwrap();
let refs: Vec<_> = doc.resolve(&co).as_array().unwrap().iter().filter_map(Object::as_ref).collect();
assert_eq!(refs, [field(&fields(&doc), "listed").obj, field(&fields(&doc), "unlisted").obj], "every calculated field once, nothing else");
assert!(!calculation_order(&doc, &fields(&doc)).fixable());
}
/// Two calculated fields with one full name (a malformed form: a full name is one field). The
/// order names them both, so a repeated name stands for the next of them in the current order:
/// the current order sets back unchanged, and Fix order and moves work around them.
#[test]
fn two_fields_with_one_name_keep_their_places_in_the_order() {
let widget = |name: &str, y: u32, reads: &str| {
format!(
"<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [50 {y} 250 {}] /P 3 0 R /AA << /C << /S /JavaScript /JS (AFSimple_Calculate\\(\"SUM\", \"{reads}\"\\);) >> >> >>",
y + 20
)
};
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 6 0 R 7 0 R 8 0 R] >>".into(),
"<< /Fields [5 0 R 6 0 R 7 0 R 8 0 R] /DA (/Helv 0 Tf 0 g) /CO [6 0 R 7 0 R 5 0 R] >>".into(),
widget("dup", 700, "x"),
widget("dup", 660, "src"),
widget("src", 620, "x"),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (x) /Rect [50 580 250 600] /P 3 0 R /V (3) >>".into(),
];
let mut doc = document(&objs);
// Each dup told apart by what it reads.
let rows = |doc: &Document| -> Vec<(String, Vec<String>, bool)> {
calculation_order(doc, &fields(doc)).fields.into_iter().map(|f| (f.name, f.reads, f.issues.is_empty())).collect()
};
let row = |name: &str, reads: &str, ok: bool| (name.to_owned(), vec![reads.to_owned()], ok);
assert_eq!(rows(&doc), [row("dup", "src", false), row("src", "x", true), row("dup", "x", true)]);
let current: Vec<String> = ["dup", "src", "dup"].map(String::from).into();
set_calculation_order(&mut doc, &current).unwrap();
assert_eq!(rows(&doc), [row("dup", "src", false), row("src", "x", true), row("dup", "x", true)], "the current order sets back unchanged");
let order = calculation_order(&doc, &fields(&doc));
assert_eq!(order.fixed(), ["src", "dup", "dup"]);
set_calculation_order(&mut doc, &order.fixed()).unwrap();
assert_eq!(rows(&doc), [row("src", "x", true), row("dup", "src", true), row("dup", "x", true)], "the dup that reads src moves after it");
// Each dup calculates, not the first of them twice.
assert_eq!(recalculate(&mut doc), Ok(3));
let values: Vec<Vec<String>> = fields(&doc).into_iter().filter(|f| f.name == "dup").map(|f| f.value).collect();
assert_eq!(values, [["3"], ["3"]]);
// A name given alone stands for the first of its fields in the current order.
set_calculation_order(&mut doc, &["dup".into()]).unwrap();
assert_eq!(rows(&doc)[0], row("dup", "src", false));
let thrice: Vec<String> = ["dup", "dup", "dup"].map(String::from).into();
assert_eq!(set_calculation_order(&mut doc, &thrice), Err(FormError::Invalid("dup is in the order twice".into())));
}
#[test]
fn new_text_fields_fit_their_box() {
// #1888: a fixed 12 pt was clipped in a short box. New text and date fields take Acrobat's
@@ -1653,6 +2047,38 @@ fn page_shapes_reads_boxes_and_rules() {
assert_eq!(sh.rules, [[40.0, 100.0, 240.0, 100.0], [60.0, 600.5, 260.0, 600.5]]);
}
#[test]
fn page_shapes_preserves_content_piece_boundaries() {
use crate::detect::{Candidate, Kind, Shapes, detect, page_shapes};
let read = |pieces: &[&[u8]]| {
let mut doc = one_page();
let page = pdfcraft_model::pages(&doc)[0].obj;
let contents =
pieces.iter().map(|bytes| Object::Ref(doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Default::default(), bytes))))).collect();
doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Array(contents))).unwrap();
page_shapes(&doc, 0)
};
let empty = Shapes::default();
for pieces in [vec![b"% trailing".as_slice(), b"40 120 300 30 re S\n"], vec![b"% trailing".as_slice(), b"", b"", b"40 120 300 30 re S\n"]] {
let shapes = read(&pieces);
assert_eq!(shapes, empty, "an unterminated comment hides the next piece's first line");
assert_eq!(detect(&[], &shapes, &[], &[]), Vec::<Candidate>::new());
}
let visible = Shapes { boxes: vec![[40.0, 120.0, 340.0, 150.0]], rules: vec![] };
for ending in [b"% trailing\n".as_slice(), b"% trailing\r", b"% trailing\r\n"] {
let shapes = read(&[ending, b"40 120 300 30 re S\n"]);
assert_eq!(shapes, visible);
assert_eq!(detect(&[], &shapes, &[], &[]), vec![Candidate { kind: Kind::Text, rect: [41.0, 121.0, 339.0, 149.0], name: "Text1".into() }]);
}
assert_eq!(read(&[b"40 120", b"", b"300 30", b"re", b"S"]), visible);
assert_eq!(
read(&[b"q 2 0 0 2 0 0 cm 10 10", b"60 20 re", b"S Q 10 20 m 100 20 l S"]),
Shapes { boxes: vec![[20.0, 20.0, 140.0, 60.0]], rules: vec![[10.0, 20.0, 100.0, 20.0]] }
);
assert_eq!(read(&[]), empty);
assert_eq!(read(&[b""]), empty);
}
fn close(a: f64, b: f64) -> bool {
(a - b).abs() < 0.05
}
@@ -2317,6 +2743,22 @@ fn page_shapes_closes_only_last_stroked_subpath_but_all_filled_subpaths() {
assert!(filled.rules.is_empty());
}
#[test]
fn zero_max_len_is_unlimited_and_overrides_inherited_limit() {
let mut doc = fixture();
let city = fields(&doc).into_iter().find(|f| f.name == "address.city").unwrap();
let parent = doc.get(city.obj).as_dict().unwrap().get(b"Parent").unwrap().as_ref().unwrap();
doc.update_dict(parent, |d| d.set(b"MaxLen".to_vec(), Object::Int(3))).unwrap();
assert_eq!(fields(&doc).into_iter().find(|f| f.name == "address.city").unwrap().max_len, Some(3));
doc.update_dict(city.obj, |d| d.set(b"MaxLen".to_vec(), Object::Int(0))).unwrap();
assert_eq!(fields(&doc).into_iter().find(|f| f.name == "address.city").unwrap().max_len, None);
set_value(&mut doc, "address.city", &FieldValue::Text("Absalom".into())).unwrap();
let again = reopen(&doc);
assert_eq!(field(&fields(&again), "address.city").value, ["Absalom"]);
assert_eq!(again.get(city.obj).as_dict().unwrap().int(b"MaxLen"), Some(0), "preserve the original entry");
assert!(set_value(&mut doc, "zip", &FieldValue::Text("123456".into())).is_err(), "positive limits still apply");
}
#[test]
fn auto_text_size_fits_single_and_multiline_text() {
use crate::appearance::auto_text_size;
@@ -2355,3 +2797,187 @@ fn document_scripts_read_each_name_tree_node_once() {
]);
assert_eq!(document_scripts_named(&doc), [("init".to_string(), "var ready = true;".to_string())]);
}
/// #3470: a form whose writer left the appearances to the viewer. Field A has an empty one,
/// B none, C its own (`(old C)`), D is a combo without one, E a check box with states, F a list
/// box without one, and G only a down appearance (`/D`, no `/N`).
fn left_to_the_viewer(need_appearances: bool) -> Document {
let flag = if need_appearances { "/NeedAppearances true " } else { "" };
document(&[
"<< /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 [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 13 0 R 14 0 R] >>".into(),
format!("<< /Fields [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 13 0 R 14 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>"),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (a) /V (Value A) /DA (/Helv 12 Tf 0 g) /Rect [50 700 350 720] /P 3 0 R /AP << /N 11 0 R >> >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (b) /V (Value B) /DA (/Helv 12 Tf 0 g) /Rect [50 650 350 670] /P 3 0 R >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (c) /V (Value C) /DA (/Helv 12 Tf 0 g) /Rect [50 600 350 620] /P 3 0 R /AP << /N 12 0 R >> >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Ch /Ff 131072 /T (d) /Opt [(Red) (Blue)] /V (Blue) /DA (/Helv 12 Tf 0 g) /Rect [50 550 350 570] /P 3 0 R >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (e) /V /Yes /AS /Yes /Rect [400 700 415 715] /P 3 0 R /AP << /N << /Yes 11 0 R /Off 11 0 R >> >> >>".into(),
"<< /Length 4 >>\nstream\nq\nQ\nendstream".into(),
"<< /Length 18 >>\nstream\nBT (old C) Tj ET\nendstream".into(),
"<< /Type /Annot /Subtype /Widget /FT /Ch /T (f) /Opt [(Green) (Grey)] /V (Green) /DA (/Helv 12 Tf 0 g) /Rect [50 450 350 520] /P 3 0 R >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (g) /V (Value G) /DA (/Helv 12 Tf 0 g) /Rect [50 400 350 420] /P 3 0 R /AP << /D 11 0 R >> >>".into(),
])
}
/// #3470: with `/NeedAppearances`, every text and choice widget of the display copy is drawn
/// from its value, its own appearance or not; buttons keep their states, and the document
/// itself is untouched.
#[test]
fn need_appearances_draws_every_text_and_choice_field_in_the_display_copy() {
let doc = left_to_the_viewer(true);
let copy = with_field_appearances(&doc).expect("fields to draw");
let all = fields(&copy);
for (name, value) in
[("a", "(Value A) Tj"), ("b", "(Value B) Tj"), ("c", "(Value C) Tj"), ("d", "(Blue) Tj"), ("f", "(Green) Tj"), ("g", "(Value G) Tj")]
{
let shown = ap(&copy, &field(&all, name).widgets[0]);
assert!(shown.contains(value), "{name}: {shown}");
}
let e = |d: &Document| d.get(field(&fields(d), "e").widgets[0].obj).as_dict().and_then(|w| w.get(b"AP").cloned());
assert_eq!(e(&copy), e(&doc), "the check box keeps its states");
let original = fields(&doc);
assert_eq!(ap(&doc, &field(&original, "a").widgets[0]).trim_end(), "q\nQ", "the document keeps its own appearances");
assert!(doc.get(field(&original, "b").widgets[0].obj).as_dict().is_some_and(|w| !w.contains(b"AP")));
}
/// #3470: without `/NeedAppearances`, only widgets with no normal appearance are drawn; an
/// appearance the file has, even an empty one, is the file's. A form with all of them needs no
/// copy.
#[test]
fn without_need_appearances_only_missing_appearances_are_drawn() {
let doc = left_to_the_viewer(false);
let copy = with_field_appearances(&doc).expect("b, d, f and g have none");
let all = fields(&copy);
assert_eq!(ap(&copy, &field(&all, "a").widgets[0]).trim_end(), "q\nQ");
assert!(ap(&copy, &field(&all, "b").widgets[0]).contains("(Value B) Tj"));
assert_eq!(ap(&copy, &field(&all, "c").widgets[0]).trim_end(), "BT (old C) Tj ET");
assert!(ap(&copy, &field(&all, "d").widgets[0]).contains("(Blue) Tj"));
assert!(ap(&copy, &field(&all, "f").widgets[0]).contains("(Green) Tj"));
assert!(ap(&copy, &field(&all, "g").widgets[0]).contains("(Value G) Tj"), "a down appearance alone is no normal one");
assert!(with_field_appearances(&copy).is_none(), "every widget has one now");
}
/// #3470: refreshing the display copy redraws only the widgets whose inputs changed. Typing in
/// one field of a form that leaves every appearance to the viewer otherwise redrew all of them.
#[test]
fn the_field_appearance_cache_redraws_only_what_changed() {
let mut doc = left_to_the_viewer(true);
let mut cache = FieldAppearanceCache::default();
with_field_appearances_cached(&doc, &mut cache).expect("fields to draw");
let drawn = cache.builds();
assert_eq!(drawn, 6, "a, b, c, d, f and g");
with_field_appearances_cached(&doc, &mut cache).expect("fields to draw");
assert_eq!((cache.builds(), cache.hits()), (drawn, drawn), "nothing changed, nothing redrawn");
set_value(&mut doc, "b", &FieldValue::Text("Typed".into())).unwrap();
let copy = with_field_appearances_cached(&doc, &mut cache).expect("fields to draw");
assert_eq!(cache.builds(), drawn + 1, "only b is redrawn");
assert!(ap(&copy, &field(&fields(&copy), "b").widgets[0]).contains("(Typed) Tj"), "and shows its new value");
assert!(ap(&copy, &field(&fields(&copy), "a").widgets[0]).contains("(Value A) Tj"), "a reuses its drawing");
}
/// #3470: each kind of input a field's drawing depends on invalidates its cached appearance when
/// it changes, wherever it lives: the parent field's value, the form's `/DA`, an indirect `/MK`
/// and a `/DR` font. Field H is a parent with one kid widget; I is a plain field.
#[test]
fn the_field_appearance_cache_sees_every_input() {
let mut doc = document(&[
"<< /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 [6 0 R 7 0 R] >>".into(),
"<< /Fields [5 0 R 7 0 R] /NeedAppearances true /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 8 0 R >> >> >>".into(),
"<< /FT /Tx /T (h) /V (Kid value) /Kids [6 0 R] >>".into(),
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [50 700 350 720] /P 3 0 R /MK 9 0 R >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (i) /V (Plain) /Rect [50 650 350 670] /P 3 0 R >>".into(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
"<< /BG [1 1 1] >>".into(),
]);
let r = |n: u32| pdfcraft_cos::ObjRef::new(n, 0);
let mut cache = FieldAppearanceCache::default();
let mut redraw = |doc: &Document| {
let before = cache.builds();
let copy = with_field_appearances_cached(doc, &mut cache).expect("fields to draw");
(cache.builds() - before, copy)
};
assert_eq!(redraw(&doc).0, 2);
assert_eq!(redraw(&doc).0, 0, "unchanged");
doc.update_dict(r(5), |d| d.set(b"V".to_vec(), Object::String(PdfString::text("New value")))).unwrap();
let (n, copy) = redraw(&doc);
assert_eq!(n, 1, "the parent's value: only its kid");
assert!(ap(&copy, &field(&fields(&copy), "h").widgets[0]).contains("(New value) Tj"));
doc.update_dict(r(9), |d| d.set(b"BG".to_vec(), Object::Array(vec![Object::Int(1), Object::Int(0), Object::Int(0)]))).unwrap();
let (n, copy) = redraw(&doc);
assert_eq!(n, 1, "the kid's indirect /MK");
assert!(ap(&copy, &field(&fields(&copy), "h").widgets[0]).contains("1 0 0 rg"), "the new background");
doc.update_dict(r(4), |d| d.set(b"DA".to_vec(), Object::String(PdfString::text("/Helv 9 Tf 0 g")))).unwrap();
let (n, copy) = redraw(&doc);
assert_eq!(n, 2, "the form's /DA: both fields");
assert!(ap(&copy, &field(&fields(&copy), "i").widgets[0]).contains("/Helv 9 Tf"));
doc.update_dict(r(8), |d| d.set(b"BaseFont".to_vec(), Object::Name(b"Courier".to_vec()))).unwrap();
assert_eq!(redraw(&doc).0, 2, "a /DR font: both fields");
}
/// #3470: an indirect `/DR` and an indirect `/Font` inside it are inputs too, with a font written
/// directly into the `/Font` dictionary, and so is the form's `/DA` when it is an indirect string.
#[test]
fn the_field_appearance_cache_sees_indirect_resources() {
let mut doc = document(&[
"<< /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] /NeedAppearances true /DA 8 0 R /DR 6 0 R >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (i) /V (Plain) /Rect [50 650 350 670] /P 3 0 R >>".into(),
"<< /Font 7 0 R >>".into(),
"<< /Helv << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> >>".into(),
"(/Helv 12 Tf 0 g)".into(),
]);
let r = |n: u32| pdfcraft_cos::ObjRef::new(n, 0);
let mut cache = FieldAppearanceCache::default();
let mut redraw = |doc: &Document| {
let before = cache.builds();
with_field_appearances_cached(doc, &mut cache).expect("a field to draw");
cache.builds() - before
};
assert_eq!(redraw(&doc), 1);
assert_eq!(redraw(&doc), 0);
let courier = || {
let mut f = Dict::new();
f.set(b"Type".to_vec(), Object::Name(b"Font".to_vec()));
f.set(b"Subtype".to_vec(), Object::Name(b"Type1".to_vec()));
f.set(b"BaseFont".to_vec(), Object::Name(b"Courier".to_vec()));
Object::Dict(f)
};
doc.update_dict(r(7), |d| d.set(b"Helv".to_vec(), courier())).unwrap();
assert_eq!(redraw(&doc), 1, "the indirect /Font dictionary");
doc.update_dict(r(6), |d| {
let mut fonts = Dict::new();
fonts.set(b"Helv".to_vec(), courier());
d.set(b"Font".to_vec(), Object::Dict(fonts));
})
.unwrap();
assert_eq!(redraw(&doc), 1, "the indirect /DR");
doc.set(r(8), Object::String(PdfString::text("/Helv 9 Tf 0 g")));
assert_eq!(redraw(&doc), 1, "the form's indirect /DA");
}
#[test]
fn page_walk_skips_repeated_and_cyclic_kids_without_queueing_them() {
let mut doc = fixture();
let root = ObjRef { num: 2, generation: 0 };
let page = ObjRef { num: 3, generation: 0 };
// A cycle back to the root, then the page 128 times over.
let mut kids = vec![Object::Ref(root)];
kids.extend(std::iter::repeat_n(Object::Ref(page), 128));
doc.update_dict(root, |d| d.set(b"Kids".to_vec(), Object::Array(kids))).unwrap();
let mut walk = PageWalk::new(&doc, root);
assert_eq!(walk.next(), Some(page));
assert!(walk.pending.len() <= 2, "repeated kids retained {} pending entries", walk.pending.len());
assert_eq!(walk.next(), None);
assert_eq!(page_refs(&doc), [page]);
}
+10
View File
@@ -0,0 +1,10 @@
use pdfcraft_forms::af::parse_date;
#[test]
fn bare_a_suffix_is_am() {
assert_eq!(parse_date("12:05 a", "h:MM t").map(|d| d.hh), Some(0));
assert_eq!(parse_date("12:30a", "h:MM t").map(|d| d.hh), Some(0));
assert_eq!(parse_date("5:30 a", "h:MM t").map(|d| d.hh), Some(5));
assert_eq!(parse_date("5:30p", "h:MM t").map(|d| d.hh), Some(17));
assert_eq!(parse_date("12:05 p", "h:MM t").map(|d| d.hh), Some(12));
}
+26 -5
View File
@@ -1097,10 +1097,16 @@ impl Interp<'_> {
self.charge(1)
}
/// Property `name` of form object `i` as FormCalc reads it: its value, attributes,
/// relatives and children by name.
fn node_member(&mut self, i: usize, name: &str) -> R<Val> {
let Some(n) = self.node(i) else { return Ok(Val::Null) };
Ok(match name {
"rawValue" | "value" | "formattedValue" => self.node_value(i),
"formattedValue" => match &n.formatted {
Some(f) => Val::Str(f.clone()),
None => self.node_value(i),
},
"rawValue" | "value" => self.node_value(i),
"name" => Val::Str(n.name.clone()),
"somExpression" => Val::Str(n.som.clone()),
"className" => Val::Str(n.kind.class_name().into()),
@@ -2052,6 +2058,10 @@ fn word_num(n: f64, cents: bool) -> String {
}
let cents_total = (n * 100.0).round() as u64;
let whole = if cents { cents_total / 100 } else { n.trunc() as u64 };
if whole >= 1_000_000_000_000 {
// Rounding to cents carried past the supported range.
return String::new();
}
let mut parts = Vec::new();
let groups = [(1_000_000_000u64, "Billion"), (1_000_000, "Million"), (1_000, "Thousand")];
let mut rest = whole;
@@ -2301,8 +2311,11 @@ impl Interp<'_> {
}
let num = self.to_num(&v);
let decimals = pic.split_once('.').map_or(0, |(_, f)| f.chars().filter(|c| matches!(c, '9' | 'z' | 'Z')).count()).min(20);
// `9` in the integer part forces a digit; `z` does not.
let min_int = pic.split('.').next().map_or(0, |i| i.chars().filter(|c| *c == '9').count()).min(20);
let width = min_int + if decimals > 0 { decimals + 1 } else { 0 };
let grouped = pic.contains(',');
let mut text = fixed(num.abs(), decimals);
let mut text = format!("{:0>width$}", fixed(num.abs(), decimals));
if grouped {
let (int, frac) = text.split_once('.').map_or((text.as_str(), ""), |(a, b)| (a, b));
let mut g = String::new();
@@ -2394,9 +2407,16 @@ impl Interp<'_> {
}
"time2num" | "isotime2num" => {
let s = self.arg_str(args, 0)?;
let parts: Vec<u64> = s.split(':').filter_map(|p| p.trim().parse::<u64>().ok()).map(|v| v.min(1_000_000)).collect();
let (h, m, sec) = (parts.first().copied().unwrap_or(0), parts.get(1).copied().unwrap_or(0), parts.get(2).copied().unwrap_or(0));
Val::Num(((h * 3600 + m * 60 + sec) * 1000) as f64)
// ISO 8601 allows a leading "T", a trailing "Z" and fractional seconds.
let t = s.trim();
let t = t.strip_prefix(['T', 't']).unwrap_or(t);
let t = t.strip_suffix(['Z', 'z']).unwrap_or(t);
let mut parts = t.split(':');
let mut whole = || parts.next().and_then(|p| p.trim().parse::<u64>().ok()).unwrap_or(0).min(1_000_000);
let (h, m) = (whole(), whole());
let sec =
parts.next().and_then(|p| p.trim().parse::<f64>().ok()).filter(|v| v.is_finite() && *v >= 0.0).unwrap_or(0.0).min(1_000_000.0);
Val::Num(((h * 3600 + m * 60) * 1000) as f64 + (sec * 1000.0).round())
}
// ── financial
"fv" => {
@@ -2638,6 +2658,7 @@ mod tests {
assert_eq!(parse_date("2/3/01", "M/D/YY"), Some((2001, 2, 3)));
assert_eq!(format_date(2001, 2, 3, "MMMM D, YYYY"), "February 3, 2001");
assert_eq!(word_num(1234.5, true), "One Thousand Two Hundred Thirty-Four Dollars And 50 Cents");
assert_eq!(word_num(999_999_999_999.999, true), "");
assert_eq!(num_text(3.0), "3");
assert_eq!(num_text(2.5), "2.5");
assert_eq!(num_text(0.1 + 0.2), "0.3");
+13 -4
View File
@@ -25,6 +25,7 @@
use std::cell::RefCell;
use std::collections::BTreeSet;
use std::sync::Arc;
use boa_engine::object::builtins::{JsArray, JsDate, JsFunction};
use boa_engine::object::{FunctionObjectBuilder, ObjectInitializer};
@@ -194,7 +195,7 @@ impl Default for Limits {
}
struct Host {
fields: Vec<FieldState>,
fields: Arc<Vec<FieldState>>,
changed: BTreeSet<usize>,
alerts: Vec<String>,
console: Vec<String>,
@@ -286,7 +287,8 @@ fn read(ctx: &Context, name: &JsString) -> JsResult<FieldState> {
fn write(ctx: &Context, name: &JsString, change: impl FnOnce(&mut FieldState)) -> JsResult<()> {
let i = field_index(ctx, name)?;
let mut h = host(ctx)?.borrow_mut();
change(&mut h.fields[i]);
let field = Arc::make_mut(&mut h.fields).get_mut(i).ok_or_else(|| error("the field is no longer available"))?;
change(field);
h.changed.insert(i);
Ok(())
}
@@ -1420,6 +1422,13 @@ fn refuse(source: &str) -> Option<String> {
/// Run `script` for `event`, after the document-level scripts (`doc_scripts`, which define the
/// functions field scripts call), with the form's `fields`.
pub fn run(script: &str, event: &Event, doc: &DocInfo, fields: &[FieldState], doc_scripts: &[String], limits: Limits) -> Outcome {
run_shared(script, event, doc, Arc::new(fields.to_vec()), doc_scripts, limits)
}
/// Like [`run`], sharing an immutable initial field snapshot. Scripts still get a fresh
/// context; the first field write copies the snapshot, so neither the caller nor another
/// script can observe uncommitted writes. Read-only calculations avoid copying the whole form.
pub fn run_shared(script: &str, event: &Event, doc: &DocInfo, fields: Arc<Vec<FieldState>>, doc_scripts: &[String], limits: Limits) -> Outcome {
let failed = |why: String| Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), error: Some(why), ..Default::default() };
if let Some(why) = std::iter::once(script).chain(doc_scripts.iter().map(String::as_str)).find_map(refuse) {
return failed(why);
@@ -1441,12 +1450,12 @@ pub fn run(script: &str, event: &Event, doc: &DocInfo, fields: &[FieldState], do
run_here(script, event, doc, fields, doc_scripts, limits)
}
fn run_here(script: &str, event: &Event, doc: &DocInfo, fields: &[FieldState], doc_scripts: &[String], limits: Limits) -> Outcome {
fn run_here(script: &str, event: &Event, doc: &DocInfo, fields: Arc<Vec<FieldState>>, doc_scripts: &[String], limits: Limits) -> Outcome {
let mut ctx = Context::default();
ctx.runtime_limits_mut().set_loop_iteration_limit(limits.loop_iterations);
ctx.runtime_limits_mut().set_recursion_limit(limits.recursion);
ctx.insert_data(RefCell::new(Host {
fields: fields.to_vec(),
fields,
changed: BTreeSet::new(),
alerts: Vec::new(),
console: Vec::new(),
+98
View File
@@ -380,12 +380,15 @@ mod xfa_model {
}
}
/// Run the JavaScript `script` for `activity` on the test form, from the node at `target`.
fn go(script: &str, target: &str, activity: &str) -> XfaOutcome {
let ev = XfaEvent { activity: activity.into(), target: target.into(), ..Default::default() };
let doc = XfaDoc { file_name: "f.pdf".into(), page: 1, page_count: 3 };
run_xfa(script, &ev, &doc, &form(), Limits::default())
}
/// A calculate script finds its siblings by name and yields its last expression, and a
/// value it sets comes back as an effect.
#[test]
fn calculate_scripts_see_siblings_by_name_and_yield_their_last_expression() {
let o = go("qty.rawValue * price.rawValue", "form1[0].page1[0].total[0]", "calculate");
@@ -395,6 +398,39 @@ mod xfa_model {
assert_eq!(o.effects, vec![XfaEffect::SetValue { som: "form1[0].page1[0].total[0]".into(), value: "5".into() }]);
}
/// A form whose one field `d` holds the ISO date 2026-10-20, shown as 20261020.
pub(super) fn dated_form() -> XfaNode {
let d = XfaNode { formatted: Some("20261020".into()), ..field("d", "form1[0].page1[0].d[0]", "2026-10-20", false) };
XfaNode {
name: "form1".into(),
som: "form1[0]".into(),
kind: Some(XfaKind::Subform),
children: vec![XfaNode {
name: "page1".into(),
som: "form1[0].page1[0]".into(),
kind: Some(XfaKind::Subform),
children: vec![d],
..Default::default()
}],
..Default::default()
}
}
/// `formattedValue` reads the value as the field's picture shows it, and `rawValue` the
/// value itself; once the script sets the value, `formattedValue` gives that value.
#[test]
fn formatted_value_reads_the_shown_value_until_the_script_sets_one() {
let ev = XfaEvent { activity: "calculate".into(), target: "form1[0].page1[0].d[0]".into(), ..Default::default() };
let doc = XfaDoc::default();
let run = |script: &str| run_xfa(script, &ev, &doc, &dated_form(), Limits::default());
let o = run("this.formattedValue + ' ' + this.rawValue");
assert_eq!(o.error, None, "{o:?}");
assert_eq!(o.result.as_deref(), Some("20261020 2026-10-20"));
let o = run("this.rawValue = '2026-10-21'; this.formattedValue");
assert_eq!(o.result.as_deref(), Some("2026-10-21"));
}
/// Long values a script sets, and long results, are cut where they are stored, with a note.
#[test]
fn long_values_and_results_are_cut_where_they_are_stored() {
// The value kept for later reads is the one the effect saves.
@@ -607,6 +643,22 @@ mod formcalc {
o.result.unwrap_or_default()
}
/// FormCalc reads `formattedValue` as the field's picture shows it too, until the script
/// sets the value.
#[test]
fn formcalc_formatted_value_reads_the_shown_value_until_the_script_sets_one() {
let ev = XfaEvent { activity: "calculate".into(), target: "form1[0].page1[0].d[0]".into(), ..Default::default() };
let doc = XfaDoc::default();
let run = |script: &str| run_formcalc(script, &ev, &doc, &super::xfa_model::dated_form(), Limits::default());
let o = run("Concat($.formattedValue, \" \", $.rawValue)");
assert_eq!(o.error, None, "{o:?}");
assert_eq!(o.result.as_deref(), Some("20261020 2026-10-20"));
let o = run("$.rawValue = \"2026-10-21\"\n$.formattedValue");
assert_eq!(o.error, None, "{o:?}");
assert_eq!(o.result.as_deref(), Some("2026-10-21"));
}
/// FormCalc arithmetic, string conversion and comparisons follow FormCalc's own rules.
#[test]
fn arithmetic_strings_and_comparisons_follow_formcalc_rules() {
assert_eq!(calc("1 + 2 * 3"), "7");
@@ -719,6 +771,9 @@ mod formcalc {
assert_eq!(calc(r#"Num2Date(45350, "YYYY-MM-DD")"#), "2024-02-29");
assert_eq!(calc(r#"Num2Date(Date2Num("2024-02-29") + 1, "MMMM D, YYYY")"#), "March 1, 2024");
assert_eq!(calc(r#"Time2Num("01:00:00")"#), "3600000");
assert_eq!(calc(r#"IsoTime2Num("12:30:45Z")"#), "45045000");
assert_eq!(calc(r#"IsoTime2Num("12:30:45.250")"#), "45045250");
assert_eq!(calc(r#"IsoTime2Num("T12:30:45")"#), "45045000");
assert_eq!(calc(r#"Num2Time(3661000)"#), "01:01:01");
assert_eq!(calc("Round(Pmt(10000, 0.01, 12), 2)"), "888.49");
assert_eq!(calc("Round(FV(100, 0.01, 12), 2)"), "1268.25");
@@ -731,6 +786,15 @@ mod formcalc {
assert!(go("Nope(1)", "form1[0].page1[0].total[0]", "calculate").error.unwrap().contains("unknown function"));
}
#[test]
fn format_nine_pads_the_integer_part() {
assert_eq!(calc(r#"Format("99999", 42)"#), "00042");
assert_eq!(calc(r#"Format("9999", 5)"#), "0005");
assert_eq!(calc(r#"Format("999.99", 4.5)"#), "004.50");
assert_eq!(calc(r#"Format("zzz9", 42)"#), "42");
assert_eq!(calc(r#"Format("99", 12345)"#), "12345");
}
#[test]
fn date2num_rejects_days_past_the_end_of_the_month() {
assert_eq!(calc(r#"Date2Num("2023-02-30")"#), "0");
@@ -1144,6 +1208,40 @@ mod session {
}
}
#[test]
fn shared_field_snapshots_isolate_writes_errors_and_script_globals() {
let initial = Arc::new(fields());
let before = initial.as_ref().clone();
let event = Event::field("Calculate", "total", "");
let write = run_shared(
"var scriptLocal = 1; getField('qty').value = 9; getField('agree').checkThisBox(0, true); event.value = getField('qty').value;",
&event,
&doc(),
Arc::clone(&initial),
&[],
Limits::default(),
);
assert_eq!(write.error, None);
assert_eq!(write.value, "9");
assert_eq!(write.changed.iter().find(|f| f.name == "qty").unwrap().value, ["9"]);
assert_eq!(write.changed.iter().find(|f| f.name == "agree").unwrap().value, ["Yes"]);
assert_eq!(*initial, before, "script writes cannot mutate the caller's snapshot");
let failed = run_shared("getField('qty').value = 99; throw new Error('rejected');", &event, &doc(), Arc::clone(&initial), &[], Limits::default());
assert!(failed.error.is_some());
assert_eq!(*initial, before, "an exception cannot leak partial writes into the initial snapshot");
let read = run_shared(
"event.value = typeof scriptLocal + ':' + getField('qty').value + ':' + getField('agree').isBoxChecked(0);",
&event,
&doc(),
initial,
&[],
Limits::default(),
);
assert_eq!(read.error, None);
assert_eq!(read.value, "undefined:4:false");
assert!(read.changed.is_empty());
}
mod scand {
use super::*;
use crate::xfa::{XfaDoc, XfaEvent, run_xfa};
+17 -1
View File
@@ -64,6 +64,9 @@ pub struct XfaNode {
/// This instance's index among its same-named siblings.
pub index: usize,
pub children: Vec<XfaNode>,
/// Fields: the value shown in the field's display picture, what `formattedValue` reads;
/// `None` when the picture doesn't change it (the raw value is read then).
pub formatted: Option<String>,
}
/// The event a script runs for.
@@ -205,6 +208,8 @@ pub(crate) struct HNode {
pub(crate) children: Vec<usize>,
/// Removed by `removeInstance`: skipped everywhere.
pub(crate) gone: bool,
/// See [`XfaNode::formatted`]; dropped when a script sets the value, which it no longer shows.
pub(crate) formatted: Option<String>,
}
/// The form as a script sees and changes it, with the effects it records.
@@ -346,6 +351,7 @@ impl XHost {
// the snapshot stays bounded however often a long value is written.
let (text, cut) = clip_value(text);
n.value = text.clone();
n.formatted = None;
let som = n.som.clone();
if cut {
self.clipped_values += 1;
@@ -357,10 +363,13 @@ impl XHost {
type Shared = RefCell<XHost>;
/// The form host of the script running in `ctx`.
fn host(ctx: &Context) -> JsResult<&Shared> {
ctx.get_data::<Shared>().ok_or_else(|| error("the XFA script host is not set up"))
}
/// The snapshot `n` and its descendants flattened into `out` under `parent` (to [`MAX_DEPTH`]
/// and [`MAX_NODES`]): the index of `n`'s node, `None` past the limits.
fn flatten(n: &XfaNode, parent: Option<usize>, out: &mut Vec<HNode>, depth: usize) -> Option<usize> {
if depth > MAX_DEPTH || out.len() >= MAX_NODES {
return None;
@@ -381,6 +390,7 @@ fn flatten(n: &XfaNode, parent: Option<usize>, out: &mut Vec<HNode>, depth: usiz
parent,
children: Vec::new(),
gone: false,
formatted: n.formatted.clone(),
});
let mut kids = Vec::new();
for c in &n.children {
@@ -618,6 +628,8 @@ fn clone_subtree(h: &mut XHost, src: usize, parent: Option<usize>, depth: usize)
parent,
children: Vec::new(),
gone: false,
// The copy holds no value, so nothing is shown in its picture either.
formatted: None,
},
n.children.clone(),
)
@@ -817,6 +829,8 @@ pub(crate) fn resolve_som(h: &XHost, from: usize, expr: &str) -> Vec<usize> {
cur
}
/// The live descendants of node `from` named `name` (`from..name` in a SOM path), in document
/// order, to [`MAX_DEPTH`].
pub(crate) fn descendants_named(h: &XHost, from: usize, name: &str, depth: usize) -> Vec<usize> {
let mut out = Vec::new();
if depth > MAX_DEPTH {
@@ -837,6 +851,8 @@ pub(crate) fn descendants_named(h: &XHost, from: usize, name: &str, depth: usize
out
}
/// A property read on a form node's Proxy: its value (and `formattedValue` as its picture shows
/// it), attributes, relatives, methods, instance managers and children by name.
fn node_get(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let idx = node_index(&arg(args, 0), ctx)?;
let key = arg(args, 1);
@@ -853,7 +869,7 @@ fn node_get(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValu
Ok(match key.as_str() {
IDX => JsValue::from(idx as f64),
"rawValue" | "value" => host(ctx)?.borrow().nodes.get(idx).map_or(JsValue::null(), value_js),
"formattedValue" => s(&value),
"formattedValue" => s(&host(ctx)?.borrow().nodes.get(idx).and_then(|n| n.formatted.clone()).unwrap_or(value)),
"name" => s(&name),
"somExpression" => s(&som),
"className" => s(kind.class_name()),
+3 -2
View File
@@ -1,8 +1,9 @@
//! One script engine for a whole form event. Every script of the event runs on one thread
//! (with the script stack), each in a fresh JavaScript context on a fresh copy of the form as
//! the caller holds it, so scripts can't see each other's variables or objects and a script
//! that failed halfway leaves nothing behind; what a script changed reaches the next one only
//! through the caller, which applies the effects and sends the form as it then is. Before,
//! that failed halfway leaves no state behind in the engine; what a script changed (up to its
//! error, when it failed) reaches the next one only through the caller, which applies the
//! effects and sends the form as it then is. Before,
//! every script also got a thread of its own and the caller rebuilt the form for each: a form
//! with hundreds of calculations spent its open-time budget on that.
//!
+2 -2
View File
@@ -4,9 +4,9 @@ Layer L4 implements distance, connected-line perimeter and simple polygon area m
Measurements are ordinary Line, PolyLine or Polygon annotations with a rectilinear Measure dictionary and a normal appearance containing the numeric caption. Their scale is stored with the annotation, so changing a drawing scale does not change earlier measurements. Moving, restyling, replying to and deleting them uses the existing comment workflow.
Scale::calibrate derives a conversion from two points and a known real-world distance. set_scale appends a named rectangular page viewport, preserving existing viewports. scale_at selects the last viewport containing the first measurement vertex. Default measurements account for UserUnit; display-coordinate conversion accounts for crop boxes and page rotation.
Scale::calibrate derives a conversion from two points and a known real-world distance. Scale::ratio takes a scale as a plan writes it, `1 mm = 50 mm` at 1:50: its page side is in pt, in, mm or cm and is converted to points, so the stored /X, /D and /A factors stay per user-space unit and the /R string keeps the units as entered. Scales read from files are used as stored. set_scale appends a named rectangular page viewport, preserving existing viewports. scale_at selects the last viewport containing the first measurement vertex. Default measurements account for UserUnit; display-coordinate conversion accounts for crop boxes and page rotation.
The desktop Measure objects panel exposes all three tools, live values and coordinate deltas, 45-degree constraints, vertex cancellation, snapping, calibration and CSV export. Automation provides measure_distance, measure_perimeter, measure_area, measure_info, measure_list, measure_scale, measure_snap and measure_export. Tool coordinates and explicit units-per-point scales use physical display points.
The desktop Measure objects panel exposes all three tools, live values and coordinate deltas, 45-degree constraints, vertex cancellation, snapping, calibration and CSV export. Automation provides measure_distance, measure_perimeter, measure_area, measure_info, measure_list, measure_scale, measure_snap and measure_export. The panel's drawing scale and measure_scale's `page_distance`/`page_unit` give the page side in a unit, by default the real-world one. Tool coordinates and explicit units-per-point scales use physical display points.
Snapping interprets painted paths and nested Form XObjects, including graphics-state transforms. Cubic paths are flattened to a maximum deviation of 0.2 user units. Raster edges, glyph outlines, clipping and optional-content visibility are not interpreted. Extraction stops at 20,000 segments, 40,000 endpoint and midpoint targets, 16 MiB of decoded content, 4,096 streams, 32 nested forms or 256 nested `q`; intersection searches use at most 256 nearby segments. Extraction is lenient: a content stream that can't be decoded is skipped and counted, and the rest of the page still snaps. The UI and automation report these limits.
+51
View File
@@ -110,6 +110,19 @@ impl Scale {
}
Self::new(real_distance / length, unit, precision)
}
/// A scale as a plan writes it: `page` `page_unit` on the page is `real` `unit` in the real
/// world ("1 mm = 50 mm" at 1:50). `a` and `b` are the user-space ends of one display point,
/// as for [`Scale::calibrate`]. The page side is converted to points, so the stored factors
/// stay per user-space unit; the ratio string keeps the units as entered.
pub fn ratio(a: Point, b: Point, page: f64, page_unit: PageUnit, real: f64, unit: &str, precision: u8) -> Result<Self> {
if !page.is_finite() || page <= 0.0 || !real.is_finite() || real <= 0.0 {
return Err(invalid("both sides of a scale ratio must be finite and positive"));
}
let mut out = Self::calibrate(a, b, real / (page * page_unit.points()), unit, precision)?;
out.ratio = format!("{} {} = {} {unit}", ratio_number(page), page_unit.label(), ratio_number(real));
out.validate()?;
Ok(out)
}
pub fn validate(&self) -> Result<()> {
for f in [self.x, self.y, self.distance_factor, self.area_factor] {
if !f.is_finite() || !(1e-12..=1e12).contains(&f) {
@@ -202,6 +215,44 @@ impl Scale {
Ok(out)
}
}
/// The unit of the page side of a scale ratio. The PDF keeps scales per user-space unit
/// (ISO 32000-2 §12.9), so a ratio given in one of these is converted to points.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PageUnit {
Pt,
In,
Mm,
Cm,
}
impl PageUnit {
pub const ALL: [Self; 4] = [Self::Pt, Self::In, Self::Mm, Self::Cm];
pub fn label(self) -> &'static str {
match self {
Self::Pt => "pt",
Self::In => "in",
Self::Mm => "mm",
Self::Cm => "cm",
}
}
pub fn from_label(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|u| u.label() == s.trim())
}
/// Points in one of this unit.
pub fn points(self) -> f64 {
match self {
Self::Pt => 1.0,
Self::In => 72.0,
Self::Mm => 72.0 / 25.4,
Self::Cm => 72.0 / 2.54,
}
}
}
/// A side of a scale ratio as people write it: at most six decimals, no trailing zeros.
fn ratio_number(v: f64) -> String {
let fixed = format!("{v:.6}");
let short = fixed.trim_end_matches('0').trim_end_matches('.');
if short == "0" || short.len() > 24 { format!("{v:e}") } else { short.into() }
}
pub(crate) fn check_points(points: &[Point]) -> Result<()> {
if points.len() > MAX_POINTS || points.iter().flatten().any(|v| !v.is_finite() || v.abs() > MAX_COORD) {
return Err(invalid("measurement geometry must be finite, within 1e9 user units, and have at most 2048 vertices"));
+2 -1
View File
@@ -218,7 +218,8 @@ impl Walker<'_> {
match s.decoded_within(remaining) {
Ok(data) => {
joined.extend(data);
joined.push(b'\n');
// A space, as the renderer joins pieces: a trailing comment runs on.
joined.push(b' ');
}
Err(_) => self.geometry.unreadable = self.geometry.unreadable.saturating_add(1),
}
+40
View File
@@ -363,3 +363,43 @@ fn user_unit_is_read_as_acrobat_reads_it() {
close(view[1], 72.0);
}
}
#[test]
fn a_scale_ratio_converts_its_page_side_to_points() {
// #2752: a plan drawn at 1:50 is "1 mm = 50 mm", so a line 10 mm long on the page is 500 mm.
// The page side used to be points: 1 pt = 50 mm read the line as 1417 mm (50 × 72 / 25.4).
let mm = 72.0 / 25.4;
let line = [[20.0, 30.0], [20.0 + 10.0 * mm, 30.0]];
let scale = Scale::ratio([0.0, 0.0], [1.0, 0.0], 1.0, PageUnit::Mm, 50.0, "mm", 2).unwrap();
close(reading(Kind::Distance, &line, &scale).unwrap().value, 500.0);
assert_eq!(reading(Kind::Distance, &line, &scale).unwrap().label, "500.00 mm");
assert_eq!(scale.ratio, "1 mm = 50 mm");
// The PDF stores the factors per point (ISO 32000-2 §12.9): X converts user units to mm.
let dict = scale.dictionary().unwrap();
let x = dict.get(b"X").unwrap().as_array().unwrap()[0].as_dict().unwrap();
close(x.get(b"C").and_then(Object::as_f64).unwrap(), 50.0 / mm);
assert_eq!(Scale::read(&fixture("", "", &[]), &Object::Dict(dict)).unwrap(), scale);
// Every page unit converts: one of it on the page is one metre.
for (unit, points) in [(PageUnit::Pt, 1.0), (PageUnit::In, 72.0), (PageUnit::Mm, mm), (PageUnit::Cm, 10.0 * mm)] {
assert_eq!(PageUnit::from_label(unit.label()), Some(unit));
let scale = Scale::ratio([0.0, 0.0], [1.0, 0.0], 2.0, unit, 2.0, "m", 3).unwrap();
close(reading(Kind::Distance, &[[0.0, 0.0], [0.0, 3.0 * points]], &scale).unwrap().value, 3.0);
assert_eq!(scale.ratio, format!("2 {} = 2 m", unit.label()));
}
assert_eq!(PageUnit::from_label("ft"), None);
// On a UserUnit 2 page one display point spans half a user unit; the ratio still holds on screen.
let mut d = fixture("", "/UserUnit 2", &[]);
let (a, b) = (view_to_user(&d, 0, [0.0, 0.0]).unwrap(), view_to_user(&d, 0, [1.0, 0.0]).unwrap());
let scale = Scale::ratio(a, b, 1.0, PageUnit::Cm, 0.5, "m", 2).unwrap();
let ends = [view_to_user(&d, 0, [10.0, 10.0]).unwrap(), view_to_user(&d, 0, [10.0 + 4.0 * 10.0 * mm, 10.0]).unwrap()];
close(reading(Kind::Distance, &ends, &scale).unwrap().value, 2.0);
// Saved and reopened, the viewport keeps the factors and the ratio as written.
set_scale(&mut d, 0, [0.0, 0.0, 612.0, 792.0], "plan", &scale).unwrap();
let d = Document::open(Arc::new(write_full(&d, &SaveOptions::default()).unwrap())).unwrap();
assert_eq!(scale_at(&d, 0, ends[0]).unwrap(), scale);
// Points per unit keep meaning points, and both sides must be finite and positive.
assert_eq!(Scale::calibrate([0.0, 0.0], [1.0, 0.0], 50.0, "mm", 2).unwrap().ratio, "1 pt = 50 mm");
for (page, real) in [(0.0, 1.0), (-1.0, 1.0), (f64::NAN, 1.0), (f64::INFINITY, 1.0), (1.0, 0.0), (1.0, f64::NAN), (1e-300, 1e300)] {
assert!(Scale::ratio([0.0, 0.0], [1.0, 0.0], page, PageUnit::Mm, real, "mm", 2).is_err(), "{page} {real}");
}
}
+41
View File
@@ -175,3 +175,44 @@ fn high_curvature_geometry_hits_segment_cap() {
let geometry = check_snap_invariants(&stress_curve);
assert!(geometry.truncated, "high-curvature geometry did not report its extraction cap ({} segments)", geometry.segments.len());
}
fn two_piece_pdf(first: &[u8], second: &[u8]) -> Vec<u8> {
let mut objects = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /Contents [4 0 R 5 0 R] /Resources << >> >>".to_vec(),
];
for content in [first, second] {
let mut object = format!("<< /Length {} >>\nstream\n", content.len()).into_bytes();
object.extend_from_slice(content);
object.extend_from_slice(b"\nendstream");
objects.push(object);
}
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (index, object) in objects.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n", index + 1).as_bytes());
bytes.extend_from_slice(object);
bytes.extend_from_slice(b"\nendobj\n");
}
let xref = bytes.len();
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
for offset in offsets {
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
bytes
}
fn snap_at_box_corner(first: &[u8]) -> Option<snap::Snap> {
let pdf = two_piece_pdf(first, b"0 0 1 RG 15 95 150 30 re S\n");
let doc = Document::open(Arc::new(pdf)).unwrap();
snap::geometry(&doc, 0).unwrap().snap([15.0, 105.0], 4.0, SnapOptions::default()).unwrap()
}
#[test]
fn trailing_comment_in_previous_piece_hides_next_piece_first_line() {
assert!(snap_at_box_corner(b"q % tail comment, no end of line").is_none(), "snapped to a rectangle the renderer comments out");
assert!(snap_at_box_corner(b"q % tail comment\n").is_some(), "control: the rectangle is drawn");
}
+2 -1
View File
@@ -18,7 +18,8 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going
drawn with nothing else between them), become one lossless image at their finest resolution,
which the image settings then resample; the strips are dropped by Clean Up. A real page
printed this way went from 4.3 MB to 231 KB (Standard) and 161 KB (Mobile). Not merged yet:
side-by-side tiles, rotated strips, masks.
side-by-side tiles, rotated strips, masks. Content arrays use the shared space boundary
policy, so trailing comments, split operands and graphics state keep their meaning after merging.
- **Images:** the effective resolution of each image is measured where pages draw it (the CTM
through form XObjects, up to 12 deep; the smallest over all uses). If a use can't be
measured (forms nested deeper, or drawn too many times), no image is resampled. Colour and grayscale images above a

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