diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 388b44f03..a21cbdfd6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 75ebd1d7c..5b1695604 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -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) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 361a453ec..6c9c88a2e 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -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" diff --git a/CLAUDE.md b/CLAUDE.md index 34f8ab85c..085e8356f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 -j 2 --all-targets` and `cargo test -p -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 ""` 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 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 `/` (`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. diff --git a/Cargo.lock b/Cargo.lock index a439c0b80..a95014924 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] diff --git a/Cargo.toml b/Cargo.toml index 0106433df..201240314 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/README.md b/README.md index af49129ec..7d3069258 100644 --- a/README.md +++ b/README.md @@ -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`. + diff --git a/ROADMAP.md b/ROADMAP.md index 4d8470853..bc057209c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 `` 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 `` 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. diff --git a/apps/pdfcraft-cli/Cargo.toml b/apps/pdfcraft-cli/Cargo.toml index bab8c28a8..991cbf98f 100644 --- a/apps/pdfcraft-cli/Cargo.toml +++ b/apps/pdfcraft-cli/Cargo.toml @@ -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. diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs index 5888e5111..14500a384 100644 --- a/apps/pdfcraft-cli/src/main.rs +++ b/apps/pdfcraft-cli/src/main.rs @@ -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 = 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::() + .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 { + value + .parse::() + .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 { 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::() + .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 = Default::default(); diff --git a/apps/pdfcraft-cli/tests/check.rs b/apps/pdfcraft-cli/tests/check.rs index a206df591..7a6236419 100644 --- a/apps/pdfcraft-cli/tests/check.rs +++ b/apps/pdfcraft-cli/tests/check.rs @@ -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}" + ); + } +} diff --git a/apps/pdfcraft-cli/tests/edit.rs b/apps/pdfcraft-cli/tests/edit.rs index b35b71ddd..3cd1442d8 100644 --- a/apps/pdfcraft-cli/tests/edit.rs +++ b/apps/pdfcraft-cli/tests/edit.rs @@ -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() { diff --git a/apps/pdfcraft-cli/tests/owner_password.rs b/apps/pdfcraft-cli/tests/owner_password.rs new file mode 100644 index 000000000..679698098 --- /dev/null +++ b/apps/pdfcraft-cli/tests/owner_password.rs @@ -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 { + 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 { + protected_with(algorithm, PAGE) +} + +fn protected_with(algorithm: Algorithm, content: &str) -> Vec { + 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:?}"); + } +} diff --git a/apps/pdfcraft-cli/tests/render.rs b/apps/pdfcraft-cli/tests/render.rs index 695431b65..561f9fa78 100644 --- a/apps/pdfcraft-cli/tests/render.rs +++ b/apps/pdfcraft-cli/tests/render.rs @@ -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"); + } +} diff --git a/apps/pdfcraft-cli/tests/split.rs b/apps/pdfcraft-cli/tests/split.rs index 7a4a254f8..71523191d 100644 --- a/apps/pdfcraft-cli/tests/split.rs +++ b/apps/pdfcraft-cli/tests/split.rs @@ -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(¬_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); +} diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index 721b7da1b..83923fbe5 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -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| --mode all|read|edit|convert|sign --tool --left open|closed //! --organize on --fields on --dialog properties|shortcuts|about --palette --home on -//! --cover on|off --default-layout single|continuous|two-up|two-page --default-continuous on|off --default-zoom fit-width|fit-page| +//! --cover on|off --default-layout single|continuous|two-up|two-page --default-continuous on|off --default-zoom fit-width|fit-page| --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 { + 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] diff --git a/contributors/people.toml b/contributors/people.toml index f7cb39292..72796216e 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -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" diff --git a/crates/a11y/README.md b/crates/a11y/README.md index 0a653f131..96cdad13c 100644 --- a/crates/a11y/README.md +++ b/crates/a11y/README.md @@ -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 diff --git a/crates/a11y/src/alt.rs b/crates/a11y/src/alt.rs index 92a5ae8c2..e4755ab38 100644 --- a/crates/a11y/src/alt.rs +++ b/crates/a11y/src/alt.rs @@ -165,7 +165,7 @@ pub(crate) fn page_content(doc: &Document, page: &Dict) -> Vec { .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) -> Result { let Some(entries) = content_entries(doc, &page.dict) else { return Ok(false) }; let props = page_properties(doc, &page.dict); + let mut streams: Vec)>> = 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() { diff --git a/crates/a11y/src/autotag.rs b/crates/a11y/src/autotag.rs index 28a613f36..4462025cf 100644 --- a/crates/a11y/src/autotag.rs +++ b/crates/a11y/src/autotag.rs @@ -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) } diff --git a/crates/a11y/src/content.rs b/crates/a11y/src/content.rs index e8a7db377..8f7221b2c 100644 --- a/crates/a11y/src/content.rs +++ b/crates/a11y/src/content.rs @@ -47,7 +47,7 @@ fn page_content(doc: &Document, page: &Dict) -> Vec { .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 { + let mut seen: HashSet = 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 { 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() { diff --git a/crates/a11y/src/headings.rs b/crates/a11y/src/headings.rs index c7e911fde..537ed908d 100644 --- a/crates/a11y/src/headings.rs +++ b/crates/a11y/src/headings.rs @@ -263,7 +263,9 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap { 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 { diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index fcd681efc..15ca643e8 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -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 { 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> 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 = 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 { let b = source.as_bytes(); diff --git a/crates/a11y/tests/cross_stream.rs b/crates/a11y/tests/cross_stream.rs new file mode 100644 index 000000000..ad613a293 --- /dev/null +++ b/crates/a11y/tests/cross_stream.rs @@ -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, usize) { + let page = &pdfcraft_model::pages(doc)[0]; + let contents = page.dict.get(b"Contents").expect("page contents"); + let pieces: Vec> = 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 { + 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); +} diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 3e1dd4a6b..4668881a6 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -559,7 +559,7 @@ pub fn build(d: &Dict) -> Option { 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 { // 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 { } 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); diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 587cd4466..6edfbf9cb 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -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, }, /// 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>, @@ -651,25 +659,54 @@ impl ReviewState { pub fn page_refs(doc: &Document) -> Result, 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, + pending: Vec<(std::sync::Arc, 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 { + 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 { @@ -803,6 +840,27 @@ pub fn picture_rotation(doc: &Document, page: usize, index: usize) -> Result Result { + 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::>>()) + .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 { /// redrawing it. See [`AppearanceCache`] for when entries stay valid. pub fn with_missing_appearances_cached(doc: &Document, cache: &mut AppearanceCache) -> Option { let mut copy: Option = 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, /// 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 { @@ -2136,6 +2265,12 @@ pub fn summaries(doc: &Document) -> Vec { } } 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 { 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, }); } } diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 4a4dfb106..a65996681 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -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> { + let rect = |d: &Dict| d.get(b"Rect").and_then(|r| r.as_array()).map(|a| a.iter().filter_map(Object::as_f64).collect::>()); + 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 = 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 = 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}"); +} diff --git a/crates/audit/README.md b/crates/audit/README.md index bda5cc977..37771a535 100644 --- a/crates/audit/README.md +++ b/crates/audit/README.md @@ -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 diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs index 723d086d6..365889d61 100644 --- a/crates/audit/src/lib.rs +++ b/crates/audit/src/lib.rs @@ -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, 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, 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, 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, AuditError> { let Some(a) = page.get(b"Annots") else { return Ok(Vec::new()) }; let resolved = doc.resolve(a); let items: Vec = match &*resolved { @@ -207,16 +221,46 @@ fn annots(doc: &Document, page: &Dict) -> Result, AuditError> { pdfcraft_cos::Object::Dict(d) => d.clone(), _ => continue, }; - if dict.name(b"Subtype") != Some(b"Redact") { - continue; - } let v: Vec = 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 = dict.get(key).map(|o| doc.resolve(o))?.as_array()?.iter().map(|o| doc.resolve(o).as_f64()).collect::>()?; + 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, + } +} diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index 63d9220cc..4ca3b0bf5 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -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. diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index 26f36728c..bed579ab8 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -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"] } diff --git a/crates/automation/README.md b/crates/automation/README.md index 669ea6146..87111b2ea 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -15,6 +15,12 @@ tools() -> Vec // 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 diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 302688f72..3c53563be 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -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::>()); + } + v }; let roots: Vec = 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 { 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::>()).as_deref() { + Some([Some(x), Some(y)]) => Ok(to_user(&info, *x, *y)), + _ => Err(wrong()), + }) + .collect::>>()?; + 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) diff --git a/crates/automation/src/content.rs b/crates/automation/src/content.rs index faefc5fa7..8926a1368 100644 --- a/crates/automation/src/content.rs +++ b/crates/automation/src/content.rs @@ -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; diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index bd70b6543..26e7b9d06 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -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 { + 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::>>()) + .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::>>()) + .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 = 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 { 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 = 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 { + 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> = 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 = 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::>()); + out["fields"] = order + .fields + .iter() + .map(|f| json!({ "name": f.name, "reads": f.reads, "listed": f.listed, "issues": f.issues.iter().map(issue).collect::>() })) + .collect(); + out["extra"] = json!(order.extra); + out["fixable"] = json!(order.fixable()); + Ok(out) + } + fn tab_sequence(&self, a: &Args) -> Result { let doc = self.doc(a)?; let mut seq: Vec<(usize, &str)> = diff --git a/crates/automation/src/layers.rs b/crates/automation/src/layers.rs new file mode 100644 index 000000000..7cb0817a4 --- /dev/null +++ b/crates/automation/src/layers.rs @@ -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 { + 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 = 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::>() })) + } +} diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index af0583717..c90240875 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -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>, 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>, + /// 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, } 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 = @@ -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 = doc - .text_blocks(page) + let current = doc.text_blocks(page); + let id = doc.id; + let blocks: Vec = current .iter() .enumerate() .map(|(i, b)| { @@ -548,6 +578,7 @@ impl Automation { }) }) .collect(); + self.record_listing(id, page, ¤t); 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::() == 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 { + 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 { + 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 = 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::>>().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> { + 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> { 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::>(), - "layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible })).collect::>(), + "layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible, "initial": l.initial })).collect::>(), // 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!({ diff --git a/crates/automation/src/links.rs b/crates/automation/src/links.rs index cd0fb9da0..8058d5639 100644 --- a/crates/automation/src/links.rs +++ b/crates/automation/src/links.rs @@ -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. diff --git a/crates/automation/src/measure.rs b/crates/automation/src/measure.rs index 13500ff97..ac3d2d95f 100644 --- a/crates/automation/src/measure.rs +++ b/crates/automation/src/measure.rs @@ -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)?; diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index 19cd48ef4..39cd45760 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -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> { + 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) -> 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()); + } +} diff --git a/crates/automation/src/redact.rs b/crates/automation/src/redact.rs index 1b6283c0a..e662bcac1 100644 --- a/crates/automation/src/redact.rs +++ b/crates/automation/src/redact.rs @@ -14,9 +14,19 @@ impl Automation { pub(crate) fn redact_mark(&mut self, a: &Args) -> Result { let doc = self.doc(a)?; let (id, n) = (doc.id, doc.info.pages.len()); - let pages: Vec = 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 = 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())), diff --git a/crates/automation/src/signing.rs b/crates/automation/src/signing.rs index 94fc4bdec..c7a043c89 100644 --- a/crates/automation/src/signing.rs +++ b/crates/automation/src/signing.rs @@ -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 { diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 597882d2f..9ce6b12d4 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -161,7 +161,7 @@ pub fn tools() -> Vec { 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 { }), &["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 { }), &["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 { 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 { 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 { "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 { 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 { "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 { .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 { }), &["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 { 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 { 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 { "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 { 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, <> page count, <<1 of n>>, <>, <<1/n>>, dates <> <> <> (and more), Bates <>. 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, <> page count, <<1 of n>>, <>, <<1/n>>, dates <> <> <> (and more), Bates <>. 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 { "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 { 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 { .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 { 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 { .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 }, diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 91a2656f4..bf6169f42 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -64,6 +64,65 @@ fn remote_link_pdf(file: &str, destination: Option<&str>) -> Vec { bytes } +#[cfg(target_os = "macos")] +#[test] +fn saving_a_pdf_keeps_the_destinations_extended_attributes() { + use rustix::fs::{XattrFlags, getxattr, setxattr}; + let dir = workdir("save-xattrs"); + let target = dir.join("a.pdf"); + let tags = b"PdfCraft\n6"; + let binary = b"\0\xffpdfcraft\0"; + let attributes: &[(&str, &[u8])] = &[("com.apple.metadata:_kMDItemUserTags", tags), ("com.pdfcraft.test", binary)]; + for (name, value) in attributes { + setxattr(&target, *name, value, XattrFlags::empty()).unwrap(); + } + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path": "a.pdf"}))["doc"].as_u64().unwrap(); + for full in [false, true] { + ok(&mut a, "page_rotate", json!({"doc": doc, "pages": [1], "degrees": 90})); + let saved = ok(&mut a, "doc_save", json!({"doc": doc, "full": full})); + assert_eq!(saved["incremental"], !full); + for (name, value) in attributes { + let mut bytes = [0u8; 256]; + let len = getxattr(&target, *name, &mut bytes[..]).unwrap(); + assert_eq!(&bytes[..len], *value, "full={full}, {name}"); + } + } + // Save As preserves the destination's metadata, not attributes from the open input file. + let other = dir.join("other.pdf"); + std::fs::write(&other, fixture(1)).unwrap(); + setxattr(&other, "com.pdfcraft.test", b"destination", XattrFlags::empty()).unwrap(); + ok(&mut a, "doc_save", json!({"doc": doc, "path": "other.pdf"})); + let mut bytes = [0u8; 256]; + let len = getxattr(&other, "com.pdfcraft.test", &mut bytes[..]).unwrap(); + assert_eq!(&bytes[..len], b"destination"); + assert_eq!(getxattr(&other, "com.apple.metadata:_kMDItemUserTags", &mut bytes[..]).unwrap_err(), rustix::io::Errno::NOATTR); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[cfg(target_os = "macos")] +#[test] +fn a_metadata_preservation_error_keeps_the_pdf_and_unsaved_edit() { + use rustix::fs::{XattrFlags, setxattr}; + let dir = workdir("save-xattrs-error"); + let target = dir.join("a.pdf"); + let original = std::fs::read(&target).unwrap(); + setxattr(&target, "com.apple.ResourceFork", &vec![42u8; 16 * 1024 * 1024 + 1], XattrFlags::empty()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path": "a.pdf"}))["doc"].as_u64().unwrap(); + ok(&mut a, "page_rotate", json!({"doc": doc, "pages": [1], "degrees": 90})); + let failure = err(&mut a, "doc_save", json!({"doc": doc})); + assert!(failure.to_string().contains("extended attributes"), "{failure}"); + assert!(failure.to_string().contains("Save As"), "{failure}"); + assert_eq!(std::fs::read(&target).unwrap(), original); + assert_eq!(ok(&mut a, "doc_info", json!({"doc": doc}))["document"]["dirty"], true); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2, "only the two original PDFs remain"); + // A fresh name remains usable: metadata refusal doesn't discard the accepted edit. + ok(&mut a, "doc_save", json!({"doc": doc, "path": "copy.pdf"})); + assert_ne!(std::fs::read(dir.join("copy.pdf")).unwrap(), original); + std::fs::remove_dir_all(dir).unwrap(); +} + fn protected_fixture(password: &str) -> Vec { use pdfcraft_engine::{Edit, Protection, Session}; let mut session = Session::new(); @@ -779,6 +838,117 @@ fn edit_undo_redo_and_save_round_trip() { assert_eq!(after.windows(5).filter(|w| w == b"%%EOF").count(), 2); } +/// A shallow-object destination tree: `depth` single-child nodes above `count` names. +fn destination_graph_fixture(depth: usize, count: usize) -> Vec { + use pdfcraft_cos::{Dict, Document, Object, PdfString, SaveOptions}; + let mut doc = Document::open(std::sync::Arc::new(fixture(3))).unwrap(); + let pages = pdfcraft_organize::pages(&doc).unwrap(); + let pairs = (0..count) + .flat_map(|i| { + let page = if i % 2 == 0 { pages[0].obj } else { pages[2].obj }; + [Object::String(PdfString::text(&format!("dest-{i:05}"))), Object::Array(vec![Object::Ref(page), Object::name("Fit")])] + }) + .collect(); + let mut leaf = Dict::new(); + leaf.set(b"Names".to_vec(), Object::Array(pairs)); + let mut tree = doc.add(leaf); + for _ in 0..depth { + let mut node = Dict::new(); + node.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(tree)])); + tree = doc.add(node); + } + let mut names = Dict::new(); + names.set(b"Dests".to_vec(), Object::Ref(tree)); + doc.update_dict(doc.root().unwrap(), |d| d.set(b"Names".to_vec(), Object::Dict(names))).unwrap(); + pdfcraft_cos::write_incremental(&doc, &SaveOptions::default()).unwrap() +} + +/// Inspect the actual working bytes, not just page counts or history labels. The fixture has +/// exactly `depth` single-child nodes, so this checks its leaf without a second general walker. +fn assert_destination_state(a: &Automation, doc: u64, depth: usize, count: usize, deleted: bool) { + use pdfcraft_cos::{Document, Object}; + let bytes = a.session().get(pdfcraft_engine::DocId(doc)).unwrap().bytes.clone(); + let parsed = Document::open(bytes).unwrap(); + let pages = pdfcraft_organize::pages(&parsed).unwrap(); + assert_eq!(pages.len(), if deleted { 2 } else { 3 }); + let catalog = parsed.get(parsed.root().unwrap()); + let names = parsed.resolve(catalog.as_dict().unwrap().get(b"Names").unwrap()); + let mut node = parsed.resolve(names.as_dict().unwrap().get(b"Dests").unwrap()); + for _ in 0..depth { + let kids = node.as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap(); + assert_eq!(kids.len(), 1); + let child = kids[0].clone(); + node = parsed.resolve(&child); + } + let pairs = node.as_dict().unwrap().get(b"Names").and_then(Object::as_array).unwrap(); + assert_eq!(pairs.len(), if deleted { count / 2 * 2 } else { count * 2 }); + let expected = (0..count).filter(|i| !deleted || i % 2 == 1); + for (pair, i) in pairs.as_chunks::<2>().0.iter().zip(expected) { + assert_eq!(pair[0].as_string().unwrap().to_text(), format!("dest-{i:05}")); + let target = if i % 2 == 0 { pages[0].obj } else { pages.last().unwrap().obj }; + assert_eq!(pair[1].as_array().unwrap()[0].as_ref(), Some(target), "destination {i}"); + } +} + +/// Exercise the real tools, including named destinations after undo and redo, and check both +/// kinds of save explicitly: a full copy, followed by an incremental update of that copy. +fn delete_destination_tree_through_tools(depth: usize, count: usize, label: &str) { + let dir = workdir(label); + let original = destination_graph_fixture(depth, count); + std::fs::write(dir.join("source.pdf"), &original).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path":"source.pdf"}))["doc"].as_u64().unwrap(); + assert_destination_state(&a, doc, depth, count, false); + let render = a.call("page_render", &json!({"doc":doc,"page":2,"dpi":72})).unwrap(); + ok(&mut a, "doc_set_info", json!({"doc":doc,"key":"Title","value":"Unsaved work"})); + ok(&mut a, "page_delete", json!({"doc":doc,"pages":[1]})); + assert_destination_state(&a, doc, depth, count, true); + assert_eq!(page_text(&mut a, doc), ["Page 2", "Page 3"]); + assert_eq!(a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72})).unwrap(), render); + ok(&mut a, "edit_undo", json!({"doc":doc})); + assert_destination_state(&a, doc, depth, count, false); + assert_eq!(page_text(&mut a, doc), ["Page 1", "Page 2", "Page 3"]); + assert_eq!(ok(&mut a, "doc_info", json!({"doc":doc}))["title"], "Unsaved work"); + ok(&mut a, "edit_redo", json!({"doc":doc})); + assert_destination_state(&a, doc, depth, count, true); + assert_eq!(page_text(&mut a, doc), ["Page 2", "Page 3"]); + assert_eq!(ok(&mut a, "doc_info", json!({"doc":doc}))["title"], "Unsaved work"); + + let saved = ok(&mut a, "doc_save", json!({"doc":doc,"path":"saved.pdf","full":true})); + assert_eq!(saved["incremental"], false); + let full = std::fs::read(dir.join("saved.pdf")).unwrap(); + let mut b = auto(&dir); + let reopened = ok(&mut b, "doc_open", json!({"path":"saved.pdf"}))["doc"].as_u64().unwrap(); + assert_destination_state(&b, reopened, depth, count, true); + assert_eq!(b.call("page_render", &json!({"doc":reopened,"page":1,"dpi":72})).unwrap(), render); + + assert_eq!(ok(&mut b, "doc_info", json!({"doc":reopened}))["title"], "Unsaved work"); + ok(&mut b, "doc_set_info", json!({"doc":reopened,"key":"Author","value":"Incremental probe"})); + let saved = ok(&mut b, "doc_save", json!({"doc":reopened})); + assert_eq!(saved["incremental"], true); + let incremental = std::fs::read(dir.join("saved.pdf")).unwrap(); + assert!(incremental.len() > full.len() && incremental.starts_with(&full)); + let mut b = auto(&dir); + let reopened = ok(&mut b, "doc_open", json!({"path":"saved.pdf"}))["doc"].as_u64().unwrap(); + assert_destination_state(&b, reopened, depth, count, true); + assert_eq!(page_text(&mut b, reopened), ["Page 2", "Page 3"]); + assert_eq!(b.call("page_render", &json!({"doc":reopened,"page":1,"dpi":72})).unwrap(), render); + let info = ok(&mut b, "doc_info", json!({"doc":reopened})); + assert_eq!(info["title"], "Unsaved work"); + assert_eq!(info["author"], "Incremental probe"); + assert_eq!(std::fs::read(dir.join("source.pdf")).unwrap(), original); +} + +#[test] +fn page_delete_handles_deep_destination_trees_with_history_and_reopen() { + delete_destination_tree_through_tools(16_384, 2, "deep-destinations"); +} + +#[test] +fn page_delete_handles_sixty_thousand_destinations_with_history_and_reopen() { + delete_destination_tree_through_tools(0, 60_000, "many-destinations"); +} + /// Issue #1329: an in-place save says it was incremental only when it kept the file it was /// saving over: protecting or sanitizing rewrites the file, and the result says so. #[test] @@ -2239,6 +2409,57 @@ fn headers_watermarks_and_backgrounds_through_tools() { assert!(matches!(a.call("doc_remove_marks", &json!({ "doc": doc, "kind": "watermark" })), Err(ToolError::Failed(_)))); assert!(matches!(a.call("doc_header_footer", &json!({ "doc": doc })), Err(ToolError::Failed(_))), "no text"); ok(&mut a, "doc_save", json!({ "doc": doc, "path": "marked.pdf" })); + // A standard face other than Helvetica (#3754), kept through save and reopen; one it doesn't + // offer is refused with the faces it does. + let mono = ok(&mut a, "doc_open", json!({ "path": "b.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_header_footer", json!({ "doc": mono, "header_left": "MONO", "font": "Courier" })); + assert!(page_text(&mut a, mono)[0].contains("MONO")); + ok(&mut a, "doc_save", json!({ "doc": mono, "path": "courier.pdf" })); + let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("courier.pdf")).unwrap())).unwrap(); + let page = &pdfcraft_model::pages(&saved)[0]; + let res = saved.resolve(page.dict.get(b"Resources").unwrap()); + let fonts = saved.resolve(res.as_dict().unwrap().get(b"Font").unwrap()).as_dict().cloned().unwrap(); + let face = saved.resolve(fonts.get(b"PCCour").expect("Courier is added")).as_dict().cloned().unwrap(); + assert_eq!(face.name(b"BaseFont"), Some(&b"Courier"[..])); + let refused = a.call("doc_header_footer", &json!({ "doc": mono, "header_left": "X", "font": "Arial" })); + assert!(matches!(refused, Err(ToolError::InvalidArgs(e)) if e.contains("Courier"))); +} + +#[test] +fn a_shrinking_header_and_footer_moves_the_page_content_out_of_their_way_through_tools() { + let dir = workdir("hf-shrink"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + // The box of "Page 1" (left, top, width, height in points), read back from a saved copy. + let mut saved = 0; + let mut text_box = |a: &mut Automation| -> [f64; 4] { + saved += 1; + let path = format!("shrunk{saved}.pdf"); + ok(a, "doc_save", json!({ "doc": doc, "path": path })); + let copy = ok(a, "doc_open", json!({ "path": path }))["doc"].as_u64().unwrap(); + let found = ok(a, "text_find", json!({ "doc": copy, "query": "Page 1" })); + let r = found["matches"][0]["rects"][0].as_array().unwrap_or_else(|| panic!("Page 1 is on the page: {found}")); + let v: Vec = r.iter().map(|x| x.as_f64().unwrap()).collect(); + [v[0], v[1], v[2] - v[0], v[3] - v[1]] + }; + let before = text_box(&mut a); + ok( + &mut a, + "doc_header_footer", + json!({ "doc": doc, "pages": [1], "header_center": "ACME", "footer_right": "<>", "shrink": true }), + ); + let after = text_box(&mut a); + // 200 × 300 page, 8 pt text and 36 pt margins: the header takes 48 pt, the footer 46.4 pt. + let k = (300.0 - 48.0 - 46.4) / 300.0; + assert!((after[2] / before[2] - k).abs() < 0.02 && (after[3] / before[3] - k).abs() < 0.02, "scaled by {k}: {before:?} → {after:?}"); + assert!((after[0] - (20.0 * k + (200.0 - 200.0 * k) / 2.0)).abs() < 1.0, "centred across: {before:?} → {after:?}"); + assert!(page_text(&mut a, doc)[0].contains("000001"), "the Bates number is drawn"); + // Margins that leave no room are refused; Remove brings the content back to full size. + let full = json!({ "doc": doc, "header_center": "X", "margins": [295, 36, 72, 72], "shrink": true, "replace": true }); + assert!(matches!(a.call("doc_header_footer", &full), Err(ToolError::Failed(_))), "no room"); + ok(&mut a, "doc_remove_marks", json!({ "doc": doc, "kind": "header_footer" })); + let restored = text_box(&mut a); + assert!(restored.iter().zip(before).all(|(r, b)| (r - b).abs() < 0.01), "{before:?} → {restored:?}"); } #[test] @@ -2294,6 +2515,63 @@ fn exporting_images_and_text_through_tools() { assert!(a.call("doc_export_text", &json!({ "doc": doc, "path": "/etc/x.txt" })).is_err(), "confined to the root"); } +#[test] +fn accessibility_character_encoding_accepts_identity_cjk_through_tools() { + use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, SaveOptions, Stream, write_full}; + use std::sync::Arc; + + let dir = workdir("a11y-identity-cjk"); + let mut a = auto(&dir); + for encoding in ["Identity-H", "Identity-V"] { + let mut source = Document::open(Arc::new(fixture(1))).unwrap(); + let mut system = Dict::new(); + system.set(b"Registry".to_vec(), Object::String(PdfString::literal(b"Adobe".to_vec()))); + system.set(b"Ordering".to_vec(), Object::String(PdfString::literal(b"Japan1".to_vec()))); + system.set(b"Supplement".to_vec(), Object::Int(0)); + let system = source.add(Object::Dict(system)); + let mut descriptor = Dict::new(); + descriptor.set(b"Type".to_vec(), Object::Name(b"FontDescriptor".to_vec())); + descriptor.set(b"FontName".to_vec(), Object::Name(b"SyntheticCID".to_vec())); + descriptor.set(b"Flags".to_vec(), Object::Int(6)); + descriptor.set(b"FontBBox".to_vec(), Object::Array([0, -200, 1000, 800].into_iter().map(Object::Int).collect())); + descriptor.set(b"ItalicAngle".to_vec(), Object::Int(0)); + descriptor.set(b"Ascent".to_vec(), Object::Int(800)); + descriptor.set(b"Descent".to_vec(), Object::Int(-200)); + descriptor.set(b"CapHeight".to_vec(), Object::Int(700)); + descriptor.set(b"StemV".to_vec(), Object::Int(80)); + let descriptor = source.add(Object::Dict(descriptor)); + let mut cid = Dict::new(); + cid.set(b"Type".to_vec(), Object::Name(b"Font".to_vec())); + cid.set(b"Subtype".to_vec(), Object::Name(b"CIDFontType0".to_vec())); + cid.set(b"BaseFont".to_vec(), Object::Name(b"SyntheticCID".to_vec())); + cid.set(b"CIDSystemInfo".to_vec(), Object::Ref(system)); + cid.set(b"FontDescriptor".to_vec(), Object::Ref(descriptor)); + cid.set(b"DW".to_vec(), Object::Int(1000)); + let cid = source.add(Object::Dict(cid)); + source + .update_dict(ObjRef::new(3, 0), |font| { + font.set(b"Subtype".to_vec(), Object::Name(b"Type0".to_vec())); + font.set(b"BaseFont".to_vec(), Object::Name(b"SyntheticCID".to_vec())); + font.set(b"Encoding".to_vec(), Object::Name(encoding.as_bytes().to_vec())); + font.set(b"DescendantFonts".to_vec(), Object::Array(vec![Object::Ref(cid)])); + }) + .unwrap(); + source.set(ObjRef::new(5, 0), Object::Stream(Stream::flate(Dict::new(), b"BT /F1 24 Tf 20 150 Td <002200230024> Tj ET"))); + let name = format!("{encoding}.pdf"); + let bytes = write_full(&source, &SaveOptions::default()).unwrap(); + std::fs::write(dir.join(&name), &bytes).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": name }))["doc"].as_u64().unwrap(); + assert_eq!(page_text(&mut a, doc), ["ABC"], "the collection maps the CIDs to Unicode without ToUnicode"); + let report = ok(&mut a, "accessibility_check", json!({ "doc": doc, "rules": ["character-encoding"] })); + let result = report["results"].as_array().unwrap().iter().find(|r| r["rule"] == "character-encoding").unwrap(); + assert_eq!(result["status"], "passed", "{encoding}: {result}"); + assert_eq!(result["findings"], json!([])); + assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc }))["document"]["dirty"], false); + assert_eq!(std::fs::read(dir.join(name)).unwrap(), bytes, "checking is read-only"); + } + std::fs::remove_dir_all(dir).unwrap(); +} + #[test] fn accessibility_check_report_and_fixes_through_tools() { let dir = workdir("a11y"); @@ -2594,6 +2872,59 @@ fn editing_existing_text_through_tools() { assert_eq!(p["lines"].as_array().map(Vec::len), Some(2), "{p}"); } +/// #2593: a paragraph whose own font cannot show a character is written from PdfCraft's fallback +/// faces. The Cyrillic the craft-fonts faces lack (Uzbek қ ғ ҳ, Kazakh ә ө ү ұ һ) comes from the +/// bundled Inter face, so the edit is not refused even without `CRAFT_FONTS_DIR`, and the words +/// survive a save and a reopen. (`crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face` +/// pins which face drew them.) +#[test] +fn editing_uzbek_and_kazakh_cyrillic_through_tools() { + let dir = workdir("cyrillic-fallback"); + let body = "BT /F1 12 Tf 14.4 TL 1 0 0 1 72 700 Tm (The quick brown fox jumps over the lazy dog) Tj T* (and keeps running through the long field.) Tj ET"; + std::fs::write(dir.join("cy.pdf"), win_ansi_paragraph_pdf(body)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "cy.pdf" }))["doc"].as_u64().unwrap(); + for word in ["қизил", "әже"] { + let text = format!("The quick brown fox jumps over the lazy dog {word}\nand keeps running through the long field."); + let edited = ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "text": text })); + let paragraph = edited["paragraph"]["text"].as_str().unwrap(); + assert!(paragraph.starts_with("The quick brown fox") && paragraph.ends_with("long field."), "{word}: {edited}"); + assert!(paragraph.contains(word), "{word}: {edited}"); + assert!(page_text(&mut a, doc)[0].contains(word), "{word}: the edited page reads it back"); + assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Edit text"); + } + // Keep the Kazakh edit, save it, and read it back from the file on disk. + let text = "The quick brown fox jumps over the lazy dog әже\nand keeps running through the long field."; + ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": 1, "text": text })); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "cy-out.pdf" })); + let saved = ok(&mut a, "doc_open", json!({ "path": "cy-out.pdf" }))["doc"].as_u64().unwrap(); + assert!(page_text(&mut a, saved)[0].contains("әже"), "the saved file reads it back"); +} + +/// One Letter page whose Helvetica is not embedded (WinAnsi), with `body` as its content stream. +fn win_ansi_paragraph_pdf(body: &str) -> Vec { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + bytes +} + /// One page whose content is one stream in three pieces (#155): a `TJ` array ends the middle /// piece and its operator starts the last one. fn split_streams_pdf() -> Vec { @@ -2671,6 +3002,15 @@ fn editing_page_images_through_tools() { assert_eq!(cropped["visible_rect"], json!([10.0, 10.0, 25.0, 30.0])); ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "rotate" })); ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "flip_horizontal" })); + // Any angle: the box grows to the turned picture's bounds, and it undoes like a quarter turn. + let before = ok(&mut a, "page_images", json!({ "doc": made, "page": 1 }))["images"][0]["rect"].clone(); + let r = ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "rotate_by", "degrees": 30.5 })); + assert_eq!(r["undo"], "Rotate image"); + let turned = ok(&mut a, "page_images", json!({ "doc": made, "page": 1 }))["images"][0]["rect"].clone(); + assert_ne!(turned, before); + ok(&mut a, "edit_undo", json!({ "doc": made })); + assert_eq!(ok(&mut a, "page_images", json!({ "doc": made, "page": 1 }))["images"][0]["rect"], before); + assert!(matches!(a.call("image_edit", &json!({ "doc": made, "page": 1, "image": 1, "action": "rotate_by" })), Err(ToolError::InvalidArgs(_)))); let saved = ok(&mut a, "image_save", json!({ "doc": made, "page": 1, "image": 1, "path": "out/picture" })); assert_eq!(saved["format"], "png"); assert!(std::fs::read(dir.join("out/picture.png")).unwrap().starts_with(b"\x89PNG")); @@ -3391,6 +3731,95 @@ fn rotating_a_field_through_tools() { assert_eq!((rot, back), (0, before)); } +/// A table of text fields through the tool: laid out from the top of the page as displayed, named +/// column by column, one undo step, and refused whole, with the reason, when any of it can't go in. +#[test] +fn adding_a_field_table_through_tools() { + let dir = workdir("field-table"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let table = + json!({ "doc": doc, "page": 1, "rect": [20, 20, 220, 120], "rows": 2, "names": ["desc_N", "price_N"], "widths": [3, 1], "row_gap": 10 }); + assert_eq!(ok(&mut a, "form_add_field_table", table.clone())["fields"], json!(["desc_1", "price_1", "desc_2", "price_2"])); + let rects = |a: &mut Automation| { + let fields = ok(a, "form_fields", json!({ "doc": doc }))["fields"].as_array().unwrap().clone(); + fields.iter().map(|f| (f["name"].as_str().unwrap().to_owned(), f["rect"].clone())).collect::>() + }; + // 150 and 50 points wide, (100 - 10) / 2 = 45 high, the first row at the top. + let near = |v: &Value, want: [f64; 4]| v.as_array().unwrap().iter().zip(want).all(|(g, w)| (g.as_f64().unwrap() - w).abs() < 0.01); + let got = rects(&mut a); + for (name, want) in [ + ("desc_1", [20.0, 20.0, 170.0, 65.0]), + ("price_1", [170.0, 20.0, 220.0, 65.0]), + ("desc_2", [20.0, 75.0, 170.0, 120.0]), + ("price_2", [170.0, 75.0, 220.0, 120.0]), + ] { + let rect = got.iter().find(|(n, _)| n == name).map(|(_, r)| r).unwrap(); + assert!(near(rect, want), "{name}: {rect} != {want:?}"); + } + // Without names, each table takes the first free base. + let plain = json!({ "doc": doc, "page": 2, "rect": [20, 20, 220, 120], "rows": 2 }); + assert_eq!(ok(&mut a, "form_add_field_table", plain.clone())["fields"], json!(["Table1_1", "Table1_2"])); + assert_eq!(ok(&mut a, "form_add_field_table", plain)["fields"], json!(["Table2_1", "Table2_2"])); + let count = |a: &mut Automation| ok(a, "form_fields", json!({ "doc": doc }))["fields"].as_array().unwrap().len(); + assert_eq!(count(&mut a), 8); + // Refused whole, saying why: nothing is added. + let refused = |a: &mut Automation, args: Value| match a.call("form_add_field_table", &args) { + Err(ToolError::InvalidArgs(m)) => m, + other => panic!("refused, not {other:?}"), + }; + assert_eq!(refused(&mut a, table), "a field named \"desc_1\" already exists"); + let small = json!({ "doc": doc, "page": 1, "rect": [20, 200, 220, 300], "rows": 30 }); + assert!(refused(&mut a, small).starts_with("the rows are too short")); + let dotted = json!({ "doc": doc, "page": 1, "rect": [20, 200, 220, 300], "rows": 2, "names": ["a.{n}"] }); + assert_eq!(refused(&mut a, dotted), "field names can't contain a period: \"a.1\""); + let disagree = json!({ "doc": doc, "page": 1, "rect": [20, 200, 220, 300], "rows": 2, "columns": 3, "names": ["a_N", "b_N"] }); + assert!(refused(&mut a, disagree).contains("must agree")); + let wide = json!({ "doc": doc, "page": 1, "rect": [20, 200, 220, 300], "rows": 2, "columns": 51 }); + assert_eq!(refused(&mut a, wide), "a table has 1 to 50 columns"); + let none = json!({ "doc": doc, "page": 1, "rect": [20, 200, 220, 300], "rows": -1 }); + assert_eq!(refused(&mut a, none), "a table has 1 to 100 rows"); + let huge = json!({ "doc": doc, "page": 1, "rect": [20, 200, 1e39, 300], "rows": 2 }); + assert!(refused(&mut a, huge).starts_with("the area, the spacing and the column widths must be numbers")); + assert_eq!(count(&mut a), 8); + // The table tool's command runs it too. + let by_command = json!({ "id": "form.add.text_table", "params": { "doc": doc, "page": 3, "rect": [20, 20, 220, 120], "rows": 2 } }); + assert_eq!(ok(&mut a, "command_run", by_command)["fields"], json!(["Table3_1", "Table3_2"])); + assert_eq!(count(&mut a), 10); + assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Add text field table"); + assert_eq!(count(&mut a), 8, "one undo takes back the whole last table"); +} + +#[test] +fn redact_mark_respects_single_page_and_rejects_conflicting_selectors() { + let dir = workdir("redact-single-page"); + std::fs::write(dir.join("memo.txt"), "Private 123-45-6789").unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_create", json!({ "from": "text", "path": "memo.txt" }))["doc"].as_u64().unwrap(); + ok(&mut a, "page_duplicate", json!({ "doc": doc, "pages": [1] })); + for selector in [json!({ "find": "Private" }), json!({ "words": ["Private"] }), json!({ "pattern": "ssn" }), json!({ "whole_pages": true })] { + let mut args = selector; + args["doc"] = json!(doc); + args["page"] = json!(1); + let result = ok(&mut a, "redact_mark", args); + assert_eq!(result["marked"], 1); + ok(&mut a, "redact_clear", json!({ "doc": doc })); + } + for args in [ + json!({ "doc": doc, "find": "Private", "page": 1, "pages": [2] }), + json!({ "doc": doc, "rect": [1, 1, 10, 10], "pages": [2] }), + json!({ "doc": doc, "find": "Private", "page": 3 }), + ] { + assert!(matches!(a.call("redact_mark", &args), Err(ToolError::InvalidArgs(_)))); + } + let result = ok(&mut a, "redact_mark", json!({ "doc": doc, "find": "Private", "page": 1 })); + assert_eq!(result["marks_pending"], 1, "invalid selections must not add marks"); + ok(&mut a, "redact_apply", json!({ "doc": doc })); + let text = page_text(&mut a, doc); + assert!(!text[0].contains("Private")); + assert!(text[1].contains("Private")); +} + #[test] fn redacting_through_tools() { let dir = workdir("redact"); @@ -3415,6 +3844,48 @@ fn redacting_through_tools() { assert!(matches!(a.call("redact_mark", &json!({ "doc": doc })), Err(ToolError::InvalidArgs(_)))); } +/// One page with the text "Secret 1234" and a Redact annotation that has neither a usable +/// QuadPoints array nor a Rect, so there is no area to redact. +fn redact_pdf_with_unreadable_mark() -> Vec { + let body = "BT /F1 24 Tf 20 150 Td (Secret 1234) Tj ET"; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>".into(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + "<< /Type /Annot /Subtype /Redact /QuadPoints /oops >>".into(), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + bytes +} + +/// A mark that can't be read is refused by `redact_apply`, with the reason in the reply, and the +/// page keeps its text. (Before, the mark was skipped and the reply said there were no marks.) +#[test] +fn an_unreadable_redaction_mark_is_refused_with_the_reason() { + let dir = workdir("redact-unreadable"); + std::fs::write(dir.join("broken.pdf"), redact_pdf_with_unreadable_mark()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "broken.pdf" }))["doc"].as_u64().unwrap(); + let Err(ToolError::Failed(reason)) = a.call("redact_apply", &json!({ "doc": doc })) else { + panic!("redact_apply must refuse a mark with no readable area"); + }; + assert!(reason.contains("1 redaction mark(s) have no readable area") && reason.contains("nothing was redacted"), "{reason}"); + assert!(page_text(&mut a, doc)[0].contains("Secret 1234"), "the page is unchanged"); +} + #[test] fn redacting_with_codes_through_tools() { let dir = workdir("redact-codes"); @@ -3720,6 +4191,103 @@ fn content_index_out_of_range_is_an_error_not_a_panic() { assert_eq!(ok(&mut a, "content_list", json!({ "doc": doc }))["count"], 1); } +fn added_rotation_image(dir: &Path) { + let mut png = Vec::new(); + let mut enc = png::Encoder::new(&mut png, 2, 1); + enc.set_color(png::ColorType::Rgb); + enc.write_header().unwrap().write_image_data(&[255, 0, 0, 0, 0, 255]).unwrap(); + std::fs::write(dir.join("rotation.png"), png).unwrap(); +} + +#[test] +fn large_added_image_rotations_keep_orientation_and_undo() { + let dir = workdir("large-added-rotation"); + added_rotation_image(&dir); + let mut a = auto(&dir); + let image = |a: &Automation, doc| { + let d = a.session().get(pdfcraft_engine::DocId(doc)).unwrap(); + let pdfcraft_engine::AddedContent::Image(image) = &d.added()[0].content else { panic!("added image") }; + image.clone() + }; + let largest = i64::MAX / 90 * 90; + let smallest = i64::MIN / 90 * 90; + for initial in [1, 0, 2, 3] { + let doc = ok(&mut a, "doc_create", json!({ "from": "blank", "width": 200, "height": 300 }))["doc"].as_u64().unwrap(); + ok(&mut a, "page_add_image", json!({ "doc": doc, "page": 1, "path": "rotation.png", "rect": [20, 20, 120, 70] })); + ok( + &mut a, + "content_update", + json!({ "doc": doc, "page": 1, "index": 1, "rotate": initial * 90, + "flip_h": true, "flip_v": true, "crop": [0.1, 0.05, 0.2, 0.1] }), + ); + let before = image(&a, doc); + for degrees in [largest, largest - 90, largest - 180, smallest, smallest + 90, smallest + 180, -450, -90, 0, 90, 450] { + ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 1, "rotate": degrees })); + let expected = pdfcraft_engine::AddedImage { rotation: (initial + (degrees / 90).rem_euclid(4)).rem_euclid(4) as u8, ..before.clone() }; + assert_eq!(image(&a, doc), expected, "initial quarter turn {initial}, degrees {degrees}"); + ok(&mut a, "edit_undo", json!({ "doc": doc })); + assert_eq!(image(&a, doc), before, "Undo restores the image and its properties"); + ok(&mut a, "edit_redo", json!({ "doc": doc })); + assert_eq!(image(&a, doc), expected, "Redo restores the normalized orientation"); + ok(&mut a, "edit_undo", json!({ "doc": doc })); + } + ok(&mut a, "doc_save", json!({ "doc": doc, "path": format!("initial-{initial}.pdf") })); + let state = |a: &Automation| { + let d = a.session().get(pdfcraft_engine::DocId(doc)).unwrap(); + (d.bytes.clone(), d.dirty, d.edit_generation(), d.can_undo().map(str::to_owned), d.can_redo().map(str::to_owned)) + }; + let saved = state(&a); + assert!(!saved.1 && saved.4.is_some(), "a clean document with a redo step"); + for degrees in [i64::MIN, i64::MAX, -1, 1, 45] { + assert!(matches!( + a.call("content_update", &json!({ "doc": doc, "page": 1, "index": 1, "rotate": degrees })), + Err(ToolError::InvalidArgs(_)) + )); + assert_eq!(state(&a), saved, "invalid degrees {degrees} leave bytes and history alone"); + } + } +} + +#[cfg(feature = "mcp")] +#[test] +fn large_added_image_rotations_round_trip_through_mcp() { + let dir = workdir("large-added-rotation-mcp"); + added_rotation_image(&dir); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_create", json!({ "from": "blank", "width": 200, "height": 300 }))["doc"].as_u64().unwrap(); + ok(&mut a, "page_add_image", json!({ "doc": doc, "page": 1, "path": "rotation.png", "rect": [20, 20, 120, 70] })); + ok( + &mut a, + "content_update", + json!({ "doc": doc, "page": 1, "index": 1, "rotate": 90, + "flip_h": true, "flip_v": true, "crop": [0.1, 0.05, 0.2, 0.1] }), + ); + let mut server = McpServer::new(a); + let render = json!({ "name": "page_render", "arguments": { "doc": doc, "page": 1, "dpi": 72 } }); + let before = rpc(&mut server, 1, "tools/call", render.clone()); + let update = rpc( + &mut server, + 2, + "tools/call", + json!({ "name": "content_update", "arguments": { + "doc": doc, "page": 1, "index": 1, "rotate": i64::MAX / 360 * 360 + } }), + ); + assert_eq!(update["result"]["isError"], false, "{update}"); + let after = rpc(&mut server, 3, "tools/call", render.clone()); + assert_eq!(after["result"]["content"], before["result"]["content"], "full turns retain the rendered orientation"); + let saved = rpc(&mut server, 4, "tools/call", json!({ "name": "doc_save", "arguments": { "doc": doc, "path": "rotated.pdf" } })); + assert_eq!(saved["result"]["isError"], false, "{saved}"); + let mut reopened = auto(&dir); + let doc = ok(&mut reopened, "doc_open", json!({ "path": "rotated.pdf" }))["doc"].as_u64().unwrap(); + let d = reopened.session().get(pdfcraft_engine::DocId(doc)).unwrap(); + let pdfcraft_engine::AddedContent::Image(image) = &d.added()[0].content else { panic!("saved image") }; + assert_eq!(image.rotation, 1); + let png = reopened.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap(); + let Content::Png { data, .. } = &png[0] else { panic!("rendered PNG") }; + assert_eq!(after["result"]["content"][0]["data"], base64::Engine::encode(&base64::engine::general_purpose::STANDARD, data)); +} + /// Text styling on an added image is refused (#1331), as image transforms are on text, and the /// refusal leaves the content, the dirty flag and the undo and redo steps as they were. #[test] @@ -3853,6 +4421,67 @@ fn renaming_a_field_keeps_the_formulas_that_read_it() { assert_eq!(r["formulas_updated"], json!([])); } +/// The calculation order through the tool: read with what each formula reads and what is wrong, +/// fixed so stale values come out right, moved, set, and undone. +#[test] +fn the_calculation_order_is_read_fixed_and_set() { + let dir = workdir("calcorder"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + for (i, name) in ["qty", "price", "sub", "total", "tax", "x", "y"].into_iter().enumerate() { + let y = 20 + 30 * i; + ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, y, 180, y + 22], "name": name })); + } + // Defined backwards: total and tax calculate before the fields they read. + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": { "op": "sum", "fields": ["sub", "tax"] } })); + ok( + &mut a, + "form_set_script", + json!({ "doc": doc, "field": "tax", "event": "calculate", "script": "event.value = getField(\"sub\").value / 10;" }), + ); + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "sub", "calculate": { "notation": "qty * price" } })); + // x and y read each other. + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "x", "calculate": { "notation": "y + 1" } })); + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "y", "calculate": { "notation": "x + 1" } })); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "qty": "3", "price": "10" } })); + let value = |a: &mut Automation, name: &str| { + let f = ok(a, "form_fields", json!({ "doc": doc })); + f["fields"].as_array().unwrap().iter().find(|x| x["name"] == name).map(|x| x["value"].clone()).unwrap() + }; + assert_eq!((value(&mut a, "total"), value(&mut a, "tax")), (json!("0"), json!("0")), "stale: they calculate too early"); + let r = ok(&mut a, "form_calculation_order", json!({ "doc": doc })); + assert_eq!(r["calculation_order"], json!(["total", "tax", "sub", "x", "y"])); + assert_eq!(r["fields"][0]["reads"], json!(["sub", "tax"])); + assert_eq!(r["fields"][0]["issues"], json!([{ "issue": "runs_too_early", "field": "tax" }, { "issue": "runs_too_early", "field": "sub" }])); + assert_eq!(r["fields"][1]["issues"], json!([{ "issue": "runs_too_early", "field": "sub" }]), "a script's reads are checked too"); + assert_eq!(r["fields"][3]["issues"], json!([{ "issue": "cycle", "fields": ["y"] }])); + assert_eq!((r["fixable"].clone(), r["extra"].clone()), (json!(true), json!([]))); + assert!(r.get("undo").is_none(), "reading changes nothing"); + // Prepare a form ▸ Calculation order is this tool headlessly. + let r = ok(&mut a, "command_run", json!({ "id": "form.calculation_order", "params": { "doc": doc } })); + assert_eq!(r["calculation_order"], json!(["total", "tax", "sub", "x", "y"])); + let r = ok(&mut a, "form_calculation_order", json!({ "doc": doc, "fix": true })); + assert_eq!(r["calculation_order"], json!(["sub", "tax", "total", "x", "y"]), "only what must move moves; the loop stays"); + assert_eq!(r["undo"], "Set calculation order"); + assert_eq!(r["fixable"], false); + assert_eq!((value(&mut a, "total"), value(&mut a, "tax")), (json!("33"), json!("3")), "recalculated in the new order"); + let r = ok(&mut a, "form_calculation_order", json!({ "doc": doc, "field": "y", "move": "earlier" })); + assert_eq!(r["calculation_order"], json!(["sub", "tax", "total", "y", "x"])); + let r = ok(&mut a, "form_calculation_order", json!({ "doc": doc, "order": ["x"] })); + assert_eq!(r["calculation_order"], json!(["x", "sub", "tax", "total", "y"]), "the fields named first, the others after them"); + for bad in [json!({ "order": ["qty"] }), json!({ "order": ["sub", "sub"] }), json!({ "field": "qty" }), json!({ "fix": true, "field": "x" })] { + let mut args = bad.clone(); + args["doc"] = json!(doc); + assert!(a.call("form_calculation_order", &args).is_err(), "{bad}"); + } + for _ in 0..3 { + ok(&mut a, "edit_undo", json!({ "doc": doc })); + } + let r = ok(&mut a, "form_calculation_order", json!({ "doc": doc })); + assert_eq!(r["calculation_order"], json!(["total", "tax", "sub", "x", "y"])); + assert_eq!((value(&mut a, "total"), value(&mut a, "tax")), (json!("0"), json!("0")), "undone with the values it recalculated"); +} + #[test] fn tab_order_and_field_appearance_through_tools() { let dir = workdir("taborder"); @@ -4372,6 +5001,60 @@ fn typed_signatures_stay_upright_on_rotated_pages() { check(&mut a, reopened); } +/// #2392: a Fill & Sign checkmark and line look the same on a turned page as on an unturned one: +/// the checkmark isn't upside down or sideways, and the line runs across as displayed. +#[test] +fn fill_sign_marks_stay_upright_on_rotated_pages() { + let dir = workdir("marks-upright"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_create", json!({ "from": "blank", "width": 200, "height": 300, "pages": 4 }))["doc"].as_u64().unwrap(); + for (page, degrees) in [(2, 90), (3, 180), (4, 270)] { + ok(&mut a, "page_rotate", json!({ "doc": doc, "pages": [page], "degrees": degrees })); + } + for page in 1..=4 { + ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": page, "type": "check", "at": [40, 60] })); + ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": page, "type": "line", "at": [120, 60] })); + } + let check = |a: &mut Automation, doc: u64| { + let mut upright = Vec::new(); + for page in 1..=4 { + // 144 dpi: two pixels a point. + let output = a.call("page_render", &json!({ "doc": doc, "page": page, "dpi": 144 })).unwrap(); + let Content::Png { data, .. } = &output[0] else { panic!() }; + let pixels = image::load_from_memory(data).unwrap().to_rgba8(); + let dark: Vec = pixels.pixels().map(|p| p.0[..3].iter().all(|v| *v < 128)).collect(); + let width = pixels.width(); + // The line, 36 × 4 pt displayed, runs across. + let mut b = [u32::MAX, u32::MAX, 0, 0]; + for (x, y, p) in pixels.enumerate_pixels() { + if x.abs_diff(240) < 60 && y.abs_diff(120) < 60 && p.0[..3].iter().all(|v| *v < 128) { + b = [b[0].min(x), b[1].min(y), b[2].max(x), b[3].max(y)]; + } + } + assert!(b[0] <= b[2], "page {page}: no line drawn"); + assert!(b[2] - b[0] > 60 && b[3] - b[1] < 10, "page {page}: the line should run across: {b:?}"); + if page == 1 { + upright = dark; + continue; + } + // Same pixels as the unturned page where both pages are (pages 2 and 4 are wider). + let (mut differ, mut ink) = (0, 0); + for y in 0..400 { + for x in 0..400 { + let (want, got) = (upright[(y * 400 + x) as usize], dark[(y * width + x) as usize]); + ink += usize::from(want); + differ += usize::from(want != got); + } + } + assert!(ink > 0 && differ * 10 < ink, "page {page}: {differ} of {ink} ink pixels differ from the unturned page"); + } + }; + check(&mut a, doc); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "marked.pdf" })); + let reopened = ok(&mut a, "doc_open", json!({ "path": "marked.pdf" }))["doc"].as_u64().unwrap(); + check(&mut a, reopened); +} + #[test] fn comment_checkmarks_locks_hiding_and_summaries_through_tools() { let dir = workdir("comment-polish"); @@ -4727,6 +5410,13 @@ fn digital_ids_signing_and_validation_through_tools() { assert!(matches!(a.call("doc_open_revision", &json!({ "doc": doc, "revision": 4 })), Err(ToolError::Failed(_)))); assert_eq!(ok(&mut a, "sign_trust", json!({ "clear": true }))["trusted"].as_array().unwrap().len(), 0); assert_eq!(ok(&mut a, "sign_list", json!({ "doc": doc }))["signatures"][0]["status"], "unknown"); + // The same certificate from a PKCS #7 bundle (.p7b), as Windows, Acrobat and CAs export one. + use pdfcraft_engine::sign::{cms, der, pkcs12}; + let cert = pkcs12::open(&std::fs::read(dir.join("ada.p12")).unwrap(), "secret12").unwrap().certificate.raw; + let sd = der::seq(&[&der::int(1), &der::set_of(&[]), &der::seq(&[&der::oid(cms::DATA)]), &der::explicit(0, &cert), &der::set_of(&[])]); + std::fs::write(dir.join("ada.p7b"), der::seq(&[&der::oid(cms::SIGNED_DATA), &der::explicit(0, &sd)])).unwrap(); + assert_eq!(ok(&mut a, "sign_trust", json!({ "paths": ["ada.p7b"] }))["trusted"].as_array().unwrap().len(), 1); + assert_eq!(ok(&mut a, "sign_list", json!({ "doc": doc }))["signatures"][0]["status"], "valid"); } /// The decoded text of every visible signature appearance (Helvetica forms) in a signed file. @@ -5178,6 +5868,65 @@ fn detecting_form_fields_through_tools() { assert_eq!(f["fields"][1]["value"], "Lisbon", "{f}"); } +#[test] +fn detecting_form_fields_preserves_content_piece_comments() { + for visible in [false, true] { + let first = format!("BT /F1 18 Tf 40 250 Td (FIRST) Tj ET % trailing comment{}", if visible { "\n" } else { "" }); + let last = b"40 120 300 30 re S\nBT /F1 18 Tf 40 50 Td (LAST) Tj ET\n"; + let mut source = pdfcraft_cos::Document::open(std::sync::Arc::new(fixture(1))).unwrap(); + let page = pdfcraft_model::pages(&source)[0].obj; + let contents = [first.as_bytes(), last] + .iter() + .map(|bytes| pdfcraft_cos::Object::Ref(source.add(pdfcraft_cos::Object::Stream(pdfcraft_cos::Stream::flate(Default::default(), bytes))))) + .collect(); + source + .update_dict(page, |d| { + d.set(b"Contents".to_vec(), pdfcraft_cos::Object::Array(contents)); + d.set(b"MediaBox".to_vec(), pdfcraft_cos::Object::Array(vec![0.into(), 0.into(), 400.into(), 300.into()])); + }) + .unwrap(); + let dir = workdir(if visible { "detect-visible-piece" } else { "detect-hidden-piece" }); + std::fs::write(dir.join("pieces.pdf"), pdfcraft_cos::write_full(&source, &Default::default()).unwrap()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path": "pieces.pdf"}))["doc"].as_u64().unwrap(); + let candidates = if visible { json!([{"page": 1, "kind": "text", "name": "Text1", "rect": [41.0, 121.0, 339.0, 149.0]}]) } else { json!([]) }; + let inventory = if visible { + json!([{"name": "Text1", "type": "text", "value": null, "page": 1, "rect": [41.0, 151.0, 339.0, 179.0], "rotation": 0, "read_only": false, "required": false, "multiline": false}]) + } else { + json!([]) + }; + let check_text = |a: &mut Automation, doc| { + let text = page_text(a, doc).join(" "); + assert_eq!(text.split_whitespace().collect::>(), ["FIRST", "LAST"]); + }; + let check_render = |a: &mut Automation, doc| { + let (width, height, pixels) = a.render_pixels(doc, 1, 1.0).unwrap(); + assert_eq!((width, height), (400, 300)); + let region: Vec<_> = (145..185).flat_map(|y| (35..345).map(move |x| (y * 400 + x) * 4)).collect(); + if visible { + assert!(region.iter().any(|&i| pixels[i..i + 3].iter().all(|&v| v < 128)), "the visible box has ink"); + } else { + assert!(region.iter().all(|&i| pixels[i..i + 3] == [255, 255, 255]), "the hidden box region stays white"); + } + }; + check_text(&mut a, doc); + check_render(&mut a, doc); + assert_eq!(ok(&mut a, "form_fields", json!({"doc": doc}))["fields"], json!([])); + assert_eq!(ok(&mut a, "form_detect_fields", json!({"doc": doc, "add": false}))["fields"], candidates); + assert_eq!(ok(&mut a, "form_fields", json!({"doc": doc}))["fields"], json!([])); + assert_eq!(ok(&mut a, "form_detect_fields", json!({"doc": doc}))["fields"], candidates); + assert_eq!(ok(&mut a, "form_fields", json!({"doc": doc}))["fields"], inventory); + assert_eq!(ok(&mut a, "form_detect_fields", json!({"doc": doc}))["fields"], json!([])); + ok(&mut a, "doc_save", json!({"doc": doc, "path": "saved.pdf"})); + let mut reopened = auto(&dir); + let again = ok(&mut reopened, "doc_open", json!({"path": "saved.pdf"}))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut reopened, "form_fields", json!({"doc": again}))["fields"], inventory); + check_text(&mut reopened, again); + check_render(&mut reopened, again); + std::fs::remove_dir_all(dir).unwrap(); + } +} + #[test] fn detecting_comb_boxes_through_tools() { let mut body = String::from("BT /F1 10 Tf 40 105 Td (Postcode) Tj ET\n"); @@ -5292,6 +6041,10 @@ fn pdfa_through_tools() { assert_eq!(r["declared"]["pdfa"], "PDF/A-3b", "{r}"); assert!(r["issues"].as_array().unwrap().iter().all(|i| i["fixable"] == false), "{r}"); assert!(a.call("pdfa_verify", &json!({ "doc": doc, "level": "9z" })).is_err()); + ok(&mut a, "standards_remove_id", json!({ "doc": doc, "standard": "pdfa" })); + let r = ok(&mut a, "pdfa_verify", json!({ "doc": doc })); + assert!(r["declared"]["pdfa"].is_null() && !r["declared"]["output_intents"].as_array().unwrap().is_empty(), "{r}"); + assert!(matches!(a.call("standards_remove_id", &json!({ "doc": doc, "standard": "pdfa" })), Err(ToolError::Failed(_))), "nothing left"); } #[test] @@ -5532,6 +6285,38 @@ fn xfa_data_files_export_and_import_through_tools() { assert!(std::fs::read_to_string(dir.join("static.xml")).unwrap().contains("Ada"), "a static XFA form exports its data too"); } +#[test] +fn xfa_rich_text_max_length_is_reported_and_enforced_through_tools() { + let dir = workdir("xfa-rich-limit"); + let template = pdfcraft_xfa::fixtures::template_with_data(0, "
Old") + .replace(r#""#, r#""#) + .replace("", ""); + std::fs::write(dir.join("rich.pdf"), pdfcraft_xfa::fixtures::shell(&template)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "rich.pdf" }))["doc"].as_u64().unwrap(); + let fields = ok(&mut a, "form_fields", json!({ "doc": doc })); + let family = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "familyName").unwrap(); + assert_eq!(family["max_length"], 5); + + // The boundary counts Unicode characters, not UTF-8 bytes. + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "familyName": "éàöüñ" } })); + ok(&mut a, "doc_export_data", json!({ "doc": doc, "path": "before.xml", "what": "fields" })); + let before = std::fs::read(dir.join("before.xml")).unwrap(); + assert!(std::str::from_utf8(&before).unwrap().contains("éàöüñ")); + let error = a.call("form_fill", &json!({ "doc": doc, "values": { "familyName": "éàöüñX" } })).unwrap_err(); + assert!(error.to_string().contains("at most 5 characters"), "{error}"); + ok(&mut a, "doc_export_data", json!({ "doc": doc, "path": "after.xml", "what": "fields" })); + assert_eq!(std::fs::read(dir.join("after.xml")).unwrap(), before, "refused edits leave the XFA data unchanged"); + + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf" })); + ok(&mut a, "doc_close", json!({ "doc": doc })); + let doc = ok(&mut a, "doc_open", json!({ "path": "out.pdf" }))["doc"].as_u64().unwrap(); + let fields = ok(&mut a, "form_fields", json!({ "doc": doc })); + let family = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "familyName").unwrap(); + assert_eq!(family["max_length"], 5); + assert_eq!(family["value"], "éàöüñ"); +} + #[test] fn dynamic_xfa_forms_open_render_fill_and_save_through_tools() { let dir = workdir("xfa"); @@ -5622,6 +6407,9 @@ fn cut_stack_printing_through_tools() { } assert!(matches!(a.call("doc_print", &json!({"doc": doc, "order": "cut-stack", "path": "refused.pdf"})), Err(ToolError::InvalidArgs(_)))); assert!(!dir.join("refused.pdf").exists()); + // The printer's own two-sided default isn't refused: cut and stack sends single-sided (#2422). + ok(&mut a, "doc_print", json!({"doc": doc, "layout": "multiple", "order": "cut-stack", "duplex": "printer", "path": "printer-default.pdf"})); + assert!(dir.join("printer-default.pdf").exists()); assert!(a.call("doc_print", &json!({"doc": doc, "layout": "multiple", "order": "cut-stack", "path": "../escaped.pdf"})).is_err()); let _ = std::fs::remove_dir_all(dir); } @@ -5680,6 +6468,51 @@ fn measurements_calibrate_draw_save_reopen_and_export() { let _ = std::fs::remove_dir_all(dir); } +#[test] +fn measurement_scale_ratio_takes_the_page_side_in_a_unit() { + // #2752: a plan drawn at 1:50 is "1 mm = 50 mm", so a line 10 mm long on the page is 500 mm. + let dir = workdir("measurement-ratio"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path":"a.pdf"}))["doc"].as_u64().unwrap(); + let mm = 72.0 / 25.4; + let line = json!([[20.0, 30.0], [20.0 + 10.0 * mm, 30.0]]); + let read = |a: &mut Automation, doc: u64| ok(a, "measure_info", json!({"doc":doc,"page":1,"points":line}))["reading"]["value"].as_f64().unwrap(); + let set = ok(&mut a, "measure_scale", json!({"doc":doc,"page":1,"page_distance":1,"page_unit":"mm","distance":50,"unit":"mm"})); + assert_eq!(set["scale"]["ratio"], "1 mm = 50 mm"); + assert!((read(&mut a, doc) - 500.0).abs() < 1e-9); + // The PDF keeps its factors per point: 50 mm for every 25.4 / 72 mm of page. + assert!((set["scale"]["x"].as_f64().unwrap() - 50.0 / mm).abs() < 1e-9); + ok(&mut a, "edit_undo", json!({"doc":doc})); + assert!((read(&mut a, doc) - 10.0 / 25.4).abs() < 1e-9, "true size in inches without a scale"); + ok(&mut a, "edit_redo", json!({"doc":doc})); + assert!((read(&mut a, doc) - 500.0).abs() < 1e-9); + ok(&mut a, "doc_save", json!({"doc":doc,"path":"ratio.pdf"})); + let reopened = ok(&mut a, "doc_open", json!({"path":"ratio.pdf"}))["doc"].as_u64().unwrap(); + assert!((read(&mut a, reopened) - 500.0).abs() < 1e-9); + assert_eq!(ok(&mut a, "measure_scale", json!({"doc":reopened,"page":1,"at":[20,30]}))["scale"]["ratio"], "1 mm = 50 mm"); + // The page side defaults to the real-world unit; any page unit converts to points. + ok(&mut a, "measure_scale", json!({"doc":doc,"page":1,"page_distance":2,"distance":1,"unit":"cm"})); + assert!((read(&mut a, doc) - 0.5).abs() < 1e-9); + ok(&mut a, "measure_scale", json!({"doc":doc,"page":1,"page_distance":0.25,"page_unit":"in","distance":1,"unit":"ft"})); + assert!((read(&mut a, doc) - 10.0 / 25.4 * 4.0).abs() < 1e-9); + // Units per point keep meaning points. + ok(&mut a, "measure_scale", json!({"doc":doc,"page":1,"units_per_point":50,"unit":"mm"})); + assert!((read(&mut a, doc) - 500.0 * mm).abs() < 1e-9); + let before = ok(&mut a, "doc_list", json!({})); + for args in [ + json!({"doc":doc,"page":1,"page_distance":1,"page_unit":"ft","distance":1,"unit":"ft"}), + json!({"doc":doc,"page":1,"page_distance":1,"distance":1,"unit":"m"}), + json!({"doc":doc,"page":1,"page_distance":1,"unit":"mm"}), + json!({"doc":doc,"page":1,"page_unit":"mm","units_per_point":1}), + json!({"doc":doc,"page":1,"page_distance":1,"units_per_point":1,"distance":1}), + json!({"doc":doc,"page":1,"page_distance":1,"points":[[0,0],[10,0]],"distance":1}), + ] { + assert!(a.call("measure_scale", &args).is_err(), "{args}"); + assert_eq!(ok(&mut a, "doc_list", json!({})), before); + } + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn measurements_bad_arguments_leave_document_and_history_unchanged() { let dir = workdir("measurement-errors"); @@ -5726,6 +6559,65 @@ fn measurement_snap_tool_covers_all_targets() { let _ = std::fs::remove_dir_all(dir); } +#[test] +fn growable_xfa_text_paginates_after_fill_save_and_print() { + let dir = workdir("xfa-growable-text"); + let text: String = (0..150).map(|i| format!("Row{i:03} {}sample\n", "sample ".repeat(8))).collect(); + assert_eq!(text.len(), 10_500); + for scripted in [false, true] { + let template = pdfcraft_xfa::fixtures::growable_text_template(scripted); + std::fs::write(dir.join("growing.pdf"), pdfcraft_xfa::fixtures::shell(&template)).unwrap(); + let mut a = auto(&dir); + let opened = ok(&mut a, "doc_open", json!({ "path": "growing.pdf" })); + let doc = opened["doc"].as_u64().unwrap(); + assert_eq!(opened["pages"], 2); + let filled = ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "ACTIVIDADES": text } })); + assert_eq!(filled["pages"], 3, "scripted={scripted}: {filled}"); + ok(&mut a, "doc_export_data", json!({ "doc": doc, "path": "data.xml", "what": "fields" })); + let data = std::fs::read_to_string(dir.join("data.xml")).unwrap(); + assert!(data.contains(text.trim_end()), "the full value remains in the datasets"); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "saved.pdf" })); + ok(&mut a, "doc_close", json!({ "doc": doc })); + let reopened = ok(&mut a, "doc_open", json!({ "path": "saved.pdf" })); + assert_eq!(reopened["pages"], 3); + let doc = reopened["doc"].as_u64().unwrap(); + ok(&mut a, "doc_print", json!({ "doc": doc, "path": "printed.pdf" })); + let printed = ok(&mut a, "doc_open", json!({ "path": "printed.pdf" })); + assert_eq!(printed["pages"], 3); + let output = ok(&mut a, "text_extract", json!({ "doc": printed["doc"] })); + let printed_text = output["pages"].as_array().unwrap().iter().map(|p| p["text"].as_str().unwrap()).collect::>().join("\n"); + let expected = format!("{text}\nFollowing section"); + assert_eq!(printed_text.split_whitespace().collect::>(), expected.split_whitespace().collect::>(), "scripted={scripted}"); + } + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn repeated_xfa_print_buttons_work_through_tools_and_after_reopen() { + let dir = workdir("xfa-print-buttons"); + let template = pdfcraft_xfa::fixtures::repeated_print_button_template(); + std::fs::write(dir.join("print.pdf"), pdfcraft_xfa::fixtures::shell(&template)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "print.pdf" }))["doc"].as_u64().unwrap(); + let check = |a: &mut Automation, doc| { + let fields = ok(a, "form_fields", json!({ "doc": doc })); + let buttons: Vec<_> = fields["fields"].as_array().unwrap().iter().filter(|f| f["type"] == "button").collect(); + assert_eq!(buttons.len(), 3); + for (index, field) in buttons.iter().enumerate() { + assert_eq!(field["page"], index + 1); + let out = ok(a, "js_run", json!({ "doc": doc, "field": field["name"], "script": "throw new Error('AcroForm fallback ran')" })); + assert!(out["error"].is_null(), "{out}"); + assert_eq!(out["requests"], json!([{ "print": true }]), "{out}"); + assert_eq!(out["alerts"], json!([])); + } + }; + check(&mut a, doc); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "saved.pdf" })); + ok(&mut a, "doc_close", json!({ "doc": doc })); + let doc = ok(&mut a, "doc_open", json!({ "path": "saved.pdf" }))["doc"].as_u64().unwrap(); + check(&mut a, doc); +} + #[test] fn xfa_scripts_run_for_buttons_and_field_changes_through_tools() { let dir = workdir("xfa-scripts"); @@ -5771,12 +6663,13 @@ fn doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off() { let _ = std::fs::remove_dir_all(dir); } -/// doc_info describes a link's set-layer-visibility action by layer name. +/// doc_info describes a link's set-layer-visibility action by layer name, and each layer's +/// visibility now and as the file opens. #[test] fn doc_info_describes_set_layer_links() { let dir = workdir("layer-links"); let objs = [ - "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [4 0 R 5 0 R] /D << >> >> >>", + "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [4 0 R 5 0 R] /D << /OFF [5 0 R] >> >> >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [6 0 R] >>", "<< /Type /OCG /Name (Red) >>", @@ -5807,6 +6700,7 @@ fn doc_info_describes_set_layer_links() { "preserve_rb": false, }) ); + assert_eq!(info["layers"], json!([{ "name": "Red", "visible": true, "initial": true }, { "name": "Green", "visible": false, "initial": false }])); } mod close_argument_tests { @@ -6491,6 +7385,7 @@ fn architectural_and_iso_papers_through_tools() { ("Letter", 612.0, 792.0), ("US Legal", 612.0, 1008.0), ("Tabloid", 792.0, 1224.0), + ("US #10 Envelope", 4.125 * 72.0, 9.5 * 72.0), ] { for orientation in ["portrait", "landscape"] { let out = ok( @@ -6589,6 +7484,91 @@ fn pixel(pixels: &[u8], x: u32, y: u32) -> [u8; 4] { pixels[at..at + 4].try_into().unwrap() } +/// A 300 × 100 page: red in layer "Walls" (left), green in "Doors" (middle), blue in a second +/// group also named "Walls" (right), and a black Square comment along the bottom. +fn layered_pdf() -> Vec { + let content = b"/OC /W1 BDC 1 0 0 rg 10 30 80 60 re f EMC /OC /D BDC 0 1 0 rg 110 30 80 60 re f EMC /OC /W2 BDC 0 0 1 rg 210 30 80 60 re f EMC"; + let ap = b"0 g 0 0 280 10 re f"; + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R 7 0 R] /D << /Order [5 0 R 6 0 R 7 0 R] >> >> >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 100] /Contents 4 0 R /Annots [8 0 R] /Resources << /Properties << /W1 5 0 R /D 6 0 R /W2 7 0 R >> >> >>".to_vec(), + [format!("<< /Length {} >>\nstream\n", content.len()).as_bytes(), content, b"\nendstream"].concat(), + b"<< /Type /OCG /Name (Walls) >>".to_vec(), + b"<< /Type /OCG /Name (Doors) >>".to_vec(), + b"<< /Type /OCG /Name (Walls) >>".to_vec(), + b"<< /Type /Annot /Subtype /Square /Rect [10 2 290 12] /C [0 0 0] /AP << /N 9 0 R >> >>".to_vec(), + [format!("<< /Type /XObject /Subtype /Form /BBox [0 0 280 10] /Length {} >>\nstream\n", ap.len()).as_bytes(), ap, b"\nendstream"].concat(), + ]; + 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 +} + +/// #2986: `layer_set` hides and shows layers in what `page_render` draws, by name (every group +/// with that name) or by position; `comments_hide` now changes `page_render` too. Neither +/// changes the file. +#[test] +fn layers_and_hidden_comments_change_what_page_render_draws() { + let dir = workdir("layer-set"); + std::fs::write(dir.join("layers.pdf"), layered_pdf()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "layers.pdf" }))["doc"].as_u64().unwrap(); + let at = |a: &mut Automation, x: u32, y: u32| { + let (w, _, px) = a.render_pixels(doc, 1, 1.0).unwrap(); + let i = ((y * w + x) * 4) as usize; + [px.as_ref()[i], px.as_ref()[i + 1], px.as_ref()[i + 2]] + }; + // Points (50, 40) red, (150, 40) green, (250, 40) blue, (150, 93) the comment; y down. + assert_eq!( + [at(&mut a, 50, 40), at(&mut a, 150, 40), at(&mut a, 250, 40), at(&mut a, 150, 93)], + [[255, 0, 0], [0, 255, 0], [0, 0, 255], [0, 0, 0]] + ); + + let r = ok(&mut a, "layer_set", json!({ "doc": doc, "layer": "Walls", "visible": false })); + assert_eq!( + r["layers"], + json!([{ "name": "Walls", "visible": false }, { "name": "Doors", "visible": true }, { "name": "Walls", "visible": false }]) + ); + assert_eq!( + [at(&mut a, 50, 40), at(&mut a, 150, 40), at(&mut a, 250, 40)], + [[255, 255, 255], [0, 255, 0], [255, 255, 255]], + "both groups named Walls hide" + ); + + ok(&mut a, "layer_set", json!({ "doc": doc, "layer": 3, "visible": true })); + assert_eq!([at(&mut a, 50, 40), at(&mut a, 250, 40)], [[255, 255, 255], [0, 0, 255]], "position 3 is the second Walls only"); + + ok(&mut a, "comments_hide", json!({ "doc": doc })); + assert_eq!(at(&mut a, 150, 93), [255, 255, 255], "hidden comments are not drawn"); + ok(&mut a, "comments_hide", json!({ "doc": doc, "hidden": false })); + assert_eq!(at(&mut a, 150, 93), [0, 0, 0]); + + for (args, what) in [ + (json!({ "doc": doc, "layer": "Roof", "visible": false }), "an unknown name"), + (json!({ "doc": doc, "layer": 0, "visible": false }), "position 0"), + (json!({ "doc": doc, "layer": 4, "visible": false }), "a position past the end"), + (json!({ "doc": doc, "layer": true, "visible": false }), "a layer that is neither"), + (json!({ "doc": doc, "layer": "Doors" }), "no visible"), + ] { + assert!(matches!(a.call("layer_set", &args), Err(ToolError::InvalidArgs(_))), "{what} must be refused"); + } + let list = ok(&mut a, "doc_list", json!({})); + assert_eq!(list["documents"][0]["dirty"], json!(false), "a view setting leaves the document clean: {list}"); +} + /// The `view_reading_colors` tool changes what `page_render` draws — text and background remap, /// images never do — without touching the file, and its errors name what's wrong (#897). #[test] @@ -6912,3 +7892,269 @@ fn text_box_and_callout_fill_and_border_through_tools() { assert_eq!(session.get(saved).unwrap().comment_props(0, index).unwrap().fill, None); } } + +/// Three paragraphs: Alpha (two rows), Bravo (one row) and Charlie (two rows), at the leading +/// of #2516. A third Alpha row brings its last baseline within 2.5 × size of Bravo's, so the next +/// read groups the two as one paragraph. +fn three_paragraph_pdf() -> Vec { + use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; + use std::sync::Arc; + + let content = "BT /F1 12 Tf 14.4 TL 1 0 0 1 10 250 Tm (Alpha paragraph, first row.) Tj T* (Alpha paragraph, second row.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 10 200 Tm (Bravo paragraph, a single row.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 10 150 Tm (Charlie paragraph, first row.) Tj T* (Charlie paragraph, second row.) Tj ET"; + let mut doc = Document::open(Arc::new(fixture(1))).unwrap(); + doc.set(ObjRef::new(5, 0), Object::Stream(Stream::flate(Dict::new(), content.as_bytes()))); + write_full(&doc, &SaveOptions::default()).unwrap() +} + +/// A one-page PDF whose second paragraph is drawn over the first one's box, as an OCR text layer +/// or a stamp would be. +fn overlaid_paragraph_pdf() -> Vec { + use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; + use std::sync::Arc; + + let content = "BT /F1 12 Tf 14.4 TL 1 0 0 1 10 250 Tm (Alpha paragraph, first row.) Tj T* (Alpha paragraph, second row.) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 10 243 Tm (Zulu overlay.) Tj ET"; + let mut doc = Document::open(Arc::new(fixture(1))).unwrap(); + doc.set(ObjRef::new(5, 0), Object::Stream(Stream::flate(Dict::new(), content.as_bytes()))); + write_full(&doc, &SaveOptions::default()).unwrap() +} + +/// Emptying a paragraph that another paragraph overlaps must not hand the emptied paragraph's +/// number to the neighbour that took over its box: the next edit by that number would rewrite it. +#[test] +fn an_emptied_paragraph_does_not_pass_its_number_to_an_overlapping_neighbour() { + let dir = workdir("text-edit-overlap-number"); + std::fs::write(dir.join("p.pdf"), overlaid_paragraph_pdf()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path":"p.pdf"}))["doc"].as_u64().unwrap(); + let listed = ok(&mut a, "text_paragraphs", json!({"doc":doc,"page":1})); + assert_eq!(listed["count"], 2, "{listed}"); + let k = listed["paragraphs"].as_array().unwrap().iter().position(|p| p["text"].as_str().unwrap().starts_with("Alpha")).unwrap() + 1; + + let first = ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":k,"text":""})); + assert_eq!(first["stale_paragraphs"], json!([k]), "the emptied paragraph's number is gone: {first}"); + let again = a.call("text_edit", &json!({"doc":doc,"page":1,"paragraph":k,"text":"Rewritten."})); + assert!(matches!(&again, Err(ToolError::InvalidArgs(m)) if m.contains("text_paragraphs")), "{again:?}"); + assert!(page_text(&mut a, doc).join(" ").contains("Zulu overlay."), "the neighbour is untouched"); + std::fs::remove_dir_all(dir).unwrap(); +} + +/// #2516: a paragraph number names the paragraph `text_paragraphs` listed. When an edit regroups +/// the page so that paragraph no longer exists on its own, the next edit by that number is +/// refused. It used to rewrite whichever paragraph had taken the number, and that text was gone. +#[test] +fn text_edit_refuses_a_paragraph_number_an_earlier_edit_regrouped() { + let dir = workdir("text-edit-stale-number"); + std::fs::write(dir.join("p.pdf"), three_paragraph_pdf()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path":"p.pdf"}))["doc"].as_u64().unwrap(); + let listed = ok(&mut a, "text_paragraphs", json!({"doc":doc,"page":1})); + assert_eq!(listed["count"], 3); + assert!(listed["paragraphs"][1]["text"].as_str().unwrap().starts_with("Bravo")); + + // Listing again here would renumber the page, so the first edit's result says what moved. + let first = ok( + &mut a, + "text_edit", + json!({"doc":doc,"page":1,"paragraph":1,"text":"Alpha paragraph, first row.\nAlpha paragraph, second row.\nAlpha paragraph, a third row."}), + ); + assert_eq!(first["stale_paragraphs"], json!([1, 2]), "the third row regroups Alpha with Bravo: {first}"); + + // Paragraph 2 of the earlier listing was Bravo, which is now part of Alpha's paragraph. + let stale = a.call("text_edit", &json!({"doc":doc,"page":1,"paragraph":2,"text":"Bravo paragraph, edited."})); + assert!(matches!(&stale, Err(ToolError::InvalidArgs(m)) if m.contains("text_paragraphs")), "{stale:?}"); + let text = page_text(&mut a, doc).join(" "); + assert!(text.contains("Charlie paragraph, first row."), "Charlie is untouched: {text}"); + assert!(text.contains("Charlie paragraph, second row."), "Charlie is untouched: {text}"); + assert!(!text.contains("Bravo paragraph, edited."), "nothing was rewritten: {text}"); + + // After listing again, the number names what the listing says. + let relisted = ok(&mut a, "text_paragraphs", json!({"doc":doc,"page":1})); + assert!(relisted["paragraphs"][1]["text"].as_str().unwrap().starts_with("Charlie")); + ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":2,"text":"Charlie paragraph, edited."})); + let text = page_text(&mut a, doc).join(" "); + assert!(text.contains("Charlie paragraph, edited.") && text.contains("Alpha paragraph, a third row."), "{text}"); + std::fs::remove_dir_all(dir).unwrap(); +} + +/// An edit that merges its paragraph into a neighbour leaves no paragraph of its own to follow: +/// the merged one holds the edited text too, but rewriting it by the edited paragraph's number +/// would drop the neighbour's text (#2516). +#[test] +fn text_edit_does_not_follow_a_paragraph_into_the_one_it_merged_with() { + let dir = workdir("text-edit-merged-number"); + std::fs::write(dir.join("p.pdf"), three_paragraph_pdf()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path":"p.pdf"}))["doc"].as_u64().unwrap(); + ok(&mut a, "text_paragraphs", json!({"doc":doc,"page":1})); + let first = ok( + &mut a, + "text_edit", + json!({"doc":doc,"page":1,"paragraph":1,"text":"Alpha paragraph, first row.\nAlpha paragraph, second row.\nAlpha paragraph, a third row."}), + ); + assert!(first.get("paragraph").is_none(), "the result must not present the merged paragraph as the edited one: {first}"); + + // Paragraph 1 was Alpha. Alpha now shares a paragraph with Bravo, so the number is refused. + let again = a.call("text_edit", &json!({"doc":doc,"page":1,"paragraph":1,"text":"Alpha only."})); + assert!(matches!(&again, Err(ToolError::InvalidArgs(m)) if m.contains("text_paragraphs")), "{again:?}"); + let text = page_text(&mut a, doc).join(" "); + assert!(text.contains("Bravo paragraph, a single row."), "Bravo is untouched: {text}"); + assert!(!text.contains("Alpha only."), "nothing was rewritten: {text}"); + std::fs::remove_dir_all(dir).unwrap(); +} + +/// The editor background preview takes the same paragraph number as `text_edit`, so it hides the +/// paragraph the number named in the listing and refuses a number an edit made stale (#2516). +#[test] +fn page_render_hide_text_block_refuses_a_paragraph_number_an_earlier_edit_regrouped() { + let dir = workdir("hide-text-block-stale-number"); + std::fs::write(dir.join("p.pdf"), three_paragraph_pdf()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path":"p.pdf"}))["doc"].as_u64().unwrap(); + ok(&mut a, "text_paragraphs", json!({"doc":doc,"page":1})); + ok( + &mut a, + "text_edit", + json!({"doc":doc,"page":1,"paragraph":1,"text":"Alpha paragraph, first row.\nAlpha paragraph, second row.\nAlpha paragraph, a third row."}), + ); + + // Paragraph 2 of the listing was Bravo, now inside Alpha's paragraph; position 2 is Charlie. + let stale = a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72,"hide_text_block":2})); + assert!(matches!(&stale, Err(ToolError::InvalidArgs(m)) if m.contains("text_paragraphs")), "{:?}", stale.map(|_| ())); + // Paragraph 3 of the listing is Charlie, wherever it is now. + assert!(a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72,"hide_text_block":3})).is_ok()); + std::fs::remove_dir_all(dir).unwrap(); +} + +/// The listings kept for paragraph numbers are capped. A page whose listing was dropped must not +/// fall back to reading its numbers as positions, which an edit may have regrouped: it is refused +/// until the page is listed again (#2516). +#[test] +fn text_edit_refuses_a_paragraph_number_whose_listing_was_dropped_at_the_cap() { + let dir = workdir("text-edit-evicted-listing"); + std::fs::write(dir.join("p.pdf"), three_paragraph_pdf()).unwrap(); + let mut a = auto(&dir); + let open = |a: &mut Automation| ok(a, "doc_open", json!({"path":"p.pdf"}))["doc"].as_u64().unwrap(); + let doc = open(&mut a); + ok(&mut a, "text_paragraphs", json!({"doc":doc,"page":1})); + // 256 more pages fill the cap, so the next new page drops every earlier listing. + let others: Vec = (0..256).map(|_| open(&mut a)).collect(); + for &other in &others { + ok(&mut a, "text_paragraphs", json!({"doc":other,"page":1})); + } + + let dropped = a.call("text_edit", &json!({"doc":doc,"page":1,"paragraph":2,"text":"Bravo paragraph, edited."})); + assert!(matches!(&dropped, Err(ToolError::InvalidArgs(m)) if m.contains("text_paragraphs")), "{dropped:?}"); + assert!(!page_text(&mut a, doc).join(" ").contains("Bravo paragraph, edited."), "nothing was rewritten"); + let hidden = a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72,"hide_text_block":2})); + assert!(matches!(&hidden, Err(ToolError::InvalidArgs(m)) if m.contains("text_paragraphs")), "{:?}", hidden.map(|_| ())); + + // A page listed after the drop keeps its numbers, and so does the page once it is listed again. + ok(&mut a, "text_edit", json!({"doc":others[255],"page":1,"paragraph":2,"text":"Bravo paragraph, edited."})); + ok(&mut a, "text_paragraphs", json!({"doc":doc,"page":1})); + ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":2,"text":"Bravo paragraph, edited."})); + assert!(page_text(&mut a, doc).join(" ").contains("Bravo paragraph, edited.")); + std::fs::remove_dir_all(dir).unwrap(); +} + +/// A number from the listing keeps naming its paragraph while other paragraphs are edited, and +/// a paragraph can be edited twice in a row by the same number. +#[test] +fn text_edit_numbers_survive_edits_that_do_not_regroup() { + let dir = workdir("text-edit-stable-number"); + std::fs::write(dir.join("p.pdf"), three_paragraph_pdf()).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path":"p.pdf"}))["doc"].as_u64().unwrap(); + ok(&mut a, "text_paragraphs", json!({"doc":doc,"page":1})); + ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":3,"text":"Charlie one.\nCharlie two."})); + ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":1,"text":"Alpha one.\nAlpha two."})); + ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":1,"text":"Alpha again.\nAlpha two."})); + ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":3,"text":"Charlie once more.\nCharlie two."})); + // A rewrite rewraps at the width of the widest row, so compare without line breaks. + let text = page_text(&mut a, doc).join(" ").split_whitespace().collect::>().join(" "); + for want in ["Alpha again.", "Bravo paragraph, a single row.", "Charlie once more."] { + assert!(text.contains(want), "{want}: {text}"); + } + std::fs::remove_dir_all(dir).unwrap(); +} + +/// Where the first bookmark on `path` goes, as `bookmark_list` reports it: points from the +/// top-left of the displayed page. +fn bookmark_destination(a: &mut Automation, doc: u64, title: &str) -> Value { + let list = ok(a, "bookmark_list", json!({ "doc": doc }))["bookmarks"].clone(); + let row = list.as_array().unwrap().iter().find(|r| r["title"] == title).unwrap_or_else(|| panic!("{title} in {list}")); + row["destination"].clone() +} + +#[test] +fn a_bookmark_added_at_a_position_reports_the_same_position_back_on_any_page_rotation() { + let dir = workdir("bookmark-position"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "page_rotate", json!({ "doc": doc, "pages": [3], "degrees": 90 })); + let near = |v: &Value, want: Option, what: &str| match want { + Some(w) => assert!((v.as_f64().unwrap_or_else(|| panic!("{what}: {v}")) - w).abs() < 0.05, "{what}: {v} vs {w}"), + None => assert!(v.is_null(), "{what}: {v}"), + }; + // Page 1 upright, page 3 turned a quarter: a displayed top and left come back as given. + for (page, title) in [(1, "Upright"), (3, "Turned")] { + for (suffix, left, top) in [(" both", Some(72.5), Some(120.25)), (" top", None, Some(200.0)), (" left", Some(50.0), None)] { + let name = format!("{title}{suffix}"); + let mut args = json!({ "doc": doc, "title": name, "page": page }); + if let Some(l) = left { + args["left"] = json!(l); + } + if let Some(t) = top { + args["top"] = json!(t); + } + ok(&mut a, "bookmark_add", args); + let d = bookmark_destination(&mut a, doc, &name); + assert_eq!(d["fit"], "XYZ", "{name}"); + near(&d["left"], left, &format!("{name} left")); + near(&d["top"], top, &format!("{name} top")); + assert!(d["zoom"].is_null(), "the zoom is kept: {name}"); + } + } + // No position is the page top at the reader's zoom, as before. + ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": "Plain", "page": 2 })); + let plain = bookmark_destination(&mut a, doc, "Plain"); + assert!(plain["left"].is_null() && plain["top"].is_null() && plain["zoom"].is_null(), "{plain}"); + + // A position that is not a usable number is refused and adds nothing. + let before = ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"].clone(); + for bad in [json!("high"), json!(2_000_000), json!(-2_000_000.5), json!(true)] { + for key in ["top", "left"] { + let args = json!({ "doc": doc, "title": "Bad", "page": 1, key: bad }); + assert!(matches!(a.call("bookmark_add", &args), Err(ToolError::InvalidArgs(_))), "{key} {bad}"); + } + } + assert_eq!(ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"], before, "nothing was added"); + + // It survives a save and reopen. + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "placed.pdf" })); + let mut b = auto(&dir); + let re = ok(&mut b, "doc_open", json!({ "path": "placed.pdf" }))["doc"].as_u64().unwrap(); + let d = bookmark_destination(&mut b, re, "Turned both"); + near(&d["left"], Some(72.5), "reopened left"); + near(&d["top"], Some(120.25), "reopened top"); +} + +/// `comment_edit points` reshapes a line, connected lines or a polygon, in one undo step, and +/// `comment_list` reports the points it takes. +#[test] +fn comments_are_reshaped_through_the_tool() { + let dir = workdir("reshape"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "line", "from": [20, 40], "to": [120, 40] })); + let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 1 })); + assert_eq!(list["comments"][0]["points"], json!([[20.0, 40.0], [120.0, 40.0]])); + ok(&mut a, "comment_edit", json!({ "doc": doc, "page": 1, "index": 1, "points": [[20, 40], [20, 140]] })); + let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 1 })); + assert_eq!(list["comments"][0]["points"], json!([[20.0, 40.0], [20.0, 140.0]])); + assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Reshape comment"); + assert!(a.call("comment_edit", &json!({ "doc": doc, "page": 1, "index": 1, "points": [[1, 2]] })).is_err(), "a line has two ends"); + assert!(matches!(a.call("comment_edit", &json!({ "doc": doc, "page": 1, "index": 1, "points": "no" })), Err(ToolError::InvalidArgs(_)))); +} diff --git a/crates/automation/tests/hostile_args.rs b/crates/automation/tests/hostile_args.rs index 607696899..d393e3726 100644 --- a/crates/automation/tests/hostile_args.rs +++ b/crates/automation/tests/hostile_args.rs @@ -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 { + let pages: Vec = (0..6).map(|i| 10 + 2 * i).collect(); + let mut kids: Vec = pages[..3].to_vec(); + kids.push(77); // does not exist + kids.extend(&pages[3..]); + let kids: Vec = 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 { + 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)); +} diff --git a/crates/automation/tests/optimize_content_boundaries.rs b/crates/automation/tests/optimize_content_boundaries.rs new file mode 100644 index 000000000..c37da05eb --- /dev/null +++ b/crates/automation/tests/optimize_content_boundaries.rs @@ -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 { + 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 = + [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, 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); +} diff --git a/crates/content/src/lib.rs b/crates/content/src/lib.rs index c7c8bca9e..eb126ffa2 100644 --- a/crates/content/src/lib.rs +++ b/crates/content/src/lib.rs @@ -219,19 +219,40 @@ fn inline_image(data: &[u8], pos: usize) -> Result<(Dict, Vec, 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 }; diff --git a/crates/content/src/tests.rs b/crates/content/src/tests.rs index 1119bdaf9..094a0665d 100644 --- a/crates/content/src/tests.rs +++ b/crates/content/src/tests.rs @@ -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 = (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 { 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 !!~>"); + } +} diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index 639edf582..ae00234d0 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -268,6 +268,20 @@ impl XrefIndex { } } + /// Every entry, newest section first, as the materialized reader keeps them. + fn to_map(&self, file: &[u8]) -> BTreeMap { + 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>, data: Arc>, } +/// 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, 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>, /// See [`Self::xref_oddities`]. xref_oddities: Arc>, + /// 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>, repair_log: Arc>, cache: Arc>>>, objstms: Arc>>>, @@ -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::>())) }); 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::>())) }); 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, §_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, §_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) { + 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 = 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 = 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) -> Result<(), CosError> { let data = self.data.clone(); let mut entries: BTreeMap = 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::().ok()); - let generation = std::str::from_utf8(g).ok().and_then(|s| s.parse::().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 { + 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::().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::().ok()); + let generation = std::str::from_utf8(g).ok().and_then(|s| s.parse::().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, log: &mut Vec) -> Option { + 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 { 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 { /// 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 { + 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 { + 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 { + 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 { + 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::>()).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 { let mut out = b"%PDF-1.5\n".to_vec(); let mut offs = [0usize; 6]; let mut obj = |out: &mut Vec, 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 { + 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"); diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index 786dcd968..3db0566ff 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -49,8 +49,10 @@ fn write_real(r: f64, out: &mut Vec) { 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) { // 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) { @@ -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:?}"), } } diff --git a/crates/cos/tests/proptests.rs b/crates/cos/tests/proptests.rs new file mode 100644 index 000000000..4380dd0bd --- /dev/null +++ b/crates/cos/tests/proptests.rs @@ -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::()), + Object::Array(a) => format!("[{}]", a.iter().map(canon).collect::>().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::>().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 { + prop_oneof![ + Just(Object::Null), + any::().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::(), 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 { + 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 { + 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 { + 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, 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)) { + // ` ` subsection header. + expected_rows += std::str::from_utf8(fields[1]).ok().and_then(|s| s.parse::().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 ` 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 { + 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 = sets.keys().copied().collect(); + (Just(sets), prop::collection::vec(0usize..keys.len(), 1..=keys.len())).prop_map(move |(s, picks)| { + let mut dels: Vec = 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); + } + } + } +} diff --git a/crates/crypt/tests/qpdf_literals.rs b/crates/crypt/tests/qpdf_literals.rs new file mode 100644 index 000000000..8957063ac --- /dev/null +++ b/crates/crypt/tests/qpdf_literals.rs @@ -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=, 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, 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 { + hex(self.id0_hex) + } +} + +fn hex(s: &str) -> Vec { + (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect() +} + +fn no_filters() -> Vec<(Vec, Method)> { + Vec::new() +} + +fn aes128_filter() -> Vec<(Vec, Method)> { + vec![(b"StdCF".to_vec(), Method::Aes128)] +} + +fn aes256_filter() -> Vec<(Vec, 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}"); +} diff --git a/crates/edit/src/background.rs b/crates/edit/src/background.rs new file mode 100644 index 000000000..d1d50e450 --- /dev/null +++ b/crates/edit/src/background.rs @@ -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, + /// 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>, +} + +/// 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, 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::>()); + 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 = 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 { + 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)) +} diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index e90d8e4f1..75bb12f47 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -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. diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index c1062b410..d3db00fe3 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -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 <> 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, `<>` page count, `<<1 of n>>`, `<<1/n>>`, `<>`, /// `<>`, dates `<>`, `<>`, `<>`, `<>`, - /// `<>`, `<>`, and Bates numbers `<>` + /// `<>`, `<>`, `<>`, `<>`, and Bates numbers `<>` /// (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 { 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, 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, &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, faces: &[(Vec, &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, 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, 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) -> 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::, _>>()?; 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 = 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 = 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 { 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::{ diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 879ebc6c6..cf3860237 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -54,12 +54,34 @@ fn cx() -> Context { Context { date: (2026, 10, 1) } } +#[test] +fn an_image_turns_by_any_angle_about_its_centre_and_saves_the_matrix() { + // 100,100..300,200 is 200 x 100 around (200, 150). + let mut doc = image_page(); + let r0 = images::page_images(&doc, 0).unwrap()[0].rect; + let t = images::turn_by_degrees(r0, 30.0).unwrap(); + images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(t)).unwrap(); + let doc = reopen(&doc); + let r = images::page_images(&doc, 0).unwrap()[0].rect; + let (c, s) = (30f64.to_radians().cos(), 30f64.to_radians().sin()); + let (w, h) = (200.0 * c + 100.0 * s, 200.0 * s + 100.0 * c); + assert!(close(r, [200.0 - w / 2.0, 150.0 - h / 2.0, 200.0 + w / 2.0, 150.0 + h / 2.0]), "{r:?}"); + // 90 degrees is the quarter turn; whole turns and non-finite angles are harmless. + let q = images::turn_about_centre(r0, 1, false, false); + let d = images::turn_by_degrees(r0, 90.0).unwrap(); + assert!(q.iter().zip(&d).all(|(a, b)| (a - b).abs() < 1e-9), "{q:?} {d:?}"); + let full = images::turn_by_degrees(r0, 720.0).unwrap(); + assert!(full.iter().zip([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]).all(|(a, b)| (a - b).abs() < 1e-9)); + assert!(images::turn_by_degrees(r0, f64::NAN).is_none() && images::turn_by_degrees(r0, f64::INFINITY).is_none()); +} + #[test] fn tokens_expand() { let c = cx(); assert_eq!(expand("Page <<1>> of <>", 3, 10, 0, &c), "Page 3 of 10"); assert_eq!(expand("<<1 of n>> · <> · <<1/n>>", 2, 5, 0, &c), "2 of 5 · Page 2 · 2/5"); assert_eq!(expand("<> <> <>", 1, 1, 0, &c), "10/1/2026 2026-10-01 October 1, 2026"); + assert_eq!(expand("<> <>", 1, 1, 0, &c), "2026/10/01 2026/10/1"); assert_eq!(expand("<>", 1, 1, 104, &c), "ABC000104-X"); assert_eq!(expand("keep <> and <> and <>"), Some(100)); @@ -97,6 +119,99 @@ fn header_and_footer_are_drawn_in_display_space_and_wrap_the_original_content() assert_eq!(marks_present(&doc), [MarkKind::HeaderFooter]); } +#[test] +fn header_and_footer_are_drawn_in_the_chosen_standard_face() { + // #3754: Times or Courier instead of Helvetica. The content names the face, the page's fonts + // gain it, and right-aligned text is placed by that face's widths (not Helvetica's). + let text = "Wide words"; + for (font, resource, base) in [(added::Family::Times, "PCTimes", "Times-Roman"), (added::Family::Courier, "PCCour", "Courier")] { + let mut doc = fixture(); + let hf = HeaderFooter { + text: [String::new(), String::new(), text.into(), String::new(), String::new(), String::new()], + font, + ..HeaderFooter::default() + }; + add_header_footer(&mut doc, &[0], &hf, false, &cx()).unwrap(); + let doc = reopen(&doc); + let mark = streams(&doc, 0).last().cloned().unwrap(); + assert!(mark.contains(&format!("/{resource} 8 Tf")), "{base}: {mark}"); + let x = 600.0 - 72.0 - font.width(text, 8.0, false, false); + assert!((x - (600.0 - 72.0 - pdfcraft_fonts::helvetica_width(text, 8.0))).abs() > 1.0, "{base}: the faces differ in width"); + assert!(mark.contains(&format!("1 0 0 1 {} ", n(x))), "{base}: right-aligned by its own widths: {mark}"); + let page = &pdfcraft_model::pages(&doc)[0]; + let res = doc.resolve(page.dict.get(b"Resources").unwrap()); + let fonts = doc.resolve(res.as_dict().unwrap().get(b"Font").unwrap()).as_dict().cloned().unwrap(); + let face = doc.resolve(fonts.get(resource.as_bytes()).unwrap()).as_dict().cloned().unwrap(); + assert_eq!(face.name(b"BaseFont"), Some(base.as_bytes()), "{base}"); + } + // The default is still Helvetica, and only Helvetica is added. + let mut doc = fixture(); + let hf = + HeaderFooter { text: [text.into(), String::new(), String::new(), String::new(), String::new(), String::new()], ..HeaderFooter::default() }; + add_header_footer(&mut doc, &[0], &hf, false, &cx()).unwrap(); + let mark = streams(&reopen(&doc), 0).last().cloned().unwrap(); + assert!(mark.contains("/PCHelv 8 Tf") && !mark.contains("/PCTimes") && !mark.contains("/PCCour"), "{mark}"); +} + +#[test] +fn a_header_never_takes_over_a_font_name_the_page_already_draws_with() { + // From review of #3756: a page that already draws a different font as /PCTimes keeps it; the + // header's Times goes under a free name, and an update reuses that name, not a third one. + let mut doc = fixture(); + let page = pdfcraft_model::pages(&doc)[0].obj; + let mut fonts = Dict::new(); + let mut theirs = Dict::new(); + theirs.set(b"Type".to_vec(), Object::name("Font")); + theirs.set(b"Subtype".to_vec(), Object::name("Type1")); + theirs.set(b"BaseFont".to_vec(), Object::name("Helvetica-Bold")); + fonts.set(b"PCTimes".to_vec(), Object::Dict(theirs)); + let mut res = Dict::new(); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + doc.update_dict(page, |d| d.set(b"Resources".to_vec(), Object::Dict(res))).unwrap(); + let hf = HeaderFooter { + text: ["Serif".into(), String::new(), String::new(), String::new(), String::new(), String::new()], + font: added::Family::Times, + ..HeaderFooter::default() + }; + let base_of = |doc: &Document, name: &[u8]| { + let fonts = page_fonts(doc, &pdfcraft_model::pages(doc)[0]); + fonts.get(name).map(|f| doc.resolve(f)).and_then(|f| f.as_dict().and_then(|d| d.name(b"BaseFont")).map(<[u8]>::to_vec)) + }; + add_header_footer(&mut doc, &[0], &hf, false, &cx()).unwrap(); + let doc = reopen(&doc); + assert_eq!(base_of(&doc, b"PCTimes").as_deref(), Some(&b"Helvetica-Bold"[..]), "the page's own font is untouched"); + assert_eq!(base_of(&doc, b"PCTimes_1").as_deref(), Some(&b"Times-Roman"[..])); + let mark = streams(&doc, 0).last().cloned().unwrap(); + assert!(mark.contains("/PCTimes_1 8 Tf"), "{mark}"); + // Updating the header finds its own name again. + let mut doc = doc; + add_header_footer(&mut doc, &[0], &hf, true, &cx()).unwrap(); + let doc = reopen(&doc); + assert!(streams(&doc, 0).last().unwrap().contains("/PCTimes_1 8 Tf")); + assert_eq!(base_of(&doc, b"PCTimes_2"), None, "no new name on update"); + assert_eq!(base_of(&doc, b"PCTimes").as_deref(), Some(&b"Helvetica-Bold"[..])); + // The same face with more to it (here a /ToUnicode map) is the page's own too: kept whole. + let mut doc = fixture(); + let page = pdfcraft_model::pages(&doc)[0].obj; + let map = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), b"/CIDInit /ProcSet findresource begin"))); + let mut theirs = Dict::new(); + for (k, v) in [("Type", "Font"), ("Subtype", "Type1"), ("BaseFont", "Times-Roman"), ("Encoding", "WinAnsiEncoding")] { + theirs.set(k.as_bytes().to_vec(), Object::name(v)); + } + theirs.set(b"ToUnicode".to_vec(), Object::Ref(map)); + let mut fonts = Dict::new(); + fonts.set(b"PCTimes".to_vec(), Object::Dict(theirs)); + let mut res = Dict::new(); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + doc.update_dict(page, |d| d.set(b"Resources".to_vec(), Object::Dict(res))).unwrap(); + add_header_footer(&mut doc, &[0], &hf, false, &cx()).unwrap(); + let doc = reopen(&doc); + let fonts = page_fonts(&doc, &pdfcraft_model::pages(&doc)[0]); + let kept = doc.resolve(fonts.get(b"PCTimes").unwrap()).as_dict().cloned().unwrap(); + assert!(kept.contains(b"ToUnicode"), "the page's map is kept"); + assert!(streams(&doc, 0).last().unwrap().contains("/PCTimes_1 8 Tf")); +} + #[test] fn header_start_number_that_would_wrap_is_refused_without_drawing() { // u32::MAX is a valid start_number on its own, but there is no room for a second page @@ -156,6 +271,100 @@ fn replace_and_remove_restore_the_original_content() { assert_eq!(streams(&reopen(&doc), 0), original, "back to exactly the original content"); } +/// The six numbers of the first `… cm` in `content`. +fn first_cm(content: &str) -> [f64; 6] { + let line = content.lines().find(|l| l.ends_with(" cm")).unwrap_or_else(|| panic!("no cm in {content:?}")); + let v: Vec = line.split_whitespace().take(6).map(|x| x.parse().unwrap()).collect(); + <[f64; 6]>::try_from(v).unwrap() +} + +fn map_point(m: [f64; 6], x: f64, y: f64) -> (f64, f64) { + (m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]) +} + +fn near(a: (f64, f64), b: (f64, f64)) -> bool { + (a.0 - b.0).abs() < 0.01 && (a.1 - b.1).abs() < 0.01 +} + +fn shrinking(header: &str, footer: &str) -> HeaderFooter { + let mut hf = HeaderFooter { shrink: true, ..HeaderFooter::default() }; + hf.text[1] = header.into(); + hf.text[5] = footer.into(); + hf +} + +#[test] +fn shrink_scales_the_original_content_into_the_band_between_header_and_footer() { + let mut doc = fixture(); + // 8 pt text, margins 36 top and bottom: the header takes 36 + 8 (+ 4 clearance) at the top, + // the footer 36 + 6.4 (its capitals above the baseline, + 4) at the bottom. + add_header_footer(&mut doc, &[0], &shrinking("ACME", "<>"), false, &cx()).unwrap(); + let doc = reopen(&doc); + let s = streams(&doc, 0); + // q-wrapper, shrink, original, Q-wrapper, header/footer. + assert_eq!(s.len(), 5, "{s:?}"); + assert_eq!((s[0].as_str(), s[3].as_str()), ("q %PdfCraft\n", "\nQ %PdfCraft\n")); + assert!(s[2].contains("(Hi) Tj"), "the original content follows the shrink: {s:?}"); + let m = first_cm(&s[1]); + let k = (800.0 - 48.0 - 46.4) / 800.0; + assert!((m[0] - k).abs() < 1e-3 && (m[3] - k).abs() < 1e-3 && m[1] == 0.0 && m[2] == 0.0, "uniform scale {k}: {m:?}"); + // The page's corners land on the band, centred across. + assert!(near(map_point(m, 0.0, 0.0), ((600.0 - 600.0 * k) / 2.0, 46.4)), "{m:?}"); + assert!(near(map_point(m, 600.0, 800.0), ((600.0 + 600.0 * k) / 2.0, 800.0 - 48.0)), "{m:?}"); + assert!(s[4].contains("(000001) Tj"), "{}", s[4]); +} + +#[test] +fn shrink_follows_the_display_space_of_a_rotated_page() { + let mut doc = fixture(); + // Page 2 is rotated 90°: shown 800 wide and 600 high. A footer alone keeps the top. + add_header_footer(&mut doc, &[1], &shrinking("", "<<1>>"), false, &cx()).unwrap(); + let s = streams(&doc, 1); + let m = first_cm(&s[1]); + let page = &pdfcraft_model::pages(&doc)[1]; + let v = page.view_matrix(&doc); + let k = (600.0 - 46.4) / 600.0; + // Display (x, y) is user v·(x, y): the shrunk content's display corners. + let to_user = |x: f64, y: f64| map_point(v, x, y); + assert!(near(map_point(m, to_user(0.0, 0.0).0, to_user(0.0, 0.0).1), to_user((800.0 - 800.0 * k) / 2.0, 46.4)), "{m:?}"); + assert!(near(map_point(m, to_user(800.0, 600.0).0, to_user(800.0, 600.0).1), to_user((800.0 + 800.0 * k) / 2.0, 600.0)), "{m:?}"); +} + +#[test] +fn shrink_is_undone_by_remove_and_not_doubled_by_update() { + let mut doc = fixture(); + let original = streams(&doc, 0); + add_background(&mut doc, &[0], &Background::default(), false).unwrap(); + add_header_footer(&mut doc, &[0], &shrinking("First", ""), false, &cx()).unwrap(); + add_header_footer(&mut doc, &[0], &shrinking("Second", ""), true, &cx()).unwrap(); + let s = streams(&doc, 0); + assert_eq!(s.iter().filter(|x| x.contains(" cm") && !x.contains("/PCMark")).count(), 2, "one shrink besides the original's own cm: {s:?}"); + assert!(s[0].contains("/PCMark /Background"), "the background stays behind, full size: {s:?}"); + // Update without shrinking: back to full size. + let mut plain = shrinking("Third", ""); + plain.shrink = false; + add_header_footer(&mut doc, &[0], &plain, true, &cx()).unwrap(); + assert_eq!(streams(&doc, 0).len(), 5, "background, wrapper, original, wrapper, header: {:?}", streams(&doc, 0)); + add_header_footer(&mut doc, &[0], &shrinking("Fourth", ""), true, &cx()).unwrap(); + assert_eq!(remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(), 2, "the header and its shrink"); + assert_eq!(remove_marks(&mut doc, &[0], MarkKind::Background).unwrap(), 1); + assert_eq!(streams(&reopen(&doc), 0), original, "back to exactly the original content"); +} + +#[test] +fn shrink_with_no_room_left_is_refused_without_drawing() { + let mut doc = fixture(); + let mut hf = shrinking("Top", "Bottom"); + hf.margins = [400.0, 400.0, 72.0, 72.0]; + let err = add_header_footer(&mut doc, &[0, 1], &hf, false, &cx()).unwrap_err(); + assert!(err.to_string().contains("no room"), "{err}"); + assert!(!doc.is_modified()); + // A page without content has nothing to shrink: it just gets the header. + let mut doc = fixture(); + add_header_footer(&mut doc, &[2], &shrinking("Top", ""), false, &cx()).unwrap(); + assert_eq!(streams(&doc, 2).len(), 1); +} + #[test] fn watermarks_rotate_fade_and_can_go_behind() { let mut doc = fixture(); @@ -168,6 +377,45 @@ fn watermarks_rotate_fade_and_can_go_behind() { assert_eq!(s.len(), 2, "no wrapper needed for content behind"); } +#[test] +fn a_second_background_is_drawn_over_the_first() { + // #3591: the new background went in front of the old one in /Contents, so the old colour + // was painted over it and stayed. + let mut doc = fixture(); + add_background(&mut doc, &[0], &Background { color: [0.0, 0.0, 1.0], opacity: 1.0, ..Background::default() }, false).unwrap(); + add_background(&mut doc, &[0], &Background { color: [0.0, 1.0, 0.0], opacity: 1.0, ..Background::default() }, false).unwrap(); + let s = streams(&doc, 0); + assert_eq!(s.len(), 3, "{s:?}"); + assert!(s[0].contains("0 0 1 rg") && s[1].contains("0 1 0 rg"), "the newer background is painted last: {s:?}"); + assert!(s[2].contains("(Hi) Tj"), "both stay behind the content: {s:?}"); +} + +#[test] +fn backgrounds_stay_below_watermarks_behind_the_content() { + // Behind the content, backgrounds come first, in the order added, and then watermarks, so a + // watermark added after a background shows and a later background doesn't cover it. + let mut doc = fixture(); + add_background(&mut doc, &[0], &Background { color: [0.0, 0.0, 1.0], opacity: 1.0, ..Background::default() }, false).unwrap(); + add_watermark(&mut doc, &[0], &Watermark { text: "DRAFT".into(), behind: true, ..Watermark::default() }, false).unwrap(); + add_background(&mut doc, &[0], &Background { color: [0.0, 1.0, 0.0], opacity: 1.0, ..Background::default() }, false).unwrap(); + let s = streams(&doc, 0); + assert_eq!(s.len(), 4, "{s:?}"); + assert!(s[0].contains("0 0 1 rg") && s[1].contains("0 1 0 rg"), "backgrounds first, oldest first: {s:?}"); + assert!(s[2].contains("/PCMark /Watermark") && s[3].contains("(Hi) Tj"), "then the watermark, then the content: {s:?}"); +} + +#[test] +fn a_watermark_behind_goes_under_one_on_top_of_a_page_without_content() { + // With no content of its own, the page's watermark on top is its first stream, which looks + // like a behind-mark; a watermark added behind later must still be painted under it. + let mut doc = fixture(); + add_watermark(&mut doc, &[2], &Watermark { text: "TOP".into(), ..Watermark::default() }, false).unwrap(); + add_watermark(&mut doc, &[2], &Watermark { text: "BEHIND".into(), behind: true, ..Watermark::default() }, false).unwrap(); + let s = streams(&doc, 2); + assert_eq!(s.len(), 2, "{s:?}"); + assert!(s[0].contains("(BEHIND) Tj") && s[1].contains("(TOP) Tj"), "the watermark behind is painted first: {s:?}"); +} + #[test] fn watermark_offset_moves_the_centre_right_and_up() { let mut doc = fixture(); @@ -284,6 +532,27 @@ fn flattening_keeps_a_note_whose_appearance_is_missing() { assert!(flat.contains("q 1 0 0 1 10 10 cm /PCFl0 Do Q"), "{flat}"); } +/// #3175: a note without an appearance is drawn as a fixed-size icon at the upper-left corner of +/// its `/Rect`. A rectangle with no width (LaTeX `pdfcomment`) used to scale the icon to nothing +/// while flattening still counted it as drawn and deleted the note. +#[test] +fn flattening_a_note_with_a_zero_width_rect_draws_a_fixed_size_icon() { + let page = stream("", " "); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R 6 0 R] >>", + &page, + "<< /Type /Annot /Subtype /Text /Rect [50 60 50 72] /Contents (Zero width) /Name /Comment /F 4 >>", + "<< /Type /Annot /Subtype /Text /Rect [10 20 150 120] /Contents (Large) /F 4 >>", + ]); + assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 2, "both icons are drawn"); + let doc = reopen(&doc); + let flat = streams(&doc, 0).pop().unwrap(); + assert!(flat.contains("q 1 0 0 1 50 52 cm /PCFl0 Do Q"), "{flat}"); + assert!(flat.contains("q 1 0 0 1 10 100 cm /PCFl1 Do Q"), "{flat}"); +} + /// #1470: a form field whose widget is on no page (left behind by page extraction or combining) /// has nothing to draw, but flattening every form field still takes it out of `/AcroForm /Fields`, /// or other software keeps treating the flattened file as a form. A hidden widget on a page is @@ -877,17 +1146,25 @@ fn odd_arabic_text_never_panics() { } } -/// One page with Helvetica (WinAnsi) and a subset font that has only the glyphs it uses. +/// One page with Helvetica (WinAnsi) and subsets of Arial: `/F2` and `/F3` are two subsets of the +/// same font, as Word writes a font split across several resources, and `/F4` repeats Helvetica. fn text_page(content: &str) -> Document { let objs: Vec = vec![ "<< /Type /Catalog /Pages 2 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), - "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R >> >> >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R /F3 7 0 R /F4 8 0 R /F5 9 0 R >> >> >>".into(), format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), // Subset: glyphs for a (97) and b (98) only. "<< /Type /Font /Subtype /TrueType /BaseFont /ABCDEF+Arial /FirstChar 97 /LastChar 99 /Widths [500 520 0] /Encoding /WinAnsiEncoding >>" .into(), + // Another subset of the same Arial, with a subset tag of its own. + "<< /Type /Font /Subtype /TrueType /BaseFont /GHIJKL+Arial /FirstChar 97 /LastChar 99 /Widths [500 520 0] /Encoding /WinAnsiEncoding >>" + .into(), + // Helvetica again, in a second resource. + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + // No `/BaseFont`: `Metrics::from_dict` leaves its base-font name empty. + "<< /Type /Font /Subtype /Type1 /Encoding /WinAnsiEncoding >>".into(), ]; let mut out = b"%PDF-1.7\n".to_vec(); let mut offs = Vec::new(); @@ -1172,6 +1449,44 @@ fn cyrillic_in_a_serif_line_falls_back_to_a_face_that_has_it() { } } +/// #2593: the craft-fonts faces are Japanese, and part of the Cyrillic (Uzbek қ ғ ҳ, Kazakh ә ө ү +/// ұ һ) is in none of them. The bundled Inter face has it, so the replacement is written instead of +/// being refused, and a build without craft-fonts can do it too. +#[test] +fn uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face() { + for replacement in ["The quick brown fox jumps over the lazy dog қизил", "әже", "ўзбекча ғўза"] { + for paragraph in [false, true] { + let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET"); + let edit = + if paragraph { text::replace_block(&mut doc, 0, 0, replacement) } else { text::replace_line(&mut doc, 0, 0, replacement) }.unwrap(); + assert_eq!(edit.substituted.as_deref(), Some("Inter Type3"), "{replacement}: {edit:?}"); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert!(content.contains("/PCLat"), "{replacement}: {content}"); + let lines = text::text_lines(&reopen(&doc), 0).unwrap(); + assert_eq!(lines[0].text, replacement); + assert_eq!(lines[0].base_font, "Inter-Regular", "the generated font reports the face it drew"); + } + } +} + +/// The Japanese faces keep first refusal for the Cyrillic they do carry, so Russian text keeps the +/// face it was drawn with before the bundled Latin face was added. +#[test] +fn cyrillic_the_japanese_faces_carry_keeps_them() { + if without_craft_fonts("cyrillic_the_japanese_faces_carry_keeps_them") { + return; + } + let face = pdfcraft_fonts::document_japanese_font_for_style(false, false).unwrap(); + if pdfcraft_fonts::japanese_glyph_from(face, 'ф').is_err() { + eprintln!("skipping: the Japanese fallback face has no Cyrillic"); + return; + } + let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET"); + let edit = text::replace_line(&mut doc, 0, 0, "Привет, world").unwrap(); + let expected = format!("{} Type3", face.family); + assert_eq!(edit.substituted.as_deref(), Some(expected.as_str())); +} + #[test] fn japanese_paragraph_uses_unicode_type3_fallback() { if without_craft_fonts("japanese_paragraph_uses_unicode_type3_fallback") { @@ -1196,6 +1511,87 @@ fn covered_japanese_chars(n: usize) -> String { .collect() } +#[test] +fn fallback_fonts_nothing_shows_any_more_leave_with_a_rewrite() { + // `PCJp1` is selected by the page, `PCJp2` only by a form that uses the page's resources, `PCJp` + // by nothing; `PCJpOther` is a Type 3 font of the file's own that only shares the prefix. + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Some text) Tj ET BT /PCJp1 12 Tf ET q /Fm0 Do Q"); + let generated = |doc: &mut Document, name: &str| { + doc.add(Object::Dict(Dict::from_iter([ + (b"Type".to_vec(), Object::name("Font")), + (b"Subtype".to_vec(), Object::name("Type3")), + (b"Name".to_vec(), Object::name(name)), + ]))) + }; + let (unused, page_shown, form_shown, own) = + (generated(&mut doc, "PCJapanese"), generated(&mut doc, "PCJapanese"), generated(&mut doc, "PCJapanese"), generated(&mut doc, "Other")); + let form = doc.add(Object::Stream(Stream::from_raw( + Dict::from_iter([(b"Subtype".to_vec(), Object::name("Form")), (b"BBox".to_vec(), Object::Array(vec![Object::Int(0); 4]))]), + b"BT /PCJp2 12 Tf ET".to_vec(), + ))); + let p = pdfcraft_model::pages(&doc)[0].obj; + let mut fonts = Dict::from_iter([(b"F1".to_vec(), Object::Ref(pdfcraft_cos::ObjRef::new(5, 0)))]); + for (name, font) in [("PCJp", unused), ("PCJp1", page_shown), ("PCJp2", form_shown), ("PCJpOther", own)] { + fonts.set(name.as_bytes().to_vec(), Object::Ref(font)); + } + let resources = Dict::from_iter([ + (b"Font".to_vec(), Object::Dict(fonts)), + (b"XObject".to_vec(), Object::Dict(Dict::from_iter([(b"Fm0".to_vec(), Object::Ref(form))]))), + ]); + doc.update_dict(p, |d| d.set(b"Resources".to_vec(), Object::Dict(resources.clone()))).unwrap(); + let names = |doc: &Document| { + let page = pdfcraft_model::pages(doc).remove(0); + let res = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone(); + let mut names: Vec = + doc.resolve(res.get(b"Font").unwrap()).as_dict().unwrap().iter().map(|(n, _)| String::from_utf8_lossy(n).into_owned()).collect(); + names.sort(); + names + }; + // A rewrite that doesn't reach the fallback fonts still drops the one nothing shows. + text::rewrite_block(&mut doc, 0, 0, Some("Other text"), &text::BlockStyle::default()).unwrap(); + assert_eq!(names(&reopen(&doc)), ["F1", "PCJp1", "PCJp2", "PCJpOther"]); + + // A form using the page's resources whose content can't be read might show any of them: they + // all stay. + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Some text) Tj ET q /Fm0 Do Q"); + let unused = generated(&mut doc, "PCJapanese"); + let broken = doc.add(Object::Stream(Stream::from_raw( + Dict::from_iter([ + (b"Subtype".to_vec(), Object::name("Form")), + (b"BBox".to_vec(), Object::Array(vec![Object::Int(0); 4])), + (b"Filter".to_vec(), Object::name("FlateDecode")), + ]), + b"not deflate data".to_vec(), + ))); + let p = pdfcraft_model::pages(&doc)[0].obj; + let fonts = Dict::from_iter([(b"F1".to_vec(), Object::Ref(pdfcraft_cos::ObjRef::new(5, 0))), (b"PCJp".to_vec(), Object::Ref(unused))]); + let resources = Dict::from_iter([ + (b"Font".to_vec(), Object::Dict(fonts)), + (b"XObject".to_vec(), Object::Dict(Dict::from_iter([(b"Fm0".to_vec(), Object::Ref(broken))]))), + ]); + doc.update_dict(p, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap(); + text::rewrite_block(&mut doc, 0, 0, Some("Other text"), &text::BlockStyle::default()).unwrap(); + assert_eq!(names(&doc), ["F1", "PCJp"]); +} + +#[test] +fn rewriting_japanese_twice_keeps_only_the_fallback_fonts_it_shows() { + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Hello) Tj ET"); + if text::rewrite_block(&mut doc, 0, 0, Some("日本語の文章"), &text::BlockStyle::default()).is_err() { + assert!(pdfcraft_fonts::document_japanese_font().is_none()); + eprintln!("built without a craft-fonts Japanese face: the Type 3 path is checked with CRAFT_FONTS_DIR"); + return; + } + let first: Vec<_> = fallback_fonts(&doc).into_iter().map(|(_, f)| f).collect(); + text::rewrite_block(&mut doc, 0, 0, Some("別の文章です"), &text::BlockStyle::default()).unwrap(); + let doc = reopen(&doc); + let after = fallback_fonts(&doc); + assert!(!after.is_empty()); + assert!(after.iter().all(|(_, f)| !first.contains(f)), "the first rewrite's fonts are gone"); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert!(after.iter().all(|(name, _)| content.contains(&format!("/{} ", String::from_utf8_lossy(name)))), "{content}"); +} + /// The page's Type 3 fallback fonts (`PCJp`, `PCJp1`, ...) by resource name. fn fallback_fonts(doc: &Document) -> Vec<(Vec, Dict)> { let p = pdfcraft_model::pages(doc).swap_remove(0); @@ -1727,6 +2123,94 @@ fn word_boxes_span_whole_words_when_glyphs_run_backwards() { } } +/// PDF writers split one font into several subsets, each a font resource of its own: a word drawn +/// through two of them is still one visual line (#3661). +#[test] +fn one_line_continues_across_two_resources_of_the_same_font() { + for (content, text) in [ + // The issue's case: two resources for the same Helvetica. + ("BT /F1 12 Tf 72 700 Td (Hel) Tj /F4 12 Tf (lo) Tj ET", "Hello"), + // Word's case: two tagged subsets of one font, "abcab" through /F2, /F3, /F2. + ("BT /F2 12 Tf 72 700 Td (ab) Tj /F3 12 Tf (ca) Tj /F2 12 Tf (b) Tj ET", "abcab"), + ] { + let doc = text_page(content); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.len(), 1, "{content}: {:?}", lines.iter().map(|l| &l.text).collect::>()); + assert_eq!(lines[0].text, text, "{content}"); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(blocks.len(), 1, "{content}"); + assert_eq!(blocks[0].text, text, "{content}"); + } +} + +/// Two genuinely different fonts still start a new line: the subset tag is all that may differ. +#[test] +fn different_fonts_start_a_new_line() { + let doc = text_page("BT /F1 12 Tf 72 700 Td (Hel) Tj /F2 12 Tf (lo) Tj ET"); + assert_eq!(text::text_lines(&doc, 0).unwrap().len(), 2); +} + +/// A font without `/BaseFont` has no name to compare. Its lines still group by resource, as they +/// did before subset joining: an empty name must not split one paragraph into two blocks. +#[test] +fn lines_in_a_font_without_a_base_font_stay_one_block() { + let doc = text_page("BT /F5 12 Tf 72 700 Td (First) Tj 0 -15 Td (second) Tj ET"); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.len(), 2); + assert!(lines.iter().all(|l| l.base_font.is_empty()), "{:?}", lines.iter().map(|l| &l.base_font).collect::>()); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0].text, "First second"); + let reading = text::reading_blocks(&doc, 0).unwrap(); + assert_eq!(reading.len(), 1, "{:?}", reading.iter().map(|b| &b.text).collect::>()); + assert_eq!(reading[0].text, "First second"); +} + +/// Two subsets of one font can carry different encodings: here the second names `-` and `l` +/// through `/Differences` (codes 33 and 34), so the same letters have different codes. The visual +/// line still joins and reads in coding order (#3661). +#[test] +fn a_reencoded_subset_of_the_same_font_stays_one_line() { + let content = "BT /F1 16 Tf 72 700 Td (Two) Tj /F2 16 Tf (!\") Tj /F1 16 Tf (ine heading) Tj ET"; + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R >> >> >>", + &stream("", content), + "<< /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+Helvetica-Bold >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /GHIJKL+Helvetica-Bold /Encoding << /Type /Encoding /BaseEncoding /WinAnsiEncoding /Differences [33 /hyphen /l] >> >>", + ]); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.len(), 1, "{:?}", lines.iter().map(|l| &l.text).collect::>()); + assert_eq!(lines[0].text, "Two-line heading"); + let reading = text::reading_blocks(&doc, 0).unwrap(); + assert_eq!(reading.len(), 1); + assert_eq!(reading[0].text, "Two-line heading"); + // Editing the one line replaces every run, including the re-encoded subset's. + text::replace_line(&mut doc, 0, 0, "Two-line heading done").unwrap(); + let reopened = reopen(&doc); + let lines = text::text_lines(&reopened, 0).unwrap(); + assert_eq!(lines.len(), 1, "{:?}", lines.iter().map(|l| &l.text).collect::>()); + assert_eq!(lines[0].text, "Two-line heading done"); +} + +/// `ABCDE+` is five letters, not the six-letter subset tag of §9.6.4, so it stays part of the +/// name: `ABCDE+Helvetica-Bold` and `Helvetica-Bold` are different fonts and their runs stay apart. +#[test] +fn a_five_letter_prefix_is_not_a_subset_tag() { + let content = "BT /F1 16 Tf 72 700 Td (Two) Tj /F2 16 Tf (-l) Tj /F1 16 Tf (ine heading) Tj ET"; + let doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R >> >> >>", + &stream("", content), + "<< /Type /Font /Subtype /Type1 /BaseFont /ABCDE+Helvetica-Bold >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>", + ]); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.len(), 3, "{:?}", lines.iter().map(|l| &l.text).collect::>()); +} + #[test] fn text_lines_are_found_and_replaced_in_place() { let mut doc = @@ -1919,6 +2403,36 @@ fn paragraphs_are_found_and_rewrapped() { assert_eq!((blocks[0].text.as_str(), blocks[0].lines.len()), ("Short.", 1)); } +/// A list whose bullet is its own text object at the margin and whose text sits at a hanging +/// indent: the wrapped first item is one paragraph, and editing it keeps the items after it +/// (#2564, the edit that deleted its neighbours). +#[test] +fn a_wrapped_list_item_keeps_its_continuation_and_the_items_after_it() { + let mut doc = text_page( + "BT /F1 12 Tf 1 0 0 1 72 700 Tm (\\225) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 86 700 Tm (First item of the list that wraps) Tj T* (onto a second row.) Tj ET \ + BT /F1 12 Tf 1 0 0 1 72 671.2 Tm (\\225) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 86 671.2 Tm (Second item, one row.) Tj ET \ + BT /F1 12 Tf 1 0 0 1 72 656.8 Tm (\\225) Tj ET \ + BT /F1 12 Tf 14.4 TL 1 0 0 1 86 656.8 Tm (Third item, one row.) Tj ET", + ); + let items = ["• First item of the list that wraps onto a second row.", "• Second item, one row.", "• Third item, one row."]; + let blocks = text::text_blocks(&doc, 0).unwrap(); + let texts: Vec<&str> = blocks.iter().map(|b| b.text.as_str()).collect(); + assert_eq!(texts, items, "one paragraph per item, the continuation with its first row"); + // Editing the paragraph the wrapped row belongs to must not take the items after it with it. + let blocks = text::text_blocks(&doc, 0).unwrap(); + let wrapped = blocks.iter().position(|b| b.text.contains("onto a second row.")).unwrap(); + text::replace_block(&mut doc, 0, wrapped, "• First item of the list that wraps onto a second row, edited.").unwrap(); + let blocks = text::text_blocks(&reopen(&doc), 0).unwrap(); + let texts: Vec<&str> = blocks.iter().map(|b| b.text.as_str()).collect(); + assert_eq!( + texts, + ["• First item of the list that wraps onto a second row, edited.", "• Second item, one row.", "• Third item, one row."], + "the items after the edited one are still on the page" + ); +} + fn page_content_bytes(doc: &Document, page: usize) -> Vec { let p = pdfcraft_model::pages(doc).swap_remove(page); let c = p.dict.get(b"Contents").unwrap(); @@ -1964,6 +2478,25 @@ fn image_page_with(content: &str) -> Document { Document::open(Arc::new(out)).unwrap() } +/// A path segment operator before any `m` (or `re`) has no subpath to extend: the image scan used +/// to call `BezPath`'s `line_to`/`curve_to`/`close_path` on an empty path, which panics by contract, +/// taking `object_list`, `page_images` and the Office export's image walk down with it (#3581). +/// The segment is now skipped, as the renderer, MuPDF and pdftotext do, and the page's images are +/// still listed. +#[test] +fn image_scan_skips_a_path_segment_before_any_m() { + for bad in ["1 1 l S", "1 1 2 2 3 3 c S", "1 1 2 2 4 4 y S", "1 1 2 2 3 3 v S", "h S"] { + let content = format!("q {bad} Q 100 100 200 100 cm /Im0 Do"); + let doc = image_page_with(&content); + assert_eq!(images::page_images(&doc, 0).unwrap().len(), 1, "{bad}: the image is still listed"); + } + // A path started with m (or re) still extends and closes as before. + let doc = image_page_with("q 0 0 m 1 1 l h S Q 100 100 200 100 cm /Im0 Do"); + assert_eq!(images::page_images(&doc, 0).unwrap().len(), 1); + let doc = image_page_with("q 10 10 20 20 re W n Q 100 100 200 100 cm /Im0 Do"); + assert_eq!(images::page_images(&doc, 0).unwrap().len(), 1); +} + /// The box of the first `re` on the page in user space, after the `cm`s before it. fn first_rect_in_user_space(doc: &Document) -> [f64; 4] { let mut ctm = pdfcraft_content::Matrix::IDENTITY; @@ -2217,6 +2750,43 @@ fn paragraphs_take_new_formatting() { } } +#[test] +fn a_one_row_title_keeps_its_centre_when_it_grows_and_when_it_wraps() { + // A centred one-row title (its centre is at x ≈ 306 on the 600 pt page) above a body + // left-aligned at x = 72. Growing it must not drag it to the right. + let src = "BT /F1 18 Tf 1 0 0 1 252.477 700 Tm (Centered title) Tj ET\n\ + BT /F1 12 Tf 1 0 0 1 72 640 Tm (Body text below the heading starts here and runs) Tj ET\n\ + BT /F1 12 Tf 1 0 0 1 72 625.6 Tm (on for a second row of ordinary words.) Tj ET"; + let mut doc = text_page(src); + let before = text::text_blocks(&doc, 0).unwrap()[0].clone(); + let centre = (before.rect[0] + before.rect[2]) / 2.0; + text::rewrite_block(&mut doc, 0, 0, Some("Centered title with a few more words"), &text::BlockStyle::default()).unwrap(); + let doc = reopen(&doc); + let blocks = text::text_blocks(&doc, 0).unwrap(); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(blocks[0].text, "Centered title with a few more words"); + let r = lines[blocks[0].lines[0]].rect; + assert!(((r[0] + r[2]) / 2.0 - centre).abs() < 1.0, "the grown title stays centred on {centre}, its centre is {}", (r[0] + r[2]) / 2.0); + // Long enough to have to wrap: every row stays centred on the same centre too. + let mut doc = text_page(src); + text::rewrite_block( + &mut doc, + 0, + 0, + Some("Centered title with many more words added so that the single row has to become much longer"), + &text::BlockStyle::default(), + ) + .unwrap(); + let doc = reopen(&doc); + let blocks = text::text_blocks(&doc, 0).unwrap(); + let lines = text::text_lines(&doc, 0).unwrap(); + assert!(blocks[0].lines.len() > 1, "the long title wraps into rows: {:?}", blocks[0].text); + for i in &blocks[0].lines { + let r = lines[*i].rect; + assert!(((r[0] + r[2]) / 2.0 - centre).abs() < 2.0, "row {:?} stays centred on {centre}", lines[*i].text); + } +} + #[test] fn a_new_paragraph_colour_does_not_spill_into_the_text_after_it() { let mut doc = text_page("BT /F1 10 Tf 72 700 Td (First paragraph) Tj 0 -40 Td (Second paragraph) Tj ET"); @@ -4715,18 +5285,76 @@ fn a_new_character_comes_from_the_source_fonts_own_embedded_program() { } } +#[test] +fn a_shaped_run_is_one_text_object_with_each_glyph_where_shaping_put_it() { + let inter = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (Stable text) Tj ET"); + let resource = text::register_text_font(&mut doc, 0, inter).unwrap(); + // Marks shaped beside their letters (q́, x̣) and one stacked above another (ñ̄). + let typed = "q\u{301}x\u{323} n\u{303}\u{304} Cafe\u{301}"; + let style = text::BlockStyle { width: Some(400.0), resource: Some(resource), ..Default::default() }; + text::rewrite_block(&mut doc, 0, 0, Some(typed), &style).unwrap(); + assert_eq!(text::text_blocks(&reopen(&doc), 0).unwrap()[0].editor_text, typed); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let start = ops.iter().position(|o| o.is("BDC")).unwrap(); + let end = start + ops[start..].iter().position(|o| o.is("EMC")).unwrap(); + let run = &ops[start..end]; + let count = |name: &str| run.iter().filter(|o| o.is(name)).count(); + assert_eq!((count("BT"), count("Tm"), count("Tj")), (1, 1, 0), "{run:?}"); + // Each glyph's origin, read back with the text-space displacements of ISO 32000-1 §9.4.4. + let font = run.iter().find(|o| o.is("Tf")).unwrap(); + let (resource, size) = (font.operands[0].as_name().unwrap().to_vec(), font.operands[1].as_f64().unwrap()); + let page = pdfcraft_model::pages(&doc).remove(0); + let resources = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone(); + let fonts = doc.resolve(resources.get(b"Font").unwrap()).as_dict().unwrap().clone(); + let metrics = pdfcraft_fonts::pdf::Metrics::from_dict(&doc, doc.resolve(fonts.get(&resource).unwrap()).as_dict().unwrap()); + let (mut x, mut rise, mut drawn) = (0.0, 0.0, Vec::new()); + for op in run { + match op.op.as_slice() { + b"Ts" => rise = op.operands[0].as_f64().unwrap(), + b"Tc" | b"Tw" => assert_eq!(op.operands[0].as_f64(), Some(0.0), "{run:?}"), + b"Tz" => assert_eq!(op.operands[0].as_f64(), Some(100.0), "{run:?}"), + b"TJ" => { + for item in op.operands[0].as_array().unwrap() { + match item.as_string() { + Some(s) => { + for code in s.bytes.chunks(2) { + drawn.push((x, rise)); + x += metrics.width(u32::from(u16::from_be_bytes([code[0], code[1]]))) * size; + } + } + None => x -= item.as_f64().unwrap() / 1000.0 * size, + } + } + } + _ => {} + } + } + let shaped = pdfcraft_fonts::source_shaping::shape(inter, typed).unwrap(); + assert_eq!(drawn.len(), shaped.glyphs.len()); + assert!(shaped.glyphs.iter().any(|g| g.position[1] != 0.0), "a mark is raised"); + for (glyph, (x, y)) in shaped.glyphs.iter().zip(&drawn) { + assert!((glyph.position[0] * size - x).abs() < 1e-3 && (glyph.position[1] * size - y).abs() < 1e-3, "{glyph:?} at {x}, {y}"); + } + assert_eq!(rise, 0.0, "the rise is reset after the run"); +} + #[test] fn a_subset_program_is_not_trusted_for_new_characters_and_the_error_names_the_character() { + let mut doc = embedded_program_page("BT /F1 12 Tf 72 700 Td (Stable text) Tj ET", "ABCDEF+Inter-Regular"); + // The subset's own program is not trusted for a character: a fallback face draws it through a + // generated font instead (the subset may map a code whose outline was dropped). The Japanese + // sans face leads the fallbacks when built with craft-fonts; the bundled Inter face otherwise. + let edit = text::rewrite_block(&mut doc, 0, 0, Some("Stable tőxt"), &text::BlockStyle::default()).unwrap(); + let face = pdfcraft_fonts::document_japanese_font_for_style(false, false).map_or("Inter", |f| f.family); + assert_eq!(edit.substituted, Some(format!("{face} Type3"))); + assert_eq!(text::text_blocks(&reopen(&doc), 0).unwrap()[0].editor_text, "Stable tőxt"); + // A character no fallback face has is refused, named, and left as it was. let mut doc = embedded_program_page("BT /F1 12 Tf 72 700 Td (Stable text) Tj ET", "ABCDEF+Inter-Regular"); let before = page_content_bytes(&doc, 0); - let result = text::rewrite_block(&mut doc, 0, 0, Some("Stable tőxt"), &text::BlockStyle::default()); - if pdfcraft_fonts::document_japanese_font().is_some() { - eprintln!("built with craft-fonts: the fallback font draws the new character"); - assert!(result.is_ok(), "{result:?}"); - return; - } + let result = text::rewrite_block(&mut doc, 0, 0, Some("Stable กtext"), &text::BlockStyle::default()); let Err(EditError::Invalid(msg)) = result else { panic!("expected a clear error, got {result:?}") }; - assert!(msg.contains("ő (U+0151)") && msg.contains("Load font") && !msg.contains("Japanese"), "{msg}"); + assert!(msg.contains("ก (U+0E01)") && msg.contains("Load font") && !msg.contains("Japanese"), "{msg}"); assert_eq!(page_content_bytes(&doc, 0), before); } @@ -5144,6 +5772,23 @@ fn flattening_comments_keeps_annotations_that_are_not_comments() { assert_eq!(left(&reopen(&doc)), without_widget, "flattening fields touches no annotation that isn't a widget"); } +/// A footnote marker (`Ts` text rise, a smaller `Tf` size) does not split the paragraph around it +/// into separate editable zones (#3252): on `main` this read as three blocks — the text up to the +/// marker, the marker alone, and the text after it — because the row-joining baseline tolerance +/// was scaled by the smaller of the two runs' sizes, far tighter than a typical superscript's rise. +#[test] +fn a_superscript_footnote_marker_stays_in_its_paragraphs_row() { + let doc = text_page( + "BT /F1 12 Tf 72 700 Td (and A.) Tj 0 -14 Td (Edward Siecienski) Tj 4 Ts /F1 7.2 Tf (3) Tj 0 Ts /F1 12 Tf (. More words continue the paragraph right after the marker.) Tj ET", + ); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.len(), 4, "the marker is its own line, raised and smaller: {lines:?}"); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(blocks.len(), 1, "one paragraph, not split at the marker: {blocks:?}"); + assert_eq!(blocks[0].rows.len(), 2, "two visual rows, the second carrying the marker"); + assert_eq!(blocks[0].text, "and A. Edward Siecienski3. More words continue the paragraph right after the marker.", "{:?}", blocks[0]); +} + /// A page whose text is a clipping mask (`mode` Tr) with a fill painted through it. fn clipping_text_page(mode: i64) -> Document { text_page(&format!("q\nBT {mode} Tr /F1 48 Tf 40 60 Td (MASK) Tj ET\n1 0 0 rg 0 0 300 150 re f\nQ\n")) @@ -5218,3 +5863,495 @@ fn a_clipping_copy_on_the_same_baseline_is_refused() { assert!(matches!(&block, Err(EditError::Invalid(m)) if m.contains("clipping path")), "{block:?}"); assert_eq!(text::text_lines(&doc, 0).unwrap().len(), 2, "refused means untouched"); } + +/// #2512: a session's drafts are laid out from the document as it was opened, never from each +/// other, and its commit writes what a one-shot rewrite writes, whatever was laid out before. +#[test] +fn a_text_session_commits_what_a_rewrite_writes_whatever_it_laid_out_before() { + let page = "BT /F1 12 Tf 14 TL 72 700 Td (The first row of a paragraph) Tj T* (and its second row here.) Tj ET"; + let bold = BlockStyle { bold: Some(true), ..Default::default() }; + let drafts: [(&str, &BlockStyle); 4] = [ + ("The first row of a paragraph\nand its second row here, with more words to wrap it onto a third row", &BlockStyle::default()), + ("Short", &bold), + ("The first row\nsecond", &BlockStyle::default()), + ("The first row of a paragraph\nand its second row here.", &bold), + ]; + let doc = text_page(page); + let session = text::TextSession::open(&doc, 0, 0).unwrap(); + assert_eq!(session.source().editor_text, "The first row of a paragraph\nand its second row here."); + for (text, style) in drafts { + let fresh = text::text_edit_layout(&text_page(page), 0, 0, text, style).unwrap(); + assert_eq!(session.layout(text, style).unwrap(), fresh, "{text:?} is laid out from the opened page"); + } + let (text, style) = drafts[0]; + let mut by_session = doc.clone(); + session.commit(&mut by_session, Some(text), style).unwrap(); + let mut by_rewrite = text_page(page); + text::rewrite_block(&mut by_rewrite, 0, 0, Some(text), style).unwrap(); + assert_eq!( + pdfcraft_cos::write_full(&by_session, &SaveOptions::default()).unwrap(), + pdfcraft_cos::write_full(&by_rewrite, &SaveOptions::default()).unwrap(), + "the commit writes what a rewrite writes" + ); + // A document changed since the session opened isn't written to. + let before = pdfcraft_cos::write_full(&by_session, &SaveOptions::default()).unwrap(); + let refused = session.commit(&mut by_session, Some("Again"), &BlockStyle::default()); + assert!(matches!(&refused, Err(EditError::Invalid(m)) if m.contains("changed while it was being edited")), "{refused:?}"); + assert_eq!(pdfcraft_cos::write_full(&by_session, &SaveOptions::default()).unwrap(), before); + assert!(session.is_current(&doc) && !session.is_current(&by_session)); +} + +/// A one-page document whose resources have `/GS0` (30 % opacity) and `/L1`, a layer that is OFF. +fn row_state_page(content: &str) -> Document { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R] /D << /OFF [5 0 R] /Order [5 0 R] >> >> >>".into(), + "<< /Type /Pages /Kids [6 0 R] /Count 1 >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /ExtGState /ca 0.3 /CA 0.3 >>".into(), + "<< /Type /OCG /Name (Hidden layer) >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 7 0 R /Resources << /Font << /F1 3 0 R >> /ExtGState << /GS0 4 0 R /GS1 << /Type /ExtGState /ca 0.3 /CA 0.3 /BM /Normal >> /GS2 << /Type /ExtGState /ca 1 /CA 1 >> >> /Properties << /L1 5 0 R >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + 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{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +/// Each shown string on page 0 with the state it is drawn in: inside a `/OC` marked-content +/// sequence, after `/GS0 gs`, and its horizontal scale (percent). +fn shown_states(doc: &Document) -> Vec<(String, bool, bool, f64)> { + let joined = streams(doc, 0).join("\n"); + let (mut oc, mut marks) = (0usize, Vec::new()); + let (mut gs, mut tz, mut stack) = (false, 100.0, Vec::new()); + let mut out = Vec::new(); + for op in pdfcraft_content::parse(joined.as_bytes()).ops { + match op.op.as_slice() { + b"BDC" | b"BMC" => { + let layer = op.name(0) == Some(b"OC".as_slice()); + oc += usize::from(layer); + marks.push(layer); + } + b"EMC" => oc -= usize::from(marks.pop().unwrap()), + b"q" => stack.push((gs, tz)), + b"Q" => (gs, tz) = stack.pop().unwrap(), + b"gs" => gs |= op.name(0) == Some(b"GS0".as_slice()), + b"Tz" => tz = op.num(0).unwrap(), + b"Tj" | b"'" => { + let Some(Object::String(s)) = op.operands.last() else { continue }; + if !s.bytes.is_empty() { + out.push((String::from_utf8_lossy(&s.bytes).into_owned(), oc > 0, gs, tz)); + } + } + _ => {} + } + } + out +} + +/// #3115: a row drawn in a hidden layer, at 30 % opacity or at 80 % horizontal scale keeps that +/// state when another row of the same paragraph is edited; the edited row doesn't take it on. +#[test] +fn editing_a_row_keeps_the_layer_opacity_and_scale_of_the_rows_around_it() { + let row1 = "BT /F1 12 Tf 72 700 Td (Hello world, this is the first line) Tj ET\n"; + let row2 = "BT /F1 12 Tf 72 686 Td (and the second) Tj ET"; + let pages = [ + (format!("{row1}/OC /L1 BDC {row2} EMC"), (true, false, 100.0)), + (format!("{row1}q /GS0 gs {row2} Q"), (false, true, 100.0)), + ("BT /F1 12 Tf 72 700 Td (Hello world, this is the first line) Tj 80 Tz 0 -14 Td (and the second) Tj ET".to_string(), (false, false, 80.0)), + (format!("{row1}{row2}"), (false, false, 100.0)), + ]; + for (n, (content, (oc, gs, tz))) in pages.iter().enumerate() { + let mut doc = row_state_page(content); + let blocks = text::text_blocks(&doc, 0).unwrap(); + let block = blocks.iter().position(|b| b.text.starts_with("Hello")).unwrap(); + let text = blocks[block].text.replacen("Hello", "Jello", 1); + text::replace_block(&mut doc, 0, block, &text).unwrap(); + let shown = shown_states(&reopen(&doc)); + let find = |needle: &str| shown.iter().filter(|s| s.0.contains(needle)).cloned().collect::>(); + let first = find("Jello"); + assert_eq!(first.len(), 1, "page {}: {shown:?}", n + 1); + assert_eq!((first[0].1, first[0].2, first[0].3), (false, false, 100.0), "page {}: the edited row keeps its own state: {shown:?}", n + 1); + let second = find("and the second"); + assert_eq!(second.len(), 1, "page {}: {shown:?}", n + 1); + assert_eq!((second[0].1, second[0].2, second[0].3), (*oc, *gs, *tz), "page {}: the other row keeps its state: {shown:?}", n + 1); + } + // The control: rows in the same state are still one paragraph. + let blocks = text::text_blocks(&row_state_page(&pages[3].0), 0).unwrap(); + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0].text, "Hello world, this is the first line and the second"); +} + +/// What keeps rows apart is the graphics state they are drawn in, not the `gs` operators +/// before them: a repeated `gs` without q/Q, or another name with the same settings, is still one +/// paragraph, and a `gs` that changes the opacity or puts it back is not. +#[test] +fn rows_under_repeated_or_equal_gs_states_stay_one_paragraph() { + let row1 = "BT /F1 12 Tf 72 700 Td (Hello world, this is the first line) Tj ET\n"; + let row2 = "BT /F1 12 Tf 72 686 Td (and the second) Tj ET"; + let paragraphs = |content: String| text::text_blocks(&row_state_page(&content), 0).unwrap().len(); + assert_eq!(paragraphs(format!("/GS0 gs {row1}/GS0 gs {row2}")), 1, "the same name twice"); + assert_eq!(paragraphs(format!("/GS0 gs {row1}/GS0 gs /GS0 gs {row2}")), 1, "the same name three times"); + assert_eq!(paragraphs(format!("/GS0 gs {row1}/GS1 gs {row2}")), 1, "two names, the same settings"); + assert_eq!(paragraphs(format!("/GS2 gs {row1}{row2}")), 1, "settings that are the defaults"); + assert_eq!(paragraphs(format!("{row1}/GS0 gs {row2}")), 2, "another opacity"); + assert_eq!(paragraphs(format!("/GS0 gs {row1}/GS2 gs {row2}")), 2, "the opacity put back"); +} + +/// A page of three vertical (`Identity-V`) columns at 20 pt: `ABCDEF` at x = 500, the same +/// text 30 pt to its left, and `ABCD` far away. Synthetic font: no font program is needed. +fn vertical_columns_page() -> Document { + vertical_page( + "BT /V1 20 Tf 500 720 Td <004100420043004400450046> Tj ET\nBT /V1 20 Tf 470 720 Td <004100420043004400450046> Tj ET\nBT /V1 20 Tf 300 720 Td <0041004200430044> Tj ET", + ) +} + +/// A page drawing `content` with the vertical font `/V1` (as in [`vertical_columns_page`]) and +/// Helvetica `/F1`. +fn vertical_page(content: &str) -> Document { + let mut doc = text_page(content); + let mut font = Dict::from_iter([ + (b"Type".to_vec(), Object::name("Font")), + (b"Subtype".to_vec(), Object::name("Type0")), + (b"BaseFont".to_vec(), Object::name("SyntheticCID")), + (b"Encoding".to_vec(), Object::name("Identity-V")), + ]); + let unicode = doc.add(Object::Stream(Stream::flate( + Dict::new(), + b"1 begincodespacerange <0000> endcodespacerange 6 beginbfchar <0041> <0041> <0042> <0042> <0043> <0043> <0044> <0044> <0045> <0045> <0046> <0046> endbfchar", + ))); + font.set(b"ToUnicode".to_vec(), Object::Ref(unicode)); + font.set( + b"DescendantFonts".to_vec(), + Object::Array(vec![Object::Dict(Dict::from_iter([ + (b"Subtype".to_vec(), Object::name("CIDFontType2")), + (b"DW".to_vec(), Object::Int(1000)), + ]))]), + ); + let p = pdfcraft_model::pages(&doc)[0].obj; + let helvetica = Dict::from_iter([ + (b"Type".to_vec(), Object::name("Font")), + (b"Subtype".to_vec(), Object::name("Type1")), + (b"BaseFont".to_vec(), Object::name("Helvetica")), + (b"Encoding".to_vec(), Object::name("WinAnsiEncoding")), + ]); + let fonts = Dict::from_iter([(b"V1".to_vec(), Object::Dict(font)), (b"F1".to_vec(), Object::Dict(helvetica))]); + let resources = Dict::from_iter([(b"Font".to_vec(), Object::Dict(fonts))]); + doc.update_dict(p, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap(); + doc +} + +#[test] +fn vertical_text_lines_are_columns_and_editing_one_keeps_its_neighbour() { + let doc = vertical_columns_page(); + let lines = text::text_lines(&doc, 0).unwrap(); + let first = lines.iter().find(|l| l.baseline()[0] == 500.0).unwrap(); + let r = first.rect; + // Six glyphs, each 20 pt wide and advancing 20 pt down, centred on x = 500. + assert!((r[0] - 490.0).abs() < 0.5 && (r[2] - 510.0).abs() < 0.5, "{r:?}"); + assert!((r[1] - 600.0).abs() < 0.5 && (r[3] - 720.0).abs() < 0.5, "{r:?}"); + for edit in [0, 1] { + let mut doc = vertical_columns_page(); + let lines = text::text_lines(&doc, 0).unwrap(); + if edit == 0 { + let line = lines.iter().position(|l| l.baseline()[0] == 500.0).unwrap(); + text::replace_line(&mut doc, 0, line, "ABCDEA").unwrap(); + } else { + let blocks = text::text_blocks(&doc, 0).unwrap(); + let block = blocks.iter().position(|b| b.lines.iter().any(|&i| lines[i].baseline()[0] == 500.0)).unwrap(); + assert_eq!(blocks[block].lines.len(), 1, "{blocks:?}"); + text::replace_block(&mut doc, 0, block, "ABCDEA").unwrap(); + } + let after = text::text_lines(&doc, 0).unwrap(); + let texts: Vec<(f64, &str)> = after.iter().map(|l| (l.baseline()[0], l.text.as_str())).collect(); + assert!(texts.contains(&(500.0, "ABCDEA")), "{texts:?}"); + assert!(texts.contains(&(470.0, "ABCDEF")), "{texts:?}"); + assert!(texts.contains(&(300.0, "ABCD")), "{texts:?}"); + } +} + +/// Gives `/V1` an embedded CMap that uses itself: which way its glyphs advance can't be known. +fn make_cmap_cyclic(doc: &mut Document) { + let cmap = doc.add(Object::Null); + doc.set( + cmap, + Object::Stream(Stream::flate( + Dict::from_iter([(b"UseCMap".to_vec(), Object::Ref(cmap))]), + b"1 begincodespacerange <0000> endcodespacerange", + )), + ); + let p = pdfcraft_model::pages(doc)[0].obj; + doc.update_dict(p, |d| { + if let Some(Object::Dict(res)) = d.get_mut(b"Resources") + && let Some(Object::Dict(fonts)) = res.get_mut(b"Font") + && let Some(Object::Dict(font)) = fonts.get_mut(b"V1") + { + font.set(b"Encoding".to_vec(), Object::Ref(cmap)); + } + }) + .unwrap(); +} + +#[test] +fn text_whose_cmap_writing_mode_cannot_be_read_is_not_edited() { + let mut doc = vertical_columns_page(); + make_cmap_cyclic(&mut doc); + let error = text::replace_line(&mut doc, 0, 0, "ABCDEA").unwrap_err(); + assert!(error.to_string().contains("cyclic"), "{error}"); +} + +#[test] +fn a_cyclic_cmap_falls_back_to_horizontal_and_only_refuses_its_own_lines() { + let mut doc = vertical_page("BT /V1 20 Tf 100 700 Td <00410042> Tj ET\nBT /F1 12 Tf 100 600 Td (Plain) Tj ET"); + make_cmap_cyclic(&mut doc); + // The page is still listed: the broken font's text as horizontal, the rest untouched. + let lines = text::text_lines(&doc, 0).unwrap(); + let texts: Vec<&str> = lines.iter().map(|l| l.text.as_str()).collect(); + assert_eq!(texts, ["AB", "Plain"], "{texts:?}"); + assert!(lines[0].rect[2] - lines[0].rect[0] > 30.0 && lines[0].rect[3] - lines[0].rect[1] < 30.0, "{:?}", lines[0].rect); + assert_eq!(text::reading_blocks(&doc, 0).unwrap().len(), 2); + assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2); + // Only the line in the broken font is refused. + let error = text::replace_line(&mut doc.clone(), 0, 0, "BA").unwrap_err(); + assert!(error.to_string().contains("cyclic"), "{error}"); + let block = text::text_blocks(&doc, 0).unwrap().iter().position(|b| b.text == "AB").unwrap(); + let error = text::replace_block(&mut doc.clone(), 0, block, "BA").unwrap_err(); + assert!(error.to_string().contains("cyclic"), "{error}"); + text::replace_line(&mut doc, 0, 1, "Changed").unwrap(); + let texts: Vec = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect(); + assert_eq!(texts, ["AB", "Changed"]); +} + +#[test] +fn vertical_text_drawn_one_glyph_per_tj_is_one_line() { + // One text object, a `Tj` per glyph; and a column with a text object per glyph. + let doc = vertical_page( + "BT /V1 20 Tf 500 720 Td <0041> Tj <0042> Tj <0043> Tj ET\n\ + BT /V1 20 Tf 1 0 0 1 470 720 Tm <0041> Tj ET\nBT /V1 20 Tf 1 0 0 1 470 700 Tm <0042> Tj ET\nBT /V1 20 Tf 1 0 0 1 470 680 Tm <0043> Tj ET", + ); + let lines = text::text_lines(&doc, 0).unwrap(); + let columns: Vec<(f64, &str)> = lines.iter().map(|l| (l.baseline()[0], l.text.as_str())).collect(); + assert_eq!(columns, [(500.0, "ABC"), (470.0, "ABC")], "{columns:?}"); + for line in &lines { + let r = line.rect; + assert!((r[1] - 660.0).abs() < 0.5 && (r[3] - 720.0).abs() < 0.5, "{r:?}"); + } + assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 2); +} + +/// Two genuinely stacked rows of ordinary body text must not join into one row just because the +/// first is narrow enough to put the second's start within the #3252 tolerance band: here the +/// second row starts 1.4pt to the left of where the first ends (so the two overlap a little, +/// as compressed text or a tight column can) and 5pt below its baseline — inside the widened 50% +/// tolerance but outside the original 20% one. They stay two rows (and two paragraphs). +#[test] +fn stacked_rows_that_merely_overlap_do_not_join_into_one_row() { + let doc = text_page("BT /F1 12 Tf 72 700 Td (A compressed line of body text here) Tj 190 -5 Td (Second stacked line of body text) Tj ET"); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.len(), 2); + let gap = lines[1].rect[0] - lines[0].rect[2]; + assert!((-2.4..0.0).contains(&gap), "the rows overlap a little, within the old tolerance band: {gap}"); + let baseline_shift = lines[0].rect[3] - lines[1].rect[3]; + assert!((2.4..6.0).contains(&baseline_shift), "between the old and new vertical tolerance: {baseline_shift}"); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(blocks.len(), 2, "two separate rows stay two paragraphs, not one: {blocks:?}"); +} + +/// Two equal-size rows with no horizontal overlap must not join into one row either (#3252): +/// the wider tolerance is for a run distinctly smaller or larger than its neighbour (a +/// super/subscript), not for two ordinary body-text rows that happen to sit close enough +/// vertically — here a 5pt baseline shift, inside the widened 50% tolerance but outside the +/// original 20% one, with the second row starting just past where the first ends. +#[test] +fn equal_size_rows_need_the_tighter_tolerance_even_without_overlap() { + let doc = text_page("BT /F1 12 Tf 72 700 Td (Short line one) Tj 75 -5 Td (Second line two here) Tj ET"); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.len(), 2); + assert_eq!(lines[0].size, lines[1].size, "same size: no super/subscript here"); + let gap = lines[1].rect[0] - lines[0].rect[2]; + assert!(gap >= 0.0, "no horizontal overlap: {gap}"); + let baseline_shift = lines[0].rect[3] - lines[1].rect[3]; + assert!((2.4..6.0).contains(&baseline_shift), "between the old and new vertical tolerance: {baseline_shift}"); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(blocks.len(), 2, "two separate rows stay two paragraphs, not one: {blocks:?}"); +} + +/// A `TJ` with a surplus array operand (`[(FIRSTARR)] [(LASTARR)] TJ`) is malformed, but the +/// operator still takes the one immediately before it, as the renderer, MuPDF and pdftotext do +/// (#3569) -- not the first, which this reader took before, showing a word the page never draws. +#[test] +fn a_tj_with_a_surplus_array_reads_the_last_one() { + let doc = text_page("BT /F1 24 Tf 20 100 Td [(FIRSTARR)] [(LASTARR)] TJ ET"); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.len(), 1); + assert_eq!(lines[0].text, "LASTARR", "the operand immediately before TJ, as the page draws: {lines:?}"); +} + +/// A producer wrote `0.0001 Tc` on one word of a line (Word through macOS Quartz). That spacing +/// can't be seen, so the word stays on its line; spacing that can be seen still keeps runs apart. +#[test] +fn a_word_with_invisible_extra_spacing_stays_on_its_line() { + let page = |spacing: &str| { + text_page(&format!( + "BT /F1 12 Tf 1 0 0 1 72 700 Tm (for company ) Tj ET \ + BT /F1 12 Tf 1 0 0 1 141.36 700 Tm (A) Tj ET \ + BT {spacing} Tc /F1 12 Tf 1 0 0 1 149.364 700 Tm (EROSOL) Tj ET" + )) + }; + let blocks = text::text_blocks(&page("0.0001"), 0).unwrap(); + assert_eq!(blocks.iter().map(|b| b.text.as_str()).collect::>(), ["for company AEROSOL"]); + + let mut doc = page("0.0001"); + text::rewrite_block(&mut doc, 0, 0, Some("for company AEROSOL LLC"), &BlockStyle::default()).unwrap(); + let lines = text::text_lines(&reopen(&doc), 0).unwrap(); + assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::>(), ["for company AEROSOL LLC"]); + assert!((lines[0].rect[0] - 72.0).abs() < 0.01, "{:?}", lines[0].rect); + + let blocks = text::text_blocks(&page("0.5"), 0).unwrap(); + assert_eq!(blocks.iter().map(|b| b.text.as_str()).collect::>(), ["for company A", "EROSOL"]); +} + +/// #3573: past the 256-level marked-content cap every `EMC` must still close its own sequence, +/// so `/ActualText` nested that deep is neither dropped nor applied to too little text. +#[test] +fn actual_text_pairs_past_the_marked_content_cap() { + let span = "/Span <> BDC BT /F1 24 Tf 72 700 Td (abc) Tj ET EMC"; + let outer = "/Span <> BDC BT /F1 24 Tf 72 700 Td /Y BMC (abc) Tj EMC (def) Tj ET EMC"; + for (content, expected) in [ + (span.to_string(), "REPLACED"), + (format!("{}{span}{}", "/X BMC ".repeat(256), " EMC".repeat(256)), "REPLACED"), + (format!("{}{span}{}", "/X BMC ".repeat(1000), " EMC".repeat(1000)), "REPLACED"), + (outer.to_string(), "OUTER"), + (format!("{}{outer}{}", "/X BMC ".repeat(255), " EMC".repeat(255)), "OUTER"), + ] { + let doc = text_page(&content); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::>(), [expected]); + let reading: Vec = text::reading_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(reading, [expected]); + } +} + +/// #2992: the colour a page is painted with before anything else is its background, for exports +/// that keep a page colour. Edit a PDF ▸ Background's fill is found under the text it goes +/// behind; a page that paints anything else first, a fill that doesn't cover the page, a +/// translucent or clipped one, or a colour that isn't plain gray, RGB or CMYK has none. +#[test] +fn a_page_painted_first_with_a_full_page_fill_has_that_background() { + let mut doc = fixture(); + assert_eq!(page_background(&doc, 0).unwrap(), None, "text first"); + add_background(&mut doc, &[0, 1], &Background { color: [0.0, 0.5, 0.5], opacity: 1.0, ..Background::default() }, false).unwrap(); + let doc = reopen(&doc); + let teal = page_background(&doc, 0).unwrap().expect("Background's fill"); + assert!(teal.iter().zip([0.0, 0.5, 0.5]).all(|(a, b)| (a - b).abs() < 1e-6), "{teal:?}"); + assert!(page_background(&doc, 1).unwrap().is_some(), "on a turned page too"); + assert_eq!(page_background(&doc, 2).unwrap(), None, "a page with no content"); + assert!(page_background(&doc, 9).is_err()); + + let page = |content: &str, resources: &str| { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /CropBox [0 0 500 700] /Contents 4 0 R /Resources << {resources} >> >>"), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(b"xref\n0 5\n0000000000 65535 f \n"); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n").as_bytes()); + page_background(&Document::open(Arc::new(out)).unwrap(), 0).unwrap() + }; + let gs = "/ExtGState << /Half << /ca 0.5 >> /Mul << /BM /Multiply >> /Full << /ca 1 /SMask /None >> >>"; + assert_eq!(page("1 g 0 0 600 800 re f", ""), Some([1.0; 3]), "a producer's white page fill"); + assert_eq!(page("0 0 1 0 k 0 0 500 700 re f", ""), Some([1.0, 1.0, 0.0]), "CMYK, covering the crop box"); + assert_eq!(page("/DeviceRGB cs 1 0 0 sc 0 0 500 700 re f", ""), Some([1.0, 0.0, 0.0]), "cs and sc"); + assert_eq!(page("/Full gs q 2 0 0 2 0 0 cm 0 1 0 rg 0 0 300 400 re f Q", gs), Some([0.0, 1.0, 0.0]), "under a cm"); + assert_eq!(page("1 0 0 rg 0 0 300 800 re f", ""), None, "half the page"); + assert_eq!(page("1 0 0 rg 0 0 300 800 re 300 0 300 800 re f", ""), None, "two halves are not one covering rectangle"); + assert_eq!(page("1 0 0 rg 0 0 300 800 re 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "one of the rectangles covers it"); + // #2994 review: a second rectangle that cuts a hole: any one under even-odd, one wound the + // other way under nonzero. + assert_eq!(page("1 0 0 rg 0 0 600 800 re 100 100 200 200 re f*", ""), None, "even-odd hole"); + assert_eq!(page("1 0 0 rg 0 0 600 800 re 300 100 -200 200 re f", ""), None, "nonzero, reversed inner rectangle"); + assert_eq!(page("1 0 0 rg 0 0 600 800 re 100 100 200 200 re f", ""), Some([1.0, 0.0, 0.0]), "nonzero, same winding: no hole"); + assert_eq!(page("1 0 0 rg q -1 0 0 1 600 0 cm 0 0 600 800 re Q 100 100 200 200 re f", ""), None, "reversed by a mirroring cm"); + assert_eq!(page("/Half gs 1 0 0 rg 0 0 600 800 re f", gs), None, "translucent"); + assert_eq!(page("/Mul gs 1 0 0 rg 0 0 600 800 re f", gs), None, "blended"); + assert_eq!(page("0 0 100 100 re W n 1 0 0 rg 0 0 600 800 re f", ""), None, "clipped to a corner"); + assert_eq!(page("0 0 600 800 re W n 1 0 0 rg 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "a page-sized clip"); + assert_eq!(page("/Pattern cs /P1 scn 0 0 600 800 re f", ""), None, "a pattern"); + assert_eq!(page("BT ET 0 0 600 800 re S 1 0 0 rg 0 0 600 800 re f", ""), None, "a stroke first"); + assert_eq!(page("0 0 m 600 0 l 600 800 l 0 800 l h f", ""), None, "a path that isn't a rectangle"); + assert_eq!(page("1 0 0 rg 0 0 600 800 re 100 100 m 200 100 l 200 200 l h f*", ""), None, "a page fill with a hole cut in it"); + // #2994 review: a rectangle turned 45° about the crop box's centre covers it only if the crop + // box's corners are inside it; its bounding box covering the crop box isn't enough. This one + // reaches 500 from the centre along each axis (its box covers the 500 × 700 crop box) but + // 354 along the diagonals, short of the corners. One reaching 707 covers them. + let turned = "0.70710678 0.70710678 -0.70710678 0.70710678 250 350 cm"; + assert_eq!(page(&format!("1 0 0 rg {turned} -353.553 -353.553 707.107 707.107 re f"), ""), None, "a turned fill short of the corners"); + assert_eq!(page(&format!("1 0 0 rg {turned} -500 -500 1000 1000 re f"), ""), Some([1.0, 0.0, 0.0]), "a turned fill over them"); + // The same for a clip. + assert_eq!(page(&format!("{turned} -353.553 -353.553 707.107 707.107 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""), None, "a turned clip"); + assert_eq!( + page(&format!("{turned} -500 -500 1000 1000 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""), + Some([1.0, 0.0, 0.0]), + "a turned clip over the corners" + ); + // Clips add up: two that each cover the page leave it covered, one that doesn't among them + // doesn't. + assert_eq!(page("0 0 600 800 re W n 0 0 550 750 re W n 1 0 0 rg 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "two covering clips"); + assert_eq!(page("0 0 600 800 re W n 0 0 300 750 re W n 1 0 0 rg 0 0 600 800 re f", ""), None, "the second clip doesn't cover"); + // Past 64 clipping rectangles the clip counts as unknown. + let clips = "0 0 600 800 re W n ".repeat(65); + assert_eq!(page(&format!("{clips}1 0 0 rg 0 0 600 800 re f"), ""), None, "too many clips to follow"); + // A rectangle drawn the other way round (negative width) covers the page all the same. + assert_eq!(page("1 0 0 rg 600 0 -600 800 re f", ""), Some([1.0, 0.0, 0.0]), "drawn clockwise"); + // The crop box counts as covered within half a percent of its size (2.5 × 3.5 here), the + // edge of that included. + assert_eq!(page("1 0 0 rg 2 3 496 694 re f", ""), Some([1.0, 0.0, 0.0]), "within the tolerance"); + assert_eq!(page("1 0 0 rg 2.5 3.5 495 693 re f", ""), Some([1.0, 0.0, 0.0]), "on the tolerance's edge"); + assert_eq!(page("1 0 0 rg 3 4 494 692 re f", ""), None, "past the tolerance"); +} + +#[test] +fn reading_keeps_dotted_leader_entries_whole_and_in_order() { + let mut content = String::new(); + for (title, page, y) in [("Art.1. Scope", "3", 700), ("Art.2. Eligibility", "7", 680), ("Art.3. Application", "12", 660)] { + content.push_str(&format!( + "BT /F1 12 Tf 72 {y} Td ({title}) Tj ET \ + BT /F1 12 Tf 250 {y} Td (....................) Tj ET \ + BT /F1 12 Tf 500 {y} Td ({page}) Tj ET\n" + )); + } + content.push_str("BT /F1 12 Tf 72 640 Td (Appendix) Tj ET BT /F1 12 Tf 250 640 Td (\\205\\205\\205) Tj ET BT /F1 12 Tf 500 640 Td (iv) Tj ET\n"); + content.push_str("BT /F1 12 Tf 72 600 Td (Ordinary body text.) Tj ET BT /F1 12 Tf 72 582 Td (Still the same paragraph.) Tj ET"); + let doc = text_page(&content); + let blocks: Vec<_> = text::reading_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!( + blocks, + [ + "Art.1. Scope .................... 3", + "Art.2. Eligibility .................... 7", + "Art.3. Application .................... 12", + "Appendix ……… iv", + "Ordinary body text. Still the same paragraph.", + ] + ); +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 267259217..93a5eec99 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -22,7 +22,10 @@ use crate::EditError; #[path = "text_regions.rs"] mod regions; -use regions::{group_blocks, rewrite_styled_block}; +#[path = "text_session.rs"] +mod session; +use regions::{group_blocks, is_centred, rewrite_styled_block}; +pub use session::TextSession; /// One line of existing text. #[derive(Clone, Debug, PartialEq)] @@ -62,6 +65,8 @@ pub struct TextLine { origin: Origin, /// Explicit marked-content scope (when present); distinct scopes must not be guessed into one paragraph. scope: Option, + /// The layers and graphics state parameters it is drawn in; see [`Paint`]. + paint: Paint, /// Right-to-left `/ActualText` already supplies typing order; glyph text still needs bidi /// recovery. Left-to-right ActualText (e.g. a Latin ligature) joins its line as usual. logical_order: bool, @@ -72,9 +77,17 @@ pub struct TextLine { /// does not require one rendering mode, so a line that starts as ink can continue as a mask; /// [`refuse_clipping_text`] reads this rather than the origin's mode alone. clips: bool, + /// Why the line can't be edited: its font's writing mode (its CMap's `WMode`) couldn't be read, + /// so which way its glyphs advance is unknown. The line is listed as horizontal text. + unreadable_mode: Option, } impl TextLine { + /// Refuses editing a line whose font's writing mode couldn't be read (§9.7.4.3). + fn ensure_editable(&self) -> Result<(), EditError> { + self.unreadable_mode.as_ref().map_or(Ok(()), |why| Err(EditError::Invalid(why.clone()))) + } + /// Origin of the source baseline in PDF user space, independent of font ascent/descent. pub fn baseline(&self) -> [f64; 2] { [self.origin.x, self.origin.baseline] @@ -152,11 +165,20 @@ pub struct TextEditLayout { } pub fn text_edit_layout(doc: &Document, page: usize, block: usize, text: &str, style: &BlockStyle) -> Result { - // This runs on every keystroke: read the page once for the paragraphs, the untouched runs - // and the rewrite, which starts from the same unchanged page. - let lines = text_lines(doc, page)?; - let original = group_blocks(&lines); - let source = original.get(block).ok_or(EditError::Invalid("text block does not exist".into()))?; + TextSession::open(doc, page, block)?.layout(text, style) +} + +/// A draft of paragraph `source` (number `block` among the page's `lines`) laid out by the rewrite +/// Apply runs, on a copy of `doc`. +fn layout_draft( + doc: &Document, + page: usize, + lines: &[TextLine], + source: &TextBlock, + block: usize, + text: &str, + style: &BlockStyle, +) -> Result { if text == source.editor_text && *style == BlockStyle::default() { return Ok(TextEditLayout { source_rect: source.rect, draft_rect: Some(source.rect), overflow: false }); } @@ -171,7 +193,7 @@ pub fn text_edit_layout(doc: &Document, page: usize, block: usize, text: &str, s } } let mut draft = doc.clone(); - rewrite_block_from(&mut draft, page, lines, block, Some(text), style, MAX_TYPE3_GLYPHS)?; + rewrite_block_from(&mut draft, page, lines.to_vec(), block, Some(text), style, MAX_TYPE3_GLYPHS)?; let mut rect: Option<[f64; 4]> = None; for line in text_lines(&draft, page)? { if let Some(count) = untouched.get_mut(&key(&line)) @@ -232,6 +254,66 @@ impl TextState { } } +/// What a run is drawn in beyond its text state: the optional content groups of the `/OC` +/// marked-content sequences around it, and the effective graphics state parameters (`gs`) in +/// force. A rewrite writes a paragraph in its first run's place, so runs that differ here are +/// never joined: one in a hidden layer or at another opacity would lose it. +#[derive(Clone, Debug, Default, PartialEq)] +struct Paint { + layers: Vec>, + gs: Gs, +} + +/// The transparency parameters of the graphics state: what the `gs` operators have set, resolved, +/// so a repeated `gs` or two names with the same settings leave it as it was. +#[derive(Clone, Debug, PartialEq)] +struct Gs { + /// `ca`, the fill opacity. + fill_alpha: f64, + /// `CA`, the stroke opacity. + stroke_alpha: f64, + /// `BM`, the blend mode (`Compatible` is `Normal`). + blend: Vec, + /// `SMask`, when it is not `None`: the reference of an indirect soft mask, else the dictionary. + soft_mask: Option, +} + +impl Default for Gs { + fn default() -> Self { + Gs { fill_alpha: 1.0, stroke_alpha: 1.0, blend: b"Normal".to_vec(), soft_mask: None } + } +} + +impl Gs { + /// Applies the parameters a graphics state parameter dictionary sets; the others stay. + fn apply(&mut self, doc: &Document, params: &Dict) { + let num = |key: &[u8]| params.get(key).and_then(|o| doc.resolve(o).as_f64()).filter(|v| v.is_finite()); + if let Some(v) = num(b"ca") { + self.fill_alpha = v; + } + if let Some(v) = num(b"CA") { + self.stroke_alpha = v; + } + if let Some(bm) = params.get(b"BM").map(|o| doc.resolve(o)) { + let first = bm + .as_name() + .map(<[u8]>::to_vec) + .or_else(|| bm.as_array().and_then(|a| a.iter().find_map(|o| doc.resolve(o).as_name().map(<[u8]>::to_vec)))); + if let Some(name) = first { + self.blend = if name == b"Compatible" { b"Normal".to_vec() } else { name }; + } + } + if let Some(mask) = params.get(b"SMask") { + self.soft_mask = match (mask.as_ref(), &*doc.resolve(mask)) { + (_, Object::Name(_)) => None, + (Some(r), _) => Some(Object::Ref(r)), + (None, Object::Dict(d)) => Some(Object::Dict(d.clone())), + (None, _) => None, + }; + } + } +} + #[derive(Clone, Debug, PartialEq)] struct Origin { tm: [f64; 6], @@ -523,9 +605,17 @@ struct Ts { leading: f64, rise: f64, render_mode: i64, + /// The transparency parameters the `gs` operators have set so far (see [`Paint`]). + gs: Gs, + /// The font's vertical advance per unit of font size (`/DW2`'s w1, normally −1) when its + /// CMap writes vertically (`WMode 1`, §9.7.4.3): each glyph then moves the pen down, not right. + vertical: Option, + /// Why the font's writing mode couldn't be read (a broken or cyclic CMap). Its text is then + /// listed as horizontal, but the lines in it are refused for editing. + unreadable: Option>, } -fn content_streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec)> { +pub(crate) fn content_streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec)> { let list: Vec = match page.get(b"Contents") { None => Vec::new(), Some(c) => match &*doc.resolve(c) { @@ -666,15 +756,22 @@ struct Shown { visual_order: bool, decodable: bool, scope: Option, + paint: Paint, logical_order: bool, actual_text: bool, } impl Shown { /// The unit direction of its baseline, where it starts and ends along it, and the baseline's - /// offset across it (for upright text: the user-space x, x and y). + /// offset across it (for upright text: the user-space x, x and y). A vertical run goes along + /// its column instead, the way the pen moves: for upright text down, with the column's x + /// across. fn span(&self) -> ((f64, f64), f64, f64, f64) { - let dir = direction(&self.tm.then(&self.state.ctm)); + let m = self.tm.then(&self.state.ctm); + let dir = match self.state.vertical { + Some(w1) => direction(&Matrix([m.0[2] * w1.signum(), m.0[3] * w1.signum(), 0.0, 0.0, 0.0, 0.0])), + None => direction(&m), + }; let (start, across) = to_frame(dir, (self.start_x, self.baseline)); (dir, start, to_frame(dir, (self.end_x, self.end_y)).0, across) } @@ -689,12 +786,13 @@ struct Pen { across: f64, end: f64, size: f64, + vertical: bool, } impl Pen { fn after(s: &Shown) -> Self { let (dir, _, end, across) = s.span(); - Pen { bt: s.bt, dir, across, end, size: s.size.max(1.0) } + Pen { bt: s.bt, dir, across, end, size: s.size.max(1.0), vertical: s.state.vertical.is_some() } } /// Whether `s` could continue the same line on geometry alone: on the same baseline, starting @@ -703,6 +801,8 @@ impl Pen { let (dir, start, _, across) = s.span(); let gap = start - self.end; let same_dir = (dir.0 - self.dir.0).abs() < 1e-3 && (dir.1 - self.dir.1).abs() < 1e-3; + // A column continues a column (along which the pen moves), a baseline a baseline. + let same_dir = same_dir && self.vertical == s.state.vertical.is_some(); (same_dir && (across - self.across).abs() < self.size * 0.3 && gap > -self.size && gap < self.size * reach, gap) } } @@ -785,6 +885,8 @@ struct Carry { ts: Ts, stack: Vec, marked: Vec>, + /// For each open marked-content sequence, its optional content group when it is `/OC`. + layers: Vec>>, } impl Carry { @@ -801,24 +903,33 @@ impl Carry { leading: 0.0, rise: 0.0, render_mode: 0, + gs: Gs::default(), + vertical: None, + unreadable: None, }; - Carry { ts, stack: Vec::new(), marked: Vec::new() } + Carry { ts, stack: Vec::new(), marked: Vec::new(), layers: Vec::new() } } } +/// A font resource's metrics, fallback face and vertical advance (see [`Ts`]), read once per page. +/// The advance is an error message when the font's writing mode can't be read. +type CachedFont = (Rc, Option, Result, String>); + /// A `Do` met while interpreting: its operator index, XObject name and the state there. type Invocation = (usize, Vec, Ts); fn interpret( doc: &Document, ops: &[Op], - fonts_res: &Dict, - cache: &mut HashMap, (Rc, Option)>, + resources: &Dict, + cache: &mut HashMap, CachedFont>, carry: &mut Carry, mut invoked: Option<&mut Vec>, // `Some(properties)`: read `/ActualText`, looking named property lists up in `properties`. logical_text: Option<&Dict>, -) -> Vec { +) -> Result, EditError> { + let fonts_res = resources.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); + let states = resources.get(b"ExtGState").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).unwrap_or_default(); let mut out: Vec = Vec::new(); let mut ts = carry.ts.clone(); let mut at_bt = (0usize, ts.clone()); @@ -826,14 +937,20 @@ fn interpret( let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY); let mut bt = 0usize; let mut marked = std::mem::take(&mut carry.marked); + let mut layers = std::mem::take(&mut carry.layers); let mut actual: Vec> = Vec::new(); for (i, op) in ops.iter().enumerate() { match op.op.as_slice() { - b"BMC" | b"BDC" if marked.len() < 256 => { + // No depth cap: every `EMC` pops, so a capped push would pair it with the enclosing + // sequence (#3573). Both stacks stay bounded by the number of operators. + b"BMC" | b"BDC" => { // Marked text paragraphs/headings are authoritative boundaries. Ignore // artifacts and inner spans, which inherit their enclosing region. let region = matches!(op.name(0), Some(b"P" | b"H" | b"H1" | b"H2" | b"H3" | b"H4" | b"H5" | b"H6" | b"LI" | b"TD" | b"TH")); marked.push(if region { Some(i) } else { marked.last().copied().flatten() }); + // A layer named in the resources is told apart by its name, an inline one by where it opens. + let layer = op.is("BDC") && op.name(0) == Some(b"OC".as_slice()); + layers.push(layer.then(|| op.operands.get(1).and_then(Object::as_name).map_or_else(|| format!("@{i}").into_bytes(), <[u8]>::to_vec))); // The property list is inline or named in the resources' /Properties (§14.6.2). let list = logical_text.and_then(|properties| match op.operands.get(1) { Some(Object::Name(n)) => properties.get(n).and_then(|o| doc.dict(o)), @@ -847,6 +964,7 @@ fn interpret( } b"EMC" => { marked.pop(); + layers.pop(); if let Some(Some((start, open, logical))) = actual.pop() { let tail = out.split_off(start.min(out.len())); let mut items = tail.into_iter(); @@ -904,14 +1022,21 @@ fn interpret( b"Tf" => { ts.size = op.num(1).unwrap_or(ts.size); if let Some(name) = op.name(0) { - let (m, face) = cache.entry(name.to_vec()).or_insert_with(|| { + let (m, face, vertical) = cache.entry(name.to_vec()).or_insert_with(|| { let dict = fonts_res.get(name).and_then(|f| doc.resolve(f).as_dict().cloned()); let metrics = dict.as_ref().map_or_else(Metrics::fallback, |d| Metrics::from_dict(doc, d)); + let vertical = dict.as_ref().map_or(Ok(None), |d| vertical_advance(doc, d).map_err(|e| e.to_string())); let face = dict.filter(is_type3_fallback).map(|_| metrics.base_font.clone()); - (Rc::new(metrics), face) + (Rc::new(metrics), face, vertical) }); ts.font = Some((name.to_vec(), m.clone())); ts.face = face.clone(); + // Which way the glyphs advance is unknown (§9.7.4.3): list the font's text as + // horizontal and record why, so editing its lines is refused (not the page). + match vertical { + Ok(v) => (ts.vertical, ts.unreadable) = (*v, None), + Err(e) => (ts.vertical, ts.unreadable) = (None, Some(Rc::from(e.as_str()))), + } } } b"Tc" => ts.char_spacing = op.num(0).unwrap_or(0.0), @@ -920,6 +1045,12 @@ fn interpret( b"TL" => ts.leading = op.num(0).unwrap_or(0.0), b"Ts" => ts.rise = op.num(0).unwrap_or(0.0), b"Tr" => ts.render_mode = op.operands.first().and_then(Object::as_int).unwrap_or(-1), + b"gs" => { + let params = op.name(0).and_then(|name| states.get(name)).map(|p| doc.resolve(p)); + if let Some(params) = params.as_deref().and_then(Object::as_dict) { + ts.gs.apply(doc, params); + } + } b"Do" => { if let (Some(list), Some(name)) = (invoked.as_deref_mut(), op.name(0)) { list.push((i, name.to_vec(), ts.clone())); @@ -956,7 +1087,11 @@ fn interpret( let Some((name, m)) = ts.font.clone() else { continue }; // Pieces: strings, and TJ number adjustments. let pieces: Vec = match op.op.as_slice() { - b"TJ" => op.operands.first().and_then(Object::as_array).cloned().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) -- not the first, which a reader counting operands + // from the front of a surplus list would otherwise take. + b"TJ" => op.operands.last().and_then(Object::as_array).cloned().unwrap_or_default(), _ => op.operands.last().cloned().into_iter().collect(), }; // Tc is already included in the pen advance; TJ may encode tracking instead. @@ -966,6 +1101,10 @@ fn interpret( let mut text = String::new(); let mut decodable = true; let mut x_text = 0.0; + // Vertical text (§9.4.4): the pen moves along y instead, and each glyph is centred + // on it horizontally; `half` is half the widest glyph. + let vertical = ts.vertical; + let (mut y_text, mut half) = (0.0, 0.0_f64); // Where each word's glyphs start and end along the run (text space), and whether // the next glyph continues the last word. let mut words: Vec<(f64, f64)> = Vec::new(); @@ -983,6 +1122,21 @@ fn interpret( Some(t) => text.push_str(&t), None => decodable = false, } + let spacing = ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 }; + if let Some(w1) = vertical { + half = half.max((m.width(code) * ts.size * ts.scale).abs() / 2.0); + if !blank { + let ink_end = y_text + w1 * ts.size; + let (lo, hi) = (y_text.min(ink_end), y_text.max(ink_end)); + match words.last_mut() { + Some(word) if in_word => *word = (word.0.min(lo), word.1.max(hi)), + _ => words.push((lo, hi)), + } + } + in_word = !blank; + y_text += w1 * ts.size + spacing; + continue; + } if !blank { let ink_end = x_text + m.width(code) * ts.size * ts.scale; // A negative font size or `Tz` runs the glyphs backwards: @@ -1000,6 +1154,18 @@ fn interpret( } other => { if let Some(n) = other.as_f64() { + if vertical.is_some() { + // Down by n/1000 em, not scaled by `Tz`. + let dy = -n / 1000.0 * ts.size; + if -dy > ts.size * 0.2 && !text.ends_with(' ') { + text.push(' '); + } + if dy.abs() > ts.size.abs() * 0.2 { + in_word = false; + } + y_text += dy; + continue; + } let dx = -n / 1000.0 * ts.size * ts.scale; // A large gap inside TJ reads as a space. if dx - tracking > (ts.size * ts.scale).abs() * 0.2 && !text.ends_with(' ') { @@ -1015,15 +1181,22 @@ fn interpret( } } } - tm = Matrix([1.0, 0.0, 0.0, 1.0, x_text, 0.0]).then(&tm); + tm = Matrix([1.0, 0.0, 0.0, 1.0, x_text, y_text]).then(&tm); let end = tm.then(&ts.ctm).apply(0.0, ts.rise); - // The box: from descent to ascent along the run. - let corners = [ - trm0.apply(0.0, ts.rise + m.descent * ts.size), - trm0.apply(x_text, ts.rise + m.descent * ts.size), - trm0.apply(0.0, ts.rise + m.ascent * ts.size), - trm0.apply(x_text, ts.rise + m.ascent * ts.size), - ]; + // The box from `a` to `b` along the run: from descent to ascent across it, or for + // vertical text, the widest glyph's width centred on the pen. + let span = |a: f64, b: f64| match vertical { + Some(_) => { + [trm0.apply(-half, ts.rise + a), trm0.apply(half, ts.rise + a), trm0.apply(-half, ts.rise + b), trm0.apply(half, ts.rise + b)] + } + None => [ + trm0.apply(a, ts.rise + m.descent * ts.size), + trm0.apply(b, ts.rise + m.descent * ts.size), + trm0.apply(a, ts.rise + m.ascent * ts.size), + trm0.apply(b, ts.rise + m.ascent * ts.size), + ], + }; + let corners = span(0.0, if vertical.is_some() { y_text } else { x_text }); let rect = corners .iter() .fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]); @@ -1037,8 +1210,7 @@ fn interpret( let ink = words .iter() .map(|&(a, b)| { - let (y0, y1) = (ts.rise + m.descent * ts.size, ts.rise + m.ascent * ts.size); - [trm0.apply(a, y0), trm0.apply(b, y0), trm0.apply(a, y1), trm0.apply(b, y1)] + span(a, b) .iter() .fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |r, p| [r[0].min(p.0), r[1].min(p.1), r[2].max(p.0), r[3].max(p.1)]) }) @@ -1053,9 +1225,18 @@ fn interpret( op: i, extra_ops: Vec::new(), glyph_count, - advance: x_text, + // Vertical text: the TJ adjustment that undoes the run is n = -1000 y / size; the + // preview divides by size × `Tz`, so `Tz` is put back here. + advance: if vertical.is_some() { y_text * ts.scale } else { x_text }, tracking: 0.0, - tm: Matrix([tm.0[0], tm.0[1], tm.0[2], tm.0[3], tm.0[4] - x_text * tm.0[0], tm.0[5] - x_text * tm.0[1]]), + tm: Matrix([ + tm.0[0], + tm.0[1], + tm.0[2], + tm.0[3], + tm.0[4] - x_text * tm.0[0] - y_text * tm.0[2], + tm.0[5] - x_text * tm.0[1] - y_text * tm.0[3], + ]), state: ts.clone(), tlm, bt_op: at_bt.0, @@ -1078,6 +1259,7 @@ fn interpret( visual_order: m.visual_order, decodable, scope: marked.last().copied().flatten(), + paint: Paint { layers: layers.iter().flatten().cloned().collect(), gs: ts.gs.clone() }, logical_order: false, actual_text: false, }); @@ -1088,15 +1270,15 @@ fn interpret( carry.ts = ts; carry.stack = stack; carry.marked = marked; + carry.layers = layers; positioned_tracking(&mut out); - out + Ok(out) } /// The lines of text on a page (0-based), in content order. pub fn text_lines(doc: &Document, page: usize) -> Result, EditError> { let p = page_dict(doc, page)?; let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); - let fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); let mut cache = HashMap::new(); let mut lines: Vec = Vec::new(); let mut carry = Carry::new(); @@ -1105,7 +1287,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro let pieces = Pieces::join(&content_streams(doc, &p.dict).into_iter().map(|(_, d)| d).collect::>()); let ops = pieces.parse().ops; let properties = res.get(b"Properties").and_then(|o| doc.dict(o)).unwrap_or_default(); - let shown = interpret(doc, &ops, &fonts_res, &mut cache, &mut carry, None, Some(&properties)); + let shown = interpret(doc, &ops, &res, &mut cache, &mut carry, None, Some(&properties))?; let mut last: Option<(usize, Pen)> = None; // the stream piece and where its last run ended for mut s in shown { // A single RTL glyph's ActualText (e.g. lam-alef) is an inline ligature, not a @@ -1123,9 +1305,9 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro let bt = pen.bt; let (near, gap) = pen.continues(&s, if bt == s.bt { 3.0 } else { 1.5 }); piece == si - && lines.last().is_some_and(|l| l.scope == s.scope && l.logical_order == s.logical_order) + && lines.last().is_some_and(|l| l.scope == s.scope && l.paint == s.paint && l.logical_order == s.logical_order) && (bt == s.bt || lines.last().is_some_and(|l| compatible_word(l, &s))) - && (s.text.trim().is_empty() || lines.last().is_some_and(|l| same_font(l, &s.font, s.face.as_deref()))) + && (s.text.trim().is_empty() || lines.last().is_some_and(|l| same_font(l, &s.font, &s.base_font, s.face.as_deref()))) && (s.text.trim().is_empty() || lines.last().is_some_and(|l| { (s.size - l.size).abs() < 0.01 && s.bold == l.bold && s.italic == l.italic && fill_color(&s.state.fill) == l.color @@ -1150,6 +1332,9 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro l.visual_order |= s.visual_order; l.actual_text |= s.actual_text; l.clips |= s.state.render_mode >= 4; + if l.unreadable_mode.is_none() { + l.unreadable_mode = s.state.unreadable.as_deref().map(str::to_owned); + } // Spaces may use a separate subset font; they do not change the visible style. } else { lines.push(TextLine { @@ -1168,9 +1353,11 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro face: s.face.clone(), stream: si, scope: s.scope, + paint: s.paint.clone(), logical_order: s.logical_order, actual_text: s.actual_text, clips: s.state.render_mode >= 4, + unreadable_mode: s.state.unreadable.as_deref().map(str::to_owned), ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(), origin: Origin { tm: s.tm.0, @@ -1203,12 +1390,18 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro /// Joining independent text objects is safe only when they use the same text and graphics /// transforms and spacing. In particular, a nearby rotated label isn't part of this line. fn compatible_word(line: &TextLine, shown: &Shown) -> bool { - let same = |a: &[f64], b: &[f64]| a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-6); + let within = |a: &[f64], b: &[f64], tolerance: f64| a.iter().zip(b).all(|(a, b)| (a - b).abs() < tolerance); + let same = |a: &[f64], b: &[f64]| within(a, b, 1e-6); let (a, b) = (&line.origin.state, TextState::of(&shown.state)); + // Spacing that differs by less than a thousandth of an em, the unit glyph widths are given in, + // can't be seen. Some producers still write it (`0.0001 Tc` on one word of a line). + let em = shown.state.size.abs() / 1000.0; line.scope == shown.scope + && line.paint == shown.paint && same(&line.origin.tm[..4], &shown.tm.0[..4]) && same(&line.origin.ctm, &shown.state.ctm.0) - && same(&[a.char_spacing, a.word_spacing, a.scale, a.rise], &[b.char_spacing, b.word_spacing, b.scale, b.rise]) + && within(&[a.char_spacing, a.word_spacing], &[b.char_spacing, b.word_spacing], em.max(1e-6)) + && same(&[a.scale, a.rise], &[b.scale, b.rise]) && a.fill.len() == b.fill.len() && a.fill.iter().zip(&b.fill).all(|(a, b)| a.op == b.op && a.operands == b.operands) } @@ -1218,9 +1411,9 @@ fn compatible_word(line: &TextLine, shown: &Shown) -> bool { fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream: usize) { let joins = last.is_some_and(|pen| { let bt = pen.bt; - lines.last().is_some_and(|l| l.scope == s.scope && l.logical_order == s.logical_order) + lines.last().is_some_and(|l| l.scope == s.scope && l.paint == s.paint && l.logical_order == s.logical_order) && (bt == s.bt || lines.last().is_some_and(|l| compatible_word(l, &s))) - && (s.text.trim().is_empty() || lines.last().is_some_and(|l| same_font(l, &s.font, s.face.as_deref()))) + && (s.text.trim().is_empty() || lines.last().is_some_and(|l| same_font(l, &s.font, &s.base_font, s.face.as_deref()))) && (s.text.trim().is_empty() || lines.last().is_some_and(|l| { (s.size - l.size).abs() < 0.01 && s.bold == l.bold && s.italic == l.italic && fill_color(&s.state.fill) == l.color @@ -1242,6 +1435,9 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream l.visual_order |= s.visual_order; l.actual_text |= s.actual_text; l.clips |= s.state.render_mode >= 4; + if l.unreadable_mode.is_none() { + l.unreadable_mode = s.state.unreadable.as_deref().map(str::to_owned); + } } else { lines.push(TextLine { text: s.text.clone(), @@ -1259,9 +1455,11 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream face: s.face.clone(), stream, scope: s.scope, + paint: s.paint.clone(), logical_order: s.logical_order, actual_text: s.actual_text, clips: s.state.render_mode >= 4, + unreadable_mode: s.state.unreadable.as_deref().map(str::to_owned), ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(), origin: Origin { tm: s.tm.0, @@ -1370,18 +1568,17 @@ fn reading_lines(doc: &Document, page: usize) -> Result<(Vec, bool), E budget: FormBudget, } /// `stream` gives the stream number of the operator at an index of `ops`. - fn walk(w: &mut Walk, ops: &[Op], resources: &Dict, carry: &mut Carry, stream: &dyn Fn(usize) -> usize) { - let fonts_res = resources.get(b"Font").map(|f| w.doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); + fn walk(w: &mut Walk, ops: &[Op], resources: &Dict, carry: &mut Carry, stream: &dyn Fn(usize) -> usize) -> Result<(), EditError> { let mut cache = HashMap::new(); let mut invoked = Vec::new(); let properties = resources.get(b"Properties").and_then(|o| w.doc.dict(o)).unwrap_or_default(); - let shown = interpret(w.doc, ops, &fonts_res, &mut cache, carry, Some(&mut invoked), Some(&properties)); + let shown = interpret(w.doc, ops, resources, &mut cache, carry, Some(&mut invoked), Some(&properties))?; let mut last = None; let mut last_stream = None; let mut forms = invoked.into_iter().peekable(); for s in shown { while let Some((_, name, ts)) = forms.next_if(|f| f.0 < s.op) { - enter(w, resources, &name, ts); + enter(w, resources, &name, ts)?; // Text after a form starts a new line. last = None; } @@ -1393,23 +1590,25 @@ fn reading_lines(doc: &Document, page: usize) -> Result<(Vec, bool), E add_shown(&mut w.lines, &mut last, s, si); } for (_, name, ts) in forms { - enter(w, resources, &name, ts); + enter(w, resources, &name, ts)?; } + Ok(()) } - fn enter(w: &mut Walk, resources: &Dict, name: &[u8], ts: Ts) { - let Some(form) = form_call(w.doc, resources, name, ts.ctm, &w.path, &mut w.budget) else { return }; + fn enter(w: &mut Walk, resources: &Dict, name: &[u8], ts: Ts) -> Result<(), EditError> { + let Some(form) = form_call(w.doc, resources, name, ts.ctm, &w.path, &mut w.budget) else { return Ok(()) }; let ops = parse(&form.data).ops; // The form starts with the graphics state at its `Do`, its own matrix applied. - let mut carry = Carry { ts: Ts { ctm: form.ctm, ..ts }, stack: Vec::new(), marked: Vec::new() }; + let mut carry = Carry { ts: Ts { ctm: form.ctm, ..ts }, stack: Vec::new(), marked: Vec::new(), layers: Vec::new() }; let stream = w.next_stream; w.next_stream = w.next_stream.saturating_add(1); if let Some(r) = form.obj { w.path.push(r); } - walk(w, &ops, &form.resources, &mut carry, &|_| stream); + walk(w, &ops, &form.resources, &mut carry, &|_| stream)?; if form.obj.is_some() { w.path.pop(); } + Ok(()) } let p = page_dict(doc, page)?; let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); @@ -1423,7 +1622,7 @@ fn reading_lines(doc: &Document, page: usize) -> Result<(Vec, bool), E let pieces = Pieces::join(&streams.into_iter().map(|(_, data)| data).collect::>()); let ops = pieces.parse().ops; let stream_of = |i: usize| ops.get(i).map_or(0, |op| pieces.pieces_of(op).0); - walk(&mut w, &ops, &res, &mut carry, &stream_of); + walk(&mut w, &ops, &res, &mut carry, &stream_of)?; let mut lines = w.lines; // A visual-order Indic font draws pre-base vowel signs before their consonant. for l in lines.iter_mut().filter(|l| l.visual_order && !l.actual_text) { @@ -1448,18 +1647,134 @@ fn needs_type3(text: &str) -> bool { const JAPANESE_FONT_NAME: &str = "PCJapanese"; /// The `/Name` of the Type 3 fonts generated for Hebrew text. const HEBREW_FONT_NAME: &str = "PCHebrew"; +/// The `/Name` of the Type 3 fonts generated from the bundled Latin (Inter) face. +const LATIN_FONT_NAME: &str = "PCLatin"; -/// Whether `font` is one of those generated fonts (Japanese or Hebrew): a replacement longer than -/// one of them is split across several, which read back as one line (see [`same_font`]). +/// Whether `font` is one of those generated fonts (Latin, Japanese or Hebrew): a replacement longer +/// than one of them is split across several, which read back as one line (see [`same_font`]). fn is_type3_fallback(font: &Dict) -> bool { font.name(b"Subtype") == Some(b"Type3") - && font.name(b"Name").is_some_and(|n| n == JAPANESE_FONT_NAME.as_bytes() || n == HEBREW_FONT_NAME.as_bytes()) + && font + .name(b"Name") + .is_some_and(|n| n == JAPANESE_FONT_NAME.as_bytes() || n == HEBREW_FONT_NAME.as_bytes() || n == LATIN_FONT_NAME.as_bytes()) } -/// Whether a run shown in `font` (a resource name, and the Japanese or Hebrew fallback face it subsets, if -/// any) continues `line`: the same font resource, or another subset of the same face. -fn same_font(line: &TextLine, font: &[u8], face: Option<&str>) -> bool { - line.font.as_bytes() == font || (line.face.is_some() && line.face.as_deref() == face) +/// The generated font's `/Name` and its resource prefix: the bundled Latin face, Hebrew, or the +/// Japanese ones. +fn fallback_names(face: &CraftFont, hebrew: bool) -> (&'static str, &'static str) { + if hebrew { + (HEBREW_FONT_NAME, "PCHe") + } else if face.family == "Inter" { + (LATIN_FONT_NAME, "PCLat") + } else { + (JAPANESE_FONT_NAME, "PCJp") + } +} + +/// Drops the generated fallback fonts ([`is_type3_fallback`]) that nothing on `page` shows any more. +/// Each rewrite generates its own, with the outlines of every character it draws, so without this +/// an earlier rewrite's fonts stay in the page's resources and the file. Only a font dictionary the +/// page owns is changed (a rewrite gives the page its own), and nothing is dropped unless every +/// content stream that may name the page's fonts can be read: the page's own, and those of form +/// XObjects and Type 3 glyphs that have no resources of their own and so use the page's. +fn drop_unused_fallback_fonts(doc: &mut Document, page: usize) { + let Ok(p) = page_dict(doc, page) else { return }; + let Some(Object::Dict(mut resources)) = p.dict.get(b"Resources").cloned() else { return }; + let Some(mut fonts) = resources.get(b"Font").and_then(|f| doc.dict(f)) else { return }; + let generated: Vec> = + fonts.iter().filter(|(_, f)| doc.dict(f).is_some_and(|d| is_type3_fallback(&d))).map(|(name, _)| name.clone()).collect(); + if generated.is_empty() { + return; + } + let Some(shown) = fonts_shown(doc, &p.dict, &resources) else { return }; + let unused: Vec<&Vec> = generated.iter().filter(|name| !shown.contains(*name)).collect(); + if unused.is_empty() { + return; + } + for name in unused { + fonts.remove(name); + } + resources.set(b"Font".to_vec(), Object::Dict(fonts)); + // Nothing is lost if this fails: the fonts just stay. + let _ = doc.update_dict(p.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))); +} + +/// The font resource names `Tf` selects in the page's content and in the content that uses the +/// page's resources (see [`drop_unused_fallback_fonts`]), or `None` if any of it can't be read. +fn fonts_shown(doc: &Document, page: &Dict, resources: &Dict) -> Option>> { + let mut streams: Vec = match page.get(b"Contents") { + None => Vec::new(), + Some(c) => match &*doc.resolve(c) { + Object::Array(a) => a.clone(), + _ => vec![c.clone()], + }, + }; + // Form XObjects without their own resources use the page's, however deeply they are nested + // (ISO 32000-1 §7.8.3), so every form the page can reach is visited. + let mut pending: Vec = vec![resources.clone()]; + let mut seen = std::collections::HashSet::new(); + while let Some(res) = pending.pop() { + for (_, x) in res.get(b"XObject").and_then(|x| doc.dict(x)).unwrap_or_default().iter() { + let Some(r) = x.as_ref() else { continue }; + if !seen.insert(r) { + continue; + } + if seen.len() > 4096 { + return None; + } + let Object::Stream(s) = &*doc.get(r) else { continue }; + if s.dict.name(b"Subtype") != Some(b"Form") { + continue; + } + match s.dict.get(b"Resources").and_then(|r| doc.dict(r)) { + Some(own) => pending.push(own), + None => streams.push(x.clone()), + } + } + } + // And so do Type 3 glyph procedures without their own (the generated fonts' are outlines only). + for (_, f) in resources.get(b"Font").and_then(|f| doc.dict(f)).unwrap_or_default().iter() { + if let Some(font) = doc.dict(f).filter(|d| d.name(b"Subtype") == Some(b"Type3") && !d.contains(b"Resources") && !is_type3_fallback(d)) { + streams.extend(font.get(b"CharProcs").and_then(|c| doc.dict(c)).unwrap_or_default().iter().map(|(_, proc)| proc.clone())); + } + } + let mut shown = std::collections::HashSet::new(); + for stream in streams { + let Object::Stream(s) = &*doc.resolve(&stream) else { return None }; + // Strict: a stream only partly read might name a font in the part that wasn't. + let data = s.decoded_strict().ok()?; + for op in parse(&data).ops { + if op.is("Tf") + && let Some(name) = op.operands.first().and_then(|n| n.as_name()) + { + shown.insert(name.to_vec()); + } + } + } + Some(shown) +} + +/// Whether a run shown in `font` (a resource name), with `/BaseFont` `base_font` and the Japanese +/// or Hebrew fallback face it subsets, if any, continues `line`: the same font resource, another +/// resource for the same font (a writer may split one font into several subsets, #3661), or +/// another subset of the same face. +fn same_font(line: &TextLine, font: &[u8], base_font: &str, face: Option<&str>) -> bool { + line.font.as_bytes() == font || same_base_font(&line.base_font, base_font) || (line.face.is_some() && line.face.as_deref() == face) +} + +/// Whether two `/BaseFont` names name the same font: a subset's six-letter tag and `+` prefix do +/// not change the font (ISO 32000-2 §9.6.4), so `ABCDEF+Arial` and `GHIJKL+Arial` are one font. +/// A name that is missing, or only a tag (`ABCDEF+`), names no font and matches nothing. +fn same_base_font(a: &str, b: &str) -> bool { + let (a, b) = (strip_subset_tag(a), strip_subset_tag(b)); + !a.is_empty() && a == b +} + +/// `ABCDEF+Arial` → `Arial`. A name without a subset tag is returned unchanged. +fn strip_subset_tag(name: &str) -> &str { + let bytes = name.as_bytes(); + let is_tag = bytes.get(6) == Some(&b'+') && bytes.get(..6).is_some_and(|tag| tag.iter().all(u8::is_ascii_uppercase)); + if is_tag { name.get(7..).unwrap_or(name) } else { name } } fn source_family(base_font: &str) -> crate::added::Family { @@ -1485,7 +1800,8 @@ const MAX_TYPE3_GLYPHS: usize = 240; struct Type3Fallback { /// The family its glyphs come from ("Shippori Mincho"). family: &'static str, - /// The fonts' resource names in order; the first is `PCJp` (`PCHe` for Hebrew text). + /// The fonts' resource names in order; the first is `PCJp` (`PCLat` for the bundled Latin + /// face, `PCHe` for Hebrew text). names: Vec, /// Every unique character with its advance (em), in order of first appearance. widths: Vec<(char, f64)>, @@ -1559,11 +1875,19 @@ fn unicode_hex(ch: char) -> String { encoded.iter().map(|u| format!("{u:04X}")).collect() } -/// No font this build has can draw `text`. Only Japanese text is about the Japanese face: other +/// The same outline for a face that may simply lack the glyph: `None` then, so the caller can try +/// the next face (and report the character once none of them has it). +fn type3_glyph(face: &CraftFont, ch: char) -> Result, f64)>, EditError> { + match japanese_glyph_from(face, ch) { + Err(GlyphError::Missing) => Ok(None), + _ => type3_path(face, ch).map(Some), + } +} + +/// No face this build has can draw `text`. Only Japanese text is about the Japanese face: other /// characters are missing from the paragraph's font, and choosing a font that has them works too. fn no_fallback_font(text: &str) -> EditError { - let japanese = |c: char| matches!(u32::from(c), 0x3000..=0x30FF | 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF); - if text.chars().any(japanese) { + if text.chars().any(needs_japanese) { return no_japanese_font(); } let Some(ch) = text.chars().find(|c| !c.is_whitespace() && !is_win_ansi_char(*c)) else { return no_japanese_font() }; @@ -1575,6 +1899,28 @@ fn no_fallback_font(text: &str) -> EditError { )) } +/// The character the craft-fonts Japanese faces are for: kana, CJK ideographs and their compatibility +/// and full-width forms. +fn needs_japanese(ch: char) -> bool { + matches!(u32::from(ch), 0x3000..=0x30FF | 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF) +} + +/// The chosen face has no outline for `ch`, and neither has any other: say which character it is and +/// what the writer can do about it. A CJK character without a Japanese face in the build is about +/// that build input, which is the one answer that changes it. +fn no_glyph_for(face: &CraftFont, ch: char) -> EditError { + if needs_japanese(ch) && pdfcraft_fonts::document_japanese_font().is_none() { + return no_japanese_font(); + } + EditError::Invalid(format!( + "the {} fallback font has no glyph for {ch} (U+{:04X}), and no fallback face covers the text; \ + choose a font that has it (Load font… or Installed fonts…), or use an official release, which includes \ + PdfCraft's fallback fonts (to build them in, set CRAFT_FONTS_DIR to a craft-fonts checkout)", + face.family, + u32::from(ch) + )) +} + /// This build has no Japanese face to draw replacement text with. fn no_japanese_font() -> EditError { EditError::Invalid( @@ -1599,12 +1945,15 @@ fn no_hebrew_font() -> EditError { } /// The faces a Type 3 replacement may draw `text` with, best first. Text with Hebrew takes the -/// Hebrew face first (its Latin and digits too, so a line keeps one look), then the Japanese -/// faces for anything it lacks; other text takes the Japanese faces, as before. +/// Hebrew face first (its Latin and digits too, so a line keeps one look), then the Japanese faces +/// and the bundled Latin one for anything it lacks; other text takes the Japanese faces, and the +/// bundled Latin face behind them, which covers the Latin, Greek and Cyrillic letters they lack +/// (#2593: the Japanese faces have no қ, ғ, ҳ, ә, ө, ү, ұ or һ). fn type3_faces(text: &str, family: crate::added::Family, bold: bool) -> Result<(Vec<&'static CraftFont>, bool), EditError> { let japanese = pdfcraft_fonts::document_japanese_fonts_for_style(family == crate::added::Family::Times, bold); + let latin = pdfcraft_fonts::document_latin_fonts(bold); if !text.chars().any(crate::bidi::is_hebrew) { - return Ok((japanese, false)); + return Ok((japanese.into_iter().chain(latin).collect(), false)); } // Points are drawn on their letter with the font's positioning, which a one-glyph-per- // character Type 3 font can't do; refuse rather than print them beside the letter. @@ -1612,7 +1961,7 @@ fn type3_faces(text: &str, family: crate::added::Family, bold: bool) -> Result<( return Err(EditError::Invalid("Hebrew points (niqqud) can't be written into a PDF yet; type the text without them".into())); } let hebrew = pdfcraft_fonts::document_hebrew_font(bold).ok_or_else(no_hebrew_font)?; - Ok((std::iter::once(hebrew).chain(japanese).collect(), true)) + Ok((std::iter::once(hebrew).chain(japanese).chain(latin).collect(), true)) } fn type3_font( @@ -1646,8 +1995,16 @@ fn type3_font( let per_font = max_glyphs.clamp(1, MAX_TYPE3_GLYPHS); let mut glyphs = Vec::with_capacity(chars.len()); for (i, ch) in chars.into_iter().enumerate() { - // Hebrew text: the first face with the glyph, else the Hebrew face's error. - let (path, width) = others.iter().fold(type3_path(face, ch), |found, f| found.or_else(|e| type3_path(f, ch).map_err(|_| e)))?; + // The chosen face first, then the faces after it (Hebrew text takes a character the Hebrew + // face lacks from the next face that has it); a character no face draws is reported once. + let mut drawn = type3_glyph(face, ch)?; + for f in others.iter().copied() { + if drawn.is_some() { + break; + } + drawn = type3_glyph(f, ch)?; + } + let Some((path, width)) = drawn else { return Err(no_glyph_for(face, ch)) }; let code = u8::try_from((i % per_font).saturating_add(1)) .map_err(|_| EditError::Invalid("replacement text has too many unique characters".into()))?; glyphs.push(Type3Glyph { code, ch, path, width }); @@ -1671,7 +2028,7 @@ fn type3_font( } /// Adds one generated font whose codes 1 to `glyphs.len()` show `glyphs`, and returns its resource -/// name (`PCJp`, then `PCJp1`, ...; `PCHe`, ... for Hebrew text). Nothing here can fail: the +/// name (`PCLat`, `PCJp`, then `PCJp1`, ...; `PCHe`, ... for Hebrew text). Nothing here can fail: the /// outlines were read already. fn add_type3_font(doc: &mut Document, fonts_res: &mut Dict, face: &CraftFont, hebrew: bool, glyphs: Vec) -> String { let last_char = glyphs.len(); @@ -1702,7 +2059,8 @@ fn add_type3_font(doc: &mut Document, fonts_res: &mut Dict, face: &CraftFont, he let mut font = Dict::new(); font.set(b"Type".to_vec(), Object::name("Font")); font.set(b"Subtype".to_vec(), Object::name("Type3")); - font.set(b"Name".to_vec(), Object::name(if hebrew { HEBREW_FONT_NAME } else { JAPANESE_FONT_NAME })); + let (font_name, prefix) = fallback_names(face, hebrew); + font.set(b"Name".to_vec(), Object::name(font_name)); let mut descriptor = Dict::new(); descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor")); descriptor.set(b"FontName".to_vec(), Object::name(&format!("{}-{}", face.family, face.style).replace(' ', ""))); @@ -1722,7 +2080,6 @@ fn add_type3_font(doc: &mut Document, fonts_res: &mut Dict, face: &CraftFont, he font.set(b"Encoding".to_vec(), Object::Dict(encoding)); font.set(b"CharProcs".to_vec(), Object::Dict(charprocs)); font.set(b"ToUnicode".to_vec(), Object::Ref(cmap_ref)); - let prefix = if hebrew { "PCHe" } else { "PCJp" }; let mut name = String::from(prefix); let mut suffix = 0usize; while fonts_res.contains(name.as_bytes()) { @@ -1842,7 +2199,9 @@ fn source_show(metrics: &Metrics, text: &str, size: f64, space: f64, char_spacin /// Replace the text of line `line` (an index into [`text_lines`]) on `page` with `text`. pub fn replace_line(doc: &mut Document, page: usize, line: usize, text: &str) -> Result { - replace_line_with_limit(doc, page, line, text, MAX_TYPE3_GLYPHS) + let edit = replace_line_with_limit(doc, page, line, text, MAX_TYPE3_GLYPHS)?; + drop_unused_fallback_fonts(doc, page); + Ok(edit) } /// Refuses a rewrite that would remove text drawn as a clipping path (`4 Tr` to `7 Tr`). Such @@ -1866,6 +2225,7 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi let text = text.replace(['\n', '\r'], " "); let lines = text_lines(doc, page)?; let target = lines.get(line).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no line {}", page + 1, line + 1)))?; + target.ensure_editable()?; // Copies of the line drawn on the same baseline go with it, so the replacement is all that // shows. Which operators go is settled here, before any font is generated, so that a refusal // leaves the document untouched. @@ -2010,6 +2370,57 @@ fn on_same_baseline(line: &TextLine, target: &TextLine) -> bool { same_dir && same_size && offset.is_finite() && offset < size * 0.3 } +/// How a listing of [`text_blocks`] records a paragraph so that it can be found again. +/// +/// The index of a paragraph is its position in a list regrouped from geometry on every read, so +/// an edit that merges or splits paragraphs makes the same index name another one (#2516). A key +/// names the paragraph by what it says and where it is; a paragraph that no longer exists as +/// such has no match, instead of a neighbour taking its place. +#[derive(Clone, Debug, PartialEq)] +pub struct BlockKey { + text: String, + rect: [f64; 4], +} + +impl BlockKey { + /// Two boxes are the same box within this many points. + const TOLERANCE: f64 = 0.01; + + pub fn of(block: &TextBlock) -> Self { + Self { text: block.text.clone(), rect: block.rect } + } + + /// The paragraph's box when it was recorded (user space). + pub fn rect(&self) -> [f64; 4] { + self.rect + } + + /// The paragraph in `blocks` with this text and box. `hint` is tried first, so two + /// paragraphs drawn on top of one another are told apart by their order. + pub fn find(&self, blocks: &[TextBlock], hint: usize) -> Option { + let matches = |b: &TextBlock| b.text == self.text && b.rect.iter().zip(&self.rect).all(|(a, b)| (a - b).abs() <= Self::TOLERANCE); + match blocks.get(hint) { + Some(b) if matches(b) => Some(hint), + _ => blocks.iter().position(matches), + } + } +} + +/// The paragraph in `blocks` whose box overlaps `rect` the most, if any overlaps it at all. An +/// edit rewrites a paragraph in place, so this is the paragraph it became. +pub fn block_overlapping(blocks: &[TextBlock], rect: [f64; 4]) -> Option { + // `f64::min` and `max` skip a NaN, which would make a box that isn't a number overlap anything. + let area = |b: &TextBlock| { + if !rect.iter().chain(&b.rect).all(|v| v.is_finite()) { + return 0.0; + } + let w = b.rect[2].min(rect[2]) - b.rect[0].max(rect[0]); + let h = b.rect[3].min(rect[3]) - b.rect[1].max(rect[1]); + if w > 0.0 && h > 0.0 { w * h } else { 0.0 } + }; + blocks.iter().enumerate().map(|(i, b)| (i, area(b))).filter(|(_, a)| *a > 0.0).max_by(|(_, a), (_, b)| a.total_cmp(b)).map(|(i, _)| i) +} + /// The paragraphs on a page (0-based). pub fn text_blocks(doc: &Document, page: usize) -> Result, EditError> { let lines = text_lines(doc, page)?; @@ -2024,15 +2435,15 @@ pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(), let blocks = group_blocks(&lines); let target = blocks.get(block).ok_or_else(|| EditError::Invalid("the paragraph no longer exists".into()))?; let targets: Vec<&TextLine> = target.lines.iter().filter_map(|i| lines.get(*i)).collect(); + targets.iter().try_for_each(|line| line.ensure_editable())?; let mut drops = coincident_ops(&lines, &targets); drops.extend(target.lines.iter().filter_map(|i| lines.get(*i)).flat_map(|l| l.ops.iter().copied())); let p = page_dict(doc, page)?; let res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - let fonts = res.get(b"Font").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); let streams = content_streams(doc, &p.dict); let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; - let shown = interpret(doc, &ops, &fonts, &mut HashMap::new(), &mut Carry::new(), None, None); + let shown = interpret(doc, &ops, &res, &mut HashMap::new(), &mut Carry::new(), None, None)?; let mut patches = HashMap::new(); for s in shown.into_iter().filter(|s| drops.contains(&s.op)) { if s.state.render_mode >= 4 { @@ -2177,7 +2588,9 @@ pub struct RangeStyle { /// Rewrite paragraph `block` with new text (or its own) and formatting, rewrapped to its width. pub fn rewrite_block(doc: &mut Document, page: usize, block: usize, text: Option<&str>, style: &BlockStyle) -> Result { - rewrite_block_with_limit(doc, page, block, text, style, MAX_TYPE3_GLYPHS) + let edit = rewrite_block_with_limit(doc, page, block, text, style, MAX_TYPE3_GLYPHS)?; + drop_unused_fallback_fonts(doc, page); + Ok(edit) } /// [`rewrite_block`] with the most unique characters one generated font may hold (tests lower it). @@ -2189,8 +2602,7 @@ pub(crate) fn rewrite_block_with_limit( style: &BlockStyle, max_glyphs: usize, ) -> Result { - let lines = text_lines(doc, page)?; - rewrite_block_from(doc, page, lines, block, text, style, max_glyphs) + TextSession::open(doc, page, block)?.commit_with_limit(doc, text, style, max_glyphs) } /// [`rewrite_block_with_limit`] with the page's [`text_lines`], read from `doc` as it is now. @@ -2211,6 +2623,7 @@ fn rewrite_block_from( dropped.extend(members.iter().flat_map(|l| l.ops.iter().copied())); refuse_clipping_text(&lines, &dropped)?; let first = members.first().copied().ok_or_else(|| EditError::Invalid("the paragraph has no source runs".into()))?; + members.iter().try_for_each(|line| line.ensure_editable())?; let p = page_dict(doc, page)?; let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let mut fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); @@ -2301,6 +2714,11 @@ fn rewrite_block_from( } else { members.iter().map(|l| l.frame_rect()).reduce(|a, f| [a[0].min(f[0]), a[1].min(f[1]), a[2].max(f[2]), a[3].max(f[3])]).unwrap_or(b.rect) }; + // A centred title is laid out in the box it already sits in, so it keeps its centre as it + // grows or wraps, rather than being grown to the right up to the page's margin (#2581). + // Turned text is left alone: its centre lives along its own baseline, not in page space. + let crop = p.crop(doc); + let centred = upright && !rtl && style.width.is_none() && style.align.is_none() && is_centred(&b, &lines, (crop[0] + crop[2]) / 2.0, first.size); // A paragraph keeps its width (or takes the one asked for); a single line grows to the right // (to the left when right-to-left), up to the page's margin. let width = match style.width { @@ -2309,14 +2727,15 @@ fn rewrite_block_from( Some(_) => return Err(EditError::Invalid("the paragraph's width must be a number".into())), // Turned text: the page's margins are along another axis. A single line grows (below). None if !upright => (frame[2] - frame[0]).max(size * k), - None if anchored_right => (b.rect[2] - b.rect[0]).max(size * k).max(b.rect[2] + dx - (p.crop(doc)[0] + 36.0)), - None if members.len() == 1 => (b.rect[2] - b.rect[0]).max(size * k).max(p.crop(doc)[2] - 36.0 - (b.rect[0] + dx)), + None if anchored_right => (b.rect[2] - b.rect[0]).max(size * k).max(b.rect[2] + dx - (crop[0] + 36.0)), + // A centred line is not stretched to the margin: its own box is the frame it is centred in. + None if members.len() == 1 && !centred => (b.rect[2] - b.rect[0]).max(size * k).max(crop[2] - 36.0 - (b.rect[0] + dx)), None => (b.rect[2] - b.rect[0]).max(size * k), }; if !width.is_finite() { return Err(EditError::Invalid("the paragraph's width must be a number".into())); } - let align = style.align.or(rtl.then_some(crate::added::Align::Right)); + let align = if centred { Some(crate::added::Align::Center) } else { style.align.or(rtl.then_some(crate::added::Align::Right)) }; // Where a right-aligned line ends, from the paragraph's left edge (user space). let right_edge = if anchored_right { (b.rect[2] - b.rect[0]).max(0.0) } else { width }; // The move and the width are checked here, before the fallback fonts are written: a refused @@ -2639,6 +3058,24 @@ pub(crate) fn selectable_blocks( .collect()) } +/// The vertical advance per unit of font size of a font that writes vertically: `/DW2`'s w1 +/// (default −1000, §9.7.4.3), or `None` for horizontal text. Per-glyph `/W2` entries are not +/// read. An encoding whose writing mode can't be read is an error, as in [`vertical_encoding`]; +/// listing text treats it as horizontal and records the error (see [`Ts::unreadable`]). +fn vertical_advance(doc: &Document, font: &Dict) -> Result, EditError> { + if !vertical_encoding(doc, font)? { + return Ok(None); + } + let descendant = font.get(b"DescendantFonts").and_then(|d| doc.resolve(d).as_array().and_then(|a| a.first().cloned())); + let w1 = descendant + .and_then(|d| doc.resolve(&d).as_dict().and_then(|d| d.get(b"DW2").cloned())) + .and_then(|a| doc.resolve(&a).as_array().and_then(|a| a.get(1).cloned())) + .and_then(|w| doc.resolve(&w).as_f64()) + .filter(|w| w.is_finite()) + .unwrap_or(-1000.0); + Ok(Some(w1 / 1000.0)) +} + /// Detect vertical CMaps before moving horizontal text. A broken/cyclic embedded CMap is /// refused rather than guessing which direction TJ advances its text matrix. fn vertical_encoding(doc: &Document, font: &Dict) -> Result { @@ -2704,6 +3141,7 @@ pub(crate) fn translate_blocks(doc: &mut Document, page: usize, indexes: &[usize let b = blocks.get(*index).ok_or_else(|| EditError::Invalid("a selected paragraph no longer exists".into()))?; for line in &b.lines { let l = lines.get(*line).ok_or_else(|| EditError::Invalid("a selected text line no longer exists".into()))?; + l.ensure_editable()?; selected.extend(l.ops.iter().copied()); } } @@ -2711,7 +3149,7 @@ pub(crate) fn translate_blocks(doc: &mut Document, page: usize, indexes: &[usize let fonts = res.get(b"Font").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; - let shown = interpret(doc, &ops, &fonts, &mut HashMap::new(), &mut Carry::new(), None, None); + let shown = interpret(doc, &ops, &res, &mut HashMap::new(), &mut Carry::new(), None, None)?; let mut patches = HashMap::new(); // Keep full precision: a tiny rounded text-space change can be magnified by a CTM, // and restoring rise/spacing must not alter following unselected text. @@ -2789,6 +3227,69 @@ pub(crate) fn translate_blocks(doc: &mut Document, page: usize, indexes: &[usize mod tests { use super::*; + fn block(text: &str, rect: [f64; 4]) -> TextBlock { + TextBlock { + text: text.into(), + rect, + base_font: String::new(), + size: 12.0, + bold: false, + italic: false, + color: [0.0; 3], + rotation: 0.0, + lines: Vec::new(), + rows: Vec::new(), + editor_text: text.into(), + spans: Vec::new(), + invisible: false, + } + } + + #[test] + fn a_block_key_finds_its_paragraph_after_others_move_and_not_a_neighbour_in_its_place() { + let (a, b, c) = (block("Alpha", [0.0, 80.0, 50.0, 100.0]), block("Bravo", [0.0, 50.0, 50.0, 60.0]), block("Charlie", [0.0, 0.0, 50.0, 20.0])); + let key = BlockKey::of(&b); + assert_eq!(key.find(&[a.clone(), b.clone(), c.clone()], 1), Some(1)); + // Alpha and Bravo became one paragraph: Bravo's number now belongs to Charlie. + let merged = block("Alpha Bravo", [0.0, 50.0, 50.0, 100.0]); + assert_eq!(key.find(&[merged, c.clone()], 1), None); + // A paragraph that split in two shifts the others down. + assert_eq!(key.find(&[a.clone(), block("x", [0.0; 4]), b.clone(), c], 1), Some(2)); + // The same words somewhere else, or other words in the same place, are not the paragraph. + assert_eq!(key.find(&[block("Bravo", [0.0, 40.0, 50.0, 50.0])], 0), None); + assert_eq!(key.find(&[block("Bravo!", b.rect)], 0), None); + assert_eq!(key.find(&[], 0), None); + // Two paragraphs drawn on top of one another are told apart by the hint. + assert_eq!(key.find(&[b.clone(), b], 1), Some(1)); + // A box that is not a number matches nothing, and cannot panic. + let nan = BlockKey::of(&block("Bravo", [f64::NAN; 4])); + assert_eq!(nan.find(&[block("Bravo", [f64::NAN; 4])], 0), None); + } + + #[test] + fn the_block_that_overlaps_most_is_the_edited_paragraph() { + let blocks = [block("a", [0.0, 80.0, 50.0, 100.0]), block("b", [0.0, 40.0, 50.0, 70.0])]; + assert_eq!(block_overlapping(&blocks, [0.0, 60.0, 50.0, 95.0]), Some(0)); + assert_eq!(block_overlapping(&blocks, [0.0, 35.0, 50.0, 75.0]), Some(1)); + assert_eq!(block_overlapping(&blocks, [200.0, 0.0, 250.0, 10.0]), None, "a box that overlaps nothing"); + assert_eq!(block_overlapping(&[], [0.0, 0.0, 1.0, 1.0]), None); + assert_eq!(block_overlapping(&blocks, [f64::NAN; 4]), None); + } + + #[test] + fn a_subset_tag_is_the_only_difference_between_subsets_of_one_font() { + assert!(same_base_font("ABCDEF+Arial", "GHIJKL+Arial")); + assert!(same_base_font("Arial", "ABCDEF+Arial")); + assert!(same_base_font("Arial", "Arial")); + assert!(!same_base_font("ABCDEF+Arial", "GHIJKL+Helvetica")); + // Five letters are part of the name, not a subset tag. + assert!(!same_base_font("ABCDE+Helvetica-Bold", "Helvetica-Bold")); + // A tag with no name after it, or no name at all, names no font. + assert!(!same_base_font("ABCDEF+", "GHIJKL+")); + assert!(!same_base_font("", "")); + assert!(!same_base_font("Arial", "")); + } + #[test] fn paragraph_wrapping_keeps_ideographic_spaces() { let width = |s: &str| s.chars().count() as f64; diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index e08068d9f..928280d5d 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -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 { + let mut edges: HashMap = 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 { 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 { - 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::(); + 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 { let mut rows: Vec> = Vec::new(); for (i, line) in lines.iter().enumerate() { @@ -71,7 +199,18 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec { 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 { 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 = 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 { diff --git a/crates/edit/src/text_session.rs b/crates/edit/src/text_session.rs new file mode 100644 index 000000000..893a0e4d2 --- /dev/null +++ b/crates/edit/src/text_session.rs @@ -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, + source: TextBlock, +} + +impl TextSession { + /// Opens paragraph `block` on `page` (both 0-based) of `doc`. + pub fn open(doc: &Document, page: usize, block: usize) -> Result { + 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 { + 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 { + 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 { + 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) + } +} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index bd92d3dca..f5e35088b 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -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 } diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 3d2ba3082..c19e1e42c 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -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), diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index fcd56efee..32cf5b443 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -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) 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()), diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs index f0ed2b4b8..50508e20e 100644 --- a/crates/engine/src/compare.rs +++ b/crates/engine/src/compare.rs @@ -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) diff --git a/crates/engine/src/dates.rs b/crates/engine/src/dates.rs index 8c14a5b1b..606ca53c6 100644 --- a/crates/engine/src/dates.rs +++ b/crates/engine/src/dates.rs @@ -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")); diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index a1820e2d9..01159df80 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -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, 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 { premultiplied @@ -230,6 +238,7 @@ fn over_white(premultiplied: &[u8]) -> Vec { pub fn encode_jpeg(width: u32, height: u32, premultiplied: &[u8], quality: u8, dpi: f64) -> Result, 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) { @@ -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()); + } } } } diff --git a/crates/engine/src/field_table.rs b/crates/engine/src/field_table.rs new file mode 100644 index 000000000..f3fd96551 --- /dev/null +++ b/crates/engine/src/field_table.rs @@ -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 { + 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}"); + } + } +} diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index b797bc8d8..a2c444bd6 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -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 { + 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, 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, } 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> = 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()); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index cdc7eb646..e20cbc596 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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 = 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 { 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>) -> Arc> { 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, doc: &pdfcraft_cos::Document) -> Option { + 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>, password: Option<&str>) -> Arc> { 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>, password: Option<&str>) -> Arc>, password: Option<&str>) -> Option { + 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 { + 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, index: usize, title: String, page: usize, + left: Option, + top: Option, }, RenameBookmark { path: Vec, @@ -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, + }, /// Add a header and footer (with `replace`, existing ones on those pages go first). AddHeaderFooter { pages: Vec, @@ -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 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 { 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 { 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 { @@ -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 { + 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>, EditError> { diff --git a/crates/engine/src/stamp_preview.rs b/crates/engine/src/stamp_preview.rs new file mode 100644 index 000000000..6e5758a7c --- /dev/null +++ b/crates/engine/src/stamp_preview.rs @@ -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>, + stamp: Arc>, + 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 { + 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 { + self.render(&self.stamp, self.stamp_config(), scale) + } + + fn stamp_config(&self) -> RenderConfig { + RenderConfig { transparent: true, ..self.config.clone() } + } + + fn render(&self, bytes: &Arc>, config: RenderConfig, scale: f32) -> Result { + 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, 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() })) + })? + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f125c41da..0df176226 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -196,6 +196,59 @@ fn rubber_stamps_point_left_as_displayed_on_rotated_pages() { } } +/// #2392: Fill & Sign marks are drawn as displayed on every page rotation, including after save: +/// a checkmark's long stroke rises to the right, and a line runs across. +#[test] +fn fill_sign_marks_are_upright_as_displayed_on_rotated_pages() { + for degrees in [0, 90, 180, 270] { + let (mut session, id) = rotated_blank(degrees); + let info = session.get(id).unwrap().info.pages[0].clone(); + // Displayed boxes: a 40 pt checkmark centred at (50, 50), an 80 × 8 pt line at (130, 50). + for (mark, vx, w, h) in [(FillMark::Check, 50.0, 40.0, 40.0), (FillMark::Line, 130.0, 80.0, 8.0)] { + let shape = Shape::Mark { rect: displayed_rect(&info, vx, 50.0, w, h), mark }; + session + .apply( + id, + Edit::AddAnnotation(NewAnnotation { + page: 0, + style: Style::default_for(&shape), + shape, + contents: String::new(), + author: "Test".into(), + }), + ) + .unwrap(); + } + let check = |session: &Session, id: DocId| { + let rendered = render_page(session, id); + // Dark pixels in a displayed box, rendered at scale 2. + let ink = |x0: u32, y0: u32, x1: u32, y1: u32| -> Vec<(u32, u32)> { + (y0 * 2..y1 * 2) + .flat_map(|y| (x0 * 2..x1 * 2).map(move |x| (x, y))) + .filter(|&(x, y)| stamp_pixel(&rendered, x, y)[..3].iter().all(|c| *c < 128)) + .collect() + }; + let tick = ink(30, 30, 70, 70); + assert!(!tick.is_empty(), "rotation {degrees}: no checkmark drawn"); + let top = tick.iter().min_by_key(|p| p.1).unwrap(); + let bottom = tick.iter().max_by_key(|p| p.1).unwrap(); + // The long stroke ends at the top right; the short one meets it low, left of centre. + assert!(top.0 > bottom.0 + 20, "rotation {degrees}: highest ink {top:?} should be right of the lowest {bottom:?}"); + assert!(bottom.0 < 100 && top.0 > 110, "rotation {degrees}: checkmark {bottom:?} .. {top:?} is not upright"); + let line = ink(85, 40, 175, 60); + assert!(!line.is_empty(), "rotation {degrees}: no line drawn"); + let (x0, x1) = (line.iter().map(|p| p.0).min().unwrap(), line.iter().map(|p| p.0).max().unwrap()); + let (y0, y1) = (line.iter().map(|p| p.1).min().unwrap(), line.iter().map(|p| p.1).max().unwrap()); + assert!((x1 - x0) > 10 * (y1 - y0 + 1), "rotation {degrees}: line {x0}..{x1} x {y0}..{y1} should run across"); + }; + check(&session, id); + let saved = session.save_bytes(id).unwrap(); + let mut reopened = Session::new().with_clock(|| 1_700_000_000); + let again = reopened.open("drawing.pdf", None, saved, None).unwrap(); + check(&reopened, again); + } +} + /// Fill & Sign text and text boxes read across as displayed on turned pages (scanned forms are /// often stored sideways with `/Rotate`), including after save. #[test] @@ -685,7 +738,7 @@ fn layer_choices_survive_edits() { #[test] fn set_layer_actions_toggle_in_order_and_keep_radio_groups() { use pdfcraft_render::LayerOp::{Off, On, Toggle}; - let layer = |n: u32, visible: bool| Layer { id: (n, 0), name: format!("L{n}"), visible }; + let layer = |n: u32, visible: bool| Layer { id: (n, 0), name: format!("L{n}"), visible, initial: visible }; let run = |changes: &[(LayerOp, u32)], preserve_rb: bool| { // L1 and L2 are a radio-button group; L3 is on its own. let mut layers = vec![layer(1, true), layer(2, false), layer(3, true)]; @@ -761,6 +814,59 @@ fn set_layer_actions_change_what_renders() { assert!(!s.set_layer_state(DocId(999), &[(LayerOp::On, (5, 0))], true)); } +#[test] +fn reset_layers_returns_to_the_files_visibility() { + // Red (left half, on) and Green (right half, off) are a radio-button group. + let content = "/OC /R BDC 1 0 0 rg 0 0 50 100 re f EMC /OC /G BDC 0 1 0 rg 50 0 50 100 re f EMC"; + let pdf = pdfcraft_testkit::Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R] /D << /OFF [6 0 R] /RBGroups [[5 0 R 6 0 R]] >> >> >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 >>") + .obj("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << /Properties << /R 5 0 R /G 6 0 R >> >> >>") + .stream("", content) + .obj("<< /Type /OCG /Name (Red) >>") + .obj("<< /Type /OCG /Name (Green) >>") + .build(); + // A page drawing on layer Blue, which that file has off. + let other = pdfcraft_testkit::Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R] /D << /OFF [5 0 R] >> >> >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 >>") + .obj("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << /Properties << /B 5 0 R >> >> >>") + .stream("", "/OC /B BDC 0 0 1 rg 0 0 10 10 re f EMC") + .obj("<< /Type /OCG /Name (Blue) >>") + .build(); + let mut s = Session::new(); + let id = s.open("layers.pdf", None, Arc::new(pdf), None).unwrap(); + let shown = |s: &Session| s.get(id).unwrap().info.layers.iter().map(|l| (l.name.clone(), l.visible)).collect::>(); + let left = |s: &Session| { + let doc = s.get(id).unwrap(); + let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone()); + let p = r.render(pdfcraft_render::RenderRequest { page: 0, kind: pdfcraft_render::RequestKind::Pixels, scale: 1.0, ..Default::default() }); + let i = (50 * p.width as usize + 25) * 4; + p.rgba[i..i + 3].to_vec() + }; + assert!(s.set_layer_state(id, &[(LayerOp::On, (6, 0))], true)); + let edit = Edit::InsertPagesFrom { + name: "b.pdf".into(), + bytes: Arc::new(other), + pages: None, + at: 1, + field_conflicts: pdfcraft_organize::FieldConflicts::Rename, + }; + s.apply(id, edit).unwrap(); + let blue = s.get(id).unwrap().info.layers.iter().position(|l| l.name == "Blue").unwrap(); + assert!(s.set_layer_visible(id, blue, true)); + assert_eq!(shown(&s), [("Red".into(), false), ("Green".into(), true), ("Blue".into(), true)]); + assert_eq!(left(&s), [255, 255, 255]); + // Every layer, the inserted one too, goes back to the file's default configuration. + assert!(s.reset_layers(id)); + assert_eq!(shown(&s), [("Red".into(), true), ("Green".into(), false), ("Blue".into(), false)]); + assert_eq!(left(&s), [255, 0, 0]); + assert!(!s.reset_layers(id), "already as the file opens"); + // What the eye sees is not an edit: Undo still undoes the insert. + assert_eq!(s.get(id).unwrap().can_undo(), Some("Insert pages from b.pdf")); + assert!(!s.reset_layers(DocId(999))); +} + #[test] fn edits_to_unknown_documents_are_rejected() { let (mut s, _) = session_with(1); @@ -1210,6 +1316,24 @@ fn copy_restricted_documents_refuse_extraction_unless_opened_by_the_owner() { assert!(s.get(plain).unwrap().check_copying().is_ok() && extracting.iter().all(|c| enabled(&s, plain, c))); } +/// Table 22, bit 3 (#2669): a document that forbids printing disables Print up front instead +/// of refusing once the dialog is confirmed. Low-resolution printing (bit 3 on, bit 12 off) is +/// still printing, and the owner password lifts the restriction. +#[test] +fn print_restricted_documents_disable_print_unless_opened_by_the_owner() { + let enabled = |s: &Session, id, c| commands::is_enabled(commands::command(c).unwrap(), s, Some(id)); + let bytes = protected("", "owner", -1 ^ 0b100); + let mut s = Session::new(); + let id = s.open("r.pdf", None, bytes.clone(), None).unwrap(); + assert!(!s.get(id).unwrap().allows_printing()); + assert!(!enabled(&s, id, "print.dialog")); + assert!(enabled(&s, id, "export.text"), "only printing is withheld"); + let oid = s.open("r.pdf", None, bytes, Some("owner")).unwrap(); + assert!(enabled(&s, oid, "print.dialog"), "the owner may print"); + let low = s.open("low.pdf", None, protected("", "owner", -1 ^ (1 << 11)), None).unwrap(); + assert!(enabled(&s, low, "print.dialog"), "low-resolution printing is printing"); +} + #[test] fn the_permissions_password_unlocks_an_open_document() { // Only a permissions password (#995, #1077): opens without asking. Changes are allowed, @@ -1303,12 +1427,42 @@ fn owner_password_of_older_revisions_opens_the_viewer_too() { } } +/// Render paths outside a session (the CLI's `info`, `text`, `render`) get the password the +/// renderer takes: the recovered user password for an R2–R4 owner password, else as given. +#[test] +fn render_password_for_recovers_the_user_password_of_older_revisions() { + for alg in [pdfcraft_cos::Algorithm::Rc4_40, pdfcraft_cos::Algorithm::Rc4_128, pdfcraft_cos::Algorithm::Aes128, pdfcraft_cos::Algorithm::Aes256] { + let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(1))).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: alg, + user_password: "u", + owner_password: "o", + permissions: 0, + encrypt_metadata: true, + seed: [1; 32], + }) + .unwrap(); + let bytes = Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()); + let owner = crate::render_password_for(bytes.clone(), Some("o")); + let expected = if alg == pdfcraft_cos::Algorithm::Aes256 { "o" } else { "u" }; + assert_eq!(owner.as_deref(), Some(expected), "{alg:?}"); + assert!(pdfcraft_render::inspect(bytes.clone(), owner.as_deref()).is_ok(), "{alg:?}: the renderer opens it"); + assert_eq!(crate::render_password_for(bytes.clone(), Some("u")).as_deref(), Some("u"), "{alg:?}"); + assert_eq!(crate::render_password_for(bytes.clone(), Some("wrong")).as_deref(), Some("wrong"), "{alg:?}"); + assert_eq!(crate::render_password_for(bytes, None), None, "{alg:?}"); + } +} + #[test] fn autosave_snapshots_only_changed_documents() { let (mut s, id) = session_with(2); + assert!(!s.has_pending_autosave()); assert!(s.autosave_snapshots().is_empty(), "clean documents are not snapshotted"); s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + assert!(s.has_pending_autosave()); + assert!(s.has_pending_autosave(), "checking a deadline must not consume a snapshot"); let snaps = s.autosave_snapshots(); + assert!(!s.has_pending_autosave()); assert_eq!(snaps.len(), 1); assert_eq!(snaps[0].bytes, s.get(id).unwrap().bytes, "the working file"); assert!(s.autosave_snapshots().is_empty(), "nothing new since the last snapshot"); @@ -1371,9 +1525,9 @@ fn outline_titles(items: &[pdfcraft_render::OutlineItem]) -> Vec { fn bookmark_edits_show_in_the_viewer_undo_and_save() { let (mut s, id) = session_with(3); let titles = |s: &Session| outline_titles(&s.get(id).unwrap().info.outline); - s.apply(id, Edit::AddBookmark { parent: vec![], index: 0, title: "Start".into(), page: 0 }).unwrap(); - s.apply(id, Edit::AddBookmark { parent: vec![], index: 1, title: "End".into(), page: 2 }).unwrap(); - s.apply(id, Edit::AddBookmark { parent: vec![1], index: 0, title: "Détail".into(), page: 1 }).unwrap(); + s.apply(id, Edit::AddBookmark { parent: vec![], index: 0, title: "Start".into(), page: 0, left: None, top: None }).unwrap(); + s.apply(id, Edit::AddBookmark { parent: vec![], index: 1, title: "End".into(), page: 2, left: None, top: None }).unwrap(); + s.apply(id, Edit::AddBookmark { parent: vec![1], index: 0, title: "Détail".into(), page: 1, left: None, top: None }).unwrap(); // The inspector (an independent parser) sees the same tree and destinations. assert_eq!(titles(&s), ["Start→1", "End→3[Détail→2]"]); s.apply(id, Edit::MoveBookmark { from: vec![1, 0], to_parent: vec![], index: 0 }).unwrap(); @@ -1940,6 +2094,47 @@ fn preparing_a_form_adds_renames_and_deletes_fields() { assert_eq!(names(&s), ["Text1", "Check Box1", "email"]); } +/// Setting the calculation order is one undoable edit that recalculates: values that ran too +/// early come out right at once, a script calculation included, and undo brings back the old +/// order and values together. +#[test] +fn setting_the_calculation_order_recalculates_and_undoes() { + use form_scripts::{CalcOp, Calculate}; + let (mut s, id) = session_with(1); + for (i, name) in ["qty", "sub", "total", "tax"].into_iter().enumerate() { + let y = 250.0 - 40.0 * i as f64; + let kind = NewField::Text { multiline: false }; + s.apply(id, Edit::AddField { page: 0, rect: [20.0, y, 180.0, y + 22.0], kind, name: Some(name.into()) }).unwrap(); + } + let calculate = |name: &str, c: Calculate| Edit::SetFieldProps { + name: name.into(), + props: Box::new(FieldProps { calculate: Some(c), ..Default::default() }), + }; + // Defined backwards: total, which reads sub, calculates first. + s.apply(id, calculate("total", Calculate::Simple { op: CalcOp::Sum, fields: vec!["sub".into()] })).unwrap(); + s.apply(id, calculate("sub", Calculate::Notation("qty * 2".into()))).unwrap(); + let script = Some("event.value = this.getField(\"total\").value / 10;".to_string()); + s.apply(id, Edit::SetFieldScript { name: "tax".into(), event: "calculate".into(), script }).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("5".into()) }).unwrap(); + let values = |s: &Session| { + let form = &s.get(id).unwrap().form; + ["sub", "total", "tax"].map(|n| form.iter().find(|f| f.name == n).and_then(|f| f.value.first().cloned()).unwrap_or_default()) + }; + assert_eq!(values(&s), ["10", "0", "0"], "total and tax ran before sub"); + let order = s.get(id).unwrap().calculation_order(); + assert_eq!(order.fields.iter().map(|f| f.name.as_str()).collect::>(), ["total", "sub", "tax"]); + assert_eq!(order.fields[0].issues, [CalculationIssue::RunsTooEarly { field: "sub".into() }]); + assert_eq!(order.fixed(), ["sub", "total", "tax"]); + s.apply(id, Edit::SetCalculationOrder { names: order.fixed() }).unwrap(); + assert_eq!(values(&s), ["10", "10", "1"]); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Set calculation order")); + assert!(s.get(id).unwrap().calculation_order().fields.iter().all(|f| f.issues.is_empty())); + s.undo(id).unwrap(); + assert_eq!(values(&s), ["10", "0", "0"]); + assert_eq!(s.get(id).unwrap().calculation_order().fixed(), ["sub", "total", "tax"], "the old order is back"); + assert!(matches!(s.apply(id, Edit::SetCalculationOrder { names: vec!["qty".into()] }), Err(EditError::Form(_)))); +} + /// One 612×300 page with the content stream `body`. Object 4 is Helvetica; `resources` is what /// the page's /Resources holds. fn one_line_page(resources: &str, body: &str) -> Vec { @@ -2460,6 +2655,60 @@ fn comment_text(s: &Session, id: DocId) -> Vec { t } +/// A dragged stamp's layers: the page without it (its text and other comments kept) and the +/// stamp alone on a transparent page. Preparing them changes nothing in the document. +#[test] +fn stamp_preview_layers_split_the_stamp_from_its_page() { + let (mut s, id) = session_with(1); + s.apply(id, fill_item(Shape::Mark { rect: [30.0, 60.0, 130.0, 66.0], mark: FillMark::Line }, "")).unwrap(); + s.apply(id, rect_comment(0, [140.0, 40.0, 190.0, 90.0])).unwrap(); + let doc = s.get(id).unwrap(); + let (generation, dirty) = (doc.edit_generation(), doc.dirty); + let preview = doc.stamp_preview(0, 0).unwrap().expect("the line is a stamp"); + let (stamp, background) = (preview.render_stamp(1.0).unwrap(), preview.render_background(1.0).unwrap()); + let px = |p: &pdfcraft_render::RenderedPage, x: u32, y: u32| -> [u8; 4] { + p.rgba[(((300 - y) * p.width + x) * 4) as usize..][..4].try_into().unwrap() + }; + let line = |x: u32, y: u32| (30..=130).contains(&x) && (59..=67).contains(&y); + let (mut drawn, mut stray) = (0, Vec::new()); + for y in 1..300 { + for x in 0..200 { + match (line(x, y), px(&stamp, x, y)) { + (true, [.., 255]) => drawn += 1, + (false, v) if v != [0, 0, 0, 0] => stray.push((x, y)), + _ => {} + } + } + } + assert!(drawn > 100, "the line is drawn opaque: {drawn} pixels"); + assert!(stray.is_empty(), "only the line is drawn on its layer: {:?}", &stray[..stray.len().min(8)]); + assert!((30..=130).all(|x| px(&background, x, 63) == [255, 255, 255, 255]), "the page beneath has no line"); + assert!((138..=142).any(|x| px(&background, x, 65) != [255, 255, 255, 255]), "other comments stay"); + assert!((20..120).any(|x| (150..170).any(|y| px(&background, x, y)[0] < 128)), "the page text stays"); + assert!(doc.stamp_preview(0, 1).unwrap().is_none(), "a rectangle isn't a stamp"); + assert!(doc.stamp_preview(0, 9).unwrap().is_none()); + let doc = s.get(id).unwrap(); + assert_eq!((doc.edit_generation(), doc.dirty, doc.can_undo()), (generation, dirty, Some("Add rectangle")), "the document is unchanged"); + + // Another app's comments without appearances: beneath a dragged stamp they are drawn as on + // screen, and such a stamp has nothing to lay over the page. + let bare = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Square /Rect [140 40 190 90] /C [1 0 0] >> endobj +5 0 obj << /Type /Annot /Subtype /Stamp /Rect [20 20 80 40] /Name /Draft >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + let mut s = Session::new(); + let id = s.open("bare.pdf", None, Arc::new(bare.to_vec()), None).unwrap(); + s.apply(id, fill_item(Shape::Mark { rect: [30.0, 60.0, 130.0, 66.0], mark: FillMark::Line }, "")).unwrap(); + let doc = s.get(id).unwrap(); + let background = doc.stamp_preview(0, 2).unwrap().expect("the line is a stamp").render_background(1.0).unwrap(); + assert!((138..=142).any(|x| px(&background, x, 65)[..3] == [255, 0, 0]), "the square without an appearance is drawn beneath"); + assert!(doc.stamp_preview(0, 1).unwrap().is_none(), "a stamp without an appearance"); +} + #[test] fn flattening_fill_and_sign_keeps_other_comments_fields_and_undoes() { let (mut s, id) = session_with(1); @@ -2709,6 +2958,47 @@ fn reload_reads_the_new_file_into_the_same_document() { assert_eq!(s.docs().len(), 2); } +/// #3443: reloading the file (it changed on disk) keeps how the reader shows it. Reading colours +/// reached the new renderer, not just the setting; hidden comments stay hidden. Each setting is +/// checked alone, so neither one rides on the other being changed. +#[test] +fn reload_keeps_reading_colours_and_hidden_comments() { + let dark_share = |s: &Session, id: DocId| { + let doc = s.get(id).unwrap(); + doc.renderer.set_queue(vec![pdfcraft_render::RenderRequest { + page: 0, + kind: pdfcraft_render::RequestKind::Pixels, + scale: 1.0, + ..Default::default() + }]); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + let page = loop { + if let Some(page) = doc.renderer.try_recv() { + break page; + } + assert!(std::time::Instant::now() < deadline, "page render did not finish"); + std::thread::yield_now(); + }; + let (px, _) = page.rgba.as_chunks::<4>(); + px.iter().filter(|p| p[0] < 64 && p[1] < 64 && p[2] < 64).count() as f64 / px.len() as f64 + }; + let (mut s, id) = session_with(1); + assert!(dark_share(&s, id) < 0.1, "a white page as the file draws it"); + assert!(s.set_reading_colors(id, ReadingColors::WHITE_ON_BLACK)); + assert!(dark_share(&s, id) > 0.9, "white on black"); + s.reload(id, Arc::new(fixture(2))).unwrap(); + assert_eq!(s.reading_colors(id), Some(ReadingColors::WHITE_ON_BLACK)); + assert!(dark_share(&s, id) > 0.9, "the reloaded page is drawn white on black too"); + assert!(!s.get(id).unwrap().comments_hidden()); + + let (mut s, id) = session_with(1); + assert!(s.set_hide_comments(id, true)); + s.reload(id, Arc::new(fixture(2))).unwrap(); + assert!(s.get(id).unwrap().comments_hidden(), "hidden comments stay hidden"); + assert_eq!(s.reading_colors(id), Some(ReadingColors::default())); + assert!(dark_share(&s, id) < 0.1); +} + #[test] fn revert_goes_back_to_the_saved_version() { let (mut s, id) = session_with(2); @@ -2735,8 +3025,8 @@ fn split_by_size_and_bookmarks_and_page_filters() { assert_eq!(parts.last().unwrap().1, 6, "every page is in a part"); assert_eq!(s.split_by_size(id, 1).unwrap().len(), 6, "pages larger than the limit stand alone"); // Top-level bookmarks name the parts. - s.apply(id, Edit::AddBookmark { parent: vec![], index: 0, title: "Intro".into(), page: 0 }).unwrap(); - s.apply(id, Edit::AddBookmark { parent: vec![], index: 1, title: "Results".into(), page: 3 }).unwrap(); + s.apply(id, Edit::AddBookmark { parent: vec![], index: 0, title: "Intro".into(), page: 0, left: None, top: None }).unwrap(); + s.apply(id, Edit::AddBookmark { parent: vec![], index: 1, title: "Results".into(), page: 3, left: None, top: None }).unwrap(); assert_eq!(s.bookmark_splits(id), [(0, "Intro".to_string()), (3, "Results".to_string())]); // Filters: page numbers 2, 4, 6 are even. let info = &s.get(id).unwrap().info; @@ -3681,6 +3971,28 @@ fn detecting_fields_on_a_printed_form() { assert!(s.detect_fields(id, &[]).is_empty(), "nothing left to detect"); } +/// #294, #2831: a detected field sits on a line or box the page already draws, so it gets no border +/// and no fill that would cover the printed form. A field drawn by hand keeps the defaults. +#[test] +fn detected_fields_have_no_border_or_fill() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("paper.pdf", None, Arc::new(paper_form()), None).unwrap(); + let added = s.auto_detect_fields(id, &[]).unwrap(); + let d = s.get(id).unwrap(); + let kinds: Vec = d.form.iter().map(|f| f.kind).collect(); + assert!(kinds.contains(&pdfcraft_forms::FieldKind::Text) && kinds.contains(&pdfcraft_forms::FieldKind::CheckBox), "{kinds:?}"); + for name in &added { + let look = d.field_look(name).unwrap(); + assert_eq!((look.border, look.fill), (None, None), "{name}"); + } + assert_eq!(d.can_undo(), Some("Detect form fields"), "still one step"); + + s.apply(id, Edit::AddField { page: 0, rect: [200.0, 10.0, 300.0, 30.0], kind: NewField::Text { multiline: false }, name: Some("drawn".into()) }) + .unwrap(); + let look = s.get(id).unwrap().field_look("drawn").unwrap(); + assert!(look.border.is_some() && look.fill.is_some(), "{look:?}"); +} + /// A printed form, 420 × 300 pt: a title, a label with a rule to write on, a box with a small /// label in its corner, an empty box with its label above, and two check boxes. fn paper_form() -> Vec { @@ -4155,6 +4467,43 @@ fn dynamic_xfa_forms_open_with_the_values_and_rows_of_their_data() { assert!(!doc.dirty); } +/// #3180: an XFA form with no fields whose configuration says `dynamicRender` is `forbidden` is a +/// static form: its pages are the form. Opening it used to lay the template out into new pages +/// and write them as a revision, so even a save with no change replaced the pages. +#[test] +fn an_xfa_form_without_fields_that_forbids_dynamic_rendering_keeps_its_pages() { + // The fixture's own config packet gains `acrobat/acrobat7/dynamicRender`. + let template = pdfcraft_xfa::fixtures::template(1); + let forbidden = "forbidden"; + let with_config = template.replacen("", forbidden, 1); + assert_ne!(with_config, template, "the fixture has a config packet"); + // The fixture shell without /NeedsRendering (blanked in place, so the offsets stay right). + let shell = |xdp: &str| { + let bytes = pdfcraft_xfa::fixtures::shell(xdp); + let at = bytes.windows(20).position(|w| w == b"/NeedsRendering true").expect("the fixture sets it"); + let mut bytes = bytes; + bytes.splice(at..at + 20, *b" "); + Arc::new(bytes) + }; + let mut s = Session::new().with_clock(|| 1_700_000_000); + let original = shell(&with_config); + let id = s.open("static.pdf", None, original.clone(), None).expect("opens"); + let doc = s.get(id).unwrap(); + assert_eq!(doc.info.xfa, Some(pdfcraft_render::Xfa::Static)); + assert!(doc.xfa.is_none(), "nothing was laid out"); + assert_eq!(doc.bytes[..], original[..], "no revision was appended on open"); + assert_eq!(doc.info.pages.len(), 1); + // The same form without the config is still taken to be dynamic: laid out from the template + // into an appended revision. + let plain = shell(&template); + let id = s.open("dynamic.pdf", None, plain.clone(), None).expect("opens"); + let doc = s.get(id).unwrap(); + assert!(doc.xfa.is_some(), "laid out from the template"); + assert!(doc.bytes.len() > plain.len() && doc.bytes.starts_with(&plain[..]), "the layout is a revision"); +} + +/// A static XFA form's fields take their values from the datasets on opening, and edits are +/// written back there. #[test] fn static_xfa_forms_take_their_values_from_the_datasets_and_write_them_back() { let original = Arc::new(pdfcraft_xfa::fixtures::static_shell("Ada1")); @@ -4184,6 +4533,56 @@ fn static_xfa_forms_take_their_values_from_the_datasets_and_write_them_back() { assert!(!d2.info.warnings.iter().any(|w| w.contains("taken from this form's XFA data")), "nothing was rewritten"); } +/// A date goes into the XFA data in the field's data picture (``), as other +/// viewers write and read it, and comes back from there when the form is opened again. It used +/// to be written as an ISO date whatever the picture said. +#[test] +fn xfa_dates_are_written_and_read_back_in_their_data_picture() { + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::bound_date_template(""))); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("form.pdf", None, bytes, None).expect("opens"); + s.apply(id, Edit::SetFieldValue { name: "due".into(), value: FieldValue::Text("10/18/2026".into()) }).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "when".into(), value: FieldValue::Text("10/18/2026".into()) }).unwrap(); + let saved = s.save_bytes(id).unwrap(); + let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap(); + let data = pdfcraft_xfa::parse_datasets(&pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp).unwrap(); + assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].page[0].due[0]")), Some("20261018"), "in the field's data picture"); + assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].page[0].when[0]")), Some("2026-10-18"), "ISO without one"); + let id2 = s.open("again.pdf", None, saved, None).unwrap(); + let d2 = s.get(id2).unwrap(); + let value = |n: &str| d2.form.iter().find(|f| f.name == n).unwrap_or_else(|| panic!("no field {n}")).value.clone(); + assert_eq!(value("due"), vec!["10/18/2026".to_string()]); + assert_eq!(value("when"), vec!["10/18/2026".to_string()]); +} + +/// Same-named date fields are each written in their own data picture, and a data picture +/// without a day keeps the ISO date. +#[test] +fn each_xfa_date_field_is_written_in_its_own_data_picture_and_a_partial_one_keeps_iso() { + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::sibling_date_template(""))); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("form.pdf", None, bytes, None).expect("opens"); + // Two siblings named `due` (laid out as `due` and `due_2`) and a field bound `YYYYMM`. + for name in ["due", "due_2", "month"] { + s.apply(id, Edit::SetFieldValue { name: name.into(), value: FieldValue::Text("10/18/2026".into()) }).unwrap(); + } + let saved = s.save_bytes(id).unwrap(); + let cos = pdfcraft_cos::Document::open(saved.clone()).unwrap(); + let data = pdfcraft_xfa::parse_datasets(&pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp).unwrap(); + let at = |som: &str| data.text_at(&pdfcraft_xfa::som_to_path(som)).map(str::to_string); + assert_eq!(at("form[0].page[0].due[0]").as_deref(), Some("20261018"), "the first sibling's data picture"); + assert_eq!(at("form[0].page[0].due[1]").as_deref(), Some("18102026"), "the second sibling's own"); + // A data picture without a day would drop the one the field shows: the data keeps ISO. + assert_eq!(at("form[0].page[0].month[0]").as_deref(), Some("2026-10-18")); + let id2 = s.open("again.pdf", None, saved, None).unwrap(); + let d2 = s.get(id2).unwrap(); + for name in ["due", "due_2", "month"] { + let f = d2.form.iter().find(|f| f.name == name).unwrap_or_else(|| panic!("no field {name}")); + assert_eq!(f.value, vec!["10/18/2026".to_string()], "{name} reads back"); + } +} + +/// Data no field binds to (other namespaces, history) survives an edit and a save. #[test] fn xfa_data_no_field_binds_to_survives_an_edit_and_save() { // Regression: editing a field replaced the whole data element with the fields' values. @@ -4279,6 +4678,8 @@ fn xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save() { assert!(taken[0].contains("langs") && !taken[0].contains("country"), "{}", taken[0]); } +/// A form's data exports, imports and takes pictures through the session, and what it holds +/// comes back the same. #[test] fn xfa_data_exports_imports_and_pictures_round_trip_through_the_session() { use pdfcraft_xfa::som_to_path; @@ -4337,6 +4738,11 @@ fn xfa_data_exports_imports_and_pictures_round_trip_through_the_session() { assert!(pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp.contains("xfa:contentType=\"image/gif\"")); assert_eq!(s.get(id).unwrap().page_images(0).len(), 2, "the field's picture (now the GIF) and the logo"); // Only pictures the form can show; only image fields. + for (data, declared) in [(pdfcraft_xfa::fixtures::tiny_bmp(false), "image/bmp"), (pdfcraft_xfa::fixtures::tiny_tiff(false), "image/tiff")] { + s.apply(id, Edit::SetFieldImage { name: "photo".into(), image: Arc::new(data) }).unwrap(); + let cos = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap(); + assert!(pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp.contains(&format!("xfa:contentType=\"{declared}\""))); + } assert!(s.apply(id, Edit::SetFieldImage { name: "photo".into(), image: Arc::new(b"BM\0\0\0\0".to_vec()) }).is_err()); assert!(s.apply(id, Edit::SetFieldImage { name: "country".into(), image: Arc::new(pdfcraft_xfa::fixtures::TINY_GIF.to_vec()) }).is_err()); // One datasets stream per edit, however many steps it took. @@ -4360,6 +4766,8 @@ fn xfa_data_exports_imports_and_pictures_round_trip_through_the_session() { assert_eq!(d2.page_images(0).len(), 2); } +/// Importing data into a static form replaces every value, emptying the fields the file +/// doesn't name, and pictures chosen for it reach its data. #[test] fn xfa_data_import_on_a_static_form_replaces_every_value_and_pictures_reach_its_data() { let original = Arc::new(pdfcraft_xfa::fixtures::static_shell("Ada1")); @@ -4380,6 +4788,8 @@ fn xfa_data_import_on_a_static_form_replaces_every_value_and_pictures_reach_its_ assert!(xml.contains("Bob") && !xml.contains("1"), "{xml}"); } +/// Importing data into a dynamic form adds and removes the rows of its repeating subform to +/// match, and the calculations over them run again. #[test] fn xfa_data_import_adds_and_removes_rows_of_a_dynamic_form() { let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template())); @@ -4412,6 +4822,8 @@ fn xfa_data_import_adds_and_removes_rows_of_a_dynamic_form() { assert!(String::from_utf8_lossy(&out).contains("only")); } +/// Hundreds of calculations, in JavaScript and FormCalc, run on opening in one engine within +/// the opening's budget, and a change recalculates them within the event's. #[test] fn hundreds_of_xfa_calculations_run_in_one_engine_within_the_open_budget() { let (sections, rows) = (10, 20); @@ -4442,11 +4854,15 @@ fn hundreds_of_xfa_calculations_run_in_one_engine_within_the_open_budget() { eprintln!("recalculated after a change in {:?}", started.elapsed()); } +/// The scripts of one event share the form as the engine holds it: a failed script's +/// half-done writes reach nobody, and a script reads the saved value a choice list was given +/// by its shown text. #[test] fn xfa_scripts_of_one_event_see_the_form_as_the_engine_holds_it() { // In document order: `a` holds 1; `b`'s calculate writes `a` then fails; `c`'s calculate - // reads `a`. A failed script's half-done changes reach nobody. And `d` sets a choice list - // by its shown text: the next script reads the saved value the engine kept. + // reads `a`. A failed script keeps what it changed before the error, and the next script + // sees it. And `d` sets a choice list by its shown text: the next script reads the saved + // value the engine kept. let tpl = pdfcraft_xfa::fixtures::fields_template("
JP").replace( r#""#, r#"1 @@ -4462,13 +4878,153 @@ fn xfa_scripts_of_one_event_see_the_form_as_the_engine_holds_it() { let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).map(|f| f.value.clone()).unwrap_or_default(); let out = s.take_js_output(id); assert!(out.errors.iter().any(|e| e.contains("boom")), "{out:?}"); - assert_eq!(value(&s, "a"), vec!["1".to_string()], "the failed script's write reached nothing"); - assert_eq!(value(&s, "c"), vec!["2".to_string()]); + assert_eq!(value(&s, "a"), vec!["99".to_string()], "the failed script's write before the error stands"); + assert_eq!(value(&s, "c"), vec!["100".to_string()]); assert_eq!(value(&s, "country"), vec!["CA".to_string()], "shown text mapped to the saved value"); assert_eq!(value(&s, "e"), vec!["CA".to_string()], "the next script reads the saved value"); assert_eq!(value(&s, "summary"), vec!["Country: CA".to_string()]); } +/// A row of dates worked out the way leave forms do it: the calculation checks that the raw value +/// is a whole ISO date, then adds days to the date as `formattedValue` shows it (`YYYYMMDD`). +#[test] +fn xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it() { + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::date_row_template())); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("row.pdf", None, bytes, None).expect("opens"); + let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).map(|f| f.value.clone()).unwrap_or_default(); + s.apply(id, Edit::SetFieldValue { name: "from".into(), value: FieldValue::Text("20261020".into()) }).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "days".into(), value: FieldValue::Text("3".into()) }).unwrap(); + let out = s.take_js_output(id); + assert_eq!(value(&s, "to"), ["20261022"], "{:?}", out.errors); +} + +#[test] +fn filling_growable_xfa_text_reflows_and_undo_restores_the_layout() { + let text: String = (0..150).map(|i| format!("Row{i:03} {}sample\n", "sample ".repeat(8))).collect(); + assert_eq!(text.len(), 10_500); + for scripted in [false, true] { + let template = pdfcraft_xfa::fixtures::growable_text_template(scripted); + let mut s = Session::new(); + let id = s.open("growing.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&template)), None).unwrap(); + assert_eq!(s.get(id).unwrap().info.pages.len(), 2); + s.apply(id, Edit::SetFieldValue { name: "ACTIVIDADES".into(), value: FieldValue::Text(text.clone()) }).unwrap(); + assert_eq!(s.get(id).unwrap().info.pages.len(), 3, "scripted={scripted}"); + let data = pdfcraft_xfa::data_of(&s.get(id).unwrap().editor.as_ref().unwrap().cos).unwrap(); + assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].Page3[0].ACTIVIDADES[0]")), Some(text.as_str())); + s.undo(id).unwrap(); + assert_eq!(s.get(id).unwrap().info.pages.len(), 2); + assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "ACTIVIDADES").unwrap().value, vec!["Short"]); + s.redo(id).unwrap(); + assert_eq!(s.get(id).unwrap().info.pages.len(), 3); + } +} + +#[test] +fn xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets() { + for fixed in [false, true] { + let size = if fixed { r#"h="30pt""# } else { r#"minH="20pt""# }; + let template = pdfcraft_xfa::fixtures::growable_text_template(false).replace(r#"minH="207.017mm""#, size); + let mut s = Session::new(); + let id = s.open("geometry.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&template)), None).unwrap(); + let field = |s: &Session| s.get(id).unwrap().form.iter().find(|f| f.name == "ACTIVIDADES").unwrap().clone(); + let before = field(&s); + s.apply(id, Edit::SetFieldValue { name: "ACTIVIDADES".into(), value: FieldValue::Text("Line\n".repeat(10)) }).unwrap(); + let after = field(&s); + assert_eq!(s.get(id).unwrap().info.pages.len(), 2); + if fixed { + assert_eq!(before.obj, after.obj, "fixed-height fields must not trigger layout"); + assert_eq!(before.widgets[0].rect, after.widgets[0].rect); + } else { + assert_ne!(before.obj, after.obj); + assert!(after.widgets[0].rect[3] - after.widgets[0].rect[1] > before.widgets[0].rect[3] - before.widgets[0].rect[1]); + assert_eq!( + s.get(id).unwrap().info.fields.iter().find(|f| f.name == "ACTIVIDADES").unwrap().value.as_deref(), + Some("Line\n".repeat(10).trim_end()) + ); + } + s.undo(id).unwrap(); + assert_eq!(field(&s).widgets[0].rect, before.widgets[0].rect); + s.redo(id).unwrap(); + assert_eq!(field(&s).widgets[0].rect, after.widgets[0].rect); + } +} + +#[test] +fn xfa_text_that_cannot_sync_to_structured_data_is_not_replaced_by_a_relayout() { + let template = pdfcraft_xfa::fixtures::growable_text_template(false) + .replace("Short", "Short"); + let mut s = Session::new(); + let id = s.open("structured.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&template)), None).unwrap(); + let text = "Keep this accepted value\n".repeat(150); + s.apply(id, Edit::SetFieldValue { name: "ACTIVIDADES".into(), value: FieldValue::Text(text.clone()) }).unwrap(); + let doc = s.get(id).unwrap(); + assert_eq!(doc.info.pages.len(), 2); + assert_eq!(doc.form.iter().find(|f| f.name == "ACTIVIDADES").unwrap().value, vec![text]); + assert!(doc.xfa_warnings.iter().any(|w| w.contains("structured content"))); +} + +#[test] +fn xfa_structured_data_whitespace_edits_survive_without_stale_relayout() { + let template = pdfcraft_xfa::fixtures::growable_text_template(false) + .replace("Short", "Short"); + for text in ["Short\n", "\nShort", " Short ", "\tShort\t"] { + let mut s = Session::new(); + let id = s.open("structured.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&template)), None).unwrap(); + let field = |s: &Session| s.get(id).unwrap().form.iter().find(|f| f.name == "ACTIVIDADES").unwrap().clone(); + let before = field(&s); + s.apply(id, Edit::SetFieldValue { name: "ACTIVIDADES".into(), value: FieldValue::Text(text.into()) }).unwrap(); + assert_eq!(field(&s).value, vec![text], "the accepted whitespace must not be replaced from stale datasets"); + assert_eq!(field(&s).obj, before.obj, "a skipped data write must not rebuild the widgets"); + let doc = s.get(id).unwrap(); + assert!(doc.dirty); + assert!(doc.xfa_warnings.iter().any(|w| w.contains("structured content")), "{:?}", doc.xfa_warnings); + let cos = pdfcraft_cos::Document::open(doc.bytes.clone()).unwrap(); + let xdp = pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp; + assert!(xdp.contains("Short")); + s.undo(id).unwrap(); + assert_eq!(field(&s).value, before.value); + s.redo(id).unwrap(); + assert_eq!(field(&s).value, vec![text]); + } +} + +/// A script that fails partway keeps what it did before the error, as JavaScript does: a form +/// whose check box opens the next boxes and then trips over an object the form no longer has +/// still opens them (DA Form 31's leave types). +#[test] +fn an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error() { + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::partly_failing_script_template())); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("form.pdf", None, bytes, None).expect("opens"); + let field = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).cloned(); + assert!(field(&s, "leave").is_some_and(|f| f.flags & 1 != 0), "protected until `kind` is checked"); + assert!(field(&s, "details").is_none(), "invisible: not laid out"); + s.take_js_output(id); + s.apply(id, Edit::SetFieldValue { name: "kind".into(), value: FieldValue::Check(true) }).unwrap(); + let out = s.take_js_output(id); + assert!(out.errors.iter().any(|e| e.contains("kind") && e.contains("change")), "the error is still reported: {out:?}"); + assert!(field(&s, "leave").is_some_and(|f| f.flags & 1 == 0), "opened before the error"); + assert!(field(&s, "details").is_some(), "shown before the error"); + s.apply(id, Edit::SetFieldValue { name: "leave".into(), value: FieldValue::Check(true) }).expect("can be checked now"); +} + +/// A scripted form end to end: initialize and calculate on open, recalculation after a change, +/// a failing validation's message, buttons that add and remove rows and show messages, a check +/// box that shows a subform (undoable), FormCalc, and a save and reopen that keep it all. +#[test] +fn repeated_xfa_print_buttons_run_their_click_script() { + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::repeated_print_button_template())); + let mut s = Session::new(); + let id = s.open("print.pdf", None, bytes, None).unwrap(); + assert_eq!(s.get(id).unwrap().info.pages.len(), 3); + for name in ["PrintButton1", "PrintButton1_2", "PrintButton1_3"] { + let out = s.run_javascript(id, "throw new Error('AcroForm fallback ran')", Some(name)).unwrap(); + assert_eq!(out.error, None, "{name}: {out:?}"); + assert_eq!(out.requests, vec![pdfcraft_js::Request::Print], "{name}: {out:?}"); + } +} + #[test] fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() { let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template())); @@ -5828,11 +6384,14 @@ fn marking_a_figure_decorative_keeps_other_page_streams() { let page = &pdfcraft_model::pages(&doc)[0]; let contents = page.dict.get(b"Contents").and_then(|c| c.as_array()).unwrap(); assert_eq!(contents.len(), 3, "the other streams stay in the page's content list"); + let figure_ref = contents[0].as_ref().unwrap(); + assert_eq!(contents[1].as_ref(), Some(pdfcraft_cos::ObjRef::new(5, 0))); + assert_eq!(contents[2].as_ref(), Some(pdfcraft_cos::ObjRef::new(6, 0))); let stream = |doc: &pdfcraft_cos::Document, n: u32| match &*doc.get(pdfcraft_cos::ObjRef::new(n, 0)) { pdfcraft_cos::Object::Stream(s) => s.clone(), other => panic!("object {n} is {other:?}"), }; - let figure = stream(&doc, 4); + let figure = stream(&doc, figure_ref.num); assert_eq!(figure.dict.get(b"VendorKey").and_then(pdfcraft_cos::Object::as_int), Some(7)); let drawn = String::from_utf8(figure.decoded().unwrap()).unwrap(); assert!(drawn.contains("/Artifact BMC") && !drawn.contains("/MCID 0"), "{drawn}"); @@ -5855,7 +6414,7 @@ fn marking_a_figure_decorative_keeps_other_page_streams() { .collect(); assert!(!text.contains("HeaderSample")); assert_eq!(pdfcraft_edit::scan_markers(&doc).added[0].content, before.added[0].content); - assert_eq!(stream(&doc, 4).dict.get(b"VendorKey").and_then(pdfcraft_cos::Object::as_int), Some(7)); + assert_eq!(stream(&doc, figure_ref.num).dict.get(b"VendorKey").and_then(pdfcraft_cos::Object::as_int), Some(7)); } /// Print as image (Acrobat's Advanced Print Setup): the print-ready PDF carries each page as a @@ -6106,3 +6665,438 @@ fn undo_never_makes_an_unsaved_document_clean() { s.undo(new).unwrap(); assert!(!s.get(new).unwrap().dirty, "once saved, undo back to the saved state is clean"); } + +#[test] +fn undo_and_redo_roll_back_on_a_refresh_panic_or_error() { + let (mut s, id) = session_with(1); + s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + let (bytes_90, gen_90) = { + let d = s.get(id).unwrap(); + (d.bytes.clone(), d.generation) + }; + + // 1. A panic during undo's refresh catches the unwind and rolls back cos, undo/redo, info, and working bytes. + panic_on_refresh(1); + let err = s.undo(id).unwrap_err(); + assert_eq!(err, EditError::Reopen("injected refresh panic".into())); + { + let d = s.get(id).unwrap(); + let ed = d.editor.as_ref().unwrap(); + assert_eq!(d.info.pages[0].rotation, 90); + assert_eq!(d.bytes, bytes_90); + assert_eq!(d.generation, gen_90); + assert!(d.dirty); + assert!(ed.cos.is_modified()); + assert_eq!(ed.undo.len(), 1); + assert!(ed.redo.is_empty()); + } + let _ = shown(&s, id, 0); + + // 2. If the rollback refresh also panics, `show_released` restores the renderer so the page still draws. + panic_on_refresh(2); + assert_eq!(s.undo(id), Err(EditError::Reopen("injected refresh panic".into()))); + assert_eq!(s.get(id).unwrap().editor.as_ref().unwrap().undo.len(), 1); + let _ = shown(&s, id, 0); + + // Undo now succeeds cleanly. + assert_eq!(s.undo(id).unwrap().as_str(), "Rotate page"); + let (bytes_0, gen_0) = { + let d = s.get(id).unwrap(); + assert_eq!(d.info.pages[0].rotation, 0); + assert!(!d.dirty); + (d.bytes.clone(), d.generation) + }; + + // 3. A panic during redo's refresh catches the unwind and rolls back to the undone state. + panic_on_refresh(1); + let err = s.redo(id).unwrap_err(); + assert_eq!(err, EditError::Reopen("injected refresh panic".into())); + { + let d = s.get(id).unwrap(); + let ed = d.editor.as_ref().unwrap(); + assert_eq!(d.info.pages[0].rotation, 0); + assert_eq!(d.bytes, bytes_0); + assert_eq!(d.generation, gen_0); + assert!(!d.dirty); + assert!(!ed.cos.is_modified()); + assert!(ed.undo.is_empty()); + assert_eq!(ed.redo.len(), 1); + } + let _ = shown(&s, id, 0); + + // 4. If redo's rollback refresh also panics, `show_released` restores the renderer. + panic_on_refresh(2); + assert_eq!(s.redo(id), Err(EditError::Reopen("injected refresh panic".into()))); + assert_eq!(s.get(id).unwrap().editor.as_ref().unwrap().redo.len(), 1); + let _ = shown(&s, id, 0); + + // Redo now succeeds cleanly. + assert_eq!(s.redo(id).unwrap().as_str(), "Rotate page"); + assert_eq!(s.get(id).unwrap().info.pages[0].rotation, 90); + + // 5. Metadata scope: a panic after `doc.info.title` was written rolls back `info.title` and keeps `edit_notices`. + s.apply(id, Edit::SetInfo { key: "Title".into(), value: "Draft".into() }).unwrap(); + let notice = TextNotice { field: None, page: 0, ch: '☺', redrawn_elsewhere: false }; + s.doc_mut(id).unwrap().edit_notices.push(notice.clone()); + panic_on_refresh(1); + assert_eq!(s.undo(id), Err(EditError::Reopen("injected refresh panic".into()))); + assert_eq!(s.get(id).unwrap().info.title.as_deref(), Some("Draft")); + assert_eq!(s.get(id).unwrap().edit_notices, [notice]); + assert_eq!(s.undo(id).unwrap().as_str(), "Change Title"); + assert_eq!(s.get(id).unwrap().info.title, None); + assert!(s.get(id).unwrap().edit_notices.is_empty()); + + panic_on_refresh(1); + assert_eq!(s.redo(id), Err(EditError::Reopen("injected refresh panic".into()))); + assert_eq!(s.get(id).unwrap().info.title, None); + assert_eq!(s.redo(id).unwrap().as_str(), "Change Title"); + assert_eq!(s.get(id).unwrap().info.title.as_deref(), Some("Draft")); + + // 6. Protection keys: undo/redo rollback restores `editor.keys` and `doc.password` before re-inspecting. + let protect = Protection { open_password: Some("u".into()), permissions_password: Some("o".into()), ..Protection::default() }; + s.apply(id, Edit::Protect(protect)).unwrap(); + assert!(s.get(id).unwrap().info.encrypted); + panic_on_refresh(1); + assert_eq!(s.undo(id), Err(EditError::Reopen("injected refresh panic".into()))); + assert!(s.get(id).unwrap().info.encrypted); + assert_eq!(s.get(id).unwrap().password.as_deref(), Some("u")); + assert_eq!(s.undo(id).unwrap().as_str(), "Protect with password"); + assert!(!s.get(id).unwrap().info.encrypted); + assert_eq!(s.get(id).unwrap().password, None); + + panic_on_refresh(1); + assert_eq!(s.redo(id), Err(EditError::Reopen("injected refresh panic".into()))); + assert!(!s.get(id).unwrap().info.encrypted); + assert_eq!(s.get(id).unwrap().password, None); + assert_eq!(s.redo(id).unwrap().as_str(), "Protect with password"); + assert!(s.get(id).unwrap().info.encrypted); + assert_eq!(s.get(id).unwrap().password.as_deref(), Some("u")); +} + +#[test] +fn a_bookmark_added_at_a_point_goes_there_through_undo_and_save() { + use pdfcraft_render::DestView; + let (mut s, id) = session_with(3); + let view = |s: &Session, i: usize| s.get(id).unwrap().info.outline.get(i).map(|o| (o.page, o.view)); + let add = |s: &mut Session, title: &str, page: usize, left: Option, top: Option| { + s.apply(id, Edit::AddBookmark { parent: vec![], index: usize::MAX, title: title.into(), page, left, top }) + }; + add(&mut s, "Plain", 0, None, None).unwrap(); + add(&mut s, "Section", 1, None, Some(500.5)).unwrap(); + add(&mut s, "Table", 2, Some(72.0), Some(300.0)).unwrap(); + // The page top (no position) is what it always was; the others carry their place. + assert_eq!(view(&s, 0), Some((Some(0), DestView::Xyz { left: None, top: None, zoom: None }))); + assert_eq!(view(&s, 1), Some((Some(1), DestView::Xyz { left: None, top: Some(500.5), zoom: None }))); + assert_eq!(view(&s, 2), Some((Some(2), DestView::Xyz { left: Some(72.0), top: Some(300.0), zoom: None }))); + + // A position that is not a number fails the edit, which then changes nothing. + for (left, top) in [(Some(f64::NAN), None), (None, Some(f64::INFINITY))] { + assert!(matches!(add(&mut s, "Bad", 0, left, top), Err(EditError::Bookmark(_))), "{left:?} {top:?}"); + } + assert_eq!(s.get(id).unwrap().info.outline.len(), 3); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Add bookmark")); + + s.undo(id).unwrap(); + assert_eq!(s.get(id).unwrap().info.outline.len(), 2); + s.redo(id).unwrap(); + let saved = s.save_bytes(id).unwrap(); + let mut again = Session::new(); + let id2 = again.open("again.pdf", None, saved, None).unwrap(); + let outline = &again.get(id2).unwrap().info.outline; + assert_eq!(outline[2].view, DestView::Xyz { left: Some(72.0), top: Some(300.0), zoom: None }); + assert_eq!(outline[1].view, DestView::Xyz { left: None, top: Some(500.5), zoom: None }); +} + +/// #3470: a filled form whose writer left the appearances to the viewer (`/NeedAppearances`): +/// field A has an empty appearance, B none. The page as displayed (and printed) shows both +/// values, and a comment without an appearance beside them still gets its own; the file itself +/// is not changed, so saving writes it as it was. +#[test] +fn fields_left_to_the_viewer_are_displayed_from_their_values() { + let pdf = |need: bool| { + let flag = if need { "/NeedAppearances true " } else { "" }; + format!( + "%PDF-1.7\n\ + 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 5 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 400 200] /Annots [6 0 R 7 0 R 10 0 R] >> endobj\n\ + 10 0 obj << /Type /Annot /Subtype /Square /Rect [360 20 390 50] /C [1 0 0] /BS << /W 4 >> >> endobj\n\ + 5 0 obj << /Fields [6 0 R 7 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 8 0 R >> >> >> endobj\n\ + 6 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (a) /V (MMMMMMMM) /DA (/Helv 12 Tf 0 g) /Rect [50 120 350 140] /F 4 /P 3 0 R /AP << /N 9 0 R >> >> endobj\n\ + 7 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (b) /V (MMMMMMMM) /DA (/Helv 12 Tf 0 g) /Rect [50 60 350 80] /F 4 /P 3 0 R >> endobj\n\ + 8 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\ + 9 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 300 20] /Length 4 >> stream\nq\nQ\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF" + ) + .into_bytes() + }; + let red = |s: &Session, id: DocId| { + let doc = s.get(id).unwrap(); + let mut r = pdfcraft_render::PageRenderer::new(doc.display.clone(), doc.config.clone()); + let page = r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + page.rgba.as_chunks::<4>().0.iter().filter(|p| p[0] > 200 && p[1] < 80 && p[2] < 80).count() + }; + // Dark pixels inside a field's box (user-space rectangle, page drawn at scale 1). + let ink = |s: &Session, id: DocId, y0: u32, y1: u32| { + let doc = s.get(id).unwrap(); + let mut r = pdfcraft_render::PageRenderer::new(doc.display.clone(), doc.config.clone()); + let page = r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + let (px, _) = page.rgba.as_chunks::<4>(); + (200 - y1..200 - y0) + .flat_map(|y| (55..345).map(move |x| (x, y))) + .filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(|p| p[0] < 128)) + .count() + }; + let mut s = Session::new(); + let id = s.open("form.pdf", None, Arc::new(pdf(true)), None).unwrap(); + let (a, b) = (ink(&s, id, 122, 138), ink(&s, id, 62, 78)); + assert!(a > 100 && b > 100, "both values are shown: a {a}, b {b}"); + assert!(red(&s, id) > 100, "the comment is drawn too"); + // The file is saved as it was: B still has no appearance, A its empty one. + let saved = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap(); + let fields = pdfcraft_forms::fields(&saved); + let widget = |name: &str| saved.get(fields.iter().find(|f| f.name == name).unwrap().widgets[0].obj); + assert!(widget("b").as_dict().is_some_and(|d| !d.contains(b"AP")), "B has no appearance"); + let n = widget("a").as_dict().and_then(|d| d.get(b"AP").and_then(|ap| saved.resolve(ap).as_dict().and_then(|ap| ap.reference(b"N")))).unwrap(); + let pdfcraft_cos::Object::Stream(n) = &*saved.get(n) else { panic!("A's appearance is a stream") }; + assert_eq!(n.decoded().unwrap().trim_ascii_end(), b"q\nQ", "A keeps its empty appearance"); + // Typing in one field redraws that field only, not the whole form, on each keystroke. + let builds = |s: &Session| s.get(id).unwrap().editor.as_ref().unwrap().field_appearances.builds(); + let before = builds(&s); + s.apply(id, Edit::SetFieldValue { name: "a".into(), value: pdfcraft_forms::FieldValue::Text("Typed".into()) }).unwrap(); + assert_eq!(builds(&s), before + 1); + + // Without the flag, the empty appearance is the file's own, and only B is drawn. + let id = s.open("form.pdf", None, Arc::new(pdf(false)), None).unwrap(); + let (a, b) = (ink(&s, id, 122, 138), ink(&s, id, 62, 78)); + assert!(a == 0 && b > 100, "a {a}, b {b}"); +} +/// Reproducible workload with 3,129 fields and 190 ordered custom calculations. +/// No external PDF assets are needed; only in-memory contributor-original objects. +fn synthetic_acroform(field_count: usize, calculation_count: usize) -> Vec { + use pdfcraft_cos::{Dict, Object, PdfString}; + let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(6))).unwrap(); + let mut refs = Vec::new(); + let mut order = Vec::new(); + for i in 0..field_count { + let name = match i { + 0 => "ranks".to_string(), + 1 => "trained".to_string(), + 2 => "bonus".to_string(), + _ => format!("field{i}"), + }; + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Annot")); + d.set(b"Subtype".to_vec(), Object::name("Widget")); + d.set(b"FT".to_vec(), Object::name(if i == 1 { "Btn" } else { "Tx" })); + d.set(b"T".to_vec(), PdfString::text(&name)); + d.set(b"V".to_vec(), if i == 1 { Object::name("Off") } else { Object::String(PdfString::text("0")) }); + d.set(b"Rect".to_vec(), Object::Array([20, 20, 100, 40].map(Object::Int).to_vec())); + d.set(b"P".to_vec(), Object::Ref(pdfcraft_cos::ObjRef::new(4, 0))); + if (2..calculation_count + 2).contains(&i) { + let js = if i == 2 { + "var r = Number(getField('ranks').value); event.value = r + (r > 0 && getField('trained').isBoxChecked(0) ? 3 : 0);".to_string() + } else { + format!("event.value = Number(getField('{}').value) + 1;", if i == 3 { "bonus".to_string() } else { format!("field{}", i - 1) }) + }; + let mut a = Dict::new(); + a.set(b"S".to_vec(), Object::name("JavaScript")); + a.set(b"JS".to_vec(), PdfString::text(&js)); + let mut aa = Dict::new(); + aa.set(b"C".to_vec(), Object::Dict(a)); + d.set(b"AA".to_vec(), Object::Dict(aa)); + } + let r = doc.add(Object::Dict(d)); + refs.push(Object::Ref(r)); + if (2..calculation_count + 2).contains(&i) { + order.push(Object::Ref(r)); + } + } + doc.update_dict(pdfcraft_cos::ObjRef::new(4, 0), |d| d.set(b"Annots".to_vec(), Object::Array(refs.clone()))).unwrap(); + let mut af = Dict::new(); + af.set(b"Fields".to_vec(), Object::Array(refs)); + af.set(b"CO".to_vec(), Object::Array(order)); + doc.update_dict(doc.root().unwrap(), |d| d.set(b"AcroForm".to_vec(), Object::Dict(af))).unwrap(); + pdfcraft_cos::write_incremental(&doc, &Default::default()).unwrap() +} + +#[test] +#[ignore = "manual performance measurement: 3,129 fields, 190 scripts"] +fn acroform_interaction_benchmark() { + let bytes = Arc::new(synthetic_acroform(3129, 190)); + // Optional local export for comparing CLI build profiles. Never overwrite an input. + if let Some(path) = std::env::var_os("PDFCRAFT_BENCH_EXPORT") { + use std::io::Write; + let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(path).unwrap(); + file.write_all(&bytes).unwrap(); + } + let mut s = Session::new(); + let id = s.open("synthetic-large-form.pdf", None, bytes, None).unwrap(); + let value = |s: &Session, name: &str| s.get(id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone(); + for (name, v) in [ + ("ranks", FieldValue::Text("1".into())), + ("trained", FieldValue::Check(true)), + ("ranks", FieldValue::Text("2".into())), + ("trained", FieldValue::Check(false)), + ("ranks", FieldValue::Text("0".into())), + ] { + let started = std::time::Instant::now(); + s.apply(id, Edit::SetFieldValue { name: name.into(), value: v }).unwrap(); + eprintln!("BENCH {name}: {:.3} ms", started.elapsed().as_secs_f64() * 1000.0); + assert!(s.take_js_output(id).errors.is_empty()); + } + assert_eq!(value(&s, "bonus"), ["0"]); + assert_eq!(value(&s, "field191"), ["189"]); + assert_eq!(value(&s, "field3128"), ["0"]); +} + +#[test] +fn checkbox_and_rank_changes_recalculate_ordered_dependents() { + let mut s = Session::new(); + let id = s.open("skills.pdf", None, Arc::new(synthetic_acroform(8, 4)), None).unwrap(); + for (name, input, bonus) in [ + ("trained", FieldValue::Check(true), "0"), + ("ranks", FieldValue::Text("1".into()), "4"), + ("trained", FieldValue::Check(false), "1"), + ("ranks", FieldValue::Text("2".into()), "2"), + ("trained", FieldValue::Check(true), "5"), + ("ranks", FieldValue::Text("0".into()), "0"), + ] { + s.apply(id, Edit::SetFieldValue { name: name.into(), value: input }).unwrap(); + let fields = &s.get(id).unwrap().form; + let value = |name: &str| fields.iter().find(|f| f.name == name).unwrap().value.clone(); + assert_eq!(value("bonus"), [bonus]); + assert_eq!(value("field5"), [(bonus.parse::().unwrap() + 3).to_string()]); + assert_eq!(value("field7"), ["0"]); + assert!(s.take_js_output(id).errors.is_empty()); + } + s.undo(id).unwrap(); + assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "bonus").unwrap().value, ["5"]); + let saved = s.save_bytes(id).unwrap(); + let reopened = s.open("saved.pdf", None, saved, None).unwrap(); + assert_eq!(s.get(reopened).unwrap().form.iter().find(|f| f.name == "bonus").unwrap().value, ["5"]); + let appearance = |s: &Session| { + let doc = s.get(id).unwrap(); + let widget = doc.form.iter().find(|f| f.name == "bonus").unwrap().widgets.first().unwrap().obj; + doc.editor.as_ref().unwrap().cos.get(widget).as_dict().unwrap().get(b"AP").cloned() + }; + let before = appearance(&s); + assert!(before.is_some()); + s.apply(id, Edit::SetFieldValue { name: "field7".into(), value: FieldValue::Text("9".into()) }).unwrap(); + assert_eq!(appearance(&s), before, "unchanged calculated values keep their appearance streams"); +} + +#[test] +fn dynamic_calculation_reads_and_side_effects_reach_later_fields() { + use pdfcraft_cos::{Dict, Object, PdfString}; + let mut cos = pdfcraft_cos::Document::open(Arc::new(synthetic_acroform(8, 4))).unwrap(); + let fields = pdfcraft_forms::fields(&cos); + let bonus = fields.iter().find(|f| f.name == "bonus").unwrap(); + let selector = fields.iter().find(|f| f.name == "field7").unwrap(); + cos.update_dict(selector.obj, |d| d.set(b"V".to_vec(), PdfString::text("ranks"))).unwrap(); + let mut a = Dict::new(); + a.set(b"S".to_vec(), Object::name("JavaScript")); + a.set(b"JS".to_vec(), PdfString::text( + "var key = getField('field7').value; var v = Number(getField(key).value); getField('field6').value = v * 2; getField('field6').readonly = true; event.value = v + 1;" + )); + let mut aa = Dict::new(); + aa.set(b"C".to_vec(), Object::Dict(a)); + cos.update_dict(bonus.obj, |d| d.set(b"AA".to_vec(), Object::Dict(aa))).unwrap(); + let bytes = pdfcraft_cos::write_incremental(&cos, &Default::default()).unwrap(); + let mut s = Session::new(); + let id = s.open("dynamic.pdf", None, Arc::new(bytes), None).unwrap(); + s.apply(id, Edit::SetFieldValue { name: "ranks".into(), value: FieldValue::Text("4".into()) }).unwrap(); + let fields = &s.get(id).unwrap().form; + let field = |name: &str| fields.iter().find(|f| f.name == name).unwrap(); + assert_eq!(field("bonus").value, ["5"]); + assert_eq!(field("field5").value, ["8"], "later calculations see the ordered result"); + assert_eq!(field("field6").value, ["8"], "side-effect value applied"); + assert!(field("field6").read_only(), "side-effect property applied"); + assert_eq!(field("field7").value, ["ranks"]); + assert!(s.take_js_output(id).errors.is_empty()); +} + +#[test] +fn scripted_display_changes_survive_warm_cache_and_new_edit() { + use pdfcraft_forms::{FieldEvent, Scripts}; + let mut cos = pdfcraft_cos::Document::open(Arc::new(synthetic_acroform(2, 0))).unwrap(); + for (display, flags) in [(1, 6), (3, 36), (2, 0), (0, 4)] { + let fields = pdfcraft_forms::fields(&cos); + let target = fields.iter().find(|f| f.name == "ranks").unwrap(); + let other = fields.iter().find(|f| f.name == "trained").unwrap(); + let old_display = crate::js::field_state(&cos, other).display; + let mut runner = crate::js::JsRunner::new(&cos, "synthetic.pdf"); + // Populate the display cache before a script writes the same field. + let warm = runner.run(FieldEvent::Calculate, "event.value = getField('trained').display;", target, "", &fields); + assert_eq!(warm.value, old_display.to_string()); + let result = runner.run( + FieldEvent::Calculate, + &format!("getField('trained').display = {display}; event.value = getField('trained').display;"), + target, + "", + &fields, + ); + assert_eq!(result.value, display.to_string()); + assert_eq!(result.changes.len(), 1); + assert_eq!(result.changes.first().unwrap().display, Some(display)); + pdfcraft_forms::apply_script_changes(&mut cos, &result.changes, &mut pdfcraft_forms::NoScripts).unwrap(); + assert_eq!(cos.get(other.widgets.first().unwrap().obj).as_dict().unwrap().int(b"F").unwrap() & 38, flags); + // Within an edit the original implementation reads an immutable pre-edit COS snapshot. + let same_edit = runner.run(FieldEvent::Calculate, "event.value = getField('trained').display;", target, "", &fields); + assert_eq!(same_edit.value, old_display.to_string()); + // The next edit must rebuild its cache from the newly written annotation flags. + let mut next = crate::js::JsRunner::new(&cos, "synthetic.pdf"); + let read = next.run(FieldEvent::Calculate, "event.value = getField('trained').display;", target, "", &pdfcraft_forms::fields(&cos)); + assert_eq!(read.value, display.to_string()); + assert!(read.changes.is_empty()); + assert!(runner.output.errors.is_empty()); + assert!(next.output.errors.is_empty()); + } +} + +/// #2992: a page coloured with Edit a PDF ▸ Background exports to Word with that page colour, +/// behind its text. +#[test] +fn exporting_to_word_keeps_the_page_background() { + let mut s = Session::new(); + let text = s.create_from_text("t", "Blue text on teal").unwrap(); + let id = s.open("t.pdf", None, text, None).unwrap(); + assert_eq!(s.get(id).unwrap().export_pages()[0].background, None, "no background yet"); + s.apply( + id, + Edit::AddBackground { + pages: vec![0], + settings: Background { color: [0.0, 0.5, 0.5], opacity: 1.0, ..Background::default() }, + replace: false, + file: None, + }, + ) + .unwrap(); + let d = s.get(id).unwrap(); + let page = &d.export_pages()[0]; + assert!(page.background.is_some_and(|c| c.iter().zip([0.0, 0.5, 0.5]).all(|(a, b)| (a - b).abs() < 1e-6)), "{:?}", page.background); + assert!(page.blocks.iter().any(|b| b.text.contains("Blue text")), "the text is still there"); + let docx = d.export_office(compare::OfficeFormat::Docx); + assert!(docx.windows(b"word/settings.xml".len()).any(|w| w == b"word/settings.xml"), "Word is told to show the page colour"); +} + +#[test] +fn exporting_office_keeps_dotted_leader_entries_as_paragraphs() { + let mut content = String::new(); + for (title, page, y) in [("Art.1. Scope", "3", 200), ("Art.2. Eligibility", "7", 180), ("Art.3. Application", "12", 160)] { + content.push_str(&format!( + "BT /F1 12 Tf 72 {y} Td ({title}) Tj ET \ + BT /F1 12 Tf 250 {y} Td (....................) Tj ET \ + BT /F1 12 Tf 500 {y} Td ({page}) Tj ET\n" + )); + } + let pdf = one_line_page("/Font << /F1 4 0 R >>", &content); + let mut session = Session::new(); + let id = session.open("contents.pdf", None, Arc::new(pdf), None).unwrap(); + let doc = session.get(id).unwrap(); + let html = String::from_utf8(doc.export_office(compare::OfficeFormat::Html)).unwrap(); + assert!(!html.contains("
The find bar showing match 10 of 16 for the word 'type', highlighted in the Expressive Type chapter heading
"), "contents entries are paragraphs: {html}"); + let entries = ["Art.1. Scope .................... 3", "Art.2. Eligibility .................... 7", "Art.3. Application .................... 12"]; + let positions: Vec<_> = entries.iter().map(|entry| html.find(&format!("

{entry}

")).expect("one paragraph per entry")).collect(); + assert!(positions.windows(2).all(|pair| pair[0] < pair[1]), "reading order: {html}"); +} diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index 0f37933af..6ce76548f 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -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>, 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 { 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 { - 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 { + // 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) } diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 30299b2d7..f3030ad58 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -49,6 +49,9 @@ pub struct Page { pub height: f64, pub blocks: Vec, pub images: Vec, + /// 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 { + 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 { @@ -728,10 +739,13 @@ pub fn docx(pages: &[Page], title: &str) -> Vec { } 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!("")).unwrap_or_default(); let doc = format!( "\n\ {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}{body}\ " ); let mut types = String::from( @@ -742,6 +756,11 @@ xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawin \ ", ); + if page_colour.is_some() { + types.push_str( + "", + ); + } types.push_str(""); let rels = "\n\ \ @@ -756,6 +775,9 @@ xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawin i + 1 )); } + if page_colour.is_some() { + doc_rels.push_str(""); + } doc_rels.push_str(""); let styles = "\n\ \ @@ -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"\n", 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").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(""), "{xml}"); + assert!(part(&zip, "word/settings.xml").contains("")); + 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(" 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(""), "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("
"), "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("
"), "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("
"), "tall two-column text must stay paragraphs: {h}"); diff --git a/crates/export/src/pptx.rs b/crates/export/src/pptx.rs index fc02c20c1..1a7250d9e 100644 --- a/crates/export/src/pptx.rs +++ b/crates/export/src/pptx.rs @@ -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(""), "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"); diff --git a/crates/fonts/build.rs b/crates/fonts/build.rs index 9be57030e..397f6fc38 100644 --- a/crates/fonts/build.rs +++ b/crates/fonts/build.rs @@ -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 { 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; } diff --git a/crates/fonts/src/craft.rs b/crates/fonts/src/craft.rs index 099179cf2..e6103a5ed 100644 --- a/crates/fonts/src/craft.rs +++ b/crates/fonts/src/craft.rs @@ -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. diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index a1f2ff277..ec103ed5f 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -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 { wrap_with(text, size, width, helvetica_width) @@ -78,7 +90,8 @@ pub fn wrap(text: &str, size: f64, width: f64) -> Vec { /// [`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 { 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); diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 26685866b..0aaed6939 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -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 { 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 { + 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> 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] diff --git a/crates/fonts/src/std14.rs b/crates/fonts/src/std14.rs index 0556b112c..aae657501 100644 --- a/crates/fonts/src/std14.rs +++ b/crates/fonts/src/std14.rs @@ -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] diff --git a/crates/forms/README.md b/crates/forms/README.md index 2ba38097e..ac48d6c58 100644 --- a/crates/forms/README.md +++ b/crates/forms/README.md @@ -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 = 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 diff --git a/crates/forms/src/af.rs b/crates/forms/src/af.rs index ab7fc327c..39e3b8a6d 100644 --- a/crates/forms/src/af.rs +++ b/crates/forms/src/af.rs @@ -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 { (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 { + 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 { + 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) -> Vec { + 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 { // 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 = VecDeque::new(); @@ -1386,6 +1427,15 @@ pub fn calculate(c: &Calculate, values: &dyn Fn(&str) -> Vec) -> 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::>().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::::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] diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index 4ec22f011..3b6f6f20d 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -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::() * 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, String, Object, Option) { + 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(); diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index b4fddb589..f5178a687 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -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) -> 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) -> 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, 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) { +/// 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) { 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) { } } 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 Result Result = 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() { diff --git a/crates/forms/src/calc_order.rs b/crates/forms/src/calc_order.rs new file mode 100644 index 000000000..e75a05d47 --- /dev/null +++ b/crates/forms/src/calc_order.rs @@ -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, + /// `/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, +} + +/// 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 }, + /// `/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, + /// `/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, + /// Fix order's order, as indexes into `fields`. + fixed: Vec, +} + +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 { + 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 { + 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) -> 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) { + let mut by_obj: HashMap = 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> = 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 = 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, reads: &[Vec]) -> Vec> { + 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]) -> Vec { + 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, 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], component: &[usize]) -> Vec { + let n = links.len(); + let mut waits = vec![0usize; n]; + let mut readers: Vec> = 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> = 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 = 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> = 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]) -> Vec { + 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> = (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 = (0..n).map(|i| format!("g.{i}")).chain((0..n).map(|i| format!("r{i}"))).collect(); + let reads: Vec> = (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> = 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> = (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::>(), "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> = fixed.iter().map(|&k| { let mut l: Vec = links[k].iter().map(|&j| at[j]).collect(); l.sort_unstable(); l }).collect(); + proptest::prop_assert_eq!(fix(&renumbered), (0..n).collect::>()); + } + } +} diff --git a/crates/forms/src/detect.rs b/crates/forms/src/detect.rs index d5bf073b1..8c980c09f 100644 --- a/crates/forms/src/detect.rs +++ b/crates/forms/src/detect.rs @@ -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 = 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), diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 5647d32c4..85284b8cc 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -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> { pub(crate) fn page_refs(doc: &Document) -> Vec { 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, + pending: Vec<(std::sync::Arc, 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 { + 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 = 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 { /// [`recalculate`], running custom Calculate (and Format) scripts through `scripts`. pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result { 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 = 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 = 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 = 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 { 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 { + 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 { + let all = need_appearances(doc); + let mut copy: Option = 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> { + // 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>| { + 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>, 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]) -> Option { + 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>, 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 }; diff --git a/crates/forms/src/scripting.rs b/crates/forms/src/scripting.rs index 9a857510b..f5b827445 100644 --- a/crates/forms/src/scripting.rs +++ b/crates/forms/src/scripting.rs @@ -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 = 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(()) } diff --git a/crates/forms/src/table.rs b/crates/forms/src/table.rs new file mode 100644 index 000000000..23ad18c8b --- /dev/null +++ b/crates/forms/src/table.rs @@ -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, + /// 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, 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) -> Result, 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) -> 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 = 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 = 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 = (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); + } + } + } + } +} diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index f8410bf89..b2e4994ec 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -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 = 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 = 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 > >> /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 = 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 = 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 = 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 = 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 { + 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 { + 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::>(); + 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> = [5, 6, 10, 11, 12].into_iter().map(|n| own_name(&doc, n)).collect(); + assert_eq!(partial.iter().map(Option::as_deref).collect::>(), [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 = 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::>(), ["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::>(); + 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 = 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 = 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 = 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, 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 = ["dup", "src", "dup"].map(String::from).into(); + set_calculation_order(&mut doc, ¤t).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> = 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 = ["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::::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(©); + 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(©, &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(©), 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(©); + assert_eq!(ap(©, &field(&all, "a").widgets[0]).trim_end(), "q\nQ"); + assert!(ap(©, &field(&all, "b").widgets[0]).contains("(Value B) Tj")); + assert_eq!(ap(©, &field(&all, "c").widgets[0]).trim_end(), "BT (old C) Tj ET"); + assert!(ap(©, &field(&all, "d").widgets[0]).contains("(Blue) Tj")); + assert!(ap(©, &field(&all, "f").widgets[0]).contains("(Green) Tj")); + assert!(ap(©, &field(&all, "g").widgets[0]).contains("(Value G) Tj"), "a down appearance alone is no normal one"); + assert!(with_field_appearances(©).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(©, &field(&fields(©), "b").widgets[0]).contains("(Typed) Tj"), "and shows its new value"); + assert!(ap(©, &field(&fields(©), "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(©, &field(&fields(©), "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(©, &field(&fields(©), "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(©, &field(&fields(©), "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]); +} diff --git a/crates/forms/tests/af_date_parse.rs b/crates/forms/tests/af_date_parse.rs new file mode 100644 index 000000000..802c40dc6 --- /dev/null +++ b/crates/forms/tests/af_date_parse.rs @@ -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)); +} diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs index a1b807a26..40b86c632 100644 --- a/crates/js/src/formcalc.rs +++ b/crates/js/src/formcalc.rs @@ -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 { 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 = s.split(':').filter_map(|p| p.trim().parse::().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::().ok()).unwrap_or(0).min(1_000_000); + let (h, m) = (whole(), whole()); + let sec = + parts.next().and_then(|p| p.trim().parse::().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"); diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 8f3bb412d..7a3d768b2 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -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, + fields: Arc>, changed: BTreeSet, alerts: Vec, console: Vec, @@ -286,7 +287,8 @@ fn read(ctx: &Context, name: &JsString) -> JsResult { 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 { /// 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>, 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>, 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(), diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index d15525163..18f6f8f8f 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -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}; diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index ef06e8418..e77db241f 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -64,6 +64,9 @@ pub struct XfaNode { /// This instance's index among its same-named siblings. pub index: usize, pub children: Vec, + /// 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, } /// The event a script runs for. @@ -205,6 +208,8 @@ pub(crate) struct HNode { pub(crate) children: Vec, /// 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, } /// 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; +/// The form host of the script running in `ctx`. fn host(ctx: &Context) -> JsResult<&Shared> { ctx.get_data::().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, out: &mut Vec, depth: usize) -> Option { if depth > MAX_DEPTH || out.len() >= MAX_NODES { return None; @@ -381,6 +390,7 @@ fn flatten(n: &XfaNode, parent: Option, out: &mut Vec, 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, 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 { 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 { 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 { 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 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()), diff --git a/crates/js/src/xfa_session.rs b/crates/js/src/xfa_session.rs index c2c5ee3cc..91447454d 100644 --- a/crates/js/src/xfa_session.rs +++ b/crates/js/src/xfa_session.rs @@ -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. //! diff --git a/crates/measure/README.md b/crates/measure/README.md index 653cfa843..95a04a2b6 100644 --- a/crates/measure/README.md +++ b/crates/measure/README.md @@ -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. diff --git a/crates/measure/src/lib.rs b/crates/measure/src/lib.rs index aac6663c0..72b6c4b07 100644 --- a/crates/measure/src/lib.rs +++ b/crates/measure/src/lib.rs @@ -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 { + 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::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")); diff --git a/crates/measure/src/snap.rs b/crates/measure/src/snap.rs index 71ba78f4a..3cf6bbd17 100644 --- a/crates/measure/src/snap.rs +++ b/crates/measure/src/snap.rs @@ -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), } diff --git a/crates/measure/src/tests.rs b/crates/measure/src/tests.rs index 6af32632f..303f9b438 100644 --- a/crates/measure/src/tests.rs +++ b/crates/measure/src/tests.rs @@ -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}"); + } +} diff --git a/crates/measure/tests/snap_property.rs b/crates/measure/tests/snap_property.rs index a05b507a7..a38bb80aa 100644 --- a/crates/measure/tests/snap_property.rs +++ b/crates/measure/tests/snap_property.rs @@ -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 { + 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 { + 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"); +} diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 32915af6b..3d517eef3 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -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 diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 8cfa82674..6182b0892 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -2,7 +2,7 @@ use std::collections::{HashMap, HashSet}; -use pdfcraft_content::Matrix; +use pdfcraft_content::{Matrix, Pieces}; use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; use crate::{Compression, ImageSettings, OptimizeError, Report, Settings, Stage}; @@ -26,17 +26,16 @@ pub(crate) fn page_resources(doc: &Document, page: ObjRef) -> Option { pub(crate) fn content_bytes(doc: &Document, contents: &Object) -> Vec { match &*doc.resolve(contents) { Object::Stream(s) => s.decoded().unwrap_or_default(), - Object::Array(a) => a - .iter() - .flat_map(|c| match &*doc.resolve(c) { - Object::Stream(s) => { - let mut v = s.decoded().unwrap_or_default(); - v.push(b'\n'); - v - } - _ => Vec::new(), - }) - .collect(), + Object::Array(a) => { + let pieces: Vec<_> = a + .iter() + .filter_map(|c| match &*doc.resolve(c) { + Object::Stream(s) => Some(s.decoded().unwrap_or_default()), + _ => None, + }) + .collect(); + Pieces::join(&pieces).data().to_vec() + } _ => Vec::new(), } } @@ -230,15 +229,19 @@ fn is_dct(s: &Stream) -> Option { /// 8-bit samples of an image (`n` components), decoded. pub(crate) fn pixels(s: &Stream, n: usize) -> Option> { - let (w, h) = (s.dict.int(b"Width")? as usize, s.dict.int(b"Height")? as usize); + let (w, h) = (u32::try_from(s.dict.int(b"Width")?).ok()?, u32::try_from(s.dict.int(b"Height")?).ok()?); + if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_PIXELS { + return None; + } + let bytes = usize::try_from(w).ok()?.checked_mul(usize::try_from(h).ok()?)?.checked_mul(n)?; if is_dct(s)? { let img = image::load_from_memory_with_format(&s.raw, image::ImageFormat::Jpeg).ok()?; // Adobe CMYK/YCCK JPEGs aren't handled (the colour space check excludes them). let v = if n == 1 { img.into_luma8().into_raw() } else { img.into_rgb8().into_raw() }; - (v.len() == w * h * n).then_some(v) + (v.len() == bytes).then_some(v) } else { let v = s.decoded().ok()?; - (v.len() >= w * h * n).then(|| v[..w * h * n].to_vec()) + Some(v.get(..bytes)?.to_vec()) } } @@ -316,7 +319,7 @@ fn process(doc: &Document, s: &Stream, ppi: f64, settings: &ImageSettings, n: us return None; } let was_dct = is_dct(s)?; - let (w, h) = (d.int(b"Width")? as u32, d.int(b"Height")? as u32); + let (w, h) = (u32::try_from(d.int(b"Width")?).ok()?, u32::try_from(d.int(b"Height")?).ok()?); if w == 0 || h == 0 || (w as u64) * (h as u64) > MAX_PIXELS { return None; } @@ -347,7 +350,7 @@ fn process(doc: &Document, s: &Stream, ppi: f64, settings: &ImageSettings, n: us if m.dict.int(b"BitsPerComponent") != Some(8) || m.dict.contains(b"Matte") || m.dict.contains(b"Decode") { return None; } - let (mw, mh) = (m.dict.int(b"Width")? as u32, m.dict.int(b"Height")? as u32); + let (mw, mh) = (u32::try_from(m.dict.int(b"Width")?).ok()?, u32::try_from(m.dict.int(b"Height")?).ok()?); let mpx = pixels(m, 1)?; let mpx = resample(&mpx, 1, mw, mh, nw, nh)?; smask = Some((mr, image_stream(&m.dict, &mpx, 1, nw, nh, Compression::Flate, false)?)); @@ -409,3 +412,24 @@ pub(crate) fn run( } Ok(()) } + +#[cfg(test)] +mod dimension_tests { + use super::*; + + #[test] + fn pixel_reader_rejects_hostile_dimensions_before_decoding() { + for (width, height, channels) in [(-1, 1, 1), (0, 1, 1), (i64::from(u32::MAX) + 101, 1, 1), (80_000_001, 1, 1), (2, 2, usize::MAX)] { + let mut dict = Dict::new(); + dict.set(b"Width".to_vec(), Object::Int(width)); + dict.set(b"Height".to_vec(), Object::Int(height)); + let stream = Stream::from_raw(dict, vec![0; 4]); + assert!(pixels(&stream, channels).is_none(), "{width} x {height} x {channels}"); + } + let mut dict = Dict::new(); + dict.set(b"Width".to_vec(), Object::Int(2)); + dict.set(b"Height".to_vec(), Object::Int(2)); + let stream = Stream::from_raw(dict, vec![1, 2, 3, 4, 5]); + assert_eq!(pixels(&stream, 1), Some(vec![1, 2, 3, 4])); + } +} diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 2a83e8d6a..fd1ef3cd9 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -1022,3 +1022,65 @@ fn images_are_not_resampled_when_some_uses_could_not_be_measured() { let refs = pdfcraft_annot::page_refs(&doc).unwrap(); assert_eq!(effective_resolutions(&doc, &refs)[&img], f64::INFINITY); } + +#[test] +fn image_resolution_keeps_comments_across_content_pieces() { + for ending in ["", "\n", "\r", "\r\n"] { + let mut doc = Document::new_empty(); + let visible = image(&mut doc, 20, 20, 3, false, None); + let hidden = image(&mut doc, 20, 20, 3, false, None); + let page = page(&mut doc, &[("Visible", visible), ("Hidden", hidden)], ""); + let first = doc + .add(Object::Stream(Stream::from_raw(Dict::new(), format!("q 72 0 0 72 0 0 cm /Visible Do Q\n% trailing comment{ending}").into_bytes()))); + let second = doc.add(Object::Stream(Stream::from_raw(Dict::new(), b"q 144 0 0 144 0 0 cm /Hidden Do Q\n".to_vec()))); + doc.update_dict(page, |dict| { + dict.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(first), Object::Int(17), Object::Ref(second)])); + }) + .unwrap(); + let measured = effective_resolutions(&doc, &[page]); + assert_eq!(measured.get(&visible), Some(&20.0)); + if ending.is_empty() { + assert_eq!(measured.len(), 1); + assert_eq!(measured.get(&hidden), None, "a comment must hide the next piece's first line"); + } else { + assert_eq!(measured.len(), 2); + assert_eq!(measured.get(&hidden), Some(&10.0), "a real line ending exposes the image"); + } + } +} + +#[test] +fn a_hostile_image_size_is_left_alone_not_multiplied_into_overflow() { + // /Width and /Height are file-controlled: 2^32 + 100 wraps to 100 when read as u32, while the + // real values overflow `w * h * n`. The image must be skipped, not sized by a wrapped product. + let mut doc = Document::new_empty(); + let side = i64::from(u32::MAX) + 101; + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(side)); + d.set(b"Height".to_vec(), Object::Int(side)); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let hostile = doc.add(Object::Stream(Stream::flate(d, &[7u8; 256]))); + page(&mut doc, &[("Im", hostile)], "q 1 0 0 1 10 10 cm /Im Do Q"); + let report = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(report.images_resampled, 0, "{report:?}"); + assert_eq!(stream(&doc, hostile).dict.int(b"Width"), Some(side), "the image is left as it was"); +} + +#[test] +fn a_hostile_soft_mask_leaves_its_image_unchanged() { + let mut doc = Document::new_empty(); + let mask = image(&mut doc, 400, 400, 1, false, None); + let mut hostile = stream(&doc, mask); + hostile.dict.set(b"Width".to_vec(), Object::Int(i64::from(u32::MAX) + 401)); + doc.set(mask, Object::Stream(hostile.clone())); + let photo = image(&mut doc, 400, 400, 3, false, Some(mask)); + let before = stream(&doc, photo); + page(&mut doc, &[("Photo", photo)], "q 72 0 0 72 10 10 cm /Photo Do Q"); + let report = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(report.images_resampled, 0, "{report:?}"); + assert_eq!(stream(&doc, photo), before); + assert_eq!(stream(&doc, mask), hostile); +} diff --git a/crates/organize/src/import.rs b/crates/organize/src/import.rs index 588539866..80f90fb2a 100644 --- a/crates/organize/src/import.rs +++ b/crates/organize/src/import.rs @@ -17,7 +17,7 @@ //! - layers (optional content groups) used by the pages are registered in the destination's //! `/OCProperties`, keeping their default on/off state. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use pdfcraft_cos::{Dict, Document, ObjRef, Object}; @@ -240,23 +240,31 @@ pub fn import_pages_with( at: usize, conflicts: FieldConflicts, ) -> Result { - let first_new = dst.object_numbers().last().map_or(1, |n| n + 1); + let before: HashSet = dst.object_numbers().into_iter().collect(); let imported = match import_pages_mapped(dst, src, src_pages, at, conflicts) { Ok(imported) => imported, Err(e) => { // A refusal while copying (a source too deep to follow) has only added objects: // take them back, so the destination is as it was. - for n in dst.object_numbers().into_iter().filter(|n| *n >= first_new) { - dst.free(ObjRef::new(n, dst.generation(n))); + for r in added_objects(dst, &before) { + dst.free(r); } return Err(e); } }; - let created: Vec = dst.object_numbers().into_iter().filter(|n| *n >= first_new).map(|n| ObjRef::new(n, dst.generation(n))).collect(); + let created = added_objects(dst, &before); crate::dedupe::dedupe_resources(dst, &created, true); Ok(ImportOutcome { pages: imported.pages, renamed_fields: imported.renamed_fields }) } +/// The objects in `dst` that are not among `before`, the numbers it held before an import: the +/// ones the import added. They are not always numbered above the others: when no number is left +/// above the highest (the document holds object 4294967295, or its `/Size` is exhausted), +/// [`Document::add`] fills free numbers below it. +fn added_objects(dst: &Document, before: &HashSet) -> Vec { + dst.object_numbers().into_iter().filter(|n| !before.contains(n)).map(|n| ObjRef::new(n, dst.generation(n))).collect() +} + /// Insert Pages from File: [`import_pages_with`], and the inserted file's bookmarks come along /// under one new bookmark titled `title` (the file's name) at its first inserted page, as Combine /// Files does for each file. That bookmark goes among the top-level bookmarks where the pages went: @@ -270,9 +278,9 @@ pub fn insert_file_pages( at: usize, conflicts: FieldConflicts, ) -> Result { - let first_new = dst.object_numbers().last().map_or(1, |n| n + 1); + let before: HashSet = dst.object_numbers().into_iter().collect(); let imported = import_pages_mapped(dst, src, src_pages, at, conflicts)?; - let created: Vec = dst.object_numbers().into_iter().filter(|n| *n >= first_new).map(|n| ObjRef::new(n, dst.generation(n))).collect(); + let created = added_objects(dst, &before); crate::dedupe::dedupe_resources(dst, &created, true); let outline = source_outline(src, imported.page_map); if let Some(first) = imported.pages.first() diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index 99c228c60..6a696dd4d 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -33,8 +33,8 @@ pub use import::{ }; pub use labels::{LabelRange, LabelStyle, PageLabels, number_pages, page_label_ranges, page_labels, set_page_label_ranges}; pub use outline::{ - Bookmark, BookmarkPage, OutlineEntry, OutlineError, add_bookmark, add_bookmark_tree, bookmark_page, bookmarks, delete_bookmark, move_bookmark, - rename_bookmark, set_bookmark_open, set_bookmark_page, top_level_bookmarks, + Bookmark, BookmarkPage, OutlineEntry, OutlineError, add_bookmark, add_bookmark_at, add_bookmark_tree, bookmark_page, bookmarks, delete_bookmark, + move_bookmark, rename_bookmark, set_bookmark_open, set_bookmark_page, top_level_bookmarks, }; pub use transition::{TRANSITION_DIRECTIONS, TRANSITION_STYLES, Transition, TransitionSpec, set_transition}; pub use view::{InitialView, displays_doc_title, initial_view, set_initial_view}; @@ -72,36 +72,74 @@ fn pages_root(doc: &Document) -> Result { /// All leaf pages in order, with their effective inherited attributes. fn walk(doc: &Document) -> Result, OrganizeError> { - let root = pages_root(doc)?; - let mut out = Vec::new(); - let mut seen = std::collections::HashSet::new(); - // (node, inherited attributes from ancestors) - let mut stack: Vec<(ObjRef, Dict)> = vec![(root, Dict::new())]; - while let Some((node, inherited)) = stack.pop() { - if !seen.insert(node) || seen.len() > 1_000_000 { - continue; // cycles in broken trees - } - let obj = doc.get(node); - let Some(d) = obj.as_dict() else { continue }; - let mut attrs = inherited.clone(); - for k in INHERITABLE { - if let Some(v) = d.get(k) { - attrs.set(k.to_vec(), v.clone()); + Ok(PageWalk::new(doc, pages_root(doc)?).collect()) +} + +/// For each of `INHERITABLE`, the nearest node that sets it, shared rather than copied. +type Owners = [Option>; 4]; + +/// Depth-first walk over the leaf pages under `top`. It keeps one cursor per open `/Kids` array, +/// never one entry per child, and carries inherited attributes as the nodes that set them, so a +/// child listed many times or shared between nodes is skipped by the seen check before anything +/// is queued or copied (`pdfcraft_model::pages` walks the same way). Cycles in broken trees end +/// at the seen check and at most a million nodes are visited. +struct PageWalk<'a> { + doc: &'a Document, + seen: std::collections::HashSet, + pending: Vec<(std::sync::Arc, usize, Owners)>, +} + +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, Default::default())] } + } +} + +impl Iterator for PageWalk<'_> { + type Item = (ObjRef, Dict); + + fn next(&mut self) -> Option<(ObjRef, Dict)> { + while let Some((kids, next, inherited)) = 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; } - } - let is_pages = d.name(b"Type") == Some(b"Pages") || (d.contains(b"Kids") && d.name(b"Type") != Some(b"Page")); - if is_pages { - let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); - for k in kids.iter().rev() { - if let Some(r) = k.as_ref() { - stack.push((r, attrs.clone())); + if !self.seen.insert(node) { + continue; + } + let obj = self.doc.get(node); + let Some(d) = obj.as_dict() else { continue }; + let mut owners = inherited.clone(); + for (k, owner) in INHERITABLE.iter().zip(&mut owners) { + if d.contains(k) { + *owner = Some(obj.clone()); } } - } else { - out.push((node, attrs)); + let is_pages = d.name(b"Type") == Some(b"Pages") || (d.contains(b"Kids") && d.name(b"Type") != Some(b"Page")); + if is_pages { + if let Some(kids) = d.get(b"Kids") { + self.pending.push((self.doc.resolve(kids), 0, owners)); + } + } else { + let mut attrs = Dict::new(); + for (k, owner) in INHERITABLE.iter().zip(&owners) { + if let Some(v) = owner.as_ref().and_then(|o| o.as_dict()).and_then(|o| o.get(k)) { + attrs.set(k.to_vec(), v.clone()); + } + } + return Some((node, attrs)); + } } + None } - Ok(out) } /// Leaf pages in document order. @@ -216,6 +254,12 @@ pub fn rotate_pages(doc: &mut Document, indices: &[usize], degrees: i64) -> Resu } /// Delete pages. Refuses to delete every page. +/// +/// # Errors +/// +/// This operation is not transactional: an error can leave `doc` partially modified. Callers +/// that need atomic edits (including replacement) must use a working clone and keep it only on +/// success; discard the working clone on error. The engine does this for its history boundary. pub fn delete_pages(doc: &mut Document, indices: &[usize]) -> Result<(), OrganizeError> { let all = walk(doc)?; check(indices, all.len())?; @@ -365,43 +409,42 @@ fn prune_legacy(doc: &Document, dests: &mut Dict, gone: &[ObjRef], dead: &mut Ve /// Prune a name tree held in `slot` (a reference, or a dictionary written inline). A reference is /// updated in place and yields `None`; an inline dictionary that changed is handed back. fn prune_tree_slot(doc: &mut Document, slot: &Object, gone: &[ObjRef], dead: &mut Vec>) -> Result, OrganizeError> { - let mut seen = Vec::new(); - match slot { + let mut stack = Vec::new(); + let inline = match slot { Object::Ref(r) => { - prune_tree_ref(doc, *r, gone, dead, &mut seen)?; - Ok(None) + stack.push(*r); + None } Object::Dict(d) => { let mut node = d.clone(); - Ok(prune_tree_node(doc, &mut node, gone, dead, &mut seen)?.then_some(node)) + prune_tree_node(doc, &mut node, gone, dead, &mut stack).then_some(node) + } + _ => None, + }; + // Name trees consist of shallow indirect nodes: graph depth must not become call-stack + // depth. Visit each indirect node once, even when cyclic or shared. Unlike the depth-limited + // outline::name_tree lookup, deletion must inspect every reachable node to remove references + // to freed pages. Both walks use seen sets to stop cycles. + let mut seen = std::collections::HashSet::new(); + while let Some(r) = stack.pop() { + if !seen.insert(r) { + continue; + } + let Some(mut node) = doc.get(r).as_dict().cloned() else { continue }; + if prune_tree_node(doc, &mut node, gone, dead, &mut stack) { + doc.update_dict(r, |d| *d = node)?; } - _ => Ok(None), } + Ok(inline) } -fn prune_tree_ref(doc: &mut Document, r: ObjRef, gone: &[ObjRef], dead: &mut Vec>, seen: &mut Vec) -> Result<(), OrganizeError> { - if seen.contains(&r) { - return Ok(()); - } - seen.push(r); - let Some(mut node) = doc.get(r).as_dict().cloned() else { return Ok(()) }; - if prune_tree_node(doc, &mut node, gone, dead, seen)? { - doc.update_dict(r, |d| *d = node)?; - } - Ok(()) -} - -/// Drop the /Names pairs of one name-tree node that go to a `gone` page, then do the same for its -/// /Kids. True when `node` itself changed (kids held by reference are written back here). -fn prune_tree_node( - doc: &mut Document, - node: &mut Dict, - gone: &[ObjRef], - dead: &mut Vec>, - seen: &mut Vec, -) -> Result { +/// Drop the /Names pairs going to a `gone` page, and queue /Kids for the iterative walk. +/// True when `node` itself changed; child nodes are written back by the caller. +fn prune_tree_node(doc: &Document, node: &mut Dict, gone: &[ObjRef], dead: &mut Vec>, stack: &mut Vec) -> bool { let mut changed = false; - if let Some(pairs) = node.get(b"Names").map(|n| doc.resolve(n)).and_then(|n| n.as_array().cloned()) { + if let Some(names) = node.get(b"Names").map(|n| doc.resolve(n)) + && let Some(pairs) = names.as_array() + { let mut kept = Vec::with_capacity(pairs.len()); for pair in pairs.chunks(2) { match pair { @@ -418,13 +461,16 @@ fn prune_tree_node( changed = true; } } - let kids = node.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); - for kid in kids { - if let Some(r) = kid.as_ref() { - prune_tree_ref(doc, r, gone, dead, seen)?; + if let Some(children) = node.get(b"Kids").map(|k| doc.resolve(k)) + && let Some(kids) = children.as_array() + { + for kid in kids.iter().rev() { + if let Some(r) = kid.as_ref() { + stack.push(r); + } } } - Ok(changed) + changed } /// Take widgets out of the form: out of their field's /Kids, or out of /AcroForm /Fields when @@ -658,5 +704,40 @@ fn set_info_entry(doc: &mut Document, key: &[u8], entry: Option) -> Resu Ok(()) } +/// A standard a document can declare it meets, for [`remove_standard_id`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Standard { + PdfA, + PdfX, +} + +impl Standard { + pub fn label(self) -> &'static str { + match self { + Standard::PdfA => "PDF/A", + Standard::PdfX => "PDF/X", + } + } +} + +/// Remove what identifies the document as meeting `standard`: the `pdfaid:` XMP properties of +/// ISO 19005, or ISO 15930's `GTS_PDFXVersion` and `GTS_PDFXConformance` in the document +/// information and as `pdfxid:` XMP properties. Output intents and other metadata stay. Refused +/// when the document declares nothing of the kind. +pub fn remove_standard_id(doc: &mut Document, standard: Standard) -> Result<(), OrganizeError> { + let (props, info_keys): (&[&str], &[&str]) = match standard { + Standard::PdfA => (&["pdfaid:part", "pdfaid:conformance", "pdfaid:amd", "pdfaid:rev"], &[]), + Standard::PdfX => (&["pdfxid:GTS_PDFXVersion", "pdfxid:GTS_PDFXConformance"], &["GTS_PDFXVersion", "GTS_PDFXConformance"]), + }; + let mut removed = xmp::remove_properties(doc, props)?; + for key in info_keys { + if info(doc, key).is_some() { + set_info_entry(doc, key.as_bytes(), None)?; + removed = true; + } + } + if removed { Ok(()) } else { Err(OrganizeError::Invalid(format!("the document doesn't declare {}", standard.label()))) } +} + #[cfg(test)] mod tests; diff --git a/crates/organize/src/outline.rs b/crates/organize/src/outline.rs index dca0b35ce..39e601d73 100644 --- a/crates/organize/src/outline.rs +++ b/crates/organize/src/outline.rs @@ -59,6 +59,8 @@ pub enum OutlineError { IntoItself, #[error("the title must not be empty")] EmptyTitle, + #[error("a bookmark's position must be a finite number")] + BadPosition, #[error("this document has no tagged headings to make bookmarks from")] NoEntries, #[error("{0}")] @@ -472,24 +474,55 @@ fn recount(doc: &mut Document) -> Result<()> { } fn destination(doc: &Document, page: usize) -> Result { + destination_at(doc, page, None, None) +} + +/// A destination on `page` (0-based) at the user-space point (`left`, `top`), either of which +/// may be left out to keep the reader's position in that direction. +fn destination_at(doc: &Document, page: usize, left: Option, top: Option) -> Result { + if ![left, top].iter().flatten().all(|v| v.is_finite()) { + return Err(OutlineError::BadPosition); + } let pages = walk(doc)?; let (p, _) = pages.get(page).ok_or(OrganizeError::NoSuchPage(page))?; - Ok(go_to(*p)) + Ok(go_to_at(*p, left, top)) } /// `/XYZ null null null`: go to the page, keeping the reader's zoom (what Acrobat writes for a /// new bookmark when the view has no specific position). fn go_to(page: ObjRef) -> Object { - Object::Array(vec![Object::Ref(page), Object::name("XYZ"), Object::Null, Object::Null, Object::Null]) + go_to_at(page, None, None) +} + +/// `/XYZ left top null`: the point (`left`, `top`) at the window's upper-left corner, at the +/// reader's zoom; a coordinate that is left out keeps the reader's position. +fn go_to_at(page: ObjRef, left: Option, top: Option) -> Object { + let coordinate = |v: Option| v.map_or(Object::Null, Object::Real); + Object::Array(vec![Object::Ref(page), Object::name("XYZ"), coordinate(left), coordinate(top), Object::Null]) } /// Add a bookmark titled `title` that goes to `page` (0-based), as child `index` of /// `parent_path` (`[]` = top level; an index past the end appends). Returns its path. pub fn add_bookmark(doc: &mut Document, parent_path: &[usize], index: usize, title: &str, page: usize) -> Result> { + add_bookmark_at(doc, parent_path, index, title, page, None, None) +} + +/// [`add_bookmark`] going to the user-space point (`left`, `top`) of `page`, as `/XYZ left top null` +/// (zoom kept). Either coordinate may be left out to keep the reader's position in that +/// direction; one that is not finite is refused. +pub fn add_bookmark_at( + doc: &mut Document, + parent_path: &[usize], + index: usize, + title: &str, + page: usize, + left: Option, + top: Option, +) -> Result> { if title.trim().is_empty() { return Err(OutlineError::EmptyTitle); } - let dest = destination(doc, page)?; + let dest = destination_at(doc, page, left, top)?; let parent = container(doc, parent_path)?; let mut kids = children_of(doc, parent, &mut HashSet::from([parent])); let mut d = Dict::new(); diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 9bd3eb396..7a80e38e4 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -202,6 +202,119 @@ fn delete_keeps_inherited_attributes_on_survivors() { assert_eq!(root.as_dict().unwrap().int(b"Count"), Some(2)); } +#[test] +fn deleting_a_page_prunes_deep_named_destination_trees() { + let mut doc = doc_a(); + let original_pages = pages(&doc).unwrap(); + let mut leaf = Dict::new(); + leaf.set( + b"Names".to_vec(), + Object::Array(vec![ + Object::String(PdfString::text("gone")), + Object::Array(vec![Object::Ref(original_pages[0].obj), Object::name("Fit")]), + Object::String(PdfString::text("kept")), + Object::Array(vec![Object::Ref(original_pages[2].obj), Object::name("Fit")]), + ]), + ); + let leaf = doc.add(leaf); + let mut tree = leaf; + for _ in 0..16_384 { + let mut node = Dict::new(); + node.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(tree)])); + tree = doc.add(node); + } + let mut names = Dict::new(); + names.set(b"Dests".to_vec(), Object::Ref(tree)); + doc.update_dict(doc.root().unwrap(), |d| d.set(b"Names".to_vec(), Object::Dict(names))).unwrap(); + + // Every node is a shallow indirect object: the page operation must not turn the + // document's graph depth into call-stack depth. + delete_pages(&mut doc, &[0]).unwrap(); + for bytes in [write_incremental(&doc, &SaveOptions::default()).unwrap(), write_full(&doc, &SaveOptions::default()).unwrap()] { + let reopened = open(bytes); + assert_eq!(labels(&reopened), ["A2", "A3"]); + let catalog = reopened.get(reopened.root().unwrap()); + let names = reopened.resolve(catalog.as_dict().unwrap().get(b"Names").unwrap()); + let mut node = reopened.resolve(names.as_dict().unwrap().get(b"Dests").unwrap()); + for _ in 0..16_384 { + let child = node.as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap()[0].clone(); + node = reopened.resolve(&child); + } + let pairs = node.as_dict().unwrap().get(b"Names").and_then(Object::as_array).cloned().unwrap(); + assert_eq!(pairs.len(), 2); + assert_eq!(pairs[0].as_string().unwrap().to_text(), "kept"); + assert_eq!(pairs[1].as_array().unwrap()[0].as_ref(), Some(pages(&reopened).unwrap()[1].obj)); + } +} + +#[test] +fn destination_pruning_visits_shared_and_cyclic_nodes_once() { + for inline_names in [false, true] { + for inline_tree in [false, true] { + let mut doc = doc_a(); + let gone = pages(&doc).unwrap()[0].obj; + let mut leaf = Dict::new(); + leaf.set( + b"Names".to_vec(), + Object::Array(vec![Object::String(PdfString::text("gone")), Object::Array(vec![Object::Ref(gone), Object::name("Fit")])]), + ); + leaf.set(b"Unknown".to_vec(), Object::Int(42)); + let leaf = doc.add(leaf); + let cycle = doc.add(Object::Null); + let mut node = Dict::new(); + node.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(cycle), Object::Ref(leaf), Object::Ref(leaf)])); + doc.set(cycle, Object::Dict(node.clone())); + let mut names = Dict::new(); + names.set(b"Dests".to_vec(), if inline_tree { Object::Dict(node) } else { Object::Ref(cycle) }); + let names = if inline_names { Object::Dict(names) } else { Object::Ref(doc.add(names)) }; + doc.update_dict(doc.root().unwrap(), |d| d.set(b"Names".to_vec(), names)).unwrap(); + + assert_eq!(drop_named_destinations(&mut doc, &[gone]).unwrap(), [b"gone".to_vec()]); + let leaf = doc.get(leaf); + assert_eq!(leaf.as_dict().unwrap().get(b"Names").and_then(Object::as_array).unwrap().len(), 0); + assert_eq!(leaf.as_dict().unwrap().int(b"Unknown"), Some(42)); + } + } +} + +#[test] +fn deleting_a_page_keeps_sixty_thousand_named_destinations_usable() { + let mut doc = doc_a(); + let original_pages = pages(&doc).unwrap(); + let pairs: Vec = (0..60_000) + .flat_map(|i| { + let page = if i % 2 == 0 { original_pages[0].obj } else { original_pages[2].obj }; + [Object::String(PdfString::text(&format!("dest-{i:05}"))), Object::Array(vec![Object::Ref(page), Object::name("Fit")])] + }) + .collect(); + let mut leaf = Dict::new(); + leaf.set(b"Names".to_vec(), Object::Array(pairs)); + leaf.set(b"Unknown".to_vec(), Object::Int(42)); + let leaf = doc.add(leaf); + let mut names = Dict::new(); + names.set(b"Dests".to_vec(), Object::Ref(leaf)); + doc.update_dict(doc.root().unwrap(), |d| d.set(b"Names".to_vec(), Object::Dict(names))).unwrap(); + + // A valid large book exceeds the former 100,000-entry budget (two entries per name). + delete_pages(&mut doc, &[0]).unwrap(); + for bytes in [write_incremental(&doc, &SaveOptions::default()).unwrap(), write_full(&doc, &SaveOptions::default()).unwrap()] { + let reopened = open(bytes); + assert_eq!(labels(&reopened), ["A2", "A3"]); + let catalog = reopened.get(reopened.root().unwrap()); + let names = reopened.resolve(catalog.as_dict().unwrap().get(b"Names").unwrap()); + let leaf = reopened.resolve(names.as_dict().unwrap().get(b"Dests").unwrap()); + let leaf = leaf.as_dict().unwrap(); + assert_eq!(leaf.int(b"Unknown"), Some(42)); + let pairs = leaf.get(b"Names").unwrap().as_array().unwrap(); + assert_eq!(pairs.len(), 60_000, "30,000 surviving name/value pairs"); + let page = pages(&reopened).unwrap()[1].obj; + for (pair, i) in pairs.as_chunks::<2>().0.iter().zip((1..60_000).step_by(2)) { + assert_eq!(pair[0].as_string().unwrap().to_text(), format!("dest-{i:05}")); + assert_eq!(pair[1].as_array().unwrap()[0].as_ref(), Some(page)); + } + } +} + #[test] fn deleted_pages_are_not_kept_by_what_points_at_them() { // A1 links to A3, a bookmark goes to A3, and A2 holds the form's only field. After deleting @@ -774,6 +887,33 @@ fn resources_split_between_page_and_tree_travel_together() { } } +#[test] +fn a_scan_whose_xref_misses_the_image_combines_with_the_image() { + // A cheap image-to-PDF converter's table: "0 5" followed by the entries of objects 1 to 5, + // so the image (object 5) has none. Viewers rebuild the table and show the scan; the + // combined copy got a null image and came out blank (#1628). + let b: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << /XObject << /Im0 5 0 R >> >> /Contents 4 0 R >>".into(), + "<< /Length 26 >>\nstream\n100 0 0 100 0 0 cm /Im0 Do\nendstream".into(), + "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\nA\nendstream".into(), + ]; + let good = build(&b, "/Root 1 0 R"); + let text = String::from_utf8_lossy(&good).into_owned(); + let at = text.rfind("\nxref\n").unwrap() + 1; + let entries: Vec<&str> = text[at..].lines().skip(3).take(5).collect(); + let mut bytes = good[..at].to_vec(); + bytes.extend_from_slice(format!("xref\n0 5\n{}\ntrailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{at}\n%%EOF\n", entries.join("\n")).as_bytes()); + let src = open(bytes); + let out = full_roundtrip(&combine(&[("scan", &src)]).unwrap()); + let res = out.dict(page_dict(&out, 0).get(b"Resources").unwrap()).unwrap(); + let image = res.get(b"XObject").and_then(|x| out.dict(x)).and_then(|d| d.get(b"Im0").map(|v| out.resolve(v))).expect("the page lists its image"); + let Object::Stream(image) = &*image else { panic!("the image is a stream, not {image:?}") }; + assert_eq!(image.dict.name(b"Subtype"), Some(&b"Image"[..])); + assert_eq!(image.decoded().unwrap(), b"A"); +} + #[test] fn links_are_rewired_to_copied_pages_or_dropped() { let a = doc_a(); @@ -1047,6 +1187,37 @@ fn malformed_output_intents_and_identification_do_not_fail() { assert!(!xmp.contains('\0') && !xmp.contains("GTS_PDFXConformance"), "{xmp}"); } +#[test] +fn removing_the_pdfx_identification_keeps_the_output_intent_and_other_claims() { + let mut x4 = doc_x4("FOGRA51", "icc"); + assert!(matches!(remove_standard_id(&mut x4, Standard::PdfA), Err(OrganizeError::Invalid(_)))); + assert!(!x4.is_modified(), "a refused removal changes nothing"); + remove_standard_id(&mut x4, Standard::PdfX).unwrap(); + let x = pdfx_of(&full_roundtrip(&x4)); + assert_eq!((x.version, x.intents), (None, vec![("FOGRA51".to_string(), b"icc".to_vec())])); + assert!(x.xmp.is_some_and(|x| !x.contains("pdfxid:GTS") && x.contains("pdfuaid:part=\"1\"")), "PDF/UA's claim stays"); + assert!(matches!(remove_standard_id(&mut x4, Standard::PdfX), Err(OrganizeError::Invalid(_))), "nothing left to remove"); + + let mut x1a = doc_print("CGATS TR 001", "icc", "/GTS_PDFXVersion (PDF/X-1:2001) /GTS_PDFXConformance (PDF/X-1a:2001)", None); + remove_standard_id(&mut x1a, Standard::PdfX).unwrap(); + assert_eq!((info(&x1a, "GTS_PDFXVersion"), info(&x1a, "GTS_PDFXConformance")), (None, None)); +} + +#[test] +fn removing_the_pdfa_identification_removes_its_xmp_properties_in_either_form() { + let xmp = "\ + 2Report\ + "; + let mut doc = doc_print("sRGB IEC61966-2.1", "icc", "/Title (Report)", Some(xmp)); + remove_standard_id(&mut doc, Standard::PdfA).unwrap(); + let x = pdfx_of(&full_roundtrip(&doc)); + let packet = x.xmp.unwrap(); + assert!(!packet.contains("pdfaid:part") && !packet.contains("pdfaid:conformance"), "{packet}"); + assert!(packet.contains(">Report"), "the rest of the packet stays: {packet}"); + assert_eq!(x.intents.len(), 1, "the output intent stays"); +} + #[test] fn duplicate_pages_become_independent_copies() { let out = full_roundtrip(&extract_pages(&doc_b(), &[0, 0]).unwrap()); @@ -2750,27 +2921,31 @@ fn combine_field_conflicts_previews_each_file_in_order() { assert_eq!(preview[2], [("Text1".to_string(), "Text1-2".to_string())], "the cascade continues past the earlier rename"); } +/// A one-page source whose page refers (`/Foo`) to object 6, followed by `tail` as objects 6, 7, … +fn chained(tail: &[String]) -> Document { + let mut b: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Foo 6 0 R >>".into(), + body("C1"), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + ]; + b.extend_from_slice(tail); + open(build(&b, "/Root 1 0 R")) +} + +/// [`chained`] with `n` objects, each referring to the next. +fn chain(n: usize) -> Document { + let mut tail: Vec = (0..n).map(|i| format!("<< /Next {} 0 R >>", 7 + i)).collect(); + tail.push("<< /End true >>".into()); + chained(&tail) +} + /// #1800: thousands of shallow objects, each referring to the next, used to recurse once per /// object and exhaust the stack. The copy is refused instead, and the destination is as it was; /// a chain within the budget and a short cycle copy as before. #[test] fn a_chain_of_objects_too_deep_to_copy_is_refused_and_leaves_the_destination_alone() { - fn chained(tail: &[String]) -> Document { - let mut b: Vec = vec![ - "<< /Type /Catalog /Pages 2 0 R >>".into(), - "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(), - "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Foo 6 0 R >>".into(), - body("C1"), - "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), - ]; - b.extend_from_slice(tail); - open(build(&b, "/Root 1 0 R")) - } - fn chain(n: usize) -> Document { - let mut tail: Vec = (0..n).map(|i| format!("<< /Next {} 0 R >>", 7 + i)).collect(); - tail.push("<< /End true >>".into()); - chained(&tail) - } // On a thread with less stack than any platform's main thread, so the budget is proven. let run = std::thread::Builder::new() .stack_size(512 << 10) @@ -2790,6 +2965,81 @@ fn a_chain_of_objects_too_deep_to_copy_is_refused_and_leaves_the_destination_alo assert!(run.is_ok(), "the copy must not overflow the stack"); } +/// The fixture after an update that adds object 20 and declares `/Size 4294967295`. No number is +/// left above that, so [`Document::add`] fills free numbers from 12 on, the gap below object 20 first. +fn fixture_with_an_exhausted_size() -> Document { + let mut bytes = fixture(); + let text = String::from_utf8_lossy(&bytes).into_owned(); + let prev: usize = text.rsplit("startxref").next().and_then(|t| t.split_whitespace().next()).and_then(|n| n.parse().ok()).expect("startxref"); + let added = bytes.len(); + bytes.extend_from_slice(b"20 0 obj\n<< /Unused true >>\nendobj\n"); + let xref = bytes.len(); + bytes.extend_from_slice( + format!( + "xref\n20 1\n{added:010} 00000 n \ntrailer\n<< /Size 4294967295 /Root 1 0 R /Info 11 0 R /Prev {prev} >>\nstartxref\n{xref}\n%%EOF\n" + ) + .as_bytes(), + ); + let doc = open(bytes); + assert!(doc.repair_log().iter().any(|l| l.contains("exhausted trailer")), "{:?}", doc.repair_log()); + doc +} + +/// The fixture holding object 4294967295, as a document does once an edit writes to an object of +/// that number (a damaged file's catalog, say). [`Document::add`] then fills free numbers below it. +fn fixture_holding_object_4294967295() -> Document { + let mut doc = open(fixture()); + doc.set(ObjRef::new(u32::MAX, 0), Object::String(PdfString::text("top"))); + doc +} + +/// Documents whose new objects are numbered in the gaps below their highest number. +fn documents_numbering_new_objects_below_their_highest() -> [(&'static str, Document); 2] { + [("an exhausted /Size", fixture_with_an_exhausted_size()), ("object 4294967295", fixture_holding_object_4294967295())] +} + +/// Duplicate, Replace and Insert Pages took the objects numbered above the destination's highest +/// number for the ones the import added. When new objects fill gaps below it, the copies escaped +/// resource sharing (each import stored the fonts again), and with object 4294967295 the number +/// after the highest overflowed: a panic in debug builds, and in release a wrap to 0 that counted +/// every object as added. The import now compares the object numbers before and after. +#[test] +fn an_import_shares_the_resources_it_added_wherever_they_are_numbered() { + let mut plain = open(fixture()); + duplicate_pages(&mut plain, &[0], None).unwrap(); + for (what, mut doc) in documents_numbering_new_objects_below_their_highest() { + let before = doc.object_numbers(); + duplicate_pages(&mut doc, &[0], None).unwrap(); + assert_eq!(labels(&doc), ["Page 1", "Page 1", "Page 2", "Page 3"], "{what}"); + assert_eq!(count_fonts(&doc), count_fonts(&plain), "{what}: the copy shares the font, as it does in the plain fixture"); + insert_file_pages(&mut doc, &open(fixture()), "Inserted", &[2], 0, FieldConflicts::Rename).unwrap(); + assert_eq!(labels(&doc), ["Page 3", "Page 1", "Page 1", "Page 2", "Page 3"], "{what}"); + assert_eq!(count_fonts(&doc), count_fonts(&plain), "{what}: the inserted page shares the font too"); + let after = doc.object_numbers(); + assert!(before.iter().all(|n| after.contains(n)), "{what}: every object the document had is still there"); + assert_eq!(labels(&full_roundtrip(&doc)), ["Page 3", "Page 1", "Page 1", "Page 2", "Page 3"], "{what}"); + } +} + +/// A refused import takes back what it added, which in these documents is numbered below their +/// highest object, and leaves the objects they had. +#[test] +fn a_refused_import_takes_back_what_it_added_wherever_it_is_numbered() { + let run = std::thread::Builder::new() + .stack_size(512 << 10) + .spawn(|| { + for (what, mut dst) in documents_numbering_new_objects_below_their_highest() { + let before = (labels(&dst), dst.object_numbers()); + let err = import_pages(&mut dst, &chain(20_000), &[0], 0).unwrap_err(); + assert!(matches!(&err, OrganizeError::Invalid(m) if m.contains("levels deep")), "{what}: {err:?}"); + assert_eq!((labels(&dst), dst.object_numbers()), before, "{what}: the destination is as it was"); + } + }) + .unwrap() + .join(); + assert!(run.is_ok(), "a refused import must leave the document as it was, without panicking"); +} + #[test] fn rotate_repeated_indices_accumulate_from_the_current_page_value() { for level in 0..3 { @@ -2821,3 +3071,64 @@ fn rotate_repeated_indices_accumulate_from_the_current_page_value() { } } } + +#[test] +fn a_bookmark_can_go_to_a_point_on_its_page_and_survives_save() { + let mut d = doc_a(); + // No position: as before, `/XYZ null null null`, the page top at the reader's zoom. + crate::add_bookmark(&mut d, &[], 9, "Top", 0).unwrap(); + // Only a height (a bookmark made at the top of the window), a full point, and only a left. + assert_eq!(crate::add_bookmark_at(&mut d, &[], 9, "Section", 1, None, Some(612.25)).unwrap(), vec![1]); + crate::add_bookmark_at(&mut d, &[], 9, "Table", 1, Some(72.5), Some(300.0)).unwrap(); + crate::add_bookmark_at(&mut d, &[1], 9, "Inside", 2, Some(10.0), None).unwrap(); + let view = |d: &Document, title: &str| crate::bookmarks(d).into_iter().find(|b| b.title == title).unwrap().view; + let xyz = |left, top| Some((b"XYZ".to_vec(), vec![left, top, None])); + let check = |d: &Document| { + assert_eq!(view(d, "Top"), xyz(None, None)); + assert_eq!(view(d, "Section"), xyz(None, Some(612.25))); + assert_eq!(view(d, "Table"), xyz(Some(72.5), Some(300.0))); + assert_eq!(view(d, "Inside"), xyz(Some(10.0), None)); + let page_of = |title: &str| crate::bookmarks(d).into_iter().find(|b| b.title == title).unwrap().page; + assert_eq!((page_of("Top"), page_of("Section"), page_of("Table"), page_of("Inside")), (Some(0), Some(1), Some(1), Some(2))); + }; + check(&d); + check(&full_roundtrip(&d)); +} + +#[test] +fn a_bookmark_position_that_is_not_a_number_is_refused_and_changes_nothing() { + let mut d = doc_a(); + crate::add_bookmark(&mut d, &[], 9, "Keep", 0).unwrap(); + for (left, top) in [(Some(f64::NAN), None), (None, Some(f64::INFINITY)), (Some(1.0), Some(f64::NEG_INFINITY))] { + assert_eq!(crate::add_bookmark_at(&mut d, &[], 9, "Bad", 0, left, top), Err(crate::OutlineError::BadPosition), "{left:?} {top:?}"); + } + assert_eq!(titles(&crate::bookmarks(&d)), ["Keep"]); + // The page is still checked, and so is the title. + assert!(crate::add_bookmark_at(&mut d, &[], 9, "Nowhere", 7, Some(1.0), Some(1.0)).is_err()); + assert_eq!(crate::add_bookmark_at(&mut d, &[], 9, " ", 0, Some(1.0), Some(1.0)), Err(crate::OutlineError::EmptyTitle)); + assert_eq!(titles(&crate::bookmarks(&d)), ["Keep"]); +} + +#[test] +fn page_walk_skips_repeated_and_cyclic_kids_without_copying_attributes() { + let mut doc = open(fixture()); + let (root, branch) = (ObjRef { num: 2, generation: 0 }, ObjRef { num: 3, generation: 0 }); + let (first, second) = (ObjRef { num: 4, generation: 0 }, ObjRef { num: 5, generation: 0 }); + // The branch node carries Resources for its pages: a cycle back to it, then both pages + // 128 times over. + let mut kids = vec![Object::Ref(branch)]; + kids.extend([Object::Ref(first), Object::Ref(second)].into_iter().cycle().take(256)); + doc.update_dict(branch, |d| d.set(b"Kids".to_vec(), Object::Array(kids))).unwrap(); + let mut walk = PageWalk::new(&doc, root); + let (page, attrs) = walk.next().unwrap(); + assert_eq!(page, first); + assert!(walk.pending.len() <= 3, "repeated kids retained {} pending entries", walk.pending.len()); + assert!(attrs.get(b"Resources").and_then(|r| r.as_dict()).is_some_and(|r| r.contains(b"Font")), "Resources from the branch"); + assert_eq!(attrs.get(b"Rotate").and_then(Object::as_int), Some(90), "Rotate from the root"); + assert_eq!(walk.next().map(|(p, _)| p), Some(second)); + let (_, last) = walk.next().unwrap(); + let media_box = last.get(b"MediaBox").and_then(Object::as_array).map(|m| m.iter().filter_map(Object::as_int).collect::>()); + assert_eq!(media_box, Some(vec![0, 0, 500, 500]), "own MediaBox kept over the root's"); + assert!(walk.next().is_none()); + assert_eq!(labels(&doc), ["Page 1", "Page 2", "Page 3"]); +} diff --git a/crates/organize/src/view.rs b/crates/organize/src/view.rs index efca2ce4d..0f115d305 100644 --- a/crates/organize/src/view.rs +++ b/crates/organize/src/view.rs @@ -96,7 +96,7 @@ pub fn initial_view(doc: &Document) -> InitialView { ..InitialView::default() }; if let Some(vp) = c.get(b"ViewerPreferences").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()) { - let flag = |k: &[u8]| matches!(vp.get(k), Some(Object::Bool(true))); + let flag = |k: &[u8]| matches!(vp.get(k).map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))); v.fit_window = flag(b"FitWindow"); v.center_window = flag(b"CenterWindow"); v.display_title = flag(b"DisplayDocTitle"); @@ -248,3 +248,50 @@ pub fn set_initial_view(doc: &mut Document, v: &InitialView) -> Result<(), Organ })?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use pdfcraft_cos::{SaveOptions, write_full}; + use std::sync::Arc; + + fn flags(view: InitialView) -> [bool; 6] { + [view.fit_window, view.center_window, view.display_title, view.hide_menubar, view.hide_toolbar, view.hide_window_ui] + } + + #[test] + fn initial_view_resolves_indirect_boolean_preferences() { + let keys: [&[u8]; 6] = [b"FitWindow", b"CenterWindow", b"DisplayDocTitle", b"HideMenubar", b"HideToolbar", b"HideWindowUI"]; + for enabled in [true, false] { + for indirect_dictionary in [false, true] { + let mut doc = Document::new_empty(); + let boolean = doc.add(Object::Bool(enabled)); + let mut preferences = Dict::new(); + for key in keys { + preferences.set(key.to_vec(), Object::Ref(boolean)); + } + let preferences = if indirect_dictionary { Object::Ref(doc.add(preferences)) } else { Object::Dict(preferences) }; + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"ViewerPreferences".to_vec(), preferences)).unwrap(); + assert_eq!(flags(initial_view(&doc)), [enabled; 6]); + let saved = write_full(&doc, &SaveOptions::default()).unwrap(); + let reopened = Document::open(Arc::new(saved)).unwrap(); + assert_eq!(flags(initial_view(&reopened)), [enabled; 6]); + assert_eq!(displays_doc_title(&reopened), enabled); + } + } + } + + #[test] + fn initial_view_malformed_boolean_preferences_keep_false_defaults() { + for value in [Object::Null, Object::Int(1), Object::name("true"), Object::Ref(pdfcraft_cos::ObjRef::new(999_999, 0))] { + let mut doc = Document::new_empty(); + let value = doc.add(value); + let mut preferences = Dict::new(); + preferences.set(b"FitWindow".to_vec(), Object::Ref(value)); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"ViewerPreferences".to_vec(), Object::Dict(preferences))).unwrap(); + assert_eq!(flags(initial_view(&doc)), [false; 6]); + } + } +} diff --git a/crates/organize/src/xmp.rs b/crates/organize/src/xmp.rs index 3efa8d265..bbcbbcf83 100644 --- a/crates/organize/src/xmp.rs +++ b/crates/organize/src/xmp.rs @@ -54,21 +54,42 @@ fn property(key: &str) -> Option<(&'static str, &'static str, Shape)> { /// that can't be edited safely. pub(crate) fn sync_info(doc: &mut Document, key: &str, value: &str) -> Result<(), OrganizeError> { let Some((name, ns, shape)) = property(key) else { return Ok(()) }; - let Some(root) = doc.root() else { return Ok(()) }; + edit_packet(doc, |xmp| set_property(xmp, name, ns, shape, value)).map(|_| ()) +} + +/// Remove every occurrence of the properties `names` from the catalog's XMP packet; whether any +/// was there. +pub(crate) fn remove_properties(doc: &mut Document, names: &[&str]) -> Result { + edit_packet(doc, |xmp| { + let mut out: Option = None; + for name in names { + // Each removal shortens the packet, so this ends. + while let Some(next) = set_property(out.as_deref().unwrap_or(xmp), name, "", Shape::Text, "") { + out = Some(next); + } + } + out + }) +} + +/// Rewrite the catalog's XMP packet with `edit` (`None`: unchanged); whether it changed. Files +/// without XMP and packets that can't be edited safely are left alone. +fn edit_packet(doc: &mut Document, edit: impl FnOnce(&str) -> Option) -> Result { + let Some(root) = doc.root() else { return Ok(false) }; // A metadata stream is always indirect (§7.3.8); anything else is left alone. - let Some(r) = doc.get(root).as_dict().and_then(|c| c.reference(b"Metadata")) else { return Ok(()) }; + let Some(r) = doc.get(root).as_dict().and_then(|c| c.reference(b"Metadata")) else { return Ok(false) }; let obj = doc.get(r); - let Object::Stream(stream) = &*obj else { return Ok(()) }; - let Ok(bytes) = stream.decoded_within(MAX_XMP) else { return Ok(()) }; - let Ok(xmp) = String::from_utf8(bytes) else { return Ok(()) }; - let Some(updated) = set_property(&xmp, name, ns, shape, value) else { return Ok(()) }; + let Object::Stream(stream) = &*obj else { return Ok(false) }; + let Ok(bytes) = stream.decoded_within(MAX_XMP) else { return Ok(false) }; + let Ok(xmp) = String::from_utf8(bytes) else { return Ok(false) }; + let Some(updated) = edit(&xmp) else { return Ok(false) }; // Written unfiltered, as PDF/A asks of metadata streams, so other tools can read it. let mut dict = stream.dict.clone(); for k in [&b"Filter"[..], b"DecodeParms", b"DL", b"Length"] { dict.remove(k); } doc.set(r, Object::Stream(Stream::from_raw(dict, updated.into_bytes()))); - Ok(()) + Ok(true) } /// `xmp` with the property `name` (namespace `ns`) set to `value`, or removed when `value` is diff --git a/crates/platform/README.md b/crates/platform/README.md index 6b23752b5..ea7af7a07 100644 --- a/crates/platform/README.md +++ b/crates/platform/README.md @@ -1,7 +1,11 @@ # pdfcraft-platform -Layer L7 holds operating-system services that both the desktop app (`pdfcraft-ui-egui`) and the automation tools (`pdfcraft-automation`) need. Those two crates sit on the same layer and can't depend on each other, so a helper they share lives here instead of being copied into each (`xtask/src/layers.rs` allows both edges). It uses only the standard library and has no UI dependencies. +Layer L7 holds operating-system services that both the desktop app (`pdfcraft-ui-egui`) and the automation tools (`pdfcraft-automation`) need. Those two crates sit on the same layer and can't depend on each other, so a helper they share lives here instead of being copied into each (`xtask/src/layers.rs` allows both edges). It has no UI dependencies; macOS filesystem calls use the safe `rustix` wrapper. `staging` creates the temporary file an atomic save writes before renaming it over the target. The file is opened with `create_new`, so anything already at the name (a file, a hard link, a symbolic link even when dangling, a folder) is skipped and never written through. On Windows a folder at the name fails with "access denied" rather than "already exists", and is skipped only when something is actually there. Names carry an unpredictable 64-bit suffix from `staging_suffixes` (the standard library's keyed hash, currently SipHash-1-3, keyed from the operating system's random source), which stops a name being planted in advance but is not a CSPRNG; fixed suffixes are only for tests. At most 16 names are tried, and then the save is refused. The target's name is cut to 128 bytes on a character boundary so the staging name fits every system's limit. Each caller keeps its own name form (`.{name}.{suffix}.pdfcraft-tmp` for automation, `.{name}.pdfcraft-{suffix}.tmp` for the desktop save). Writing, closing the file before the rename, renaming and removing the staging file on failure stay with the caller. Synthetic tests pin both name forms, the 128-byte cut, the attempt limit and refusal when no name is left. The planted-file, link, folder and failed-rename tests stay with each caller, where they exercise the whole save. + +On macOS, `write_atomically_with` copies the existing regular destination's accessible extended attributes to the open staging file after writing the data and before flushing and renaming. Finder tags, comments, binary values and empty attributes therefore survive both incremental and full saves (#2658). Save As retains attributes of an existing destination; a new destination does not inherit metadata from the source PDF. Missing files and filesystems without xattr support keep working. The metadata read does not follow the destination's symlink or block on a FIFO. macOS manages the new file's `com.apple.provenance`; it is not copied. Kernel-hidden compression attributes are not requested. + +Attribute copying is bounded: 64 KiB of names, 1024 attributes, 16 MiB per value and 64 MiB total. Each value must fill the initially queried size and retain that size in a second query after reading: a resource fork may return a partial prefix on growth rather than an error. A read/copy error or exceeded bound returns an error suggesting Save As; the original bytes and metadata stay intact and the staging file is removed. Flush errors retain the same guarantee. Tests cover exact metadata before flush and after repeated saves, empty/binary values, oversized resource forks, a simulated partial read of a growing fork and symlink isolation. Automation covers incremental/full saves, Save As and retaining an unsaved edit after metadata refusal. The staging writer also retains Unix permissions and, where supported, Windows/macOS creation dates; attribute copying runs before the creation date is restored. ACLs and hard-link identity are not preserved. diff --git a/crates/platform/src/staging.rs b/crates/platform/src/staging.rs index fee0a1633..0b727449c 100644 --- a/crates/platform/src/staging.rs +++ b/crates/platform/src/staging.rs @@ -101,6 +101,8 @@ pub fn staging_suffixes() -> impl Iterator { /// that is replaced keeps its permissions, so a private file stays private; a new file gets the /// usual mode for a new file. On Windows and macOS a replaced file keeps its creation date, where /// the volume stores one. +/// On macOS, accessible extended attributes of an existing regular destination are copied +/// before flushing; a copy failure refuses the save rather than losing metadata (#2658). pub fn write_atomically(path: &Path, bytes: &[u8], form: StagingName) -> std::io::Result<()> { write_atomically_with(path, bytes, form, staging_suffixes(), std::fs::File::sync_all) } @@ -138,6 +140,10 @@ pub fn write_atomically_with( { let mut file = file; file.write_all(bytes)?; + #[cfg(target_os = "macos")] + preserve_target_xattrs(path, &file).map_err(|e| { + std::io::Error::new(e.kind(), format!("could not preserve the file's extended attributes; save a copy with Save As: {e}")) + })?; #[cfg(any(windows, target_os = "macos"))] keep_created(&file, created); sync_staged(&file, sync_all)?; @@ -150,6 +156,73 @@ pub fn write_atomically_with( result } +/// Copy accessible macOS attributes to the staging descriptor before publication. The kernel +/// hides compression bookkeeping unless SHOWCOMPRESSION is requested; leave that option off. +/// Provenance is managed by macOS for the new file, not writable document metadata. +#[cfg(target_os = "macos")] +fn preserve_target_xattrs(path: &Path, staged: &std::fs::File) -> std::io::Result<()> { + use rustix::fs::{Mode, OFlags, XattrFlags, fgetxattr, flistxattr, fsetxattr, open}; + use rustix::io::Errno; + + // A new destination has nothing to retain. Replacing a symlink has always replaced the + // link, not its referent: do not open it to read metadata from a different file. NONBLOCK + // also prevents a FIFO at the destination from hanging the metadata read. + let source = match open(path, OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK, Mode::empty()) { + Ok(fd) => std::fs::File::from(fd), + Err(Errno::NOENT | Errno::LOOP) => return Ok(()), + Err(e) => return Err(e.into()), + }; + if !source.metadata()?.is_file() { + return Ok(()); + } + + // Metadata is untrusted too: bound both allocations and total copying, including forks. + const MAX_NAMES_BYTES: usize = 64 * 1024; + const MAX_ATTRIBUTES: usize = 1024; + const MAX_VALUE_BYTES: usize = 16 * 1024 * 1024; + const MAX_TOTAL_BYTES: usize = 64 * 1024 * 1024; + let mut names = vec![0u8; MAX_NAMES_BYTES]; + let len = match flistxattr(&source, names.as_mut_slice()) { + Ok(len) => len, + Err(Errno::NOTSUP) => return Ok(()), // No attributes exist on this filesystem. + Err(Errno::RANGE) => return Err(std::io::Error::other("extended attribute name list exceeds 64 KiB")), + Err(e) => return Err(e.into()), + }; + let names = names.get(..len).ok_or_else(|| std::io::Error::other("extended attribute name list exceeds 64 KiB"))?; + let mut total = 0usize; + for (index, name) in names.split(|b| *b == 0).filter(|name| !name.is_empty()).enumerate() { + if index >= MAX_ATTRIBUTES { + return Err(std::io::Error::other("extended attribute count exceeds 1024")); + } + if name == b"com.apple.provenance" { + continue; + } + let name = std::ffi::CString::new(name).map_err(|_| std::io::Error::other("invalid extended attribute name"))?; + let size = fgetxattr(&source, &name, &mut [] as &mut [u8])?; + if size > MAX_VALUE_BYTES || size > MAX_TOTAL_BYTES.saturating_sub(total) { + return Err(std::io::Error::other("extended attributes exceed the 16 MiB per-value or 64 MiB total limit")); + } + // Resource forks may return a prefix instead of ERANGE on growth; check the size again. + let value = read_xattr_value(size, |buffer| Ok(fgetxattr(&source, &name, buffer)?))?; + fsetxattr(staged, &name, &value, XattrFlags::empty())?; + total = total.saturating_add(value.len()); + } + Ok(()) +} + +/// Read an already size-bounded attribute, refusing a partial result or a changed post-read size. +#[cfg(target_os = "macos")] +fn read_xattr_value(size: usize, mut read: impl FnMut(&mut [u8]) -> std::io::Result) -> std::io::Result> { + let mut value = vec![0u8; size.max(1)]; + let len = read(value.as_mut_slice())?; + let after = read(&mut [])?; + if len != size || after != size { + return Err(std::io::Error::other("extended attribute changed size while saving")); + } + value.truncate(len); + Ok(value) +} + /// Give the staging file the creation date of the file it replaces, where the system keeps one /// that a program can set (Windows, macOS). Best effort: a volume that can't store it leaves the /// new file's own date, as before. @@ -304,6 +377,182 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[cfg(target_os = "macos")] + #[test] + fn atomic_saves_keep_finder_tags_and_binary_extended_attributes() { + use rustix::fs::{XattrFlags, fgetxattr, setxattr}; + use std::os::macos::fs::FileTimesExt as _; + let dir = test_dir("xattrs"); + let target = dir.join("out.pdf"); + let attributes: &[(&str, &[u8])] = &[ + ("com.apple.metadata:_kMDItemUserTags", b"PdfCraft\n6"), + ("com.pdfcraft.test.binary", b"\0\xff\x80original\0"), + ("com.pdfcraft.test.empty", b""), + ]; + std::fs::write(&target, b"OLD").unwrap(); + for (name, value) in attributes { + setxattr(&target, *name, value, XattrFlags::empty()).unwrap(); + } + let created = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_577_934_245); + let original = std::fs::OpenOptions::new().write(true).open(&target).unwrap(); + original.set_times(std::fs::FileTimes::new().set_created(created)).unwrap(); + assert_eq!(original.metadata().unwrap().created().unwrap(), created); + drop(original); + // Both desktop and automation name forms, including a second save of the replacement. + for form in [StagingName::TagThenSuffix, StagingName::SuffixThenTag] { + write_atomically_with(&target, b"NEW", form, [7], |staged| { + assert_eq!(staged.metadata().unwrap().created().unwrap(), created, "creation date must survive attribute copying before flush"); + for (name, value) in attributes { + let mut bytes = [0u8; 256]; + let len = fgetxattr(staged, *name, &mut bytes[..]).unwrap(); + assert_eq!(&bytes[..len], *value, "metadata must be present before flush: {name}"); + } + Ok(()) + }) + .unwrap(); + let saved = std::fs::File::open(&target).unwrap(); + assert_eq!(saved.metadata().unwrap().created().unwrap(), created); + for (name, value) in attributes { + let mut bytes = [0u8; 256]; + let len = fgetxattr(&saved, *name, &mut bytes[..]).unwrap(); + assert_eq!(&bytes[..len], *value); + } + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + } + std::fs::remove_dir_all(dir).unwrap(); + } + + #[cfg(target_os = "macos")] + #[test] + fn a_failed_flush_keeps_the_original_extended_attributes() { + use rustix::fs::{XattrFlags, getxattr, setxattr}; + let dir = test_dir("xattrs-flush"); + let target = dir.join("out.pdf"); + std::fs::write(&target, b"OLD").unwrap(); + setxattr(&target, "com.pdfcraft.test", b"original", XattrFlags::empty()).unwrap(); + let e = write_atomically_with(&target, b"NEW", StagingName::TagThenSuffix, [9], |_| Err(std::io::Error::other("flush refused"))).unwrap_err(); + assert!(e.to_string().contains("flush refused")); + assert_eq!(std::fs::read(&target).unwrap(), b"OLD"); + let mut value = [0u8; 64]; + let len = getxattr(&target, "com.pdfcraft.test", &mut value[..]).unwrap(); + assert_eq!(&value[..len], b"original"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + std::fs::remove_dir_all(dir).unwrap(); + } + + /// Model an HFS-style partial read while using real macOS metadata and the atomic writer. + /// The shared read validator must reject growth before the staging file can be published. + #[cfg(target_os = "macos")] + #[test] + fn a_growing_resource_fork_refuses_publication_and_keeps_original() { + use rustix::fs::{XattrFlags, fgetxattr, fsetxattr, getxattr, setxattr}; + let dir = test_dir("xattrs-growing-fork"); + let target = dir.join("out.pdf"); + let name = "com.apple.ResourceFork"; + for form in [StagingName::TagThenSuffix, StagingName::SuffixThenTag] { + std::fs::write(&target, b"ORIGINAL PDF").unwrap(); + setxattr(&target, name, b"ABC", XattrFlags::empty()).unwrap(); + let source = std::fs::File::open(&target).unwrap(); + let result = write_atomically_with(&target, b"REPLACEMENT PDF", form, [23], |staged| { + let size = fgetxattr(&source, name, &mut [] as &mut [u8])?; + let value = read_xattr_value(size, |buffer| { + if buffer.is_empty() { + return Ok(fgetxattr(&source, name, buffer)?); + } + // HFS can return the prefix that fits instead of ERANGE. APFS need not + // have that behaviour: model the kernel response, not a timing race. + setxattr(&target, name, b"ABCD", XattrFlags::empty())?; + buffer.get_mut(..3).unwrap().copy_from_slice(b"ABC"); + Ok(3) + })?; + fsetxattr(staged, name, &value, XattrFlags::empty())?; + Ok(()) + }); + let error = result.expect_err("a short prefix of the grown fork must not be published"); + assert!(error.to_string().contains("changed size"), "{error}"); + assert_eq!(std::fs::read(&target).unwrap(), b"ORIGINAL PDF"); + let mut bytes = [0u8; 8]; + let len = getxattr(&target, name, &mut bytes[..]).unwrap(); + assert_eq!(&bytes[..len], b"ABCD", "the original's grown fork must remain intact"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "staging files are removed on refusal"); + } + std::fs::remove_dir_all(dir).unwrap(); + } + + /// Stable empty/binary values are accepted; short reads, growth and failed rechecks are refused. + #[cfg(target_os = "macos")] + #[test] + fn attribute_reads_require_a_complete_value_and_stable_size() { + for bytes in [&b""[..], &b"\0\xffB"[..]] { + let value = read_xattr_value(bytes.len(), |buffer| { + if !buffer.is_empty() { + buffer.get_mut(..bytes.len()).unwrap().copy_from_slice(bytes); + } + Ok(bytes.len()) + }) + .unwrap(); + assert_eq!(value, bytes); + } + for (before, read_len, after) in [(3, 2, 3), (3, 3, 4), (0, 1, 1), (3, 2, 2)] { + let result = read_xattr_value(before, |buffer| Ok(if buffer.is_empty() { after } else { read_len })); + assert!(result.unwrap_err().to_string().contains("changed size")); + } + let result = read_xattr_value(3, |buffer| { + if buffer.is_empty() { Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "size recheck refused")) } else { Ok(3) } + }); + assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::PermissionDenied); + } + + #[cfg(target_os = "macos")] + #[test] + fn oversized_extended_attributes_refuse_the_save_without_replacing_the_target() { + use rustix::fs::{XattrFlags, getxattr, setxattr}; + let dir = test_dir("xattrs-limit"); + let target = dir.join("out.pdf"); + std::fs::write(&target, b"OLD").unwrap(); + // A resource fork can exceed the ordinary attribute limit on macOS. No fixture file + // is committed; only the OS metadata of this temporary synthetic file holds the bytes. + let fork = vec![42u8; 16 * 1024 * 1024 + 1]; + setxattr(&target, "com.apple.ResourceFork", &fork, XattrFlags::empty()).unwrap(); + let mut flushed = false; + let e = write_atomically_with(&target, b"NEW", StagingName::TagThenSuffix, [10], |_| { + flushed = true; + Ok(()) + }) + .unwrap_err(); + assert!(e.to_string().contains("extended attributes"), "{e}"); + assert!(e.to_string().contains("Save As"), "{e}"); + assert!(!flushed, "metadata failure must stop before flush and rename"); + assert_eq!(std::fs::read(&target).unwrap(), b"OLD"); + let size = getxattr(&target, "com.apple.ResourceFork", &mut [] as &mut [u8]).unwrap(); + assert_eq!(size, fork.len()); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[cfg(target_os = "macos")] + #[test] + fn replacing_a_symlink_does_not_copy_attributes_from_its_referent() { + use rustix::fs::{XattrFlags, getxattr, setxattr}; + let dir = test_dir("xattrs-symlink"); + let outside = test_dir("xattrs-outside").join("private.pdf"); + std::fs::write(&outside, b"PRIVATE").unwrap(); + setxattr(&outside, "com.pdfcraft.test", b"private metadata", XattrFlags::empty()).unwrap(); + let target = dir.join("out.pdf"); + std::os::unix::fs::symlink(&outside, &target).unwrap(); + write_atomically(&target, b"NEW", StagingName::TagThenSuffix).unwrap(); + assert_eq!(std::fs::read(&outside).unwrap(), b"PRIVATE"); + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + assert!(!target.symlink_metadata().unwrap().file_type().is_symlink()); + let mut bytes = [0u8; 64]; + assert_eq!(getxattr(&target, "com.pdfcraft.test", &mut bytes[..]).unwrap_err(), rustix::io::Errno::NOATTR); + let len = getxattr(&outside, "com.pdfcraft.test", &mut bytes[..]).unwrap(); + assert_eq!(&bytes[..len], b"private metadata"); + std::fs::remove_dir_all(dir).unwrap(); + std::fs::remove_dir_all(outside.parent().unwrap()).unwrap(); + } + /// #1790: a replaced file keeps its permissions, and its staging file already has them while /// it is written; a new file gets the same mode as any other new file. #[cfg(unix)] diff --git a/crates/print/Cargo.toml b/crates/print/Cargo.toml index 3c9b1071d..6ffa60b81 100644 --- a/crates/print/Cargo.toml +++ b/crates/print/Cargo.toml @@ -14,3 +14,6 @@ pdfcraft-cos = { workspace = true } pdfcraft-content = { workspace = true } pdfcraft-model = { workspace = true } thiserror = { workspace = true } + +[dev-dependencies] +pdfcraft-render = { workspace = true } diff --git a/crates/print/README.md b/crates/print/README.md index c7d6351fa..ae60e1a2c 100644 --- a/crates/print/README.md +++ b/crates/print/README.md @@ -27,11 +27,21 @@ spool::submit(&pdf, &spool::Job { printer: None, copies: 2, ..Default::default() - **Poster**: tile scale, overlap shared by neighbouring tiles, cut marks. - **Comments & forms**: document, + markups, + stamps, or form fields only; annotations print only with their Print flag, drawn from their appearance streams (Algorithm 8.1). +- **Properties… on CUPS** reads the queue's PPD and its current defaults (`lpoptions -l`) off + the GUI thread, and gives `lpoptions` ten seconds: a CUPS that has stopped answering leaves + the PPD's own defaults in force instead of holding the dialog (#1818; `spool::output_within` + kills and reaps the command at the deadline). Each source page becomes a Form XObject (its content wrapped in q/Q, plus the printable annotations); sheets place them with a clip. The result is a fresh, unencrypted, garbage-collected file (callers check the print permission). +The GUI Print dialog remembers its last selected paper size across application sessions, +including when Print is cancelled. The preference stores a canonical name such as `A4`; +unrecognized saved values retain the current selection (US Letter on a fresh app). +This change does not alter persistence of other print preferences. +Headless `doc_print` defaults are unchanged. + `spool` delegates discovery, driver options, preferences and submission to [`craft-print`](https://github.com/storytold/craft-libs/tree/main/crates/print). PdfCraft retains page selection, imposition, annotations and print-permission checks. diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index 802d9595b..e935cb938 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -279,7 +279,7 @@ impl Default for Settings { } /// Common paper sizes (points, portrait). -pub const PAPERS: [(&str, (f64, f64)); 20] = [ +pub const PAPERS: [(&str, (f64, f64)); 21] = [ ("US Letter", (612.0, 792.0)), ("US Legal", (612.0, 1008.0)), ("Tabloid", (792.0, 1224.0)), @@ -300,13 +300,15 @@ pub const PAPERS: [(&str, (f64, f64)); 20] = [ ("A1", (1683.78, 2383.94)), ("A0", (2383.94, 3370.39)), ("4 x 6 in label (101.6 x 152.4 mm)", (288.0, 432.0)), + ("US #10 Envelope (4.125 x 9.5 in)", (297.0, 684.0)), ]; /// `PAPERS` index of the 4 × 6 in shipping label. pub const LABEL_PAPER: usize = 19; /// The CUPS media name for `PAPERS[index]`, so the spooler feeds the sheet the PDF was laid out -/// on rather than the queue's default (PPD keywords; `w288h432` is the usual 4×6 label). +/// on rather than the queue's default (PPD keywords; `w288h432` is the usual 4×6 label, `Env10` +/// the US #10 envelope). pub fn cups_media(index: usize) -> Option<&'static str> { match PAPERS.get(index)?.0 { "US Letter" => Some("Letter"), @@ -315,6 +317,7 @@ pub fn cups_media(index: usize) -> Option<&'static str> { "A3" => Some("A3"), "A4" => Some("A4"), "A5" => Some("A5"), + "US #10 Envelope (4.125 x 9.5 in)" => Some("Env10"), _ if index == LABEL_PAPER => Some("w288h432"), _ => None, } @@ -748,7 +751,12 @@ fn appearance_matrix(doc: &Document, form: &Dict, rect: [f64; 4]) -> Option Result { +fn page_form( + doc: &mut Document, + index: usize, + content: Content, + promoted: &mut std::collections::HashMap, +) -> Result { let page = pdfcraft_model::pages(doc).swap_remove(index); let crop = page.crop(doc); let mut res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); @@ -777,6 +785,23 @@ fn page_form(doc: &mut Document, index: usize, content: Content) -> Result Result, PrintError let mut doc = src.clone(); // One form per source page used (a page may appear on several sheets: posters). let mut forms: std::collections::HashMap = std::collections::HashMap::new(); + let mut promoted = std::collections::HashMap::new(); let views: Vec<[f64; 6]> = pages.iter().map(|p| p.view_matrix(src)).collect(); let pages_root = doc.add(Object::Null); let mut kids = Vec::new(); @@ -816,7 +842,7 @@ pub fn impose(src: &Document, settings: &Settings) -> Result, PrintError let form = match forms.get(&pl.page) { Some(f) => *f, None => { - let f = page_form(&mut doc, pl.page, settings.content)?; + let f = page_form(&mut doc, pl.page, settings.content, &mut promoted)?; forms.insert(pl.page, f); f } diff --git a/crates/print/src/spool.rs b/crates/print/src/spool.rs index cf1c0cb76..5ce3d9736 100644 --- a/crates/print/src/spool.rs +++ b/crates/print/src/spool.rs @@ -10,21 +10,105 @@ pub use craft_print::{ /// empty when it has none or on other platforms. Reads files and runs `lpoptions`, so call it /// when the user asks, not every frame. pub fn printer_options(printer: &str) -> Vec { + printer_options_in(printer, "en") +} + +/// [`printer_options`] with the driver's own names in `lang` (a UI language code such as `bg`, +/// `pt-br` or `zh-hans`) where its PPD has them (`*bg.Translation`, `*bg.InputSlot Tray2/…`); +/// the driver's default names otherwise. +pub fn printer_options_in(printer: &str, lang: &str) -> Vec { #[cfg(all(unix, not(target_arch = "wasm32")))] { - printer_options_via(printer, std::path::Path::new("/etc/cups/ppd"), std::process::Command::new("lpoptions")) + printer_options_via_within(printer, std::path::Path::new("/etc/cups/ppd"), std::process::Command::new("lpoptions"), LPOPTIONS_LIMIT, lang) } #[cfg(not(all(unix, not(target_arch = "wasm32"))))] { + let _ = lang; craft_print::printer_options(printer) } } +/// How long `lpoptions` may take: a CUPS that has stopped answering must not hold whoever asked +/// (#1818). After this the PPD's own defaults stand. +#[cfg(all(unix, not(target_arch = "wasm32")))] +const LPOPTIONS_LIMIT: std::time::Duration = std::time::Duration::from_secs(10); + +/// The most of a command's output that is kept (`lpoptions -l` is a few kilobytes). +#[cfg(any(test, all(unix, not(target_arch = "wasm32"))))] +const OUTPUT_CAP: u64 = 4 << 20; + +/// Run `command` and collect its standard output, giving it `limit`: a command still running +/// then is killed and reaped, and the error is `TimedOut`. Standard input and error are not +/// connected. The exit status is not checked, as `Command::output` leaves it to the caller. +/// Only CUPS runs a command today; the tests cover every desktop platform. +#[cfg(any(test, all(unix, not(target_arch = "wasm32"))))] +pub(crate) fn output_within(command: &mut std::process::Command, limit: std::time::Duration) -> std::io::Result> { + use std::io::Read as _; + use std::process::Stdio; + let mut child = command.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn()?; + let stop = |child: &mut std::process::Child| { + let _ = child.kill(); + let _ = child.wait(); + }; + let Some(mut stdout) = child.stdout.take() else { + stop(&mut child); + return Err(std::io::Error::other("the command's output could not be read")); + }; + // Read on a thread of its own, so a full pipe never holds the command while the deadline is + // kept here. Past the cap the rest is drained and dropped, so the command still finishes. + // The output comes back over a channel rather than through `join`: a process the command + // started can keep the pipe open after the command itself has exited or been killed, and + // the wait for the output must end at the deadline too. Such a reader is left to finish on + // its own when that process closes the pipe. + let (tx, rx) = std::sync::mpsc::channel(); + let reader = std::thread::Builder::new().name("command output".into()).spawn(move || { + let mut out = Vec::new(); + let read = (&mut stdout).take(OUTPUT_CAP).read_to_end(&mut out); + let _ = std::io::copy(&mut stdout, &mut std::io::sink()); + let _ = tx.send(read.map(|_| out)); + }); + if let Err(e) = reader { + stop(&mut child); + return Err(e); + } + let timed_out = || std::io::Error::new(std::io::ErrorKind::TimedOut, format!("the command did not answer within {limit:?}")); + let deadline = std::time::Instant::now() + limit; + loop { + match child.try_wait() { + Ok(Some(_)) => { + // A moment's grace, so output written just before an exit at the deadline counts. + let left = deadline.saturating_duration_since(std::time::Instant::now()).max(std::time::Duration::from_millis(100)); + return match rx.recv_timeout(left) { + Ok(read) => read, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(timed_out()), + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(std::io::Error::other("the command's output was lost")), + }; + } + Ok(None) if std::time::Instant::now() >= deadline => { + stop(&mut child); + return Err(timed_out()); + } + Ok(None) => std::thread::sleep(std::time::Duration::from_millis(10)), + Err(e) => { + stop(&mut child); + return Err(e); + } + } + } +} + /// Read a destination's options with the PPD directory and command supplied, so tests need /// neither a system queue nor a physical printer. CUPS instances share their queue's PPD but -/// have their own defaults, queried with the complete `queue/instance` destination. +/// have their own defaults, queried with the complete `queue/instance` destination. `limit` is +/// how long the command may take before the PPD's defaults stand on their own. #[cfg(all(unix, not(target_arch = "wasm32")))] -pub(crate) fn printer_options_via(printer: &str, ppd_dir: &std::path::Path, mut lpoptions: std::process::Command) -> Vec { +pub(crate) fn printer_options_via_within( + printer: &str, + ppd_dir: &std::path::Path, + mut lpoptions: std::process::Command, + limit: std::time::Duration, + lang: &str, +) -> Vec { let (queue, instance) = printer.split_once('/').map_or((printer, None), |(q, i)| (q, Some(i))); // Retain the conservative name checks, applied to each component. Neither component is // a path: reject dot components, backslashes and any additional separator before I/O. @@ -43,8 +127,12 @@ pub(crate) fn printer_options_via(printer: &str, ppd_dir: &std::path::Path, mut let mut bytes = Vec::new(); f.take(PPD_CAP).read_to_end(&mut bytes).ok().map(|_| bytes) }); - let mut options = ppd.map(|b| parse_ppd(&ppd_text(&b))).unwrap_or_default(); - let current = lpoptions.args(["-p", printer, "-l"]).output().map(|o| parse_lpoptions(&String::from_utf8_lossy(&o.stdout))).unwrap_or_default(); + let mut options = ppd.as_deref().map(|b| parse_ppd(&ppd_text(b))).unwrap_or_default(); + if let Some(b) = &ppd { + localize(&mut options, b, lang); + } + let current = + output_within(lpoptions.args(["-p", printer, "-l"]), limit).map(|out| parse_lpoptions(&String::from_utf8_lossy(&out))).unwrap_or_default(); for (key, value) in current { if let Some(o) = options.iter_mut().find(|o| o.key == key) && o.choices.iter().any(|(c, _)| *c == value) @@ -68,3 +156,81 @@ pub fn submit_with_progress(pdf: &[u8], job: &Job, progress: &mut dyn FnMut(usiz pub fn open_printer_preferences(printer: &str) -> Result<(), crate::PrintError> { craft_print::open_printer_preferences(printer).map_err(|e| crate::PrintError::Spool(e.to_string())) } + +/// The PPD language codes (`*cupsLanguages`, the `ll` of `*ll.Translation`) to try for a UI +/// language code, most specific first: `pt-br` → `pt_BR`, `pt`; `zh-hans` → `zh_CN`, `zh`. +// Read only by the CUPS path; the tests exercise it everywhere. +#[cfg_attr(not(all(unix, not(target_arch = "wasm32"))), allow(dead_code))] +fn ppd_languages(lang: &str) -> Vec { + let lang = lang.to_ascii_lowercase(); + let mut parts = lang.split(['-', '_']).filter(|p| !p.is_empty()); + let Some(primary) = parts.next() else { return Vec::new() }; + let region = match (primary, parts.next()) { + ("zh", Some("hans")) => Some("CN".to_string()), + ("zh", Some("hant")) => Some("TW".to_string()), + (_, Some(r)) if r.len() == 2 => Some(r.to_ascii_uppercase()), + _ => None, + }; + region.map(|r| format!("{primary}_{r}")).into_iter().chain(std::iter::once(primary.to_string())).collect() +} + +/// Give `options` the driver's own names in `lang` from the PPD's localization lines +/// (`*ll.Translation Key/Label: ""` for options and groups, `*ll.Key choice/Label: ""` for +/// choices). Each line is read as UTF-8 on its own: drivers write them so in files whose other +/// text is Latin-1. Names with no translation, or an empty one, stay as they are. +// Read only by the CUPS path; the tests exercise it everywhere. +#[cfg_attr(not(all(unix, not(target_arch = "wasm32"))), allow(dead_code))] +pub(crate) fn localize(options: &mut [PrinterOption], ppd: &[u8], lang: &str) { + use std::collections::HashMap; + let Some(code) = ppd_languages(lang).into_iter().find(|code| { + let prefix = format!("*{code}."); + ppd.split(|b| *b == b'\n').any(|l| l.starts_with(prefix.as_bytes())) + }) else { + return; + }; + if code == "en" { + return; + } + let prefix = format!("*{code}."); + let mut names: HashMap = HashMap::new(); + let mut choices: HashMap<(String, String), String> = HashMap::new(); + let mut groups: HashMap = HashMap::new(); + for line in ppd.split(|b| *b == b'\n') { + let Some(text) = std::str::from_utf8(line).ok() else { continue }; + let text = text.trim_end_matches('\r'); + if let Some(rest) = text.strip_prefix("*OpenGroup:") { + // Group label → keyword, since options carry their group's label. + let rest = rest.trim(); + if let Some((key, label)) = rest.split_once('/') { + groups.insert(label.trim().to_string(), key.trim().to_string()); + } + continue; + } + let Some(rest) = text.strip_prefix(prefix.as_str()) else { continue }; + let Some((head, _)) = rest.split_once(':') else { continue }; + let Some((main, spec)) = head.split_once(' ') else { continue }; + let Some((key, label)) = spec.split_once('/') else { continue }; + let label = label.trim(); + if label.is_empty() { + continue; + } + if main == "Translation" { + names.insert(key.trim().to_string(), label.to_string()); + } else { + choices.insert((main.to_string(), key.trim().to_string()), label.to_string()); + } + } + for o in options.iter_mut() { + if let Some(label) = names.get(&o.key) { + o.label = label.clone(); + } + if let Some(label) = groups.get(&o.group).and_then(|key| names.get(key)) { + o.group = label.clone(); + } + for (choice, label) in o.choices.iter_mut() { + if let Some(t) = choices.get(&(o.key.clone(), choice.clone())) { + *label = t.clone(); + } + } + } +} diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index 54cbbd4c7..99634f1aa 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -499,6 +499,8 @@ mod cups_options { const RECORD: &str = "PDFCRAFT_LPOPTIONS_TEST_RECORD"; const ANSWER: &str = "PDFCRAFT_LPOPTIONS_TEST_ANSWER"; + /// Generous: these tests are about what is read, not how long it takes. + const LIMIT: std::time::Duration = std::time::Duration::from_secs(60); /// The child process stands in for lpoptions, recording its real arguments. #[test] @@ -506,8 +508,31 @@ mod cups_options { let Some(record) = std::env::var_os(RECORD) else { return }; let args: Vec = std::env::args().skip_while(|a| a != "--").skip(1).collect(); std::fs::write(std::path::Path::new(&record).join("args"), args.join("\n")).unwrap(); + let answer = std::env::var(ANSWER).unwrap(); + // A queue that never answers (CUPS stopped responding). + if answer == "HANG" { + std::thread::sleep(std::time::Duration::from_secs(60)); + } // Serial libtest prints its test label without a newline before this helper's stdout. - println!("\n{}", std::env::var(ANSWER).unwrap()); + println!("\n{answer}"); + } + + /// `lpoptions` that never answers is stopped at the deadline: the PPD's defaults stand and + /// whoever asked is not held (#1818). + #[test] + fn a_queue_that_never_answers_is_given_up_at_the_deadline() { + let dir = folder(); + std::fs::write( + dir.join("Office.ppd"), + "*OpenUI *InputSlot/Paper tray: PickOne\n*DefaultInputSlot: Auto\n*InputSlot Auto/Automatic: \"\"\n*InputSlot Tray2/Tray 2: \"\"\n*CloseUI: *InputSlot\n", + ) + .unwrap(); + let started = std::time::Instant::now(); + let options = spool::printer_options_via_within("Office", &dir, command(&dir, "HANG"), std::time::Duration::from_millis(500), "en"); + assert!(started.elapsed() < std::time::Duration::from_secs(20), "given up at the deadline, not when the command does"); + assert_eq!(options.len(), 1, "the PPD is still read"); + assert_eq!(options[0].default, "Auto", "the PPD default stands when the queue does not answer"); + std::fs::remove_dir_all(dir).unwrap(); } fn command(record: &std::path::Path, answer: &str) -> std::process::Command { @@ -557,7 +582,8 @@ mod cups_options { (boundary_queue.as_str(), "Tray2", "Tray2"), (boundary_instance.as_str(), "Tray2", "Tray2"), ] { - let options = spool::printer_options_via(destination, &dir, command(&dir, &format!("InputSlot/Paper tray: *{current}"))); + let options = + spool::printer_options_via_within(destination, &dir, command(&dir, &format!("InputSlot/Paper tray: *{current}")), LIMIT, "en"); assert_eq!(options.len(), 1, "{destination}: the queue's PPD must be read"); assert_eq!(options[0].default, expected, "{destination}: only valid instance choices override the PPD default"); assert_eq!(options[0].choices, [("Auto".into(), "Automatic".into()), ("Tray2".into(), "Tray 2".into())]); @@ -598,7 +624,10 @@ mod cups_options { long_queue_instance.as_str(), long_instance.as_str(), ] { - assert!(spool::printer_options_via(destination, &dir, command(&dir, "InputSlot: *Auto")).is_empty(), "{destination:?}"); + assert!( + spool::printer_options_via_within(destination, &dir, command(&dir, "InputSlot: *Auto"), LIMIT, "en").is_empty(), + "{destination:?}" + ); assert!(!dir.join("args").exists(), "{destination:?}: rejected before spawning"); } std::fs::remove_dir_all(dir).unwrap(); @@ -645,6 +674,17 @@ fn paper_matching_accepts_rounded_metric_sizes_but_not_custom_or_invalid_sizes() } } +#[test] +fn us_number_10_envelope_is_offered_and_fed_by_name() { + // 4.125 × 9.5 in (#2734). + let index = matching_paper((684.0, 297.0)).unwrap(); + assert_eq!(PAPERS[index].0, "US #10 Envelope (4.125 x 9.5 in)"); + assert_eq!(PAPERS[index].1, (297.0, 684.0)); + assert_eq!(cups_media(index), Some("Env10")); + let s = Settings { pages: vec![0], paper: PAPERS[index].1, layout: Layout::Size(SizeMode::Actual), ..Settings::default() }; + assert_eq!(layout(&[(297.0, 684.0)], &s).unwrap()[0].size, (297.0, 684.0)); +} + #[test] fn label_is_cut_turned_upright_and_fitted_without_distortion() { let label = LABEL_PAPER; @@ -744,3 +784,96 @@ fn imposed_pages_preserve_large_user_unit_matrices() { } } } + +/// `output_within` (#1818): the bounded wait every platform's `lpoptions` call goes through. +#[cfg(not(target_arch = "wasm32"))] +mod bounded_commands { + const MODE: &str = "PDFCRAFT_COMMAND_TEST_MODE"; + + /// The child process stands in for a command: it answers, or it never does. + #[test] + fn stand_in_for_a_command() { + match std::env::var(MODE).as_deref() { + Ok("answer") => println!("hello from the command"), + Ok("hang") => std::thread::sleep(std::time::Duration::from_secs(60)), + // Holds the output pipe it inherited for a while, as a daemon a command starts can. + Ok("linger") => std::thread::sleep(LINGER), + // Starts a lingering process on its own output, then exits or hangs itself. + Ok(mode @ ("orphan-exit" | "orphan-hang")) => { + let _ = command("linger").spawn(); + println!("started"); + if mode == "orphan-hang" { + std::thread::sleep(std::time::Duration::from_secs(60)); + } + } + _ => {} + } + } + + const LINGER: std::time::Duration = std::time::Duration::from_secs(8); + + fn command(mode: &str) -> std::process::Command { + let mut c = std::process::Command::new(std::env::current_exe().unwrap()); + c.args(["--exact", "tests::bounded_commands::stand_in_for_a_command", "--nocapture", "--"]).env(MODE, mode); + c + } + + #[test] + fn a_command_that_answers_gives_its_output() { + let out = crate::spool::output_within(&mut command("answer"), std::time::Duration::from_secs(60)).unwrap(); + assert!(String::from_utf8_lossy(&out).contains("hello from the command")); + } + + #[test] + fn a_command_that_never_answers_is_stopped_at_the_deadline() { + let started = std::time::Instant::now(); + let err = crate::spool::output_within(&mut command("hang"), std::time::Duration::from_millis(500)).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::TimedOut); + assert!(started.elapsed() < std::time::Duration::from_secs(20), "stopped soon after the deadline, not when the command gives up"); + } + + /// A process the command started keeps the output pipe open after the command has exited, + /// or after it was killed at the deadline: the wait still ends at the deadline (#2108 + /// review), not when that process lets go of the pipe. + #[test] + fn a_process_left_holding_the_output_does_not_extend_the_wait() { + for mode in ["orphan-exit", "orphan-hang"] { + let started = std::time::Instant::now(); + let err = crate::spool::output_within(&mut command(mode), std::time::Duration::from_millis(1500)).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::TimedOut, "{mode}"); + assert!(started.elapsed() < LINGER / 2, "{mode}: returned after {:?}, when the lingering process let go", started.elapsed()); + } + } +} + +#[test] +fn driver_options_take_the_ppds_own_names_in_the_ui_language() { + use crate::spool::{PrinterOption, localize, parse_ppd, ppd_text}; + // Latin-1 elsewhere (the é), UTF-8 in the localization lines, as drivers ship them. + let mut ppd = b"*cupsLanguages: \"bg pt_BR\"\n*OpenGroup: Finishing/Finishing Options\n*OpenUI *KMDuplex/Print Type: PickOne\n*DefaultKMDuplex: Single\n*KMDuplex Single/1-Sided: \"\"\n*KMDuplex Double/2-Sided: \"\"\n*CloseUI: *KMDuplex\n*OpenUI *Staple/Staple: PickOne\n*DefaultStaple: None\n*Staple None/None: \"\"\n*CloseUI: *Staple\n*CloseGroup: Finishing\n*fr.Translation KMDuplex/Type d'impression r".to_vec(); + ppd.push(0xE9); + ppd.extend_from_slice("el: \"\"\n*bg.Translation Finishing/Довършителни операции: \"\"\n*bg.Translation KMDuplex/Тип печат: \"\"\n*bg.KMDuplex Single/Едностранен: \"\"\n*bg.KMDuplex Double/: \"\"\n*pt_BR.Translation KMDuplex/Tipo de impressão: \"\"\n".as_bytes()); + let parsed = parse_ppd(&ppd_text(&ppd)); + let label = |o: &[PrinterOption], key: &str| o.iter().find(|o| o.key == key).map(|o| o.label.clone()).unwrap(); + let mut bg = parsed.clone(); + localize(&mut bg, &ppd, "bg"); + let duplex = bg.iter().find(|o| o.key == "KMDuplex").unwrap(); + assert_eq!((duplex.label.as_str(), duplex.group.as_str()), ("Тип печат", "Довършителни операции")); + assert_eq!( + duplex.choices, + [("Single".to_string(), "Едностранен".to_string()), ("Double".to_string(), "2-Sided".to_string())], + "an empty translation keeps the driver's name" + ); + assert_eq!(label(&bg, "Staple"), "Staple", "untranslated options keep theirs"); + // Region-specific codes, then the language alone; languages the PPD lacks change nothing. + let mut pt = parsed.clone(); + localize(&mut pt, &ppd, "pt-br"); + assert_eq!(label(&pt, "KMDuplex"), "Tipo de impressão"); + for lang in ["en", "de", "", "bg-"] { + let mut other = parsed.clone(); + localize(&mut other, &ppd, lang); + assert_eq!(other == parsed, lang != "bg-", "{lang:?}"); + } +} + +mod appearances; diff --git a/crates/print/src/tests/appearances.rs b/crates/print/src/tests/appearances.rs new file mode 100644 index 000000000..8d6f56ff6 --- /dev/null +++ b/crates/print/src/tests/appearances.rs @@ -0,0 +1,332 @@ +use super::*; +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RenderedPage, RequestKind}; + +fn numbers(values: &[f64]) -> Object { + Object::Array(values.iter().copied().map(Object::Real).collect()) +} + +// Contributor-original artwork uses a named color resource, without fonts or external assets. +fn widget_fixture(subtype: Option, matrix: Option<[f64; 6]>) -> (Document, ObjRef, ObjRef) { + let mut doc = fixture(1); + let page = pdfcraft_model::pages(&doc)[0].obj; + let mut colors = Dict::new(); + colors.set(b"Ink".to_vec(), Object::name("DeviceRGB")); + let mut resources = Dict::new(); + resources.set(b"ColorSpace".to_vec(), Object::Dict(colors)); + let resources = doc.add(Object::Dict(resources)); + let mut d = Dict::new(); + d.set(b"BBox".to_vec(), numbers(&[0.0, 0.0, 20.0, 10.0])); + d.set(b"Resources".to_vec(), Object::Ref(resources)); + d.set(b"PrivateKey".to_vec(), Object::name("PreserveMe")); + if let Some(subtype) = subtype { + d.set(b"Subtype".to_vec(), subtype); + } + if let Some(matrix) = matrix { + d.set(b"Matrix".to_vec(), numbers(&matrix)); + } + let mut stream = Stream::flate(d, b"/Ink cs 0 1 0 scn 0 0 20 10 re f"); + stream.dict.set(b"DecodeParms".to_vec(), Object::Dict(Dict::new())); + let ap = doc.add(Object::Stream(stream)); + let mut widget = Dict::new(); + widget.set(b"Subtype".to_vec(), Object::name("Widget")); + widget.set(b"FT".to_vec(), Object::name("Tx")); + widget.set(b"F".to_vec(), Object::Int(4)); + widget.set(b"Rect".to_vec(), numbers(&[10.0, 20.0, 50.0, 40.0])); + let mut appearance = Dict::new(); + appearance.set(b"N".to_vec(), Object::Ref(ap)); + widget.set(b"AP".to_vec(), Object::Dict(appearance)); + let widget = doc.add(Object::Dict(widget)); + let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), b"0 0 1 rg 0 0 8 8 re f"))); + doc.update_dict(page, |d| { + d.set(b"MediaBox".to_vec(), numbers(&[0.0, 0.0, 100.0, 100.0])); + d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(widget)])); + d.set(b"Contents".to_vec(), Object::Ref(contents)); + d.set(b"Resources".to_vec(), Object::Dict(Dict::new())); + }) + .unwrap(); + (doc, ap, widget) +} + +fn print_settings(content: Content) -> Settings { + Settings { pages: vec![0], paper: (100.0, 100.0), layout: Layout::Size(SizeMode::Actual), content, ..Settings::default() } +} + +fn render(bytes: Vec) -> RenderedPage { + render_page(bytes, 0) +} + +fn render_page(bytes: Vec, index: usize) -> RenderedPage { + let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest { + page: index, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + preview: false, + }); + assert_eq!(page.error, None); + assert!(page.warnings.is_empty(), "{:?}", page.warnings); + assert_eq!((page.width, page.height), (100, 100)); + page +} + +fn pixel(page: &RenderedPage, x: usize, y: usize) -> &[u8] { + let offset = (y * page.width as usize + x) * 4; + &page.rgba[offset..offset + 4] +} + +fn saved(doc: &Document) -> Vec { + write_full(doc, &SaveOptions::default()).unwrap() +} + +fn xobject(doc: &Document, owner: &Dict, name: &[u8]) -> ObjRef { + let resources = doc.resolve(owner.get(b"Resources").unwrap()); + let objects = doc.resolve(resources.as_dict().unwrap().get(b"XObject").unwrap()); + objects.as_dict().unwrap().get(name).unwrap().as_ref().unwrap() +} + +#[test] +fn missing_subtype_appearance_survives_vector_print() { + let (control, _, _) = widget_fixture(Some(Object::name("Form")), None); + let control = render(impose(&control, &print_settings(Content::Document)).unwrap()); + assert_eq!(pixel(&control, 30, 70), [0, 255, 0, 255], "valid Form control"); + let (doc, ap, _) = widget_fixture(None, None); + let before = saved(&doc); + let state = doc.read_state(); + let source_bytes = doc.bytes().clone(); + let graph: Vec<_> = doc.scan_objects().collect(); + let original = doc.get(ap); + let source = render(before.clone()); + assert_eq!(pixel(&source, 30, 70), [0, 255, 0, 255], "source display"); + let output = impose(&doc, &print_settings(Content::Document)).unwrap(); + let reopened = Document::open(Arc::new(output.clone())).unwrap(); + assert_eq!(pdfcraft_model::pages(&reopened).len(), 1); + let printed = render(output); + assert_eq!(pixel(&printed, 5, 5), [255, 255, 255, 255], "outside the appearance"); + assert_eq!(pixel(&printed, 30, 70), [0, 255, 0, 255], "imposed missing-Subtype appearance"); + assert_eq!(printed.rgba, control.rgba); + assert!(state.matches(&doc)); + assert_eq!(doc.bytes(), &source_bytes); + assert_eq!(doc.scan_objects().collect::>(), graph); + assert_eq!(doc.get(ap), original); + assert_eq!(saved(&doc), before); + let source = Document::open(Arc::new(before)).unwrap(); + let page = &pdfcraft_model::pages(&source)[0]; + let annots = source.resolve(page.dict.get(b"Annots").unwrap()); + let widget = source.resolve(&annots.as_array().unwrap()[0]); + let selected = appearance(&source, widget.as_dict().unwrap()).unwrap(); + let Object::Stream(stream) = &*source.get(selected) else { panic!("source AP stream") }; + assert!(!stream.dict.contains(b"Subtype") && !stream.dict.contains(b"Type")); + assert_eq!(stream.decoded().unwrap(), b"/Ink cs 0 1 0 scn 0 0 20 10 re f"); + assert_eq!(source.resolve(stream.dict.get(b"Resources").unwrap()), doc.resolve(original.as_dict().unwrap().get(b"Resources").unwrap())); + let sheet = &pdfcraft_model::pages(&reopened)[0]; + let form = reopened.get(xobject(&reopened, &sheet.dict, b"P0")); + let promoted = reopened.get(xobject(&reopened, form.as_dict().unwrap(), b"PCPrA0")); + let Object::Stream(promoted) = &*promoted else { panic!("promoted AP stream") }; + assert_eq!(promoted.dict.name(b"Subtype"), Some(b"Form".as_slice())); + let Object::Stream(original) = &*original else { panic!("original AP stream") }; + assert_eq!(promoted.raw, original.raw); + let mut expected = original.dict.clone(); + let mut actual = promoted.dict.clone(); + actual.remove(b"Subtype"); + actual.remove(b"Length"); + expected.remove(b"Length"); + let expected_resources = doc.resolve(expected.remove(b"Resources").as_ref().unwrap()); + let actual_resources = reopened.resolve(actual.remove(b"Resources").as_ref().unwrap()); + assert_eq!(actual_resources, expected_resources); + // The writer can spell integral Real values as integers. Compare their PDF values. + let mut actual_bytes = Vec::new(); + let mut expected_bytes = Vec::new(); + pdfcraft_cos::serialize(&Object::Dict(actual), &mut actual_bytes); + pdfcraft_cos::serialize(&Object::Dict(expected), &mut expected_bytes); + assert_eq!(actual_bytes, expected_bytes); +} + +#[test] +fn promotion_preserves_streams_and_explicit_subtypes() { + for subtype in [None, Some(Object::name("Form")), Some(Object::name("Image")), Some(Object::Null), Some(Object::Int(7))] { + let (mut doc, ap, _) = widget_fixture(subtype.clone(), Some([2.0, 0.0, 0.0, 3.0, 5.0, 7.0])); + let original = doc.get(ap); + let form = page_form(&mut doc, 0, Content::Document, &mut std::collections::HashMap::new()).unwrap(); + assert_eq!(doc.get(ap), original, "original in the print clone"); + let promoted = xobject(&doc, doc.get(form).as_dict().unwrap(), b"PCPrA0"); + if subtype.is_some() { + assert_eq!(promoted, ap, "an explicit subtype keeps its object"); + let printed = Document::open(Arc::new(impose(&doc, &print_settings(Content::Document)).unwrap())).unwrap(); + let sheet = &pdfcraft_model::pages(&printed)[0]; + let page_form = printed.get(xobject(&printed, &sheet.dict, b"P0")); + let printed_ap = printed.get(xobject(&printed, page_form.as_dict().unwrap(), b"PCPrA0")); + assert_eq!(printed_ap.as_dict().unwrap().get(b"Subtype"), subtype.as_ref(), "explicit subtype survives serialization"); + } else { + assert_ne!(promoted, ap, "repair needs a separate object"); + let Object::Stream(mut expected) = (*original).clone() else { panic!("AP stream") }; + expected.dict.set(b"Subtype".to_vec(), Object::name("Form")); + assert_eq!(*doc.get(promoted), Object::Stream(expected), "only Subtype changes, including encoded data and resource refs"); + } + } +} + +#[test] +fn selected_shared_appearances_keep_placement_flags_and_content_modes() { + let mut default_images = Vec::new(); + for matrix in [None, Some([2.0, 0.0, 0.0, 3.0, 5.0, 7.0])] { + let (mut doc, ap, widget) = widget_fixture(None, matrix); + // Optional Type does not replace the required Subtype. + doc.update_dict(ap, |d| d.set(b"Type".to_vec(), Object::name("XObject"))).unwrap(); + let Object::Stream(mut off) = (*doc.get(ap)).clone() else { panic!("AP stream") }; + off.raw = Stream::flate(off.dict.clone(), b"/Ink cs 1 0 0 scn 0 0 20 10 re f").raw; + let off = doc.add(Object::Stream(off)); + let mut states = Dict::new(); + states.set(b"On".to_vec(), Object::Ref(ap)); + states.set(b"Off".to_vec(), Object::Ref(off)); + let mut appearances = Dict::new(); + appearances.set(b"N".to_vec(), Object::Dict(states)); + doc.update_dict(widget, |d| { + d.set(b"AP".to_vec(), Object::Dict(appearances)); + d.set(b"AS".to_vec(), Object::name("On")); + }) + .unwrap(); + let mut widgets = vec![Object::Ref(widget)]; + for (rect, flags) in [([60.0, 20.0, 90.0, 40.0], 4), ([10.0, 60.0, 50.0, 80.0], 0), ([60.0, 60.0, 90.0, 80.0], 6)] { + let mut copy = doc.get(widget).as_dict().unwrap().clone(); + copy.set(b"Rect".to_vec(), numbers(&rect)); + copy.set(b"F".to_vec(), Object::Int(flags)); + widgets.push(Object::Ref(doc.add(Object::Dict(copy)))); + } + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| d.set(b"Annots".to_vec(), Object::Array(widgets))).unwrap(); + let before = saved(&doc); + for (mode, content) in + [Content::Document, Content::DocumentAndMarkups, Content::DocumentAndStamps, Content::FormFieldsOnly].into_iter().enumerate() + { + let output = impose(&doc, &print_settings(content)).unwrap(); + let reopened = Document::open(Arc::new(output.clone())).unwrap(); + assert_eq!(pdfcraft_model::pages(&reopened).len(), 1); + let sheet = &pdfcraft_model::pages(&reopened)[0]; + let form = reopened.get(xobject(&reopened, &sheet.dict, b"P0")); + assert_eq!( + xobject(&reopened, form.as_dict().unwrap(), b"PCPrA0"), + xobject(&reopened, form.as_dict().unwrap(), b"PCPrA1"), + "selected shared appearance keeps one promotion" + ); + let page = render(output); + for (x, y, expected) in + [(30, 70, [0, 255, 0, 255]), (75, 70, [0, 255, 0, 255]), (30, 30, [255, 255, 255, 255]), (75, 30, [255, 255, 255, 255])] + { + assert_eq!(pixel(&page, x, y), expected, "{matrix:?}, {content:?}, ({x},{y})"); + } + let expected = if content == Content::FormFieldsOnly { [255, 255, 255, 255] } else { [0, 0, 255, 255] }; + assert_eq!(pixel(&page, 4, 96), expected, "document artwork under {content:?}"); + if matrix.is_none() { + default_images.push(page.rgba); + } else { + assert_eq!(page.rgba, default_images[mode], "Matrix must preserve the complete appearance extent under {content:?}"); + } + } + assert_eq!(saved(&doc), before, "shared source graph stays unchanged"); + } +} + +#[test] +fn shared_promotions_span_pages_without_merging_distinct_sources() { + let mut doc = fixture(2); + // Contributor-original drawing and a bounded PDF comment expose repeated encoded payloads. + let mut payload = b"/Ink cs 0 1 0 scn 0 0 20 10 re f\n%".to_vec(); + payload.extend(std::iter::repeat_n(b'x', 32 * 1024)); + payload.push(b'\n'); + let mut appearances = Vec::new(); + for marker in ["Shared", "Distinct"] { + let mut colors = Dict::new(); + colors.set(b"Ink".to_vec(), Object::name("DeviceRGB")); + let mut resources = Dict::new(); + resources.set(b"ColorSpace".to_vec(), Object::Dict(colors)); + resources.set(b"PrivateMarker".to_vec(), Object::name(marker)); + let resources = doc.add(Object::Dict(resources)); + let mut dict = Dict::new(); + dict.set(b"BBox".to_vec(), numbers(&[0.0, 0.0, 20.0, 10.0])); + dict.set(b"Resources".to_vec(), Object::Ref(resources)); + appearances.push(doc.add(Object::Stream(Stream::from_raw(dict, payload.clone())))); + } + let pages = pdfcraft_model::pages(&doc); + for (index, page) in pages.iter().enumerate() { + let mut widgets = Vec::new(); + for (ap, rect) in [(appearances[0], [10.0, 20.0, 50.0, 40.0]), (appearances[0], [60.0, 20.0, 90.0, 40.0])] + .into_iter() + .chain((index == 1).then_some((appearances[1], [10.0, 60.0, 50.0, 80.0]))) + { + let mut widget = Dict::new(); + widget.set(b"Subtype".to_vec(), Object::name("Widget")); + widget.set(b"FT".to_vec(), Object::name("Tx")); + widget.set(b"F".to_vec(), Object::Int(4)); + widget.set(b"Rect".to_vec(), numbers(&rect)); + let mut appearance = Dict::new(); + appearance.set(b"N".to_vec(), Object::Ref(ap)); + widget.set(b"AP".to_vec(), Object::Dict(appearance)); + widgets.push(Object::Ref(doc.add(Object::Dict(widget)))); + } + doc.update_dict(page.obj, |dict| { + dict.set(b"MediaBox".to_vec(), numbers(&[0.0, 0.0, 100.0, 100.0])); + dict.set(b"Annots".to_vec(), Object::Array(widgets)); + dict.remove(b"Contents"); + dict.set(b"Resources".to_vec(), Object::Dict(Dict::new())); + }) + .unwrap(); + } + let before = saved(&doc); + let state = doc.read_state(); + let source_bytes = doc.bytes().clone(); + let graph: Vec<_> = doc.scan_objects().collect(); + let originals: Vec<_> = appearances.iter().map(|ap| doc.get(*ap)).collect(); + let settings = Settings { pages: vec![0, 1], ..print_settings(Content::Document) }; + for _ in 0..2 { + let output = impose(&doc, &settings).unwrap(); + let reopened = Document::open(Arc::new(output.clone())).unwrap(); + let sheets = pdfcraft_model::pages(&reopened); + assert_eq!(sheets.len(), 2); + let mut shared = Vec::new(); + let mut distinct = None; + for (index, sheet) in sheets.iter().enumerate() { + let form = reopened.get(xobject(&reopened, &sheet.dict, b"P0")); + let form = form.as_dict().unwrap(); + shared.push(xobject(&reopened, form, b"PCPrA0")); + shared.push(xobject(&reopened, form, b"PCPrA1")); + if index == 1 { + distinct = Some(xobject(&reopened, form, b"PCPrA2")); + } + let rendered = render_page(output.clone(), index); + assert_eq!(pixel(&rendered, 30, 70), [0, 255, 0, 255]); + assert_eq!(pixel(&rendered, 75, 70), [0, 255, 0, 255]); + assert_eq!(pixel(&rendered, 30, 30), if index == 1 { [0, 255, 0, 255] } else { [255, 255, 255, 255] }); + assert_eq!(pixel(&rendered, 75, 30), [255, 255, 255, 255]); + assert_eq!(pixel(&rendered, 5, 5), [255, 255, 255, 255]); + } + let copies = reopened + .scan_objects() + .filter(|(_, object)| matches!(&**object, Object::Stream(stream) if stream.raw.as_ref() == payload.as_slice())) + .count(); + eprintln!( + "shared promotion output: bytes={}, payload_bytes={}, copies={copies}, shared={shared:?}, distinct={distinct:?}", + output.len(), + payload.len() + ); + assert!(state.matches(&doc)); + assert_eq!(doc.bytes(), &source_bytes); + assert_eq!(doc.scan_objects().collect::>(), graph); + assert_eq!(saved(&doc), before); + for (ap, original) in appearances.iter().zip(&originals) { + assert_eq!(doc.get(*ap), *original); + } + assert_eq!(copies, 2, "one encoded payload per distinct source appearance"); + assert!(shared.iter().all(|ap| *ap == shared[0]), "shared uses span both page forms"); + let distinct = distinct.unwrap(); + assert_ne!(shared[0], distinct, "identical bytes do not merge source references"); + for (ap, marker) in [(shared[0], b"Shared".as_slice()), (distinct, b"Distinct".as_slice())] { + let object = reopened.get(ap); + let Object::Stream(stream) = &*object else { panic!("promoted appearance stream") }; + assert_eq!(stream.raw.as_ref(), payload.as_slice()); + assert_eq!(stream.dict.name(b"Subtype"), Some(b"Form".as_slice())); + let resources = reopened.resolve(stream.dict.get(b"Resources").unwrap()); + assert_eq!(resources.as_dict().unwrap().name(b"PrivateMarker"), Some(marker)); + } + } +} diff --git a/crates/redact/README.md b/crates/redact/README.md index 7cd5ded1e..9c2793e24 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -32,7 +32,9 @@ clear_marks(&mut doc, None)?; // remove marks without app A verification pass re-reads each redacted page and fails the operation if any glyph or inline image is still under an area. Content that can't be decoded makes the operation fail rather than -leave it unredacted. +leave it unredacted. A Redact annotation with neither a usable `/QuadPoints` array nor a usable +`/Rect` makes it fail with `RedactError::UnreadableMark` before anything changes, instead of +skipping that mark. The count covers the marks on the pages being applied. Before changing any page, `apply` prepares the redaction appearances with a shared 8 MiB budget for their uncompressed PDF content across the selected pages. It bounds repeated-text @@ -42,7 +44,11 @@ the document. Reduce the marked area, disable repeated text, or apply fewer page This limits appearance generation; it is not a limit on the input PDF or the whole process's memory. Structure cleanup resolves named MCIDs and indirect content references. Form content -references are updated to the cleaned copy; unchanged sibling descriptions remain. +references are updated to the cleaned copy; unchanged sibling descriptions remain. The structure +tree's object references (`/OBJR`) to the annotations and form fields `apply` removes are dropped, +as Sanitize drops them, so a full rewrite of a tagged PDF can't keep a removed comment or field value. +A tree where that can't be done (an element written in place, or one too large to walk) makes +`apply` fail with `RedactError::Tags` rather than leave the annotation reachable. Replacement descriptions are removed only from marked-content spans changed by cleanup. Unchanged spans retain their descriptions; unused property entries are retired during redaction diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index 415382b23..c9a404b93 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -900,7 +900,10 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec< tm = tlm; } let items: Vec = match o { - b"TJ" => op.operands.first().and_then(Object::as_array).cloned().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), so a redaction search matches what is drawn. + b"TJ" => op.operands.last().and_then(Object::as_array).cloned().unwrap_or_default(), _ => op.operands.last().cloned().into_iter().collect(), }; // Vertical-writing text is laid out horizontally by `show`, so its glyphs are diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index fdac37dbe..6df5102af 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -38,6 +38,12 @@ pub enum RedactError { NothingToApply, #[error("page {0} has content that can't be read, so it can't be redacted safely")] Unreadable(usize), + /// Redact annotations with no readable area (neither usable `/QuadPoints` nor a usable `/Rect`). + /// Skipping them would leave the text under them in the file while the others report success. + #[error( + "{0} redaction mark(s) have no readable area (neither usable QuadPoints nor a usable Rect), so the text under them can't be known; nothing was redacted. Remove those marks or mark the text again, then apply again" + )] + UnreadableMark(usize), #[error("redaction could not be verified: {0} item(s) were still found under the marks, so nothing was changed")] Residue(usize), /// A mark's overlay text has a character Helvetica can't draw; nothing was redacted. @@ -127,7 +133,8 @@ pub struct Report { pub annotations: usize, pub fields: usize, pub replacement_text: usize, - /// Structure elements that lost alternate/actual text or emptied marked content. + /// Structure elements that lost alternate/actual text or emptied marked content, and + /// structure references to removed annotations. pub tags: usize, /// The form was an AcroForm+XFA form and its `/XFA` packets (which still held redacted field /// values, and from which a relayout could rebuild them) were removed. @@ -153,9 +160,21 @@ pub(crate) fn annots_of(doc: &Document, page: &Dict) -> Vec { page.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() } +/// Every annotation the pages list. Compared before and after a removal, what is no longer listed +/// leaves the structure tree too ([`tags::drop_object_refs`]). +pub(crate) fn listed_annotations(doc: &Document) -> HashSet { + pdfcraft_model::pages(doc).iter().flat_map(|p| annots_of(doc, &p.dict)).filter_map(|a| a.as_ref()).collect() +} + /// Every redaction mark in the document, page by page. pub fn marks(doc: &Document) -> Vec { - let mut out = Vec::new(); + marks_checked(doc).0 +} + +/// [`marks`], and the 0-based page of each Redact annotation that has no readable area, which +/// [`apply`] refuses rather than skipping. +pub(crate) fn marks_checked(doc: &Document) -> (Vec, Vec) { + let (mut out, mut unreadable) = (Vec::new(), Vec::new()); for (pi, p) in pdfcraft_model::pages(doc).iter().enumerate() { for a in annots_of(doc, &p.dict) { let Some(r) = a.as_ref() else { continue }; @@ -190,13 +209,14 @@ pub fn marks(doc: &Document) -> Vec { rects.push(r); } if rects.is_empty() { + unreadable.push(pi); continue; } let overlay = d.get(b"OverlayText").and_then(|t| doc.resolve(t).as_string().map(|s| s.to_text())).unwrap_or_default(); out.push(Mark { page: pi, obj: r, rects, fill: color(doc, d, b"IC"), overlay, look: overlay_look(doc, d) }); } } - out + (out, unreadable) } /// A page's content streams: (the references or inline objects as listed, their decoded data). @@ -319,7 +339,13 @@ fn localize_resources(doc: &mut Document, selected: &[usize]) -> Result<(), pdfc /// sensitive content still present. Call this on a working clone and replace the original only /// on success. On any error, discard the working clone; do not save or reuse it. pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result { - let all_marks = marks(doc); + let (all_marks, unreadable) = marks_checked(doc); + // A mark with no readable area is not applied, and applying the others would look complete: + // refuse before anything changes. Only the pages being applied count. + let unreadable = unreadable.iter().filter(|page| pages.is_none_or(|p| p.contains(page))).count(); + if unreadable > 0 { + return Err(RedactError::UnreadableMark(unreadable)); + } let chosen: Vec<&Mark> = all_marks.iter().filter(|m| pages.is_none_or(|p| p.contains(&m.page))).collect(); if chosen.is_empty() { return Err(RedactError::NothingToApply); @@ -351,6 +377,7 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result = pdfcraft_forms::fields(doc).into_iter().flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.name.clone())).collect::>()).collect(); + let listed_before = listed_annotations(doc); for &(pi, page_marks) in &groups { let page = pdfcraft_model::pages(doc).swap_remove(pi); @@ -523,6 +550,12 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result = listed_before.difference(&listed_annotations(doc)).copied().collect(); + report.tags += tags::drop_object_refs(doc, &gone)?; + // 3b. An XFA form mirrors its field values in the /XFA datasets packet, and a relayout // rebuilds fields and pages from that packet, so deleting the AcroForm field alone leaves the // redacted value in the file and lets a later relayout put it back. Once redactions are diff --git a/crates/redact/src/patterns.rs b/crates/redact/src/patterns.rs index 4da8236c2..f707f179a 100644 --- a/crates/redact/src/patterns.rs +++ b/crates/redact/src/patterns.rs @@ -56,7 +56,12 @@ fn digit_run(s: &[char], start: usize, seps: &[char], max_len: usize) -> (usize, let c = s[i]; if c.is_ascii_digit() { n += 1; - } else if !(seps.contains(&c) && (digit(s.get(i + 1)) || s.get(i + 1) == Some(&'(')) || (c == '(' || c == ')') && seps.contains(&c)) { + } else if !(seps.contains(&c) + && (digit(s.get(i + 1)) || s.get(i + 1) == Some(&'(')) + // A slash only ever joins two digits ("0426/123456"). + && (c != '/' || digit(s.get(i + 1)) && digit(i.checked_sub(1).and_then(|p| s.get(p)))) + || (c == '(' || c == ')') && seps.contains(&c)) + { break; } i += 1; @@ -84,8 +89,56 @@ fn run_continues(s: &[char], k: usize, step: isize, seps: &[char]) -> bool { } } +/// Words that label the number after them as a phone number, lowercased ("Tel.", "cell.", "Fax:"). +const PHONE_LABELS: [&str; 24] = [ + "tel", + "tél", + "tele", + "telefon", + "telefono", + "teléfono", + "telephone", + "téléphone", + "phone", + "ph", + "cell", + "cel", + "cellulare", + "cellphone", + "mobile", + "mob", + "mobil", + "móvil", + "movil", + "handy", + "fax", + "tlf", + "telf", + "gsm", +]; + +/// Whether the text before `i` is a phone label, with one to four spaces, colons or dots between. +fn labelled(s: &[char], i: usize) -> bool { + let before = |k: usize| k.checked_sub(1).and_then(|p| s.get(p)).copied(); + let mut j = i; + while i - j < 4 && before(j).is_some_and(|c| matches!(c, ' ' | ':' | '.' | '\u{a0}')) { + j -= 1; + } + let end = j; + while end - j < 12 && before(j).is_some_and(char::is_alphabetic) { + j -= 1; + } + if end == i || before(j).is_some_and(char::is_alphanumeric) { + return false; + } + let label: String = s.get(j..end).unwrap_or_default().iter().flat_map(|c| c.to_lowercase()).collect(); + PHONE_LABELS.contains(&label.as_str()) +} + fn phone(s: &[char], i: usize) -> Option { const SEPS: [char; 5] = [' ', '-', '.', '(', ')']; + // A slash joins the area code to the number in Italian and German landlines ("0426/123456"). + const RUN_SEPS: [char; 6] = [' ', '-', '.', '(', ')', '/']; let start_ok = s[i] == '+' && digit(s.get(i + 1)) || s[i] == '(' && digit(s.get(i + 1)) || s[i].is_ascii_digit(); if !start_ok || word(i.checked_sub(1).and_then(|p| s.get(p))) || s.get(i.wrapping_sub(1)) == Some(&'+') { return None; @@ -97,20 +150,26 @@ fn phone(s: &[char], i: usize) -> Option { } let plus = s[i] == '+'; let first = if plus { i + 1 } else { i }; - let (end, n) = digit_run(s, first, &SEPS, 28); + let (end, n) = digit_run(s, first, &RUN_SEPS, 28); let run = s.get(first..end)?; let seps = run.iter().filter(|c| !c.is_ascii_digit()).count(); - // Always grouped by at least one separator, so plain numbers never match; nor does the - // head of a run too long to be a phone number. - if seps == 0 || digit(s.get(end)) || run_continues(s, end - 1, 1, &SEPS) { + // Grouped by at least one separator, or labelled ("cell. 3451234567", #3205), so plain + // numbers never match; nor does the head of a run too long to be a phone number. + if seps == 0 && !labelled(s, i) || digit(s.get(end)) || run_continues(s, end.checked_sub(1)?, 1, &SEPS) { + return None; + } + // Only an area code with a trunk 0 takes a slash, so "2024/123456" is no phone number; and + // only one, right after it ("0426/123456", not "0426/12/3456" or "0426 12/3456"). + let slash = run.contains(&'/'); + if run.iter().filter(|c| **c == '/').count() > 1 || slash && !run.iter().take_while(|c| **c != '/').all(char::is_ascii_digit) { return None; } let ok = if plus { // E.164: a `+` country code and up to 15 digits in all ("+49 2151 123456"). - (8..=15).contains(&n) + !slash && (8..=15).contains(&n) } else { // North American: 10 digits, 11 with a country code ("(555) 123-4567"). - let nanp = (10..=11).contains(&n); + let nanp = (10..=11).contains(&n) && !slash; // National with a trunk 0 and 6–14 more digits ("0211 123456", "01 23 45 67 89"). // The first group needs two digits so decimals ("0.1234567") don't match, and // day-first dates ("01.10.2026") are left to the date pattern. @@ -404,6 +463,48 @@ mod tests { ); } + /// #3205: Italian numbers written with a slash, and, after a phone label, without separators. + #[test] + fn slashed_and_labelled_unseparated_phone_numbers() { + let lines = [ + "A tel. 0426 123456", + "B tel. +39 0426 123456", + "C cell. 345 1234567", + "D cell. 3451234567", + "E tel. 0426/123456", + "F cell. 345.123.4567", + "G tel. 0426.123456", + "H cell. +393451234567", + "Fax: 0211123456", + "Telefono 0426/123456", + ]; + assert_eq!( + found(Pattern::Phone, &lines.join("\n")), + [ + "0426 123456", + "+39 0426 123456", + "345 1234567", + "3451234567", + "0426/123456", + "345.123.4567", + "0426.123456", + "+393451234567", + "0211123456", + "0426/123456" + ] + ); + // Without a label, or after a word that only ends like one, unseparated numbers stay + // unmarked; a slash needs a trunk 0 and digits on both sides, and dates stay dates. + assert_eq!( + found( + Pattern::Phone, + "id 0211123456, +4921511234567, hotel 3451234567, prot. 2024/123456, on 01/10/2026, \ + tel. 12345, tel 0426/ 123456, +39 0426/123456, tel 0426/12/3456, tel 0426 12/3456" + ), + Vec::::new() + ); + } + #[test] fn the_tail_of_a_longer_number_is_not_a_phone_number() { // #1884: the last groups of a French social security number, an IBAN and a SIRET used diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index bafa769bd..83db13d72 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -8,7 +8,7 @@ use pdfcraft_content::Matrix; use pdfcraft_cos::{Dict, Document, ObjRef, Object}; use crate::interp::{Mode, Scope, process}; -use crate::{RedactError, Report, annots_of, page_streams}; +use crate::{RedactError, Report, annots_of, listed_annotations, page_streams}; /// The categories of Acrobat's Remove Hidden Information panel that PdfCraft handles. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -97,17 +97,45 @@ fn name_tree_len(doc: &Document, node: &Dict, depth: usize) -> usize { leaves + kids.iter().filter_map(|k| doc.resolve(k).as_dict().cloned()).map(|k| name_tree_len(doc, &k, depth + 1)).sum::() } +/// The most distinct outline items one count reads, over all levels together. +const OUTLINE_ITEM_BUDGET: usize = 100_000; + fn outline_len(doc: &Document, first: Option<&Object>, depth: usize) -> usize { + let (mut seen, mut counted) = (std::collections::HashSet::new(), 0); + outline_len_seen(doc, first, depth, &mut seen, &mut counted) +} + +/// Counts an outline level and everything below it, each item once. `seen` holds the indirect items +/// already counted, and `counted` every item counted so far, inline ones included, which the total +/// budget applies to. An item that two others name (as `/First` or `/Next`) is counted where the walk +/// first reaches it, and a level stops at an item already seen, because the walk that reached that +/// item counts the rest of its level. A hostile outline therefore costs one step per item, not 2^n +/// steps for n levels of shared children. The depth limit of 64 only matters for outlines nested that +/// deep: an item first reached through a deeper path is not visited again from a shallower one, so +/// items nested beyond the limit may stay uncounted. +fn outline_len_seen( + doc: &Document, + first: Option<&Object>, + depth: usize, + seen: &mut std::collections::HashSet, + counted: &mut usize, +) -> usize { let mut n = 0; let mut cur = first.cloned(); let mut guard = 0; while let Some(o) = cur { guard += 1; - if guard > 100_000 || depth > 64 { + if guard > 100_000 || depth > 64 || *counted >= OUTLINE_ITEM_BUDGET { + break; + } + if let Object::Ref(r) = &o + && !seen.insert(*r) + { break; } let Some(d) = doc.resolve(&o).as_dict().cloned() else { break }; - n += 1 + outline_len(doc, d.get(b"First"), depth + 1); + *counted += 1; + n += 1 + outline_len_seen(doc, d.get(b"First"), depth + 1, seen, counted); cur = d.get(b"Next").cloned(); } n @@ -287,10 +315,7 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result std::collections::HashSet { - pdfcraft_model::pages(doc).iter().flat_map(|p| annots_of(doc, &p.dict)).filter_map(|a| a.as_ref()).collect() - }; - let listed_before = listed(doc); + let listed_before = listed_annotations(doc); if which.contains(&Hidden::FormFields) && count(Hidden::FormFields) > 0 { // Fields without appearances get one first, so their values stay visible. for f in pdfcraft_forms::fields(doc) { @@ -363,7 +388,7 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result = listed_before.difference(&still).copied().collect(); crate::tags::drop_object_refs(doc, &gone)?; let off = hidden_layers(doc); diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 8bc5ec141..286667937 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -1057,6 +1057,75 @@ fn unreadable_content_fails_closed() { assert_eq!(apply(&mut doc, None), Err(RedactError::Unreadable(1))); } +/// Leaves a mark with nothing readable: /QuadPoints becomes a name and /Rect is removed. +fn break_area(doc: &mut Document, obj: ObjRef) { + doc.update_dict(obj, |d| { + d.set(b"QuadPoints".to_vec(), Object::name("oops")); + d.remove(b"Rect"); + }) + .unwrap(); +} + +/// A Redact annotation with no readable area (neither usable QuadPoints nor a usable Rect) used to +/// vanish: `marks` skipped it, so applying the readable mark next to it reported success and left +/// the text under the broken mark in place. Now the whole operation is refused before anything +/// changes, and the count is of the unreadable marks. +#[test] +fn a_mark_with_no_readable_area_is_refused_instead_of_vanishing() { + let mut doc = one_page(b"BT /F1 10 Tf 10 100 Td (AB1234CD) Tj ET", "", vec![]); + mark(&mut doc, 0, &[[40.0, 95.0, 50.0, 110.0]], ""); + mark(&mut doc, 0, &[[0.0, 95.0, 10.0, 110.0]], ""); + for m in marks(&doc) { + break_area(&mut doc, m.obj); + } + mark(&mut doc, 0, &[[20.0, 95.0, 40.0, 110.0]], ""); + assert_eq!(marks(&doc).len(), 1, "only the readable mark is listed"); + assert_eq!(apply(&mut doc, None), Err(RedactError::UnreadableMark(2))); + // Nothing changed: the readable mark is still pending and its text is still drawn. + assert_eq!(marks(&doc).len(), 1); + assert!(content(&doc, 0).contains("(AB1234CD) Tj"), "{}", content(&doc, 0)); +} + +/// A mark whose QuadPoints can't be used still redacts through its Rect, as it always did. +#[test] +fn a_mark_with_only_a_usable_rect_still_redacts() { + let mut doc = one_page(b"BT /F1 10 Tf 10 100 Td (AB1234CD) Tj ET", "", vec![]); + mark(&mut doc, 0, &[[20.0, 95.0, 40.0, 110.0]], ""); + let m = marks(&doc).swap_remove(0); + doc.update_dict(m.obj, |d| { + d.set(b"QuadPoints".to_vec(), Object::name("oops")); + d.set(b"Rect".to_vec(), Object::Array(vec![Object::Int(20), Object::Int(95), Object::Int(40), Object::Int(110)])); + }) + .unwrap(); + assert_eq!(marks(&doc).len(), 1, "the Rect keeps the mark readable"); + let r = apply(&mut doc, None).unwrap(); + assert_eq!((r.marks, r.glyphs), (1, 4)); + assert!(!content(&doc, 0).contains("1234")); +} + +/// Only the pages being applied decide the refusal: an unreadable mark on another page does not +/// stop the marks of the selected page from being applied. +#[test] +fn an_unreadable_mark_on_another_page_does_not_stop_the_selected_page() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R 7 0 R] /Count 2 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), + stream("", b"BT /F1 10 Tf 10 100 Td (AB1234CD) Tj ET"), + FONT.replace("95 0 R", "6 0 R").into_bytes(), + widths(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 8 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), + stream("", b"BT /F1 10 Tf 10 100 Td (XY) Tj ET"), + ]); + mark(&mut doc, 1, &[[10.0, 95.0, 30.0, 110.0]], ""); + let broken = marks(&doc).swap_remove(0); + break_area(&mut doc, broken.obj); + mark(&mut doc, 0, &[[20.0, 95.0, 40.0, 110.0]], ""); + assert_eq!(apply(&mut doc, None), Err(RedactError::UnreadableMark(1))); + assert_eq!(apply(&mut doc, Some(&[0])).map(|r| r.glyphs), Ok(4)); + assert!(!content(&doc, 0).contains("1234")); +} + /// A document with something in every hidden-information category. fn hidden_fixture() -> Document { let content = b"BT /F1 10 Tf 10 100 Td (Visible) Tj 3 Tr (Hidden) Tj 0 Tr ET BT /F1 10 Tf 500 500 Td (Offpage) Tj ET /OC /L1 BDC BT /F1 10 Tf 10 50 Td (Layer) Tj ET EMC"; @@ -1141,6 +1210,130 @@ fn hidden_information_is_counted_and_removed() { assert!(shown, "the field's value stays visible as page content"); } +/// A one-page document whose catalog names an outline root (object 5). `items` are the outline +/// items, objects 6 onward. +fn outlined_document(items: Vec>) -> Document { + let mut objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /Outlines 5 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R >>".to_vec(), + stream("", b"BT ET"), + b"<< /Type /Outlines /First 6 0 R >>".to_vec(), + ]; + objs.extend(items); + pdf(objs) +} + +/// An outline of `levels` levels with two items each. Both items of a level name the first item +/// of the next level as their /First, so each of those items has two parents, and a walk that +/// follows every path takes 2^levels steps. Items a and b of level k are objects 6 + 2k and 7 + 2k. +fn hostile_outline(levels: usize) -> Document { + let mut items = Vec::new(); + for k in 0..levels { + let (a, b) = (6 + 2 * k, 7 + 2 * k); + let child = if k + 1 < levels { format!(" /First {} 0 R", a + 2) } else { String::new() }; + items.push(format!("<< /Title (a) /Next {b} 0 R{child} >>").into_bytes()); + items.push(format!("<< /Title (b){child} /Parent {a} 0 R >>").into_bytes()); + } + outlined_document(items) +} + +/// Runs `work` on another thread and returns its result. A walk that spins fails the test after +/// `bound` instead of hanging the whole run; the thread stays behind and ends with the process. +fn finishes_within(bound: std::time::Duration, work: impl FnOnce() -> T + Send + 'static) -> T { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(work()); + }); + match rx.recv_timeout(bound) { + Ok(out) => out, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + panic!("the walk did not finish within {bound:?}: it is spinning on shared outline items") + } + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => panic!("the walk panicked"), + } +} + +/// Generous for a slow machine: the bounded walk takes a few milliseconds. +const WALK_BOUND: std::time::Duration = std::time::Duration::from_secs(10); + +/// An outline whose items name shared children is counted item by item, each item once, and the +/// walk ends. Counting along every path instead takes 2^60 steps. +#[test] +fn an_outline_whose_items_share_children_is_counted_without_spinning() { + use crate::sanitize::{Hidden, scan}; + let doc = hostile_outline(60); + let counts = finishes_within(WALK_BOUND, move || scan(&doc)); + assert_eq!(counts.iter().find(|c| c.0 == Hidden::Bookmarks), Some(&(Hidden::Bookmarks, 120)), "60 levels of two distinct items"); +} + +/// Removing the bookmarks counts them through the same walk first, so it must finish too, and +/// it must take the outline out of the catalog. +#[test] +fn removing_bookmarks_from_an_outline_whose_items_share_children_finishes() { + use crate::sanitize::{Hidden, remove_hidden}; + let mut doc = hostile_outline(60); + let (done, outlines_left) = finishes_within(WALK_BOUND, move || { + let done = remove_hidden(&mut doc, &[Hidden::Bookmarks]); + let root = doc.root().unwrap(); + let left = doc.get(root).as_dict().is_some_and(|c| c.contains(b"Outlines")); + (done, left) + }); + assert_eq!(done.unwrap(), [(Hidden::Bookmarks, 120)]); + assert!(!outlines_left, "the outline is removed from the catalog"); +} + +/// Nesting and siblings: every item of an ordinary outline is counted once, as before. +#[test] +fn a_nested_outline_is_counted_item_by_item() { + use crate::sanitize::{Hidden, scan}; + let doc = outlined_document(vec![ + b"<< /Title (Chapter 1) /Parent 5 0 R /First 7 0 R /Last 8 0 R /Next 9 0 R >>".to_vec(), + b"<< /Title (1.1) /Parent 6 0 R /Next 8 0 R >>".to_vec(), + b"<< /Title (1.2) /Parent 6 0 R /Prev 7 0 R >>".to_vec(), + b"<< /Title (Chapter 2) /Parent 5 0 R /Prev 6 0 R /First 10 0 R >>".to_vec(), + b"<< /Title (2.1) /Parent 9 0 R >>".to_vec(), + ]); + assert_eq!(scan(&doc).iter().find(|c| c.0 == Hidden::Bookmarks), Some(&(Hidden::Bookmarks, 5))); +} + +/// Two levels of 60,000 distinct items: each level is under the per-level cap, so only the total +/// cap stops the count at 100,000 (without it the count would be 120,000). +#[test] +fn an_outline_counts_no_more_items_than_its_total_budget() { + use crate::sanitize::{Hidden, scan}; + let per_level = 60_000; + let mut items = Vec::with_capacity(2 * per_level); + // Item j is object 6 + j. Level one is items 0..per_level; its first item leads to level two. + for i in 0..per_level { + let next = if i + 1 < per_level { format!(" /Next {} 0 R", 7 + i) } else { String::new() }; + let first = if i == 0 { format!(" /First {} 0 R", 6 + per_level) } else { String::new() }; + items.push(format!("<< /Title (a){next}{first} >>").into_bytes()); + } + for i in 0..per_level { + let next = if i + 1 < per_level { format!(" /Next {} 0 R", 7 + per_level + i) } else { String::new() }; + items.push(format!("<< /Title (b){next} >>").into_bytes()); + } + let doc = outlined_document(items); + assert_eq!(scan(&doc).iter().find(|c| c.0 == Hidden::Bookmarks), Some(&(Hidden::Bookmarks, 100_000))); +} + +/// Items written inline (not as indirect objects) count towards the total budget too. Each of +/// 60,000 indirect siblings has one inline child, so 120,000 items are reached, and the total must +/// still stop at 100,000. +#[test] +fn inline_outline_children_count_towards_the_total_budget() { + use crate::sanitize::{Hidden, scan}; + let siblings = 60_000; + let mut items = Vec::with_capacity(siblings); + for i in 0..siblings { + let next = if i + 1 < siblings { format!(" /Next {} 0 R", 7 + i) } else { String::new() }; + items.push(format!("<< /Title (a){next} /First << /Title (c) >> >>").into_bytes()); + } + let doc = outlined_document(items); + assert_eq!(scan(&doc).iter().find(|c| c.0 == Hidden::Bookmarks), Some(&(Hidden::Bookmarks, 100_000))); +} + /// Issue #2447: in a tagged PDF the structure tree points at the link and the comment it tags /// (`/OBJR`). Removing them must drop those references too, or the full rewrite keeps the /// annotations, the URL and the comment text included, while the page no longer lists them. @@ -1242,6 +1435,89 @@ fn flattened_widgets_and_far_pop_ups_leave_the_structure_tree() { assert!(!field_left, "the flattened field didn't survive the save through the structure tree"); } +/// Issue #2874: applying redactions removes the comment and the form field under a mark, and +/// with them the structure tree's object references (`/OBJR`) to them, so a full rewrite of a +/// tagged PDF can't keep the comment's text or the field's value. The field's widget on another +/// page goes with it; a comment outside the mark keeps its reference. +#[test] +fn applied_redactions_drop_structure_tree_references_to_what_they_remove() { + let objs: Vec> = vec![ + // 1 catalog with a form and a structure tree + b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> /MarkInfo << /Marked true >> /StructTreeRoot 9 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R 16 0 R] /Count 2 >>".to_vec(), + // 3 page 1: a label, a text field's widget and a comment under the mark, a comment outside + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [7 0 R 8 0 R 15 0 R] /StructParents 0 >>".to_vec(), + stream("", b"BT /F1 10 Tf 10 230 Td (Name:) Tj ET"), + FONT.replace("95 0 R", "14 0 R").into_bytes(), + // 6 the field, 7 its widget on page 1 (17 is its widget on page 2), 8 the comment + b"<< /FT /Tx /T (name) /V (FIELDSECRETVALUE) /Kids [7 0 R 17 0 R] >>".to_vec(), + b"<< /Type /Annot /Subtype /Widget /Parent 6 0 R /P 3 0 R /Rect [50 225 200 240] /StructParent 1 /AP << /N 13 0 R >> >>".to_vec(), + b"<< /Type /Annot /Subtype /Text /P 3 0 R /Rect [200 200 220 220] /StructParent 2 /Contents (COMMENTSECRETNOTE) >>".to_vec(), + // 9 struct tree root, 10 document, 11 form element (direct OBJRs to both widgets), + // 12 annotation element (OBJR to the comment), 18 annotation element for the kept comment + b"<< /Type /StructTreeRoot /K 10 0 R /ParentTree << /Nums [1 11 0 R 2 12 0 R 3 18 0 R 4 11 0 R] >> /ParentTreeNextKey 5 >>".to_vec(), + b"<< /Type /StructElem /S /Document /P 9 0 R /K [11 0 R 12 0 R 18 0 R] >>".to_vec(), + b"<< /Type /StructElem /S /Form /P 10 0 R /Pg 3 0 R /Alt (FIELDSECRETVALUE) /K [<< /Type /OBJR /Obj 7 0 R /Pg 3 0 R >> << /Type /OBJR /Obj 17 0 R /Pg 16 0 R >>] >>".to_vec(), + b"<< /Type /StructElem /S /Annot /P 10 0 R /Pg 3 0 R /K [<< /Type /OBJR /Obj 8 0 R /Pg 3 0 R >>] >>".to_vec(), + stream("/Type /XObject /Subtype /Form /BBox [0 0 150 15] /Resources << /Font << /F1 5 0 R >> >>", b"BT /F1 10 Tf 2 3 Td (FIELDSECRETVALUE) Tj ET"), + widths(), + // 15 the comment outside the mark + b"<< /Type /Annot /Subtype /Text /P 3 0 R /Rect [250 10 270 30] /StructParent 3 /Contents (Kept note) >>".to_vec(), + // 16 page 2, with the field's second widget + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Annots [17 0 R] /StructParents 4 >>".to_vec(), + b"<< /Type /Annot /Subtype /Widget /Parent 6 0 R /P 16 0 R /Rect [50 50 200 65] /StructParent 4 /AP << /N 13 0 R >> >>".to_vec(), + b"<< /Type /StructElem /S /Annot /P 10 0 R /Pg 3 0 R /K [<< /Type /OBJR /Obj 15 0 R /Pg 3 0 R >>] >>".to_vec(), + ]; + let mut doc = pdf(objs); + mark(&mut doc, 0, &[[40.0, 190.0, 230.0, 245.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!((report.annotations, report.fields), (1, 1), "{report:?}"); + assert!(report.tags >= 3, "the three references to the comment and both widgets are counted: {report:?}"); + // Without object streams, whose compression would hide the strings from the search. + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + let has = |needle: &[u8]| bytes.windows(needle.len()).any(|w| w == needle); + assert!(has(b"(Name:)") && has(b"(Kept note)"), "the search sees the saved objects"); + assert!(!has(b"COMMENTSECRETNOTE"), "the redacted comment's text survived the save through the structure tree"); + assert!(!has(b"FIELDSECRETVALUE"), "the redacted field's value survived the save through the structure tree"); + let saved = Document::open(Arc::new(bytes)).unwrap(); + let widget_left = saved + .object_numbers() + .into_iter() + .any(|n| saved.get(ObjRef::new(n, saved.generation(n))).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget"))); + assert!(!widget_left, "neither of the field's widgets survived the save"); + // The kept comment is still tagged. + let kept = saved.object_numbers().into_iter().any(|n| { + saved.get(ObjRef::new(n, saved.generation(n))).as_dict().and_then(|d| d.get(b"K")).is_some_and(|k| match &*saved.resolve(k) { + Object::Array(a) => a.iter().any(|x| { + saved.resolve(x).as_dict().and_then(|d| d.get(b"Obj")).and_then(Object::as_ref).is_some_and(|r| { + saved.get(r).as_dict().and_then(|d| d.get(b"Contents")).and_then(Object::as_string).is_some_and(|s| s.to_text() == "Kept note") + }) + }), + _ => false, + }) + }); + assert!(kept, "the comment outside the mark keeps its structure reference"); +} + +/// #2874: a structure element written in place that tags a comment under a mark can't be edited +/// without rewriting what holds it, so applying is refused rather than leaving the comment +/// reachable (fail-closed, as in Sanitize). +#[test] +fn applying_redactions_refuses_an_in_place_element_tagging_a_removed_comment() { + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 6 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Annots [4 0 R] >>".to_vec(), + b"<< /Type /Annot /Subtype /Text /Rect [200 200 220 220] /StructParent 0 /Contents (COMMENTSECRETNOTE) >>".to_vec(), + b"<< >>".to_vec(), + // 6 the root's only kid, an annotation element written in place + b"<< /Type /StructTreeRoot /K [<< /Type /StructElem /S /Annot /K [<< /Type /OBJR /Obj 4 0 R >>] >>] >>".to_vec(), + ]; + let mut doc = pdf(objs); + mark(&mut doc, 0, &[[190.0, 190.0, 230.0, 230.0]], ""); + assert!(matches!(apply(&mut doc, None), Err(RedactError::Tags))); +} + /// #2532 review: a structure tree larger than the walk's limit can't be searched in full, so a /// reference the walk never reached could keep a removed annotation. Then it is an error, and /// nothing is changed, rather than success with references left behind (removing only the tree @@ -2186,3 +2462,13 @@ fn unused_descriptions_in_shared_or_inherited_resources_are_retired() { } mod property_retirement; + +/// `[(FIRSTARR)] [(LASTARR)] TJ` is malformed (a surplus array operand), but the operator still +/// draws only the one immediately before it (LASTARR, 7 glyphs, 35 pt wide from x = 10) -- not +/// the first (FIRSTARR, 8 glyphs), which is never drawn at all, as the renderer agrees (#3569). +#[test] +fn a_tj_surplus_arrays_undrawn_glyphs_are_not_seen_under_a_mark() { + let mut doc = one_page(b"BT /F1 10 Tf 10 100 Td [(FIRSTARR)] [(LASTARR)] TJ ET", "", vec![]); + assert_eq!(under(&mut doc, 0, &[[45.0, 95.0, 50.0, 110.0]]), 0, "nothing is drawn past LASTARR's 35pt width"); + assert_eq!(under(&mut doc, 0, &[[10.0, 95.0, 45.0, 110.0]]), 7, "LASTARR's 7 glyphs, the one the page draws"); +} diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 0979f3d38..e9afb4ec4 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -30,6 +30,10 @@ const LOAD_STREAM_LIMIT: usize = 256 << 20; /// At most this many `/State` entries of a set-layer-visibility action are read. const MAX_LAYER_STATE: usize = 1024; +/// Decoded bytes read from an XFA form's configuration packet (or a whole-XDP `/XFA` stream) to +/// find `dynamicRender`. Designer's packets are a few kilobytes; a whole XDP can hold the template. +const MAX_XFA_CONFIG_BYTES: usize = 16 << 20; + /// File specifications retained from a remote-Go-To action are deliberately small. const MAX_REMOTE_FILE_SPEC_BYTES: usize = 4096; const MAX_REMOTE_DESTINATION_NAME_BYTES: usize = 1024; @@ -90,8 +94,8 @@ pub enum Xfa { /// The pages and fields are ordinary PDF; the XFA packets describe the same form (and hold /// its data, which Acrobat shows in preference to the fields). Static, - /// The form is laid out from the XFA packets at open (`/NeedsRendering`, or no fields): the - /// PDF pages are only a placeholder. + /// The form is laid out from the XFA packets at open (`/NeedsRendering`, or no fields and a + /// configuration that doesn't forbid dynamic rendering): the PDF pages are only a placeholder. Dynamic, } @@ -219,6 +223,18 @@ impl PageInfo { [a * self.width, b * self.height] } + /// A point as displayed (points from the page's top-left, y down, before any view rotation) + /// → the `/XYZ` position `[left, top]` in user space that puts it at the window's top-left. + /// Only a coordinate that is given is pinned: a bookmark made at the top of the window + /// leaves the horizontal position alone. On a page turned a quarter, the displayed `y` decides + /// the user `left` and the displayed `x` the user `top` (the inverse of [`Self::dest_fraction`]). + /// A value that is not finite counts as not given. + pub fn xyz_for_view(&self, x: Option, y: Option) -> [Option; 2] { + let (x, y) = (x.filter(|v| v.is_finite()), y.filter(|v| v.is_finite())); + let [ux, uy] = self.view_to_user(x.unwrap_or(0.0), y.unwrap_or(0.0)); + if self.rotation.is_multiple_of(180) { [x.map(|_| ux), y.map(|_| uy)] } else { [y.map(|_| ux), x.map(|_| uy)] } + } + /// A destination's user-space point (each coordinate possibly unspecified) → the same point /// as a fraction of the displayed page `[x, y]` (0..1 from its top-left corner, y down), /// after `/Rotate` and a further clockwise `view_rotation` (0, 90, 180 or 270). On a page @@ -386,6 +402,9 @@ pub struct Annotation { /// A Fill & Sign mark: typed text, a check, cross, dot or line, or a typed, drawn or picture /// signature or initials (the rules of `pdfcraft_annot::is_fill_sign`). pub fill_sign: bool, + /// The points that shape a line (its two ends, `/L`) or connected lines, a polygon or a + /// cloud (their corners, `/Vertices`), in user space; empty for other comments. + pub points: Vec<[f32; 2]>, } impl Annotation { @@ -425,6 +444,8 @@ pub struct Layer { pub id: (u32, u16), pub name: String, pub visible: bool, + /// Shown as the file opens: the default configuration (`/OCProperties /D`). + pub initial: bool, } #[derive(Clone, Debug)] @@ -643,6 +664,9 @@ impl<'a> Inspector<'a> { .ok() .and_then(|o| self.dict(o)) .and_then(|m| m.get(b"Marked").ok()) + // Any value may be written as an indirect reference, `/Marked 9 0 R` included, so the + // flag is resolved before it is read as a boolean (#3671). + .map(|o| self.resolve(o)) .and_then(|o| o.as_bool().ok()) .unwrap_or(false); match self.page_labels(catalog, &mut info.pages) { @@ -683,8 +707,12 @@ impl<'a> Inspector<'a> { } info.has_javascript |= info.fields.iter().any(|f| f.has_actions); let needs_rendering = catalog.get(b"NeedsRendering").ok().and_then(|o| self.resolve(o).as_bool().ok()).unwrap_or(false); - if catalog.get(b"AcroForm").ok().and_then(|o| self.dict(o)).is_some_and(|form| form.get(b"XFA").is_ok()) { - info.xfa = Some(if needs_rendering || info.fields.is_empty() { Xfa::Dynamic } else { Xfa::Static }); + if let Some(xfa) = catalog.get(b"AcroForm").ok().and_then(|o| self.dict(o)).and_then(|form| form.get(b"XFA").ok()) { + // A form with no fields is taken to be laid out from its template, unless its + // configuration says it is a static form (#3180): Designer writes + // `forbidden` for one, whose pages are the form. + let static_form = self.xfa_dynamic_render(xfa).as_deref() == Some("forbidden"); + info.xfa = Some(if needs_rendering || (info.fields.is_empty() && !static_form) { Xfa::Dynamic } else { Xfa::Static }); } self.layers(catalog, &mut info.layers); info.layer_groups = self.layer_groups(catalog); @@ -755,6 +783,28 @@ impl<'a> Inspector<'a> { } } + /// The value of `dynamicRender` in an XFA form's configuration packet (`config`, under + /// `acrobat/acrobat7`): `required` for a dynamic form, `forbidden` for a static one. `/XFA` is + /// either one stream holding the whole XDP or an array of packet names and streams (ISO + /// 32000-1 §12.7.8). Lenient: `None` when the packet is missing, unreadable or over + /// `MAX_XFA_CONFIG_BYTES` decoded, or has no such element. + fn xfa_dynamic_render(&self, xfa: &'a Object) -> Option { + let stream = match self.resolve(xfa) { + Object::Array(items) => { + let at = items.iter().position(|o| matches!(self.resolve(o), Object::String(name, _) if name.as_slice() == b"config"))?; + self.resolve(items.get(at.checked_add(1)?)?) + } + whole => whole, + }; + let stream = stream.as_stream().ok()?; + let bytes = if stream.dict.get(b"Filter").is_ok() { + stream.decompressed_content_with_limit(MAX_XFA_CONFIG_BYTES).ok()? + } else { + stream.content.get(..stream.content.len().min(MAX_XFA_CONFIG_BYTES))?.to_vec() + }; + xfa_config_dynamic_render(&xfa_packet_text(&bytes)) + } + fn resolve(&self, o: &'a Object) -> &'a Object { self.doc.dereference(o).map(|(_, o)| o).unwrap_or(o) } @@ -1149,6 +1199,16 @@ impl<'a> Inspector<'a> { }; let in_reply_to = d.get(b"IRT").ok().and_then(|o| self.dict(o)).and_then(|p| self.text(p, b"NM")); let fill_sign = self.is_fill_sign(d, &subtype); + // Keep in step with `pdfcraft_annot::summaries`. + let key: &[u8] = if subtype == "Line" { b"L" } else { b"Vertices" }; + let points = match d.get(key).map(|o| self.resolve(o)) { + Ok(Object::Array(v)) if matches!(subtype.as_str(), "Line" | "PolyLine" | "Polygon") => { + let v: Vec = v.iter().map(|o| o.as_float().unwrap_or(0.0)).collect(); + let pts: Vec<[f32; 2]> = v.as_chunks::<2>().0.to_vec(); + if subtype == "Line" && pts.len() != 2 { Vec::new() } else { pts } + } + _ => Vec::new(), + }; info.annotations.push(Annotation { page, subtype, @@ -1167,6 +1227,7 @@ impl<'a> Inspector<'a> { locked: d.get(b"F").ok().and_then(|f| self.resolve(f).as_i64().ok()).unwrap_or(0) & 128 != 0, intent: self.name(d, b"IT"), fill_sign, + points, }); } } @@ -1428,7 +1489,7 @@ impl<'a> Inspector<'a> { _ => !base_off, }; let Some(id) = id else { continue }; - out.push(Layer { id, name: self.text(d, b"Name").unwrap_or_else(|| "Layer".into()), visible }); + out.push(Layer { id, name: self.text(d, b"Name").unwrap_or_else(|| "Layer".into()), visible, initial: visible }); } } @@ -1640,6 +1701,116 @@ pub fn attachment_data(bytes: &[u8], password: Option<&str>, att: &Attachment) - catch_unwind(AssertUnwindSafe(run)).unwrap_or_else(|p| Err(format!("reading the attachment crashed: {}", crate::raster::panic_message(&p)))) } +/// An XFA packet's bytes as text: UTF-16 (little- or big-endian, told by a byte order mark or by +/// how `<` is written) or UTF-8, as `pdfcraft_xfa::packets::decode` reads them. Lossy: only one +/// element is looked for. +fn xfa_packet_text(bytes: &[u8]) -> String { + let utf16 = |b: &[u8], from: fn([u8; 2]) -> u16| { + let units = b.as_chunks::<2>().0.iter().map(|c| from(*c)); + char::decode_utf16(units).map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER)).collect::() + }; + match bytes { + [0xFF, 0xFE, rest @ ..] => utf16(rest, u16::from_le_bytes), + [0xFE, 0xFF, rest @ ..] => utf16(rest, u16::from_be_bytes), + [b'<', 0, ..] => utf16(bytes, u16::from_le_bytes), + [0, b'<', ..] => utf16(bytes, u16::from_be_bytes), + [0xEF, 0xBB, 0xBF, rest @ ..] => String::from_utf8_lossy(rest).into_owned(), + _ => String::from_utf8_lossy(bytes).into_owned(), + } +} + +/// One markup tag found by [`next_tag`]: where it starts (`<`) and ends (after `>`), whether it +/// closes (``) or closes itself (``), and its name without a namespace prefix. +struct Tag<'a> { + start: usize, + end: usize, + close: bool, + empty: bool, + local: &'a str, +} + +/// The next element tag in `xml` at or after `from`, skipping comments, CDATA sections, +/// processing instructions and declarations. A byte scan, not an XML parser: enough to find one +/// known element in an XFA packet, whose tags Designer writes with a line break before each `>`. +fn next_tag(xml: &str, mut from: usize) -> Option> { + loop { + let lt = from + xml.get(from..)?.find('<')?; + let rest = xml.get(lt..)?; + let skip_to = |end: &str| rest.find(end).map(|i| lt + i + end.len()); + if rest.starts_with("")?; + continue; + } + if rest.starts_with("")?; + continue; + } + if rest.starts_with("")?; + continue; + } + let gt = lt + rest.find('>')?; + let inner = xml.get(lt + 1..gt)?; + let (close, inner) = match inner.strip_prefix('/') { + Some(i) => (true, i), + None => (false, inner), + }; + let empty = !close && inner.ends_with('/'); + let name = inner.split(|c: char| c.is_whitespace() || c == '/').next().unwrap_or_default(); + let local = name.rsplit(':').next().unwrap_or(name); + return Some(Tag { start: lt, end: gt + 1, close, empty, local }); + } +} + +/// What `…` holds: the content of the first top-level element of `xml` called `name` +/// (any prefix). Elements nested in other elements are not looked at. `None` when there is none, +/// it closes itself, or it is never closed. +fn xml_child_body<'a>(xml: &'a str, name: &str) -> Option<&'a str> { + let mut at = 0; + // The elements open around `at`, innermost last: a closing tag must name the innermost one, + // or the markup is malformed and has no value. + let mut open_tags: Vec<&str> = Vec::new(); + // Where the wanted element's content starts, once its opening tag is seen at the top level. + let mut open = None; + loop { + let tag = next_tag(xml, at)?; + at = tag.end; + if tag.close { + if open_tags.pop()? != tag.local { + return None; + } + if open_tags.is_empty() + && let Some(start) = open + { + return xml.get(start..tag.start); + } + } else if !tag.empty { + if open_tags.is_empty() && tag.local == name { + open = Some(tag.end); + } + open_tags.push(tag.local); + } else if open_tags.is_empty() && tag.local == name { + return None; + } + } +} + +/// The trimmed text at `path` (element names from the outside in, any prefix, each a child of the +/// one before) in `xml`, if it is plain text. +fn xml_path_text(xml: &str, path: &[&str]) -> Option { + let body = path.iter().try_fold(xml, |within, name| xml_child_body(within, name))?; + let text = body.trim(); + (!text.is_empty() && !text.contains('<')).then(|| text.to_owned()) +} + +/// `dynamicRender` at `config/acrobat/acrobat7` in the configuration packet: the `config` child +/// of the `xdp` root in a whole-XDP stream, or the `config` packet itself. The same names inside +/// the form data or the template are not the configuration. +fn xfa_config_dynamic_render(packet: &str) -> Option { + const IN_CONFIG: [&str; 4] = ["config", "acrobat", "acrobat7", "dynamicRender"]; + xml_path_text(packet, &["xdp", IN_CONFIG[0], IN_CONFIG[1], IN_CONFIG[2], IN_CONFIG[3]]).or_else(|| xml_path_text(packet, &IN_CONFIG)) +} + #[cfg(test)] mod tests { #[test] @@ -2358,6 +2529,24 @@ trailer << /Root 1 0 R /Info 4 0 R >> assert_eq!(info.layer_groups, [vec![(5, 0), (6, 0)], vec![(6, 0), (7, 0)]]); } + #[test] + fn initial_layer_visibility_is_the_default_configuration() { + // BaseState OFF: Red is listed ON; Green is listed both ON and OFF, and OFF wins; Blue + // isn't listed, so it takes the base state. + let pdf = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R 7 0 R] /D << /BaseState /OFF /ON [5 0 R 6 0 R] /OFF [6 0 R] >> >> >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >> endobj +5 0 obj << /Type /OCG /Name (Red) >> endobj +6 0 obj << /Type /OCG /Name (Green) >> endobj +7 0 obj << /Type /OCG /Name (Blue) >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + let info = inspect(Arc::new(pdf.to_vec()), None).expect("opens"); + let layers: Vec<_> = info.layers.iter().map(|l| (l.name.as_str(), l.initial)).collect(); + assert_eq!(layers, [("Red", true), ("Green", false), ("Blue", false)]); + } + const ORPHAN_FIELDS: &[u8] = b"%PDF-1.7 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj @@ -2439,6 +2628,71 @@ trailer << /Root 1 0 R >> assert!(reads < 20 * (PAGES + PAGES * PER_PAGE), "{reads} object reads"); } + /// #2365: the issue's fixture is two PDFs concatenated, the second cut off before its + /// cross-reference data, and the first's only trailer is a cross-reference stream. The + /// lazy reader refuses a file that needed repair, and lopdf's rebuild found no `trailer` + /// keyword and failed, so Document Properties showed every field empty. The rebuild now + /// takes the cross-reference stream's dictionary as the trailer. + #[test] + fn rebuilt_file_keeps_the_info_of_its_cross_reference_stream() { + let header = "%PDF-1.5\n"; + let obj1 = "1 0 obj<>endobj\n"; + let obj2 = "2 0 obj<>endobj\n"; + let obj3 = "3 0 obj<>endobj\n"; + let obj4 = "4 0 obj<>endobj\n"; + let o1 = header.len(); + let o2 = o1 + obj1.len(); + let o3 = o2 + obj2.len(); + let o4 = o3 + obj3.len(); + let o5 = o4 + obj4.len(); + let rows: Vec = + [[0u8, 0, 0, 255], [1, 0, o1 as u8, 0], [1, 0, o2 as u8, 0], [1, 0, o3 as u8, 0], [1, 0, o4 as u8, 0], [1, (o5 >> 8) as u8, o5 as u8, 0]] + .concat(); + let mut bytes = format!("{header}{obj1}{obj2}{obj3}{obj4}").into_bytes(); + bytes.extend_from_slice(format!("5 0 obj<>stream\n", rows.len()).as_bytes()); + bytes.extend_from_slice(&rows); + // A wrong startxref, and a second file after %%EOF: every reader rebuilds the table. + bytes.extend_from_slice(b"\nendstream\nendobj\nstartxref\n999999\n%%EOF\n%PDF-1.3\n"); + bytes.extend_from_slice(&[b'x'; 1024]); + + let info = inspect(Arc::new(bytes), None).unwrap(); + assert_eq!(info.pages.len(), 1); + assert_eq!(info.title.as_deref(), Some("Kept")); + assert_eq!(info.creator.as_deref(), Some("Me")); + } + + /// #2365 review: the rebuild prefers a trailer whose /Root is the catalog. A later + /// cross-reference stream whose /Root points at the Info dictionary used to replace the + /// earlier `trailer`, so Document Properties lost the catalog it should have read. + #[test] + fn rebuilt_file_prefers_a_trailer_whose_root_is_the_catalog() { + let mut bytes = b"%PDF-1.5\n".to_vec(); + for body in [ + "<>", + "<>", + "<>", + "<>", + ] { + let n = bytes.iter().filter(|b| **b == b'\n').count(); + bytes.extend_from_slice(format!("{n} 0 obj{body}endobj\n").as_bytes()); + } + bytes.extend_from_slice(b"trailer\n<>\n"); + bytes.extend_from_slice(b"5 0 obj<>stream\n\x01\x00\x09\x00\nendstream\nendobj\n"); + // A wrong startxref, and a second file after %%EOF: every reader rebuilds the table. + bytes.extend_from_slice(b"startxref\n999999\n%%EOF\n%PDF-1.3\n"); + bytes.extend_from_slice(&[b'x'; 1024]); + + let info = inspect(Arc::new(bytes.clone()), None).unwrap(); + assert_eq!(info.pages.len(), 1); + assert_eq!(info.title.as_deref(), Some("Kept")); + // A later stream whose /Root names the catalog's number with another generation does + // not count either: loading `1 1 R` finds no such object. + let at = bytes.windows(10).position(|w| w == b"/Root 4 0 ").unwrap(); + bytes[at..at + 10].copy_from_slice(b"/Root 1 1 "); + let info = inspect(Arc::new(bytes), None).unwrap(); + assert_eq!((info.pages.len(), info.title.as_deref()), (1, Some("Kept"))); + } + #[test] fn lazy_structure_matches_the_compatibility_inspector() { // Each fixture as lopdf writes it, and with its objects in object streams. @@ -2663,6 +2917,101 @@ trailer << /Root 1 0 R >> assert_eq!(titles, ["第一章", "目录", "A\u{FFFD}B", "概述", "Café", "A"]); } + #[test] + fn xfa_dynamic_render_is_read_from_config_acrobat7_only() { + let text = xfa_config_dynamic_render; + let config = + |inner: &str| format!("{inner}"); + // Designer's style: a line break before each `>`. + assert_eq!( + text("forbidden").as_deref(), + Some("forbidden") + ); + assert_eq!(text(&config(" required ")).as_deref(), Some("required")); + // Commented out, in CDATA, or a longer name: not the element. + assert_eq!(text(&config("")), None); + assert_eq!(text(&config(" b forbidden ]]>")), None); + assert_eq!(text(&config("forbidden")), None); + // Self-closing, empty, or holding markup: no value. + assert_eq!(text(&config("")), None); + assert_eq!(text(&config("")), None); + assert_eq!(text(&config("forbidden")), None); + // Closing tags that don't match what they close: malformed, no value (review on #3192). + assert_eq!(text(&config("forbidden")), None); + assert_eq!(text("forbidden"), None); + // The same name anywhere but config/acrobat/acrobat7 (form data, another config branch). + let xdp = "forbiddenforbidden"; + assert_eq!(text(xdp), None); + // A whole XDP with the config after the template. + let xdp = format!("{}", config("forbidden")); + assert_eq!(text(&xdp).as_deref(), Some("forbidden")); + // A whole config path inside the form data, before the real config packet, is data: the + // real packet's value counts (review on #3192). + let xdp = format!( + "{}{}", + config("forbidden"), + config("required") + ); + assert_eq!(text(&xdp).as_deref(), Some("required")); + // Only in the data, or nested in the template: no configuration at all. + let xdp = + format!("{}", config("forbidden")); + assert_eq!(text(&xdp), None); + let xdp = format!("", config("forbidden")); + assert_eq!(text(&xdp), None); + // acrobat7 must be a child of acrobat, not deeper. + assert_eq!(text("forbidden"), None); + // Cut short anywhere: no panic, nothing read. + let whole = config("forbidden"); + for end in 0..whole.len() { + assert_eq!(text(&whole[..end]), None, "cut at {end}"); + } + assert_eq!(text(&whole).as_deref(), Some("forbidden")); + } + + #[test] + fn xfa_config_packets_are_read_in_utf16_too() { + let xml = "forbidden"; + let le: Vec = xml.encode_utf16().flat_map(u16::to_le_bytes).collect(); + let be: Vec = xml.encode_utf16().flat_map(u16::to_be_bytes).collect(); + for bytes in [ + le.clone(), + be.clone(), + [&[0xFF, 0xFE][..], &le].concat(), + [&[0xFE, 0xFF][..], &be].concat(), + [&[0xEF, 0xBB, 0xBF][..], xml.as_bytes()].concat(), + ] { + assert_eq!(xfa_packet_text(&bytes), xml); + } + // An odd byte or a broken surrogate doesn't stop the scan. + assert!(xfa_packet_text(&[0xFF, 0xFE, b'<', 0, 0x00, 0xD8, b'x']).starts_with('<')); + } + + /// #3180: an XFA form with no fields was always taken to be dynamic, so its pages were + /// replaced by a layout on open, even when its configuration says it is a static form. + #[test] + fn an_xfa_form_without_fields_is_static_when_its_config_forbids_dynamic_rendering() { + let xfa_of = |config: &str, catalog_extra: &str| { + let bodies = [ + format!("<< /Type /Catalog /Pages 2 0 R {catalog_extra} /AcroForm << /Fields [] /XFA [(config) 4 0 R (template) 5 0 R] >> >>"), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".to_string(), + "<< /Type /Page /Parent 2 0 R >>".to_string(), + format!("<< /Length {} >>\nstream\n{config}\nendstream", config.len()), + "<< /Length 11 >>\nstream\n