diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 000000000..2408084cf --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,6 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +reviews: + auto_review: + # Dependabot bumps are checked by CI; skip CodeRabbit reviews on them. + ignore_usernames: + - "dependabot[bot]" diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..1910ea4bd --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,32 @@ +version: 2 +updates: + # Maintain Rust / Cargo dependencies + - package-ecosystem: "cargo" + directories: + - "/" + - "/crates/filters/fuzz" + schedule: + interval: "daily" + time: "06:00" + timezone: "America/New_York" + # Consolidate updates into a single PR rather than one PR per crate + groups: + cargo-dependencies: + patterns: + - "*" + commit-message: + prefix: "chore(deps)" + + # Maintain GitHub Actions workflows + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + time: "06:00" + timezone: "America/New_York" + groups: + actions: + patterns: + - "*" + commit-message: + prefix: "ci(deps)" 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/.github/workflows/nix.yml b/.github/workflows/nix.yml new file mode 100644 index 000000000..01cb9cd66 --- /dev/null +++ b/.github/workflows/nix.yml @@ -0,0 +1,23 @@ +# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation +# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice. +name: Nix + +on: + push: + branches: [main] + paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"] + pull_request: + paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: nix-${{ github.ref }} + cancel-in-progress: true + +jobs: + nix: + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/nix.yml@main diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml new file mode 100644 index 000000000..ecee45a6d --- /dev/null +++ b/.github/workflows/sonarcloud.yml @@ -0,0 +1,20 @@ +name: SonarCloud + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: sonarcloud-${{ github.ref }} + cancel-in-progress: true + +jobs: + sonarcloud: + # Tracked at @main so org-wide CI improvements propagate without PR churn. + uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main + secrets: inherit diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 903d13284..4efb31105 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -4,31 +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 (272) +## 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/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/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 | @@ -224,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. | @@ -268,20 +275,29 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `assets/icons/thumbs-down.svg` | Lucide icon "thumbs-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/thumbs-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/message-square-plus.svg` | Lucide icon "message-square-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/circle-x.svg` | Lucide icon "circle-x" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-x.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `crates/ui-egui/tests/data/form.pdf` | One-page AcroForm test fixture (text, check box, radio group, combo box) | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects, no third-party content | Form-filling UI tests (crates/ui-egui/tests/forms.rs, commands.rs) | +| `crates/ui-egui/tests/data/form.pdf` | One-page AcroForm test fixture (text, check box, radio group, combo box) | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects, no third-party content | Form-filling UI tests (crates/ui-egui/tests/it/forms.rs, commands.rs) | | `assets/icons/pentagon.svg` | Lucide icon "pentagon" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pentagon.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/message-square-quote.svg` | Lucide icon "message-square-quote" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-quote.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `crates/sign/tests/data/openssl-signed.pdf` | One-page PDF signed with OpenSSL CMS (adbe.pkcs7.detached) by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl cms -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/pdf.rs): a signature PdfCraft did not make | +| `crates/sign/tests/data/openssl-signed.pdf` | One-page PDF signed with OpenSSL CMS (adbe.pkcs7.detached) by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl cms -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/it/pdf.rs): a signature PdfCraft did not make | | `assets/icons/clipboard-paste.svg` | Lucide icon "clipboard-paste" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clipboard-paste.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/fonts/DancingScript.ttf` | Dancing Script (variable) | The Dancing Script Project Authors (Pablo Impallari) | OFL-1.1 | https://github.com/google/fonts/blob/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/dancingscript/DancingScript%5Bwght%5D.ttf | Fill & Sign typed signatures and initials: the typed name's glyph outlines, drawn as filled paths (the font file itself is not embedded in PDFs) | | `packaging/macos/dmg/background.svg` | PdfCraft macOS DMG window background | @XusBadia | MIT OR Apache-2.0 | Original work for this repository, derived from the PdfCraft app icon (assets/app-icon/pdfcraft.svg, referenced via an SVG image element, not copied) | Finder window of the macOS DMG; rendered to background.tiff by packaging/macos/dmg/generate.py | | `packaging/macos/dmg/background.tiff` | PdfCraft macOS DMG window background (rendered) | @XusBadia | MIT OR Apache-2.0 | Rendered from packaging/macos/dmg/background.svg by packaging/macos/dmg/generate.py | 1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packaging/macos/package.sh; includes the app icon and the free LittleCMS built-in sRGB profile | -| `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 | +| `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/it/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/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 | |---|---|---|---|---|---|---| @@ -289,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 34f17b513..7f70df3f8 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 = "cefa955aa92790d6773cb1d1e15e19019b3165a00528d57d6617362e1d1988ab" +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,222 +25,286 @@ sha256 = "cefa955aa92790d6773cb1d1e15e19019b3165a00528d57d6617362e1d1988ab" 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 = "1de29a6cc089be31cb6206bf41e0715f8ddcf74ef887b45ccee235d743bed223" +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 = "0b0dafa3806a6b3508684f64e1f07b435113f8efd0e59201d12250c63c1d9547" +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 = "8a8aed18bd8fceabd1389580361ba75b0dccf141d845650a353d8dfe19c95536" +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 = "1c197ac045810950aceb733152a296f2224f38857b1169032fdc7092ab4aa3f7" +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 = "a9cf0e062b006b40f549ce2316b88d4b0196d70bd9cecd66c12d980d699bf679" +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 = "ef5c84bd13ef8318f7deab7f42136cfe3c14cbef6fd5231014b94167baaecd2a" +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 = "be689af8bb22537879269ad9ab9bdcc4b730665cf0b27c6bf4bad560cacf3b69" +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 = "989d2e5f57cf22c21a85951835ca9fbd05e314da67eb4c17332df1f03679fa00" +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 = "425c42acb8d98ac31b216b13c05a285e0676318dd379dcfb9d57cc7329e9d7a5" +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 = "988bcf2e0322a46c0464ff7b91ab5d90193b0ea7f454812d12595eb843de6fc7" +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 = "ebe7aa9152baff38ae7e36aeb4f6a7ed2198166ab93425a612a66d53e43cb363" +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 = "69d2da21949f02c3c40f71159e377eef2fdd1a87f749d6c504e1d151ef33f643" +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 = "98359685aa555f391846f2259ac302ce1234c7a180fb4abe07c36e0c1c62211e" +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 = "9f6296d9c79c848167fc80bb4cfaacc9e60f72edb70cc1d70f7b6088ada83578" +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 = "3528e85afd4117de2c22b9f37d32a380a073a49834fa5a3063d21b803606ffbf" +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 = "4db7e23074d44305f4584e2cd8b9a0fc35b433cb187dad493c8f7d78a8cd4ad2" +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 = "2af01de9509db4b4ae61193f41ff0f7f3b70ca5f91cd2afda667e90d34980b25" +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 = "de988b6959572931c4878a0588109c236bf8c5d46ef57c7a406b6ab42de51339" +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 = "62151d79be5089d964876b6569d490ee3fdacef4388684b574be9627b1354e92" - +sha256 = "dea5076ce9c19f054812b678241931ad5e7f85dee08acf1c1e12c7ecaaa5d0f5" [[asset]] -path = "crates/ui-egui/src/i18n/vi.tsv" -title = "Vietnamese interface translation catalog" +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; 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, 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 = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "f9002d56e44e49429e0cdeabb10525f79f32831b94233f3c0a8a0ecb202850d5" +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" @@ -2436,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" @@ -3214,7 +3277,7 @@ source = "Contributor-original: hand-written PDF objects, no third-party content licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "document" -usage = "Form-filling UI tests (crates/ui-egui/tests/forms.rs, commands.rs)" +usage = "Form-filling UI tests (crates/ui-egui/tests/it/forms.rs, commands.rs)" sha256 = "c6f8b656e225101129aa36bd5467adc8dba16730915f19caca17f6a546d0b040" [[asset]] @@ -3247,7 +3310,7 @@ source = "Contributor-original: hand-written PDF objects; signature made with `o licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "document" -usage = "Signature validation tests (crates/sign/tests/pdf.rs): a signature PdfCraft did not make" +usage = "Signature validation tests (crates/sign/tests/it/pdf.rs): a signature PdfCraft did not make" sha256 = "8b91c3b4e39c4a31717a0dd0685bd7d0468671df32aee5b9818d44dc72c2bc3c" [[asset]] @@ -3340,7 +3403,7 @@ source = "Contributor-original: hand-written PDF objects; signature made with `o licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "document" -usage = "Signature validation tests (crates/sign/tests/pdf.rs): the legacy adbe.x509.rsa_sha1 format" +usage = "Signature validation tests (crates/sign/tests/it/pdf.rs): the legacy adbe.x509.rsa_sha1 format" sha256 = "b77bfb21166f4bd043597e45a1ea28b9d9f4ba6df0142b3a0fc8b7f8e0fc111b" [[asset]] @@ -3354,18 +3417,30 @@ kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" sha256 = "d1a443233345724859de8e4fb48968ba91e885e12ddbd710701548bd91b39428" +[[asset]] +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; 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 = "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 = "5881cc7fbfe9da5f5f611309dacf267f95db3a143b916a113b649839019b292c" +sha256 = "a3b8351f0da80c2d394221dbe5d62e794bb019c74c2adfc5d933d999f7d3236f" [[asset]] + path = "vendor/vello_common/assets/probe.png" title = "vello_common probe reference image (rendered from probe.rgba)" author = "Linebender vello contributors" @@ -3375,3 +3450,91 @@ licence_file = "vendor/vello_common/LICENSE-MIT" 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\"" +author = "Lucide Contributors (portions Cole Bemis, Feather)" +source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg)" +licence = "ISC" +licence_file = "assets/icons/LICENSE-lucide.txt" +kind = "icon" +usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" +sha256 = "34af289365aaa19295f874e4fda3b56196e203785d6fe1d9c3c929ae290518a0" + +[[asset]] +path = "assets/icons/subscript.svg" +title = "Lucide icon \"subscript\"" +author = "Lucide Contributors (portions Cole Bemis, Feather)" +source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg)" +licence = "ISC" +licence_file = "assets/icons/LICENSE-lucide.txt" +kind = "icon" +usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" +sha256 = "06462f57e87a14e6496da9216eee9906b83ded0ff99b70498efa37ae88a5cba4" + +[[asset]] +path = "assets/icons/pilcrow.svg" +title = "Lucide icon \"pilcrow\"" +author = "Lucide Contributors (portions Cole Bemis, Feather)" +source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg)" +licence = "ISC" +licence_file = "assets/icons/LICENSE-lucide.txt" +kind = "icon" +usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" +sha256 = "d344d9f9222f6c1b698c0c82a753a2212d9bd7785f06c00cd6e4910fe2fee8f8" + +[[asset]] +path = "assets/icons/move-horizontal.svg" +title = "Lucide icon \"move-horizontal\"" +author = "Lucide Contributors (portions Cole Bemis, Feather)" +source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg)" +licence = "ISC" +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 12779b1e3..085e8356f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado - No `unsafe` (`unsafe_code = "forbid"`). - Every crash fix gets a synthetic regression test. See `AGENTS.md` §4 (and, for team members, the internal `craftrules/standards/never-crash.md`). - **Rust only** in the product and build (`xtask`). No handwritten JS/TS. +- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). - **Review every update as you make it, at four zooms.** Not an end-of-task pass — a per-update habit, so a defect never gets built on. **Clause:** checked arithmetic, `get()` not `[i]`, char-boundary slicing, the right error through `?`, `<` against `<=`, the 1-based/0-based seam, capped allocations. **Function:** is it total — name an input that panics; walk empty, zero, max, malformed, cyclic; is the return type honest. **File/module:** two sources of truth, dead or duplicated code, is the public surface still smallest and the doc comment still true. **Project:** layering, wasm, undo/redo, save fidelity, encryption and permissions, a matching `automation` tool or registry entry, and whether a `parity/` status or note just became untrue. Scale the zoom to the change, and fix what a level turns up before widening. - Tests mirror the zooms: property → unit → crate integration → end-to-end. - **Prove a test fails before the fix by actually reverting the fix.** A test nobody has seen fail is not evidence. This has caught two worthless tests in this repo: one confinement test passed with path resolution switched off entirely, because the tools failed on a nonexistent document id before reaching the path. @@ -38,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 91ed3c490..a95014924 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1142,6 +1142,11 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-docpath" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" + [[package]] name = "craft-print" version = "0.1.0" @@ -1882,7 +1887,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -2544,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", @@ -3933,8 +3936,9 @@ dependencies = [ [[package]] name = "pdfcraft" -version = "0.8.0" +version = "0.11.0" dependencies = [ + "chrono", "craft-quit", "eframe", "egui", @@ -3958,7 +3962,7 @@ dependencies = [ [[package]] name = "pdfcraft-a11y" -version = "0.8.0" +version = "0.11.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -3970,7 +3974,7 @@ dependencies = [ [[package]] name = "pdfcraft-annot" -version = "0.8.0" +version = "0.11.0" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -3981,7 +3985,7 @@ dependencies = [ [[package]] name = "pdfcraft-audit" -version = "0.8.0" +version = "0.11.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -3992,7 +3996,7 @@ dependencies = [ [[package]] name = "pdfcraft-automation" -version = "0.8.0" +version = "0.11.0" dependencies = [ "base64 0.22.1", "flate2", @@ -4006,6 +4010,7 @@ dependencies = [ "pdfcraft-render", "pdfcraft-xfa", "png 0.18.1", + "rustix 1.1.5", "serde_json", "sha2 0.11.0", "tempfile", @@ -4013,7 +4018,7 @@ dependencies = [ [[package]] name = "pdfcraft-cli" -version = "0.8.0" +version = "0.11.0" dependencies = [ "base64 0.22.1", "pdfcraft-audit", @@ -4021,7 +4026,9 @@ dependencies = [ "pdfcraft-cos", "pdfcraft-engine", "pdfcraft-platform", + "pdfcraft-preflight", "pdfcraft-render", + "png 0.18.1", "rustix 1.1.5", "serde_json", "winresource", @@ -4029,19 +4036,20 @@ dependencies = [ [[package]] name = "pdfcraft-compare" -version = "0.8.0" +version = "0.11.0" [[package]] name = "pdfcraft-content" -version = "0.8.0" +version = "0.11.0" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-cos" -version = "0.8.0" +version = "0.11.0" dependencies = [ + "chrono", "hayro-syntax", "pdfcraft-crypt", "pdfcraft-filters", @@ -4054,7 +4062,7 @@ dependencies = [ [[package]] name = "pdfcraft-create" -version = "0.8.0" +version = "0.11.0" dependencies = [ "encoding_rs", "hayro-syntax", @@ -4070,7 +4078,7 @@ dependencies = [ [[package]] name = "pdfcraft-crypt" -version = "0.8.0" +version = "0.11.0" dependencies = [ "aes", "md-5", @@ -4082,7 +4090,7 @@ dependencies = [ [[package]] name = "pdfcraft-edit" -version = "0.8.0" +version = "0.11.0" dependencies = [ "hayro-syntax", "kurbo 0.13.1", @@ -4097,9 +4105,10 @@ dependencies = [ [[package]] name = "pdfcraft-engine" -version = "0.8.0" +version = "0.11.0" dependencies = [ "chrono", + "craft-docpath", "icu_properties", "idna", "image", @@ -4125,24 +4134,26 @@ dependencies = [ "pdfcraft-redact", "pdfcraft-render", "pdfcraft-sign", + "pdfcraft-testkit", "pdfcraft-xfa", "pdfcraft-xfdf", "png 0.18.1", "thiserror 2.0.21", + "tiff", "unicode-segmentation", "url", ] [[package]] name = "pdfcraft-export" -version = "0.8.0" +version = "0.11.0" dependencies = [ "flate2", ] [[package]] name = "pdfcraft-filters" -version = "0.8.0" +version = "0.11.0" dependencies = [ "flate2", "proptest", @@ -4152,7 +4163,7 @@ dependencies = [ [[package]] name = "pdfcraft-fonts" -version = "0.8.0" +version = "0.11.0" dependencies = [ "harfrust", "pdfcraft-cos", @@ -4162,30 +4173,32 @@ dependencies = [ [[package]] name = "pdfcraft-forms" -version = "0.8.0" +version = "0.11.0" dependencies = [ "hayro-syntax", "pdfcraft-content", "pdfcraft-cos", "pdfcraft-fonts", "pdfcraft-model", + "proptest", "thiserror 2.0.21", ] [[package]] name = "pdfcraft-geom" -version = "0.8.0" +version = "0.11.0" [[package]] name = "pdfcraft-js" -version = "0.8.0" +version = "0.11.0" dependencies = [ "boa_engine", + "chrono", ] [[package]] name = "pdfcraft-measure" -version = "0.8.0" +version = "0.11.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4198,14 +4211,14 @@ dependencies = [ [[package]] name = "pdfcraft-model" -version = "0.8.0" +version = "0.11.0" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-ocr" -version = "0.8.0" +version = "0.11.0" dependencies = [ "ocrs", "pdfcraft-fonts", @@ -4215,7 +4228,7 @@ dependencies = [ [[package]] name = "pdfcraft-optimize" -version = "0.8.0" +version = "0.11.0" dependencies = [ "image", "pdfcraft-annot", @@ -4228,11 +4241,11 @@ dependencies = [ [[package]] name = "pdfcraft-oracle" -version = "0.8.0" +version = "0.11.0" [[package]] name = "pdfcraft-organize" -version = "0.8.0" +version = "0.11.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4243,7 +4256,7 @@ dependencies = [ [[package]] name = "pdfcraft-platform" -version = "0.8.0" +version = "0.11.0" dependencies = [ "async-channel", "futures-lite", @@ -4253,27 +4266,30 @@ dependencies = [ [[package]] name = "pdfcraft-preflight" -version = "0.8.0" +version = "0.11.0" dependencies = [ + "flate2", "pdfcraft-content", "pdfcraft-cos", "pdfcraft-model", + "roxmltree", ] [[package]] name = "pdfcraft-print" -version = "0.8.0" +version = "0.11.0" dependencies = [ "craft-print", "pdfcraft-content", "pdfcraft-cos", "pdfcraft-model", + "pdfcraft-render", "thiserror 2.0.21", ] [[package]] name = "pdfcraft-redact" -version = "0.8.0" +version = "0.11.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4289,7 +4305,7 @@ dependencies = [ [[package]] name = "pdfcraft-render" -version = "0.8.0" +version = "0.11.0" dependencies = [ "bytemuck", "flate2", @@ -4308,7 +4324,7 @@ dependencies = [ [[package]] name = "pdfcraft-sign" -version = "0.8.0" +version = "0.11.0" dependencies = [ "aes", "aws-lc-rs", @@ -4316,6 +4332,7 @@ dependencies = [ "bp256", "bp384", "cbc", + "chrono", "crypto-bigint", "des", "ecdsa", @@ -4342,15 +4359,16 @@ dependencies = [ [[package]] name = "pdfcraft-testkit" -version = "0.8.0" +version = "0.11.0" [[package]] name = "pdfcraft-ui-egui" -version = "0.8.0" +version = "0.11.0" dependencies = [ "arboard", "base64 0.22.1", "bytemuck", + "chrono", "craft-quit", "craft-ui", "eframe", @@ -4390,7 +4408,7 @@ dependencies = [ [[package]] name = "pdfcraft-web" -version = "0.8.0" +version = "0.11.0" dependencies = [ "eframe", "egui", @@ -4402,7 +4420,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfa" -version = "0.8.0" +version = "0.11.0" dependencies = [ "base64 0.22.1", "image", @@ -4416,7 +4434,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfdf" -version = "0.8.0" +version = "0.11.0" dependencies = [ "pdfcraft-annot", "pdfcraft-cos", @@ -4924,7 +4942,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -5474,7 +5492,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -5544,7 +5562,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -5555,9 +5573,9 @@ checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "aws-lc-rs", "ring", @@ -6421,7 +6439,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.5", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -7296,9 +7314,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb" dependencies = [ "rustls-pki-types", ] @@ -7504,7 +7522,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -8024,6 +8042,7 @@ name = "xtask" version = "0.1.0" dependencies = [ "anyhow", + "chrono", "lopdf", "pdfcraft-cos", "pdfcraft-oracle", diff --git a/Cargo.toml b/Cargo.toml index 597dd316f..201240314 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ exclude = ["vendor"] default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"] [workspace.package] -version = "0.8.0" +version = "0.11.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -18,40 +18,42 @@ craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "f58bc1a3df490992d633393d0898af0588ce882c" } # Shared system spooler; document composition remains in pdfcraft-print. craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "666c358065261a2816b376fa3bb10538dbf53040" } +# Whether a path stored in a document may be opened (GoToR link targets). +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } # internal -pdfcraft-geom = { path = "crates/geom", version = "0.8.0" } -pdfcraft-filters = { path = "crates/filters", version = "0.8.0" } -pdfcraft-cos = { path = "crates/cos", version = "0.8.0" } -pdfcraft-crypt = { path = "crates/crypt", version = "0.8.0" } -pdfcraft-organize = { path = "crates/organize", version = "0.8.0" } -pdfcraft-measure = { path = "crates/measure", version = "0.8.0" } -pdfcraft-annot = { path = "crates/annot", version = "0.8.0" } -pdfcraft-audit = { path = "crates/audit", version = "0.8.0" } -pdfcraft-fonts = { path = "crates/fonts", version = "0.8.0" } -pdfcraft-model = { path = "crates/model", version = "0.8.0" } -pdfcraft-content = { path = "crates/content", version = "0.8.0" } -pdfcraft-redact = { path = "crates/redact", version = "0.8.0" } -pdfcraft-print = { path = "crates/print", version = "0.8.0" } -pdfcraft-xfdf = { path = "crates/xfdf", version = "0.8.0" } -pdfcraft-sign = { path = "crates/sign", version = "0.8.0" } -pdfcraft-optimize = { path = "crates/optimize", version = "0.8.0" } -pdfcraft-a11y = { path = "crates/a11y", version = "0.8.0" } -pdfcraft-oracle = { path = "crates/oracle", version = "0.8.0" } -pdfcraft-testkit = { path = "crates/testkit", version = "0.8.0" } -pdfcraft-ocr = { path = "crates/ocr", version = "0.8.0" } -pdfcraft-js = { path = "crates/js", version = "0.8.0" } -pdfcraft-compare = { path = "crates/compare", version = "0.8.0" } -pdfcraft-preflight = { path = "crates/preflight", version = "0.8.0" } -pdfcraft-export = { path = "crates/export", version = "0.8.0" } -pdfcraft-edit = { path = "crates/edit", version = "0.8.0" } -pdfcraft-create = { path = "crates/create", version = "0.8.0" } -pdfcraft-forms = { path = "crates/forms", version = "0.8.0" } -pdfcraft-xfa = { path = "crates/xfa", version = "0.8.0" } -pdfcraft-render = { path = "crates/render", version = "0.8.0" } -pdfcraft-engine = { path = "crates/engine", version = "0.8.0" } -pdfcraft-platform = { path = "crates/platform", version = "0.8.0" } -pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.8.0" } -pdfcraft-automation = { path = "crates/automation", version = "0.8.0", default-features = false } +pdfcraft-geom = { path = "crates/geom", version = "0.11.0" } +pdfcraft-filters = { path = "crates/filters", version = "0.11.0" } +pdfcraft-cos = { path = "crates/cos", version = "0.11.0" } +pdfcraft-crypt = { path = "crates/crypt", version = "0.11.0" } +pdfcraft-organize = { path = "crates/organize", version = "0.11.0" } +pdfcraft-measure = { path = "crates/measure", version = "0.11.0" } +pdfcraft-annot = { path = "crates/annot", version = "0.11.0" } +pdfcraft-audit = { path = "crates/audit", version = "0.11.0" } +pdfcraft-fonts = { path = "crates/fonts", version = "0.11.0" } +pdfcraft-model = { path = "crates/model", version = "0.11.0" } +pdfcraft-content = { path = "crates/content", version = "0.11.0" } +pdfcraft-redact = { path = "crates/redact", version = "0.11.0" } +pdfcraft-print = { path = "crates/print", version = "0.11.0" } +pdfcraft-xfdf = { path = "crates/xfdf", version = "0.11.0" } +pdfcraft-sign = { path = "crates/sign", version = "0.11.0" } +pdfcraft-optimize = { path = "crates/optimize", version = "0.11.0" } +pdfcraft-a11y = { path = "crates/a11y", version = "0.11.0" } +pdfcraft-oracle = { path = "crates/oracle", version = "0.11.0" } +pdfcraft-testkit = { path = "crates/testkit", version = "0.11.0" } +pdfcraft-ocr = { path = "crates/ocr", version = "0.11.0" } +pdfcraft-js = { path = "crates/js", version = "0.11.0" } +pdfcraft-compare = { path = "crates/compare", version = "0.11.0" } +pdfcraft-preflight = { path = "crates/preflight", version = "0.11.0" } +pdfcraft-export = { path = "crates/export", version = "0.11.0" } +pdfcraft-edit = { path = "crates/edit", version = "0.11.0" } +pdfcraft-create = { path = "crates/create", version = "0.11.0" } +pdfcraft-forms = { path = "crates/forms", version = "0.11.0" } +pdfcraft-xfa = { path = "crates/xfa", version = "0.11.0" } +pdfcraft-render = { path = "crates/render", version = "0.11.0" } +pdfcraft-engine = { path = "crates/engine", version = "0.11.0" } +pdfcraft-platform = { path = "crates/platform", version = "0.11.0" } +pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.11.0" } +pdfcraft-automation = { path = "crates/automation", version = "0.11.0", default-features = false } # pdf hayro = "0.7.1" @@ -66,6 +68,8 @@ serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" log = "0.4" kurbo = "0.13" +# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md). +chrono = { version = "0.4.45", default-features = false, features = ["std"] } # testing proptest = "1" @@ -110,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 bd33ad23b..d39bec5aa 100644 --- a/README.md +++ b/README.md @@ -8,19 +8,26 @@

+

+ PdfCraft icon: an engraved lion on a teal tile +

+

PdfCraft

- The PDF workbench; an open-source, clean-room reimplementation of Adobe Acrobat, rebuilt in pure Rust.
+ The PDF workbench; an open-source, modern PDF reader and editor, built in pure Rust.
Read, organize, combine, split and secure PDFs in a fast, native app, written in Rust from the ground up.
macOS · Windows · Linux · FreeBSD · the web

- License: MIT OR Apache-2.0 - Written in Rust - Platforms: macOS, Windows, Linux, FreeBSD, web - No account, no telemetry + License: MIT OR Apache-2.0 + Written in Rust + Platforms: macOS, Windows, Linux, FreeBSD, web + No account, no telemetry +
+ Scanned by SonarCloud + Reviewed by CodeRabbit

@@ -137,6 +144,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`. + @@ -417,16 +426,16 @@ Logs, environment variables and other development notes are in [docs/development ## What's next -PdfCraft is young and moving fast. The aim is a workbench where you can view, organize, annotate, fill, sign and edit PDFs, at parity with Acrobat Pro. +PdfCraft is young and moving fast. The aim is a workbench where you can view, organize, annotate, fill, sign and edit PDFs, for professional and everyday workflows. -**Where it stands (October 2026), honestly:** PdfCraft is **alpha**. Most of Acrobat Pro's offline features exist in some form (57% of all tracked features, 94% of the must-haves). For everyday work (viewing, commenting, filling and signing forms, organizing pages) it is about 55% of the way to replacing Acrobat; against all of Acrobat Pro, about 49%; for someone who only needs the essentials, about 66%. Every everyday workflow can be completed, but converting and sharing is weak: no Office-to-PDF, and Word export that loses layout. +**Where it stands (October 2026), honestly:** PdfCraft is **alpha**. Most essential offline PDF features exist in some form (57% of the features tracked in [docs/target-app-parity.md](docs/target-app-parity.md), 94% of core essentials). For everyday work (viewing, commenting, filling and signing forms, organizing pages) readiness is about 55%; across all professional workflows, about 49%; for someone who only needs the essentials, about 66%. Every everyday workflow can be completed, but converting and sharing is weak: no office-document-to-PDF, and .docx export that loses layout. - **Good today:** viewing and search; organizing, combining and splitting; most kinds of comment; filling and authoring forms (with sandboxed JavaScript); passwords, redaction and sanitizing; basic digital signatures; printing; the Accessibility Checker; agent control through the CLI and MCP. - **Still borrowed:** pages are drawn by the `hayro` crate while our own renderer is built. -- **Thin or missing:** reliable editing of existing text (especially CJK and right-to-left), OCR beyond Latin script, Office import/export, signature tokens and online long-term validation, Preflight and PDF/X/UA, printing from the web build, physical printer fidelity, and full XFA forms. -- **Hardening:** fuzzing still turns up crashes and hangs on hostile files; each one is fixed with a regression test. Quality has not yet been compared with Acrobat side by side. +- **Thin or missing:** reliable editing of existing text (especially CJK and right-to-left), OCR beyond Latin script, office document import/export, signature tokens and online long-term validation, Preflight and PDF/X/UA, printing from the web build, physical printer fidelity, and full XFA forms. +- **Hardening:** fuzzing still turns up crashes and hangs on hostile files; each one is fixed with a regression test. Quality has not yet been benchmarked side by side. -**Next, in order:** Office-to-PDF and a Word export that keeps layout, the reported bugs and the Chinese interface font, a fidelity harness against Acrobat and the renderer, editing existing content, then the Pro workflows (OCR, Office, Preflight, signatures, accessibility tagging) and 1.0 polish. +**Next, in order:** office-document-to-PDF and a .docx export that keeps layout, the reported bugs and the Chinese interface font, a fidelity harness against a reference renderer, editing existing content, then professional workflows (OCR, office formats, Preflight, signatures, accessibility tagging) and 1.0 polish. The summary is in **[ROADMAP.md](ROADMAP.md)**; the full assessment by dimension and area is in [docs/target-app-parity.md](docs/target-app-parity.md), the ranked gaps in [docs/gaps.md](docs/gaps.md), and the milestones in [docs/roadmap.md](docs/roadmap.md). @@ -581,8 +590,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif part of this repository and PdfCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). Forks and modified versions must remove them. -Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. PdfCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with. -

ArtCraft
Made by the ArtCraft team and community. diff --git a/ROADMAP.md b/ROADMAP.md index fe9723fdf..bc057209c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,10 +2,36 @@ **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away +> **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) +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M4, #1919: repeated page indices accumulate rotations; M3, #3149: discard render demand after page geometry changes and check the GPU texture limit before upload; estimates unchanged) +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** minor (M8, #1350: named replacement descriptions retired during Sanitize and across shared resources; estimates unchanged) +> **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (M4 #2997: Ctrl-drag (Option on macOS) in the Organize grid copies the pages into the drop gap; `page_duplicate` takes `at`; 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: AcroForm XML text; estimates unchanged) +> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M2/gap 12: the Annex D.2 alias codes read as the ink they draw in both tables; the text oracle scores reading order; 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 (M14/#2383 GPU-limit candidate; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M4: indirect page rotations; M13: CLI check visits each directory once through aliases and cycles; 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 (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) @@ -26,6 +52,13 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (#594 paragraph drags validated in the Linux test environment; 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 (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 @@ -117,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 | @@ -136,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 @@ -156,15 +189,116 @@ 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. + +- **2026-10-18 (M4, PR #1919 review):** repeated page indices apply each rotation to the latest page-local value, preserving indirect/inherited angles. Synthetic adjacent/interleaved duplicates retain siblings and page contents through incremental and full saves. Estimates unchanged. +- **2026-10-15 (M4, #1919 refresh):** Integrated upstream `99941daa` while retaining the indirect page rotation correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged. +- **2026-10-18 (M3, #3149):** Page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. +- **2026-10-19 (M3, #3360):** Switching between Read and the docking workspace retains the current document’s scroll position at fixed zoom, including within-page position; separate tabs retain their own offsets. Estimates unchanged. +- **2026-10-18 (M8, #1350):** Integrated current main while preserving content-stream comment boundaries. Hidden-text cleanup retires unused named replacement descriptions on rewritten pages and forms, including inherited/shared resource holders, without removing descriptions still used by surviving content. Synthetic saved-byte and preservation regressions cover the cleanup. Estimates unchanged. +- **2026-10-17 (M4, #2997):** Dragging page thumbnails in the Organize grid with Ctrl held (Option on macOS) drops copies at the gap instead of moving the pages: a "+" badge sits by the pointer, the copies land at the gap selected, and one undo step ("Duplicate pages") takes them out again. `page_duplicate` takes `at` (1-based, where the first copy goes; left out, after the last of them) so agents can drive the same thing, and the UI control channel's `ui.drag` modifiers now reach gestures that read the input state. Estimates unchanged. +- **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-15 (M6, #1917 refresh):** Integrated upstream `99941daa` while retaining the XML/XFDF attribute whitespace correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged. +- **2026-10-15 (M1, #2267 refresh):** Integrated upstream `99941daa` while retaining the raw-deflate fallback correction and all upstream progress. Only independent documentation conflicts required resolution; focused production and test files are unchanged from the previously green CI head. Estimates unchanged. +- **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. +- **2026-10-19 (M12, XFA, #2574):** The Acrobat observations behind the line above were first made on a public real-world form, which the Privacy rule does not allow; they are replaced by ones made on a synthetic form (three `breakBefore pageArea` sections, then a page area with `occur min="1" max="1"`, with and without `relevant="-print"`). Acrobat Pro shows the closing page last in both and prints it in both, blank only with `relevant="-print"`: confirmed. The synthetic form also shows a difference not fixed yet: Acrobat opens it as 5 pages, with an empty first body page before the first section, where PdfCraft lays out 4. Two more synthetic forms narrow it down: with `breakBefore targetType="pageArea"` on the first section, with or without `startNew`, Acrobat opens an empty body page first; without a break on the first section it lays out 4, as PdfCraft does (so `startNew` is not the cause). Not fixed yet: `layout.rs` skips a break while the page is still empty. Estimates unchanged. +- **2026-10-15 (M8, #2447):** Removing links or comments (Remove Hidden Information, Sanitize) also drops the tagged-PDF structure tree's object references to them, so the full rewrite no longer keeps a removed link's URL or a comment's text. Regression seen to fail without the fix. Estimates unchanged. +- **2026-10-15 (M6, CSV tab-containing headers):** CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Synthetic export/import and incremental/full save regressions cover header tabs, quotes and line breaks, plus ordinary TSV controls. Formula-cell policy is unchanged. Estimates unchanged. +- **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. +- **2026-10-13 (M4, indirect page rotation):** Rotation queries and edits resolve indirect integer values on pages and their page-tree ancestors. Turning a page already at 270° by 90° now saves it at 0°, including when its rotation is inherited. Synthetic full/incremental save-and-reopen tests cover nested inheritance, sibling preservation, negative/large angles and invalid-value fallback. Estimates unchanged. +- **2026-10-14 (M1, Flate fallback):** Raw deflate whose first bytes resemble a zlib header no longer loses a decoding-limit error during fallback. Strict and tolerant filter regressions and a COS stream regression cover refusal at the caller limit and successful decoding within it. No parity status or estimate changes. - **2026-10-12 (M6, XML field names):** AcroForm XML data export constrains generated element names to XML 1.0 NCNames, so Latin-1 names such as `Area²` and `Aµ` produce readable data files. Original PDF field names travel through the existing `xfdf:original` attribute; previously valid tags, including Greek and CJK, keep their spelling. Saved-PDF regressions cover invalid prefixes, combining letters and normalized-name collisions. Estimates unchanged. - **2026-10-12 (M6, CSV carriage returns):** Form-data CSV export quotes carriage returns in field names and values, so a trailing carriage return cannot turn into a record separator and disappear on reimport. Saved-PDF round-trip regressions cover CR, LF, CRLF, commas and quotes; estimates unchanged. - **2026-10-13 (community, M14, #1553 review):** Relative Fontconfig includes resolve beside each including config, including nested configuration selected through `FONTCONFIG_PATH`. Directory includes accept only digit-prefixed `*.conf` in lexical order. Isolated regressions reproduce both review findings; configuration size/count/depth limits, cycles, symlinked config and valid reset ordering are covered. Estimates unchanged. @@ -173,6 +307,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-12 (M14, #1395):** XDG desktops use the configuration base for app.ron, safely copying existing preferences without overwriting a destination. Original preferences, logs, recovery and digital IDs are retained; failed migration falls back to the original location. Portable and non-XDG locations remain unchanged; estimates unchanged. - **2026-10-12 (M10, Windows print progress, follow-up to #944):** While a job spools, the progress card counts its sheets ("Sending sheet 2 of 3 to Office…") as craft-print reports them (`submit_with_progress`, storytold/craft-libs#27), and steps aside for another long job's card. Pins craft-libs 666c358. Estimates unchanged. +- **2026-10-15 (M2, renderer speed):** Three vendored hayro-interpret patches (31–33), pixel for pixel the same over the first three pages of every pdf.js corpus file. A sampled (Type 0) function keeps its samples in one flat array instead of a hash map rebuilt every time a shading using it is painted: `bug1721218` (InDesign) draws in 0.49 s (was 1.34 s), and a 2,097,152-sample ramp painted 30 times in under a second (was 38 s). One decode of a JPEG or Flate image serves every size it is drawn at (only JPX decodes for a size), so zooming a 300-dpi scan in and out costs 35–40% less render work. Coons and tensor patch meshes copy their vertices' colours less: mesh-gradient pages draw 10–16% faster (page 8 of #1584's annual report 773 → 696 ms). Estimates unchanged. - 2026-10-12 · M3: selected page text can be copied from Edit and the canvas context menu (#1225). Five focused regressions, full CI and native Unicode clipboard checks pass; floating Copy shares the document permission gate. Estimates unchanged. - **2026-10-13 (M2, page-label prefixes):** Preserve literal leading and trailing whitespace in page-label prefixes without changing generic metadata normalization. Nine original public-inspection cases cover prefix spaces, encodings and empty-prefix controls. Existing empty-prefix fallback remains a compatibility policy, not a conformance claim. Estimates unchanged. @@ -201,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. @@ -283,6 +425,7 @@ Newest first: the date, what moved, and the new overall estimate. - **2026-10-11 (M5, partial comment imports):** Replacing an editable named comment through XFDF or FDF (including an indirect name in the source PDF) preserves its object identity, so existing replies and review statuses remain attached after saving, reopening and exporting again. Replaced pop-ups are still removed. Locked comments, redaction marks and their pop-ups stay unchanged, including when their names are indirect; imported redactions remain visible. Synthetic round-trip tests cover both formats, incremental and full saves; milestone and effort estimates unchanged. +- **2026-10-15 (M9, #1895):** Preserve original signature artwork and show validation through a separate page overlay. Generic in-place appearance rewriting is deferred: neither a caption substring nor a unique drawing establishes an icon role. Signed regressions cover chamfered borders, mixed signer/caption text, image/text exports, both print modes and saves. Snapshot reuse and trust/cache refresh remain. Baked Not Verified captions are not replaced; #79 remains open. Estimates unchanged. - **2026-10-10 (M9, signature graphics):** A visible digital signature can show a graphic where the signer's name is large, as Acrobat's Configure Signature Appearance does: the saved Fill & Sign signature (drawn strokes smoothed as ink, typed outlines in the script font, or an image) or an imported PNG/JPEG. Sign as previews it and remembers the choice for the next signing; `sign_document` takes `graphic` (`image`, `text` or `strokes`). Graphics are checked and capped before drawing (non-finite or unbounded points, mismatched pixel buffers, oversized images are refused with an error the caller can act on); images keep their alpha as a soft mask. `sign.signature-appearance` stays partial (no named saved appearances or PDF-page graphics). Overall estimates unchanged. - **2026-10-11 (M14, absolute interface sizes):** A chosen Interface size is now absolute, as PhotoCraft's UI Scale is: "100%" is one screen pixel per point whatever scale the display reports, so on a 1080p screen whose system reports 200 % it draws at a true 100 % (#456, reported after the first change). Auto still keeps the display's scale and adds GNOME's text scaling, and its menu entry shows the size it draws at. The menu offers PhotoCraft's sizes (75–300 %). - **2026-10-11 (automation, #971):** `text_find` reads pages in growing batches (8, doubling to 128) and stops once `limit` is reached, so a bounded search on a long document no longer extracts and caches every page first. Results say `complete`, and a capped one gives `next` (`start_page`, `skip`) to continue. Still open from #971: a byte budget for the automation text cache and per-page warnings for pages whose text can't be extracted. @@ -488,8 +631,27 @@ Newest first: the date, what moved, and the new overall estimate. ## Revision history +**2026-10-15 · minor:** M14/#2383 candidate: GPU device requests clamp unsupported limits while retaining the adapter texture extent. Synthetic V3D/downlevel regressions and Windows GPU-required tests pass; Raspberry Pi hardware remains unverified. Estimates unchanged. + | 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. | +| 2026-10-18 | minor | M4, PR #1919 review: repeated page indices apply each rotation to the latest page-local value, preserving indirect/inherited angles. Synthetic adjacent/interleaved duplicates retain siblings and page contents through incremental and full saves. Estimates unchanged. | +| 2026-10-18 | minor | M3, #3149: page replacement cancels render demand collected for the previous geometry; all viewer raster upload paths reject empty or oversized textures before cache eviction. Synthetic regressions reproduce the reported 1880×2350 upload at a 2048 limit, retain cached images and unsaved edits, and resume bounded backdrops and tiles. Estimates unchanged. | +| 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. | +| 2026-10-13 | minor | M4: rotation queries and edits resolve indirect page-tree values; inherited, negative and large rotations verified through both saves. Estimates unchanged. | | 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. | | 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. | | 2026-10-13 | minor | M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. Estimates unchanged. | @@ -510,7 +672,15 @@ Newest first: the date, what moved, and the new overall estimate. | 2026-10-10 | major | Restructured to the progress-docs standard (stage, two numbers, dimensions, features, languages, upcoming, log); full re-measure; assessment, gaps and milestones moved to `docs/`; log sorted newest first and a stray merged entry dated | | 2026-10-07 | minor | 806 tracked features, 51.0% shipped, effort-weighted ≈ 30–35%, 600–1,100 h | | 2026-10-05 | major | Honest assessment and "Where we're lacking" added | +| 2026-10-12 | minor | M8: cleanup removes replacement descriptions only from changed marked-content spans, preserving surviving accessibility text. Estimates unchanged. | | 2026-10-12 | minor | M8: image redaction removes alternate images and unsupported masks only on affected images. Estimates unchanged. | - **2026-10-12 (M8):** Redaction cleans indirect and form structure references, preserving surviving form tags. - **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 d7b52fbd6..7b5f9a82f 100644 --- a/apps/pdfcraft-cli/Cargo.toml +++ b/apps/pdfcraft-cli/Cargo.toml @@ -18,10 +18,15 @@ serde_json = { workspace = true } pdfcraft-engine = { workspace = true } pdfcraft-audit = { workspace = true } pdfcraft-cos = { workspace = true } +pdfcraft-preflight = { workspace = true } pdfcraft-automation = { workspace = true } pdfcraft-platform = { workspace = true } base64 = "0.22" +[dev-dependencies] +# Decodes `render` output in tests/it/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 0e24800d3..14500a384 100644 --- a/apps/pdfcraft-cli/src/main.rs +++ b/apps/pdfcraft-cli/src/main.rs @@ -57,6 +57,7 @@ macro_rules! mcp_usage { const USAGE: &str = concat!( "\ pdfcraft-cli info [--password PW] document summary as JSON + (includes any declared PDF/A or PDF/UA level) pdfcraft-cli render --page N [--dpi 96] --out x.png (.png, .jpg, .tif or .pam) pdfcraft-cli text [--page N] extracted text (pages separated by form feeds) pdfcraft-cli edit --out out.pdf [--rotate 1,3:90] [--delete 2,4] [--move 5:1] @@ -175,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| { @@ -298,29 +304,47 @@ 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 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). let xfa_layout = (info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) .then(|| { let mut s = pdfcraft_engine::Session::new(); let id = s.open("info.pdf", None, bytes.clone(), password).ok()?; let d = s.get(id)?; - d.xfa.as_ref().map(|x| serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings })) + d.xfa.as_ref().map(|x| { + info = d.info.clone(); + // The in-memory layout is an appended revision, not the file being inspected. + info.file_size = bytes.len(); + serde_json::json!({ "pages": x.pages, "fields": x.fields, "warnings": x.warnings }) + }) }) .flatten(); + // What the document declares as a standard, read the way the Standards panel reads it, so + // `info` and the app agree. A file that declares nothing leaves these null and empty. + let declared = + pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map(|doc| pdfcraft_preflight::declared(&doc)).unwrap_or_default(); let json = serde_json::json!({ "file": path, "pdf_version": info.pdf_version, "file_size": info.file_size, "pages": info.pages.len(), + "pdf_pages": pdf_pages, "acroform_fields": acroform_fields, "first_page_pt": info.pages.first().map(|p| [p.width, p.height]), "title": info.title, "author": info.author, "producer": info.producer, "creator": info.creator, "encrypted": info.encrypted, "tagged": info.tagged, "javascript": info.has_javascript, + "pdfa": declared.pdfa.as_ref().map(|(part, conformance)| format!("PDF/A-{part}{}", conformance.to_lowercase())), + "pdfua": declared.pdfua, + "output_intents": declared.output_intents, "xfa": info.xfa.map(|x| match x { pdfcraft_render::Xfa::Static => "static", pdfcraft_render::Xfa::Dynamic => "dynamic" }), "xfa_layout": xfa_layout, "bookmarks": info.outline.len(), "annotations": info.annotations.len(), "fields": info.fields.len(), "links": info.links.len(), "layers": info.layers.len(), "attachments": info.attachments.len(), "transitions": info.transitions.len(), + "custom_properties": info.custom.len(), "page_labels": info.pages.iter().take(8).map(|p| p.label.clone()).collect::>(), "warnings": info.warnings, }); @@ -348,25 +372,31 @@ fn text(args: &[String]) -> Result<(), CliError> { } } let password = flag(args, "--password"); - let bytes = read(path)?; - let mut r = PageRenderer::new(bytes.clone(), RenderConfig { password: password.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}"))?; - if !info.pages.is_empty() { - return Err("text: the document could not be parsed".into()); - } - } + 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(); - if let Some(doc) = session.open(path, None, bytes, password).ok().and_then(|id| session.get(id)) { + if let Some(doc) = session.open(path, None, bytes.clone(), password).ok().and_then(|id| session.get(id)) { // The engine's hint is about editing in the app; on the command line the way in is // `--password`. doc.check_copying() .map_err(|_| "text: the document's permissions don't allow copying its text; pass the owner password with --password to extract it")?; + if doc.xfa.is_some() { + bytes = doc.display.clone(); + } + } + 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(), 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()); + } } let pages: Vec = match selected_page { Some(page) => vec![page], @@ -458,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() }), @@ -550,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()), }; @@ -570,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() { @@ -588,16 +654,26 @@ fn render(args: &[String]) -> Result<(), CliError> { // The app renders a display copy that draws comments missing their /AP streams; the CLI // must agree with it (and with independent viewers), while keeping the file it reads intact. let bytes = read(path)?; - let mut r = PageRenderer::new( - pdfcraft_engine::display_bytes_for(bytes.clone(), flag(args, "--password")), - RenderConfig { password: flag(args, "--password").map(Arc::from), reject_oversize: true, ..Default::default() }, - ); + let password = flag(args, "--password"); + // 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: 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()); } let bytes = match format { - Some(f) => pdfcraft_engine::export::encode_image(p.width, p.height, &p.rgba, f)?, + // With `reject_oversize` the page is drawn at `--dpi` or not at all, so the file can say so. + Some(f) => pdfcraft_engine::export::encode_image(p.width, p.height, &p.rgba, f, f64::from(dpi))?, None => { // PAM (netpbm RGB_ALPHA): the raw premultiplied pixels, for tools that read it. let mut pam = format!("P7\nWIDTH {}\nHEIGHT {}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n", p.width, p.height).into_bytes(); @@ -621,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) { @@ -713,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/audit.rs b/apps/pdfcraft-cli/tests/it/audit.rs similarity index 100% rename from apps/pdfcraft-cli/tests/audit.rs rename to apps/pdfcraft-cli/tests/it/audit.rs diff --git a/apps/pdfcraft-cli/tests/automation_roots.rs b/apps/pdfcraft-cli/tests/it/automation_roots.rs similarity index 100% rename from apps/pdfcraft-cli/tests/automation_roots.rs rename to apps/pdfcraft-cli/tests/it/automation_roots.rs diff --git a/apps/pdfcraft-cli/tests/batch.rs b/apps/pdfcraft-cli/tests/it/batch.rs similarity index 100% rename from apps/pdfcraft-cli/tests/batch.rs rename to apps/pdfcraft-cli/tests/it/batch.rs diff --git a/apps/pdfcraft-cli/tests/check.rs b/apps/pdfcraft-cli/tests/it/check.rs similarity index 79% rename from apps/pdfcraft-cli/tests/check.rs rename to apps/pdfcraft-cli/tests/it/check.rs index a206df591..7a6236419 100644 --- a/apps/pdfcraft-cli/tests/check.rs +++ b/apps/pdfcraft-cli/tests/it/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/it/edit.rs similarity index 88% rename from apps/pdfcraft-cli/tests/edit.rs rename to apps/pdfcraft-cli/tests/it/edit.rs index b35b71ddd..3cd1442d8 100644 --- a/apps/pdfcraft-cli/tests/edit.rs +++ b/apps/pdfcraft-cli/tests/it/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/extract.rs b/apps/pdfcraft-cli/tests/it/extract.rs similarity index 100% rename from apps/pdfcraft-cli/tests/extract.rs rename to apps/pdfcraft-cli/tests/it/extract.rs diff --git a/apps/pdfcraft-cli/tests/help.rs b/apps/pdfcraft-cli/tests/it/help.rs similarity index 100% rename from apps/pdfcraft-cli/tests/help.rs rename to apps/pdfcraft-cli/tests/it/help.rs diff --git a/apps/pdfcraft-cli/tests/it/info_standards.rs b/apps/pdfcraft-cli/tests/it/info_standards.rs new file mode 100644 index 000000000..68be86228 --- /dev/null +++ b/apps/pdfcraft-cli/tests/it/info_standards.rs @@ -0,0 +1,87 @@ +//! `info` reports the standard a document declares, as the Standards panel does (#3319). A file +//! carrying `pdfaid:part` and `pdfaid:conformance` in its XMP metadata was summarised with no +//! mention of PDF/A at all, though the app reads the same declaration and shows it. + +use std::process::Command; + +const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli"); + +/// A one-page PDF, with `metadata` as the catalog's XMP stream when given. +fn fixture(metadata: Option<&str>) -> Vec { + let content = b"0 0 0 rg 10 10 40 20 re f"; + let catalog = match metadata { + Some(_) => "<< /Type /Catalog /Pages 2 0 R /Metadata 5 0 R >>".to_string(), + None => "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + }; + let mut objs: Vec> = vec![ + catalog.into_bytes(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 60] /Contents 4 0 R >>".to_vec(), + format!("<< /Length {} >> stream\n", content.len()).into_bytes(), + ]; + objs[3].extend_from_slice(content); + objs[3].extend_from_slice(b"\nendstream"); + if let Some(xmp) = metadata { + let mut stream = format!("<< /Type /Metadata /Subtype /XML /Length {} >> stream\n", xmp.len()).into_bytes(); + stream.extend_from_slice(xmp.as_bytes()); + stream.extend_from_slice(b"\nendstream"); + objs.push(stream); + } + 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 off in &offsets { + out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +fn xmp(part: &str, conformance: &str) -> String { + format!( + "\n\ +\n \n\ + \n\ + {part}\n {conformance}\n\ + \n \n\n" + ) +} + +fn info_of(bytes: &[u8], name: &str) -> serde_json::Value { + let dir = std::env::temp_dir().join(format!("pdfcraft-info-standards-{}-{name}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("f.pdf"); + std::fs::write(&path, bytes).unwrap(); + let out = Command::new(BIN).args(["info", path.to_str().unwrap()]).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let json = serde_json::from_slice(&out.stdout).expect("info prints JSON"); + let _ = std::fs::remove_dir_all(&dir); + json +} + +#[test] +fn info_names_the_declared_pdfa_level() { + let declared = info_of(&fixture(Some(&xmp("2", "B"))), "pdfa2b"); + assert_eq!(declared["pdfa"], serde_json::json!("PDF/A-2b"), "{declared}"); + assert_eq!(declared["pdfua"], serde_json::Value::Null); + assert_eq!(declared["output_intents"], serde_json::json!([])); + // Lower-case conformance in the file still reads as the level's own spelling. + assert_eq!(info_of(&fixture(Some(&xmp("3", "a"))), "pdfa3a")["pdfa"], serde_json::json!("PDF/A-3a")); +} + +#[test] +fn info_declares_nothing_for_a_plain_pdf() { + let plain = info_of(&fixture(None), "plain"); + assert_eq!(plain["pdfa"], serde_json::Value::Null, "{plain}"); + assert_eq!(plain["pdfua"], serde_json::Value::Null); + // The rest of the summary is unaffected. + assert_eq!(plain["pages"], serde_json::json!(1)); + assert_eq!(plain["pdf_version"], serde_json::json!("1.7")); +} diff --git a/apps/pdfcraft-cli/tests/it/main.rs b/apps/pdfcraft-cli/tests/it/main.rs new file mode 100644 index 000000000..f0b87c97a --- /dev/null +++ b/apps/pdfcraft-cli/tests/it/main.rs @@ -0,0 +1,24 @@ +//! The crate's integration tests, linked into one test binary. + +mod audit; +mod automation_roots; +mod batch; +mod check; +mod edit; +mod extract; +mod help; +mod info_standards; +mod mcp_compact; +mod options; +mod owner_password; +mod render; +mod root_arguments; +mod run_report; +mod run_root; +mod split; +mod stderr; +mod stdout; +mod text; +mod ui_control; +mod version_resource; +mod xfa; diff --git a/apps/pdfcraft-cli/tests/mcp_compact.rs b/apps/pdfcraft-cli/tests/it/mcp_compact.rs similarity index 100% rename from apps/pdfcraft-cli/tests/mcp_compact.rs rename to apps/pdfcraft-cli/tests/it/mcp_compact.rs diff --git a/apps/pdfcraft-cli/tests/options.rs b/apps/pdfcraft-cli/tests/it/options.rs similarity index 100% rename from apps/pdfcraft-cli/tests/options.rs rename to apps/pdfcraft-cli/tests/it/options.rs diff --git a/apps/pdfcraft-cli/tests/it/owner_password.rs b/apps/pdfcraft-cli/tests/it/owner_password.rs new file mode 100644 index 000000000..679698098 --- /dev/null +++ b/apps/pdfcraft-cli/tests/it/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/it/render.rs similarity index 59% rename from apps/pdfcraft-cli/tests/render.rs rename to apps/pdfcraft-cli/tests/it/render.rs index b0ab24560..561f9fa78 100644 --- a/apps/pdfcraft-cli/tests/render.rs +++ b/apps/pdfcraft-cli/tests/it/render.rs @@ -56,6 +56,17 @@ fn jpeg_and_tiff_names_get_those_formats() { assert!(bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*"), "not a TIFF: {:?}", &bytes[..4]); } +#[test] +fn the_file_records_the_dpi_it_was_rendered_at() { + // The engine's tests check each format; this checks that `--dpi` reaches the file. + let pdf = fixture("dpi"); + let jpg = tmp("dpi", "p1.jpg"); + assert!(render(&pdf, &jpg).status.success()); + let bytes = std::fs::read(&jpg).unwrap(); + assert_eq!(&bytes[6..11], b"JFIF\0"); + assert_eq!(bytes[13..18], [1, 0, 36, 0, 36], "JFIF density in dots per inch"); +} + #[test] fn a_pam_name_still_gets_the_netpbm_stream() { let pdf = fixture("pam"); @@ -77,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/root_arguments.rs b/apps/pdfcraft-cli/tests/it/root_arguments.rs similarity index 100% rename from apps/pdfcraft-cli/tests/root_arguments.rs rename to apps/pdfcraft-cli/tests/it/root_arguments.rs diff --git a/apps/pdfcraft-cli/tests/run_report.rs b/apps/pdfcraft-cli/tests/it/run_report.rs similarity index 100% rename from apps/pdfcraft-cli/tests/run_report.rs rename to apps/pdfcraft-cli/tests/it/run_report.rs diff --git a/apps/pdfcraft-cli/tests/run_root.rs b/apps/pdfcraft-cli/tests/it/run_root.rs similarity index 100% rename from apps/pdfcraft-cli/tests/run_root.rs rename to apps/pdfcraft-cli/tests/it/run_root.rs diff --git a/apps/pdfcraft-cli/tests/split.rs b/apps/pdfcraft-cli/tests/it/split.rs similarity index 75% rename from apps/pdfcraft-cli/tests/split.rs rename to apps/pdfcraft-cli/tests/it/split.rs index 7a4a254f8..71523191d 100644 --- a/apps/pdfcraft-cli/tests/split.rs +++ b/apps/pdfcraft-cli/tests/it/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-cli/tests/stderr.rs b/apps/pdfcraft-cli/tests/it/stderr.rs similarity index 100% rename from apps/pdfcraft-cli/tests/stderr.rs rename to apps/pdfcraft-cli/tests/it/stderr.rs diff --git a/apps/pdfcraft-cli/tests/stdout.rs b/apps/pdfcraft-cli/tests/it/stdout.rs similarity index 100% rename from apps/pdfcraft-cli/tests/stdout.rs rename to apps/pdfcraft-cli/tests/it/stdout.rs diff --git a/apps/pdfcraft-cli/tests/text.rs b/apps/pdfcraft-cli/tests/it/text.rs similarity index 100% rename from apps/pdfcraft-cli/tests/text.rs rename to apps/pdfcraft-cli/tests/it/text.rs diff --git a/apps/pdfcraft-cli/tests/ui_control.rs b/apps/pdfcraft-cli/tests/it/ui_control.rs similarity index 100% rename from apps/pdfcraft-cli/tests/ui_control.rs rename to apps/pdfcraft-cli/tests/it/ui_control.rs diff --git a/apps/pdfcraft-cli/tests/version_resource.rs b/apps/pdfcraft-cli/tests/it/version_resource.rs similarity index 100% rename from apps/pdfcraft-cli/tests/version_resource.rs rename to apps/pdfcraft-cli/tests/it/version_resource.rs diff --git a/apps/pdfcraft-cli/tests/it/xfa.rs b/apps/pdfcraft-cli/tests/it/xfa.rs new file mode 100644 index 000000000..1b5ff7bc9 --- /dev/null +++ b/apps/pdfcraft-cli/tests/it/xfa.rs @@ -0,0 +1,136 @@ +//! CLI page commands must use the same dynamic XFA layout as the app and tools (#1645). + +use std::path::PathBuf; +use std::process::{Command, Output}; +use std::sync::Arc; + +const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli"); + +fn fixture(test: &str) -> PathBuf { + let xdp = r#" +"#; + let objects = [ + "<< /Type /Catalog /Pages 2 0 R /NeedsRendering true /AcroForm << /XFA 5 0 R /Fields [] >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R >>".into(), + "<< /Length 0 >>\nstream\n\nendstream".into(), + format!("<< /Length {} >>\nstream\n{xdp}\nendstream", xdp.len()), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(b"xref\n0 6\n0000000000 65535 f \n"); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + let dir = std::env::temp_dir().join(format!("pdfcraft-cli-xfa-{test}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("form.pdf"); + std::fs::write(&path, bytes).unwrap(); + path +} + +fn success(output: &Output) { + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); +} + +#[test] +fn info_reports_the_laid_out_pages_and_fields() { + let path = fixture("info"); + let original = std::fs::read(&path).unwrap(); + let output = Command::new(BIN).arg("info").arg(&path).output().unwrap(); + success(&output); + let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(info["xfa_layout"]["pages"], 2, "the synthetic form lays out into two pages"); + assert_eq!(info["pages"], 2, "top-level page count must agree with the app"); + assert_eq!(info["fields"], 1); + assert_eq!(info["pdf_pages"], 1); + assert_eq!(info["acroform_fields"], 0); + assert_eq!(info["first_page_pt"], serde_json::json!([200.0, 300.0])); + assert_eq!(info["file_size"], original.len()); + assert_eq!(std::fs::read(path).unwrap(), original, "inspection must not write the layout to the input"); +} + +#[test] +fn text_reads_the_layout_including_a_page_absent_from_the_placeholder() { + let path = fixture("text"); + let output = Command::new(BIN).arg("text").arg(&path).output().unwrap(); + success(&output); + let text = String::from_utf8(output.stdout).unwrap(); + assert!(text.contains("First XFA page") && text.contains("Second XFA page"), "{text:?}"); + let output = Command::new(BIN).arg("text").arg(&path).args(["--page", "2"]).output().unwrap(); + success(&output); + let text = String::from_utf8(output.stdout).unwrap(); + assert!(text.contains("Second XFA page") && !text.contains("First XFA page"), "{text:?}"); +} + +#[test] +fn render_matches_the_engine_layout_on_the_second_page() { + use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind}; + let path = fixture("render"); + let original = std::fs::read(&path).unwrap(); + let mut session = pdfcraft_engine::Session::new(); + let id = session.open("form.pdf", None, Arc::new(original.clone()), None).unwrap(); + let doc = session.get(id).unwrap(); + assert_eq!(doc.info.pages.len(), 2); + assert!(doc.text_lines(1).iter().any(|line| line.text.contains("Second XFA page"))); + let mut renderer = PageRenderer::new(doc.display.clone(), RenderConfig::default()); + let expected = renderer.render(RenderRequest { page: 1, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + assert!(expected.error.is_none(), "{:?}", expected.error); + assert!(expected.rgba.as_chunks::<4>().0.iter().filter(|[r, _, _, a]| *r < 128 && *a > 0).count() > 10, "the layout has visible ink"); + let out = path.with_extension("pam"); + let output = Command::new(BIN).arg("render").arg(&path).args(["--page", "2", "--dpi", "72", "--out"]).arg(&out).output().unwrap(); + success(&output); + let actual = std::fs::read(out).unwrap(); + let header = format!("P7\nWIDTH {}\nHEIGHT {}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n", expected.width, expected.height); + assert!(actual.starts_with(header.as_bytes())); + assert_eq!(&actual[header.len()..], &expected.rgba[..]); + assert_eq!(std::fs::read(path).unwrap(), original); +} + +#[test] +fn a_laid_out_form_keeps_the_original_copy_permissions() { + let path = fixture("permissions"); + let mut doc = pdfcraft_cos::Document::open(Arc::new(std::fs::read(&path).unwrap())).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "openme", + owner_password: "boss", + permissions: -17, + encrypt_metadata: true, + seed: [17; 32], + }) + .unwrap(); + let original = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(); + std::fs::write(&path, &original).unwrap(); + let output = Command::new(BIN).arg("text").arg(&path).args(["--password", "openme"]).output().unwrap(); + assert!(!output.status.success()); + assert!(output.stdout.is_empty()); + assert!(String::from_utf8_lossy(&output.stderr).contains("don't allow copying")); + let output = Command::new(BIN).arg("text").arg(&path).args(["--password", "boss", "--page", "2"]).output().unwrap(); + success(&output); + assert!(String::from_utf8_lossy(&output.stdout).contains("Second XFA page")); + let output = Command::new(BIN).arg("info").arg(&path).args(["--password", "openme"]).output().unwrap(); + success(&output); + let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(info["pages"], 2); + assert_eq!(info["encrypted"], true); + assert_eq!(info["file_size"], original.len()); + assert_eq!(std::fs::read(path).unwrap(), original); +} diff --git a/apps/pdfcraft-web/src/main.rs b/apps/pdfcraft-web/src/main.rs index a46a4674b..73d14f4a6 100644 --- a/apps/pdfcraft-web/src/main.rs +++ b/apps/pdfcraft-web/src/main.rs @@ -70,6 +70,10 @@ fn main() { app.host_save = Some(bridge.save_fn()); app.host_dirty = Some(bridge.dirty_fn()); bridge.post_ready(); + } else if query_param("host").as_deref() == Some("parent") { + eframe::web_sys::console::warn_1( + &"PdfCraft: ?host=parent needs a framing page on the same origin; saves download instead".into(), + ); } Ok(Box::new(app)) }), @@ -130,7 +134,8 @@ async fn fetch_bytes(url: &str) -> Result, String> { /// - `pdfcraft:dirty` (to the parent): `{ dirty }` whenever unsaved work appears or goes away. /// - `pdfcraft:failed` (to the parent): `{ error }` when the app couldn't start. /// -/// Only the parent window on the app's own origin is heard or answered. +/// Only the parent window on the app's own origin is heard or answered. With a parent on +/// another origin there is no bridge: the app runs as if unframed and saves download. #[cfg(target_arch = "wasm32")] mod host { use eframe::wasm_bindgen::{JsCast, JsValue, closure::Closure}; @@ -143,7 +148,9 @@ mod host { } impl Bridge { - /// `None` when the page isn't framed (there is no parent to talk to). + /// `None` when the page isn't framed (there is no parent to talk to), or when the parent + /// is on another origin: messages only go to this origin, so the browser would drop + /// every save without an error while the document showed as saved (#2167). pub fn new() -> Option { let window = web_sys::window()?; let parent = window.parent().ok().flatten()?; @@ -151,6 +158,10 @@ mod host { return None; } let origin = window.location().origin().ok()?; + // Reading another origin's location throws, which is `None` too. + if parent.location().origin().ok()? != origin { + return None; + } Some(Self { window, parent, origin }) } diff --git a/apps/pdfcraft/Cargo.toml b/apps/pdfcraft/Cargo.toml index a27401c70..6943ad6e4 100644 --- a/apps/pdfcraft/Cargo.toml +++ b/apps/pdfcraft/Cargo.toml @@ -23,6 +23,8 @@ egui = { workspace = true } serde_json = { workspace = true } # Records from every crate go to standard error and the log file (src/logging.rs). log = { workspace = true } +# The log timestamp goes through chrono (no hand-rolled calendar code). +chrono = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] # Finder double-click / Open With / Dock drops arrive as Apple events (#73); this small audited @@ -54,8 +56,9 @@ winit = { version = "0.30", default-features = false, features = ["wayland"] } [target.'cfg(target_os = "windows")'.dependencies] # Which GPU drives which display (EnumDisplayDevices), so the window is drawn on the GPU that -# shows it (issue #378). Safe wrappers over Win32; only the `user` feature (which brings `kernel` with it). -winsafe = { version = "0.0.29", default-features = false, features = ["user"] } +# shows it (issue #378). Safe wrappers over Win32; only the `user` feature (which brings `kernel` with it), +# and `advapi` for the session id that names the single-instance socket (#3338). +winsafe = { version = "0.0.29", default-features = false, features = ["user", "advapi"] } # Settings ▸ Accessibility ▸ Visual effects ▸ Animation effects (src/motion.rs), through WinRT's # `UISettings`, whose bindings are safe to call (winsafe's `SystemParametersInfo` is an unsafe # fn). Already in the tree through wgpu's DX12 backend; this adds one small namespace to it. diff --git a/apps/pdfcraft/src/logging.rs b/apps/pdfcraft/src/logging.rs index 71ed1d2d2..bef27de16 100644 --- a/apps/pdfcraft/src/logging.rs +++ b/apps/pdfcraft/src/logging.rs @@ -90,7 +90,10 @@ fn matches(name: &str, target: &str) -> bool { } } -/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch. +/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch (the +/// `duration_since` clamp); past the last date chrono can represent (year 262143) the proleptic +/// Gregorian date is kept via the 400-year cycle rather than falling back to the epoch, so the +/// output never changes for a value the old formatter accepted and logging never panics. pub fn timestamp(t: SystemTime) -> String { let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); let secs = since.as_secs(); @@ -99,20 +102,25 @@ pub fn timestamp(t: SystemTime) -> String { format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis()) } -/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's -/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). `days` is at most -/// `u64::MAX / 86_400`, so none of the arithmetic below can overflow. -fn civil_from_days(days: u64) -> (u64, u64, u64) { - let z = days + 719_468; - let era = z / 146_097; - let doe = z % 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = yoe + era * 400 + u64::from(m <= 2); - (y, m, d) +/// Days since 1970-01-01 → proleptic Gregorian (year, month, day). +/// +/// `timestamp` clamps a pre-1970 clock to the epoch, so `days` is an unsigned count that can run +/// past `NaiveDate`'s ±262143-year range. The Gregorian calendar repeats every 400 years +/// (146_097 days), so chrono converts the day within one cycle and whole cycles go back into the +/// year (an `i128`, then saturated into `i64`), keeping chrono as the only calendar logic and +/// reproducing the old `civil_from_days` result exactly without hand-rolled day math. 1970-01-01 +/// is `num_days_from_ce` 719_163. +fn civil_from_days(days: u64) -> (i64, u32, u32) { + use chrono::Datelike; + let (cycles, rest) = (days / 146_097, days % 146_097); + // `rest + 719_163` is 719_163..865_259, always a valid CE day. + match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { + Some(d) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(i64::MAX), d.month(), d.day()) + } + None => (1970, 1, 1), // unreachable + } } fn numbered(dir: &Path, n: usize) -> PathBuf { @@ -337,6 +345,18 @@ mod tests { assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_767_225_599)), "2025-12-31T23:59:59.000Z"); // Before the epoch (a clock set wrong) clamps instead of panicking. assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); + // Past chrono's range (a clock set absurdly far ahead) keeps the proleptic Gregorian date + // the old formatter gave, rather than falling back to the epoch. + // Windows' SystemTime holds only up to about year 30828, so this is checked where it fits. + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) { + assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z"); + } + // Windows' SystemTime can't hold u64::MAX / 2 seconds, so it is only checked where it exists. + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) { + assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z"); + } + // Years past 9999 keep the plain zero-padded year (no `+`). + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z"); } #[test] diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index 7a0bb2043..1a36d64bf 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 @@ -231,7 +232,10 @@ fn main() -> eframe::Result { // running and exits (#282, #317, #1726). Any option means the caller wants this launch's own // window. let may_hand_off = !new_window && !files.is_empty() && options.is_empty() && control_file.is_none() && !create_images; - let instance = match single_instance::claim(&single_instance::absolute(&files), may_hand_off) { + // Absolute for this instance too: links relative to a document resolve against its folder, + // never the working directory. + let files = single_instance::absolute(&files); + let instance = match single_instance::claim(&files, may_hand_off) { single_instance::Claim::HandedOff => return Ok(()), single_instance::Claim::Primary(server) => Some(server), single_instance::Claim::Alone => None, @@ -377,6 +381,7 @@ fn app_creator<'a>( if let Some(json) = cc.storage.and_then(|s| s.get_string("pdfcraft").or_else(|| s.get_string(LEGACY_STORAGE_KEY))) { app.restore(&json); } + app.window_state = pdfcraft_ui_egui::window_state::WindowState::started_with(cc.storage); app.integrated_titlebar = integrated; app.window_controls = window_controls; app.system_text_scale = text_scale::system(); @@ -553,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 @@ -562,7 +572,7 @@ fn configure_gpu(native: &mut eframe::NativeOptions) { setup.instance_descriptor.flags = setup.instance_descriptor.flags.difference(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env(); // egui-wgpu requests only 8192 pixels even when the adapter can render larger surfaces. // A restored 3440-point window at 250% DPI requests 8600 pixels and otherwise panics in - // Surface::configure before the app starts. Keep the renderer's other device requirements, + // Surface::configure before the app starts. Keep the renderer's other compatible requirements, // but enable the adapter's actual 2D texture extent (also for smaller/downlevel adapters). let device_descriptor = std::sync::Arc::clone(&setup.device_descriptor); setup.device_descriptor = std::sync::Arc::new(move |adapter| { @@ -600,11 +610,21 @@ 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. -/// Changing only the 2D extent preserves egui-wgpu's backend-specific downlevel requirements. +/// Clamp egui-wgpu's backend-specific requirements before enabling the adapter's full 2D extent. fn surface_texture_limits(mut required: eframe::wgpu::Limits, supported: &eframe::wgpu::Limits) -> eframe::wgpu::Limits { + required = required.or_worse_values_from(supported); required.max_texture_dimension_2d = supported.max_texture_dimension_2d; required } @@ -760,7 +780,7 @@ mod tests { /// graphics libraries at a time. The test threads otherwise run them together, and on /// FreeBSD's CI machine the test process then died with SIGSEGV, in about half the runs: /// the first device was still being set up when the second test began its own. The UI - /// tests take turns the same way (`crates/ui-egui/tests/view.rs`). Hold it for the whole + /// tests take turns the same way (`crates/ui-egui/tests/it/main.rs`). Hold it for the whole /// test (declared before the instance, so it's released after the device is dropped). static GPU: std::sync::Mutex<()> = std::sync::Mutex::new(()); @@ -873,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 { @@ -891,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] @@ -909,16 +941,51 @@ mod tests { fn surface_limits_preserve_other_requirements_and_never_overrequest() { for required in [eframe::wgpu::Limits::default(), eframe::wgpu::Limits::downlevel_webgl2_defaults()] { for extent in [4096, 8192, 16384] { - let supported = eframe::wgpu::Limits { max_texture_dimension_2d: extent, ..required.clone() }; + let supported = eframe::wgpu::Limits { + max_texture_dimension_2d: extent, + max_bind_groups: 8, + min_uniform_buffer_offset_alignment: 128, + min_storage_buffer_offset_alignment: 128, + ..required.clone() + }; let enabled = super::surface_texture_limits(required.clone(), &supported); let mut expected = required.clone(); expected.max_texture_dimension_2d = extent; assert_eq!(enabled, expected); assert_eq!(enabled.max_texture_dimension_2d, supported.max_texture_dimension_2d); + assert!(enabled.check_limits(&supported)); } } } + #[test] + fn surface_limits_fit_v3d_shader_variable_limit() { + // Issue #2383: V3D supports 15 variables, below the default request of 16. + let required = eframe::wgpu::Limits::default(); + let supported = eframe::wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..required.clone() }; + let enabled = super::surface_texture_limits(required.clone(), &supported); + assert!(enabled.check_limits(&supported), "unsupported device limits: {enabled:?}"); + let expected = eframe::wgpu::Limits { max_texture_dimension_2d: 7680, max_inter_stage_shader_variables: 15, ..required }; + assert_eq!(enabled, expected); + } + + #[test] + fn surface_limits_fit_downlevel_maxima_and_minimum_alignments() { + let required = eframe::wgpu::Limits::downlevel_webgl2_defaults(); + let supported = eframe::wgpu::Limits { + max_texture_dimension_2d: 2048, + max_bind_groups: 2, + max_vertex_attributes: 8, + min_uniform_buffer_offset_alignment: 512, + min_storage_buffer_offset_alignment: 512, + ..required.clone() + }; + let enabled = super::surface_texture_limits(required, &supported); + assert!(enabled.check_limits(&supported), "unsupported device limits: {enabled:?}"); + // Larger minimum alignments are weaker capabilities, unlike maximum limits. + assert_eq!(enabled, supported); + } + #[test] fn a_window_larger_than_the_gpu_limit_gets_a_surface_within_it() { use eframe::egui_wgpu::winit::surface_fit; diff --git a/apps/pdfcraft/src/single_instance.rs b/apps/pdfcraft/src/single_instance.rs index ecfa12f5c..751785d0c 100644 --- a/apps/pdfcraft/src/single_instance.rs +++ b/apps/pdfcraft/src/single_instance.rs @@ -153,7 +153,7 @@ pub fn claim(files: &[String], may_hand_off: bool) -> Claim { /// `%LOCALAPPDATA%` is unset. #[cfg(windows)] fn socket_path() -> Option { - let name = socket_name(std::env::var("SESSIONNAME").ok().as_deref()); + let name = socket_name(session_id(), std::env::var("SESSIONNAME").ok().as_deref()); // %LOCALAPPDATA% is private to the user's sign-in, so other accounts can't receive this // user's file paths or send it files to open. A portable copy uses it too (#889), under its // own name so it never hands files to an installed copy (which keeps its settings elsewhere). @@ -184,13 +184,31 @@ fn display_socket_name(display: Option<&str>) -> String { if display.is_empty() { "instance.sock".to_string() } else { format!("instance-{display}.sock") } } -/// The socket's file name for a Windows session (`SESSIONNAME`: `Console`, `RDP-Tcp#3`…). The -/// same user signed in twice (at the console and over Remote Desktop) gets one socket per session, -/// so a file never opens in a window on the other screen. Only letters, digits and `-` are kept. +/// The Windows session this process runs in, from its access token: the console, each Remote +/// Desktop sign-in and session 0 (services) each have their own. `None` if Windows won't say. +#[cfg(windows)] +fn session_id() -> Option { + use winsafe::{HACCESSTOKEN, TokenInfo, co::TOKEN_INFORMATION_CLASS}; + match HACCESSTOKEN::GetCurrentProcessToken().GetTokenInformation(TOKEN_INFORMATION_CLASS::SessionId) { + Ok(TokenInfo::SessionId(id)) => Some(*id), + _ => None, + } +} + +/// The socket's file name for a Windows session. The same user signed in twice (at the console +/// and over Remote Desktop) gets one socket per session, so a file never opens in a window on the +/// other screen. The session is the token's session id (`session`): a launch from Task Scheduler, +/// a service or a remote shell has no `SESSIONNAME` but still runs in a session, so it finds the +/// window of the session it runs in (#3338), and never one in another session (review on #3340). +/// Only if the id can't be read does `SESSIONNAME` (`Console`, `RDP-Tcp#3`…) name it, letters, +/// digits and `-` kept. #[cfg_attr(not(windows), allow(dead_code))] -fn socket_name(session: Option<&str>) -> String { - let session: String = session.unwrap_or_default().chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').take(32).collect(); - if session.is_empty() { "instance.sock".to_string() } else { format!("instance-{session}.sock") } +fn socket_name(session: Option, session_name: Option<&str>) -> String { + if let Some(id) = session { + return format!("instance-s{id}.sock"); + } + let name: String = session_name.unwrap_or_default().chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').take(32).collect(); + if name.is_empty() { "instance.sock".to_string() } else { format!("instance-{name}.sock") } } /// Held while a launch finds out whether it is the first, so launches that start together (several @@ -359,12 +377,20 @@ mod tests { #[test] fn each_windows_session_gets_its_own_socket_name() { - assert_eq!(socket_name(Some("Console")), "instance-Console.sock"); - assert_eq!(socket_name(Some("RDP-Tcp#3")), "instance-RDP-Tcp3.sock"); - assert_ne!(socket_name(Some("RDP-Tcp#3")), socket_name(Some("RDP-Tcp#4"))); - assert_eq!(socket_name(Some(r"..\x/y:z")), "instance-xyz.sock", "never a path"); - assert_eq!(socket_name(None), "instance.sock"); - assert_eq!(socket_name(Some("")), "instance.sock"); + // The session id decides: #3338, a launch from Task Scheduler (no SESSIONNAME) in the + // console's session finds the window opened from Explorer there... + assert_eq!(socket_name(Some(1), None), socket_name(Some(1), Some("Console"))); + // ...and (review on #3340) one in a Remote Desktop session never reaches the console's. + assert_ne!(socket_name(Some(2), None), socket_name(Some(1), Some("Console"))); + assert_ne!(socket_name(Some(2), Some("RDP-Tcp#3")), socket_name(Some(3), Some("RDP-Tcp#3"))); + assert_eq!(socket_name(Some(4_000_000_000), None), "instance-s4000000000.sock"); + // Only without an id does SESSIONNAME name it. + assert_eq!(socket_name(None, Some("Console")), "instance-Console.sock"); + assert_eq!(socket_name(None, Some("RDP-Tcp#3")), "instance-RDP-Tcp3.sock"); + assert_ne!(socket_name(None, Some("RDP-Tcp#3")), socket_name(None, Some("RDP-Tcp#4"))); + assert_eq!(socket_name(None, Some(r"..\x/y:z")), "instance-xyz.sock", "never a path"); + assert_eq!(socket_name(None, None), "instance.sock"); + assert_eq!(socket_name(None, Some("")), "instance.sock"); } #[test] @@ -394,11 +420,21 @@ mod tests { assert_eq!(display_socket_name(Some("")), "instance.sock"); } + /// The socket is named by the session the process runs in, which Windows always gives. + #[cfg(windows)] + #[test] + fn this_process_has_a_session_id() { + let id = super::session_id(); + assert!(id.is_some(), "the access token names the session"); + let name = socket_name(id, None); + assert!(socket_path().is_none_or(|p| p.file_name().is_some_and(|f| f.to_string_lossy().ends_with(&name))), "{name}"); + } + #[cfg(any(windows, all(unix, not(target_os = "macos"))))] fn socket(test: &str) -> std::path::PathBuf { let dir = std::env::temp_dir().join(format!("pdfcraft-instance-{}-{test}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); - dir.join(socket_name(None)) + dir.join(socket_name(None, None)) } #[cfg(any(windows, all(unix, not(target_os = "macos"))))] diff --git a/apps/pdfcraft/tests/it/main.rs b/apps/pdfcraft/tests/it/main.rs new file mode 100644 index 000000000..bc05099a5 --- /dev/null +++ b/apps/pdfcraft/tests/it/main.rs @@ -0,0 +1,21 @@ +//! The crate's integration tests, linked into one test binary. + +// Compile winit's scroll policy against its real event type, without a compositor. The vendored +// source (`winit_wayland_scroll`) names it as `crate::event`. +#[cfg(target_os = "linux")] +mod event { + pub use winit::event::TouchPhase; +} + +mod winit_macos_emoji_picker; +mod winit_macos_ime_commit; +mod winit_macos_ime_discard; +mod winit_macos_korean_first_key; +mod winit_macos_korean_space; +mod winit_macos_title_bar_drag; +mod winit_wayland_scroll; +mod winit_wayland_text_input; +mod winit_windows_hidden_maximized; +mod winit_windows_ime_cursor; +mod winit_x11_dpi; +mod winit_x11_ime_focus; diff --git a/apps/pdfcraft/tests/winit_macos_emoji_picker.rs b/apps/pdfcraft/tests/it/winit_macos_emoji_picker.rs similarity index 100% rename from apps/pdfcraft/tests/winit_macos_emoji_picker.rs rename to apps/pdfcraft/tests/it/winit_macos_emoji_picker.rs diff --git a/apps/pdfcraft/tests/winit_macos_ime_commit.rs b/apps/pdfcraft/tests/it/winit_macos_ime_commit.rs similarity index 100% rename from apps/pdfcraft/tests/winit_macos_ime_commit.rs rename to apps/pdfcraft/tests/it/winit_macos_ime_commit.rs diff --git a/apps/pdfcraft/tests/winit_macos_ime_discard.rs b/apps/pdfcraft/tests/it/winit_macos_ime_discard.rs similarity index 100% rename from apps/pdfcraft/tests/winit_macos_ime_discard.rs rename to apps/pdfcraft/tests/it/winit_macos_ime_discard.rs diff --git a/apps/pdfcraft/tests/winit_macos_korean_first_key.rs b/apps/pdfcraft/tests/it/winit_macos_korean_first_key.rs similarity index 100% rename from apps/pdfcraft/tests/winit_macos_korean_first_key.rs rename to apps/pdfcraft/tests/it/winit_macos_korean_first_key.rs diff --git a/apps/pdfcraft/tests/winit_macos_korean_space.rs b/apps/pdfcraft/tests/it/winit_macos_korean_space.rs similarity index 100% rename from apps/pdfcraft/tests/winit_macos_korean_space.rs rename to apps/pdfcraft/tests/it/winit_macos_korean_space.rs diff --git a/apps/pdfcraft/tests/winit_macos_title_bar_drag.rs b/apps/pdfcraft/tests/it/winit_macos_title_bar_drag.rs similarity index 100% rename from apps/pdfcraft/tests/winit_macos_title_bar_drag.rs rename to apps/pdfcraft/tests/it/winit_macos_title_bar_drag.rs diff --git a/apps/pdfcraft/tests/winit_wayland_scroll.rs b/apps/pdfcraft/tests/it/winit_wayland_scroll.rs similarity index 93% rename from apps/pdfcraft/tests/winit_wayland_scroll.rs rename to apps/pdfcraft/tests/it/winit_wayland_scroll.rs index ec76d2b4b..96686f7ec 100644 --- a/apps/pdfcraft/tests/winit_wayland_scroll.rs +++ b/apps/pdfcraft/tests/it/winit_wayland_scroll.rs @@ -3,11 +3,8 @@ use egui::{Event, Modifiers, MouseWheelUnit, RawInput, TouchPhase, vec2}; use wayland_client::protocol::wl_pointer::AxisSource; -// Compile the production policy against winit's real event type, without a compositor. -mod event { - pub use winit::event::TouchPhase; -} -#[path = "../../../vendor/winit/src/platform_impl/linux/wayland/seat/pointer/scroll.rs"] +use crate::event; +#[path = "../../../../vendor/winit/src/platform_impl/linux/wayland/seat/pointer/scroll.rs"] mod scroll; fn egui_phase(phase: event::TouchPhase) -> TouchPhase { diff --git a/apps/pdfcraft/tests/winit_wayland_text_input.rs b/apps/pdfcraft/tests/it/winit_wayland_text_input.rs similarity index 100% rename from apps/pdfcraft/tests/winit_wayland_text_input.rs rename to apps/pdfcraft/tests/it/winit_wayland_text_input.rs diff --git a/apps/pdfcraft/tests/winit_windows_hidden_maximized.rs b/apps/pdfcraft/tests/it/winit_windows_hidden_maximized.rs similarity index 100% rename from apps/pdfcraft/tests/winit_windows_hidden_maximized.rs rename to apps/pdfcraft/tests/it/winit_windows_hidden_maximized.rs diff --git a/apps/pdfcraft/tests/winit_windows_ime_cursor.rs b/apps/pdfcraft/tests/it/winit_windows_ime_cursor.rs similarity index 100% rename from apps/pdfcraft/tests/winit_windows_ime_cursor.rs rename to apps/pdfcraft/tests/it/winit_windows_ime_cursor.rs diff --git a/apps/pdfcraft/tests/winit_x11_dpi.rs b/apps/pdfcraft/tests/it/winit_x11_dpi.rs similarity index 100% rename from apps/pdfcraft/tests/winit_x11_dpi.rs rename to apps/pdfcraft/tests/it/winit_x11_dpi.rs diff --git a/apps/pdfcraft/tests/winit_x11_ime_focus.rs b/apps/pdfcraft/tests/it/winit_x11_ime_focus.rs similarity index 100% rename from apps/pdfcraft/tests/winit_x11_ime_focus.rs rename to apps/pdfcraft/tests/it/winit_x11_ime_focus.rs diff --git a/assets/demo/showcase.html b/assets/demo/showcase.html index 267e56a56..22e0619ea 100644 --- a/assets/demo/showcase.html +++ b/assets/demo/showcase.html @@ -401,12 +401,12 @@ a.box { position: absolute; display: block; color: transparent; font-size: 1pt;

The families in this specimen

-
AgPlayfair DisplayDisplay
-
AgEB GaramondBody text
-
AgInterLabels
-
AgJetBrains MonoCode
-
AgGreat VibesScript
-
∑xSTIX Two MathMathematics
+
AgPlayfair DisplayDisplay
+
AgEB GaramondBody text
+
AgInterLabels
+
AgJetBrains MonoCode
+
AgGreat VibesScript
+
∑xSTIX Two MathMathematics
Forewordii
@@ -449,7 +449,7 @@ a.box { position: absolute; display: block; color: transparent; font-size: 1pt; - +
The find bar showing match 10 of 16 for the word 'type', highlighted in the Expressive Type chapter heading
onumOld-style figuresSince 1885: 3,467,920Since 1885: 3,467,920
fracFractions1/2 cup, 3/4 inch, 7/81/2 cup, 3/4 inch, 7/8
swshSwash capitalsThe Quiet RiverThe Quiet River
ss01Stylistic set 1a 1 4 6 9 G ?a 1 4 6 9 G ?
ss01Stylistic set 1a 1 4 6 9 G ?a 1 4 6 9 G ?
kernKerningAVATAR Type WaveAVATAR Type Wave
tnumTabular figures1111 / 88881111 / 8888
@@ -519,9 +519,9 @@ a.box { position: absolute; display: block; color: transparent; font-size: 1pt;

A short greeting — roughly, “every letter deserves a good home” — in writing systems that need bidirectional layout, contextual shaping, reordering and vertical setting.

-
Arabic RTL · joining
لكل حرف بيت
Amiri
+
Arabic RTL · joining
لكل حرف بيت
Amiri
Hebrew RTL
לכל אות יש בית טוב
Noto Sans Hebrew
-
Devanagari conjuncts
हर अक्षर का घर
Noto Sans Devanagari
+
Devanagari conjuncts
हर अक्षर का घर
Noto Sans Devanagari
Thai no spaces
ทุกอักษรมีบ้าน
Noto Sans Thai
Greek polytonic
Κάθε γράμμα ἔχει σπίτι
EB Garamond
Cyrillic
Каждой букве — дом
EB Garamond
@@ -541,7 +541,7 @@ a.box { position: absolute; display: block; color: transparent; font-size: 1pt;

Mixed direction in one line

-

The word كتاب means “book”, and ספר does too — the bidi algorithm keeps each run in its own direction while the sentence stays left-to-right, even across numbers like ٣٤٥ and 345.

+

The word كتاب means “book”, and ספר does too — the bidi algorithm keeps each run in its own direction while the sentence stays left-to-right, even across numbers like ٣٤٥ and 345.

Colour emoji

📄🖋️🎨📐🔍🌍🧮✅🦀📎
diff --git a/assets/icons/move-horizontal.svg b/assets/icons/move-horizontal.svg new file mode 100644 index 000000000..7be9e3af6 --- /dev/null +++ b/assets/icons/move-horizontal.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/pilcrow.svg b/assets/icons/pilcrow.svg new file mode 100644 index 000000000..1a1b38b15 --- /dev/null +++ b/assets/icons/pilcrow.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/subscript.svg b/assets/icons/subscript.svg new file mode 100644 index 000000000..a37e9c42d --- /dev/null +++ b/assets/icons/subscript.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/superscript.svg b/assets/icons/superscript.svg new file mode 100644 index 000000000..c7978fb09 --- /dev/null +++ b/assets/icons/superscript.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/contributors/people.toml b/contributors/people.toml index da3cfc6a7..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" @@ -145,3 +153,23 @@ sync_github_name = false [people.Silvertree2010] display_name = "Netter" sync_github_name = false + +[people.MortenHusted] +sync_github_name = true + +[people.EricKaden] +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 0f0706428..4668881a6 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -174,7 +174,7 @@ fn interior_color(d: &Dict, fallback: Option) -> Option { color(d, b"IC").flatten().or(fallback) } -fn border_width(d: &Dict) -> f64 { +pub(crate) fn border_width(d: &Dict) -> f64 { if let Some(w) = d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()) { return w.max(0.0); } @@ -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 f4973aa02..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> { @@ -1987,13 +2113,12 @@ pub fn set_font_size(doc: &mut Document, page: usize, index: usize, size: f64, m result } -/// Whether a comment of this subtype has an interior that can be filled (`/IC`: rectangles, -/// ovals, polygons and clouds, §12.5.6.8–9). +/// Whether a comment has a fill: `/IC` for shapes, `/C` for FreeText (§12.5.6). pub fn fillable(subtype: &str) -> bool { - matches!(subtype, "Square" | "Circle" | "Polygon") + matches!(subtype, "Square" | "Circle" | "Polygon" | "FreeText") } -/// Fill a rectangle, oval or polygon comment with `fill`, or remove its fill (`None`), and +/// Fill a shape or FreeText comment with `fill`, or remove its fill (`None`), and /// redraw its appearance (Acrobat: Properties ▸ Fill Color). pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option, meta: &Meta) -> Result<(), AnnotError> { let (_, r) = annot_ref(doc, page, index)?; @@ -2001,7 +2126,7 @@ pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option let d = annot_dict(doc, r); let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); if !fillable(&subtype) { - return Err(AnnotError::Invalid("only rectangles, ovals and polygons can be filled".into())); + return Err(AnnotError::Invalid("only rectangles, ovals, polygons and text boxes can be filled".into())); } // Check before changing anything: a stale appearance would contradict the new fill. if appearance::build(&d).is_none() { @@ -2013,10 +2138,11 @@ pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option refuse_foreign_text(doc, &d)?; let before = d; doc.update_dict(r, |d| { + let key: &[u8] = if subtype == "FreeText" { b"C" } else { b"IC" }; match fill { - Some(c) => d.set(b"IC".to_vec(), rgb(c)), + Some(c) => d.set(key.to_vec(), rgb(c)), None => { - d.remove(b"IC"); + d.remove(key); } } touch(d, meta); @@ -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, }); } } @@ -2205,7 +2341,7 @@ pub struct Props { pub subject: String, pub color: Option, pub opacity: f64, - /// Border / line width, for shapes, lines and drawings. + /// Border / line width, for shapes, lines, drawings and text boxes. pub width: Option, /// Notes only. pub icon: Option, @@ -2217,9 +2353,9 @@ pub struct Props { pub locked: bool, /// `/LE`: two names for a line or polyline (`None` when unset), one for a callout. pub endings: Option>, - /// Rectangles, ovals and polygons: their interior can be filled (see [`fillable`]). + /// Whether the comment has a fill control (see [`fillable`]). pub fillable: bool, - /// `/IC`, the interior colour (`None`: no fill). + /// The interior colour (`None`: no fill). pub fill: Option, /// Text boxes, typewriter text and callouts: the `/DA` font size in points. pub font_size: Option, @@ -2234,8 +2370,13 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option { let subtype = String::from_utf8_lossy(d.name(b"Subtype")?).into_owned(); let c: Vec = d.get(b"C").and_then(|o| o.as_array().map(|a| a.iter().filter_map(|x| x.as_f64()).collect())).unwrap_or_default(); let color = if subtype == "FreeText" { Some(appearance::parse_da(d).0) } else { (c.len() == 3).then(|| [c[0], c[1], c[2]]) }; - let width = matches!(subtype.as_str(), "Square" | "Circle" | "Line" | "Ink" | "Polygon" | "PolyLine") - .then(|| d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0)); + let width = matches!(subtype.as_str(), "Square" | "Circle" | "Line" | "Ink" | "Polygon" | "PolyLine" | "FreeText").then(|| { + if subtype == "FreeText" { + if !d.contains(b"BS") && !d.contains(b"Border") { 0.0 } else { appearance::border_width(d) } + } else { + d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0) + } + }); Some(Props { author: text_value(doc, d, b"T").unwrap_or_default(), subject: text_value(doc, d, b"Subj").unwrap_or_default(), @@ -2254,9 +2395,10 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option { }) } -/// `/IC` as an RGB colour. An empty array means no fill; grey and CMYK fills are converted. +/// A shape’s `/IC` or FreeText `/C` as RGB. Grey and CMYK fills are converted. fn fill_of(d: &Dict) -> Option { - let c: Vec = d.get(b"IC")?.as_array()?.iter().filter_map(|x| x.as_f64()).collect(); + let key: &[u8] = if d.name(b"Subtype") == Some(b"FreeText") { b"C" } else { b"IC" }; + let c: Vec = d.get(key)?.as_array()?.iter().filter_map(|x| x.as_f64()).collect(); let rgb = match c.as_slice() { [g] => [*g; 3], [r, g, b] => [*r, *g, *b], @@ -2282,6 +2424,11 @@ fn line_endings_of(d: &Dict) -> Option> { /// Replace Text (Acrobat's proposal): strike out `quads` and add a caret at the end of the /// struck text holding `replacement`, grouped with the strikeout (`/IRT` + `/RT /Group`) so they /// move, list and delete as one. Returns the strikeout's index. +/// +/// The two annotations need their own `/NM`: XFDF names a reply by the `/NM` of what it replies +/// to, so one id for both writes `inreplyto` equal to the caret's own `name`, and a reader that +/// resolves names (Acrobat, and our own importer) loses the pair. `caret_meta` is a second id +/// from the caller, so the edit stays deterministic. #[allow(clippy::too_many_arguments)] pub fn add_text_replacement( doc: &mut Document, @@ -2292,6 +2439,7 @@ pub fn add_text_replacement( strike: &Style, caret: &Style, meta: &Meta, + caret_meta: &Meta, ) -> Result { let last = *quads.last().ok_or_else(|| AnnotError::Invalid("select the text to replace first".into()))?; let s = add_annotation( @@ -2317,7 +2465,7 @@ pub fn add_text_replacement( let c = add_annotation( doc, &NewAnnotation { page, shape: Shape::Caret { rect }, style: caret.clone(), contents: replacement.into(), author: author.into() }, - meta, + caret_meta, )?; let (_, cr) = annot_ref(doc, page, c)?; doc.update_dict(cr, |d| { diff --git a/crates/annot/src/links.rs b/crates/annot/src/links.rs index 2e45b4e4d..4d3eef632 100644 --- a/crates/annot/src/links.rs +++ b/crates/annot/src/links.rs @@ -178,6 +178,19 @@ fn write_style(d: &mut Dict, s: &LinkStyle) { d.set(b"H".to_vec(), Object::name(s.highlight.code())); } +/// Bytes of `u` as a 7-bit ASCII URI string (§12.6.4.8). +fn uri_bytes(u: &str) -> Vec { + let mut out = Vec::with_capacity(u.len()); + for &b in u.as_bytes() { + if (0x21..=0x7e).contains(&b) { + out.push(b); + } else { + out.extend_from_slice(format!("%{b:02X}").as_bytes()); + } + } + out +} + fn write_action(doc: &Document, d: &mut Dict, a: &LinkAction) -> Result<(), AnnotError> { match a { LinkAction::Page(p) => { @@ -194,7 +207,7 @@ fn write_action(doc: &Document, d: &mut Dict, a: &LinkAction) -> Result<(), Anno let mut act = Dict::new(); act.set(b"S".to_vec(), Object::name("URI")); // URIs are 7-bit ASCII strings (§12.6.4.8). - act.set(b"URI".to_vec(), PdfString::literal(u.as_bytes().to_vec())); + act.set(b"URI".to_vec(), PdfString::literal(uri_bytes(u))); d.set(b"A".to_vec(), Object::Dict(act)); } LinkAction::Other(_) => {} diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index faed409ab..a65996681 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -588,6 +588,25 @@ fn links_are_added_edited_listed_and_removed() { assert!(links::add(&mut doc, 0, [0.0, 0.0, 1.0, 1.0], &LinkAction::Page(0), &LinkStyle::default()).is_err(), "too small"); } +#[test] +fn link_uri_is_percent_encoded_to_7_bit_ascii() { + use crate::links::{self, LinkAction, LinkStyle}; + let mut doc = fixture(); + for url in ["https://example.com/ação", "https://example.com/a%20b"] { + links::add(&mut doc, 0, [10.0, 10.0, 110.0, 30.0], &LinkAction::Uri(url.into()), &LinkStyle::default()).unwrap(); + } + let doc = reopen(&doc); + let urls: Vec = links::list(&doc) + .into_iter() + .filter_map(|link| match link.action { + LinkAction::Uri(url) => Some(url), + _ => None, + }) + .collect(); + assert_eq!(urls, ["https://example.com/a%C3%A7%C3%A3o", "https://example.com/a%20b"]); + assert!(urls.iter().all(|url| url.is_ascii())); +} + #[test] fn link_edit_rejects_non_finite_and_overflowed_rectangles_without_mutating_the_pdf() { use crate::links::{self, LinkAction, LinkStyle}; @@ -784,7 +803,7 @@ fn replace_text_groups_a_strikeout_and_a_caret() { let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]]; let red = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None }; let blue = Style { color: [0.0, 0.47, 0.84], opacity: 1.0, width: 1.0, fill: None }; - let i = add_text_replacement(&mut doc, 0, &quads, "new words", "Ada", &red, &blue, &meta("r")).unwrap(); + let i = add_text_replacement(&mut doc, 0, &quads, "new words", "Ada", &red, &blue, &meta("r"), &meta("r-caret")).unwrap(); let doc = reopen(&doc); let a = list(&doc, 0); let strike = &a[i]; @@ -792,13 +811,21 @@ fn replace_text_groups_a_strikeout_and_a_caret() { let caret = a.iter().find(|d| d.name(b"Subtype") == Some(b"Caret")).unwrap(); assert_eq!((text(caret, b"Contents").as_str(), caret.name(b"RT")), ("new words", Some(&b"Group"[..]))); assert!(caret.reference(b"IRT").is_some()); + // #2666: the pair needs its own `/NM` each. XFDF names a reply by the `/NM` of what it + // replies to, so sharing one id writes `inreplyto` equal to the caret's own `name` and the + // pair does not survive an export and import. + assert_eq!(text(caret, b"NM").as_str(), "r-caret"); + assert_eq!(text(strike, b"NM").as_str(), "r"); + assert_ne!(text(caret, b"NM"), text(strike, b"NM"), "the caret must not carry the strikeout's name"); + let irt = caret.reference(b"IRT").unwrap(); + assert_eq!(text(doc.get(irt).as_dict().unwrap(), b"NM").as_str(), "r", "the caret replies to the strikeout"); let r = rect(caret); assert!(r[0] < 200.0 && r[2] > 200.0 && r[1] < 706.0, "at the end of the line, below its baseline: {r:?}"); // Deleting the strikeout takes the caret with it (the group). let mut doc = doc; delete_annotation(&mut doc, 0, i).unwrap(); assert!(!list(&doc, 0).iter().any(|d| d.name(b"Subtype") == Some(b"Caret"))); - assert!(add_text_replacement(&mut doc, 0, &[], "x", "Ada", &red, &blue, &meta("r")).is_err()); + assert!(add_text_replacement(&mut doc, 0, &[], "x", "Ada", &red, &blue, &meta("r"), &meta("r-caret")).is_err()); } #[test] @@ -964,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]; @@ -1274,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] @@ -1308,3 +1380,127 @@ fn text_box_font_size_changes_the_da_and_grows_the_box() { assert_eq!(props(&doc, 0, i).unwrap().font_size, None); } } + +#[test] +fn text_boxes_and_callouts_can_change_fill_and_border() { + let mut doc = fixture(); + let shapes = [ + Shape::TextBox { rect: [300.0, 600.0, 500.0, 660.0], font_size: 12.0 }, + Shape::Callout { + rect: [300.0, 600.0, 500.0, 660.0], + knee: [260.0, 560.0], + point: [200.0, 520.0], + font_size: 12.0, + ending: LineEnding::OpenArrow, + }, + ]; + for shape in shapes { + let index = add_annotation(&mut doc, &new(0, shape), &meta("test")).unwrap(); + assert!(props(&doc, 0, index).unwrap().fillable); + set_fill(&mut doc, 0, index, Some([1.0, 1.0, 1.0]), &meta("test")).unwrap(); + set_style(&mut doc, 0, index, None, None, Some(3.0), None, &meta("test")).unwrap(); + let saved = reopen(&doc); + let properties = props(&saved, 0, index).unwrap(); + assert_eq!(properties.fill, Some([1.0; 3])); + assert_eq!(properties.width, Some(3.0)); + let ap = ap_content(&saved, &list(&saved, 0)[index]); + assert!(ap.contains("1 1 1 rg") && ap.contains(" re f") && ap.contains("3 w"), "{ap}"); + set_fill(&mut doc, 0, index, None, &meta("test")).unwrap(); + assert_eq!(props(&doc, 0, index).unwrap().fill, None); + } +} + +#[test] +fn free_text_properties_show_legacy_or_absent_border_width() { + let mut doc = fixture(); + let index = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [100.0, 100.0, 200.0, 140.0], font_size: 12.0 }), &meta("test")).unwrap(); + let (_, reference) = annot_ref(&mut doc, 0, index).unwrap(); + doc.update_dict(reference, |d| { + d.remove(b"BS"); + d.remove(b"Border"); + }) + .unwrap(); + assert_eq!(props(&doc, 0, index).unwrap().width, Some(0.0)); + doc.update_dict(reference, |d| { + d.set(b"Border".to_vec(), Object::Array([0.0, 0.0, 4.0].into_iter().map(Object::Real).collect())); + }) + .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 5dd15de3a..37771a535 100644 --- a/crates/audit/README.md +++ b/crates/audit/README.md @@ -10,15 +10,15 @@ 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 shading and highlights don't either: they're painted before the text, or light against it. -A rectangle covers a text line when it hides at least 40% of the area of the words the line -draws, or at least 40% of the line's box together with a positive-area intersection with one -of its words. The gap an applied redaction leaves (a `TJ` adjustment where the text was) is in the line's box but +A rectangle covers a text line when it hides at least half of one of the line's words, at +least 40% of the area of the words the line draws, or at least 40% of the line's box together +with a positive-area intersection with one of its words. The gap an applied redaction leaves (a `TJ` adjustment where the text was) is in the line's box but holds no word, so a redaction box over it alone does not count. Limits, by design and documented rather than hidden: @@ -29,8 +29,8 @@ Limits, by design and documented rather than hidden: - covers inside form XObjects are found, but only text drawn directly in the page content is checked under them; - annotation rectangles are compared in unrotated user space, so `/Rotate` pages may misalign; -- a cover that hides less than 40% of a line's box and less than 40% of its words (a short - value in the middle of a long line) is not flagged. +- a cover that hides less than half of every word it touches, less than 40% of a line's box and + less than 40% of its words (a sliver of a long value) is not flagged. It fails closed: an undecodable content stream, malformed `/Annots`, form XObjects nested deeper than 32 or in a cycle, or a walk past the operator bound is an error, never "no findings". diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs index 204d237b3..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. //! @@ -87,6 +88,9 @@ pub enum AuditError { /// Fraction of a text line's ink (the area of its words), or of its box, that must lie under a /// cover to count as hidden (see [`covers`]). const COVER_FRACTION: f64 = 0.4; +/// Fraction of one word's area that must lie under a cover to count as hidden on its own, however +/// long the line around it (see [`covers`]). +const WORD_FRACTION: f64 = 0.5; /// WCAG contrast ratio below which the text is unreadable against the cover. const COVER_CONTRAST: f64 = 3.0; @@ -111,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(' '); } @@ -123,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 }); } } } @@ -138,7 +150,10 @@ fn is_cover(rect: &cover::FilledRect, line: &TextLine) -> bool { rect.pos > line.paint_pos() && covers(&rect.rect, line) && contrast(line.color, rect.rgb) < COVER_CONTRAST } -/// Does `cover` hide a substantial part of what `line` draws? Two ways, either is enough: +/// Does `cover` hide a substantial part of what `line` draws? Three ways, any is enough: +/// +/// - Half of one word lies under the cover. A box over one number in a long line hides a few +/// percent of the line, and the number all the same (#2662). /// /// - 40% of the area of the line's words (its `ink`) lies under the cover. Measured on the words, /// not the box: an applied redaction leaves a gap that draws nothing (a `TJ` adjustment where @@ -155,6 +170,9 @@ fn covers(cover: &[f64; 4], line: &TextLine) -> bool { let total: f64 = boxes.iter().map(area).sum(); if total > 0.0 { boxes.iter().map(hidden).sum::() / total } else { 0.0 } }; + if line.ink.iter().any(|w| area(w) > 0.0 && hidden(w) / area(w) >= WORD_FRACTION) { + return true; + } let inked = fraction(&line.ink) >= COVER_FRACTION; let boxed = fraction(std::slice::from_ref(&line.rect)) >= COVER_FRACTION; let word_intersects = || line.ink.iter().any(|w| hidden(w) > 0.0); @@ -182,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 { @@ -198,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 d7d83a7db..4ca3b0bf5 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -175,6 +175,110 @@ fn unapplied_redact_mark_is_found() { assert!(findings[0].covered_text.contains("DO NOT SHARE")); } +/// #2662: a box over one number of a long line hides a few percent of the line, and all of the +/// number. Found as a cover, and as an unapplied mark over the same word. +#[test] +fn a_cover_over_one_word_of_a_long_line_is_found() { + // Helvetica 11: "The contract value is " ends near x 132, "98765" spans about 132-163. + let line = b"BT /F1 11 Tf 30 100 Td (The contract value is 98765 and the rest of this long line continues here.) Tj ET +"; + let mut content = line.to_vec(); + content.extend_from_slice( + b"0 g +131 96 33 14 re f +", + ); + let findings = audit_redactions(&open(one_page(&content, ""))).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert_eq!(findings[0].kind, FindingKind::CoveredText); + assert!(findings[0].covered_text.contains("98765"), "{findings:?}"); + + 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(line); + b.add(FONT); + b.add("<< /Type /Annot /Subtype /Redact /Rect [131 96 164 110] >>"); + let findings = audit_redactions(&open(b.build())).expect("audit must run"); + assert_eq!(findings.len(), 1, "{findings:?}"); + assert_eq!(findings[0].kind, FindingKind::UnappliedMark); +} + +/// A box that only grazes the words beside it (less than half of each) is not a cover. +#[test] +fn a_box_grazing_a_word_is_not_flagged() { + let content = b"BT /F1 11 Tf 30 100 Td (The contract value is 98765 and the rest of this long line continues here.) Tj ET +0 g +131 96 6 14 re f +"; + let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run"); + 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. @@ -303,15 +407,17 @@ fn partial_value_is_found(rotated: bool) { /// Keep the historic detections with several covers, lines and text-spacing states. #[test] fn faux_redactions_with_multiple_covers_lines_and_spacing_are_found() { - let cases: [&[u8]; 4] = [ - b"BT /F1 11 Tf 72 742 Td (Account: 123456789) Tj ET\n0 g\n72 738 65 16 re f\n140 738 40 16 re f\n", - b"BT /F1 11 Tf 72 742 Td (Account: 123456789) Tj 0 -15 Td (Account: 987654321) Tj ET\n0 g\n115 723 100 30 re f\n", - b"BT /F1 11 Tf 8 Tc 72 742 Td (Account: 1234) Tj ET\n0 g\n145 738 90 16 re f\n", - b"BT /F1 11 Tf 250 Tz 72 742 Td (Account: 1234) Tj ET\n0 g\n100 738 160 16 re f\n", + // The second box of the first case hides most of the number (less than 40% of the line): a + // finding of its own since covers are also measured per word (#2662). + let cases: [(&[u8], usize); 4] = [ + (b"BT /F1 11 Tf 72 742 Td (Account: 123456789) Tj ET\n0 g\n72 738 65 16 re f\n140 738 40 16 re f\n", 2), + (b"BT /F1 11 Tf 72 742 Td (Account: 123456789) Tj 0 -15 Td (Account: 987654321) Tj ET\n0 g\n115 723 100 30 re f\n", 1), + (b"BT /F1 11 Tf 8 Tc 72 742 Td (Account: 1234) Tj ET\n0 g\n145 738 90 16 re f\n", 1), + (b"BT /F1 11 Tf 250 Tz 72 742 Td (Account: 1234) Tj ET\n0 g\n100 738 160 16 re f\n", 1), ]; - for content in cases { + for (content, expected) in cases { let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run"); - assert_eq!(findings.len(), 1, "{}: {findings:?}", String::from_utf8_lossy(content)); + assert_eq!(findings.len(), expected, "{}: {findings:?}", String::from_utf8_lossy(content)); } } @@ -457,3 +563,32 @@ fn forms_nested_past_depth_limit_fail_closed() { let result = audit_redactions(&open(nested_forms(33))); assert!(matches!(result, Err(crate::AuditError::TooDeep)), "{result:?}"); } + +#[test] +fn comment_across_streams_neither_invents_text_nor_a_cover() { + let doc = open(two_streams(b"BT /F1 24 Tf 72 700 Td (Visible text) Tj ET % note", b"0 g 70 690 200 40 re f")); + assert!(audit_redactions(&doc).unwrap().is_empty(), "commented cover must not be reported"); + let doc = open(two_streams(b"BT /F1 24 Tf 72 700 Td (Before comment) Tj ET % note", b"BT /F1 24 Tf 72 650 Td (After comment) Tj ET")); + let text: String = pdfcraft_edit::text_lines(&doc, 0).unwrap().into_iter().map(|line| line.text).collect(); + assert!(!text.contains("After comment"), "editor exposed phantom text: {text}"); +} + +#[test] +fn reading_blocks_respect_stream_comments_and_split_operands() { + let doc = open(two_streams(b"BT /F1 24 Tf 72 700 Td (Before comment) Tj ET % note", b"BT /F1 24 Tf 72 650 Td (After comment) Tj ET")); + let text: String = pdfcraft_edit::reading_blocks(&doc, 0).unwrap().into_iter().map(|block| block.editor_text).collect(); + assert_eq!(text, "Before comment"); + let doc = open(two_streams(b"BT /F1 24 Tf 72 700 Td (Split operand)", b"Tj ET")); + let text: String = pdfcraft_edit::reading_blocks(&doc, 0).unwrap().into_iter().map(|block| block.editor_text).collect(); + assert_eq!(text, "Split operand"); + // Marked content opened in one stream applies to text in the next: its /ActualText replaces + // the glyphs, as when the streams are read joined. + for (s1, s2) in [ + (b"BT /F1 24 Tf 72 700 Td /Span <> BDC (Glyphs) Tj EMC ET".as_slice()), + (b"BT /F1 24 Tf 72 700 Td /Span <> BDC", b"(Glyphs) Tj EMC ET"), + ] { + let doc = open(two_streams(s1, s2)); + let text: String = pdfcraft_edit::reading_blocks(&doc, 0).unwrap().into_iter().map(|block| block.editor_text).collect(); + assert_eq!(text, "Replacement text", "{}", String::from_utf8_lossy(s1)); + } +} diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index 60bd9eef4..bed579ab8 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -17,7 +17,6 @@ pdfcraft-platform = { workspace = true } serde_json = { workspace = true } sha2 = "0.11" tempfile = "3.27" -png = "0.18" base64 = { version = "0.22", optional = true } @@ -28,7 +27,11 @@ mcp = ["dep:base64"] [dev-dependencies] pdfcraft-fonts = { workspace = true } +png = "0.18" 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 4e27702a3..7eb8ca119 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 @@ -64,6 +70,20 @@ semantics; they never silently act on partial text. ## Shared field properties +For an individual push button, `form_set_props` also edits its visible normal label: + +```json +{"doc": 1, "field": "clear_button", "caption": "Clear Form", "widget": 1} +``` + +`widget` is a 1-based index (default 1), used only with `caption`; an empty caption clears the +label. Neither renames the field. `form_fields` returns the first widget's `caption` and all +`captions` as `[{"widget": 1, "caption": "Clear Form"}]`. Other widgets and icon-only layouts +keep their settings. CLI and MCP use the same edit, including undo/redo and save/reopen. +The desktop exposes it as **Button Properties → Options → Label**. Rollover/down labels and +icon layout are not edited here; the existing caption font and unsupported-character warning +policy apply. + `form_set_props` accepts either `field` (one name) or `fields` (1–1000 unique names): ```json @@ -75,7 +95,7 @@ The batch is atomic and creates one undo step. A missing or locked field, invali unknown argument or malformed appearance refuses the whole edit. Set `locked: false` to unlock and change fields together. Only supplied properties change: each field/widget keeps its other colours, fonts, values and unknown PDF dictionary keys. `"none"` removes a border -or fill colour. Names, rectangles and rotations require the single-field form. Results keep +or fill colour. Names, rectangles, rotations and captions require the single-field form. Results keep `field` for single edits and return `fields` for batches. CLI and MCP use the same tool. ## MCP server @@ -108,7 +128,7 @@ claude mcp add pdfcraft -- pdfcraft-cli mcp --root 1. Add the engine capability first, with its tests (the tool is a thin adapter). 2. Add a `ToolDef` in `src/tools.rs`: a precise description, the schema, `ro()`/`destructive()`, and `cmd()` if a registry command exists. 3. Handle it in `Automation::call`, validating pages and positions with the existing helpers. -4. Add an end-to-end test in `tests/automation.rs`. `tool_table_is_well_formed` checks names, schemas and command links. +4. Add an end-to-end test in `tests/it/automation.rs`. `tool_table_is_well_formed` checks names, schemas and command links. ## Moving several page objects diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 1e8d71bd1..212454973 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -710,9 +710,19 @@ impl Automation { let ext = path.extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default(); let format = pdfcraft_engine::compare::OfficeFormat::from_extension(&ext) .ok_or_else(|| ToolError::InvalidArgs(format!("unsupported extension {ext:?} (docx, pptx, html or rtf)")))?; - let bytes = self.doc_to_copy(a)?.export_office(format); - write_atomic(&path, &bytes)?; - Ok(json!({ "path": path.to_string_lossy(), "bytes": bytes.len(), "format": format.extension() })) + let export = self.doc_to_copy(a)?.export_office_checked(format); + write_atomic(&path, &export.bytes)?; + // Text left out to keep a hostile page bounded is said, not silent (#3120). + let truncated: Vec = export.truncated_pages.iter().map(|p| p.saturating_add(1)).collect(); + let mut out = json!({ "path": path.to_string_lossy(), "bytes": export.bytes.len(), "format": format.extension(), + "truncated": !truncated.is_empty(), "truncated_pages": truncated }); + if !export.truncated_pages.is_empty() { + use pdfcraft_engine::compare::{MAX_FORM_DEPTH, MAX_FORM_VISITS}; + out["warnings"] = json!([format!( + "forms nested more than {MAX_FORM_DEPTH} deep, or past the first {MAX_FORM_VISITS} forms of a page (forms the page draws itself count too), were left out on the pages in truncated_pages: their text and images are missing from the export, although the viewer may still show them" + )]); + } + Ok(out) } fn pdfa_level(&self, a: &Args) -> Result { @@ -918,11 +928,16 @@ impl Automation { let list: Vec = found .iter() .map(|(p, c)| { - let kind = match c.kind { - pdfcraft_engine::detect::Kind::Text => "text", - pdfcraft_engine::detect::Kind::CheckBox => "checkbox", + let (kind, comb) = match c.kind { + pdfcraft_engine::detect::Kind::Text => ("text", None), + pdfcraft_engine::detect::Kind::CheckBox => ("checkbox", None), + pdfcraft_engine::detect::Kind::Comb { cells } => ("text", Some(cells)), }; - json!({ "page": p + 1, "kind": kind, "name": c.name, "rect": c.rect.map(|v| (v * 100.0).round() / 100.0) }) + let mut v = json!({ "page": p + 1, "kind": kind, "name": c.name, "rect": c.rect.map(|v| (v * 100.0).round() / 100.0) }); + if let Some(cells) = comb { + v["comb"] = json!(cells); + } + v }) .collect(); if a.opt_bool("add")?.unwrap_or(true) && !found.is_empty() { 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 710952d7c..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) => { @@ -138,7 +138,7 @@ impl Automation { .added() .iter() .filter(|x| x.page == page) - .nth((index - 1).max(0) as usize) + .nth(usize::try_from(index.saturating_sub(1)).unwrap_or(usize::MAX)) .filter(|_| index >= 1) .ok_or_else(|| failed(format!("page {} has no added item {index} (see content_list)", page + 1)))?; Ok((page, index as usize - 1, it.content.clone(), ph)) @@ -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 7c60425a8..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}; @@ -233,6 +233,19 @@ impl Automation { o.insert("unsupported_scripts".into(), json!(f.actions.unsupported)); } match f.kind { + FormFieldKind::PushButton => { + o.insert("caption".into(), json!(w.and_then(|w| w.caption.as_deref()).unwrap_or_default())); + o.insert( + "captions".into(), + json!( + f.widgets + .iter() + .enumerate() + .map(|(i, w)| { json!({"widget": i.saturating_add(1), "caption": w.caption.as_deref().unwrap_or_default()}) }) + .collect::>() + ), + ); + } FormFieldKind::Radio => { o.insert("options".into(), json!((0..f.widgets.len()).filter_map(|i| f.export_of(i)).collect::>())); } @@ -326,7 +339,71 @@ 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")); + } let single = a.opt_str("field")?; let bulk = a.get("fields").is_some(); if single.is_some() == bulk { @@ -344,7 +421,7 @@ impl Automation { return Err(bad(format!("field {name:?} is listed more than once"))); } } - for key in ["name", "rect", "rotation"] { + for key in ["name", "rect", "rotation", "caption", "widget"] { if a.get(key).is_some() { return Err(bad(format!("{key} applies to one field; use field instead of fields"))); } @@ -370,12 +447,24 @@ impl Automation { } else { edits.pop().ok_or_else(|| bad("give a field name"))? }; + // A rename also renames the field in the formulas that read it. + let before = if a.get("name").is_some() { Some(self.doc(a)?.form.clone()) } else { None }; let mut out = self.apply(a, edit)?; if bulk { out["fields"] = json!(names); } else { out["field"] = json!(new_name); } + if let Some(before) = before { + let after = &self.doc(a)?.form; + // The renamed field itself, found by object: the name stored can differ from the + // argument (trimmed, kept under its parent's prefix). + let own = + names.first().and_then(|n| before.iter().find(|f| &f.name == n)).and_then(|f| after.iter().find(|g| g.obj == f.obj)).map(|g| &g.name); + let mut updated = pdfcraft_engine::form_calculations_changed(&before, after); + updated.retain(|f| Some(f) != own); + out["formulas_updated"] = json!(updated); + } Ok(out) } @@ -389,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])) } @@ -398,6 +487,15 @@ impl Automation { rect, name: a.opt_str("name")?.map(str::to_owned), tooltip: a.opt_str("tooltip")?.map(str::to_owned), + caption: a + .opt_str("caption")? + .map(|caption| -> Result<_> { + let wi = a.opt_int("widget")?.unwrap_or(1); + let wi = + usize::try_from(wi).ok().and_then(|wi| wi.checked_sub(1)).ok_or_else(|| bad("widget must be a positive 1-based index"))?; + Ok((wi, caption.to_owned())) + }) + .transpose()?, read_only: a.opt_bool("read_only")?, required: a.opt_bool("required")?, multiline: a.opt_bool("multiline")?, @@ -499,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 88856305b..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). @@ -283,6 +301,13 @@ impl Automation { let to = self.position(&a, "to")?; self.apply(&a, Edit::MovePages { pages, to })? } + "page_reverse" => { + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(&a, "pages")?, + None => (0..self.doc(&a)?.info.pages.len()).collect(), + }; + self.apply(&a, Edit::ReversePages { pages })? + } "page_insert_blank" => self.insert_blank(&a)?, "page_insert_file" => self.insert_file(&a)?, "page_extract" => self.page_extract(&a)?, @@ -332,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()); @@ -373,7 +404,8 @@ impl Automation { } "page_duplicate" => { let pages = self.pages(&a, "pages")?; - self.apply(&a, Edit::DuplicatePages { pages })? + let at = self.opt_position(&a, "at")?; + self.apply(&a, Edit::DuplicatePages { pages, at })? } "page_set_box" => self.page_set_box(&a)?, "page_transition_set" => self.page_transition_set(&a)?, @@ -491,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 = @@ -522,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)| { @@ -540,6 +578,7 @@ impl Automation { }) }) .collect(); + self.record_listing(id, page, ¤t); json!({ "page": page + 1, "count": blocks.len(), "paragraphs": blocks }) } "text_font_files" => { @@ -597,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")?, @@ -609,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); @@ -622,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"))?, @@ -666,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" => { @@ -718,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)?, @@ -782,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)?, @@ -838,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)?, @@ -874,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)?; @@ -900,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 })) } @@ -1191,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(); @@ -1199,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" => { @@ -1746,16 +1874,21 @@ impl Automation { let mut used = std::collections::HashSet::new(); for (i, (first, last, bytes)) in parts.iter().enumerate() { let safe = |t: &str| t.chars().map(|c| if c.is_alphanumeric() || " -_.,()".contains(c) { c } else { '_' }).collect::(); - let file = match titles.iter().find(|t| t.0 + 1 == *first) { - Some((_, t)) => format!("{stem}-{}.pdf", safe(t)), - None => format!("{stem}-part{}.pdf", i + 1), + let mut base = match titles.iter().find(|t| t.0 + 1 == *first) { + Some((_, t)) => format!("{stem}-{}", safe(t)), + None => format!("{stem}-part{}", i + 1), }; + // A file name is at most 255 bytes: keep the base to 200, cut between characters, which leaves room for ".pdf" and a -N suffix. + if let Some((end, _)) = base.char_indices().find(|(at, c)| at + c.len_utf8() > 200) { + base.truncate(end); + } + let file = format!("{base}.pdf"); // Equal titles (or titles that sanitize alike) get -2, -3, ... so no part overwrites another; compared case-insensitively. - let mut unique = file.clone(); + let mut unique = file; let mut n = 1; while !used.insert(unique.to_lowercase()) { n += 1; - unique = format!("{}-{n}.pdf", file.trim_end_matches(".pdf")); + unique = format!("{base}-{n}.pdf"); } let path = child(&dir, &unique); write_atomic(&path, bytes)?; @@ -1781,21 +1914,26 @@ impl Automation { /// A 1-based insertion position (1 = before the first page, n+1 = after the last) → 0-based. fn position(&self, a: &Args, key: &str) -> Result { + self.opt_position(a, key)?.ok_or_else(|| Args::missing(key)) + } + + /// `position`, but `None` when the key is absent. + fn opt_position(&self, a: &Args, key: &str) -> Result> { + let Some(at) = a.opt_int(key)? else { return Ok(None) }; let n = self.doc(a)?.info.pages.len(); - let at = a.int(key)?; if at < 1 || at as usize > n + 1 { return Err(ToolError::InvalidArgs(format!("{key} must be between 1 and {} (the document has {n} pages)", n + 1))); } - Ok(at as usize - 1) + Ok(Some(at as usize - 1)) } 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(); @@ -1817,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() @@ -2269,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"))?; @@ -2394,6 +2543,9 @@ fn info(d: &Document) -> Value { "pdf_version": i.pdf_version, "file_size": i.file_size, "title": i.title, "author": i.author, "subject": i.subject, "keywords": i.keywords, + // Custom document properties (Document Properties ▸ Custom): /Info entries beyond the + // ones the spec names, set and cleared with doc_set_info. + "custom": i.custom.iter().map(|(name, value)| json!({ "name": name, "value": value })).collect::>(), "creator": i.creator, "producer": i.producer, "tagged": i.tagged, "has_javascript": i.has_javascript, @@ -2451,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!({ @@ -2485,24 +2637,9 @@ fn outline(items: &[pdfcraft_render::OutlineItem]) -> Value { Value::Array(items.iter().map(|o| json!({ "title": o.title, "page": o.page.map(|p| p + 1), "children": outline(&o.children) })).collect()) } +/// A rendered raster as PNG, through the engine's encoder (Export ▸ Image writes the same). fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Result> { - let mut rgba = premultiplied.to_vec(); - for px in rgba.as_chunks_mut::<4>().0 { - let a = u32::from(px[3]); - if a != 0 && a != 255 { - for c in &mut px[..3] { - *c = ((u32::from(*c) * 255 + a / 2) / a).min(255) as u8; - } - } - } - let mut out = Vec::new(); - let mut enc = png::Encoder::new(&mut out, width, height); - enc.set_color(png::ColorType::Rgba); - enc.set_depth(png::BitDepth::Eight); - let mut w = enc.write_header().map_err(failed)?; - w.write_image_data(&rgba).map_err(failed)?; - w.finish().map_err(failed)?; - Ok(out) + pdfcraft_engine::export::encode_png(width, height, premultiplied).map_err(failed) } /// Extract the text of `pages` (0-based) with one renderer per worker thread. @@ -3101,4 +3238,23 @@ mod tests { std::fs::set_permissions(&target, perms).unwrap(); let _ = std::fs::remove_dir_all(&dir); } + + /// A recovered document, or a new one, rotated and then undone still has never been saved: + /// `doc_close` refuses it unless told to discard it (#2346 review). + #[test] + fn closing_a_recovered_or_new_document_after_undo_is_refused() { + let mut a = Automation::new(); + let recovered = a.session.open("Report.pdf", None, Arc::new(numbered_pages(2)), None).unwrap(); + a.session.mark_recovered(recovered, Some("Report.pdf".into())); + let new = a.session.open_new("Combined.pdf", Arc::new(numbered_pages(2))).unwrap(); + for id in [recovered, new] { + json(&mut a, "page_rotate", json!({"doc": id.0, "pages": [1], "degrees": 90})); + json(&mut a, "edit_undo", json!({"doc": id.0})); + let refused = a.call("doc_close", &json!({"doc": id.0})); + assert!(matches!(&refused, Err(e) if e.to_string().contains("unsaved changes")), "{id:?}: {refused:?}"); + assert!(a.session.get(id).is_some(), "still open"); + json(&mut a, "doc_close", json!({"doc": id.0, "discard_changes": true})); + assert!(a.session.get(id).is_none()); + } + } } 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 5e89aef78..9ce6b12d4 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -156,12 +156,12 @@ pub fn tools() -> Vec { }), &["doc"], )), - t("doc_set_info", "Set document metadata", "Set a document information entry such as Title, Author, Subject or Keywords; an empty value removes it. When the file has XMP metadata, the matching XMP property (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) is changed too, so viewers that read XMP first show the new value. Undoable.") + t("doc_set_info", "Set document metadata", "Set a document information entry such as Title, Author, Subject or Keywords, or a custom property under any other name (no spaces, delimiters, / or #; at most 127 bytes; doc_info lists them under custom); an empty value removes it. When the file has XMP metadata, the matching XMP property (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) is changed too, so viewers that read XMP first show the new value. Undoable.") .with(schema(json!({ "doc": doc(), "key": { "type": "string" }, "value": { "type": "string" } }), &["doc", "key", "value"])), t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600).") .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.") @@ -224,6 +224,9 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "pages": pages("to move"), "to": { "type": "integer", "minimum": 1 } }), &["doc", "pages", "to"], )), + t("page_reverse", "Reverse pages", "Reverse the order of pages among the positions they occupy: the listed pages (at least two), or the whole document when pages is omitted. Undoable.") + .cmd("page.reverse") + .with(schema(json!({ "doc": doc(), "pages": pages("to reverse (default: all)") }), &["doc"])), t("page_insert_blank", "Insert a blank page", "Insert a blank page so it becomes page `at`. Size defaults to the neighbouring page. Undoable.") .cmd("page.insert_blank") .with(schema( @@ -286,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.") @@ -318,7 +334,7 @@ pub fn tools() -> Vec { }), &["doc", "from", "to"], )), - t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags.") + t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags. Push buttons include caption (first widget's normal label) and captions [{widget, caption}] for all widgets (1-based).") .ro() .with(schema(json!({ "doc": doc() }), &["doc"])), t( @@ -352,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( @@ -371,7 +408,7 @@ pub fn tools() -> Vec { t( "form_set_props", "Field properties", - "Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename), rect (position) and rotation require a single field. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.", + "Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename), rect (position), rotation and caption require a single field. A rename also renames the field in the calculations that read it (calculate field lists and notation, and getField calls naming it in a string literal; names built at run time are not rewritten), in the same Undo step, and formulas_updated lists the fields whose calculation changed. caption changes one push-button normal label (widget: 1-based index, default 1); an empty string clears it. Other widgets and icon-only layouts are preserved. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.", ) .with(schema( json!({ @@ -380,6 +417,8 @@ pub fn tools() -> Vec { "fields": { "type": "array", "items": { "type": "string" }, "minItems": 1, "maxItems": 1000, "uniqueItems": true, "description": "Apply the same changes to these fields in one atomic edit. Use either field or fields." }, "name": { "type": "string" }, "tooltip": { "type": "string" }, + "caption": { "type": "string", "description": "Normal push-button label; an empty string clears it. Requires a single field." }, + "widget": { "type": "integer", "minimum": 1, "description": "Widget whose caption changes (1-based, default 1). Requires caption. See form_fields.captions." }, "read_only": { "type": "boolean" }, "required": { "type": "boolean" }, "multiline": { "type": "boolean" }, @@ -405,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"])), @@ -416,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 }, @@ -429,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)\"]." }, @@ -482,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!({ @@ -507,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)." }, @@ -611,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.") @@ -745,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" } }), &[], )), @@ -757,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"])), @@ -779,16 +829,17 @@ 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; \"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(), "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, "width": { "type": "number", "minimum": 0 }, - "fill": { "type": "string", "description": "Interior colour of a rectangle, oval or polygon: #RRGGBB, a colour name, or \"none\" for no fill." }, + "fill": { "type": "string", "description": "Interior colour of a rectangle, oval, polygon, text box or callout: #RRGGBB, a colour name, or \"none\" for no fill." }, "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"], )), @@ -822,9 +873,9 @@ pub fn tools() -> Vec { t("page_replace", "Replace pages", "Replace the content of pages with pages of another PDF (same count). Links, comments, form fields and bookmarks on the original pages stay, as in Acrobat. Undoable.") .cmd("page.replace") .with(schema(json!({ "doc": doc(), "pages": pages("to replace"), "path": path_arg(), "from_pages": pages("of the other file, in order (default: its first pages)") }), &["doc", "pages", "path"])), - t("page_duplicate", "Duplicate pages", "Insert copies of pages after the last of them (fonts and images are shared, not copied). Undoable.") + t("page_duplicate", "Duplicate pages", "Insert copies of pages so the first copy becomes page `at`, or after the last of them when `at` is left out (fonts and images are shared, not copied). Undoable.") .cmd("page.duplicate") - .with(schema(json!({ "doc": doc(), "pages": pages("to duplicate") }), &["doc", "pages"])), + .with(schema(json!({ "doc": doc(), "pages": pages("to duplicate"), "at": { "type": "integer", "minimum": 1 } }), &["doc", "pages"])), t( "page_transition_set", "Set page transitions", @@ -864,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( @@ -873,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"], @@ -1060,7 +1113,7 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "field": { "type": "string" }, "event": { "type": "string", "enum": ["keystroke", "format", "validate", "calculate", "mouse_up"] }, "script": { "type": "string" } }), &["doc", "field", "event"], )), - t("doc_export_office", "Export to Word, PowerPoint, HTML or RTF", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline) or RTF (.rtf), chosen by path's extension: text, headings, bold and italic. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited.") + t("doc_export_office", "Export to Word, PowerPoint, HTML or RTF", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline) or RTF (.rtf), chosen by path's extension: text, headings, bold and italic. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited. Export reads forms 50 deep and up to 50 000 a page, counting forms the page draws itself. Pages that go past either limit are listed in truncated_pages, with a warning; the text and images of the forms left out are missing.") .cmd("export.docx") .with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])), t("pdfa_verify", "Verify PDF/A", "Standards ▸ Verify PDF/A compliance: the PDF/A-2b or 3b rules the document breaks (ISO 19005 clause, message, page, whether Save as PDF/A can fix it), plus what it declares.") @@ -1073,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") @@ -1098,7 +1153,7 @@ pub fn tools() -> Vec { t("doc_compare_mark", "Mark differences as comments", "Add the differences from other (older) to doc (newer) as comments in doc: highlights over replaced (blue) and inserted (green) text, notes where text was deleted (red), authored Compare. Undoable.") .cmd("doc.compare") .with(schema(json!({ "doc": doc(), "other": { "type": "integer" } }), &["doc", "other"])), - t("form_detect_fields", "Detect form fields", "Prepare a form ▸ automatic field detection: find the blanks a printed form asks to be filled (underscore runs, lines, empty boxes, small squares for check boxes; on scanned pages read from the pixels) and name each field from its label. With add (default true) the fields are created as one undoable step; otherwise they are only proposed. Returns page (1-based), kind, name and rect (points, origin bottom-left).") + t("form_detect_fields", "Detect form fields", "Prepare a form ▸ automatic field detection: find the blanks a printed form asks to be filled (underscore runs, lines, empty boxes, rows of comb boxes, small squares for check boxes; on scanned pages read from the pixels) and name each field from its label. With add (default true) the fields are created as one undoable step; otherwise they are only proposed. Returns page (1-based), kind, name and rect (points, origin bottom-left); a row of comb boxes is one text field with comb set to its number of cells, created with the comb flag and that character limit.") .cmd("form.detect") .with(schema(json!({ "doc": doc(), "pages": pages("to look at (default: all)"), "add": { "type": "boolean" } }), &["doc"])), t("form_actions", "Field actions", "Field Properties ▸ Actions: the field's action for each trigger (mouse_up, mouse_down, mouse_enter, mouse_exit, on_focus, on_blur).") @@ -1241,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"], @@ -1268,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"])), @@ -1276,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/it/automation.rs similarity index 79% rename from crates/automation/tests/automation.rs rename to crates/automation/tests/it/automation.rs index 1e662d8f3..0ebf3a9bc 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/it/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(); @@ -214,6 +273,98 @@ fn page_transitions_are_set_and_removed_through_the_tools() { ); } +/// #1937 through the tools: a damaged file is repaired on open (`doc_info` lists the repair) and +/// `doc_save` over it writes the rebuilt file, so opening it again needs no repair. +#[test] +fn saving_a_repaired_file_puts_the_repair_on_disk() { + let dir = workdir("repaired-save"); + let good = fixture(2); + let mut broken = b"%PDF-1.7\n%shifted\n".to_vec(); + broken.extend_from_slice(&good[b"%PDF-1.7\n".len()..]); + std::fs::write(dir.join("broken.pdf"), &broken).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "broken.pdf" }))["doc"].as_u64().unwrap(); + let info = ok(&mut a, "doc_info", json!({ "doc": doc })); + assert!(info["repairs"].as_array().unwrap().iter().any(|r| r.as_str().unwrap_or("").contains("rebuilt")), "{}", info["repairs"]); + let saved = ok(&mut a, "doc_save", json!({ "doc": doc })); + assert_eq!(saved["incremental"], false, "{saved}"); + let bytes = std::fs::read(dir.join("broken.pdf")).unwrap(); + assert_ne!(bytes, broken, "the damaged bytes were written back"); + let reopened = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)).unwrap(); + assert!(reopened.repair_log().is_empty(), "{:?}", reopened.repair_log()); + let info = ok(&mut a, "doc_info", json!({ "doc": doc })); + assert_eq!(info["repairs"].as_array().unwrap().len(), 0, "{}", info["repairs"]); + assert_eq!(info["pages"].as_array().unwrap().len(), 2); +} + +/// Custom document properties through the tools: set and cleared with doc_set_info under any +/// name the spec does not use, listed by doc_info apart from the standard entries, bad names +/// refused without a change. +#[test] +fn custom_document_properties_are_set_listed_and_cleared_through_the_tools() { + let dir = workdir("custom-info"); + std::fs::write(dir.join("a.pdf"), fixture(1)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Title", "value": "Report" })); + ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Project", "value": "Apollo" })); + ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Revision", "value": "7" })); + let info = ok(&mut a, "doc_info", json!({ "doc": doc })); + assert_eq!(info["title"], "Report"); + let mut custom: Vec<(String, String)> = + info["custom"].as_array().unwrap().iter().map(|c| (c["name"].as_str().unwrap().into(), c["value"].as_str().unwrap().into())).collect(); + custom.sort(); + assert_eq!(custom, [("Project".to_string(), "Apollo".to_string()), ("Revision".to_string(), "7".to_string())], "{}", info["custom"]); + assert!(a.call("doc_set_info", &json!({ "doc": doc, "key": "Bad Name", "value": "x" })).is_err(), "a name with a space is refused"); + assert!(a.call("doc_set_info", &json!({ "doc": doc, "key": "", "value": "x" })).is_err()); + ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Project", "value": "" })); + let info = ok(&mut a, "doc_info", json!({ "doc": doc })); + assert_eq!(info["custom"].as_array().unwrap().len(), 1, "{}", info["custom"]); + assert_eq!(info["custom"][0]["name"], "Revision"); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out/custom.pdf" })); + let mut b = auto(&dir); + let re = ok(&mut b, "doc_open", json!({ "path": "out/custom.pdf" }))["doc"].as_u64().unwrap(); + let info = ok(&mut b, "doc_info", json!({ "doc": re })); + assert_eq!(info["custom"][0]["value"], "7", "{}", info["custom"]); +} + +/// The custom list `doc_info` shows when a file opens is the one it shows after an unrelated +/// edit: a name value, a blank or NUL-padded string and a number are judged the same way by the +/// open-time reader and by the engine's refresh. +#[test] +fn custom_document_properties_do_not_change_after_an_unrelated_edit() { + let dir = workdir("custom-info-stable"); + let objs = [ + "<< /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] >>", + "<< /Producer (Test) /Project (Apollo) /Dept /RnD /Blank ( ) /Padded (Lab\\000\\000) /Revision 7 /Empty () /Mixed ( \\000Lab\\000 ) /Pad ( \\000 ) >>", + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R /Info 4 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + std::fs::write(dir.join("custom.pdf"), pdf).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "custom.pdf" }))["doc"].as_u64().unwrap(); + let opened = ok(&mut a, "doc_info", json!({ "doc": doc }))["custom"].clone(); + let names: Vec<&str> = opened.as_array().unwrap().iter().map(|c| c["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["Project", "Padded", "Mixed"], "{opened}"); + assert_eq!(opened[1]["value"], "Lab", "{opened}"); + assert_eq!(opened[2]["value"], "Lab", "{opened}"); + ok(&mut a, "doc_set_info", json!({ "doc": doc, "key": "Title", "value": "Report" })); + let edited = ok(&mut a, "doc_info", json!({ "doc": doc }))["custom"].clone(); + assert_eq!(edited, opened); +} + #[test] fn autotag_preview_proposes_text_without_changing_the_pdf() { let dir = workdir("autotag-preview"); @@ -388,7 +539,7 @@ fn tool_table_is_well_formed() { #[test] fn explicit_font_and_disjoint_ranges_save_reopen_through_tools() { let dir = workdir("font-ranges"); - std::fs::write(dir.join("inter.ttf"), include_bytes!("../../../assets/fonts/Inter-Regular.ttf")).unwrap(); + std::fs::write(dir.join("inter.ttf"), include_bytes!("../../../../assets/fonts/Inter-Regular.ttf")).unwrap(); let mut a = auto(&dir); std::fs::write(dir.join("broken.otf"), b"not a font").unwrap(); let found = ok(&mut a, "text_font_files", json!({})); @@ -687,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] @@ -2082,6 +2344,34 @@ fn orphan_form_fields_through_tools() { assert_ne!(v, "Filled by an agent"); } +#[test] +fn reversing_pages_through_tools() { + let dir = workdir("reverse"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "page_reverse", json!({ "doc": doc })); + assert_eq!(page_text(&mut a, doc), ["Page 3", "Page 2", "Page 1"], "no pages reverses the whole document"); + ok(&mut a, "page_reverse", json!({ "doc": doc, "pages": [1, 2] })); + assert_eq!(page_text(&mut a, doc), ["Page 2", "Page 3", "Page 1"], "only the listed pages swap"); + ok(&mut a, "edit_undo", json!({ "doc": doc })); + assert_eq!(page_text(&mut a, doc), ["Page 3", "Page 2", "Page 1"]); + assert!(matches!(a.call("page_reverse", &json!({ "doc": doc, "pages": [4] })), Err(ToolError::InvalidArgs(_)))); + assert!(matches!(a.call("page_reverse", &json!({ "doc": doc, "pages": [2] })), Err(ToolError::Failed(_))), "one page has nothing to reverse"); +} + +#[test] +fn page_duplicate_at_inserts_the_copies_there() { + let dir = workdir("duplicate-at"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let r = ok(&mut a, "page_duplicate", json!({ "doc": doc, "pages": [2], "at": 1 })); + assert_eq!(r["pages"], 4); + assert_eq!(page_text(&mut a, doc), ["Page 2", "Page 1", "Page 2", "Page 3"]); + // `at` is 1-based and within 1..=pages+1, as for the other page inserts. + assert!(matches!(a.call("page_duplicate", &json!({ "doc": doc, "pages": [1], "at": 0 })), Err(ToolError::InvalidArgs(_))), "1-based"); + assert!(matches!(a.call("page_duplicate", &json!({ "doc": doc, "pages": [1], "at": 6 })), Err(ToolError::InvalidArgs(_))), "past the end"); +} + #[test] fn duplicating_and_cropping_through_tools() { let dir = workdir("boxes"); @@ -2119,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] @@ -2174,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"); @@ -2474,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 { @@ -2551,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")); @@ -3271,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"); @@ -3295,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"); @@ -3582,6 +4173,121 @@ fn adding_content_through_tools() { assert!(matches!(a.call("content_delete", &json!({ "doc": doc, "page": 1, "index": 5 })), Err(ToolError::Failed(_)))); } +/// An out-of-range `index` (including `i64::MIN`, which overflowed `index - 1`) is a readable +/// failure for `content_update` and `content_delete`, not a panic. +#[test] +fn content_index_out_of_range_is_an_error_not_a_panic() { + let dir = workdir("content-index-range"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "page_add_text", json!({ "doc": doc, "page": 1, "text": "hi", "at": [10, 10] })); + for index in [i64::MIN, i64::MIN + 1, -1, 0, i64::MAX] { + for tool in ["content_delete", "content_update"] { + let args = json!({ "doc": doc, "page": 1, "index": index, "text": "x" }); + let args = if tool == "content_delete" { json!({ "doc": doc, "page": 1, "index": index }) } else { args }; + assert!(matches!(a.call(tool, &args), Err(ToolError::Failed(_))), "{tool} index {index}"); + } + } + 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] @@ -3661,6 +4367,121 @@ fn form_formats_and_calculations_through_tools() { )); } +/// Renaming a field renames it in the formulas that read it: a sum dropped the field, notation +/// read it as 0 and a script kept a stale value. +#[test] +fn renaming_a_field_keeps_the_formulas_that_read_it() { + let dir = workdir("formrename"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + for (i, name) in ["a", "b", "c", "d", "e", "x", "sum", "product", "double", "total"].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 })); + } + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "sum", "calculate": { "op": "sum", "fields": ["a", "b"] } })); + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "product", "calculate": { "notation": "c * d" } })); + ok( + &mut a, + "form_set_script", + json!({ "doc": doc, "field": "double", "event": "calculate", "script": "event.value = getField(\"e\").value * 2;" }), + ); + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": { "op": "sum", "fields": ["x"] } })); + let renamed = |a: &mut Automation, from: &str, to: &str| ok(a, "form_set_props", json!({ "doc": doc, "field": from, "name": to })); + assert_eq!(renamed(&mut a, "a", "qty")["formulas_updated"], json!(["sum"])); + assert_eq!(renamed(&mut a, "c", "price")["formulas_updated"], json!(["product"])); + assert_eq!(renamed(&mut a, "e", "base")["formulas_updated"], json!(["double"])); + assert_eq!(renamed(&mut a, "total", "grand")["formulas_updated"], json!([]), "a calculated field's own name is in no formula"); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "qty": "10", "b": "2", "price": "10", "d": "4", "base": "10", "x": "9" } })); + let values = |a: &mut Automation| { + let f = ok(a, "form_fields", json!({ "doc": doc })); + let value = |name: &str| f["fields"].as_array().unwrap().iter().find(|x| x["name"] == name).map(|x| x["value"].clone()); + [value("sum"), value("product"), value("double"), value("grand")] + }; + assert_eq!(values(&mut a), [Some(json!("12")), Some(json!("40")), Some(json!("20")), Some(json!("9"))]); + let f = ok(&mut a, "form_fields", json!({ "doc": doc })); + let field = |name: &str| f["fields"].as_array().unwrap().iter().find(|x| x["name"] == name).unwrap().clone(); + assert_eq!(field("sum")["calculate"]["fields"], json!(["qty", "b"])); + assert_eq!(field("product")["calculate"]["notation"], "price * d"); + // The rename and the formulas it changed are one undo step: undo the fill, then the last + // three renames. + for _ in 0..4 { + ok(&mut a, "edit_undo", json!({ "doc": doc })); + } + let f = ok(&mut a, "form_fields", json!({ "doc": doc })); + let field = |name: &str| f["fields"].as_array().unwrap().iter().find(|x| x["name"] == name).cloned(); + assert!(field("c").is_some() && field("price").is_none()); + assert_eq!(field("product").unwrap()["calculate"]["notation"], "c * d", "undone together with the rename"); + // The renamed field's own formula, changed in the same call, is not one the rename + // rewrote, though the name is stored trimmed. + let r = ok( + &mut a, + "form_set_props", + json!({ "doc": doc, "field": "sum", "name": " added ", "calculate": { "op": "sum", "fields": ["qty", "b", "x"] } }), + ); + 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"); @@ -3742,15 +4563,13 @@ fn natural_image_stamps_through_tools_on_rotated_pages() { let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap(); let mut pixels = vec![0; reader.output_buffer_size().unwrap()]; let info = reader.next_frame(&mut pixels).unwrap(); - assert_eq!(info.color_type, png::ColorType::Rgba); - for ((x, y), colour) in [(80, 90), (120, 90), (80, 110), (120, 110)].into_iter().zip([ - [240, 20, 20, 255], - [20, 180, 20, 255], - [20, 20, 240, 255], - [230, 180, 20, 255], - ]) { - let offset = ((y * info.width + x) * 4) as usize; - assert_eq!(&pixels[offset..offset + 4], &colour, "page rotation {degrees}"); + // A rendered page is opaque, so it comes as RGB. + assert_eq!(info.color_type, png::ColorType::Rgb); + for ((x, y), colour) in + [(80, 90), (120, 90), (80, 110), (120, 110)].into_iter().zip([[240, 20, 20], [20, 180, 20], [20, 20, 240], [230, 180, 20]]) + { + let offset = ((y * info.width + x) * 3) as usize; + assert_eq!(&pixels[offset..offset + 3], &colour, "page rotation {degrees}"); } } } @@ -3806,6 +4625,35 @@ fn bookmark_split_with_equal_titles_keeps_every_part() { } } +#[test] +fn bookmark_split_with_long_titles_shortens_the_file_names() { + let dir = workdir("organize_long_titles"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + // Two equal titles far over the 255-byte name limit, then a multibyte one (3 bytes a character). + let long = format!("Chapter {}", "x".repeat(300)); + ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": long, "page": 1 })); + ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": long, "page": 2 })); + ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": "章".repeat(100), "page": 3 })); + let s = ok(&mut a, "doc_split", json!({ "doc": doc, "bookmarks": true, "out_dir": "parts" })); + let files: Vec = s["files"] + .as_array() + .unwrap() + .iter() + .map(|f| std::path::Path::new(f["path"].as_str().unwrap()).file_name().unwrap().to_string_lossy().into_owned()) + .collect(); + assert_eq!(files.len(), 3, "{files:?}"); + let distinct: std::collections::HashSet<_> = files.iter().map(|f| f.to_lowercase()).collect(); + assert_eq!(distinct.len(), 3, "{files:?}"); + for f in &files { + assert!(f.len() <= 255, "{f} is {} bytes", f.len()); + assert!(f.ends_with(".pdf"), "{f}"); + assert!(dir.join("parts").join(f).exists(), "{f} missing"); + } + assert!(files[0].starts_with("a-Chapter x"), "{files:?}"); + assert!(files[2].starts_with("a-章"), "{files:?}"); +} + #[test] fn organizing_with_filters_bookmark_splits_and_extract_options() { let dir = workdir("organize2"); @@ -4153,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"); @@ -4508,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. @@ -4959,6 +5868,100 @@ 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"); + for i in 0..5 { + body.push_str(&format!("{} 100 16 20 re S\n", 120 + i * 16)); + } + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>".to_string(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_string(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + let dir = workdir("detect-comb"); + std::fs::write(dir.join("comb.pdf"), pdf).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "comb.pdf" }))["doc"].as_u64().unwrap(); + let r = ok(&mut a, "form_detect_fields", json!({ "doc": doc })); + assert_eq!(r["fields"], json!([{ "page": 1, "kind": "text", "comb": 5, "name": "Postcode", "rect": [120.0, 100.0, 200.0, 120.0] }]), "{r}"); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "Postcode": "75001" } })); + assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"][0]["value"], "75001"); +} + #[test] fn comparing_documents_through_tools() { let dir = workdir("compare"); @@ -5038,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] @@ -5080,6 +6087,74 @@ fn exporting_to_word_html_and_rtf() { assert!(a.call("doc_export_office", &json!({ "doc": doc, "path": "a.xyz" })).is_err()); } +/// #3120: a PDF whose pages draw "Head", then a chain of `depths[page]` nested forms whose +/// innermost writes "TAIL". +fn nested_forms_pdf(depths: &[usize]) -> Vec { + let mut objs: Vec = + vec!["<< /Type /Catalog /Pages 2 0 R >>".into(), String::new(), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into()]; + let mut kids = Vec::new(); + for &depth in depths { + // The object number of D1, D2 and so on. + let first = objs.len() + 1; + let xobjects: String = (1..=depth).map(|i| format!("/D{i} {} 0 R ", first + i - 1)).collect(); + let resources = format!("<< /Font << /F1 3 0 R >> /XObject << {xobjects}>> >>"); + for i in 1..=depth { + let body = if i == depth { "BT /F1 24 Tf 20 150 Td (TAIL) Tj ET".to_string() } else { format!("/D{} Do", i + 1) }; + objs.push(format!( + "<< /Type /XObject /Subtype /Form /BBox [0 0 200 300] /Resources {resources} /Length {} >>\nstream\n{body}\nendstream", + body.len() + )); + } + let body = "BT /F1 24 Tf 20 250 Td (Head) Tj ET /D1 Do"; + objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len())); + objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources {resources} >>", objs.len())); + kids.push(format!("{} 0 R", objs.len())); + } + objs[1] = format!("<< /Type /Pages /Kids [{}] /Count {} /MediaBox [0 0 200 300] >>", kids.join(" "), kids.len()); + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// #3120: export reads forms as deeply nested as the renderer draws them (13 used to lose their +/// text), and its result says which pages draw forms past that, whose text is missing. +#[test] +fn exporting_office_files_says_which_pages_left_form_text_out() { + use pdfcraft_engine::compare::{MAX_FORM_DEPTH, MAX_FORM_VISITS}; + let dir = workdir("office-forms"); + std::fs::write(dir.join("deep.pdf"), nested_forms_pdf(&[13, MAX_FORM_DEPTH + 1, MAX_FORM_DEPTH])).unwrap(); + std::fs::write(dir.join("fine.pdf"), nested_forms_pdf(&[13])).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "deep.pdf" }))["doc"].as_u64().unwrap(); + for ext in ["html", "docx"] { + let r = ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": format!("deep.{ext}") })); + assert_eq!((&r["truncated"], &r["truncated_pages"]), (&json!(true), &json!([2])), "{r}"); + let warning = r["warnings"][0].as_str().unwrap(); + assert!(warning.contains(&format!("{MAX_FORM_DEPTH} deep")) && warning.contains(&MAX_FORM_VISITS.to_string()), "{warning}"); + assert!(warning.contains("truncated_pages"), "{warning}"); + } + let html = std::fs::read_to_string(dir.join("deep.html")).unwrap(); + let tails: Vec = html.split("
").map(|page| page.matches("TAIL").count()).collect(); + assert_eq!(tails, [1, 0, 1], "{html}"); + assert_eq!(html.matches("Head").count(), 3, "{html}"); + + let fine = ok(&mut a, "doc_open", json!({ "path": "fine.pdf" }))["doc"].as_u64().unwrap(); + let r = ok(&mut a, "doc_export_office", json!({ "doc": fine, "path": "fine.html" })); + assert_eq!((&r["truncated"], &r["truncated_pages"]), (&json!(false), &json!([])), "{r}"); + assert!(r.get("warnings").is_none(), "{r}"); + assert!(std::fs::read_to_string(dir.join("fine.html")).unwrap().contains("TAIL")); +} + /// #773: a diagonal watermark must not enter the body flow or move invoice fields. #[test] fn exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow() { @@ -5210,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"); @@ -5300,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); } @@ -5358,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"); @@ -5404,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"); @@ -5449,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) >>", @@ -5485,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 { @@ -6169,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( @@ -6245,7 +7462,8 @@ fn reading_colors_workdir() -> PathBuf { dir } -/// Decodes a `page_render` PNG (pages here are 200×300 pt) into RGBA pixels. +/// Decodes a `page_render` PNG (pages here are 200×300 pt) into RGBA pixels. Opaque pages +/// come as RGB; their alpha is 255. fn png_pixels(content: &Content) -> Vec { let Content::Png { data, width, height } = content else { panic!("expected a PNG") }; assert_eq!((*width, *height), (200, 300)); @@ -6253,7 +7471,11 @@ fn png_pixels(content: &Content) -> Vec { let mut pixels = vec![0; reader.output_buffer_size().unwrap()]; let info = reader.next_frame(&mut pixels).unwrap(); assert_eq!((info.width, info.height), (200, 300)); - pixels + match info.color_type { + png::ColorType::Rgba => pixels, + png::ColorType::Rgb => pixels.as_chunks::<3>().0.iter().flat_map(|p| [p[0], p[1], p[2], 255]).collect(), + other => panic!("unexpected colour type {other:?}"), + } } /// The RGBA pixel at (`x`, `y`), top-left origin, of a 200-wide image. @@ -6262,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] @@ -6495,3 +7802,359 @@ fn a_root_less_write_output_is_atomic_too() { assert!(matches!(&e, ToolError::Failed(m) if m.contains("is a folder")), "{e:?}"); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn fdf_nested_comment_values_survive_export_import_and_save_through_tools() { + use pdfcraft_cos::{Dict, Document, Object, PdfString, SaveOptions, write_full}; + use std::sync::Arc; + let dir = workdir("fdf-nested-values"); + let mut source = Document::open(Arc::new(fixture(1))).unwrap(); + let page = pdfcraft_model::pages(&source)[0].obj; + let mut note = Dict::new(); + note.set(b"Type".to_vec(), Object::name("Annot")); + note.set(b"Subtype".to_vec(), Object::name("Square")); + note.set(b"NM".to_vec(), PdfString::text("nested-values")); + note.set(b"Rect".to_vec(), Object::Array([10, 20, 30, 40].map(|value| Object::Ref(source.add(Object::Int(value)))).into())); + note.set(b"C".to_vec(), Object::Array([0.25, 0.5, 0.75].map(|value| Object::Ref(source.add(Object::Real(value)))).into())); + let note = source.add(note); + source.update_dict(page, |dict| dict.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(note)]))).unwrap(); + std::fs::write(dir.join("source.pdf"), write_full(&source, &SaveOptions::default()).unwrap()).unwrap(); + let mut a = auto(&dir); + let source = ok(&mut a, "doc_open", json!({ "path": "source.pdf" }))["doc"].as_u64().unwrap(); + + ok(&mut a, "doc_export_data", json!({ "doc": source, "path": "notes.fdf", "what": "comments" })); + let target = ok(&mut a, "doc_open", json!({ "path": "b.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_import_data", json!({ "doc": target, "path": "notes.fdf" })); + ok(&mut a, "doc_save", json!({ "doc": target, "path": "restored.pdf" })); + let reopened = ok(&mut a, "doc_open", json!({ "path": "restored.pdf" }))["doc"].as_u64().unwrap(); + let after = ok(&mut a, "comment_list", json!({ "doc": reopened })); + // The saved input has legal indirect scalar numbers. The engine's source inspection + // currently reads those as defaults, so compare the restored data to the fixture's + // known values (converted from PDF bottom-left to the tool's top-left coordinates). + assert_eq!(after["comments"][0]["rect"], json!([10.0, 260.0, 30.0, 280.0])); + assert_eq!(after["comments"][0]["color"], "#4080BF"); +} + +#[test] +fn refused_fdf_export_keeps_the_destination_and_source_through_tools() { + use pdfcraft_cos::{Dict, Document, Object, SaveOptions, write_full}; + use std::sync::Arc; + let dir = workdir("fdf-export-limit"); + let mut source = Document::open(Arc::new(fixture(1))).unwrap(); + let page = pdfcraft_model::pages(&source)[0].obj; + let mut note = Dict::new(); + note.set(b"Type".to_vec(), Object::name("Annot")); + note.set(b"Subtype".to_vec(), Object::name("Square")); + note.set(b"Rect".to_vec(), Object::Array(vec![10.into(), 20.into(), 30.into(), 40.into()])); + note.set(b"PCData".to_vec(), Object::Array(vec![Object::Null; 100_001])); + let note = source.add(note); + source.update_dict(page, |dict| dict.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(note)]))).unwrap(); + let bytes = write_full(&source, &SaveOptions::default()).unwrap(); + std::fs::write(dir.join("source.pdf"), &bytes).unwrap(); + std::fs::write(dir.join("notes.fdf"), b"existing destination").unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "source.pdf" }))["doc"].as_u64().unwrap(); + let before = ok(&mut a, "comment_list", json!({ "doc": doc })); + let error = a.call("doc_export_data", &json!({ "doc": doc, "path": "notes.fdf", "what": "comments" })).unwrap_err(); + assert!(error.to_string().contains("100,000 values"), "{error}"); + assert_eq!(std::fs::read(dir.join("notes.fdf")).unwrap(), b"existing destination"); + assert_eq!(std::fs::read(dir.join("source.pdf")).unwrap(), bytes); + assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc })), before); +} + +#[test] +fn text_box_and_callout_fill_and_border_through_tools() { + let dir = workdir("textbox-fill"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + for kind in ["textbox", "callout"] { + let mut args = json!({ "doc": doc, "page": 1, "type": kind, "rect": [80, 40, 180, 100], "contents": "Example" }); + if kind == "callout" { + args["to"] = json!([20, 120]); + } + let added = ok(&mut a, "comment_add", args); + let comment = &added["comment"]; + ok(&mut a, "comment_edit", json!({ "doc": doc, "page": 1, "index": comment["index"], "fill": "white", "width": 3 })); + } + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "filled.pdf" })); + let mut session = pdfcraft_engine::Session::new(); + let bytes = std::sync::Arc::new(std::fs::read(dir.join("filled.pdf")).unwrap()); + let saved = session.open("filled.pdf", None, bytes, None).unwrap(); + for index in 0..2 { + let props = session.get(saved).unwrap().comment_props(0, index).unwrap(); + assert_eq!((props.fill, props.width), (Some([1.0; 3]), Some(3.0))); + ok(&mut a, "comment_edit", json!({ "doc": doc, "page": 1, "index": index + 1, "fill": "none" })); + } + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "transparent.pdf" })); + let bytes = std::sync::Arc::new(std::fs::read(dir.join("transparent.pdf")).unwrap()); + let saved = session.open("transparent.pdf", None, bytes, None).unwrap(); + for index in 0..2 { + 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/automation_roots.rs b/crates/automation/tests/it/automation_roots.rs similarity index 100% rename from crates/automation/tests/automation_roots.rs rename to crates/automation/tests/it/automation_roots.rs diff --git a/crates/automation/tests/batch.rs b/crates/automation/tests/it/batch.rs similarity index 95% rename from crates/automation/tests/batch.rs rename to crates/automation/tests/it/batch.rs index b2d342894..af5e0c127 100644 --- a/crates/automation/tests/batch.rs +++ b/crates/automation/tests/it/batch.rs @@ -11,7 +11,17 @@ fn result(auto: &mut Automation, args: Value) -> Value { // Fresh fixtures include the current timestamp; retain their bytes for output comparisons. fn pdf() -> Vec { - Session::new().create_from_text("Original", "Original text").unwrap().as_ref().clone() + // Reuse one synthetic document so byte comparisons cannot straddle the + // writer's modification-time clock. Each caller gets an independent buffer. + static PDF: std::sync::OnceLock> = std::sync::OnceLock::new(); + PDF.get_or_init(|| Session::new().create_from_text("Original", "Original text").unwrap().as_ref().clone()).clone() +} + +#[test] +fn pdf_fixture_remains_identical_across_metadata_clock_tick() { + let before = pdf(); + std::thread::sleep(std::time::Duration::from_millis(1100)); + assert_eq!(before, pdf()); } #[test] diff --git a/crates/automation/tests/it/button_caption.rs b/crates/automation/tests/it/button_caption.rs new file mode 100644 index 000000000..cb1a22a02 --- /dev/null +++ b/crates/automation/tests/it/button_caption.rs @@ -0,0 +1,91 @@ +//! Editing a button's visible label through the CLI/MCP tool table (#2892). + +use pdfcraft_automation::{Automation, Content}; +use pdfcraft_engine::{DocId, Edit, NewField, Session}; +use serde_json::{Value, json}; + +fn tool(a: &mut Automation, name: &str, args: Value) -> Value { + match a.call(name, &args).unwrap().remove(0) { + Content::Json(value) => value, + other => panic!("expected JSON: {other:?}"), + } +} + +#[test] +fn button_caption_can_be_changed_after_creation() { + let dir = std::env::temp_dir().join(format!("pdfcraft-button-caption-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let mut session = Session::new(); + let bytes = session.create_blank(300.0, 400.0, 1).unwrap(); + let id = session.open("buttons.pdf", None, bytes, None).unwrap(); + session + .apply( + id, + Edit::AddField { + page: 0, + rect: [40.0, 300.0, 200.0, 330.0], + kind: NewField::Button { caption: String::new() }, + name: Some("clear_button".into()), + }, + ) + .unwrap(); + std::fs::write(dir.join("buttons.pdf"), session.save_bytes(id).unwrap().as_slice()).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap().with_clock(|| 1_700_000_000); + let id = tool(&mut a, "doc_open", json!({"path": "buttons.pdf"}))["doc"].as_u64().unwrap(); + let before = a.session().save_bytes(DocId(id)).unwrap(); + let (_, _, empty_pixels) = a.render_pixels(id, 1, 1.0).unwrap(); + tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "caption": "Clear Form"})); + let fields = tool(&mut a, "form_fields", json!({"doc": id})); + assert_eq!(fields["fields"][0]["caption"], "Clear Form"); + assert_eq!(fields["fields"][0]["captions"], json!([{"widget": 1, "caption": "Clear Form"}])); + assert_eq!(a.session().get(DocId(id)).unwrap().can_undo(), Some("Change field properties")); + let (_, _, labelled_pixels) = a.render_pixels(id, 1, 1.0).unwrap(); + assert_ne!(empty_pixels, labelled_pixels, "the label must reach the actual page appearance"); + tool(&mut a, "edit_undo", json!({"doc": id})); + assert_eq!(a.session().save_bytes(DocId(id)).unwrap(), before); + tool(&mut a, "edit_redo", json!({"doc": id})); + tool(&mut a, "doc_save", json!({"doc": id, "path": "labelled.pdf"})); + let saved = std::sync::Arc::new(std::fs::read(dir.join("labelled.pdf")).unwrap()); + let doc = pdfcraft_cos::Document::open(saved).unwrap(); + let root = doc.get(doc.root().unwrap()); + let form = doc.resolve(root.as_dict().unwrap().get(b"AcroForm").unwrap()); + let fields = doc.resolve(form.as_dict().unwrap().get(b"Fields").unwrap()); + let button = doc.resolve(&fields.as_array().unwrap()[0]); + let mk = doc.resolve(button.as_dict().unwrap().get(b"MK").unwrap()); + assert_eq!(doc.resolve(mk.as_dict().unwrap().get(b"CA").unwrap()).as_string().unwrap().to_text(), "Clear Form"); + assert_eq!(doc.resolve(button.as_dict().unwrap().get(b"T").unwrap()).as_string().unwrap().to_text(), "clear_button"); + let reopened = tool(&mut a, "doc_open", json!({"path": "labelled.pdf"}))["doc"].as_u64().unwrap(); + let fields = tool(&mut a, "form_fields", json!({"doc": reopened})); + assert_eq!(fields["fields"][0]["caption"], "Clear Form"); + let (_, _, pixels) = a.render_pixels(reopened, 1, 1.0).unwrap(); + assert_eq!(pixels, labelled_pixels); + let out = tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "caption": "提交"})); + assert!(out["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).any(|w| w.contains("U+63D0"))); + let fields = tool(&mut a, "form_fields", json!({"doc": id})); + assert_eq!(fields["fields"][0]["caption"], "提交", "unsupported characters are retained and reported"); + tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "caption": "Clear Form"})); + for invalid in [ + json!({"doc": id, "field": "clear_button", "caption": "bad", "widget": 0}), + json!({"doc": id, "field": "clear_button", "caption": "bad", "widget": -1}), + json!({"doc": id, "field": "clear_button", "caption": "bad", "widget": 2}), + json!({"doc": id, "field": "clear_button", "caption": "bad", "widget": i64::MAX}), + json!({"doc": id, "field": "clear_button", "caption": 42}), + json!({"doc": id, "field": "clear_button", "widget": 1, "tooltip": "bad"}), + json!({"doc": id, "fields": ["clear_button"], "caption": "bad"}), + ] { + let before = a.session().save_bytes(DocId(id)).unwrap(); + assert!(a.call("form_set_props", &invalid).is_err(), "{invalid}"); + assert_eq!(a.session().save_bytes(DocId(id)).unwrap(), before, "{invalid}"); + } + tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "locked": true})); + let before = a.session().save_bytes(DocId(id)).unwrap(); + assert!(a.call("form_set_props", &json!({"doc": id, "field": "clear_button", "caption": "bad"})).is_err()); + assert_eq!(a.session().save_bytes(DocId(id)).unwrap(), before); + tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "locked": false})); + tool(&mut a, "form_set_props", json!({"doc": id, "field": "clear_button", "caption": ""})); + let fields = tool(&mut a, "form_fields", json!({"doc": id})); + assert_eq!(fields["fields"][0]["caption"], ""); + let (_, _, pixels) = a.render_pixels(id, 1, 1.0).unwrap(); + assert_eq!(pixels, empty_pixels); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/automation/tests/form_bulk.rs b/crates/automation/tests/it/form_bulk.rs similarity index 100% rename from crates/automation/tests/form_bulk.rs rename to crates/automation/tests/it/form_bulk.rs diff --git a/crates/automation/tests/hostile_args.rs b/crates/automation/tests/it/hostile_args.rs similarity index 64% rename from crates/automation/tests/hostile_args.rs rename to crates/automation/tests/it/hostile_args.rs index 607696899..d393e3726 100644 --- a/crates/automation/tests/hostile_args.rs +++ b/crates/automation/tests/it/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/it/main.rs b/crates/automation/tests/it/main.rs new file mode 100644 index 000000000..8bb3c7781 --- /dev/null +++ b/crates/automation/tests/it/main.rs @@ -0,0 +1,9 @@ +//! The crate's integration tests, linked into one test binary. + +mod automation; +mod automation_roots; +mod batch; +mod button_caption; +mod form_bulk; +mod hostile_args; +mod optimize_content_boundaries; diff --git a/crates/automation/tests/it/optimize_content_boundaries.rs b/crates/automation/tests/it/optimize_content_boundaries.rs new file mode 100644 index 000000000..c37da05eb --- /dev/null +++ b/crates/automation/tests/it/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 32334e047..eb126ffa2 100644 --- a/crates/content/src/lib.rs +++ b/crates/content/src/lib.rs @@ -60,6 +60,10 @@ pub struct Parsed { pub ops: Vec, /// Bytes that could not be read as operands or operators. pub skipped: usize, + /// For [`Pieces::parse`]: per piece, whether a comment runs into it from the piece before + /// and whether one runs out of it into the next. Only boundaries are recorded, so the size + /// depends on the number of pieces, not of comments. + comment_edges: Vec<(bool, bool)>, } fn is_ws(b: u8) -> bool { @@ -72,12 +76,25 @@ fn is_delim(b: u8) -> bool { /// Parse a (decoded) content stream. pub fn parse(data: &[u8]) -> Parsed { - let mut out = Parsed::default(); + parse_pieces(data, &[]) +} + +/// Parse `data`, recording which of the piece boundaries `ends` (as in [`Pieces`]) a comment +/// crosses. +fn parse_pieces(data: &[u8], ends: &[usize]) -> Parsed { + let mut out = Parsed { comment_edges: vec![(false, false); ends.len()], ..Parsed::default() }; + let mut boundary = 0; let mut lx = Lexer::new(data, 0); let mut operands: Vec = Vec::new(); let mut start: Option = None; loop { + let trivia = lx.pos; lx.skip_ws(); + // Only the whitespace the lexer skips here holds top-level comments: a `%` inside a + // string, a name, an array or dictionary operand or inline image data is not one. + if boundary < ends.len() { + comment_edges(data, trivia..lx.pos, ends, &mut boundary, &mut out.comment_edges); + } let Some(&b) = data.get(lx.pos) else { break }; let at = lx.pos; let operand_start = matches!(b, b'/' | b'(' | b'<' | b'[' | b'+' | b'-' | b'.') || b.is_ascii_digit(); @@ -148,6 +165,39 @@ pub fn parse(data: &[u8]) -> Parsed { out } +/// Record the piece boundaries crossed by the comments in `trivia` (skipped whitespace and +/// comments). `boundary` is the first boundary not yet passed; boundaries only move forward. +fn comment_edges(data: &[u8], trivia: std::ops::Range, ends: &[usize], boundary: &mut usize, edges: &mut [(bool, bool)]) { + let mut at = trivia.start; + while at < trivia.end { + if data.get(at) != Some(&b'%') { + at += 1; + continue; + } + let comment = at; + while at < trivia.end && !matches!(data.get(at), Some(b'\r' | b'\n')) { + at += 1; + } + // The separator of piece `boundary` is the last byte before its end. + while let Some(&end) = ends.get(*boundary) { + let separator = end.saturating_sub(1); + if separator >= at { + break; + } + if separator >= comment { + if let Some(edge) = edges.get_mut(*boundary) { + edge.1 = true; + } + // Includes a comment whose end of line is the next piece's first byte. + if let Some(edge) = edges.get_mut(boundary.saturating_add(1)) { + edge.0 = true; + } + } + *boundary += 1; + } + } +} + /// Read an inline image starting after `BI`: (parameters, data, position after `EI`). `Err` holds /// the position after the `EI` (or the end of the stream) of an image whose parameters can't be read. fn inline_image(data: &[u8], pos: usize) -> Result<(Dict, Vec, usize), usize> { @@ -169,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. @@ -243,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 }; @@ -305,7 +413,9 @@ pub fn serialize_ops(ops: &[Op]) -> Vec { /// A page's content streams joined into the one stream they are. Writers may split it between /// any two tokens (ISO 32000-2 §7.8.2), so an operator's operands can end one piece and its /// keyword start the next: parse the pieces joined ([`Pieces::parse`]) and map positions back -/// with [`Pieces::piece_of`]. A newline separates the pieces so tokens never run together. +/// with [`Pieces::piece_of`]. A space separates the pieces, as renderers join them (hayro, +/// MuPDF and Poppler alike): tokens never run together, and a comment at the end of a piece +/// runs on into the next one, up to its first end of line. #[derive(Clone, Debug, Default, PartialEq)] pub struct Pieces { data: Vec, @@ -319,7 +429,7 @@ impl Pieces { let mut ends = Vec::with_capacity(pieces.len()); for p in pieces { data.extend_from_slice(p.as_ref()); - data.push(b'\n'); + data.push(b' '); ends.push(data.len()); } Pieces { data, ends } @@ -341,12 +451,22 @@ impl Pieces { /// Parse the pieces as one stream. pub fn parse(&self) -> Parsed { - parse(&self.data) + parse_pieces(&self.data, &self.ends) + } + + /// Per piece, from `parsed` (this piece set's [`Pieces::parse`]): whether a comment runs + /// into it from the piece before, and whether one runs out of it into the next. A writer + /// that rewrites a piece without its comments must keep both: end the incoming comment + /// before its first operator, and leave a comment open at its end. + pub fn comment_edges(&self, parsed: &Parsed) -> Vec<(bool, bool)> { + let mut edges = parsed.comment_edges.clone(); + edges.resize(self.len(), (false, false)); + edges } /// The piece holding joined position `at` (the last piece for positions past the end). pub fn piece_of(&self, at: usize) -> usize { - self.ends.iter().position(|&e| at < e).unwrap_or(self.ends.len().saturating_sub(1)) + self.ends.partition_point(|&e| e <= at).min(self.ends.len().saturating_sub(1)) } /// The pieces an operator spans: (the one its first token is in, the one its keyword is in). diff --git a/crates/content/src/tests.rs b/crates/content/src/tests.rs index 18e0b8cbc..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}; @@ -283,3 +309,153 @@ fn pieces_parse_as_one_stream_and_splice_back() { assert_eq!(out.len(), 3); assert!(Pieces::join::<&[u8]>(&[]).splice([(0..1, b"x".to_vec())]).is_empty()); } + +#[test] +fn stream_boundary_comment_matches_space_concatenation() { + for eol in [b"\n".as_slice(), b"\r", b"\r\n"] { + let mut tail = b"(HIDDEN) Tj".to_vec(); + tail.extend(eol); + tail.extend(b"(LIVE) Tj"); + let pieces = Pieces::join(&[b"BT % comment".as_slice(), b"(ALSO HIDDEN) Tj", &tail, b"ET"]); + let parsed = pieces.parse(); + let shown: Vec<_> = parsed.ops.iter().filter(|op| op.is("Tj")).collect(); + assert_eq!(shown.len(), 1); + assert_eq!(shown[0].operands[0].as_string().unwrap().bytes, b"LIVE"); + } +} + +#[test] +fn comment_edges_terminate_at_next_piece_byte_zero() { + for eol in [b"\n".as_slice(), b"\r", b"\r\n"] { + let mut next = eol.to_vec(); + next.extend_from_slice(b"q Q"); + let pieces = Pieces::join(&[b"% outgoing".as_slice(), next.as_slice()]); + let parsed = pieces.parse(); + assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, false)], "EOL {eol:?}"); + assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::>(), vec![b"q".as_slice(), b"Q"]); + } +} + +#[test] +fn comment_edges_span_empty_pieces_and_end_of_content() { + let pieces = Pieces::join(&[b"q % outgoing".as_slice(), b"", b"", b"ignored\nQ"]); + let parsed = pieces.parse(); + assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, true), (true, true), (true, false)]); + assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::>(), vec![b"q".as_slice(), b"Q"]); + + let pieces = Pieces::join(&[b"% outgoing".as_slice(), b"", b"still ignored"]); + let parsed = pieces.parse(); + assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, true), (true, true)]); + assert!(parsed.ops.is_empty()); + + let pieces = Pieces::join::<&[u8]>(&[]); + assert!(pieces.comment_edges(&pieces.parse()).is_empty()); +} + +#[test] +fn comment_edges_ignore_percent_inside_operands_and_image_data() { + for first in [ + b"(literal % not a comment) Tj".as_slice(), + b"/Percent#25Name MP", + b"[(literal % not a comment)] TJ", + b"/P << /ActualText (literal % not a comment) >> BDC EMC", + b"BI /W 1 /H 1 /BPC 8 /CS /G ID % EI", + ] { + let pieces = Pieces::join(&[first, b"q Q"]); + let parsed = pieces.parse(); + assert_eq!(pieces.comment_edges(&parsed), vec![(false, false), (false, false)], "{first:?}"); + assert_eq!(parsed.ops.last().unwrap().op, b"Q"); + assert_eq!(parsed.skipped, 0); + } +} + +#[test] +fn compound_operand_comment_split_is_owned_by_spanning_operator() { + for parts in [[b"[(A) % ignored".as_slice(), b"still ignored\n(B)] TJ"], [b"/P << /MCID 0 % ignored".as_slice(), b"still ignored\n>> BDC"]] { + let pieces = Pieces::join(&parts); + let parsed = pieces.parse(); + // The object lexer consumes this nested comment. Its containing operator spans + // both pieces, which forces both to be rewritten together by the redactor. + assert_eq!(pieces.comment_edges(&parsed), vec![(false, false), (false, false)]); + assert_eq!(parsed.ops.len(), 1); + assert_eq!(pieces.pieces_of(&parsed.ops[0]), (0, 1)); + assert_eq!(parsed.skipped, 0); + } +} + +#[test] +fn comment_edges_do_not_confuse_local_comments_with_boundary_comments() { + let pieces = Pieces::join(&[b"% local\nq % crossing".as_slice(), b"ignored\n% local again\nQ\n% final local\n", b"q Q"]); + let parsed = pieces.parse(); + assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, false), (false, false)]); + assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::>(), vec![b"q".as_slice(), b"Q", b"q", b"Q"]); +} + +#[test] +fn comment_edge_storage_depends_on_piece_count_not_comment_count() { + let mut first = b"%\n".repeat(16_384); + first.extend_from_slice(b"q % crossing"); + let pieces = Pieces::join(&[first.as_slice(), b"ignored\nQ"]); + let parsed = pieces.parse(); + assert_eq!(pieces.comment_edges(&parsed), vec![(false, true), (true, false)]); + assert_eq!(parsed.ops.len(), 2); + // Also exercise public parsing without piece-boundary metadata. + assert_eq!(parse(&first).ops.len(), 1); +} + +#[test] +fn piece_lookup_preserves_exact_boundary_and_empty_piece_mapping() { + // Joined layout: qQ; separator belongs to prior piece. + let pieces = Pieces::join(&[b"q".as_slice(), b"", b"Q"]); + for (at, expected) in [(0, 0), (1, 0), (2, 1), (3, 2), (4, 2), (5, 2), (usize::MAX, 2)] { + assert_eq!(pieces.piece_of(at), expected, "position {at}"); + } + let empty = Pieces::join::<&[u8]>(&[]); + 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/Cargo.toml b/crates/cos/Cargo.toml index 9ef9126ca..a2fb3f4c4 100644 --- a/crates/cos/Cargo.toml +++ b/crates/cos/Cargo.toml @@ -12,6 +12,8 @@ workspace = true pdfcraft-crypt = { workspace = true } pdfcraft-filters = { workspace = true } thiserror = { workspace = true } +# PDF date strings (`pdf_date`) go through chrono (no hand-rolled calendar code). +chrono = { workspace = true } [dev-dependencies] pdfcraft-oracle = { workspace = true } diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index a0b1f56ee..ae00234d0 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -82,6 +82,8 @@ struct XrefSection { row_count: usize, max_num: u32, has_entries: bool, + /// A table subsection was read one number lower than it says ([`subsection_start`]). + renumbered: bool, } #[derive(Clone, Debug)] @@ -266,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, @@ -307,15 +323,30 @@ pub struct Revision { pub xref_offset: u64, /// `true` if the section is a cross-reference stream. pub is_stream: bool, + /// `true` if `xref_offset` counts from the `%PDF-` header rather than from the start of the + /// file. The two differ only in a file with bytes before its header, and an update appended + /// to such a file must count the same way as the section it points back to. + pub header_relative: bool, } #[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 { @@ -337,6 +368,40 @@ impl PartialEq for SourceIdentity { impl Eq for SourceIdentity {} +/// What a document reads as: its source ([`SourceIdentity`]), its trailer and every object changed +/// since it was opened. A clone, or a snapshot that undo restores, has the same state, and every +/// change makes a different one. Changed objects are compared as allocations, which the state +/// keeps alive, so a later object can't take an address one of them had. +#[derive(Clone)] +pub struct ReadState { + source: SourceIdentity, + trailer: Dict, + overlay: BTreeMap, +} + +impl ReadState { + /// Whether `doc` reads as this state. + pub fn matches(&self, doc: &Document) -> bool { + self.source == doc.source_identity() + && self.trailer == doc.trailer + && self.overlay.len() == doc.overlay.len() + && self.overlay.iter().zip(&doc.overlay).all(|((a, x), (b, y))| { + a == b + && match (x, y) { + (Slot::Set(g, o), Slot::Set(h, p)) => g == h && Arc::ptr_eq(o, p), + (Slot::Freed(g), Slot::Freed(h)) => g == h, + _ => false, + } + }) + } +} + +impl std::fmt::Debug for ReadState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ReadState").field("changed_objects", &self.overlay.len()).finish_non_exhaustive() + } +} + #[derive(Clone)] pub struct Document { data: Arc>, @@ -346,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>>>, @@ -395,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 @@ -468,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(), @@ -549,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(), @@ -576,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() { @@ -801,6 +873,12 @@ impl Document { SourceIdentity(Arc::downgrade(&self.source_identity)) } + /// What the document reads as now (see [`ReadState`]): unlike [`Self::source_identity`], it + /// changes with every edit, so a value derived from the document can be cached against it. + pub fn read_state(&self) -> ReadState { + ReadState { source: self.source_identity(), trailer: self.trailer.clone(), overlay: self.overlay.clone() } + } + pub fn version(&self) -> &str { &self.version } @@ -817,6 +895,19 @@ impl Document { &self.revisions } + /// Position of the `%PDF-` header in the file: 0 unless bytes come before it. + pub fn header_offset(&self) -> usize { + self.header_offset + } + + /// There is no cross-reference chain to append to: the index was rebuilt from object + /// headers because the file's own was unusable (see [`Self::repair_log`]), or the document + /// was built from scratch ([`Self::new_empty`]). A save writes such a document in full, even + /// when nothing was edited: for a repaired file that is what puts the repair on disk. + pub fn reconstructed(&self) -> bool { + self.revisions.is_empty() + } + /// Where each revision of the file ends (oldest first): the byte after the `%%EOF` (and its /// end-of-line) that closes its cross-reference section. `data[..end]` is that revision as it /// was saved. A linearized file's first-page section belongs to the revision it precedes, so @@ -851,9 +942,10 @@ impl Document { self.repair_log = Arc::new(log); } - /// `true` when there are unsaved edits. + /// `true` when there are unsaved edits. Removing an `/Encrypt` dictionary written inline in + /// the trailer (as MuPDF writes it) changes no object, and is an edit all the same. pub fn is_modified(&self) -> bool { - !self.overlay.is_empty() + !self.overlay.is_empty() || self.encryption_changed } /// Object numbers changed since the document was opened (or last saved). @@ -890,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))), } } @@ -1005,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 }; @@ -1091,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() } } @@ -1113,15 +1201,27 @@ impl Document { if let Some(s) = self.objstms.lock().map_err(|_| CosError::Poisoned)?.get(&num) { return Ok(s.clone()); } - let Object::Stream(s) = &*self.try_get(num)? else { return Err(CosError::MissingObject(num)) }; + // A file entry that cannot place the stream (one that says it is itself compressed + // inside another stream, as in #2367, or points nowhere) is tried by the stream's own + // header, as a plain object with a wrong offset is. An edited or freed stream, in the + // overlay or in a saved revision's table, is what that says it is: the file's copy + // must not come back. + let loaded = match self.try_get(num) { + Ok(o) if matches!(*o, Object::Stream(_)) => o, + _ if self.overlay.contains_key(&num) || matches!(self.xref_entry(num), Some(XrefEntry::Free { .. })) => { + return Err(CosError::MissingObject(num)); + } + _ => Arc::new(self.scan_for(num).map(|(id, o)| self.decrypted(id, o)).ok_or(CosError::MissingObject(num))?), + }; + 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. @@ -1345,13 +1445,13 @@ impl Document { log.push(format!("cross-reference chain loops at offset {off}; stopped")); break; } - let (primary, sect_trailer, is_stream) = match self.read_compact_section(off as usize) { - Ok(Some(v)) => v, + let ((primary, sect_trailer, is_stream), header_relative) = match self.read_compact_section(off as usize) { + Ok(Some(v)) => (v, false), Ok(None) => return Ok(None), Err(e) if self.header_offset > 0 => { log.push(format!("xref at {off} unreadable ({e}); retrying relative to the header")); match self.read_compact_section(off as usize + self.header_offset)? { - Some(v) => v, + Some(v) => (v, true), None => return Ok(None), } } @@ -1359,7 +1459,10 @@ impl Document { // materialized reader logs it and the file is rebuilt from object headers (#1656). Err(e) => return Err(e), }; - revisions.push(Revision { xref_offset: off, is_stream }); + revisions.push(Revision { xref_offset: off, is_stream, header_relative }); + if primary.renumbered { + log.push(format!("xref subsection at {off} is numbered from 1 but begins with object 0's entry; read from 0")); + } let Some(rows) = retained_rows.checked_add(primary.row_count).filter(|rows| *rows <= MAX_COMPACT_XREF_ROWS) else { return Ok(None) }; let Some(bytes) = retained_bytes.checked_add(primary.retained_bytes()).filter(|bytes| *bytes <= MAX_COMPACT_XREF_BYTES) else { return Ok(None); @@ -1401,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); } @@ -1434,6 +1537,7 @@ impl Document { let mut has_entries = false; let mut previous_end = None; let mut row_count = 0usize; + let mut renumbered = false; loop { lx.skip_ws(); if lx.eat_keyword(b"trailer") { @@ -1442,7 +1546,7 @@ impl Document { if !layout_ok { return Ok(None); } - return Ok(Some((XrefSection { data: XrefSectionData::Table, ranges, row_count, max_num, has_entries }, d, false))); + return Ok(Some((XrefSection { data: XrefSectionData::Table, ranges, row_count, max_num, has_entries, renumbered }, d, false))); } let (Ok(Object::Int(start)), Ok(Object::Int(count))) = (lx.object(), lx.object()) else { return Err(CosError::Syntax { offset: lx.pos, detail: "bad xref subsection header".into() }); @@ -1450,6 +1554,9 @@ 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, 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) }; if next_rows > MAX_COMPACT_XREF_ROWS { return Ok(None); @@ -1602,7 +1709,14 @@ impl Document { } let max_num = ranges.iter().filter_map(|range| range.start.checked_add(range.count.saturating_sub(1))).max().unwrap_or(0); let row_count = row_start; - let section = XrefSection { data: XrefSectionData::Stream { bytes: Arc::new(raw), widths }, ranges, row_count, max_num, has_entries: false }; + let section = XrefSection { + data: XrefSectionData::Stream { bytes: Arc::new(raw), widths }, + ranges, + row_count, + max_num, + has_entries: false, + renumbered: false, + }; let has_entries = section.has_any_entry(); let section = XrefSection { has_entries, ..section }; Ok(Some((section, stream.dict.clone(), true))) @@ -1636,13 +1750,13 @@ impl Document { let mut sect = BTreeMap::new(); // Object numbers whose entry in this section was set aside ([`Self::xref_oddities`]). let mut odd = Vec::new(); - let (sect_trailer, is_stream) = match self.read_section(off as usize, &mut sect, &mut odd) { - Ok(v) => v, + let ((sect_trailer, is_stream), header_relative) = match self.read_section(off as usize, &mut sect, &mut odd, log) { + Ok(v) => (v, false), Err(e) if self.header_offset > 0 => { log.push(format!("xref at {off} unreadable ({e}); retrying relative to the header")); sect.clear(); odd.clear(); - self.read_section(off as usize + self.header_offset, &mut sect, &mut odd)? + (self.read_section(off as usize + self.header_offset, &mut sect, &mut odd, log)?, true) } Err(e) => { // An unreadable older section loses every object only it locates (the page @@ -1653,12 +1767,12 @@ impl Document { return Err(e); } }; - revisions.push(Revision { xref_offset: off, is_stream }); + revisions.push(Revision { xref_offset: off, is_stream, header_relative }); // Hybrid files (§7.5.8.4): the /XRefStm entries supplement the table and replace the // free entries it gives objects only a cross-reference stream can locate. if let Some(x) = sect_trailer.int(b"XRefStm").filter(|x| *x >= 0) { let mut stm = BTreeMap::new(); - match self.read_section(x as usize, &mut stm, &mut odd) { + match self.read_section(x as usize, &mut stm, &mut odd, log) { Ok(_) => { for (num, e) in stm { match sect.get(&num) { @@ -1678,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); } @@ -1693,7 +1807,13 @@ impl Document { /// Read one section at `off`, adding entries not already known (newer sections win). `odd` /// collects the object numbers whose entry here is set aside ([`Self::xref_oddities`]). - fn read_section(&self, off: usize, entries: &mut BTreeMap, odd: &mut Vec) -> Result<(Dict, bool), CosError> { + fn read_section( + &self, + off: usize, + entries: &mut BTreeMap, + odd: &mut Vec, + log: &mut Vec, + ) -> Result<(Dict, bool), CosError> { let data = &self.data; let mut lx = Lexer::new(data, off); if lx.eat_keyword(b"xref") { @@ -1710,6 +1830,11 @@ 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, 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")); + } + let start = shifted; for i in 0..count { lx.skip_ws(); let o = lx.token(); @@ -1809,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() }); @@ -1848,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 @@ -1883,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; @@ -1893,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() { @@ -1920,6 +2203,38 @@ fn generated_id(seed: &[u8; 32]) -> Vec { out } +/// 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, 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; + } + let mut lx = Lexer::new(data, rows); + let mut row = || { + lx.skip_ws(); + let offset = std::str::from_utf8(lx.token()).ok().and_then(|s| s.parse::().ok()); + lx.skip_ws(); + let generation = std::str::from_utf8(lx.token()).ok().and_then(|s| s.parse::().ok()); + lx.skip_ws(); + (offset, generation, lx.token() == b"f") + }; + let (Some(0), Some(65535), true) = row() else { return start }; + let (Some(second), _, false) = row() else { return 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. /// Validate the whole subsection before adding entries, so neither arithmetic overflow nor /// truncation can turn a damaged range into entries for unrelated objects. @@ -1935,6 +2250,28 @@ const MAX_XREF_OBJECTS: usize = 8_388_607; mod tests { use super::*; + #[test] + fn a_read_state_matches_a_clone_and_no_changed_document() { + let mut doc = Document::new_empty(); + let a = doc.add(Object::Int(1)); + let state = doc.read_state(); + let snapshot = doc.clone(); + assert!(state.matches(&doc) && state.matches(&snapshot)); + // The same value set again is still a change: another allocation. + doc.set(a, Object::Int(1)); + assert!(!state.matches(&doc) && state.matches(&snapshot)); + let edited = doc.read_state(); + doc.free(a); + assert!(!edited.matches(&doc)); + let mut trailer_changed = snapshot.clone(); + trailer_changed.trailer_mut().set(b"Info".to_vec(), Object::Null); + assert!(!state.matches(&trailer_changed)); + // Another document's state never matches, even with the same changes. + let mut other = Document::new_empty(); + other.add(Object::Int(1)); + assert!(!other.read_state().matches(&snapshot)); + } + /// Build a classic-xref file from object bodies (object i+1 = bodies[i]). pub(crate) fn build(bodies: &[&str], trailer: &str) -> Vec { let mut out = b"%PDF-1.7\n".to_vec(); @@ -2085,6 +2422,286 @@ mod tests { assert!(doc.xref_entry_is_exact(1) && doc.xref_entry_is_exact(2)); } + /// A classic table with `/Size` rows and object 0's free entry first, its subsection numbered + /// `1 n` instead of `0 n` (#2676): every row is one number off. + fn misnumbered_table(start: u32) -> Vec { + let bodies = ["<< /Marker (first object) >>", "<< /Type /Pages /Kids [] /Count 0 >>", "<< /Type /Catalog /Pages 2 0 R >>"]; + let mut out = b"%PDF-1.7 +" + .to_vec(); + let mut offsets = Vec::new(); + for (i, body) in bodies.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice( + format!( + "{} 0 obj +{body} +endobj +", + i + 1 + ) + .as_bytes(), + ); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n{start} 4\n0000000000 65535 f \n").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 4 /Root 3 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + out + } + + #[test] + fn a_table_numbered_from_1_that_begins_with_object_0_is_read_from_0() { + let doc = Document::open(Arc::new(misnumbered_table(1))).unwrap(); + let first = doc.get(ObjRef::new(1, 0)).as_dict().cloned().expect("object 1 is not free"); + assert!(first.contains(b"Marker")); + assert_eq!(doc.root(), Some(ObjRef::new(3, 0))); + assert!(doc.repair_log().iter().any(|l| l.contains("numbered from 1")), "{:?}", doc.repair_log()); + // A correct table reads the same and logs nothing. + let fine = Document::open(Arc::new(misnumbered_table(0))).unwrap(); + assert!(fine.get(ObjRef::new(1, 0)).as_dict().is_some_and(|d| d.contains(b"Marker"))); + assert!(!fine.repair_log().iter().any(|l| l.contains("numbered from 1"))); + } + + #[test] + fn a_table_that_really_starts_at_1_keeps_its_numbers() { + // Object 1 deleted for good (generation 65535) in a subsection that does start at 1: its + // second row locates object 2, so nothing is shifted. + let mut bytes = b"%PDF-1.7\n".to_vec(); + let pages = bytes.len(); + bytes.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n"); + let catalog = bytes.len(); + bytes.extend_from_slice(b"3 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"); + let xref = bytes.len(); + bytes.extend_from_slice( + format!( + "xref\n0 1\n0000000000 65535 f \n1 3\n0000000000 65535 f \n{pages:010} 00000 n \n{catalog:010} 00000 n \ntrailer\n<< /Size 4 /Root 3 0 R >>\nstartxref\n{xref}\n%%EOF\n" + ) + .as_bytes(), + ); + let doc = Document::open(Arc::new(bytes)).unwrap(); + assert!(doc.get(ObjRef::new(2, 0)).as_dict().is_some_and(|d| d.name(b"Type") == Some(b"Pages"))); + assert!(doc.get(ObjRef::new(1, 0)).as_dict().is_none(), "object 1 stays free"); + 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. @@ -2362,7 +2979,7 @@ mod tests { 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); @@ -2483,6 +3100,16 @@ mod tests { /// 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]| { @@ -2493,12 +3120,13 @@ mod tests { }; 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"); @@ -2510,11 +3138,47 @@ mod tests { 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] @@ -2535,6 +3199,212 @@ mod tests { assert!(old.get(ObjRef::new(1, 0)).as_dict().unwrap().get(b"Lang").is_none()); } + /// qpdf's `xref-compressed-in-compressed.pdf` in miniature: object 3 is an object stream + /// holding object 4, and the cross-reference stream says object 3 itself lives in stream 99. + fn compressed_in_compressed_fixture() -> Document { + let mut out = b"%PDF-1.5\n".to_vec(); + let at = |out: &mut Vec, num: u32, body: &str| { + let off = out.len(); + out.extend_from_slice(format!("{num} 0 obj\n{body}\nendobj\n").as_bytes()); + off as u16 + }; + let o1 = at(&mut out, 1, "<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>"); + let o2 = at(&mut out, 2, "<< /Type /Pages /Kids [] /Count 0 >>"); + let inner = "<< /Type /Outlines /Count 0 >>"; + at(&mut out, 3, &format!("<< /Type /ObjStm /N 1 /First 4 /Length {} >>\nstream\n4 0 {inner}\nendstream", 4 + inner.len())); + // /W [1 2 1]: object 3 is said to live in stream 99 (which does not exist), 4 in stream 3. + let rows: Vec<[u8; 4]> = vec![ + [0, 0, 0, 255], + [1, 0, o1 as u8, 0], + [1, 0, o2 as u8, 0], + [2, 0, 99, 0], + [2, 0, 3, 0], + [1, (out.len() >> 8) as u8, out.len() as u8, 0], + ]; + let rows: Vec = rows.concat(); + let xref = out.len(); + out.extend_from_slice( + format!("5 0 obj\n<< /Type /XRef /W [1 2 1] /Index [0 6] /Size 6 /Root 1 0 R /Length {} >>\nstream\n", rows.len()).as_bytes(), + ); + out.extend_from_slice(&rows); + out.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + Document::open(Arc::new(out)).unwrap() + } + + /// An object stream whose own cross-reference entry says it is compressed inside another + /// stream (which the format forbids, and which does not exist here: qpdf's + /// `xref-compressed-in-compressed.pdf`, #2367) could not be loaded, so every object in it + /// read as null and a full save wrote `/Outlines null`. The stream is now found by its own + /// header, as a plain object with a wrong offset is. + #[test] + fn an_object_stream_marked_as_compressed_is_found_by_its_header() { + let doc = compressed_in_compressed_fixture(); + assert_eq!(doc.revisions().len(), 1, "the chain itself reads: {:?}", doc.repair_log()); + let outlines = doc.get(ObjRef::new(4, 0)); + assert_eq!(outlines.as_dict().and_then(|d| d.name(b"Type")), Some(&b"Outlines"[..]), "{outlines:?}"); + let saved = crate::write_full(&doc, &crate::SaveOptions::default()).unwrap(); + let back = Document::open(Arc::new(saved)).unwrap(); + let catalog = back.get(back.root().unwrap()); + let outlines = back.resolve(catalog.as_dict().unwrap().get(b"Outlines").unwrap()); + assert_eq!(outlines.as_dict().and_then(|d| d.name(b"Type")), Some(&b"Outlines"[..]), "the full save keeps it: {outlines:?}"); + } + + /// The header fallback is for file entries only: once the stream is freed (or replaced) in + /// the overlay, its objects must not come back from the file's copy of it. + #[test] + fn a_freed_object_stream_is_not_found_by_its_header() { + let doc = compressed_in_compressed_fixture(); + assert!(doc.get(ObjRef::new(4, 0)).as_dict().is_some()); + let mut edited = doc.clone(); + edited.free(ObjRef::new(3, 0)); + let fresh = edited.object_reader().document; + assert!(matches!(*fresh.get(ObjRef::new(4, 0)), Object::Null), "{:?}", fresh.get(ObjRef::new(4, 0))); + let mut replaced = doc.clone(); + replaced.set(ObjRef::new(3, 0), Object::Int(0)); + let fresh = replaced.object_reader().document; + assert!(matches!(*fresh.get(ObjRef::new(4, 0)), Object::Null), "{:?}", fresh.get(ObjRef::new(4, 0))); + } + + /// The same once the free is saved: the revision's table says the stream is free, and + /// neither reading the file back nor rewriting it in full brings its objects back. + #[test] + fn a_container_freed_in_a_saved_revision_is_not_found_by_its_header() { + let mut edited = compressed_in_compressed_fixture(); + edited.free(ObjRef::new(3, 0)); + let incremental = crate::write_incremental(&edited, &crate::SaveOptions::default()).unwrap(); + let reopened = Document::open(Arc::new(incremental)).unwrap(); + assert_eq!(reopened.revisions().len(), 2); + assert!(matches!(reopened.xref_entry(3), Some(XrefEntry::Free { .. }))); + assert!(matches!(*reopened.get(ObjRef::new(4, 0)), Object::Null), "{:?}", reopened.get(ObjRef::new(4, 0))); + for object_streams in [true, false] { + let opts = crate::SaveOptions { object_streams, ..Default::default() }; + let full = crate::write_full(&reopened, &opts).unwrap(); + let back = Document::open(Arc::new(full)).unwrap(); + let catalog = back.get(back.root().unwrap()); + let outlines = catalog.as_dict().unwrap().get(b"Outlines").map(|o| back.resolve(o)); + assert!(outlines.as_ref().is_none_or(|o| matches!(**o, Object::Null)), "the full save keeps it out: {outlines:?}"); + } + } + + /// 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/lib.rs b/crates/cos/src/lib.rs index 213ff341b..152daed2d 100644 --- a/crates/cos/src/lib.rs +++ b/crates/cos/src/lib.rs @@ -22,7 +22,7 @@ mod security; mod writer; pub use bytes::Bytes; -pub use document::{Document, Revision, SourceIdentity, XrefEntry}; +pub use document::{Document, ReadState, Revision, SourceIdentity, XrefEntry}; pub use object::{Dict, MAX_DECODED, Name, ObjRef, Object, PdfString, Stream}; pub use parser::{Lexer, is_whitespace, parse_indirect}; pub use pdfcraft_crypt::{ diff --git a/crates/cos/src/object.rs b/crates/cos/src/object.rs index dc7648d4f..438b8d5f3 100644 --- a/crates/cos/src/object.rs +++ b/crates/cos/src/object.rs @@ -482,6 +482,19 @@ mod tests { assert_eq!(stream.decoded_strict().unwrap(), b"abc"); } + #[test] + fn raw_flate_fallback_streams_preserve_the_caller_limit() { + // Raw deflate with a stored byte and a final empty block. 78 01 also + // passes the zlib header check, so decoding must retry the whole input. + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::name("FlateDecode")); + let stream = Stream::from_raw(dict, vec![0x78, 0x01, 0x00, 0xfe, 0xff, b'A', 0x01, 0x00, 0x00, 0xff, 0xff]); + let result = stream.decoded_within(0); + assert!(matches!(&result, Err(CosError::Filter(detail)) if detail.contains("exceeds the limit of 0 bytes")), "{result:?}"); + assert_eq!(stream.decoded_within(1).unwrap(), b"A"); + assert_eq!(stream.decoded_strict().unwrap(), b"A"); + } + #[test] fn first_filter_decodes_only_the_outer_wrapper() { let data = "0 0 m 100 100 l S\n".repeat(64).into_bytes(); diff --git a/crates/cos/src/parser.rs b/crates/cos/src/parser.rs index 8ac8a195b..045805596 100644 --- a/crates/cos/src/parser.rs +++ b/crates/cos/src/parser.rs @@ -337,11 +337,14 @@ impl<'a> Lexer<'a> { } return Ok(Object::Real(0.0)); } - match s.trim_start_matches('+').parse::() { + // Tolerate a doubled sign ("--280") like the real branch does. + let t = s.trim_start_matches('+'); + let cleaned = t.strip_prefix('-').filter(|r| r.starts_with('-')).unwrap_or(t); + match cleaned.parse::() { Ok(v) => Ok(Object::Int(v)), Err(_) => { // Out-of-range integers are treated as reals (§7.3.3 allows implementation limits). - s.parse::().map(Object::Real).map_err(|_| CosError::Syntax { offset: start, detail: format!("bad number {s:?}") }) + cleaned.parse::().map(Object::Real).map_err(|_| CosError::Syntax { offset: start, detail: format!("bad number {s:?}") }) } } } @@ -401,7 +404,16 @@ fn parse_indirect_in( if let Object::Dict(dict) = &obj && lx.eat_keyword(b"stream") { - // The keyword is followed by CRLF or LF (a lone CR is tolerated). + // The keyword is followed by CRLF or LF (a lone CR is tolerated). Microsoft Reporting + // Services writes a space first (`stream \r\n`); spaces and tabs count only when a line + // break follows, so they are never taken from the data. + let mut eol = lx.pos; + while matches!(data.get(eol), Some(b' ' | b'\t')) { + eol += 1; + } + if matches!(data.get(eol), Some(b'\r' | b'\n')) { + lx.pos = eol; + } match (data.get(lx.pos), data.get(lx.pos + 1)) { (Some(b'\r'), Some(b'\n')) => lx.pos += 2, (Some(b'\n'), _) | (Some(b'\r'), _) => lx.pos += 1, @@ -475,6 +487,20 @@ mod tests { Lexer::new(s.as_bytes(), 0).object().unwrap_or_else(|e| panic!("{s}: {e}")) } + #[test] + fn doubled_sign_integer_reads_like_the_real_form() { + // Some writers emit a TJ kerning as "--280"; the real form "--280.0" is already read. + assert_eq!(obj("--280.0"), Object::Real(-280.0)); + assert_eq!(obj("--280"), Object::Int(-280)); + match obj("[(Hello) --280 (World)]") { + Object::Array(items) => { + assert_eq!(items.len(), 3); + assert_eq!(items[1], Object::Int(-280)); + } + other => panic!("not an array: {other:?}"), + } + } + #[test] fn scalars() { assert_eq!(obj("true"), Object::Bool(true)); @@ -537,6 +563,25 @@ mod tests { assert_eq!(s.dict.int(b"Length"), Some(5)); } + #[test] + fn indirect_stream_with_a_space_before_the_line_break() { + // `stream \r\n` as Microsoft Reporting Services writes it: the data starts after the + // line break, so the /Length is still right and the data keeps no leading space. + for eol in [&b" \r\n"[..], b" \n", b"\t \r"] { + let mut data = b"7 0 obj\n<< /Length 5 >>\nstream".to_vec(); + data.extend_from_slice(eol); + data.extend_from_slice(b"HELLO\r\nendstream\nendobj\n"); + let (_, o) = parse_indirect(&data, 0, &|_| None).unwrap(); + let Object::Stream(s) = o else { panic!("stream expected") }; + assert_eq!(&s.raw[..], b"HELLO", "{eol:?}"); + } + // Spaces with no line break after them are data, not a keyword terminator. + let data = b"7 0 obj\n<< /Length 7 >>\nstream HELLO\nendstream\nendobj\n"; + let (_, o) = parse_indirect(data, 0, &|_| None).unwrap(); + let Object::Stream(s) = o else { panic!("stream expected") }; + assert_eq!(&s.raw[..], b" HELLO"); + } + #[test] fn indirect_length_is_resolved() { let data = b"7 0 obj\n<< /Length 8 0 R >>\nstream\r\nAB\r\nendstream\nendobj\n"; diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index f8d8fd3d8..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) { @@ -183,7 +189,7 @@ pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result, } // Reconstructed files have no chain to append to; added or removed encryption must // rewrite every object; redaction must not leave the old revision behind. All need a full save. - if doc.revisions().is_empty() || doc.encryption_changed() || doc.full_save_required() { + if doc.reconstructed() || doc.encryption_changed() || doc.full_save_required() { return write_full(doc, opts); } let mut doc = doc.clone(); @@ -193,7 +199,11 @@ pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result, // allocated once at its exact size: growing a buffer that holds a large original past a // guessed capacity would copy the whole file again and keep up to as much again unused. let newline = !matches!(original.last(), Some(b'\n' | b'\r')); - let base = (original.len() + usize::from(newline)) as u64; + // A file with bytes before its `%PDF-` header may count its offsets from the header. The new + // section then has to as well: readers take `/Prev` and `startxref` in one convention, and a + // mix sends them into reconstruction (#2364). + let shift = if doc.revisions().last().is_some_and(|r| r.header_relative) { doc.header_offset() } else { 0 }; + let base = (original.len() + usize::from(newline)).saturating_sub(shift) as u64; let mut out = Vec::new(); let mut offsets: BTreeMap = BTreeMap::new(); // num → (offset, gen, in use) for (num, generation, obj) in doc.overlay_entries() { @@ -574,22 +584,33 @@ fn ensure_id(trailer: &mut Dict, opts: &SaveOptions, content: &[u8]) { /// A PDF date string for "now" given seconds since the Unix epoch (UTC). pub fn pdf_date(unix_secs: i64) -> String { - let days = unix_secs.div_euclid(86_400); - let secs = unix_secs.rem_euclid(86_400); - // Civil-from-days (Howard Hinnant's algorithm). - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let y = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = if m <= 2 { y + 1 } else { y }; + let (days, secs) = (unix_secs.div_euclid(86_400), unix_secs.rem_euclid(86_400)); + let (y, m, d) = civil_from_days(days); + // `{y:04}` rather than `%Y`, which writes a `+` before years past 9999 (and a PDF date is a + // bare four-plus-digit year, never signed). No year clamp — the old code wrote `y` as-is. format!("D:{y:04}{m:02}{d:02}{:02}{:02}{:02}Z", secs / 3600, secs / 60 % 60, secs % 60) } +/// Days since 1970-01-01 → proleptic Gregorian (year, month, day). +/// +/// A `from_timestamp` epoch fallback would change dates past `NaiveDate`'s ±262143-year range, +/// which the old hand-rolled converter formatted directly. The Gregorian calendar repeats every +/// 400 years (146_097 days), so chrono converts the day within one cycle and whole cycles go back +/// into the year (an `i128` so the extremes can't overflow), keeping chrono as the only calendar +/// logic and reproducing the old result exactly. 1970-01-01 is `num_days_from_ce` 719_163. +fn civil_from_days(days: i64) -> (i64, u32, u32) { + use chrono::Datelike; + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` is 719_163..865_259, always a valid CE day. + match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { + Some(d) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.month(), d.day()) + } + None => (1970, 1, 1), // unreachable + } +} + #[cfg(test)] mod tests { use proptest::prelude::*; @@ -652,6 +673,26 @@ mod tests { } } + #[test] + fn pdf_dates_are_utc_and_byte_identical() { + // `D:YYYYMMDDHHmmSSZ` (ISO 32000-2 §7.9.4), the same strings the hand-rolled code wrote. + assert_eq!(pdf_date(0), "D:19700101000000Z"); + assert_eq!(pdf_date(1_790_000_000), "D:20260921141320Z"); + // Leap days, including the 400-year rule, and the last second of a year. + assert_eq!(pdf_date(1_709_164_800), "D:20240229000000Z"); + assert_eq!(pdf_date(951_782_400), "D:20000229000000Z"); + assert_eq!(pdf_date(1_767_225_599), "D:20251231235959Z"); + // Before the epoch (seconds into a day are counted forward from midnight UTC). + assert_eq!(pdf_date(-1), "D:19691231235959Z"); + // Years past 9999 keep the plain zero-padded year (no `+`), as the hand-rolled code did. + assert_eq!(pdf_date(253_402_300_800), "D:100000101000000Z"); + // Past chrono's ±262143-year range the proleptic Gregorian date is kept, not the epoch. + // Pinned to the old hand-rolled output (which had no year clamp — a negative year keeps + // its sign, exactly as before). + assert_eq!(pdf_date(10_000_000_000_000), "D:3188570520174640Z"); + assert_eq!(pdf_date(-10_000_000_000_000), "D:-3149180813061320Z"); + } + #[test] fn reals_keep_full_precision_without_exponents() { for r in [0.1, -0.000_000_123_456_789, 1e-12, 123_456.789_012_345, 1.0 / 3.0, -2.5e7 + 0.25] { @@ -662,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/bounded_writer.rs b/crates/cos/tests/it/bounded_writer.rs similarity index 100% rename from crates/cos/tests/bounded_writer.rs rename to crates/cos/tests/it/bounded_writer.rs diff --git a/crates/cos/tests/corpus.rs b/crates/cos/tests/it/corpus.rs similarity index 100% rename from crates/cos/tests/corpus.rs rename to crates/cos/tests/it/corpus.rs diff --git a/crates/cos/tests/encryption.rs b/crates/cos/tests/it/encryption.rs similarity index 90% rename from crates/cos/tests/encryption.rs rename to crates/cos/tests/it/encryption.rs index af92cf67c..e05161558 100644 --- a/crates/cos/tests/encryption.rs +++ b/crates/cos/tests/it/encryption.rs @@ -336,3 +336,41 @@ fn an_earlier_revision_opens_with_the_documents_own_credentials() { assert_eq!(content(¤t.open_with_same_credentials(Arc::new(fixture())).unwrap()), SECRET, "{alg:?}"); } } + +/// `protect`, with the `/Encrypt` dictionary written inline in the trailer, as MuPDF writes it, +/// instead of as an indirect object. +fn protect_inline(alg: Algorithm, user: &str, owner: &str) -> Vec { + let mut doc = Document::open(Arc::new(fixture())).unwrap(); + doc.set_encryption(&NewEncryption { + algorithm: alg, + user_password: user, + owner_password: owner, + permissions: -1, + encrypt_metadata: true, + seed: [9; 32], + }) + .unwrap(); + let dict = doc.get(doc.trailer().reference(b"Encrypt").unwrap()).as_dict().cloned().unwrap(); + doc.trailer_mut().set(b"Encrypt".to_vec(), Object::Dict(dict)); + write_full(&doc, &SaveOptions::default()).unwrap() +} + +/// MuPDF writes `/Encrypt` inline in the trailer, so removing it changes no object. It is still +/// an unsaved change, and saving must write a file that opens without a password (#2661). +#[test] +fn removing_an_inline_encrypt_dictionary_leaves_an_unencrypted_file() { + for alg in ALL { + let bytes = Arc::new(protect_inline(alg, "user", "owner")); + assert!(matches!(Document::open(bytes.clone()), Err(CosError::NeedsPassword)), "{alg:?}"); + let mut doc = Document::open_with_password(bytes, Some("owner")).unwrap(); + assert!(matches!(doc.trailer().get(b"Encrypt"), Some(Object::Dict(_))), "{alg:?}: inline"); + assert!(!doc.is_modified(), "{alg:?}"); + doc.remove_encryption(); + assert!(doc.is_modified(), "{alg:?}: removing security is an unsaved change"); + let plain = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let again = Document::open(Arc::new(plain.clone())).unwrap_or_else(|e| panic!("{alg:?}: {e:?}")); + assert!(again.security().is_none() && !again.trailer().contains(b"Encrypt"), "{alg:?}"); + assert_eq!(content(&again), SECRET, "{alg:?}"); + assert!(hayro_syntax::Pdf::new(plain).is_ok(), "{alg:?}"); + } +} diff --git a/crates/cos/tests/it/main.rs b/crates/cos/tests/it/main.rs new file mode 100644 index 000000000..52cf05d6c --- /dev/null +++ b/crates/cos/tests/it/main.rs @@ -0,0 +1,8 @@ +//! The crate's integration tests, linked into one test binary. + +mod bounded_writer; +mod corpus; +mod encryption; +mod proptests; +mod shared_streams; +mod writer; diff --git a/crates/cos/tests/it/proptests.rs b/crates/cos/tests/it/proptests.rs new file mode 100644 index 000000000..4380dd0bd --- /dev/null +++ b/crates/cos/tests/it/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/cos/tests/shared_streams.rs b/crates/cos/tests/it/shared_streams.rs similarity index 100% rename from crates/cos/tests/shared_streams.rs rename to crates/cos/tests/it/shared_streams.rs diff --git a/crates/cos/tests/writer.rs b/crates/cos/tests/it/writer.rs similarity index 83% rename from crates/cos/tests/writer.rs rename to crates/cos/tests/it/writer.rs index ec18b13f3..ac04be2e9 100644 --- a/crates/cos/tests/writer.rs +++ b/crates/cos/tests/it/writer.rs @@ -392,3 +392,67 @@ fn full_save_preserves_unknown_trailer_data() { assert_eq!(trailer_note(&back), trailer_note(&doc)); assert_eq!(back.trailer().get(b"Version"), doc.trailer().get(b"Version")); } + +/// The `startxref` value at the end of `bytes`. +fn last_startxref(bytes: &[u8]) -> usize { + let at = bytes.windows(9).rposition(|w| w == b"startxref").unwrap(); + String::from_utf8_lossy(&bytes[at + 9..]).split_whitespace().next().unwrap().parse().unwrap() +} + +#[test] +fn incremental_saves_of_a_file_with_bytes_before_its_header_count_from_the_header() { + // Offsets written before the prefix was added count from `%PDF-`, as in the KillerPDF file + // of #2364; the appended section must count the same way as the `/Prev` it points back to. + let prefix = vec![b'#'; 128]; + let as_stream = write_full(&Document::open(Arc::new(fixture())).unwrap(), &SaveOptions::default()).unwrap(); + for (name, body, marker) in [("table", classic_table(), &b"xref"[..]), ("stream", as_stream, &b" 0 obj"[..])] { + let input = [prefix.as_slice(), &body].concat(); + let mut doc = Document::open(Arc::new(input.clone())).unwrap(); + assert!(doc.revisions().last().unwrap().header_relative, "{name}"); + doc.update_dict(ObjRef::new(1, 0), |d| d.set(b"Lang".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(b"en".to_vec())))).unwrap(); + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert!(saved.starts_with(&input), "{name}"); + + let xref = last_startxref(&saved) + prefix.len(); + let section = saved.get(xref..).unwrap_or_default(); + assert!(section.windows(marker.len()).position(|w| w == marker).is_some_and(|p| p < 16), "{name}: startxref misses its section"); + let back = Document::open(Arc::new(saved.clone())).unwrap(); + assert_eq!(back.revisions().len(), doc.revisions().len() + 1, "{name}"); + assert!(back.revisions().iter().all(|r| r.header_relative), "{name}: one convention across the chain"); + let edited = back.get(ObjRef::new(1, 0)); + assert_eq!(edited.as_dict().unwrap().get(b"Lang").and_then(|o| o.as_string()).map(|s| s.bytes.as_slice()), Some(b"en".as_slice())); + assert!(back.xref_entry_is_exact(1), "{name}"); + assert_qpdf_clean(&saved); + } +} + +#[test] +fn incremental_saves_of_a_file_whose_offsets_count_from_its_first_byte_keep_counting_so() { + // The other convention: the same file, but its offsets include the bytes before the header. + let prefix = vec![b'#'; 128]; + let mut input = [prefix.as_slice(), &classic_table()].concat(); + let xref = input.windows(6).rposition(|w| w == b"\nxref\n").unwrap() + 1; + let table: String = String::from_utf8_lossy(&input[xref..]) + .lines() + .map(|line| match line.strip_suffix(" 00000 n ").and_then(|o| o.parse::().ok()) { + Some(o) => format!("{:010} 00000 n \n", o + prefix.len()), + None if line.parse::().is_ok() => format!("{xref}\n"), + None => format!("{line}\n"), + }) + .collect(); + input.truncate(xref); + input.extend_from_slice(table.as_bytes()); + + let mut doc = Document::open(Arc::new(input.clone())).unwrap(); + assert!(doc.repair_log().is_empty(), "{:?}", doc.repair_log()); + assert!(!doc.revisions().last().unwrap().header_relative); + doc.update_dict(ObjRef::new(1, 0), |d| d.set(b"Lang".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(b"en".to_vec())))).unwrap(); + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert!(saved.get(last_startxref(&saved)..).unwrap_or_default().starts_with(b"xref")); + let back = Document::open(Arc::new(saved.clone())).unwrap(); + assert!(back.repair_log().is_empty(), "{:?}", back.repair_log()); + assert_eq!(back.revisions().len(), 2); + assert!(back.xref_entry_is_exact(1)); + // No qpdf check here: qpdf counts every offset from the header in a file with bytes before + // it, so it already reconstructs this input before anything is appended. +} diff --git a/crates/create/README.md b/crates/create/README.md index 88c6da2ea..499081059 100644 --- a/crates/create/README.md +++ b/crates/create/README.md @@ -23,3 +23,9 @@ Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2: line says so (`ESCAPED_NOTE`). Every function returns a `pdfcraft_cos::Document`; the caller writes it. + +Image export and Save Image As apply each lossless image's Decode pair before converting its color components. +Indexed Decode values select palette entries after rounding and clipping. Color-key masks compare the original samples. +Missing, null or malformed Decode arrays keep the default mapping. +The existing 16-bit path still uses each sample's high byte. JPEG and mask Decode handling remain limited. +ICC transforms and calibrated color conversion remain outside this export path. diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs index 103448945..768da4d1f 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -126,13 +126,13 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> // A stencil mask (`ImageMask true`) has no /SMask or /Mask of its own (8.9.6.2). let mut alpha = if mask { None } else { soft_mask(doc, s, w, h).or_else(|| stencil_mask(doc, s, w, h)) }; let key = if mask { None } else { colour_key(doc, s) }; - // Decode arrays: only inversion ([1 0] per component) is honoured, the common case. - let inverted = s - .dict - .get(b"Decode") - .map(|d| doc.resolve(d)) - .and_then(|d| d.as_array().map(|a| a.first().and_then(Object::as_f64) > a.get(1).and_then(Object::as_f64))) - .unwrap_or(false); + // Keep the existing JPEG and stencil-mask Decode handling separate from lossless colour images. + let inverted = (jpeg || mask) + && s.dict + .get(b"Decode") + .map(|d| doc.resolve(d)) + .and_then(|d| d.as_array().map(|a| a.first().and_then(Object::as_f64) > a.get(1).and_then(Object::as_f64))) + .unwrap_or(false); // In a PDF a CMYK JPEG's samples are ink amounts unless /Decode inverts them, while image // viewers read a CMYK JPEG file as inverted (Adobe's convention): as is, it would show as // a negative. @@ -161,6 +161,7 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> (unpack(&data, w, h, space.components(), bpc).ok_or("the image data is shorter than its size says")?, bpc) }; let n = space.components(); + let decode = if jpeg || mask { None } else { decode(doc, s, n) }; if alpha.is_none() { alpha = key.and_then(|k| keyed_alpha(&samples, n, bpc, &k)); } @@ -168,10 +169,21 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> return as_is(); } let invert = inverted ^ mask; // a stencil mask paints its 0 samples + // `unpack` keeps only the high byte at 16 bits, so Decode uses an effective maximum of 255 too. let max = (1u32 << bpc.min(8)) - 1; let mut rgb = Vec::with_capacity(w * h * 3); for px in samples.chunks_exact(n) { + let decoded = |i: usize| { + decode.as_ref().and_then(|pairs| pairs.get(i)).map(|&[lo, hi]| { + let t = f64::from(px.get(i).copied().unwrap_or(0)) / f64::from(max); + // Avoid overflowing hi - lo for finite endpoints with opposite signs. + (1.0 - t) * lo + t * hi + }) + }; let v = |i: usize| { + if let Some(value) = decoded(i) { + return (value.clamp(0.0, 1.0) * 255.0).round() as u8; + } let x = (px.get(i).copied().unwrap_or(0) as u32 * 255 / max) as u8; if invert { 255 - x } else { x } }; @@ -180,8 +192,12 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> Space::Rgb => rgb.extend_from_slice(&[v(0), v(1), v(2)]), Space::Cmyk => rgb.extend_from_slice(&cmyk(v(0), v(1), v(2), v(3))), Space::Indexed(base, hival, table) => { - // Indexed samples outside the declared range use the nearest endpoint (ISO 32000-1, 8.6.6.3). - let i = px.first().copied().unwrap_or(0).min(*hival) as usize; + // Explicit Decode maps into index units, rounded then clipped (8.6.6.3). + // Without Decode, retain raw indices rather than normalizing to hival. + let i = match decoded(0) { + Some(value) => value.round().clamp(0.0, f64::from(*hival)) as usize, + None => px.first().copied().unwrap_or(0).min(*hival) as usize, + }; let k = base.components(); let e = table.get(i * k..i * k + k).unwrap_or(&[0, 0, 0, 0][..k]); rgb.extend_from_slice(&match **base { @@ -198,6 +214,24 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec), String> png(w as u32, h as u32, &rgb, alpha.as_deref()).map(|p| ("png", p)) } +/// The explicit Decode intervals for a lossless colour image (8.9.5.2, Table 90). +/// +/// A malformed array (not an array, the wrong length, a non-number or non-finite entry) gives +/// the default mapping, so the image still exports. +fn decode(doc: &Document, s: &Stream, n: usize) -> Option> { + let value = doc.resolve(s.dict.get(b"Decode")?); + let values = value.as_array()?; + if Some(values.len()) != n.checked_mul(2) { + return None; + } + let endpoint = |value: &Object| doc.resolve(value).as_f64().filter(|v| v.is_finite()); + let mut pairs = Vec::with_capacity(n); + for [lo, hi] in values.as_chunks::<2>().0 { + pairs.push([endpoint(lo)?, endpoint(hi)?]); + } + Some(pairs) +} + /// Width and height as sizes, when both are positive and the image is at most `MAX_PIXELS`. fn size(w: i64, h: i64) -> Option<(usize, usize)> { let (w, h) = (usize::try_from(w).ok()?, usize::try_from(h).ok()?); diff --git a/crates/create/src/lib.rs b/crates/create/src/lib.rs index a42ec84fa..5717977b1 100644 --- a/crates/create/src/lib.rs +++ b/crates/create/src/lib.rs @@ -5,6 +5,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; mod extract; +mod upright_image; pub use extract::{ExtractedImage, ImageExport, extract_images, image_file}; use pdfcraft_fonts::{literal, win_ansi, wrap}; @@ -528,7 +529,7 @@ pub fn source_kind(name: &str, bytes: &[u8]) -> Option { /// Detect the image format from its bytes; a TIFF may hold several pages. fn embed(name: &str, bytes: &[u8]) -> Result, CreateError> { if bytes.starts_with(&[0xFF, 0xD8]) { - Ok(vec![jpeg(name, bytes)?]) + Ok(vec![jpeg(name, &upright_image::upright(bytes))?]) } else if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { Ok(vec![png_image(name, bytes)?]) } else if is_tiff(bytes) { diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 993c38d9f..635610569 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -124,6 +124,18 @@ fn text_is_wrapped_and_paginated() { assert!(content(p.last().unwrap()).contains("(After a form feed) Tj"), "a form feed starts a page"); } +#[test] +fn text_keeps_leading_spaces_and_tabs() { + let doc = reopen(&from_text("t", " indented\n\ttabbed\nplain", LETTER, 11.0).unwrap()); + let p = pages(&doc); + let c = doc.resolve(p[0].get(b"Contents").unwrap()); + let Object::Stream(s) = &*c else { panic!() }; + let content = String::from_utf8_lossy(&s.decoded().unwrap()).into_owned(); + assert!(content.contains("( indented) Tj"), "{content}"); + assert!(content.contains("( tabbed) Tj"), "{content}"); + assert!(content.contains("(plain) Tj"), "{content}"); +} + /// Text Helvetica can't draw is refused with its line and character, never set as `?`; text /// PdfCraft generates writes such characters as [U+XXXX] and says so. #[test] @@ -875,6 +887,193 @@ fn indexed_hival_keeps_existing_short_palette_fallback() { assert_eq!(exported_png_pixels(&bytes), vec![255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); } +/// Save and reopen a synthetic image before checking both export entry points. +fn decode_fixture(dict: Dict, samples: Vec) -> (Document, pdfcraft_cos::ObjRef) { + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |_| Stream::from_raw(dict, samples)); + let doc = reopen(&doc); + let page = pdfcraft_model::pages(&doc)[0].clone(); + let res = doc.resolve(page.dict.get(b"Resources").unwrap()); + let xo = doc.resolve(res.as_dict().unwrap().get(b"XObject").unwrap()); + let image = xo.as_dict().unwrap().iter().next().unwrap().1.as_ref().unwrap(); + (doc, image) +} + +fn assert_decode_pixels(dict: Dict, samples: Vec, expected: &[u8]) { + let (doc, image) = decode_fixture(dict, samples); + let out = extract_images(&doc, &[0], 0); + assert!(out.skipped.is_empty(), "{:?}", out.skipped); + assert_eq!(out.images.len(), 1); + assert_eq!(exported_png_pixels(&out.images[0].data), expected); + let (ext, bytes) = image_file(&doc, image).unwrap(); + assert_eq!(ext, "png"); + assert_eq!(exported_png_pixels(&bytes), expected); +} + +fn decode_array(values: &[f64]) -> Object { + Object::Array(values.iter().copied().map(Object::Real).collect()) +} + +#[test] +fn lossless_decode_maps_each_component_before_colour_conversion() { + for (space, samples, decode, expected) in [ + ("DeviceRGB", vec![0, 64, 255], vec![1., 0., 0., 1., 0., 1.], [255, 64, 255]), + ("DeviceRGB", vec![0, 64, 255], vec![0., 1., 1., 0., 0., 1.], [0, 191, 255]), + ("DeviceGray", vec![0], vec![0.2, 0.8], [51, 51, 51]), + ("DeviceGray", vec![128], vec![-1., 2.], [129, 129, 129]), + ("DeviceCMYK", vec![0, 0, 0, 0], vec![0., 1., 1., 0., 0., 1., 0., 1.], [255, 0, 255]), + ] { + let mut dict = image_dict(1, 1, space, 8); + dict.set(b"Decode".to_vec(), decode_array(&decode)); + assert_decode_pixels(dict, samples, &expected); + } +} + +#[test] +fn lossless_decode_scales_packed_samples_by_their_bit_depth() { + let mut dict = image_dict(4, 1, "DeviceGray", 2); + dict.set(b"Decode".to_vec(), decode_array(&[0.2, 0.8])); + assert_decode_pixels(dict, vec![0x1b], &[51, 51, 51, 102, 102, 102, 153, 153, 153, 204, 204, 204]); +} + +#[test] +fn lossless_decode_identity_and_defaults_keep_depth_and_row_padding() { + for (bpc, samples, expected) in [ + (1, vec![0x7f, 0xff], vec![0, 0, 0, 255, 255, 255]), + (2, vec![0x7f, 0xbf], vec![85, 85, 85, 170, 170, 170]), + (4, vec![0x5f, 0xaf], vec![85, 85, 85, 170, 170, 170]), + (8, vec![85, 170], vec![85, 85, 85, 170, 170, 170]), + (16, vec![85, 255, 170, 0], vec![85, 85, 85, 170, 170, 170]), + ] { + for decode in [None, Some(Object::Null), Some(decode_array(&[0., 1.]))] { + let mut dict = image_dict(1, 2, "DeviceGray", bpc); + if let Some(decode) = decode { + dict.set(b"Decode".to_vec(), decode); + } + assert_decode_pixels(dict, samples.clone(), &expected); + } + } +} + +#[test] +fn lossless_indexed_decode_rounds_in_index_units_then_clamps() { + let palette = vec![10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120]; + for (bpc, samples, decode, expected) in [ + (2, vec![0x1b], Some(vec![3., 0.]), vec![100, 110, 120, 70, 80, 90, 40, 50, 60, 10, 20, 30]), + (2, vec![0x1b], Some(vec![-1., 5.]), vec![10, 20, 30, 40, 50, 60, 100, 110, 120, 100, 110, 120]), + (2, vec![0x1b], Some(vec![0.5, 0.5]), vec![40, 50, 60, 40, 50, 60, 40, 50, 60, 40, 50, 60]), + (8, vec![0, 1, 2, 255], None, vec![10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120]), + // Existing 16-bit policy retains the high byte, 42 / 255 * 3 < 0.5. + (16, vec![0x2a, 0xab, 0, 0, 255, 255, 128, 0], Some(vec![0., 3.]), vec![10, 20, 30, 10, 20, 30, 100, 110, 120, 70, 80, 90]), + ] { + let (mut doc, image) = indexed_export_fixture(Object::Int(3), palette.clone(), false, false, bpc, samples); + if let Some(decode) = decode { + let Object::Stream(mut stream) = (*doc.get(image)).clone() else { panic!() }; + stream.dict.set(b"Decode".to_vec(), decode_array(&decode)); + doc.set(image, Object::Stream(stream)); + } + let doc = reopen(&doc); + let out = extract_images(&doc, &[0], 0); + assert!(out.skipped.is_empty(), "{:?}", out.skipped); + assert_eq!(exported_png_pixels(&out.images[0].data), expected); + let (_, bytes) = image_file(&doc, out.images[0].object).unwrap(); + assert_eq!(exported_png_pixels(&bytes), expected); + } +} + +#[test] +fn lossless_decode_keeps_colour_key_comparisons_in_raw_samples() { + let mut dict = image_dict(2, 1, "DeviceGray", 8); + dict.set(b"Decode".to_vec(), decode_array(&[0.2, 0.8])); + dict.set(b"Mask".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0)])); + assert_decode_pixels(dict, vec![0, 255], &[51, 51, 51, 0, 204, 204, 204, 255]); + + let (mut doc, image) = indexed_export_fixture(Object::Int(1), vec![255, 0, 0, 0, 0, 255], false, false, 2, vec![0x1b]); + let Object::Stream(mut stream) = (*doc.get(image)).clone() else { panic!() }; + stream.dict.set(b"Decode".to_vec(), decode_array(&[3., 0.])); + stream.dict.set(b"Mask".to_vec(), Object::Array(vec![Object::Int(2), Object::Int(2)])); + doc.set(image, Object::Stream(stream)); + let doc = reopen(&doc); + let out = extract_images(&doc, &[0], 0); + let expected = [0, 0, 255, 255, 0, 0, 255, 255, 0, 0, 255, 0, 255, 0, 0, 255]; + assert_eq!(exported_png_pixels(&out.images[0].data), expected); + let (_, bytes) = image_file(&doc, out.images[0].object).unwrap(); + assert_eq!(exported_png_pixels(&bytes), expected); +} + +#[test] +fn lossless_decode_resolves_indirect_arrays_and_endpoints() { + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |doc| { + let lo = doc.add(Object::Real(0.2)); + let hi = doc.add(Object::Real(0.8)); + let decode = doc.add(Object::Array(vec![Object::Ref(lo), Object::Ref(hi)])); + let mut dict = image_dict(1, 1, "DeviceGray", 8); + dict.set(b"Decode".to_vec(), Object::Ref(decode)); + Stream::from_raw(dict, vec![0]) + }); + let doc = reopen(&doc); + let out = extract_images(&doc, &[0], 0); + assert_eq!(exported_png_pixels(&out.images[0].data), [51, 51, 51]); + let (_, bytes) = image_file(&doc, out.images[0].object).unwrap(); + assert_eq!(exported_png_pixels(&bytes), [51, 51, 51]); + + let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); + with_image(&mut doc, |doc| { + let decode = doc.add(Object::Null); + let mut dict = image_dict(1, 1, "DeviceGray", 8); + dict.set(b"Decode".to_vec(), Object::Ref(decode)); + Stream::from_raw(dict, vec![64]) + }); + let doc = reopen(&doc); + let out = extract_images(&doc, &[0], 0); + assert_eq!(exported_png_pixels(&out.images[0].data), [64, 64, 64]); + let (_, bytes) = image_file(&doc, out.images[0].object).unwrap(); + assert_eq!(exported_png_pixels(&bytes), [64, 64, 64]); +} + +#[test] +fn lossless_decode_interpolation_avoids_endpoint_difference_overflow() { + let (mut doc, image) = decode_fixture(image_dict(3, 1, "DeviceGray", 8), vec![0, 64, 255]); + let Object::Stream(mut stream) = (*doc.get(image)).clone() else { panic!() }; + stream.dict.set(b"Decode".to_vec(), decode_array(&[-f64::MAX, f64::MAX])); + doc.set(image, Object::Stream(stream)); + let expected = [0, 0, 0, 0, 0, 0, 255, 255, 255]; + let out = extract_images(&doc, &[0], 0); + assert_eq!(exported_png_pixels(&out.images[0].data), expected); + let (_, bytes) = image_file(&doc, image).unwrap(); + assert_eq!(exported_png_pixels(&bytes), expected); +} + +#[test] +fn lossless_decode_falls_back_to_the_default_mapping_when_malformed() { + for (space, samples, decode, expected) in [ + ("DeviceRGB", vec![0, 64, 255], decode_array(&[1., 0.]), [0, 64, 255]), + ("DeviceGray", vec![64], Object::name("bad"), [64, 64, 64]), + ("DeviceGray", vec![64], decode_array(&[]), [64, 64, 64]), + ("DeviceGray", vec![64], decode_array(&[0.]), [64, 64, 64]), + ("DeviceGray", vec![64], decode_array(&[1., 0., 0., 1.]), [64, 64, 64]), + ("DeviceGray", vec![64], Object::Array(vec![Object::Int(1), Object::name("bad")]), [64, 64, 64]), + ("DeviceGray", vec![64], Object::Array(vec![Object::Int(1), Object::Null]), [64, 64, 64]), + ] { + let mut dict = image_dict(1, 1, space, 8); + dict.set(b"Decode".to_vec(), decode); + assert_decode_pixels(dict, samples, &expected); + } + // Non-finite reals cannot be serialized as PDF numbers. Exercise the in-memory input. + for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let mut dict = image_dict(1, 1, "DeviceGray", 8); + dict.set(b"Decode".to_vec(), decode_array(&[1., value])); + let (mut doc, image) = decode_fixture(image_dict(1, 1, "DeviceGray", 8), vec![64]); + doc.set(image, Object::Stream(Stream::from_raw(dict, vec![64]))); + let out = extract_images(&doc, &[0], 0); + assert!(out.skipped.is_empty(), "{:?}", out.skipped); + assert_eq!(exported_png_pixels(&out.images[0].data), [64, 64, 64]); + let (_, bytes) = image_file(&doc, image).unwrap(); + assert_eq!(exported_png_pixels(&bytes), [64, 64, 64]); + } +} + /// An 8×8 baseline JPEG of one flat colour with four components, stored as `samples`, and an /// Adobe APP14 segment with `transform` (0: CMYK, 2: YCCK). fn flat_four_component_jpeg(samples: [u8; 4], transform: u8) -> Vec { @@ -949,3 +1148,36 @@ fn cmyk_and_ycck_jpegs_export_in_the_colours_the_page_shows() { } } } + +/// A 60 x 20 JPEG at `dpi` x 72 dpi, tagged with EXIF `orientation` (6: shown turned 90 degrees +/// clockwise). +pub(crate) fn phone_jpeg(orientation: u8, dpi: u16) -> Vec { + use image::codecs::jpeg::{JpegEncoder, PixelDensity, PixelDensityUnit}; + let mut jpeg = Vec::new(); + let mut encoder = JpegEncoder::new(&mut jpeg); + encoder.set_pixel_density(PixelDensity { density: (dpi, 72), unit: PixelDensityUnit::Inches }); + encoder.encode_image(&image::RgbImage::from_pixel(60, 20, image::Rgb([20, 20, 20]))).unwrap(); + let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0".to_vec(); + exif.extend_from_slice(&[orientation, 0, 0, 0, 0, 0, 0, 0]); + let mut out = jpeg[..2].to_vec(); + out.extend_from_slice(&[0xff, 0xe1]); + out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes()); + out.extend_from_slice(&exif); + out.extend_from_slice(&jpeg[2..]); + out +} + +#[test] +fn a_jpeg_with_an_exif_orientation_becomes_an_upright_page() { + let doc = reopen(&from_images(&[("photo.jpg".into(), phone_jpeg(6, 144)), ("plain.jpg".into(), phone_jpeg(1, 72))]).unwrap()); + // Stored 60 x 20 px at 144 x 72 dpi; turned it is 20 x 60 px at 72 x 144 dpi: 20 x 30 pt. + let m0 = media(&pages(&doc)[0]); + assert!((m0[2] - 20.0).abs() < 0.01 && (m0[3] - 30.0).abs() < 0.01, "{m0:?}"); + let img = image_of(&doc, 0); + assert_eq!((img.int(b"Width"), img.int(b"Height")), (Some(20), Some(60))); + // "As stored" stays as it is. + let m1 = media(&pages(&doc)[1]); + assert!((m1[2] - 60.0).abs() < 0.01 && (m1[3] - 20.0).abs() < 0.01, "{m1:?}"); + let img = image_of(&doc, 1); + assert_eq!((img.int(b"Width"), img.int(b"Height")), (Some(60), Some(20))); +} diff --git a/crates/engine/src/upright_image.rs b/crates/create/src/upright_image.rs similarity index 62% rename from crates/engine/src/upright_image.rs rename to crates/create/src/upright_image.rs index dc2b9cdbf..9816dfc4b 100644 --- a/crates/engine/src/upright_image.rs +++ b/crates/create/src/upright_image.rs @@ -1,6 +1,6 @@ //! Photos from phones are stored sideways with an EXIF Orientation tag. PDF image XObjects have -//! no such tag, so a JPEG embedded as it is would show up turned. Images placed into a page -//! (stamps, added images, replacements) go through here first. +//! no such tag, so a JPEG embedded as it is would show up turned. Every JPEG embedded by this +//! crate (pages made from images, stamps, added images, replacements) goes through here first. use std::borrow::Cow; use std::io::Cursor; @@ -79,24 +79,7 @@ fn jfif_density(bytes: &[u8]) -> Option { #[cfg(test)] mod tests { use super::*; - use crate::{Edit, MarkFile}; - - /// A 60 x 20 JPEG at `dpi` x 72 dpi, tagged with EXIF `orientation` (6: shown turned 90 - /// degrees clockwise). - fn phone_jpeg(orientation: u8, dpi: u16) -> Vec { - let mut jpeg = Vec::new(); - let mut encoder = JpegEncoder::new(&mut jpeg); - encoder.set_pixel_density(PixelDensity { density: (dpi, 72), unit: PixelDensityUnit::Inches }); - encoder.encode_image(&image::RgbImage::from_pixel(60, 20, image::Rgb([20, 20, 20]))).unwrap(); - let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0".to_vec(); - exif.extend_from_slice(&[orientation, 0, 0, 0, 0, 0, 0, 0]); - let mut out = jpeg[..2].to_vec(); - out.extend_from_slice(&[0xff, 0xe1]); - out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes()); - out.extend_from_slice(&exif); - out.extend_from_slice(&jpeg[2..]); - out - } + use crate::tests::phone_jpeg; #[test] fn orientation_is_applied_and_plain_images_are_left_alone() { @@ -111,23 +94,4 @@ mod tests { assert!(matches!(upright(b"\x89PNG\r\n\x1a\n"), Cow::Borrowed(_))); assert!(matches!(upright(b"\xff\xd8 not a jpeg"), Cow::Borrowed(_))); } - - #[test] - fn a_phone_photo_used_as_a_custom_stamp_is_embedded_upright() { - let mut session = crate::Session::new(); - let blank = session.create_blank(300.0, 400.0, 1).unwrap(); - let id = session.open_new("form.pdf", blank).unwrap(); - // A click places the stamp at its natural size: 30 x 20 pt as stored, 20 x 30 pt upright. - // Named "Signature" so `signature_image` finds its picture. - let file = MarkFile { name: "photo.jpg".into(), bytes: std::sync::Arc::new(phone_jpeg(6, 144)), page: 0 }; - let stamp = Edit::AddCustomStamp { page: 0, rect: [100.0, 100.0, 100.0, 100.0], name: "Signature".into(), file, author: "Ada".into() }; - session.apply(id, stamp).unwrap(); - let cos = &session.get(id).unwrap().editor.as_ref().unwrap().cos; - let picture = pdfcraft_annot::signature_image(cos, 0, 0).unwrap().unwrap(); - let obj = cos.get(picture); - let pdfcraft_cos::Object::Stream(s) = &*obj else { panic!() }; - assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(20), Some(60))); - let rect = pdfcraft_annot::summaries(cos)[0].rect; - assert!(((rect[2] - rect[0]) - 20.0).abs() < 0.01 && ((rect[3] - rect[1]) - 30.0).abs() < 0.01, "{rect:?}"); - } } diff --git a/crates/crypt/src/lib.rs b/crates/crypt/src/lib.rs index fd600cb9a..82b598688 100644 --- a/crates/crypt/src/lib.rs +++ b/crates/crypt/src/lib.rs @@ -203,8 +203,22 @@ impl SecurityHandler { self.recovered_user.clone() } + /// For R5/R6, `/P` is stored in clear and again in `/Perms`, encrypted with the file key + /// (Algorithm 13). Where the two differ, a permission counts only when both grant it, so an + /// edited `/P` grants nothing extra. Without a readable `/Perms`, `/P` applies as before. pub fn permissions(&self) -> Permissions { - Permissions { bits: self.dict.p, owner: self.auth == Auth::Owner } + let bits = self.perms_p().map_or(self.dict.p, |p| self.dict.p & p); + Permissions { bits, owner: self.auth == Auth::Owner } + } + + /// `/P` as `/Perms` holds it: AES-256 ECB of one block (= CBC with a zero IV), marked `adb`. + fn perms_p(&self) -> Option { + if !matches!(self.dict.r, 5 | 6) { + return None; + } + let plain = aes_cbc_decrypt(&self.key, &[0; 16], self.dict.perms.first_chunk::<16>()?, false); + let p = i32::from_le_bytes(*plain.first_chunk::<4>()?); + (plain.get(9..12) == Some(&b"adb"[..])).then_some(p) } pub fn dict(&self) -> &EncryptDict { diff --git a/crates/crypt/src/tests.rs b/crates/crypt/src/tests.rs index 7272af9ea..5110e7999 100644 --- a/crates/crypt/src/tests.rs +++ b/crates/crypt/src/tests.rs @@ -212,6 +212,28 @@ fn permission_bits_follow_table_22() { assert!(p.modify(), "owners ignore /P"); } +#[test] +fn perms_limits_what_an_edited_p_grants() { + let made = new(Algorithm::Aes256, "user pw", "owner pw", 0b0100); // print only + let open = |d: &EncryptDict, pw| SecurityHandler::open(d.clone(), ID, Some(pw)).unwrap().permissions(); + let mut d = made.dict().clone(); + assert_eq!(open(&d, "user pw").bits, d.p, "/P and /Perms agree"); + d.p = -4; // /P edited to allow everything, /Perms as written + let p = open(&d, "user pw"); + assert!(p.print() && !p.modify() && !p.copy() && !p.annotate() && !p.fill_forms() && !p.assemble()); + assert!(open(&d, "owner pw").modify(), "owners ignore /P"); + d.p = -3904; // a stricter /P still applies + assert!(!open(&d, "user pw").print()); + // No /Perms, or one that does not decrypt to an `adb` block: /P as before. + for perms in [Vec::new(), vec![0; 16], made.dict().perms[..15].to_vec()] { + let d = EncryptDict { p: -4, perms, ..made.dict().clone() }; + assert!(open(&d, "user pw").modify()); + } + // R2 to R4 have no /Perms: /P is part of their file key, so an edited /P does not open. + let d = EncryptDict { p: -4, ..new(Algorithm::Aes128, "user pw", "owner pw", 0b0100).dict().clone() }; + assert_eq!(SecurityHandler::open(d, ID, Some("user pw")).unwrap_err(), CryptError::WrongPassword); +} + proptest::proptest! { #[test] fn malformed_dictionaries_never_panic(o in proptest::collection::vec(proptest::num::u8::ANY, 0..60), diff --git a/crates/crypt/tests/it/main.rs b/crates/crypt/tests/it/main.rs new file mode 100644 index 000000000..8b39bf195 --- /dev/null +++ b/crates/crypt/tests/it/main.rs @@ -0,0 +1,4 @@ +//! The crate's integration tests, linked into one test binary. + +mod proptests; +mod qpdf_literals; diff --git a/crates/crypt/tests/proptests.rs b/crates/crypt/tests/it/proptests.rs similarity index 100% rename from crates/crypt/tests/proptests.rs rename to crates/crypt/tests/it/proptests.rs diff --git a/crates/crypt/tests/it/qpdf_literals.rs b/crates/crypt/tests/it/qpdf_literals.rs new file mode 100644 index 000000000..f34464d98 --- /dev/null +++ b/crates/crypt/tests/it/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/it/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/flatten.rs b/crates/edit/src/flatten.rs index 8202c18d8..3ac546805 100644 --- a/crates/edit/src/flatten.rs +++ b/crates/edit/src/flatten.rs @@ -63,9 +63,15 @@ enum Which { fn selected(doc: &Document, d: &Dict, which: Which) -> bool { let subtype = d.name(b"Subtype").unwrap_or_default(); match which { + // Comments are what the comment list calls comments: links, pop-ups and the non-markup + // multimedia and print-production annotations (3D, Screen, Movie, RichMedia, + // PrinterMark, TrapNet, Watermark) are neither comments nor fields, and stay (#3087). Which::Comments { comments, fields } => { - let widget = subtype == b"Widget"; - !matches!(subtype, b"Link" | b"Popup") && (!widget || fields) && (widget || comments) + if subtype == b"Widget" { + fields + } else { + comments && pdfcraft_annot::is_comment_subtype(&String::from_utf8_lossy(subtype)) + } } Which::FillSign => !matches!(subtype, b"Link" | b"Popup" | b"Widget") && pdfcraft_annot::is_fill_sign(doc, d), } diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index fb6212588..75bb12f47 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -4,8 +4,8 @@ //! or delete. Only the stream that draws it is rewritten, as a new object. use kurbo::{BezPath, Point, Shape}; -use pdfcraft_content::{Matrix, Op, parse, serialize_ops}; -use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; +use pdfcraft_content::{Matrix, Op, Pieces, parse}; +use pdfcraft_cos::{Dict, Document, ObjRef, Object}; use std::sync::Arc; use crate::EditError; @@ -30,6 +30,8 @@ pub struct PageImage { /// Pixel size; zero for vector/grouped Form artwork. pub width: u32, pub height: u32, + /// The content stream its `Do` (its first token) is in, and the `Do`'s index among the + /// operators of the page's streams joined ([`Pieces`]). stream: usize, op: usize, /// CTM before `Do`, excluding a Form's own Matrix. @@ -90,6 +92,11 @@ fn streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec)> { .collect() } +/// The page's streams joined into the one stream they are: an operator may span two of them. +fn joined(streams: &[(Object, Vec)]) -> Pieces { + Pieces::join(&streams.iter().map(|(_, data)| data.as_slice()).collect::>()) +} + fn page_of(doc: &Document, page: usize) -> Result { pdfcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page)) } @@ -158,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)); } } @@ -175,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)); @@ -214,72 +235,71 @@ pub fn page_images(doc: &Document, page: usize) -> Result, EditEr let p = page_of(doc, page)?; let xo = xobjects(doc, &p.dict); let mut out = Vec::new(); - // The graphics state carries over from one stream to the next. + // The streams are one stream in pieces, so the graphics state and operators carry over. let mut state = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); - for (si, (_, data)) in streams(doc, &p.dict).into_iter().enumerate() { - for (i, op) in parse(&data).ops.iter().enumerate() { - state.apply(op)?; - let ctm = state.placement.matrix; - if op.is("Do") { - let Some(name) = op.name(0) else { continue }; - let Some(resource) = xo.get(name) else { continue }; - let obj = doc.resolve(resource); - let Object::Stream(s) = &*obj else { continue }; - let is_form = s.dict.name(b"Subtype") == Some(b"Form"); - let (matrix, bounds) = if is_form { - let Some(bounds) = s.dict.get(b"BBox").map(|v| doc.resolve(v)).and_then(|v| { - v.as_array().and_then(|a| { - if a.len() != 4 { - return None; - } - let mut b = [0.0; 4]; - for (n, v) in b.iter_mut().zip(a) { - *n = v.as_f64()?; - } - Some(b) - }) - }) else { - continue; - }; - let matrix = match s.dict.get(b"Matrix") { - Some(v) => { - let v = doc.resolve(v); - let Some(m) = v.as_array().and_then(|a| Matrix::from_operands(a)) else { continue }; - m + let pieces = joined(&streams(doc, &p.dict)); + for (i, op) in pieces.parse().ops.iter().enumerate() { + state.apply(op)?; + let ctm = state.placement.matrix; + if op.is("Do") { + let Some(name) = op.name(0) else { continue }; + let Some(resource) = xo.get(name) else { continue }; + let obj = doc.resolve(resource); + let Object::Stream(s) = &*obj else { continue }; + let is_form = s.dict.name(b"Subtype") == Some(b"Form"); + let (matrix, bounds) = if is_form { + let Some(bounds) = s.dict.get(b"BBox").map(|v| doc.resolve(v)).and_then(|v| { + v.as_array().and_then(|a| { + if a.len() != 4 { + return None; } - None => Matrix::IDENTITY, - }; - (matrix.then(&ctm), bounds) - } else if s.dict.name(b"Subtype") == Some(b"Image") { - (ctm, [0.0, 0.0, 1.0, 1.0]) - } else { + let mut b = [0.0; 4]; + for (n, v) in b.iter_mut().zip(a) { + *n = v.as_f64()?; + } + Some(b) + }) + }) else { continue; }; - let rect = matrix.bbox(bounds); - if !bounds.iter().chain(matrix.0.iter()).chain(rect.iter()).all(|v| v.is_finite()) - || bounds[2] <= bounds[0] - || bounds[3] <= bounds[1] - || rect[2] <= rect[0] - || rect[3] <= rect[1] - { - continue; - } - out.push(PageImage { - rect, - visible_rect: intersection(rect, state.placement.clip), - clip_rect: Some(state.placement.clip), - matrix: matrix.0, - name: String::from_utf8_lossy(name).into_owned(), - object: resource.as_ref(), - is_form, - width: if is_form { 0 } else { s.dict.int(b"Width").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 }, - height: if is_form { 0 } else { s.dict.int(b"Height").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 }, - stream: si, - op: i, - draw_matrix: ctm.0, - clips: state.placement.clips.clone(), - }); + let matrix = match s.dict.get(b"Matrix") { + Some(v) => { + let v = doc.resolve(v); + let Some(m) = v.as_array().and_then(|a| Matrix::from_operands(a)) else { continue }; + m + } + None => Matrix::IDENTITY, + }; + (matrix.then(&ctm), bounds) + } else if s.dict.name(b"Subtype") == Some(b"Image") { + (ctm, [0.0, 0.0, 1.0, 1.0]) + } else { + continue; + }; + let rect = matrix.bbox(bounds); + if !bounds.iter().chain(matrix.0.iter()).chain(rect.iter()).all(|v| v.is_finite()) + || bounds[2] <= bounds[0] + || bounds[3] <= bounds[1] + || rect[2] <= rect[0] + || rect[3] <= rect[1] + { + continue; } + out.push(PageImage { + rect, + visible_rect: intersection(rect, state.placement.clip), + clip_rect: Some(state.placement.clip), + matrix: matrix.0, + name: String::from_utf8_lossy(name).into_owned(), + object: resource.as_ref(), + is_form, + width: if is_form { 0 } else { s.dict.int(b"Width").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 }, + height: if is_form { 0 } else { s.dict.int(b"Height").unwrap_or(0).clamp(0, u32::MAX as i64) as u32 }, + stream: pieces.piece_of(op.span.start), + op: i, + draw_matrix: ctm.0, + clips: state.placement.clips.clone(), + }); } } Ok(out) @@ -294,24 +314,24 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi /// The forms being read (cycle guard) and how many were entered (the page's budget). struct Forms { path: Vec, - visits: usize, + budget: crate::text::FormBudget, } fn walk( doc: &Document, - data: &[u8], + ops: &[Op], resources: &Dict, st: &mut ImageState, - stream: usize, + stream: &dyn Fn(&Op) -> usize, forms: &mut Forms, out: &mut Vec, ) -> Result<(), EditError> { let xo = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); - for (i, op) in parse(data).ops.iter().enumerate() { + for (i, op) in ops.iter().enumerate() { st.apply(op)?; let ctm = st.placement.matrix; if op.is("Do") { let Some(name) = op.name(0) else { continue }; - if let Some(form) = crate::text::form_call(doc, resources, name, ctm, &forms.path, &mut forms.visits) { + if let Some(form) = crate::text::form_call(doc, resources, name, ctm, &forms.path, &mut forms.budget) { if let Some(r) = form.obj { forms.path.push(r); } @@ -325,7 +345,8 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi ); let clip = bounds.map_or(st.placement.clip, |b| intersection(st.placement.clip, b).unwrap_or([0.0; 4])); let mut inner = ImageState::new(form.ctm, clip, st.placement.clips.clone()); - walk(doc, &form.data, &form.resources, &mut inner, stream, forms, out)?; + let piece = stream(op); + walk(doc, &parse(&form.data).ops, &form.resources, &mut inner, &|_| piece, forms, out)?; if form.obj.is_some() { forms.path.pop(); } @@ -347,7 +368,7 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi is_form: false, width: s.dict.int(b"Width").unwrap_or(0).max(0) as u32, height: s.dict.int(b"Height").unwrap_or(0).max(0) as u32, - stream, + stream: stream(op), op: i, draw_matrix: ctm.0, clips: st.placement.clips.clone(), @@ -360,10 +381,9 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let mut out = Vec::new(); let mut st = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); - let mut forms = Forms { path: Vec::new(), visits: 0 }; - for (si, (_, data)) in streams(doc, &p.dict).into_iter().enumerate() { - walk(doc, &data, &res, &mut st, si, &mut forms, &mut out)?; - } + let mut forms = Forms { path: Vec::new(), budget: crate::text::FormBudget::default() }; + let pieces = joined(&streams(doc, &p.dict)); + walk(doc, &pieces.parse().ops, &res, &mut st, &|op| pieces.piece_of(op.span.start), &mut forms, &mut out)?; Ok(out) } @@ -403,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. @@ -450,9 +484,9 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag let img = images.get(index).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1)))?; let p = page_of(doc, page)?; let all = streams(doc, &p.dict); - let (obj, data) = all.get(img.stream).cloned().ok_or_else(|| EditError::Invalid("the image stream is missing".into()))?; - let mut ops = parse(&data).ops; - if ops.get(img.op).is_none() { + let pieces = joined(&all); + let ops = pieces.parse().ops; + if !ops.get(img.op).is_some_and(|op| op.is("Do")) { return Err(EditError::Invalid("the image operation is missing".into())); } let mut resources = None; @@ -479,16 +513,10 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag vec![Op::new("Do", vec![Object::name(&name)])] } }; - ops.splice(img.op..=img.op, replacement); - let mut dict = match &*doc.resolve(&obj) { - Object::Stream(s) => s.dict.clone(), - _ => Dict::new(), - }; - dict.remove(b"Length"); - let new = doc.add(Object::Stream(Stream::flate(dict, &serialize_ops(&ops)))); - let contents: Vec = all.iter().enumerate().map(|(i, (o, _))| if i == img.stream { Object::Ref(new) } else { o.clone() }).collect(); + let changed = crate::text::splice(&pieces, &ops, |index| if index == img.op { (replacement.clone(), false) } else { (Vec::new(), true) }); + let contents = crate::text::rewritten_contents(doc, &all, changed); doc.update_dict(p.obj, |d| { - d.set(b"Contents".to_vec(), if let [one] = contents.as_slice() { one.clone() } else { Object::Array(contents) }); + d.set(b"Contents".to_vec(), contents); if let Some(res) = resources { d.set(b"Resources".to_vec(), Object::Dict(res)); } @@ -599,22 +627,12 @@ fn transform_image( if !transform.iter().all(|v| v.is_finite() && v.abs() <= 1e12) { return Err(EditError::Invalid("the image transform must be finite and no larger than 1e12".into())); } - let mut offset = 0_usize; - for (_, data) in streams.iter().take(image.stream) { - offset = offset - .checked_add(data.len()) - .and_then(|n| n.checked_add(1)) - .ok_or_else(|| EditError::Invalid("the image content is too large".into()))?; - } - let data = streams.get(image.stream).ok_or_else(|| EditError::Invalid("the image stream is missing".into()))?; - let op = parse(&data.1).ops.get(image.op).cloned().ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?; - let span = offset.checked_add(op.span.start).ok_or_else(|| EditError::Invalid("the image content is too large".into()))?; - let pieces = pdfcraft_content::Pieces::join(&streams.iter().map(|(_, data)| data.as_slice()).collect::>()); + let pieces = joined(streams); let ops = pieces.parse().ops; - let target = ops - .iter() - .position(|op| op.span.start == span && op.is("Do")) - .ok_or_else(|| EditError::Invalid("the image operation could not be located".into()))?; + let target = image.op; + if !ops.get(target).is_some_and(|op| op.is("Do")) { + return Err(EditError::Invalid("the image operation could not be located".into())); + } // A private path crop moves with its image regardless of intervening state operators. // Shared crops retain the existing fixed-clip behavior, preserving neighbouring paint. let (start, end, placement) = image_transform_scope(&ops, target)?.unwrap_or((target, target, Matrix(image.draw_matrix))); @@ -677,22 +695,12 @@ fn crop_image( if x1 - x0 < 1e-6 || y1 - y0 < 1e-6 { return Err(EditError::Invalid("the crop box has no area".into())); } - let mut offset = 0_usize; - for (_, data) in streams.iter().take(image.stream) { - offset = offset - .checked_add(data.len()) - .and_then(|n| n.checked_add(1)) - .ok_or_else(|| EditError::Invalid("the image content is too large".into()))?; - } - let data = streams.get(image.stream).ok_or_else(|| EditError::Invalid("the image stream is missing".into()))?; - let op = parse(&data.1).ops.get(image.op).cloned().ok_or_else(|| EditError::Invalid("the image operation is missing".into()))?; - let span = offset.checked_add(op.span.start).ok_or_else(|| EditError::Invalid("the image content is too large".into()))?; - let pieces = pdfcraft_content::Pieces::join(&streams.iter().map(|(_, data)| data.as_slice()).collect::>()); + let pieces = joined(streams); let ops = pieces.parse().ops; - let target = ops - .iter() - .position(|op| op.span.start == span && op.is("Do")) - .ok_or_else(|| EditError::Invalid("the image operation could not be located".into()))?; + let target = image.op; + if !ops.get(target).is_some_and(|op| op.is("Do")) { + return Err(EditError::Invalid("the image operation could not be located".into())); + } let (start, end, placement) = image_transform_scope(&ops, target)?.unwrap_or((target, target, Matrix(image.draw_matrix))); let inverse = placement.invert().ok_or_else(|| EditError::Invalid("the image has no area".into()))?; let corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)].map(|(x, y)| inverse.apply(x, y)); @@ -758,67 +766,72 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize if !movement.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) { return Err(EditError::Invalid("the image move is too large".into())); } - plans.insert((image.stream, image.op), (movement, placement, offset)); + plans.insert(image.op, (movement, placement, offset)); } - let mut data = Vec::with_capacity(all.len()); + let pieces = joined(&all); + let ops = pieces.parse().ops; let mut inside_text = false; - for (stream, (_, bytes)) in all.iter().enumerate() { - let pieces = pdfcraft_content::Pieces::join(&[bytes.as_slice()]); - let ops = pieces.parse().ops; - for (index, op) in ops.iter().enumerate() { - if op.is("BT") { - inside_text = true; - } else if op.is("ET") { - inside_text = false; - } else if inside_text && plans.contains_key(&(stream, index)) { - return Err(EditError::Invalid("an image inside a text object cannot be moved in a group".into())); - } + for (index, op) in ops.iter().enumerate() { + if op.is("BT") { + inside_text = true; + } else if op.is("ET") { + inside_text = false; + } else if inside_text && plans.contains_key(&index) { + return Err(EditError::Invalid("an image inside a text object cannot be moved in a group".into())); } - // Images alone in their own `q re W n cm Do Q` take the clip with them. - let mut clips = std::collections::BTreeMap::new(); - for index in 0..ops.len() { - let Some(&(_, placement, offset)) = plans.get(&(stream, index)) else { continue }; - let Some((at, cm)) = exclusive_clip(&ops, index) else { continue }; - let translation = Matrix([1.0, 0.0, 0.0, 1.0, offset[0], offset[1]]); - let Some(c) = clip_matrix(placement, cm, translation).filter(|c| c.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12)) else { - return Err(EditError::Invalid("the image move is too large".into())); - }; - plans.remove(&(stream, index)); - clips.insert((stream, at), c); - } - // An image already alone in `q cm Do Q` moves by changing that `cm`, not by nesting another. - let mut folds = std::collections::BTreeMap::new(); - for index in 0..ops.len() { - let Some(&(movement, _, _)) = plans.get(&(stream, index)) else { continue }; - let Some((at, cm)) = enclosing_transform(&ops, index, index) else { continue }; - let folded = movement.then(&cm); - if !folded.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) { - return Err(EditError::Invalid("the image move is too large".into())); - } - plans.remove(&(stream, index)); - folds.insert(at, folded); - } - let changed = crate::text::splice(&pieces, &ops, |index| { - if let Some(c) = clips.remove(&(stream, index)) { - return (vec![Op::new("cm", c.0.map(Object::Real).to_vec())], true); - } - if let Some(m) = folds.remove(&index) { - return (vec![Op::new("cm", m.0.map(Object::Real).to_vec())], false); - } - let Some((matrix, _, _)) = plans.remove(&(stream, index)) else { return (Vec::new(), true) }; - let original = ops.get(index).cloned().into_iter(); - // Local translations can be tiny under a large CTM. Preserve their precision - // rather than rounding them to zero or amplifying an error on the page. - let mut out = vec![Op::new("q", vec![]), Op::new("cm", matrix.0.map(Object::Real).to_vec())]; - out.extend(original); - out.push(Op::new("Q", vec![])); - (out, false) - }); - data.push(changed.into_iter().next().unwrap_or_default()); } - if !plans.is_empty() { + // Images alone in their own `q re W n cm Do Q` take the clip with them. + let mut clips = std::collections::BTreeMap::new(); + for index in 0..ops.len() { + let Some(&(_, placement, offset)) = plans.get(&index) else { continue }; + let Some((at, cm)) = exclusive_clip(&ops, index) else { continue }; + let translation = Matrix([1.0, 0.0, 0.0, 1.0, offset[0], offset[1]]); + let Some(c) = clip_matrix(placement, cm, translation).filter(|c| c.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12)) else { + return Err(EditError::Invalid("the image move is too large".into())); + }; + plans.remove(&index); + // A `cm` an earlier move put before the clip takes this move too: `c · prev` is the + // same CTM as `prev` followed by another `cm c`, without one more operator per move. + let folded = at + .checked_sub(1) + .and_then(|i| Some((i, c.then(&Matrix(ops.get(i).filter(|op| op.is("cm"))?.nums::<6>()?))))) + .filter(|(_, m)| m.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12)); + match folded { + Some((i, m)) => clips.insert(i, (m, false)), + None => clips.insert(at, (c, true)), + }; + } + // An image already alone in `q cm Do Q` moves by changing that `cm`, not by nesting another. + let mut folds = std::collections::BTreeMap::new(); + for index in 0..ops.len() { + let Some(&(movement, _, _)) = plans.get(&index) else { continue }; + let Some((at, cm)) = enclosing_transform(&ops, index, index) else { continue }; + let folded = movement.then(&cm); + if !folded.0.iter().all(|v| v.is_finite() && v.abs() <= 1e12) { + return Err(EditError::Invalid("the image move is too large".into())); + } + plans.remove(&index); + folds.insert(at, folded); + } + if plans.keys().any(|index| !ops.get(*index).is_some_and(|op| op.is("Do"))) { return Err(EditError::Invalid("a selected image operation is missing".into())); } + let data = crate::text::splice(&pieces, &ops, |index| { + if let Some((c, keep)) = clips.remove(&index) { + return (vec![Op::new("cm", c.0.map(Object::Real).to_vec())], keep); + } + if let Some(m) = folds.remove(&index) { + return (vec![Op::new("cm", m.0.map(Object::Real).to_vec())], false); + } + let Some((matrix, _, _)) = plans.remove(&index) else { return (Vec::new(), true) }; + let original = ops.get(index).cloned().into_iter(); + // Local translations can be tiny under a large CTM. Preserve their precision + // rather than rounding them to zero or amplifying an error on the page. + let mut out = vec![Op::new("q", vec![]), Op::new("cm", matrix.0.map(Object::Real).to_vec())]; + out.extend(original); + out.push(Op::new("Q", vec![])); + (out, false) + }); let contents = crate::text::rewritten_contents(doc, &all, data); doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), contents))?; Ok(()) diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 515f17d8d..d3db00fe3 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -2,15 +2,18 @@ //! 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 //! anything is drawn on top, the page's original content is wrapped in `q … Q` (two tiny streams) -//! so its graphics state can't leak into the mark. +//! 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)] @@ -54,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, @@ -64,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, + } } } @@ -82,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 @@ -244,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). @@ -296,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 { @@ -327,7 +421,41 @@ fn add_resources(doc: &mut Document, page: &pdfcraft_model::Page, opacity: Optio } const WRAP_OPEN: &[u8] = b"q %PdfCraft\n"; -const WRAP_CLOSE: &[u8] = b"Q %PdfCraft\n"; +/// Starts with an end of line, so a comment left open at the end of the content before it +/// can't swallow the `Q`. Older files have the close without it. +const WRAP_CLOSE: &[u8] = b"\nQ %PdfCraft\n"; +const OLD_WRAP_CLOSE: &[u8] = b"Q %PdfCraft\n"; + +/// Whether `bytes` are a wrapper's close: canonical `EMC` closures, then the existing +/// graphics closures. Legacy closes without the leading newline remain valid on their own. +fn is_wrap_close(bytes: &[u8]) -> bool { + let mut rest = bytes; + while let Some(shorter) = rest.strip_prefix(b"\nEMC".as_slice()) { + rest = shorter; + } + let legacy = rest.len() == bytes.len(); + loop { + if rest == WRAP_CLOSE || (legacy && rest == OLD_WRAP_CLOSE) { + return true; + } + match rest.strip_prefix(b"\nQ".as_slice()) { + Some(shorter) => rest = shorter, + None => return false, + } + } +} + +/// A `Wrap`-tagged stream's role from its exact bytes: `Some(true)` for an open, `Some(false)` +/// for a close. +fn wrapper_role(doc: &Document, o: &Object) -> Option { + if stream_tag(doc, o).as_deref() != Some("Wrap") { + return None; + } + let Object::Stream(stream) = &*doc.resolve(o) else { return None }; + // Wrappers are a few bytes; one that decodes to more isn't PdfCraft's. + let bytes = stream.decoded_within(64 * 1024).ok()?; + if bytes == WRAP_OPEN { Some(true) } else { is_wrap_close(&bytes).then_some(false) } +} #[cfg(test)] fn stream_bytes(doc: &Document, o: &Object) -> Option> { @@ -350,27 +478,169 @@ fn tagged(tag: &str) -> Dict { d } -/// Put a mark's content on a page: behind (prepended) or on top (appended, after wrapping the -/// original content in q/Q). +/// What placing a mark on top needs, from the page's actual operators (not the `/PCMark` tags, +/// which another writer may keep, copy or forge). +enum Isolation { + /// The content parses cleanly with balanced `q`/`Q` and closed text objects and paths. + /// `suffix`: the stream from which the content leaves graphics state set (or a comment open) + /// that a mark on top must not inherit, so it needs a wrapper from there to the end. + /// `wrappers`: PdfCraft wrapper pairs (open, close) as stream indexes. + /// `marked`: unfinished marked-content sequences to close before a new mark. + Clean { suffix: Option, wrappers: Vec<(usize, usize)>, marked: usize }, + /// The content could not be read that precisely (it doesn't decode, is too large, or has + /// stray bytes, unbalanced `q`/`Q` or an unfinished text object or path). `unclosed` is + /// the count of `q` left open at the end, when known. `marked` counts known unfinished + /// marked-content sequences independently of graphics malformation, or zero when unknown. + Unknown { unclosed: usize, marked: usize }, +} + +const ISOLATION_MAX_STREAMS: usize = 100_000; +const ISOLATION_MAX_BYTES: usize = 128 * 1024 * 1024; +const ISOLATION_MAX_OPS: usize = 1_000_000; +const ISOLATION_MAX_DEPTH: usize = 10_000; + +fn isolation(doc: &Document, list: &[Object]) -> Isolation { + let unknown = Isolation::Unknown { unclosed: 0, marked: 0 }; + if list.len() > ISOLATION_MAX_STREAMS { + return unknown; + } + let mut remaining = ISOLATION_MAX_BYTES; + let mut data = Vec::with_capacity(list.len()); + for object in list { + let resolved = doc.resolve(object); + let Object::Stream(stream) = &*resolved else { return unknown }; + let Ok(bytes) = stream.decoded_within(remaining) else { return unknown }; + let Some(left) = remaining.checked_sub(bytes.len().saturating_add(1)) else { return unknown }; + remaining = left; + data.push(bytes); + } + let pieces = pdfcraft_content::Pieces::join(&data); + let parsed = pieces.parse(); + if parsed.skipped != 0 || parsed.ops.len() > ISOLATION_MAX_OPS { + return unknown; + } + let edges = pieces.comment_edges(&parsed); + let (mut text_open, mut path_open, mut dirty, mut malformed) = (false, false, false, false); + let mut stack: Vec = Vec::new(); + let (mut marked, mut marked_known) = (0usize, true); + let mut split = 0; + let mut previous_last = None; + let mut wrappers = Vec::new(); + for op in &parsed.ops { + let (first, last) = pieces.pieces_of(op); + // A wrapper can only go in before a whole stream, outside graphics and marked-content + // scopes, between operators, where no comment would swallow the wrapper's `q`. + if stack.is_empty() + && marked_known + && marked == 0 + && !text_open + && !path_open + && !dirty + && previous_last.is_none_or(|previous| first > previous) + && !edges.get(first).is_some_and(|edge| edge.0) + { + split = first; + } + previous_last = Some(last); + // Match the reader's tolerant operands: BMC uses the last name, BDC the last + // name/object pair, and EMC ignores surplus operands. Invalid begins are ignored. + let begin_marked = match op.op.as_slice() { + b"BMC" => op.operands.last().is_some_and(|o| matches!(o, Object::Name(_))), + b"BDC" => op.operands.len().checked_sub(2).and_then(|i| op.operands.get(i)).is_some_and(|o| matches!(o, Object::Name(_))), + _ => false, + }; + if marked_known { + if begin_marked { + if marked < ISOLATION_MAX_DEPTH { + marked += 1; + } else { + // Once the budget is exceeded, later EMCs cannot restore a known depth. + marked_known = false; + marked = 0; + } + } else if op.is("EMC") { + marked = marked.saturating_sub(1); + } + } + match op.op.as_slice() { + b"BT" if !text_open => text_open = true, + b"ET" if text_open => text_open = false, + b"BT" | b"ET" => malformed = true, + b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re" | b"W" | b"W*" => path_open = true, + b"n" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" => path_open = false, + _ => {} + } + match op.op.as_slice() { + b"q" if op.operands.is_empty() && stack.len() < ISOLATION_MAX_DEPTH => stack.push(first), + b"Q" if op.operands.is_empty() => match stack.pop() { + Some(open) => { + // Only a standalone PdfCraft wrapper pair may later be removed again. + let wrap = |i: usize| list.get(i).is_some_and(|o| stream_tag(doc, o).as_deref() == Some("Wrap")); + if wrap(open) + && wrap(first) + && data.get(open).is_some_and(|d| d == WRAP_OPEN) + && data.get(first).is_some_and(|d| is_wrap_close(d)) + { + wrappers.push((open, first)); + } + } + None => malformed = true, + }, + b"q" | b"Q" => malformed = true, + _ if stack.is_empty() => dirty = true, + _ => {} + } + } + if malformed || text_open || path_open || !stack.is_empty() || !marked_known { + return Isolation::Unknown { unclosed: if malformed { 0 } else { stack.len() }, marked }; + } + let comment_open = edges.last().is_some_and(|edge| edge.1); + Isolation::Clean { suffix: (dirty || comment_open || marked != 0).then_some(split), wrappers, marked } +} + +/// 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) } -/// Put content on a page, tagged `tag` (see [`place`]). +/// Put content on a page, tagged `tag` (see [`place`]). On top, whatever graphics state the +/// content before leaves set (a `cm`, a clip, text state…) must not reach the mark, including +/// content appended after an earlier PdfCraft wrapper by another tool (#3084): the stretch of +/// content that leaves state set is wrapped in q/Q, once. Any known unfinished marked-content +/// 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 { + match isolation(doc, &list) { + Isolation::Clean { suffix, marked, .. } => suffix.map(|split| (split, 0, marked)), + // 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 } => (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 { - let wrapped = list.iter().any(|o| stream_tag(doc, o).as_deref() == Some("Wrap")); - let original = list.iter().any(|o| stream_tag(doc, o).is_none()); - if !wrapped && original { + if let Some((split, unclosed, marked)) = wrap { + let mut close_bytes = b"\nEMC".repeat(marked); + close_bytes.extend_from_slice(&b"\nQ".repeat(unclosed)); + close_bytes.extend_from_slice(WRAP_CLOSE); let open = Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged("Wrap"), WRAP_OPEN.to_vec())))); - let close = Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged("Wrap"), WRAP_CLOSE.to_vec())))); - // Behind-marks stay outside the wrapper. - let split = list.iter().position(|o| stream_tag(doc, o).is_none()).unwrap_or(list.len()); - list.insert(split, open); + let close = Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged("Wrap"), close_bytes)))); + list.insert(split.min(list.len()), open); list.push(close); } list.push(mark); @@ -379,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. @@ -459,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)?; } @@ -472,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() { @@ -482,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, @@ -503,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(()) } @@ -613,8 +918,9 @@ pub fn add_background(doc: &mut Document, pages: &[usize], bg: &Background, repl Ok(()) } -/// Remove marks of `kind` from `pages`; returns how many were removed. When no mark is left on -/// top of a page, the q/Q wrapper goes too, restoring the original content list. +/// Remove marks of `kind` from `pages`; returns how many were removed. Wrappers left with +/// nothing after them go too, restoring the original content list; a wrapper that content +/// after it still relies on (another tool's appended stream) stays. pub fn remove_marks(doc: &mut Document, pages: &[usize], kind: MarkKind) -> Result { let all = page_list(doc); check(pages, all.len())?; @@ -625,12 +931,41 @@ pub fn remove_marks(doc: &mut Document, pages: &[usize], kind: MarkKind) -> Resu let before = list.len(); let mut kept: Vec = list.into_iter().filter(|o| stream_tag(doc, o).as_deref() != Some(kind.tag())).collect(); removed += before - kept.len(); - // Marks on top come after the closing wrapper; without any, the wrapper has no purpose. - let is_wrap = |o: &Object| stream_tag(doc, o).as_deref() == Some("Wrap"); - let close = kept.iter().rposition(&is_wrap); - let marks_on_top = close.is_some_and(|c| kept[c + 1..].iter().any(|o| stream_tag(doc, o).is_some_and(|t| t != "Wrap"))); - if !marks_on_top { - kept.retain(|o| !is_wrap(o)); + // Strip only wrapper pairs whose close ends the list (after stripping the pairs after + // it): nothing comes after them to need the state they restore. Pairs come from the + // operators; for content that can't be read that precisely, from the wrapper streams + // alone, so updating a mark there doesn't nest another wrapper each time. + if before != kept.len() { + let wrappers = match isolation(doc, &kept) { + Isolation::Clean { wrappers, .. } => wrappers, + Isolation::Unknown { .. } => { + let mut opens = Vec::new(); + let mut pairs = Vec::new(); + for (i, o) in kept.iter().enumerate() { + match wrapper_role(doc, o) { + Some(true) => opens.push(i), + Some(false) => pairs.extend(opens.pop().map(|open| (open, i))), + None => {} + } + } + pairs + } + }; + let pairs: std::collections::HashMap = wrappers.into_iter().map(|(open, close)| (close, open)).collect(); + let mut discarded = std::collections::HashSet::new(); + let mut end = kept.len(); + while let Some(close) = end.checked_sub(1) { + if discarded.contains(&close) { + end = close; + } else if let Some(&open) = pairs.get(&close) { + discarded.insert(open); + discarded.insert(close); + end = close; + } else { + break; + } + } + kept = kept.into_iter().enumerate().filter(|(i, _)| !discarded.contains(i)).map(|(_, o)| o).collect(); } if before != kept.len() { doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(kept)))?; @@ -644,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, RangeStyle, TextBlock, TextLine, clear_block, reading_blocks, 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::{ @@ -665,5 +1002,7 @@ pub use added::{ update_content, }; +#[cfg(test)] +mod marked_isolation_tests; #[cfg(test)] mod tests; diff --git a/crates/edit/src/marked_isolation_tests.rs b/crates/edit/src/marked_isolation_tests.rs new file mode 100644 index 000000000..6da832229 --- /dev/null +++ b/crates/edit/src/marked_isolation_tests.rs @@ -0,0 +1,122 @@ +use super::*; + +fn fixture(parts: &[&[u8]]) -> Document { + let mut doc = Document::new_empty(); + let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap(); + let streams = parts.iter().map(|data| Object::Ref(doc.add(Object::Stream(Stream::from_raw(Dict::new(), data.to_vec()))))).collect(); + let mut page = Dict::new(); + page.set(b"Type".to_vec(), Object::name("Page")); + page.set(b"Parent".to_vec(), Object::Ref(pages)); + page.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()])); + page.set(b"Contents".to_vec(), Object::Array(streams)); + let page = doc.add(page); + doc.update_dict(pages, |d| { + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])); + d.set(b"Count".to_vec(), Object::Int(1)); + }) + .unwrap(); + doc +} + +fn placed(parts: &[&[u8]]) -> (Document, Vec) { + let mut doc = fixture(parts); + stamp(&mut doc, 0, "Probe", b"q Q\n".to_vec()).unwrap(); + let page = page_list(&doc).remove(0); + let list = contents(&mut doc, &page).unwrap(); + (doc, list) +} + +fn close(parts: &[&[u8]]) -> Vec { + let (doc, list) = placed(parts); + let object = &list[list.len() - 2]; + assert_eq!(stream_tag(&doc, object).as_deref(), Some("Wrap")); + let bytes = stream_bytes(&doc, object).unwrap(); + assert_eq!(wrapper_role(&doc, object), Some(false)); + bytes +} + +#[test] +fn closes_only_marked_operators_not_strings_names_comments_or_image_samples() { + for tail in + [b"/Span << /Text (BMC BDC EMC) >> DP".as_slice(), b"/BMC /EMC DP", b"% BMC BDC EMC", b"BI /W 11 /H 1 /BPC 8 /CS /G ID BMC BDC EMC EI\n"] + { + let mut input = b"/OC /L1 BDC\n".to_vec(); + input.extend_from_slice(tail); + assert_eq!(close(&[&input]), b"\nEMC\nQ %PdfCraft\n", "{tail:?}"); + } +} + +#[test] +fn safe_suffix_stays_outside_marked_content_across_balanced_graphics() { + let (doc, list) = placed(&[b"q /OC /L1 BDC Q", b"50 Tz"]); + assert_eq!(stream_bytes(&doc, &list[0]).unwrap(), WRAP_OPEN); + assert_eq!(stream_bytes(&doc, &list[1]).unwrap(), b"q /OC /L1 BDC Q"); + assert_eq!(stream_bytes(&doc, &list[list.len() - 2]).unwrap(), b"\nEMC\nQ %PdfCraft\n"); + assert_eq!(close(&[b"q /OC /L1 BDC Q"]), b"\nEMC\nQ %PdfCraft\n"); +} + +#[test] +fn known_marked_depth_survives_unknown_graphics_and_tolerant_operands() { + for (input, expected) in [ + (b"/OC /L1 BDC q".as_slice(), b"\nEMC\nQ\nQ %PdfCraft\n".as_slice()), + (b"Q /OC /L1 BDC", b"\nEMC\nQ %PdfCraft\n"), + (b"/OC /L1 BDC BT", b"\nEMC\nQ %PdfCraft\n"), + (b"/OC /L1 BDC 0 0 m", b"\nEMC\nQ %PdfCraft\n"), + (b"/OC /L1 BDC 123 BMC /Artifact BDC", b"\nEMC\nQ %PdfCraft\n"), + (b"EMC 123 /OC /L1 BDC 123 /Artifact BMC", b"\nEMC\nEMC\nQ %PdfCraft\n"), + (b"/OC /L1 BDC 123 EMC", b"\nQ %PdfCraft\n"), + (b"/Span 123 BDC", b"\nEMC\nQ %PdfCraft\n"), + ] { + assert_eq!(close(&[input]), expected, "{input:?}"); + } +} + +#[test] +fn closes_cross_stream_begins_without_closing_balanced_sequences_twice() { + assert_eq!(close(&[b"/OC", b"/L1 BDC"]), b"\nEMC\nQ %PdfCraft\n"); + assert_eq!(close(&[b"/OC /L1 BDC", b"EMC"]), WRAP_CLOSE); + assert_eq!(close(&[b"/OC /L1 BDC % EMC", b"EMC\n"]), b"\nEMC\nQ %PdfCraft\n"); +} + +#[test] +fn recognizes_only_canonical_new_closes_and_retains_legacy_closes() { + for bytes in [WRAP_CLOSE, OLD_WRAP_CLOSE, b"\nQ\nQ %PdfCraft\n", b"\nEMC\nQ %PdfCraft\n", b"\nEMC\nEMC\nQ\nQ %PdfCraft\n"] { + assert!(is_wrap_close(bytes), "{bytes:?}"); + } + for bytes in [ + b"\nEMCQ %PdfCraft\n".as_slice(), + b"\nEMC\nQQ %PdfCraft\n", + b"\nQ\nEMC\nQ %PdfCraft\n", + b"EMC\nQ %PdfCraft\n", + b"\nEMC 1 w\nQ %PdfCraft\n", + b"\nEMC\nQ %PdfCraft\nq Q", + ] { + assert!(!is_wrap_close(bytes), "{bytes:?}"); + } +} + +#[test] +fn depth_budget_bounds_emitted_closures_and_overflow_stays_unknown() { + let maximum = b"/Artifact BMC\n".repeat(ISOLATION_MAX_DEPTH); + let mut expected = b"\nEMC".repeat(ISOLATION_MAX_DEPTH); + expected.extend_from_slice(WRAP_CLOSE); + assert_eq!(close(&[&maximum]), expected); + let mut combined = maximum.clone(); + combined.extend_from_slice(&b"q\n".repeat(ISOLATION_MAX_DEPTH)); + let mut expected = b"\nEMC".repeat(ISOLATION_MAX_DEPTH); + expected.extend_from_slice(&b"\nQ".repeat(ISOLATION_MAX_DEPTH)); + expected.extend_from_slice(WRAP_CLOSE); + assert_eq!(close(&[&combined]), expected); + assert!(expected.len() < 64 * 1024, "maximum generated close remains recognizable for removal"); + let mut doc = fixture(&[&combined]); + let page = page_list(&doc).remove(0); + let original = contents(&mut doc, &page).unwrap(); + place_tagged(&mut doc, &page, MarkKind::HeaderFooter.tag(), b"q Q\n".to_vec(), false).unwrap(); + assert_eq!(remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(), 1); + assert_eq!(contents(&mut doc, &page).unwrap(), original, "maximum combined close can be removed without nesting wrappers"); + for suffix in [b"/Artifact BMC\n".as_slice(), b"/Artifact BMC\nEMC\n", b"/Artifact BMC\nEMC\nEMC\n"] { + let mut over = maximum.clone(); + over.extend_from_slice(suffix); + assert_eq!(close(&[&over]), WRAP_CLOSE, "overflow cannot resume speculative depth tracking after EMC"); + } +} diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index b542142df..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)); @@ -77,7 +99,8 @@ fn header_and_footer_are_drawn_in_display_space_and_wrap_the_original_content() let s0 = streams(&doc, 0); // q-wrapper, original, Q-wrapper, header/footer. assert_eq!(s0.len(), 4, "{s0:?}"); - assert_eq!((s0[0].as_str(), s0[2].as_str()), ("q %PdfCraft\n", "Q %PdfCraft\n")); + // The close starts on a new line, so a comment the original ends in can't swallow it. + assert_eq!((s0[0].as_str(), s0[2].as_str()), ("q %PdfCraft\n", "\nQ %PdfCraft\n")); let mark = &s0[3]; assert!(mark.contains("/PCMark /HeaderFooter") && mark.contains("(Page 1 of 3) Tj") && mark.contains("(Confidential \\(draft\\)) Tj"), "{mark}"); assert!(mark.contains("1 0 0 1 0 0 cm"), "upright page: identity"); @@ -96,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 @@ -155,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(); @@ -167,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(); @@ -283,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 @@ -876,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(); @@ -903,6 +1181,92 @@ fn text_page(content: &str) -> Document { Document::open(Arc::new(out)).unwrap() } +#[test] +fn tracked_text_keeps_words_in_paragraphs_and_objects() { + let expected = "TRACKED SAMPLE TEXT"; + for spacing in [1.5, 2.0001, 3.0] { + for scale in [50.0, 100.0, 150.0] { + for positioned in [false, true] { + let mut show = String::new(); + if !positioned { + show.push('['); + } + for (i, c) in expected.chars().enumerate() { + if positioned { + show.push_str(&format!("({c}) Tj ")); + if i + 1 < expected.len() { + show.push_str(&format!("[{}] TJ ", -spacing * 100.0)); + } + } else { + show.push_str(&format!("({c}) ")); + if i + 1 < expected.len() { + show.push_str(&format!("{} ", -spacing * 100.0)); + } + } + } + if !positioned { + show.push_str("] TJ"); + } + let doc = text_page(&format!("BT /F1 10 Tf {scale} Tz 72 700 Td {show} ET")); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, expected, "{spacing}, {scale}, {positioned}"); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, expected); + assert_eq!(crate::objects::editable_objects(&doc, 0).unwrap()[0].text.as_deref(), Some(expected)); + } + } + let doc = text_page(&format!("BT /F1 10 Tf {spacing} Tc 72 700 Td ({expected}) Tj ET")); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, expected); + } +} + +#[test] +fn single_glyph_words_and_cells_are_not_tracking() { + for (content, expected) in [ + ("BT /F1 10 Tf 72 700 Td [(a) -1000 (b) -1000 (c) -1000 (d)] TJ ET", "a b c d"), + ("BT /F1 10 Tf 72 700 Td [(1) -2500 (2) -2500 (3) -2500 (4) -2500 (5)] TJ ET", "1 2 3 4 5"), + ("BT /F1 10 Tf 72 700 Td [(A) -300 (B) -1200 (C) -1200 (D) -1200 (E) -300 (F) -1200 (G)] TJ ET", "A B C D E F G"), + ] { + assert_eq!(text::text_blocks(&text_page(content), 0).unwrap()[0].text, expected, "{content}"); + } + let mut s = String::from("BT /F1 10 Tf "); + for (i, c) in "12345".chars().enumerate() { + s.push_str(&format!("1 0 0 1 {} 700 Tm ({c}) Tj ", 72 + i * 12)); + } + s.push_str("ET"); + assert_eq!(text::text_blocks(&text_page(&s), 0).unwrap()[0].text, "1 2 3 4 5"); +} + +#[test] +fn tracked_text_preserves_larger_word_gaps() { + for show in [ + "[(T) -300 (E) -300 (S) -300 (T) -600 (W) -300 (O) -300 (R) -300 (D)] TJ", + "(T) Tj [-300] TJ (E) Tj [-300] TJ (S) Tj [-300] TJ (T) Tj [-600] TJ (W) Tj [-300] TJ (O) Tj [-300] TJ (R) Tj [-300] TJ (D) Tj", + "[(TEST) -300 (WORD)] TJ", + "(TEST) Tj [-300] TJ (W) Tj [-300] TJ (O) Tj [-300] TJ (R) Tj [-300] TJ (D) Tj", + ] { + let doc = text_page(&format!("BT /F1 10 Tf 72 700 Td {show} ET")); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, "TEST WORD", "{show}"); + } +} + +#[test] +fn tracked_text_positions_do_not_count_character_spacing_twice() { + let expected = "TRACKED SAMPLE TEXT"; + for char_spacing in [0.0, 2.0] { + for transform in ["1 0 0 1 0 0", "0 1 -1 0 800 0"] { + let mut content = format!("q {transform} cm BT /F1 10 Tf {char_spacing} Tc "); + let mut x = 72.0; + for c in expected.chars() { + content.push_str(&format!("1 0 0 1 {x:.4} 700 Tm ({c}) Tj ")); + x += pdfcraft_fonts::helvetica_width(&c.to_string(), 10.0) + 2.0001; + } + content.push_str("ET Q"); + let doc = text_page(&content); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].text, expected); + assert_eq!(crate::objects::editable_objects(&doc, 0).unwrap()[0].text.as_deref(), Some(expected)); + } + } +} + fn descriptor_text_page() -> Document { let content = "BT /F1 12 Tf 72 700 Td (ab) Tj ET"; let objs: Vec = vec![ @@ -1085,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") { @@ -1109,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); @@ -1472,6 +1955,65 @@ fn skia_subset_page() -> Document { doc } +/// #2616: a subset whose ToUnicode has no space (as LaTeX and Sphinx output often has, spacing +/// words with TJ kerning) makes finding the space width read every font on the page. That ran +/// for every character of a multi-row draft, so each keystroke in a long paragraph took half a +/// second. It runs once per font now, however long the paragraph. +#[test] +fn a_long_draft_in_a_font_without_a_space_works_out_the_space_width_once() { + // Two rows of one paragraph (14 pt apart) in the Chrome-style subset of `skia_subset_page`, + // whose ToUnicode here has no entry for the space glyph. + let mut doc = + text_page("BT /F3 12 Tf 72 700 Td <0177031602E001FB03050255> Tj ET BT /F3 12 Tf 72 686 Td <008D0266035402FE01FB030503E6> Tj ET"); + let program = doc.add(Object::Stream(Stream::flate(Dict::new(), include_bytes!("../../../assets/fonts/Inter-Regular.ttf")))); + let unicode = doc.add(Object::Stream(Stream::flate( + Dict::new(), + b"1 begincodespacerange <0000> endcodespacerange 12 beginbfchar <0177> <0050> <0316> <006F> <02E0> <006C> <01FB> <0061> <0305> <006E> <0255> <0064> <008D> <0047> <0266> <0065> <0354> <0072> <02FE> <006D> <03E6> <0079> <1000> <007A> endbfchar", + ))); + let widths: Vec = + [(0x8D, 746), (0x177, 639), (0x1FB, 551), (0x255, 612), (0x266, 583), (0x2E0, 242), (0x2FE, 876), (0x305, 591), (0x316, 600)] + .into_iter() + .chain([(0x354, 376), (0x3E6, 515)]) + .flat_map(|(cid, width)| [Object::Int(cid), Object::Array(vec![Object::Int(width)])]) + .collect(); + let descriptor = Dict::from_iter([ + (b"Type".to_vec(), Object::name("FontDescriptor")), + (b"FontName".to_vec(), Object::name("BAAAAA+SampleSans")), + (b"Flags".to_vec(), Object::Int(32)), + (b"FontFile2".to_vec(), Object::Ref(program)), + ]); + let cid_font = Dict::from_iter([ + (b"Type".to_vec(), Object::name("Font")), + (b"Subtype".to_vec(), Object::name("CIDFontType2")), + (b"BaseFont".to_vec(), Object::name("BAAAAA+SampleSans")), + (b"CIDToGIDMap".to_vec(), Object::name("Identity")), + (b"DW".to_vec(), Object::Int(562)), + (b"W".to_vec(), Object::Array(widths)), + (b"FontDescriptor".to_vec(), Object::Dict(descriptor)), + ]); + let font = Dict::from_iter([ + (b"Type".to_vec(), Object::name("Font")), + (b"Subtype".to_vec(), Object::name("Type0")), + (b"BaseFont".to_vec(), Object::name("BAAAAA+SampleSans")), + (b"Encoding".to_vec(), Object::name("Identity-H")), + (b"DescendantFonts".to_vec(), Object::Array(vec![Object::Dict(cid_font)])), + (b"ToUnicode".to_vec(), Object::Ref(unicode)), + ]); + let p = pdfcraft_model::pages(&doc)[0].obj; + let fonts = Dict::from_iter([(b"F3".to_vec(), Object::Dict(font))]); + doc.update_dict(p, |d| d.set(b"Resources".to_vec(), Object::Dict(Dict::from_iter([(b"Font".to_vec(), Object::Dict(fonts))])))).unwrap(); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(blocks.len(), 1, "one paragraph: {:?}", blocks.iter().map(|b| &b.text).collect::>()); + let draft = "Poland and Germany +" + .repeat(20); + text::SOURCE_SPACE_CALLS.with(|c| c.set(0)); + let layout = text::text_edit_layout(&doc, 0, 0, draft.trim_end(), &text::BlockStyle::default()).unwrap(); + let calls = text::SOURCE_SPACE_CALLS.with(std::cell::Cell::get); + assert!(layout.draft_rect.is_some_and(|r| r[2] > r[0]), "laid out: {:?}", layout.draft_rect); + assert!(calls <= 8, "the space width was worked out {calls} times for {} characters", draft.len()); +} + #[test] fn a_subset_keeps_its_font_for_glyphs_left_to_the_default_width() { let doc = skia_subset_page(); @@ -1581,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 = @@ -1773,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(); @@ -1818,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; @@ -1920,6 +2599,31 @@ fn repeated_image_edits_change_one_transform_instead_of_nesting() { } } +#[test] +fn repeated_group_moves_of_a_cropped_image_change_one_clip_transform() { + // The crop is smaller than the image, so a clip left behind would shrink `visible_rect`. + let mut doc = image_page_with("q 150 120 100 50 re W n 200 0 0 100 100 100 cm /Im0 Do Q BT /F1 12 Tf 72 700 Td (Caption) Tj ET"); + let operators = |doc: &Document| pdfcraft_content::parse(&page_content_bytes(doc, 0)).ops.len(); + let start = images::page_images(&doc, 0).unwrap()[0].clone(); + let mut first = 0; + for i in 0..20 { + images::translate_images(&mut doc, 0, &[0], [1.0, -2.0]).unwrap(); + if i == 0 { + first = operators(&doc); + } + } + let moved = |r: [f64; 4]| [r[0] + 20.0, r[1] - 40.0, r[2] + 20.0, r[3] - 40.0]; + let now = images::page_images(&doc, 0).unwrap()[0].clone(); + assert!(close(now.rect, moved(start.rect)), "{:?}", now.rect); + assert!(close(now.visible_rect.unwrap(), moved(start.visible_rect.unwrap())), "the crop moves with the image: {:?}", now.visible_rect); + assert_eq!(operators(&doc), first, "twenty group moves add no more operators than one"); + assert_eq!(q_depth(&doc), 1); + let doc = reopen(&doc); + assert!(close(images::page_images(&doc, 0).unwrap()[0].visible_rect.unwrap(), moved(start.visible_rect.unwrap()))); + let caption: Vec<_> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect(); + assert_eq!(caption, ["Caption"]); +} + #[test] fn page_images_move_turn_replace_and_delete() { let mut doc = image_page(); @@ -2046,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"); @@ -2220,6 +2961,72 @@ fn replacing_a_line_keeps_a_distinct_nearby_line() { } } +#[test] +fn editing_text_keeps_an_overlapping_watermark_and_covered_paragraphs() { + let listed = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().map(|l| l.text).collect::>(); + // #1756: a rotated watermark's bounding box covers the middle of the page, and a body line + // whose baseline happens to sit near the watermark's starting y is still unrelated text. + for rotation in [35.0, 45.0, 0.0] { + let mut base = text_page("BT /F1 14 Tf 60 700 Td (Heading) Tj ET"); + add_watermark( + &mut base, + &[0], + &Watermark { text: "TEST WATERMARK".into(), font_size: 48.0, rotation, opacity: 0.25, ..Watermark::default() }, + false, + ) + .unwrap(); + let wm_y = text::text_lines(&base, 0).unwrap().into_iter().find(|l| l.text == "TEST WATERMARK").unwrap().origin_baseline(); + let mut doc = text_page(&format!("BT /F1 14 Tf 1 0 0 1 60 {wm_y:.2} Tm (Body paragraph one with several words here) Tj ET")); + add_watermark( + &mut doc, + &[0], + &Watermark { text: "TEST WATERMARK".into(), font_size: 48.0, rotation, opacity: 0.25, ..Watermark::default() }, + false, + ) + .unwrap(); + let line_idx = text::text_lines(&doc, 0).unwrap().iter().position(|l| l.text.starts_with("Body")).unwrap(); + let mut line_edited = doc.clone(); + text::replace_line(&mut line_edited, 0, line_idx, "Edited body line").unwrap(); + assert_eq!(listed(&reopen(&line_edited)), ["Edited body line", "TEST WATERMARK"], "rotation {rotation}"); + + let block_idx = text::text_blocks(&doc, 0).unwrap().iter().position(|b| b.text.starts_with("Body")).unwrap(); + let mut preview = doc.clone(); + text::clear_block(&mut preview, 0, block_idx).unwrap(); + assert_eq!(listed(&preview), ["TEST WATERMARK"], "clear_block keeps the watermark at rotation {rotation}"); + + text::rewrite_block(&mut doc, 0, block_idx, Some("Edited body paragraph"), &text::BlockStyle::default()).unwrap(); + assert_eq!(listed(&reopen(&doc)), ["Edited body paragraph", "TEST WATERMARK"], "rotation {rotation}"); + } + + // #1368: dragging one paragraph onto another paragraph's baseline and moving it on again + // leaves the covered paragraph intact. + let mut doc = text_page("BT /F1 12 Tf 72 700 Td (First paragraph) Tj ET BT /F1 12 Tf 72 600 Td (Second paragraph) Tj ET"); + text::rewrite_block(&mut doc, 0, 0, None, &text::BlockStyle { offset: Some([0.0, -100.0]), ..Default::default() }).unwrap(); + let second = text::text_blocks(&doc, 0).unwrap().iter().position(|b| b.text == "Second paragraph"); + assert!(second.is_some(), "the covered paragraph stays on the page: {:?}", listed(&doc)); + let first = text::text_blocks(&doc, 0).unwrap().iter().position(|b| b.text == "First paragraph").unwrap(); + text::rewrite_block(&mut doc, 0, first, None, &text::BlockStyle { offset: Some([0.0, -100.0]), ..Default::default() }).unwrap(); + assert_eq!(listed(&reopen(&doc)), ["First paragraph", "Second paragraph"]); + + // Even when a large horizontal watermark shows the same word as a body line at the same y, + // or a rotated 12 pt line crosses a horizontal 12 pt line of the same word at the same origin, + // editing one keeps the other. + let mut same_word = text_page("BT /F1 12 Tf 1 0 0 1 200 400 Tm (DRAFT) Tj ET BT /F1 48 Tf 1 0 0 1 150 400 Tm (DRAFT) Tj ET"); + text::replace_line(&mut same_word, 0, 0, "FINAL").unwrap(); + let after = text::text_lines(&reopen(&same_word), 0).unwrap(); + assert_eq!(after.iter().map(|l| (&l.text[..], l.size as i32)).collect::>(), [("FINAL", 12), ("DRAFT", 48)]); + + let mut crossed = text_page("BT /F1 12 Tf 1 0 0 1 100 500 Tm (Crossed) Tj ET BT /F1 12 Tf 0.707 0.707 -0.707 0.707 100 500 Tm (Crossed) Tj ET"); + text::replace_line(&mut crossed, 0, 0, "Horizontal").unwrap(); + assert_eq!(listed(&reopen(&crossed)), ["Horizontal", "Crossed"]); + + // An invisible OCR text layer (`3 Tr`) on the same baseline and size as a visible line is + // still removed when the visible line is edited, even if OCR misread a letter. + let mut ocr = text_page("BT /F1 12 Tf 0 Tr 1 0 0 1 72 700 Tm (Hello world) Tj ET BT /F1 12 Tf 3 Tr 1 0 0 1 72 700 Tm (Rello world) Tj ET"); + text::replace_line(&mut ocr, 0, 0, "Goodbye world").unwrap(); + assert_eq!(listed(&reopen(&ocr)), ["Goodbye world"]); +} + #[test] fn paragraphs_move_and_rewrap_to_a_new_width() { let para = "BT /F1 10 Tf 12 TL 100 700 Td (One two three four five six seven) Tj T* (eight nine ten eleven twelve) Tj ET"; @@ -2745,6 +3552,88 @@ fn form_xobjects_that_fan_out_stop_at_the_visit_budget() { assert!(start.elapsed() < std::time::Duration::from_secs(30), "took {:?}", start.elapsed()); } +/// #3120: a one-page document whose page draws "Secret", then `content`. `forms` are (name, +/// content) form XObjects, which the page and every form can draw, as they can a 1×1 image `/Im`. +fn page_with_forms(content: &str, forms: &[(String, String)]) -> Document { + let xobjects: String = forms.iter().enumerate().map(|(i, (name, _))| format!("/{name} {} 0 R ", 7 + i)).collect(); + let resources = format!("<< /Font << /F1 5 0 R >> /XObject << /Im 6 0 R {xobjects}>> >>"); + let mut objs = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources {resources} /Contents 4 0 R >>"), + stream("", &format!("BT /F1 24 Tf 72 700 Td (Secret) Tj ET\n{content}")), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(), + stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", "A"), + ]; + objs.extend(forms.iter().map(|(_, body)| stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 612 792] /Resources {resources}"), body))); + build(&objs.iter().map(String::as_str).collect::>()) +} + +/// #3120: export reads text from forms as deep and as often as the renderer draws them, and says +/// when a limit left some out: a text form drawn 5 000 times (past the old budget of 4 096) or +/// forms nested 13 deep (past the old 12) lost the form drawn after them, silently. +#[test] +fn reading_follows_forms_as_far_as_the_renderer_and_says_where_it_stops() { + // Covers "Secret" with a black box and writes "TAIL": drawn last, so a reader that stops + // early misses it. + const TAIL: &str = "0 g 70 692 90 30 re f BT /F1 24 Tf 72 600 Td (TAIL) Tj ET"; + let tail = |doc: &Document| { + let read = text::reading_blocks_checked(doc, 0).unwrap(); + (read.blocks.iter().any(|b| b.text.contains("TAIL")), read.truncated) + }; + let form = |name: &str, body: &str| (name.to_string(), body.to_string()); + + let rows = + |n: usize| page_with_forms(&format!("{}/C Do", "/R Do ".repeat(n)), &[form("R", "BT /F1 6 Tf 300 100 Td (row) Tj ET"), form("C", TAIL)]); + assert_eq!(tail(&rows(5_000)), (true, false)); + + // The innermost form also draws an image: an image at the depth limit is read, and leaves + // nothing out. + let chain = |depth: usize| { + let mut forms: Vec<_> = (1..depth).map(|i| form(&format!("D{i}"), &format!("/D{} Do", i + 1))).collect(); + forms.push(form(&format!("D{depth}"), &format!("{TAIL} q 20 0 0 10 5 5 cm /Im Do Q"))); + page_with_forms("/D1 Do", &forms) + }; + assert_eq!(tail(&chain(13)), (true, false)); + // The renderer's limit, written out so the test does not move with the constant. + assert_eq!(tail(&chain(50)), (true, false)); + assert_eq!(tail(&chain(51)), (false, true)); + let deepest = chain(text::MAX_FORM_DEPTH); + assert_eq!(tail(&deepest), (true, false)); + assert_eq!(images::reading_images(&deepest, 0).unwrap().len(), 1); + let deep = chain(text::MAX_FORM_DEPTH + 1); + assert_eq!(tail(&deep), (false, true)); + assert!(images::reading_images(&deep, 0).unwrap().is_empty()); + + // A wrapper form A draws a dot form B `n` times, then C. A, the dots and C use the page's + // budget up exactly; one dot more leaves C unread. + let fanout = + |n: usize| page_with_forms("/A Do", &[form("A", &format!("{}/C Do", "/B Do ".repeat(n))), form("B", "0 g 10 10 1 1 re f"), form("C", TAIL)]); + assert_eq!(tail(&fanout(text::MAX_FORM_VISITS - 2)), (true, false)); + let many = fanout(text::MAX_FORM_VISITS - 1); + assert_eq!(tail(&many), (false, true)); + assert!(images::reading_images(&many, 0).unwrap().is_empty()); + // Without the flag, reading is unchanged. + let blocks: Vec = text::reading_blocks(&many, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(blocks, ["Secret"]); +} + +/// #3120: with the renderer's limits, forms that each draw the next a hundred times, 12 levels +/// deep (100^11 forms) or past the depth cap, still stop at the page's budget quickly, and say +/// that text was left out. +#[test] +fn forms_that_fan_out_a_hundred_ways_stop_quickly_and_say_so() { + for levels in [12, text::MAX_FORM_DEPTH + 1] { + let mut forms: Vec<(String, String)> = (1..levels).map(|i| (format!("N{i}"), format!("/N{} Do ", i + 1).repeat(100))).collect(); + forms.push((format!("N{levels}"), "BT /F1 10 Tf 10 10 Td (leaf) Tj ET".to_string())); + let doc = page_with_forms("/N1 Do", &forms); + let start = std::time::Instant::now(); + assert!(text::reading_blocks_checked(&doc, 0).unwrap().truncated, "{levels} levels"); + assert!(images::reading_images(&doc, 0).unwrap().is_empty()); + assert!(start.elapsed() < std::time::Duration::from_secs(30), "{levels} levels took {:?}", start.elapsed()); + } +} + /// Forms that draw themselves or each other, have no resources, a malformed matrix, or are /// missing are read once (or skipped) without panicking or looping. #[test] @@ -3285,6 +4174,55 @@ fn latin_ligature_actual_text_stays_on_its_line() { } } +/// #3110: `/ActualText` may come from a named property list (`/Span /MC0 BDC`) looked up in the +/// page's or the form's `/Properties`, as well as from an inline one. +#[test] +fn actual_text_from_named_property_list() { + let properties = "/Properties << /MC0 << /ActualText (Hello World) >> /MC1 << /Lang (en) >> >>"; + let page_with = |content: &str| { + build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + &format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> {properties} >> /Contents 4 0 R >>"), + &stream("", content), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + ]) + }; + for (content, expected) in [ + ("/Span /MC0 BDC BT /F1 24 Tf 72 700 Td (Glyphs) Tj ET EMC", "Hello World"), + ("/Span <> BDC BT /F1 24 Tf 72 700 Td (Glyphs) Tj ET EMC", "Hello World"), + ("/Span /MC1 BDC BT /F1 24 Tf 72 700 Td (Glyphs) Tj ET EMC", "Glyphs"), + ("/Span /Missing BDC BT /F1 24 Tf 72 700 Td (Glyphs) Tj ET EMC", "Glyphs"), + ] { + let doc = page_with(content); + for doc in [&doc, &reopen(&doc)] { + let lines = text::text_lines(doc, 0).unwrap(); + assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::>(), [expected], "{content}"); + assert_eq!(text::text_blocks(doc, 0).unwrap()[0].editor_text, expected, "{content}"); + let reading: Vec = text::reading_blocks(doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(reading, [expected], "{content}"); + } + } + + // A form's named list is looked up in the form's own /Properties, not the page's. + let fm = stream( + "/Type /XObject /Subtype /Form /BBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> /Properties << /MC0 << /ActualText (Form text) >> >> >>", + "/Span /MC0 BDC BT /F1 24 Tf 72 600 Td (Glyphs) Tj ET EMC", + ); + let doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + &format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> /XObject << /Fm1 6 0 R >> {properties} >> /Contents 4 0 R >>" + ), + &stream("", "/Fm1 Do"), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + &fm, + ]); + let reading: Vec = text::reading_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(reading, ["Form text"]); +} + #[test] fn actual_text_and_visual_rtl_glyphs_keep_their_own_reading_order() { let hex: String = "שלום".encode_utf16().map(|u| format!("{u:04X}")).collect(); @@ -3728,6 +4666,74 @@ fn private_image_crop_with_intervening_state_resizes_across_streams() { } } +/// [`image_page`] with `pieces` as its `/Contents` array and a Form /Fm0 drawing 72,500–172,600. +fn split_image_page(pieces: &[&[u8]]) -> Document { + let mut doc = image_page(); + let page = pdfcraft_model::pages(&doc)[0].obj; + let mut form = Dict::new(); + form.set(b"Type".to_vec(), Object::name("XObject")); + form.set(b"Subtype".to_vec(), Object::name("Form")); + form.set(b"BBox".to_vec(), Object::Array([72, 500, 172, 600].map(Object::Int).to_vec())); + let form = doc.add(Object::Stream(Stream::flate(form, b"0.5 g 72 500 100 100 re f"))); + let image = doc.get(pdfcraft_model::pages(&doc)[0].obj); + let mut res = image.as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| r.as_dict().cloned()).unwrap(); + let mut xo = res.get(b"XObject").and_then(|x| x.as_dict().cloned()).unwrap(); + xo.set(b"Fm0".to_vec(), Object::Ref(form)); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + let refs = pieces.iter().map(|b| Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), b))))).collect(); + doc.update_dict(page, |d| { + d.set(b"Contents".to_vec(), Object::Array(refs)); + d.set(b"Resources".to_vec(), Object::Dict(res)); + }) + .unwrap(); + doc +} + +#[test] +fn an_image_whose_operator_starts_the_next_stream_can_be_selected_moved_and_deleted() { + // #3113: an operator's operands end one piece and its keyword starts the next. + let square = [72.0, 500.0, 172.0, 600.0]; + let cases: [(&[&[u8]], bool); 4] = [ + (&[b"q 100 0 0 100 72 500 cm /Im0", b"Do Q"], false), + (&[b"q 100 0 0 100 72 500", b"cm /Im0 Do Q"], false), + (&[b"q /Fm0", b"Do Q"], true), + (&[b"q 100 0 0 100 72 500 cm /Im0 Do Q"], false), + ]; + for (pieces, is_form) in cases { + let doc = split_image_page(pieces); + let label: Vec<_> = pieces.iter().map(|p| String::from_utf8_lossy(p)).collect(); + let images = images::page_images(&doc, 0).unwrap(); + assert_eq!(images.len(), 1, "{label:?}"); + assert!(close(images[0].rect, square), "{label:?}: {:?}", images[0].rect); + assert_eq!(images[0].is_form, is_form); + assert!(images[0].contains([122.0, 550.0]), "{label:?}"); + if !is_form { + let read = images::reading_images(&doc, 0).unwrap(); + assert!(read.len() == 1 && close(read[0].rect, square), "{label:?}: {read:?}"); + } + + let mut deleted = doc.clone(); + images::change_image(&mut deleted, 0, 0, &images::ImageChange::Delete).unwrap(); + let deleted = reopen(&deleted); + assert!(images::page_images(&deleted, 0).unwrap().is_empty(), "{label:?}"); + let (joined, ops) = joined_ops(&deleted); + assert!(!ops.iter().any(|o| o.is("Do")), "{joined}"); + + let target = [300.0, 500.0, 400.0, 600.0]; + let mut moved = doc.clone(); + images::change_image(&mut moved, 0, 0, &images::ImageChange::Transform(images::rect_to_rect(square, target))).unwrap(); + let moved = reopen(&moved); + let after = images::page_images(&moved, 0).unwrap(); + assert!(after.len() == 1 && close(after[0].rect, target), "{label:?}: {after:?} {:?}", joined_ops(&moved).0); + + let mut shifted = doc.clone(); + images::translate_images(&mut shifted, 0, &[0], [228.0, 0.0]).unwrap(); + let shifted = reopen(&shifted); + let after = images::page_images(&shifted, 0).unwrap(); + assert!(after.len() == 1 && close(after[0].rect, target), "{label:?}: {after:?} {:?}", joined_ops(&shifted).0); + } +} + #[test] fn curved_image_clips_have_analytic_bounds_and_holes_do_not_capture_points() { let mut doc = image_page(); @@ -3906,6 +4912,31 @@ fn turned_paragraph_keeps_its_line_breaks_when_a_word_is_added() { assert!(text::rewrite_block(&mut doc, 0, 0, Some("Only line"), &style).is_err()); } +/// A paragraph drawn at a slant rewraps at the width its rows have along their baseline, as the +/// same paragraph upright does. Its upright bounding box turned into the baseline's frame is wider +/// by the rows' height times |sin 2θ|, which let a word that doesn't fit stay on its row. +#[test] +fn a_paragraph_at_a_slant_rewraps_at_the_width_it_has_upright() { + let rows = ["Rotated paragraph, first row of text", "that continues on a second row and", "ends on the third row here."]; + let draft = "Rotated paragraph, first row of text that continues on a second row and now carries a few extra words \ + before it ends on the third row here."; + let rewrapped = |degrees: f64| { + let (sin, cos) = degrees.to_radians().sin_cos(); + let shown = rows.iter().map(|r| format!("({r}) Tj")).collect::>().join(" T* "); + let mut doc = text_page(&format!("BT /F1 12 Tf 14.4 TL {cos:.6} {sin:.6} {:.6} {cos:.6} 300 400 Tm {shown} ET", -sin)); + let block = text::text_blocks(&doc, 0).unwrap().remove(0); + assert_eq!(block.editor_text, rows.join("\n"), "{degrees}°"); + text::rewrite_block(&mut doc, 0, 0, Some(draft), &text::BlockStyle::default()).unwrap(); + text::text_lines(&reopen(&doc), 0).unwrap().into_iter().map(|l| l.text).collect::>() + }; + let upright = rewrapped(0.0); + // The widest source row is 189.44 pt; "now carries a few extra words before" is 196.07 pt. + assert_eq!(upright[2], "now carries a few extra words", "{upright:?}"); + for degrees in [30.0, 45.0, 60.0, 90.0, 135.0, 210.0, 330.0] { + assert_eq!(rewrapped(degrees), upright, "{degrees}°"); + } +} + /// A paragraph in a Chrome-style subset font: `/W` leaves out the glyphs of the `/DW` width. /// Adding a word keeps every row as it was and shows the word in the page's own font (#1443). #[test] @@ -3960,6 +4991,76 @@ fn default_width_subset_paragraph_keeps_its_rows_when_a_word_is_added() { } } +/// #2578: a single turned row grows along its baseline only up to the page's 36 pt margin and wraps +/// onto further rows from there, instead of running off the page. +#[test] +fn turned_single_row_wraps_at_the_page_margin() { + let draft = "A single turned row that keeps growing with many more words added until it is far longer than the page is tall, so it has to stop or wrap somewhere"; + // Reading upwards (90° CCW) from y = 600 on the 600 × 800 test page: top margin is at y = 764. + let before = text_page("BT /F1 12 Tf 0 1 -1 0 300 600 Tm (A single turned row) Tj ET"); + let layout = text::text_edit_layout(&before, 0, 0, draft, &text::BlockStyle::default()).unwrap(); + let mut doc = before; + text::rewrite_block(&mut doc, 0, 0, Some(draft), &text::BlockStyle::default()).unwrap(); + let reopened = reopen(&doc); + let lines = text::text_lines(&reopened, 0).unwrap(); + assert!(lines.len() > 1, "expected the turned row to wrap at the top margin: {lines:?}"); + for line in &lines { + assert!((line.rect[1] - 600.0).abs() < 0.5, "row starts at y = 600: {:?}", line.rect); + assert!(line.rect[3] <= 800.0 - 36.0 + 0.5, "row stays within the top margin: {:?}", line.rect); + } + let blocks = text::text_blocks(&reopened, 0).unwrap(); + assert_eq!(blocks.len(), 1, "wrapped turned rows stay one paragraph: {blocks:?}"); + assert_eq!(blocks[0].text, draft); + assert_eq!(layout.draft_rect, Some(blocks[0].rect)); + + // Reading downwards (90° CW) from y = 200: bottom margin is at y = 36. + let mut doc = text_page("BT /F1 12 Tf 0 -1 1 0 300 200 Tm (A single turned row) Tj ET"); + text::rewrite_block(&mut doc, 0, 0, Some(draft), &text::BlockStyle::default()).unwrap(); + let lines = text::text_lines(&reopen(&doc), 0).unwrap(); + assert!(lines.len() > 1, "expected 90° CW row to wrap at the bottom margin: {lines:?}"); + for line in &lines { + assert!((line.rect[3] - 200.0).abs() < 0.5, "row starts at y = 200: {:?}", line.rect); + assert!(line.rect[1] >= 36.0 - 0.5, "row stays within the bottom margin: {:?}", line.rect); + } + + // Reading upside down (180°) from x = 200: left margin is at x = 36. + let mut doc = text_page("BT /F1 12 Tf -1 0 0 -1 200 600 Tm (A single turned row) Tj ET"); + text::rewrite_block(&mut doc, 0, 0, Some(draft), &text::BlockStyle::default()).unwrap(); + let lines = text::text_lines(&reopen(&doc), 0).unwrap(); + assert!(lines.len() > 1, "expected 180° row to wrap at the left margin: {lines:?}"); + for line in &lines { + assert!((line.rect[2] - 200.0).abs() < 0.5, "row starts at x = 200: {:?}", line.rect); + assert!(line.rect[0] >= 36.0 - 0.5, "row stays within the left margin: {:?}", line.rect); + } + + // Slanted (45°) from (400, 600): baseline stops where it first reaches a 36 pt margin. + let (sin, cos) = 45.0_f64.to_radians().sin_cos(); + let mut doc = text_page(&format!("BT /F1 12 Tf {cos:.6} {sin:.6} {:.6} {cos:.6} 400 600 Tm (A single turned row) Tj ET", -sin)); + text::rewrite_block(&mut doc, 0, 0, Some(draft), &text::BlockStyle::default()).unwrap(); + let lines = text::text_lines(&reopen(&doc), 0).unwrap(); + assert!(lines.len() > 1, "expected 45° slanted row to wrap at the margin: {lines:?}"); + for line in &lines { + assert!(line.rect[2] <= 600.0 && line.rect[3] <= 800.0, "slanted row stays on the page: {:?}", line.rect); + } + + // Moving a turned single row with `offset` reduces the room left before the margin. + let mut doc = text_page("BT /F1 12 Tf 0 1 -1 0 300 500 Tm (A single turned row) Tj ET"); + let moved = text::BlockStyle { offset: Some([0.0, 150.0]), ..Default::default() }; + text::rewrite_block(&mut doc, 0, 0, Some(draft), &moved).unwrap(); + let lines = text::text_lines(&reopen(&doc), 0).unwrap(); + assert!(lines.len() > 1, "{lines:?}"); + for line in &lines { + assert!((line.rect[1] - 650.0).abs() < 0.5, "moved row starts at y = 650: {:?}", line.rect); + assert!(line.rect[3] <= 800.0 - 36.0 + 0.5, "moved row stops at the top margin: {:?}", line.rect); + } + + // A turned row that already starts inside the 36 pt margin keeps its own width instead of collapsing. + let mut doc = text_page("BT /F1 12 Tf 0 1 -1 0 300 770 Tm (Edge label) Tj ET"); + text::rewrite_block(&mut doc, 0, 0, Some("Edge label updated"), &text::BlockStyle::default()).unwrap(); + let lines = text::text_lines(&reopen(&doc), 0).unwrap(); + assert!(lines.len() >= 2, "{lines:?}"); +} + /// An added item is written out as a specific standard-14 face (`/Times-Italic`, `/Helvetica-Bold` /// …), so it has to be *measured* with that same face. Measuring the upright face while writing /// the italic one mis-sizes every box: Times-Italic `A` is 611 against Times-Roman's 722. @@ -4184,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); } @@ -4249,3 +5408,950 @@ fn japanese_paragraphs_wrap_between_characters_without_closing_marks_at_a_row_st } } } + +#[test] +fn rewritten_paragraph_is_drawn_after_backgrounds_between_its_rows() { + // Each row has its own background box drawn immediately before its BT..ET, as cards and + // highlighted headings exported from LibreOffice or browsers do (#2377). + let card = "0 0.45 0.25 rg 60 696 240 16 re f BT /F1 10 Tf 1 1 1 rg 64 700 Td (SEMINARIO ONLINE) Tj ET \ + 0 0.45 0.25 rg 60 668 240 24 re f BT /F1 16 Tf 1 1 1 rg 64 674 Td (Dalla teoria alla pratica:) Tj ET \ + 0 0.45 0.25 rg 60 632 240 32 re f BT /F1 24 Tf 1 1 1 rg 64 640 Td (gestire con metodo) Tj ET \ + 0 0.45 0.25 rg 60 596 240 32 re f BT /F1 24 Tf 1 1 1 rg 64 604 Td (un progetto complesso) Tj ET"; + let shown_at = |ops: &[pdfcraft_content::Op], needle: &str| { + ops.iter() + .position(|op| { + matches!(op.op.as_slice(), b"Tj" | b"TJ") + && op.operands.iter().any(|o| matches!(o, Object::String(s) if String::from_utf8_lossy(&s.bytes).contains(needle))) + }) + .unwrap() + }; + + // 1. Editing the title rows emits all rewritten rows after all four background fills. + let mut doc = text_page(card); + assert_eq!(text::text_blocks(&doc, 0).unwrap().len(), 1); + let edited = "SEMINARIO ONLINE\nDalla teoria alla pratica:\ngestire con criterio\nun progetto nuovo"; + text::rewrite_block(&mut doc, 0, 0, Some(edited), &text::BlockStyle::default()).unwrap(); + let doc = reopen(&doc); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, edited); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let fills: Vec = ops.iter().enumerate().filter_map(|(i, op)| op.is("f").then_some(i)).collect(); + assert_eq!(fills.len(), 4); + assert!( + shown_at(&ops, "criterio") > fills[3] && shown_at(&ops, "nuovo") > fills[3], + "rewritten title rows must be drawn after their row background fills (fills at {fills:?}): {ops:?}" + ); + + // 2. Partial edit with a kept suffix row: rewritten rows 0..=2 are drawn after fills[0..=2] + // and still before kept row 3, preserving reading order. + let mut doc = text_page(card); + let partial = "WORKSHOP ONLINE\nDalla teoria alla pratica:\ngestire con criterio\nun progetto complesso"; + text::rewrite_block(&mut doc, 0, 0, Some(partial), &text::BlockStyle::default()).unwrap(); + let doc = reopen(&doc); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, partial); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let fills: Vec = ops.iter().enumerate().filter_map(|(i, op)| op.is("f").then_some(i)).collect(); + assert!( + shown_at(&ops, "WORKSHOP") > fills[2] + && shown_at(&ops, "pratica:") > fills[2] + && shown_at(&ops, "criterio") > fills[2] + && shown_at(&ops, "criterio") < shown_at(&ops, "complesso") + && shown_at(&ops, "complesso") > fills[3], + "rewritten prefix rows must follow their fills and precede the kept final row" + ); + + // 3. Editing row 2 so that it wraps pushes row 3 down; all rewritten rows must follow fills[3]. + let mut doc = text_page(card); + let wrapped_edit = "SEMINARIO ONLINE\nDalla teoria alla pratica:\ngestire con criterio e metodo approfondito\nun progetto complesso"; + text::rewrite_block(&mut doc, 0, 0, Some(wrapped_edit), &text::BlockStyle::default()).unwrap(); + let doc = reopen(&doc); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let last_fill = ops.iter().rposition(|op| op.is("f")).unwrap(); + assert!( + shown_at(&ops, "approfondito") > last_fill && shown_at(&ops, "complesso") > last_fill, + "wrapped rows and pushed-down suffix row must follow all row background fills" + ); + + // 4. Single-style paragraph path across a CTM change between rows, with movement, underline, + // and a trailing unrelated line sharing the second row's BT..ET. + let cross_ctm = "0.9 0.9 0.2 rg 60 696 240 18 re f BT /F1 12 Tf 0 0 0 rg 64 700 Td (First highlighted row) Tj ET \ + q 1 0 0 1 10 12 cm 0.9 0.9 0.2 rg 50 668 240 18 re f \ + BT /F1 12 Tf 0 0 0 rg 54 672 Td (Second highlighted row) Tj 246 -40 Td (Side footer) Tj ET Q"; + let mut doc = text_page(cross_ctm); + let para = text::text_blocks(&doc, 0).unwrap().into_iter().position(|b| b.text.starts_with("First highlighted")).unwrap(); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[para].rows.len(), 2); + text::rewrite_block( + &mut doc, + 0, + para, + Some("Updated first highlighted row and updated second highlighted row"), + &text::BlockStyle { offset: Some([6.0, -4.0]), underline: Some(true), ..Default::default() }, + ) + .unwrap(); + let doc = reopen(&doc); + let ops = pdfcraft_content::parse(&page_content_bytes(&doc, 0)).ops; + let last_fill = ops.iter().rposition(|op| op.is("f")).unwrap(); + assert!( + shown_at(&ops, "Updated first") > last_fill && shown_at(&ops, "Side footer") > shown_at(&ops, "Updated first"), + "cross-CTM single-style rewrite must follow both background fills and precede the shared BT's trailing line" + ); + let lines = text::text_lines(&doc, 0).unwrap(); + let updated_first = lines.iter().find(|l| l.text.starts_with("Updated first")).unwrap(); + let side_footer = lines.iter().find(|l| l.text == "Side footer").unwrap(); + assert!( + (updated_first.baseline()[0] - 70.0).abs() < 0.01 && (updated_first.baseline()[1] - 696.0).abs() < 0.01, + "offset baseline in user space across CTM: {:?}", + updated_first.baseline() + ); + assert!( + (side_footer.baseline()[0] - 310.0).abs() < 0.01 && (side_footer.baseline()[1] - 644.0).abs() < 0.01, + "trailing line in shared BT keeps its tlm across CTM: {:?}", + side_footer.baseline() + ); +} + +/// Small graphics-state oracle for synthetic streams: every tracked state change must +/// have been restored when a probe XObject would draw. No external assets are involved. +fn probe_states(doc: &Document) -> Vec, Vec>> { + let bytes = streams(doc, 0); + let joined = pdfcraft_content::Pieces::join(&bytes); + let mut state = std::collections::BTreeMap::new(); + let mut stack = Vec::new(); + let mut probes = Vec::new(); + for op in joined.parse().ops { + match op.op.as_slice() { + b"q" => stack.push(state.clone()), + b"Q" => state = stack.pop().expect("balanced graphics state"), + b"cm" | b"W" | b"W*" | b"Tz" | b"Tc" | b"Tw" | b"Tr" | b"cs" => { + state.insert(op.op, op.operands); + } + b"Do" if op.name(0) == Some(b"PCProbe") => probes.push(state.clone()), + _ => {} + } + } + probes +} + +fn isolation_fixture(parts: &[(&str, &str)]) -> Document { + let mut doc = fixture(); + let page = page_list(&doc).remove(0); + let list = parts + .iter() + .map(|(tag, bytes)| { + Object::Ref(doc.add(Object::Stream(Stream::from_raw(if tag.is_empty() { Dict::new() } else { tagged(tag) }, bytes.as_bytes().to_vec())))) + }) + .collect(); + doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list))).unwrap(); + doc +} + +#[test] +fn new_marks_isolate_state_appended_after_old_wrappers() { + for dirt in ["0.5 0 0 0.5 0 0 cm", "0 0 200 200 re W n", "50 Tz", "5 Tc 20 Tw", "3 Tr", "/Pattern cs"] { + for tag in ["", "OCR", "Wrap"] { + let mut doc = isolation_fixture(&[ + ("Wrap", "q %PdfCraft\n"), + ("", "2 0 0 2 0 0 cm"), + ("Wrap", "Q %PdfCraft\n"), + ("Added", "q /PCProbe Do Q"), + (tag, dirt), + ]); + let before = probe_states(&doc); + stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap(); + let after = probe_states(&reopen(&doc)); + assert_eq!(&after[..before.len()], &before, "existing content must not change"); + assert!(after.last().unwrap().is_empty(), "tag={tag}, dirt={dirt}: {after:?}"); + } + } +} + +#[test] +fn new_marks_isolate_dirty_tagged_only_pages() { + let mut doc = isolation_fixture(&[("OCR", "50 Tz 5 Tc 20 Tw")]); + stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap(); + assert!(probe_states(&doc).last().unwrap().is_empty()); +} + +#[test] +fn new_marks_do_not_accumulate_wrappers_for_self_contained_marks() { + let mut doc = isolation_fixture(&[("", "2 0 0 2 0 0 cm")]); + for _ in 0..10 { + stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap(); + } + assert_eq!(streams(&doc, 0).len(), 13); + assert!(probe_states(&doc).iter().all(|s| s.is_empty())); +} + +#[test] +fn new_marks_close_wrapper_after_an_unterminated_comment() { + let mut doc = isolation_fixture(&[("", "50 Tz % no final newline")]); + stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap(); + // Check literal concatenation too: a separator must exist in the saved stream itself, + // not just in the inspection parser, or the comment can swallow the closing Q. + let bytes = streams(&doc, 0).concat(); + let parsed = pdfcraft_content::parse(bytes.as_bytes()); + assert_eq!(parsed.ops.iter().filter(|op| op.is("Q")).count(), 2); + assert!(probe_states(&doc).last().unwrap().is_empty()); +} + +#[test] +fn new_marks_wrap_unbalanced_graphics_state_whole_without_changing_it() { + // Content that can't be isolated precisely is wrapped whole, as before #3084, instead of + // refusing: redaction stamps its boxes after removing the text, so it can't back out. + for (data, probe_clean) in [("q 50 Tz", true), ("50 Tz q", true), ("q q Q", true), ("Q 50 Tz", false), ("q Q Q 50 Tz", false)] { + let mut doc = isolation_fixture(&[("Wrap", data)]); + let page = page_list(&doc).remove(0); + place_tagged(&mut doc, &page, "Probe", b"q /PCProbe Do Q".to_vec(), false).unwrap(); + let after = streams(&doc, 0); + assert_eq!(after.len(), 4, "{data}: {after:?}"); + assert_eq!((after[0].as_str(), after[1].as_str()), ("q %PdfCraft\n", data), "{data}"); + // A `q` the content leaves open is closed after it, so the mark starts clean. A stray + // `Q` pops the wrapper itself: nothing placed after it can undo that. + let probe = probe_states_lenient(&doc); + assert_eq!(probe.last().unwrap().is_empty(), probe_clean, "{data}: {after:?}"); + } +} + +/// [`probe_states`] for unbalanced content: a `Q` with nothing saved leaves the state as is, +/// as renderers do. +fn probe_states_lenient(doc: &Document) -> Vec, Vec>> { + let joined = pdfcraft_content::Pieces::join(&streams(doc, 0)); + let mut state = std::collections::BTreeMap::new(); + let mut stack = Vec::new(); + let mut probes = Vec::new(); + for op in joined.parse().ops { + match op.op.as_slice() { + b"q" => stack.push(state.clone()), + b"Q" => state = stack.pop().unwrap_or(state), + b"cm" | b"W" | b"W*" | b"Tz" | b"Tc" | b"Tw" | b"Tr" | b"cs" => { + state.insert(op.op, op.operands); + } + b"Do" if op.name(0) == Some(b"PCProbe") => probes.push(state.clone()), + _ => {} + } + } + probes +} + +#[test] +fn new_marks_preserve_cross_stream_operands_and_q_pairs() { + let mut doc = isolation_fixture(&[("", "q 2 0 0 2"), ("", "0 0 cm Q 50"), ("", "Tz")]); + stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap(); + assert!(probe_states(&doc).last().unwrap().is_empty()); +} + +#[test] +fn removing_marks_keeps_wrappers_needed_by_later_external_content() { + let mut doc = isolation_fixture(&[ + ("Wrap", "q %PdfCraft\n"), + ("", "2 0 0 2 0 0 cm"), + ("Wrap", "Q %PdfCraft\n"), + ("HeaderFooter", "q /PCProbe Do Q"), + ("", "/PCProbe Do"), + ]); + remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(); + assert_eq!(probe_states(&doc).len(), 1); + assert!(probe_states(&doc)[0].is_empty(), "the external content still needs the original reset"); +} + +#[test] +fn new_marks_wrap_unfinished_text_paths_and_operands_whole() { + for data in ["BT", "ET", "BT BT ET", "0 0 10 10 re", "q 0 0 m Q", "50"] { + let mut doc = isolation_fixture(&[("Background", "q Q"), ("", data)]); + let page = page_list(&doc).remove(0); + place_tagged(&mut doc, &page, "Probe", b"q /PCProbe Do Q".to_vec(), false).unwrap(); + // The behind-mark stays outside the wrapper and the content itself is untouched. + assert_eq!(streams(&doc, 0), ["q Q", "q %PdfCraft\n", data, "\nQ %PdfCraft\n", "q /PCProbe Do Q"], "{data}"); + } +} + +#[test] +fn new_marks_do_not_reveal_cross_stream_comments() { + let mut doc = isolation_fixture(&[("", "q 50 Tz % comment continues"), ("", "Q 20 Tc\nQ 5 Tw")]); + stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap(); + assert!(probe_states(&doc).last().unwrap().is_empty()); + let joined = pdfcraft_content::Pieces::join(&streams(&doc, 0)); + assert!(!joined.parse().ops.iter().any(|op| op.is("Tc")), "commented content must remain inert"); +} + +#[test] +fn new_marks_follow_clean_streams_ending_in_comments() { + let mut doc = isolation_fixture(&[("Added", "q 50 Tz Q % no newline")]); + stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap(); + assert_eq!(probe_states(&doc).len(), 1, "the new mark must not be swallowed by a comment"); + assert!(probe_states(&doc)[0].is_empty()); +} + +#[test] +fn removing_marks_does_not_strip_arbitrary_wrap_tagged_content() { + let mut doc = isolation_fixture(&[("Wrap", "q /PCProbe Do Q"), ("HeaderFooter", "q Q")]); + assert_eq!(remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(), 1); + assert_eq!(probe_states(&doc).len(), 1); +} + +#[test] +fn removing_marks_strips_nested_trailing_wrapper_pairs() { + let mut doc = isolation_fixture(&[ + ("Wrap", "q %PdfCraft\n"), + ("Wrap", "q %PdfCraft\n"), + ("", "2 0 0 2 0 0 cm"), + ("Wrap", "Q %PdfCraft\n"), + ("Wrap", "Q %PdfCraft\n"), + ("HeaderFooter", "q Q"), + ]); + assert_eq!(remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(), 1); + assert_eq!(streams(&doc, 0), ["2 0 0 2 0 0 cm"]); +} + +#[test] +fn updating_marks_on_unreadable_content_does_not_nest_wrappers() { + for data in ["q 50 Tz", "Q 50 Tz", "BT"] { + let mut doc = isolation_fixture(&[("", data)]); + let page = page_list(&doc).remove(0); + for _ in 0..3 { + place_tagged(&mut doc, &page, MarkKind::HeaderFooter.tag(), b"q /PCProbe Do Q".to_vec(), false).unwrap(); + assert_eq!(streams(&doc, 0).len(), 4, "{data}: {:?}", streams(&doc, 0)); + assert_eq!(remove_marks(&mut doc, &[0], MarkKind::HeaderFooter).unwrap(), 1); + assert_eq!(streams(&doc, 0), [data], "{data}: the original list comes back"); + } + } +} + +#[test] +fn removing_marks_strips_wrappers_that_closed_open_state() { + let mut doc = isolation_fixture(&[("", "q 2 0 0 2 0 0 cm")]); + stamp(&mut doc, 0, "Probe", b"q /PCProbe Do Q\n".to_vec()).unwrap(); + assert!(probe_states(&doc).last().unwrap().is_empty()); + // Now balanced, the page reads cleanly: the close (with its extra `Q`) still pairs. + let page = page_list(&doc).remove(0); + place_tagged(&mut doc, &page, MarkKind::Watermark.tag(), b"q /PCProbe Do Q\n".to_vec(), false).unwrap(); + assert_eq!(streams(&doc, 0).len(), 5, "no second wrapper: {:?}", streams(&doc, 0)); + let mut doc = isolation_fixture(&[("", "q 2 0 0 2 0 0 cm")]); + let page = page_list(&doc).remove(0); + place_tagged(&mut doc, &page, MarkKind::Watermark.tag(), b"q /PCProbe Do Q\n".to_vec(), false).unwrap(); + assert_eq!(remove_marks(&mut doc, &[0], MarkKind::Watermark).unwrap(), 1); + assert_eq!(streams(&doc, 0), ["q 2 0 0 2 0 0 cm"]); +} + +/// #3087: flattening comments leaves alone what isn't a comment: links, and the non-markup +/// multimedia and print-production annotations (3D, Screen, Movie, RichMedia, PrinterMark, +/// TrapNet, Watermark), which it used to bake into the page and delete with their data. +/// Flattening fields only leaves them alone too; real comments are still flattened. +#[test] +fn flattening_comments_keeps_annotations_that_are_not_comments() { + let kept = ["Widget", "Link", "3D", "Screen", "Movie", "RichMedia", "PrinterMark", "TrapNet", "Watermark"]; + let flattened = ["Text", "Square", "FileAttachment"]; + let all: Vec<&str> = flattened.iter().chain(kept.iter()).copied().collect(); + let form = "<< /Type /XObject /Subtype /Form /BBox [0 0 40 20] /Length 26 >>\nstream\n0 0 1 rg 0 0 40 20 re f\nendstream"; + let annots: Vec = all + .iter() + .enumerate() + .map(|(i, s)| { + format!("<< /Type /Annot /Subtype /{s} /Rect [{x} 700 {r} 720] /AP << /N 4 0 R >> /Contents ({s}) >>", x = 20 + 50 * i, r = 60 + 50 * i) + }) + .collect(); + let refs: Vec = (0..all.len()).map(|i| format!("{} 0 R", 6 + i)).collect(); + let page = format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << >> /Contents 5 0 R /Annots [{}] >>", refs.join(" ")); + let content = stream("", " "); + let mut objs: Vec<&str> = vec!["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", &page, form, &content]; + objs.extend(annots.iter().map(String::as_str)); + let left = |doc: &Document| -> Vec { + let p = &pdfcraft_model::pages(doc)[0]; + let Some(a) = p.dict.get(b"Annots").map(|a| doc.resolve(a)) else { return Vec::new() }; + a.as_array() + .unwrap() + .iter() + .filter_map(|o| doc.resolve(o).as_dict().and_then(|d| d.name(b"Subtype")).map(|s| String::from_utf8_lossy(s).into_owned())) + .collect() + }; + let mut doc = build(&objs); + assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), flattened.len()); + assert_eq!(left(&reopen(&doc)), kept, "only the comments were flattened"); + let mut doc = build(&objs); + assert_eq!(flatten(&mut doc, &[0], false, true).unwrap(), 1, "the widget"); + let without_widget: Vec<&str> = all.iter().copied().filter(|s| *s != "Widget").collect(); + 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")) +} + +/// Rewriting text that is a clipping path destroyed the artwork it masked: the glyphs went, the +/// mask with them, and the fill behind flooded its own rectangle, in a file that still opened +/// without a warning (#3429). Photoshop exports draw every text object this way. The rewrite is +/// refused instead, as `clear_block` already refuses a preview. +#[test] +fn rewriting_clipping_path_text_is_refused_for_every_clipping_mode() { + for mode in [4, 5, 6, 7] { + let before = text::text_lines(&clipping_text_page(mode), 0).unwrap(); + assert_eq!(before.len(), 1, "mode {mode}: the mask is one line"); + + let mut doc = clipping_text_page(mode); + let line = text::replace_line(&mut doc, 0, 0, "EDITED"); + assert!(matches!(&line, Err(EditError::Invalid(m)) if m.contains("clipping path")), "mode {mode}: {line:?}"); + + let mut doc = clipping_text_page(mode); + let block = text::rewrite_block(&mut doc, 0, 0, Some("EDITED"), &BlockStyle::default()); + assert!(matches!(&block, Err(EditError::Invalid(m)) if m.contains("clipping path")), "mode {mode}: {block:?}"); + + // Refused means untouched: the mask is still there to shape what is painted through it. + let after = text::text_lines(&doc, 0).unwrap(); + assert_eq!(after.len(), 1, "mode {mode}"); + assert_eq!(after[0].text, "MASK", "mode {mode}"); + } +} + +/// The modes that paint ink are still editable: only 4 to 7 add to the clip path. +#[test] +fn painting_modes_are_still_rewritten() { + for mode in [0, 1, 2] { + let mut doc = clipping_text_page(mode); + assert!(text::replace_line(&mut doc, 0, 0, "EDITED").is_ok(), "mode {mode}"); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "EDITED", "mode {mode}"); + } +} + +/// A line's runs need not share a rendering mode: nothing in the join requires it, so a line that +/// starts as ink can continue as a mask. The refusal reads the whole line, not the mode its first +/// run set -- otherwise the rewrite passed the guard and took the mask away with the ink. +#[test] +fn a_line_that_starts_as_ink_and_continues_as_a_mask_is_refused() { + let content = "q\nBT /F1 48 Tf 40 60 Td 0 Tr (TITLE) Tj 7 Tr (MASK) Tj ET\n1 0 0 rg 0 0 300 150 re f\nQ\n"; + let before = text::text_lines(&text_page(content), 0).unwrap(); + assert_eq!(before.len(), 1, "the two runs are one line: {:?}", before.iter().map(|l| &l.text).collect::>()); + assert_eq!(before[0].text, "TITLEMASK", "and the mask is the second half of it"); + + let mut doc = text_page(content); + let line = text::replace_line(&mut doc, 0, 0, "EDITED"); + assert!(matches!(&line, Err(EditError::Invalid(m)) if m.contains("clipping path")), "{line:?}"); + let block = text::rewrite_block(&mut doc, 0, 0, Some("EDITED"), &BlockStyle::default()); + assert!(matches!(&block, Err(EditError::Invalid(m)) if m.contains("clipping path")), "{block:?}"); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "TITLEMASK", "refused means untouched"); +} + +/// Another drawing of the line on the same baseline goes with it, so that the replacement is all +/// that shows -- and that copy can be the mask while the line being replaced is ink. Dropping it +/// destroys the artwork as surely as rewriting the mask itself, so this is refused too. +#[test] +fn a_clipping_copy_on_the_same_baseline_is_refused() { + let content = "q\nBT 0 Tr /F1 48 Tf 40 60 Td (MASK) Tj ET\nBT 7 Tr /F1 48 Tf 40 60 Td (MASK) Tj ET\n1 0 0 rg 0 0 300 150 re f\nQ\n"; + let before = text::text_lines(&text_page(content), 0).unwrap(); + assert_eq!(before.len(), 2, "two drawings of the same line"); + + let mut doc = text_page(content); + let line = text::replace_line(&mut doc, 0, 0, "EDITED"); + assert!(matches!(&line, Err(EditError::Invalid(m)) if m.contains("clipping path")), "{line:?}"); + let block = text::rewrite_block(&mut doc, 0, 0, Some("EDITED"), &BlockStyle::default()); + 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 9c83cf9a3..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)] @@ -50,6 +53,10 @@ pub struct TextLine { pub visual_order: bool, /// The Japanese or Hebrew fallback face the font subsets, when the line is in one (see [`same_font`]). face: Option, + /// Its box along and across its baseline, `[along0, across0, along1, across1]` (see + /// [`TextLine::frame_rect`]). Upright text has `rect`; turned text has its own box, which is + /// narrower than `rect` turned into the same frame. + frame: [f64; 4], /// The content stream piece the line starts in, and its operators (indices into the page's /// streams parsed joined). stream: usize, @@ -58,15 +65,29 @@ 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, /// Some of the line's text is `/ActualText`, which is already in logical order: visual-order /// Indic recovery would scramble it. actual_text: bool, + /// Whether any of the line's runs draws in a clipping mode (`4 Tr` to `7 Tr`). The join below + /// 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] @@ -144,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 }); } @@ -163,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)) @@ -224,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], @@ -470,12 +560,7 @@ impl TextLine { /// Its box in the same frame, `[along0, across0, along1, across1]` (for upright text, `rect`). fn frame_rect(&self) -> [f64; 4] { - let dir = self.direction(); - let r = self.rect; - [(r[0], r[1]), (r[2], r[1]), (r[0], r[3]), (r[2], r[3])] - .into_iter() - .map(|p| to_frame(dir, p)) - .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)]) + self.frame } } @@ -493,6 +578,11 @@ fn to_frame(dir: (f64, f64), p: (f64, f64)) -> (f64, f64) { (p.0 * dir.0 + p.1 * dir.1, p.1 * dir.0 - p.0 * dir.1) } +/// The box covering both `[x0, y0, x1, y1]` boxes. +fn union(a: [f64; 4], b: [f64; 4]) -> [f64; 4] { + [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])] +} + /// What replacing a line did. #[derive(Clone, Debug, PartialEq)] pub struct LineEdit { @@ -515,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) { @@ -631,6 +729,7 @@ struct Shown { glyph_count: usize, /// Advance in text space, including spacing and TJ adjustments. advance: f64, + tracking: f64, /// The text matrix where it starts (text space), and the state there and at its `BT`. tm: Matrix, tlm: Matrix, @@ -639,6 +738,8 @@ struct Shown { bt_state: Ts, text: String, rect: [f64; 4], + /// Its box along and across its baseline (see [`TextLine::frame`]). + frame: [f64; 4], /// The boxes of its words (see [`TextLine::ink`]). ink: Vec<[f64; 4]>, baseline: f64, @@ -655,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) } @@ -678,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 @@ -692,10 +801,83 @@ 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) } } +/// Use the lower third of letter gaps, not word gaps. A pair of isolated labels is not +/// evidence of tracking; unadjusted neighbours inside a string contribute zero too. +/// The tracking a run of glyphs was set with: the lower third of its gaps, when that is at +/// most half an em (`limit`). A wider "tracking" is word or cell spacing between one-glyph +/// words (`a b c`, a column of digits or Y/N ticks), which must keep its spaces. +fn typical_tracking(gaps: &mut [f64], limit: f64) -> f64 { + if gaps.len() < 3 { + return 0.0; + } + let at = gaps.len() / 3; + gaps.select_nth_unstable_by(at, f64::total_cmp); + gaps.get(at).copied().filter(|n| n.is_finite() && *n <= limit).unwrap_or(0.0).max(0.0) +} + +fn tj_tracking(pieces: &[Object], metrics: &Metrics, size: f64, scale: f64) -> f64 { + let mut gaps = Vec::new(); + let mut previous = false; + let mut gap = 0.0; + for piece in pieces { + if let Object::String(s) = piece { + for (code, len) in metrics.codes(&s.bytes) { + let blank = metrics.is_space(code, len) || metrics.text_of(code).is_some_and(|t| !t.is_empty() && t.chars().all(char::is_whitespace)); + if previous && !blank { + gaps.push(gap); + } + previous = !blank; + gap = 0.0; + } + } else if let Some(n) = piece.as_f64() { + gap += -n / 1000.0 * size * scale; + } + } + typical_tracking(&mut gaps, size * scale.abs() * 0.5) +} + +fn positioned_tracking(shown: &mut [Shown]) { + let mut start = 0; + while start < shown.len() { + let mut end = start.saturating_add(1); + while let (Some(prev), Some(next)) = (shown.get(end.saturating_sub(1)), shown.get(end)) { + if prev.glyph_count != 1 + || next.glyph_count != 1 + || prev.font != next.font + || prev.scope != next.scope + || (prev.size - next.size).abs() > 0.01 + || prev.state.char_spacing != next.state.char_spacing + || prev.state.scale != next.state.scale + || !Pen::after(prev).continues(next, 1.5).0 + { + break; + } + end = end.saturating_add(1); + } + if let Some(run) = shown.get_mut(start..end) { + let mut gaps: Vec = run + .windows(2) + .filter_map(|pair| { + let [prev, next] = pair else { return None }; + (!prev.text.trim().is_empty() && !next.text.trim().is_empty()).then(|| Pen::after(prev).continues(next, 0.0).1) + }) + .collect(); + let limit = run.first().map_or(0.0, |s| s.size * s.state.scale.abs() * 0.5); + let tracking = typical_tracking(&mut gaps, limit); + for s in run.iter_mut().skip(1) { + s.tracking = tracking; + } + } + start = end; + } +} + /// The graphics state carried from one of a page's content streams to the next: the streams /// are one stream in pieces (§7.8.2), so a `cm` (AutoCAD scales the whole page in the first /// stream), an unbalanced `q`, the font and the colour still apply in the streams after it. @@ -703,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 { @@ -719,23 +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>, - logical_text: bool, -) -> Vec { + // `Some(properties)`: read `/ActualText`, looking named property lists up in `properties`. + logical_text: Option<&Dict>, +) -> 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()); @@ -743,22 +937,34 @@ 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() }); - let logical = op.operands.get(1).and_then(Object::as_dict).and_then(|d| d.get(b"ActualText")).and_then(|s| match s { + // 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)), + other => other.and_then(Object::as_dict).cloned(), + }); + let logical = list.as_ref().and_then(|d| d.get(b"ActualText")).and_then(|s| match s { Object::String(s) if s.bytes.len() <= 4_000_000 => Some(s.to_text()), _ => None, }); - actual.push(if logical_text { logical.map(|s| (out.len(), i, s)) } else { None }); + actual.push(logical.map(|s| (out.len(), i, s))); } 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(); @@ -773,6 +979,7 @@ fn interpret( first.rect[2].max(shown.rect[2]), first.rect[3].max(shown.rect[3]), ]; + first.frame = union(first.frame, shown.frame); first.ink.extend(shown.ink); } first.extra_ops.extend([open, i]); @@ -815,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), @@ -831,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())); @@ -867,14 +1087,24 @@ 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. + let tracking = if op.is("TJ") { tj_tracking(&pieces, &m, ts.size, ts.scale) } else { 0.0 }; let trm0 = tm.then(&ts.ctm); let start = trm0.apply(0.0, ts.rise); 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(); @@ -892,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: @@ -909,14 +1154,26 @@ 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 > ts.size * 0.2 && !text.ends_with(' ') { + if dx - tracking > (ts.size * ts.scale).abs() * 0.2 && !text.ends_with(' ') { text.push(' '); } - // Kerning keeps a word together; a jump either way of more - // than 0.2 em ends it (whatever the sign of the size or `Tz`). - if dx.abs() > (ts.size * ts.scale).abs() * 0.2 { + // Tracking and kerning keep a word together; a larger jump + // ends it (whatever the sign of the size or `Tz`). + if dx - tracking > (ts.size * ts.scale).abs() * 0.2 || dx < -(ts.size * ts.scale).abs() * 0.2 { in_word = false; } x_text += dx; @@ -924,23 +1181,36 @@ 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)]); + // The same corners along and across the baseline: the run's own box, not its + // upright bounding box turned (which is wider by the box's height on a slant). + let dir = direction(&trm0); + let frame = corners + .iter() + .map(|&p| to_frame(dir, p)) + .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)]); 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)]) }) @@ -955,14 +1225,25 @@ fn interpret( op: i, extra_ops: Vec::new(), glyph_count, - advance: x_text, - 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]]), + // 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] - 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, bt_state: at_bt.1.clone(), text, rect, + frame, ink, baseline: start.1, start_x: start.0, @@ -978,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, }); @@ -988,14 +1270,15 @@ fn interpret( carry.ts = ts; carry.stack = stack; carry.marked = marked; - out + carry.layers = layers; + positioned_tracking(&mut 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(); @@ -1003,7 +1286,8 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro // joined, so an operator whose operands end one piece still has them. let pieces = Pieces::join(&content_streams(doc, &p.dict).into_iter().map(|(_, d)| d).collect::>()); let ops = pieces.parse().ops; - let shown = interpret(doc, &ops, &fonts_res, &mut cache, &mut carry, None, true); + let properties = res.get(b"Properties").and_then(|o| doc.dict(o)).unwrap_or_default(); + 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 @@ -1021,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 @@ -1035,22 +1319,28 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro }); if joins && let Some(l) = lines.last_mut() { let gap = last.map_or(0.0, |(_, pen)| pen.continues(&s, 0.0).1); - if gap > l.size * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') { + if gap - s.tracking > l.size * s.state.scale.abs() * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') { l.text.push(' '); } l.text.push_str(&s.text); l.rect = [l.rect[0].min(s.rect[0]), l.rect[1].min(s.rect[1]), l.rect[2].max(s.rect[2]), l.rect[3].max(s.rect[3])]; + l.frame = union(l.frame, s.frame); l.ink.extend(s.ink.iter().copied()); l.ops.push(s.op); l.ops.extend(s.extra_ops.iter().copied()); l.decodable &= s.decodable; 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 { text: s.text.clone(), rect: s.rect, + frame: s.frame, ink: s.ink.clone(), font: String::from_utf8_lossy(&s.font).into_owned(), base_font: s.base_font.clone(), @@ -1063,8 +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, @@ -1097,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) } @@ -1112,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 @@ -1123,21 +1422,27 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream }); if joins && let Some(l) = lines.last_mut() { let gap = last.map_or(0.0, |pen| pen.continues(&s, 0.0).1); - if gap > l.size * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') { + if gap - s.tracking > l.size * s.state.scale.abs() * 0.2 && !l.text.ends_with(' ') && !s.text.starts_with(' ') { l.text.push(' '); } l.text.push_str(&s.text); l.rect = [l.rect[0].min(s.rect[0]), l.rect[1].min(s.rect[1]), l.rect[2].max(s.rect[2]), l.rect[3].max(s.rect[3])]; + l.frame = union(l.frame, s.frame); l.ink.extend(s.ink.iter().copied()); l.ops.push(s.op); l.ops.extend(s.extra_ops.iter().copied()); l.decodable &= s.decodable; 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(), rect: s.rect, + frame: s.frame, ink: s.ink.clone(), font: String::from_utf8_lossy(&s.font).into_owned(), base_font: s.base_font.clone(), @@ -1150,8 +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, @@ -1169,13 +1477,27 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream *last = Some(Pen::after(&s)); } -/// How deep form XObjects nest before reading stops (they may also refer to themselves). -pub(crate) const MAX_FORM_DEPTH: usize = 12; +/// How deep form XObjects nest before reading stops (they may also refer to themselves). The +/// renderer stops at the same depth, so export reads the text the page shows (#3120). +pub const MAX_FORM_DEPTH: usize = 50; /// The most form XObjects one page's walk enters, at any depth. The depth cap alone still lets -/// a form that draws another form many times fan out exponentially (100 `Do`s on each of 12 -/// levels), which would hang export on a hostile PDF. -pub(crate) const MAX_FORM_VISITS: usize = 4096; +/// a form that draws another form many times fan out exponentially (100 `Do`s on each of 50 +/// levels), which would hang export on a hostile PDF. The renderer paints as many forms nested +/// in forms; this budget also counts the forms the page draws itself, so a page that draws one +/// a million times stays bounded too. A label or table cell drawn as a form a few thousand +/// times is read in full (#3120). +pub const MAX_FORM_VISITS: usize = 50_000; + +/// One page's walk through the form XObjects it draws: how many it entered (the page's +/// [`MAX_FORM_VISITS`]), and whether a limit left a form unread. +#[derive(Default)] +pub(crate) struct FormBudget { + visits: usize, + /// A form was left unread because it nests deeper than [`MAX_FORM_DEPTH`] or comes past + /// [`MAX_FORM_VISITS`]: what it draws is missing from the walk. + pub(crate) truncated: bool, +} /// A form XObject a page draws: its object, content, resources and its matrix composed with the /// CTM at the `Do`. @@ -1187,17 +1509,19 @@ pub(crate) struct FormCall { } /// The form XObject `name` in `resources`, drawn with `ctm`. `None` for images, missing names -/// and forms already being read (`path`), nested too deep, or past the page's `visits` budget -/// ([`MAX_FORM_VISITS`]); each form entered counts one visit. +/// and forms already being read (`path`), and for forms nested too deep or past the page's +/// budget, which `budget` records as truncated; each form entered counts one visit. pub(crate) fn form_call( doc: &Document, resources: &Dict, name: &[u8], ctm: Matrix, path: &[pdfcraft_cos::ObjRef], - visits: &mut usize, + budget: &mut FormBudget, ) -> Option { - if path.len() >= MAX_FORM_DEPTH || *visits >= MAX_FORM_VISITS { + let limited = path.len() >= MAX_FORM_DEPTH || budget.visits >= MAX_FORM_VISITS; + // Once a limit has cut the page short, refusing the rest costs nothing more. + if limited && budget.truncated { return None; } let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned())?; @@ -1210,6 +1534,11 @@ pub(crate) fn form_call( if s.dict.name(b"Subtype") != Some(b"Form") { return None; } + // Only a form left unread loses content; an image at the limit is still read by its caller. + if limited { + budget.truncated = true; + return None; + } let m = s.dict.get(b"Matrix").map(|m| doc.resolve(m)).and_then(|m| { let a = m.as_array()?; let v: Vec = a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect(); @@ -1217,7 +1546,7 @@ pub(crate) fn form_call( }); // A form without its own resources uses the ones it was drawn with (PDF 1.1 files). let own = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()); - *visits = visits.saturating_add(1); + budget.visits = budget.visits.saturating_add(1); Some(FormCall { obj, data: s.decoded().unwrap_or_default(), @@ -1228,64 +1557,79 @@ pub(crate) fn form_call( /// The lines a page shows as it reads, including text drawn by form XObjects (which /// [`text_lines`] leaves out because it can only rewrite the page's own streams). Lines from a -/// form carry a stream number past the page's streams, one per form drawn. -fn reading_lines(doc: &Document, page: usize) -> Result, EditError> { +/// form carry a stream number past the page's streams, one per form drawn. The flag is `true` +/// when forms past [`MAX_FORM_DEPTH`] or [`MAX_FORM_VISITS`] were left unread. +fn reading_lines(doc: &Document, page: usize) -> Result<(Vec, bool), EditError> { struct Walk<'a> { doc: &'a Document, lines: Vec, next_stream: usize, path: Vec, - visits: usize, + budget: FormBudget, } - fn walk(w: &mut Walk, ops: &[Op], resources: &Dict, carry: &mut Carry, stream: usize) { - let fonts_res = resources.get(b"Font").map(|f| w.doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); + /// `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) -> Result<(), EditError> { let mut cache = HashMap::new(); let mut invoked = Vec::new(); - let shown = interpret(w.doc, ops, &fonts_res, &mut cache, carry, Some(&mut invoked), true); + let properties = resources.get(b"Properties").and_then(|o| w.doc.dict(o)).unwrap_or_default(); + 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; } - add_shown(&mut w.lines, &mut last, s, stream); + let si = stream(s.op); + // Text from another stream starts a new line, as when each stream was read alone. + if last_stream.replace(si).is_some_and(|prev| prev != si) { + last = None; + } + 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.visits) 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(); let streams = content_streams(doc, &p.dict); - let mut w = Walk { doc, lines: Vec::new(), next_stream: streams.len(), path: Vec::new(), visits: 0 }; + let mut w = Walk { doc, lines: Vec::new(), next_stream: streams.len(), path: Vec::new(), budget: FormBudget::default() }; let mut carry = Carry::new(); - for (si, (_, data)) in streams.into_iter().enumerate() { - walk(&mut w, &parse(&data).ops, &res, &mut carry, si); - } + // Read the page's streams as the one stream they are, in one pass, as rendering and editing + // do: a comment, an operator's operands or a marked-content sequence (its /ActualText too) + // can run on from one stream into the next. Text still counts as the stream its operator + // starts in, which keeps lines from different streams apart. + 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)?; 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) { l.text = crate::indic::to_logical(&l.text); } lines.retain(|l| !l.text.trim().is_empty()); - Ok(lines) + Ok((lines, w.budget.truncated)) } /// Text → the runs that show it: each run's font resource name and the bytes shown in that font. @@ -1303,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 { @@ -1340,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)>, @@ -1414,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() }; @@ -1430,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( @@ -1454,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. @@ -1467,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( @@ -1501,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 }); @@ -1526,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(); @@ -1557,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(' ', ""))); @@ -1577,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()) { @@ -1630,7 +2132,15 @@ fn source_encodes(metrics: &Metrics, text: &str) -> bool { text.split(' ').all(|word| metrics.encode(word).is_some()) } +#[cfg(test)] +thread_local! { + /// How often [`source_space`] ran on this thread, for the test that pins it out of per-character loops. + pub(crate) static SOURCE_SPACE_CALLS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + fn source_space(doc: &Document, fonts: &Dict, metrics: &Metrics) -> f64 { + #[cfg(test)] + SOURCE_SPACE_CALLS.with(|c| c.set(c.get().saturating_add(1))); let width = |m: &Metrics| m.encode(" ").map(|b| m.codes(&b).iter().map(|(c, _)| m.width(*c)).sum::()); if let Some(w) = width(metrics) { return w; @@ -1689,7 +2199,25 @@ 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 +/// glyphs are not ink but a mask, and whatever is painted through them takes their shape. +/// Removing the glyphs removes the mask, and the fill or image behind floods its own rectangle +/// instead -- the artwork is destroyed, in a file that still opens without a warning (#3429). +/// `clear_block` already refuses these for the same reason. +/// +/// Every line the rewrite drops is checked, not only the one being replaced: a line's runs need +/// not share a rendering mode (see [`TextLine::clips`]), and [`coincident_ops`] drops a whole +/// overlapping line without regard to its mode -- so a mask could go with text that is ink. +fn refuse_clipping_text(lines: &[TextLine], dropped: &std::collections::HashSet) -> Result<(), EditError> { + if lines.iter().any(|l| l.clips && l.ops.iter().any(|op| dropped.contains(op))) { + return Err(EditError::Invalid("text used as a clipping path cannot be edited: its glyphs mask the artwork behind them".into())); + } + Ok(()) } /// [`replace_line`] with the most unique characters one generated font may hold (tests lower it). @@ -1697,6 +2225,13 @@ 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. + let mut drops = coincident_ops(&lines, &[&target]); + drops.extend(target.ops.iter().copied()); + refuse_clipping_text(&lines, &drops)?; 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(); @@ -1770,9 +2305,7 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi } } } - // Rebuild: the line's first operator becomes the replacement, its others go. Copies of the - // line drawn on the same baseline go too, so the replacement is all that shows. - let drops = coincident_ops(&lines, &[&target]); + // Rebuild: the line's first operator becomes the replacement, its others go. let new_data = splice(&pieces, &ops, |i| if i == first { (std::mem::take(&mut replacement), false) } else { (Vec::new(), !drops.contains(&i)) }); let contents = rewritten_contents(doc, &streams, new_data); let page_ref = p.obj; @@ -1804,7 +2337,10 @@ fn coincident_ops(lines: &[TextLine], targets: &[&TextLine]) -> std::collections let area = |r: [f64; 4]| ((r[2] - r[0]) * (r[3] - r[1])).max(0.0); let mut drop = std::collections::HashSet::new(); for line in lines { - let copy = targets.iter().any(|target| overlaps_half(line.rect, target.rect, area) && on_same_baseline(line, target)); + let copy = targets.iter().any(|target| { + let same_text = line.text.trim() == target.text.trim() || (line.origin.state.render_mode == 3) != (target.origin.state.render_mode == 3); + same_text && overlaps_half(line.rect, target.rect, area) && on_same_baseline(line, target) + }); if copy { drop.extend(line.ops.iter().copied()); } @@ -1822,13 +2358,67 @@ fn overlaps_half(a: [f64; 4], b: [f64; 4], area: impl Fn([f64; 4]) -> f64) -> bo /// Perpendicular distance between baselines, in user space. Horizontal text compares height; /// turned text compares the distance across the writing direction. fn on_same_baseline(line: &TextLine, target: &TextLine) -> bool { + let (dir, target_dir) = (line.direction(), target.direction()); + let same_dir = (dir.0 - target_dir.0).abs() < 1e-3 && (dir.1 - target_dir.1).abs() < 1e-3; + let same_size = line.size.min(target.size) >= line.size.max(target.size) * 0.8; let angle = target.rotation().to_radians(); let (sin, cos) = if angle.is_finite() { angle.sin_cos() } else { (0.0, 1.0) }; let dx = line.origin.x - target.origin.x; let dy = line.origin.baseline - target.origin.baseline; let offset = dx.mul_add(-sin, dy * cos).abs(); let size = line.size.min(target.size).max(1.0); - offset.is_finite() && offset < size * 0.3 + 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). @@ -1845,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, false); + 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 { @@ -1886,9 +2476,27 @@ pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(), /// The paragraphs on a page as a reader sees them, including text drawn by form XObjects /// (Export to Word, HTML and RTF). Read-only: indexes into this list are not /// [`text_blocks`] indexes, so they can't be passed to [`replace_block`] or [`rewrite_block`]. +/// [`reading_blocks_checked`] also says whether text was left out. pub fn reading_blocks(doc: &Document, page: usize) -> Result, EditError> { - let lines = reading_lines(doc, page)?; - Ok(regions::group_reading_blocks(&lines)) + reading_blocks_checked(doc, page).map(|r| r.blocks) +} + +/// A page's paragraphs as a reader sees them ([`reading_blocks_checked`]). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ReadingBlocks { + /// The paragraphs, as [`reading_blocks`] returns them. + pub blocks: Vec, + /// The page draws forms nested deeper than [`MAX_FORM_DEPTH`], or more of them than + /// [`MAX_FORM_VISITS`]. Reading leaves those out to stay bounded on hostile files, so their + /// text is missing from `blocks`; [`crate::reading_images`] walks the same forms and misses + /// their images too. + pub truncated: bool, +} + +/// [`reading_blocks`], saying whether the limits that bound reading left text out. +pub fn reading_blocks_checked(doc: &Document, page: usize) -> Result { + let (lines, truncated) = reading_lines(doc, page)?; + Ok(ReadingBlocks { blocks: regions::group_reading_blocks(&lines), truncated }) } // An ideographic space carries intentional Japanese spacing, including paragraph indentation. @@ -1980,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). @@ -1992,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. @@ -2009,7 +2618,12 @@ fn rewrite_block_from( let blocks = group_blocks(&lines); let b = blocks.get(block).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no paragraph {}", page + 1, block + 1)))?; let members: Vec<&TextLine> = b.lines.iter().map(|i| &lines[*i]).collect(); + // One clipping run is enough: rewriting the paragraph would drop its mask with the rest. + let mut dropped = coincident_ops(&lines, &members); + 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(); @@ -2060,6 +2674,8 @@ fn rewrite_block_from( // The characters to show, for choosing fonts: the breaks are moves, not glyphs. let flat = text.replace('\n', " "); let o = &first.origin; + let last = members.last().copied().unwrap_or(first); + let emit = &last.origin; let (font_name, old_size) = o.state.font.clone().ok_or_else(|| EditError::Invalid("the paragraph has no font".into()))?; let k = o.k.max(1e-6); // The size in text space: points ÷ the text-to-user scale. @@ -2098,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 { @@ -2106,21 +2727,22 @@ 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 // paragraph leaves the document as it was. The move is in page space, so it is taken back through // the CTM's linear part into the space the paragraph is drawn in. let movement = if dx != 0.0 || dy != 0.0 { - let c = o.ctm; + let c = emit.ctm; let back = Matrix([c[0], c[1], c[2], c[3], 0.0, 0.0]) .invert() .ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it can't be moved in".into()))?; @@ -2128,6 +2750,12 @@ fn rewrite_block_from( } else { None }; + let tm = if o.ctm == emit.ctm { + o.tm + } else { + let emit_inv = Matrix(emit.ctm).invert().ok_or_else(|| EditError::Invalid("the paragraph has a singular transform".into()))?; + Matrix(o.tm).then(&Matrix(o.ctm)).then(&emit_inv).0 + }; let type3 = if !reuse && needs_type3(&flat) { Some(type3_font(doc, &mut fonts_res, &flat, family, bold, max_glyphs)?) } else { None }; let advance = |s: &str| -> f64 { let t = match (&metrics, reuse) { @@ -2149,9 +2777,27 @@ fn rewrite_block_from( }; t * o_state.scale * k }; - // A turned single line grows to fit, as far as the largest page PDF allows. + // A turned single line grows to fit, up to where its baseline reaches the page's margin. let width = if !upright && style.width.is_none() && members.len() == 1 { - text.split('\n').map(&advance).fold(width, f64::max).min(14_400.0) + let crop = p.crop(doc); + let (x0, y0) = (first.origin.x + dx, first.origin.baseline + dy); + let (ux, uy) = first.direction(); + let tx = if ux > 1e-6 { + (crop[2] - 36.0 - x0) / ux + } else if ux < -1e-6 { + (crop[0] + 36.0 - x0) / ux + } else { + f64::INFINITY + }; + let ty = if uy > 1e-6 { + (crop[3] - 36.0 - y0) / uy + } else if uy < -1e-6 { + (crop[1] + 36.0 - y0) / uy + } else { + f64::INFINITY + }; + let cap = width.max(tx.min(ty)).min(14_400.0); + text.split('\n').map(&advance).fold(width, f64::max).min(cap) } else { width }; @@ -2205,7 +2851,7 @@ fn rewrite_block_from( let n = pdfcraft_content::num; // In its own graphics state, so a new colour (or anything else) stops at the paragraph. let mut block_ops = vec![Op::new("q", vec![])]; - block_ops.extend(reveal_ops(&b, &lines, style, o.ctm)?); + block_ops.extend(reveal_ops(&b, &lines, style, emit.ctm)?); // Moved: a translation inside that state. if let Some((ax, ay)) = movement { block_ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(ax), n(ay)])); @@ -2220,7 +2866,7 @@ fn rewrite_block_from( state.fill = vec![Op::new("rg", vec![n(r), n(g), n(bl)])]; } block_ops.extend(state.ops()); - block_ops.push(Op::new("Tm", o.tm.iter().map(|v| n(*v)).collect())); + block_ops.push(Op::new("Tm", tm.iter().map(|v| n(*v)).collect())); // Alignment: each line's offset from the left edge, in text space. let offset = |line: &str| -> f64 { let free = (width - advance(line)).max(0.0) / k; @@ -2303,7 +2949,7 @@ fn rewrite_block_from( }) .collect(); block_ops.push(Op::new("q", vec![])); - block_ops.push(Op::new("cm", o.tm.iter().map(|v| n(*v)).collect())); + block_ops.push(Op::new("cm", tm.iter().map(|v| n(*v)).collect())); block_ops.extend(colour); block_ops.push(Op::new("w", vec![n(size * 0.06)])); for (x0, x1, y) in underlines { @@ -2318,24 +2964,24 @@ fn rewrite_block_from( // second copy, an invisible text layer): the new text is all that may show. let mut drop = coincident_ops(&lines, &members); drop.extend(members.iter().flat_map(|l| l.ops.iter().copied())); - // Text shown earlier in the same text object stays first: the paragraph then goes where it - // was, between two halves of the text object, so it keeps its place in reading order. - let start = first.ops.first().copied().unwrap_or(o.bt_op); + // Text shown earlier in the same text object stays first: the paragraph then goes where its + // last row was, after any backgrounds between the rows, or between two halves of the text object. + let start = last.ops.first().copied().unwrap_or(emit.bt_op); let split = ops - .get(o.bt_op..start) + .get(emit.bt_op..start) .unwrap_or_default() .iter() .enumerate() - .any(|(i, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.contains(&(o.bt_op + i))); + .any(|(i, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.contains(&(emit.bt_op + i))); // What the text after the paragraph expects: the state at its BT (or where it was split). - let after = if split { &o.state } else { &o.bt_state }; + let after = if split { &emit.state } else { &emit.bt_state }; block_ops.extend(after.ops().into_iter().filter(|op| !op.is("Tf") || after.font.is_some())); if split { block_ops.insert(0, Op::new("ET", vec![])); block_ops.push(Op::new("BT", vec![])); - block_ops.push(Op::new("Tm", o.tlm.iter().map(|v| n(*v)).collect())); + block_ops.push(Op::new("Tm", emit.tlm.iter().map(|v| n(*v)).collect())); } - let at = if split { start } else { o.bt_op }; + let at = if split { start } else { emit.bt_op }; let new_data = splice(&pieces, &ops, |i| { let insert = if i == at { std::mem::take(&mut block_ops) } else { Vec::new() }; (insert, !drop.contains(&i)) @@ -2412,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 { @@ -2477,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()); } } @@ -2484,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, false); + 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. @@ -2562,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 2d7baf519..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); @@ -17,10 +18,6 @@ fn same_plane(a: &TextLine, b: &TextLine) -> bool { }) } -fn union(a: [f64; 4], b: [f64; 4]) -> [f64; 4] { - [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])] -} - fn row_rect(row: &[usize], lines: &[TextLine]) -> Option<[f64; 4]> { row.iter().filter_map(|i| lines.get(*i)).map(|l| l.rect).reduce(union) } @@ -31,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`]) @@ -55,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 @@ -64,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() { @@ -75,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 @@ -98,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 @@ -493,6 +645,20 @@ pub(super) fn rewrite_styled_block( }; // A source run may carry TJ kerning/justification that is absent from its text state. // Keep its measured advance when rebuilding the run instead of making it wrap on entry. + // Each font's space width, worked out once: when a font can't show a space, finding one + // reads every font on the page, and doing that for every character of a paragraph made each + // keystroke in a long paragraph take half a second (#2616). + // Only for the fonts the paragraph uses, when first asked: working out every page font's width + // up front would scan the page once per font for a short edit on a page of many fonts. + let spaces: std::cell::RefCell> = std::cell::RefCell::new(HashMap::new()); + let space_of = |name: &str, m: &Metrics| { + if let Some(w) = spaces.borrow().get(name) { + return *w; + } + let w = source_space(doc, &fonts, m); + spaces.borrow_mut().insert(name.to_owned(), w); + w + }; let mut calibration = HashMap::new(); for (index, source) in lines.iter().enumerate() { if let Some(m) = metrics.get(&source.font) { @@ -500,7 +666,7 @@ pub(super) fn rewrite_styled_block( m, &source.text, source.origin.state.font.as_ref().map_or(1.0, |(_, s)| *s), - source_space(doc, &fonts, m), + space_of(&source.font, m), source.origin.state.char_spacing, source.origin.state.word_spacing, ) * source.origin.state.scale @@ -517,7 +683,7 @@ pub(super) fn rewrite_styled_block( m, &item.0.to_string(), size_of(source, item.1), - source_space(doc, &fonts, m), + space_of(&item.2, m), overrides .get(&item.1) .and_then(|r| r.char_spacing) @@ -620,19 +786,20 @@ pub(super) fn rewrite_styled_block( let kept_lines: std::collections::HashSet = kept.iter().enumerate().filter(|(_, k)| **k).filter_map(|(r, _)| block.rows.get(r)).flatten().copied().collect(); let rewritten: Vec<&TextLine> = block.lines.iter().filter(|i| !kept_lines.contains(i)).filter_map(|i| lines.get(*i)).collect(); - // The rewrite goes where the first rewritten row was drawn, so it must share the paragraph's - // space. A dropped `'` or `"` also moved to the next line, which a kept row relies on. - let lead = rewritten.first().copied().filter(|lead| { - lead.origin.ctm.iter().zip(first.origin.ctm).all(|(a, b)| (a - b).abs() < 1e-9) - && !rewritten.iter().flat_map(|l| &l.ops).any(|i| ops.get(*i).is_some_and(|op| op.is("'") || op.is("\""))) - }); + // The rewrite goes where the last rewritten row was drawn, after any backgrounds painted + // between the rows. A dropped `'` or `"` also moved to the next line, which a kept row relies on. + let lead = + rewritten.last().copied().filter(|_| !rewritten.iter().flat_map(|l| &l.ops).any(|i| ops.get(*i).is_some_and(|op| op.is("'") || op.is("\"")))); let (members, lead) = match lead { Some(lead) if !kept_lines.is_empty() => (rewritten, lead), _ => { kept.iter_mut().for_each(|k| *k = false); - (block.lines.iter().filter_map(|i| lines.get(*i)).collect(), first) + let all: Vec<&TextLine> = block.lines.iter().filter_map(|i| lines.get(*i)).collect(); + let last = all.last().copied().unwrap_or(first); + (all, last) } }; + let o = &lead.origin; for (row, _) in rows.iter().zip(&kept).filter(|(_, k)| !**k) { let text: String = row.iter().map(|v| v.0).collect(); if pdfcraft_fonts::source_shaping::required(&text) && row.first().is_some_and(|first| row.iter().any(|v| v.1 != first.1 || v.2 != first.2)) { @@ -668,18 +835,14 @@ pub(super) fn rewrite_styled_block( } let n = pdfcraft_content::num; let mut block_ops = vec![Op::new("q", vec![])]; - block_ops.extend(reveal_ops(block, lines, style, first.origin.ctm)?); - let back = Matrix([first.origin.ctm[0], first.origin.ctm[1], first.origin.ctm[2], first.origin.ctm[3], 0.0, 0.0]) + block_ops.extend(reveal_ops(block, lines, style, o.ctm)?); + let back = Matrix([o.ctm[0], o.ctm[1], o.ctm[2], o.ctm[3], 0.0, 0.0]) .invert() .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) @@ -741,7 +904,7 @@ pub(super) fn rewrite_styled_block( if let Some([r, g, b]) = overrides.get(&item.1).and_then(|r| r.color).or(style.color) { state.fill = vec![Op::new("rg", vec![n(r), n(g), n(b)])]; } - let inverse = Matrix(first.origin.ctm).invert().ok_or_else(|| EditError::Invalid("the paragraph has a singular transform".into()))?; + let inverse = Matrix(o.ctm).invert().ok_or_else(|| EditError::Invalid("the paragraph has a singular transform".into()))?; let mut tm = Matrix(source.origin.tm).then(&Matrix(source.origin.ctm)).then(&inverse).0; let (ax, ay) = back.apply(x - source.origin.x, baseline - source.origin.baseline); tm[4] += ax; @@ -753,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 { @@ -781,7 +979,7 @@ pub(super) fn rewrite_styled_block( m, &string, size_of(source, item.1), - source_space(doc, &fonts, m), + spaces.borrow().get(&item.2).copied().unwrap_or_else(|| source_space(doc, &fonts, m)), state.char_spacing, state.word_spacing, )?); @@ -790,10 +988,10 @@ pub(super) fn rewrite_styled_block( if underlines.get(&item.1).copied().or(style.underline) == Some(true) { let [r, g, b] = overrides.get(&item.1).and_then(|r| r.color).or(style.color).unwrap_or(source.color); let underline_size = size_of(source, item.1) * source.origin.k; - let (x0, y0) = back.apply(x - first.origin.ctm[4], baseline - first.origin.ctm[5] - underline_size * 0.12); - let (x1, y1) = back.apply(x + segment_width - first.origin.ctm[4], baseline - first.origin.ctm[5] - underline_size * 0.12); + let (x0, y0) = back.apply(x - o.ctm[4], baseline - o.ctm[5] - underline_size * 0.12); + let (x1, y1) = back.apply(x + segment_width - o.ctm[4], baseline - o.ctm[5] - underline_size * 0.12); block_ops.push(Op::new("RG", vec![n(r), n(g), n(b)])); - block_ops.push(Op::new("w", vec![n(underline_size * 0.06 / first.origin.ctm[2].hypot(first.origin.ctm[3]).max(1e-6))])); + block_ops.push(Op::new("w", vec![n(underline_size * 0.06 / o.ctm[2].hypot(o.ctm[3]).max(1e-6))])); block_ops.push(Op::new("m", vec![n(x0), n(y0)])); block_ops.push(Op::new("l", vec![n(x1), n(y1)])); block_ops.push(Op::new("S", vec![])); @@ -810,7 +1008,6 @@ pub(super) fn rewrite_styled_block( block_ops.push(Op::new("Q", vec![])); let mut drop = coincident_ops(lines, &members); drop.extend(members.iter().flat_map(|l| l.ops.iter().copied())); - let o = &lead.origin; let start = lead.ops.first().copied().unwrap_or(o.bt_op); let split = ops .get(o.bt_op..start) 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 7de028763..f5e35088b 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true workspace = true [dependencies] +craft-docpath = { workspace = true } pdfcraft-measure = { workspace = true } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "tiff"] } pdfcraft-render = { workspace = true } @@ -36,6 +37,9 @@ pdfcraft-model = { workspace = true } pdfcraft-geom = { workspace = true } thiserror = { workspace = true } png = "0.18" +# Already in the graph (through image and pdfcraft-create): image's TiffEncoder can't set the +# resolution or the compression of an exported page. +tiff = "0.11" # Already in the graph (url and idna through webbrowser, icu_properties through boa): they show # the host of a document's link the way the browser reads it (links::display_host). url = "2.5" @@ -44,5 +48,9 @@ icu_properties = "2.3" unicode-segmentation = "1.13" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -# Already in the graph through lopdf; used only for the local UTC offset ("today"). -chrono = { version = "0.4", default-features = false, features = ["clock"] } +# 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 b46d88acf..c19e1e42c 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -206,6 +206,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ item("Set page boxes", "square-dashed-mouse-pointer", "page.boxes", Ready), item("Crop pages", "crop", "page.crop", Ready), item("Duplicate pages", "copy-plus", "page.duplicate", Ready), + item("Reverse pages", "arrow-up-down", "page.reverse", Ready), item("Page labels", "tag", "page.number", Ready), item("Page transitions", "presentation", "page.transitions", Planned("M4")), ], @@ -346,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), @@ -355,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 b28758e24..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"), @@ -362,6 +367,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("page.insert_blank", "Insert blank page", PAGES, None, Assembly, "file-plus"), c("page.rotate_dialog", "Rotate pages…", PAGES, None, Assembly, "rotate-cw"), c("page.duplicate", "Duplicate pages", PAGES, None, Assembly, "copy-plus"), + c("page.reverse", "Reverse pages", PAGES, None, Assembly, "arrow-up-down"), c("page.crop", "Crop pages", PAGES, None, Assembly, "crop"), c("page.boxes", "Set page boxes…", PAGES, None, Assembly, "square-dashed-mouse-pointer"), c("page.insert", "Insert pages from file…", PAGES, None, Assembly, "file-input"), @@ -397,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 3c03870f3..50508e20e 100644 --- a/crates/engine/src/compare.rs +++ b/crates/engine/src/compare.rs @@ -4,6 +4,9 @@ use std::sync::Arc; pub use pdfcraft_compare::{Change, Comparison, Kind, Side}; +// How far Office export reads into form XObjects; `OfficeExport::truncated_pages` lists the pages +// that draw more. +pub use pdfcraft_edit::{MAX_FORM_DEPTH, MAX_FORM_VISITS}; use crate::{DocId, Edit, EditError, Markup, NewAnnotation, NoteIcon, Session, Shape}; @@ -151,12 +154,31 @@ impl OfficeFormat { } } +/// A Word, PowerPoint, HTML or RTF export ([`crate::Document::export_office_checked`]). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct OfficeExport { + /// The exported file. + pub bytes: Vec, + /// Pages (0-based) that draw forms nested deeper than [`MAX_FORM_DEPTH`] or more of them + /// than [`MAX_FORM_VISITS`]: export stops reading there to stay bounded on hostile files, so + /// the text and images of those forms are missing from `bytes`. + pub truncated_pages: Vec, +} + impl crate::Document { /// The pages as paragraphs and images (for Word, HTML and RTF export), including what /// form XObjects draw. pub fn export_pages(&self) -> Vec { - let Some(cos) = self.editor.as_ref().map(|e| &e.cos) else { return Vec::new() }; - self.info + self.export_pages_checked().0 + } + + /// [`Self::export_pages`], and the pages whose forms were left partly unread + /// ([`OfficeExport::truncated_pages`]). + fn export_pages_checked(&self) -> (Vec, Vec) { + let Some(cos) = self.editor.as_ref().map(|e| &e.cos) else { return (Vec::new(), Vec::new()) }; + let mut truncated = Vec::new(); + let pages = self + .info .pages .iter() .enumerate() @@ -171,8 +193,12 @@ impl crate::Document { let crop_width = f64::from(info.crop[2] - info.crop[0]); let display_width = f64::from(if info.rotation % 180 == 0 { info.width } else { info.height }); let unit = if crop_width > 0.0 && crop_width.is_finite() && display_width.is_finite() { display_width / crop_width } else { 1.0 }; - let blocks = pdfcraft_edit::reading_blocks(cos, i) - .unwrap_or_default() + let reading = pdfcraft_edit::reading_blocks_checked(cos, i).unwrap_or_default(); + if reading.truncated { + truncated.push(i); + } + let blocks = reading + .blocks .into_iter() .filter(|b| !b.text.trim().is_empty()) .map(|b| { @@ -202,20 +228,30 @@ 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() + .collect(); + (pages, truncated) } /// The document as a Word, PowerPoint, HTML or RTF file. pub fn export_office(&self, format: OfficeFormat) -> Vec { - let pages = self.export_pages(); + self.export_office_checked(format).bytes + } + + /// [`Self::export_office`], and the pages whose text it had to leave out + /// ([`OfficeExport::truncated_pages`]). + pub fn export_office_checked(&self, format: OfficeFormat) -> OfficeExport { + let (pages, truncated_pages) = self.export_pages_checked(); let title = self.info.title.clone().unwrap_or_else(|| self.name.trim_end_matches(".pdf").to_string()); - match format { + let bytes = match format { OfficeFormat::Docx => pdfcraft_export::docx(&pages, &title), OfficeFormat::Pptx => pdfcraft_export::pptx(&pages, &title), OfficeFormat::Html => pdfcraft_export::html(&pages, &title).into_bytes(), OfficeFormat::Rtf => pdfcraft_export::rtf(&pages).into_bytes(), - } + }; + OfficeExport { bytes, truncated_pages } } } 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 579a483ef..01159df80 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -27,7 +27,10 @@ pub struct ExportSource { } impl Document { - /// The current state, for exporting on another thread. + /// What an export reads, for exporting on another thread. These are the displayed + /// bytes, so they may carry missing comment appearances PdfCraft drew. Signature + /// artwork stays original; viewer validation badges are not document content and + /// do not enter image/text exports or Print as Image. Saving uses the working file. pub fn export_source(&self) -> ExportSource { ExportSource { bytes: self.display.clone(), @@ -90,14 +93,14 @@ impl Exporter { encode_png(r.width, r.height, &r.rgba) } - /// Page `page` as an image file of `format`. + /// Page `page` as an image file of `format`, which says the resolution it was drawn at. pub fn image(&mut self, page: usize, dpi: f64, format: ImageFormat) -> Result, String> { self.check(page)?; let r = self.renderer.render(RenderRequest { page, scale: (dpi.clamp(18.0, 1200.0) / 72.0) as f32, ..Default::default() }); if let Some(e) = r.error { return Err(format!("page {}: {e}", page + 1)); } - encode_image(r.width, r.height, &r.rgba, format) + encode_image(r.width, r.height, &r.rgba, format, self.dpi_used(page, dpi)) } /// The reading-order text of a page. @@ -124,32 +127,51 @@ impl Exporter { } } -/// Premultiplied RGBA → an image file of `format`. -pub fn encode_image(width: u32, height: u32, premultiplied: &[u8], format: ImageFormat) -> Result, String> { +/// Premultiplied RGBA → an image file of `format`. A JPEG or TIFF says it is `dpi` dots per inch, +/// so other programs show and print it at the page's size. A PNG says nothing: its `pHYs` counts +/// pixels per metre, so 72 dpi would read back as 72.009 and a page made from it would shrink. +pub fn encode_image(width: u32, height: u32, premultiplied: &[u8], format: ImageFormat, dpi: f64) -> Result, String> { match format { ImageFormat::Png => encode_png(width, height, premultiplied), - ImageFormat::Jpeg { quality } => encode_jpeg(width, height, premultiplied, quality), - ImageFormat::Tiff => encode_tiff(width, height, premultiplied), + ImageFormat::Jpeg { quality } => encode_jpeg(width, height, premultiplied, quality, dpi), + ImageFormat::Tiff => encode_tiff(width, height, premultiplied, dpi), } } -/// Premultiplied RGBA → PNG (straight alpha). +/// Premultiplied RGBA → PNG (straight alpha). A raster with no translucent pixel (a rendered +/// page always is one: it is drawn on white) is written as RGB. +/// +/// Deflate level 3 with adaptive filtering: on rendered pages it writes files the size of the +/// `png` crate's default (level 6) in a third to a fifth of the time. Measured with RGB at +/// level 3 against RGBA at the default: a 300-dpi scan drawn at 220 dpi took 805 → 167 ms +/// (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> { - let mut rgba = premultiplied.to_vec(); - for px in rgba.as_chunks_mut::<4>().0 { - let a = u32::from(px[3]); - if a != 0 && a != 255 { - for c in &mut px[..3] { - *c = ((u32::from(*c) * 255 + a / 2) / a).min(255) as u8; + 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 { + (png::ColorType::Rgb, pixels.iter().flat_map(|px| [px[0], px[1], px[2]]).collect::>()) + } else { + let mut rgba = premultiplied.to_vec(); + for px in rgba.as_chunks_mut::<4>().0 { + let a = u32::from(px[3]); + if a != 0 && a != 255 { + for c in &mut px[..3] { + *c = ((u32::from(*c) * 255 + a / 2) / a).min(255) as u8; + } } } - } + (png::ColorType::Rgba, rgba) + }; let mut out = Vec::new(); let mut enc = png::Encoder::new(&mut out, width, height); - enc.set_color(png::ColorType::Rgba); + enc.set_color(color); enc.set_depth(png::BitDepth::Eight); + enc.set_deflate_compression(png::DeflateCompression::Level(3)); + enc.set_filter(png::Filter::Adaptive); let mut w = enc.write_header().map_err(|e| e.to_string())?; - w.write_image_data(&rgba).map_err(|e| e.to_string())?; + w.write_image_data(&data).map_err(|e| e.to_string())?; w.finish().map_err(|e| e.to_string())?; Ok(out) } @@ -183,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 @@ -196,23 +234,39 @@ fn over_white(premultiplied: &[u8]) -> Vec { .collect() } -/// Premultiplied RGBA → baseline JPEG (pages are opaque: transparency becomes white paper). -pub fn encode_jpeg(width: u32, height: u32, premultiplied: &[u8], quality: u8) -> Result, String> { +/// Premultiplied RGBA → baseline JPEG at `dpi` (pages are opaque: transparency becomes white paper). +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(); - image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100)) - .write_image(&rgb, width, height, image::ExtendedColorType::Rgb8) - .map_err(|e| e.to_string())?; + let mut enc = JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100)); + // The JFIF density is whole dots per inch. + enc.set_pixel_density(PixelDensity::dpi(dpi.round() as u16)); + enc.write_image(&rgb, width, height, image::ExtendedColorType::Rgb8).map_err(|e| e.to_string())?; Ok(out) } -/// Premultiplied RGBA → TIFF (RGB, LZW-compressed by the encoder's default). -pub fn encode_tiff(width: u32, height: u32, premultiplied: &[u8]) -> Result, String> { - use image::ImageEncoder; +/// Premultiplied RGBA → TIFF (RGB, LZW-compressed) at `dpi`. Written with `tiff` directly: +/// `image`'s `TiffEncoder` always says 1 × 1 with no unit and leaves the strips uncompressed. +/// No predictor: LZW alone is the compression TIFF readers support most widely after none. +pub fn encode_tiff(width: u32, height: u32, premultiplied: &[u8], dpi: f64) -> Result, String> { + 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()); - image::codecs::tiff::TiffEncoder::new(&mut out).write_image(&rgb, width, height, image::ExtendedColorType::Rgb8).map_err(|e| e.to_string())?; + let mut tiff = TiffEncoder::new(&mut out).map_err(err)?.with_compression(Compression::Lzw); + let mut image = tiff.new_image::(width, height).map_err(err)?; + // In hundredths: a page drawn at a lowered resolution (`Exporter::dpi_used`) is rarely a whole number. + let resolution = Rational { n: (dpi * 100.0).round() as u32, d: 100 }; + let tags = image.encoder(); + tags.write_tag(Tag::ResolutionUnit, ResolutionUnit::Inch).map_err(err)?; + tags.write_tag(Tag::XResolution, resolution.clone()).map_err(err)?; + tags.write_tag(Tag::YResolution, resolution).map_err(err)?; + image.write_data(&rgb).map_err(err)?; Ok(out.into_inner()) } @@ -241,3 +295,71 @@ mod image_name_tests { assert_eq!(image_file_name("doc", &image(0), 1, 0), "doc_Page_1_Image_0001.png"); } } + +#[cfg(test)] +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) { + let mut reader = png::Decoder::new(std::io::Cursor::new(png)).read_info().unwrap(); + let mut pixels = vec![0; reader.output_buffer_size().unwrap()]; + let frame = reader.next_frame(&mut pixels).unwrap(); + pixels.truncate(frame.buffer_size()); + (frame.color_type, pixels) + } + + /// A rendered page has no translucent pixel: it is written as RGB, each pixel exactly as + /// rendered (the alpha channel would only make the file bigger and slower to write). + #[test] + fn opaque_rasters_are_written_as_rgb_with_their_exact_pixels() { + let rgba = [[255, 255, 255, 255], [0, 0, 0, 255], [200, 30, 90, 255], [1, 2, 3, 255], [17, 250, 128, 255], [90, 90, 90, 255]]; + let (color, pixels) = decode(&encode_png(3, 2, rgba.as_flattened()).unwrap()); + assert_eq!(color, png::ColorType::Rgb); + assert_eq!(pixels, rgba.iter().flat_map(|p| [p[0], p[1], p[2]]).collect::>()); + } + + /// A raster with translucent pixels keeps its alpha, with the colours un-premultiplied. + #[test] + fn translucent_rasters_keep_straight_alpha() { + let rgba = [[64, 0, 32, 128], [0, 0, 0, 0], [10, 20, 30, 255]]; + let (color, pixels) = decode(&encode_png(3, 1, rgba.as_flattened()).unwrap()); + assert_eq!(color, png::ColorType::Rgba); + 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 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] = [ + ("opaque, a partial pixel after", 1, 1, &[10, 20, 30, 255, 0]), + ("opaque, three bytes after", 1, 1, &[10, 20, 30, 255, 0, 0, 0]), + ("translucent, a partial pixel after", 1, 1, &[10, 20, 30, 128, 0]), + ("opaque, a pixel short", 2, 1, &[10, 20, 30, 255]), + ("translucent, a pixel too many", 1, 1, &[10, 20, 30, 128, 1, 2, 3, 4]), + ("empty", 1, 1, &[]), + ("a size whose byte count overflows", u32::MAX, u32::MAX, &[10, 20, 30, 255]), + ]; + 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 8958688d2..a2c444bd6 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -6,7 +6,7 @@ //! Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript is [`Session::set_javascript`]; with it //! off, scripts don't run and AF calls keep working natively. -use pdfcraft_forms::{Field, FieldChange, FieldEvent, FieldKind, ScriptResult, Scripts}; +use pdfcraft_forms::{Field, FieldChange, FieldEvent, FieldKind, FieldProps, ScriptResult, Scripts}; pub use pdfcraft_js::{Limits, Outcome, Request}; use crate::{DocId, Edit, EditError, Session}; @@ -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() @@ -343,23 +376,75 @@ impl Session { let names = found.iter().map(|(_, c, _)| c.name.clone()).collect(); let mut edits = Vec::with_capacity(found.len()); for (page, c, turn) in found { - // A turned field keeps its place: the rectangle is already the one on the page. - let props = (turn != 0).then(|| pdfcraft_forms::FieldProps { rotation: Some((0, turn)), rect: Some((0, c.rect)), ..Default::default() }); let tall = if turn % 180 == 0 { c.rect[3] - c.rect[1] } else { c.rect[2] - c.rect[0] }; - edits.push(Edit::AddField { - page, - rect: c.rect, - kind: match c.kind { - pdfcraft_forms::detect::Kind::Text => pdfcraft_forms::NewField::Text { multiline: tall > 30.0 }, - pdfcraft_forms::detect::Kind::CheckBox => pdfcraft_forms::NewField::CheckBox, - }, - name: Some(c.name.clone()), - }); - if let Some(props) = props { - edits.push(Edit::SetFieldProps { name: c.name, props: Box::new(props) }); + let (kind, comb) = match c.kind { + pdfcraft_forms::detect::Kind::Text => (pdfcraft_forms::NewField::Text { multiline: tall > 30.0 }, None), + pdfcraft_forms::detect::Kind::CheckBox => (pdfcraft_forms::NewField::CheckBox, None), + pdfcraft_forms::detect::Kind::Comb { cells } => (pdfcraft_forms::NewField::Text { multiline: false }, Some(cells)), + }; + // 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)); + props.rect = Some((0, c.rect)); } + if let Some(cells) = comb { + props.max_len = Some(Some(cells)); + props.flags = vec![(pdfcraft_forms::flags::COMB, true)]; + } + edits.push(Edit::AddField { page, rect: c.rect, kind, name: Some(c.name.clone()) }); + 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 01f162ae7..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,20 +35,22 @@ mod scan_colours; mod scan_fields; pub mod signature_image; pub mod signature_scan; +mod stamp_preview; pub mod text_fonts; mod text_preview; -mod upright_image; pub mod word_count; 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; @@ -57,13 +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, 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 { .. } @@ -306,6 +330,10 @@ struct Editor { saved: Arc>, /// Passwords that open [`Self::saved`]. saved_keys: Keys, + /// [`Self::saved`] was never written where the document belongs: a new document (combine, + /// extract, create) or one restored from recovery. It stays unsaved until a save, whatever + /// undo brings back, or closing it would lose the whole document without asking. + unsaved_file: bool, undo: Vec, redo: Vec, keys: Keys, @@ -313,19 +341,23 @@ 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, } -/// One page's text blocks ([`pdfcraft_edit::text_blocks`]) and the unmodified document state they -/// were read from. Opening a paragraph and applying its edit both need them, and grouping a long -/// page is not cheap. Only an unmodified document is cached: its identity changes on every edit, -/// undo and save (each reopens the working file), so a stale entry never matches. +/// One page's text blocks ([`pdfcraft_edit::text_blocks`]) and the document state they were read +/// from. Opening a paragraph and applying its edit both need them, and grouping a long page is not +/// cheap. The state ([`pdfcraft_cos::ReadState`]) changes with every edit and save and returns +/// with undo, so a stale entry never matches, also after the document has been edited. #[derive(Default)] struct TextBlocksCache(std::sync::Mutex>); /// The document state, the page (0-based) and its text blocks. -type CachedTextBlocks = (pdfcraft_cos::SourceIdentity, usize, Arc>); +type CachedTextBlocks = (pdfcraft_cos::ReadState, usize, Arc>); impl Clone for TextBlocksCache { fn clone(&self) -> Self { @@ -337,20 +369,16 @@ impl Clone for TextBlocksCache { impl Editor { /// The text blocks on `page` (0-based), from the cache when the document hasn't changed. fn text_blocks(&self, page: usize) -> Result>, EditError> { - let source = (!self.cos.is_modified()).then(|| self.cos.source_identity()); - if let Some(source) = &source - && let Ok(cache) = self.text_blocks.0.lock() - && let Some((s, p, blocks)) = cache.as_ref() - && s == source + if let Ok(cache) = self.text_blocks.0.lock() + && let Some((state, p, blocks)) = cache.as_ref() && *p == page + && state.matches(&self.cos) { return Ok(blocks.clone()); } let blocks = Arc::new(pdfcraft_edit::text_blocks(&self.cos, page)?); - if let Some(source) = source - && let Ok(mut cache) = self.text_blocks.0.lock() - { - *cache = Some((source, page, blocks.clone())); + if let Ok(mut cache) = self.text_blocks.0.lock() { + *cache = Some((self.cos.read_state(), page, blocks.clone())); } Ok(blocks) } @@ -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() @@ -832,6 +866,11 @@ impl Document { self.editor.as_ref().and_then(|e| pdfcraft_organize::info(&e.cos, key)) } + /// The custom document properties as they are now (name, text), Document Properties ▸ Custom. + pub fn custom_info(&self) -> Vec<(String, String)> { + self.editor.as_ref().map(|e| pdfcraft_organize::custom_info(&e.cos)).unwrap_or_default() + } + /// The name shown on the tab and window: the document title when the document asks for it /// (Initial View ▸ Show: Document Title) and has a real one, else the file name. pub fn display_name(&self) -> String { @@ -872,32 +911,46 @@ fn is_placeholder_title(title: &str) -> bool { address || t == "untitled" } -/// What is displayed: the working file, plus (in memory only, never saved) the appearances -/// PdfCraft draws for comments that have none ([`pdfcraft_annot::with_missing_appearances`]). -/// The working file is unchanged, and so is everything that reads it (saving, signatures, …). +/// 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 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 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() && a.iter().zip(b).all(|(x, y)| (x.width - y.width).abs() < 0.01 && (x.height - y.height).abs() < 0.01 && x.rotation == y.rotation) } -fn display_bytes(editor: Option<&mut Editor>, bytes: &Arc>) -> Arc> { - let Some(display) = editor.and_then(|e| pdfcraft_annot::with_missing_appearances_cached(&e.cos, &mut e.appearances)) else { - return bytes.clone(); - }; - // Writing fails only as saving the document would; the page then shows as it always did. - write_incremental(&display, &SaveOptions::default()).map(Arc::new).unwrap_or_else(|_| bytes.clone()) -} - /// 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) }) @@ -906,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 @@ -978,6 +1049,27 @@ fn render_threads() -> usize { std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 8) - 1 } +/// Write `layers`' visibility into the default optional-content configuration of `cos` (a copy +/// made for printing): `/BaseState /ON`, `/ON` the visible layers, `/OFF` the hidden ones. Its +/// other entries (`/Order`, `/RBGroups`, `/AS`…) are kept. A document without `/OCProperties` +/// is left alone. +fn set_default_layer_state(cos: &mut pdfcraft_cos::Document, layers: &[Layer]) { + use pdfcraft_cos::{Dict, ObjRef, Object}; + let Some(root_ref) = cos.trailer().get(b"Root").and_then(Object::as_ref) else { return }; + let Some(mut root) = cos.get(root_ref).as_dict().cloned() else { return }; + let Some(properties) = root.get(b"OCProperties").cloned() else { return }; + let Some(mut properties) = cos.resolve(&properties).as_dict().cloned() else { return }; + let mut config = properties.get(b"D").and_then(|d| cos.resolve(d).as_dict().cloned()).unwrap_or_else(Dict::new); + let refs = + |visible: bool| Object::Array(layers.iter().filter(|l| l.visible == visible).map(|l| Object::Ref(ObjRef::new(l.id.0, l.id.1))).collect()); + config.set(b"BaseState".to_vec(), Object::name("ON")); + config.set(b"ON".to_vec(), refs(true)); + config.set(b"OFF".to_vec(), refs(false)); + properties.set(b"D".to_vec(), Object::Dict(config)); + root.set(b"OCProperties".to_vec(), Object::Dict(properties)); + cos.set(root_ref, Object::Dict(root)); +} + /// Render `doc` with the layer visibility in its `info.layers`. fn use_layer_choices(doc: &mut Document) { let overrides: Vec<(i32, i32, bool)> = doc.info.layers.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect(); @@ -1241,14 +1333,20 @@ pub enum Edit { pages: Vec, to: usize, }, + /// `pages` reversed among the positions they occupy. + ReversePages { + pages: Vec, + }, InsertBlankPage { at: usize, width: f64, height: f64, }, - /// Copies of `pages` inserted after the last of them. + /// Copies of `pages` inserted at `at` (an index in the page order as it is now), or after + /// the last of them when `at` is `None`. DuplicatePages { pages: Vec, + at: Option, }, /// Replace Pages: the content of `pages` is replaced by `src_pages` of another PDF; the /// pages' links, comments and bookmarks stay. @@ -1284,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, @@ -1398,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, @@ -1523,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 { @@ -1579,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, @@ -1771,6 +1890,7 @@ pub const EDIT_LABELS: &[&str] = &[ "Delete pages", "Move page", "Move pages", + "Reverse pages", "Insert blank page", "Duplicate page", "Duplicate pages", @@ -1837,6 +1957,7 @@ pub const EDIT_LABELS: &[&str] = &[ "Lock comment", "Unlock comment", "Move comment", + "Reshape comment", "Resize comment", "Change comment properties", "Fill in {name}", @@ -1847,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", @@ -1854,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", @@ -1901,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"). @@ -1910,8 +2034,9 @@ impl Edit { Edit::RotatePages { pages, .. } => plural("Rotate page", "Rotate pages", pages.len()), Edit::DeletePages { pages } => plural("Delete page", "Delete pages", pages.len()), Edit::MovePages { pages, .. } => plural("Move page", "Move pages", pages.len()), + Edit::ReversePages { .. } => Label::of("Reverse pages"), Edit::InsertBlankPage { .. } => Label::of("Insert blank page"), - Edit::DuplicatePages { pages } => plural("Duplicate page", "Duplicate pages", pages.len()), + Edit::DuplicatePages { pages, .. } => plural("Duplicate page", "Duplicate pages", pages.len()), Edit::ReplacePages { pages, .. } => plural("Replace page", "Replace pages", pages.len()), Edit::SetPageBox { pages, which: PageBox::Crop, .. } => plural("Crop page", "Crop pages", pages.len()), Edit::SetPageBox { .. } => Label::of("Set page boxes"), @@ -1960,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") @@ -1972,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"), @@ -1985,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"), @@ -1993,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", @@ -2082,6 +2210,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed Edit::RotatePages { .. } | Edit::DeletePages { .. } | Edit::MovePages { .. } + | Edit::ReversePages { .. } | Edit::InsertBlankPage { .. } | Edit::DuplicatePages { .. } | Edit::ReplacePages { .. } @@ -2114,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 { .. } @@ -2168,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 { .. } @@ -2180,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 { .. } @@ -2292,11 +2424,12 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Edit::RotatePages { pages, degrees } => pdfcraft_organize::rotate_pages(doc, pages, *degrees)?, Edit::DeletePages { pages } => pdfcraft_organize::delete_pages(doc, pages)?, Edit::MovePages { pages, to } => pdfcraft_organize::move_pages(doc, pages, *to)?, + Edit::ReversePages { pages } => pdfcraft_organize::reverse_pages(doc, pages)?, Edit::InsertBlankPage { at, width, height } => { pdfcraft_organize::insert_blank_page(doc, *at, *width, *height)?; } Edit::SetInfo { key, value } => pdfcraft_organize::set_info(doc, key, value)?, - Edit::DuplicatePages { pages } => pdfcraft_organize::duplicate_pages(doc, pages)?, + Edit::DuplicatePages { pages, at } => pdfcraft_organize::duplicate_pages(doc, pages, *at)?, Edit::ReplacePages { pages, name, bytes, src_pages } => { let src = open_source(name, bytes)?; pdfcraft_organize::replace_pages(doc, pages, &src, src_pages)?; @@ -2314,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)?, @@ -2368,13 +2501,17 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> pdfcraft_annot::erase_ink(doc, *page, *index, path, *radius, &cx.meta())?; } Edit::ReplaceText { page, quads, text, author, strike, caret } => { - pdfcraft_annot::add_text_replacement(doc, *page, quads, text, author, strike, caret, &cx.meta())?; + // One `/NM` each: the caret replies to the strikeout, and XFDF names that reply by + // the strikeout's `/NM` (#2666). + let (strike_meta, caret_meta) = (cx.meta(), cx.meta()); + pdfcraft_annot::add_text_replacement(doc, *page, quads, text, author, strike, caret, &strike_meta, &caret_meta)?; } Edit::MarkAnnotation { page, index, marked, author } => { pdfcraft_annot::set_marked(doc, *page, *index, *marked, author, &cx.meta())?; } 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())?; @@ -2415,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()); @@ -2452,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()))?; @@ -2482,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))?; @@ -2496,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, @@ -2704,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"), } } @@ -3206,8 +3360,8 @@ fn xfa_values_from_datasets(doc: &mut pdfcraft_cos::Document) -> Result Arc> { - Arc::new(pdfcraft_sign::pdf::list_cached(cos, bytes, trust, cache)) +fn signatures_of(cos: &pdfcraft_cos::Document, bytes: &[u8], trust: &TrustStore, cache: &pdfcraft_sign::DigestCache) -> Vec { + pdfcraft_sign::pdf::list_cached(cos, bytes, trust, cache) } impl Session { @@ -3337,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), @@ -3520,10 +3674,12 @@ impl Session { cos, saved, saved_keys: keys.clone(), + unsaved_file: false, undo: Vec::new(), redo: Vec::new(), keys, appearances: AppearanceCache::default(), + field_appearances: pdfcraft_forms::FieldAppearanceCache::default(), text_blocks: TextBlocksCache::default(), }), None, @@ -3532,6 +3688,8 @@ impl Session { Ok(Err(e)) => (None, Some(e.to_string())), Err(_) => (None, Some("the document structure could not be read for editing".into())), }; + let sig_cache = Arc::new(pdfcraft_sign::DigestCache::default()); + let signatures = editor.as_ref().map(|e| signatures_of(&e.cos, &bytes, trust, &sig_cache)).unwrap_or_default(); let display = display_bytes(editor.as_mut(), &bytes); let renderer = RenderPool::new(display.clone(), render_threads(), config.clone()); let (form, adopted) = editor @@ -3553,8 +3711,6 @@ impl Session { }; e.cos.note_repair(line); } - let sig_cache = Arc::new(pdfcraft_sign::DigestCache::default()); - let signatures = editor.as_ref().map(|e| signatures_of(&e.cos, &bytes, trust, &sig_cache)).unwrap_or_default(); Document { id: DocId(0), name, @@ -3571,7 +3727,7 @@ impl Session { form: Arc::new(form), marker_scan: OnceLock::new(), links: OnceLock::new(), - signatures, + signatures: Arc::new(signatures), trust: trust.clone(), sig_cache, editor, @@ -3599,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(); @@ -3718,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. @@ -3736,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); @@ -3785,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) @@ -3800,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) @@ -3817,7 +4013,7 @@ impl Session { /// file is clean; undo past a later save is not, because the restored snapshot's own base /// is an earlier revision. fn sync_dirty(doc: &mut Document) { - doc.dirty = doc.editor.as_ref().is_some_and(|e| e.cos.is_modified() || !Arc::ptr_eq(e.cos.bytes(), &e.saved)); + doc.dirty = doc.editor.as_ref().is_some_and(|e| e.unsaved_file || e.cos.is_modified() || !Arc::ptr_eq(e.cos.bytes(), &e.saved)); } /// Use the current state's password for viewing (protection edits change it). @@ -3867,9 +4063,13 @@ impl Session { doc.info.keywords = info("Keywords"); doc.info.creator = info("Creator"); 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() { - doc.signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); + let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); + doc.signatures = Arc::new(signatures); } doc.bytes = bytes; return Ok(()); @@ -3912,10 +4112,9 @@ impl Session { } doc.info.file_size = bytes.len(); doc.form = form; - if !doc.signatures.is_empty() { - doc.signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); - } + let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) }; doc.display = display_bytes(Some(editor), &bytes); + doc.signatures = Arc::new(signatures); doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); doc.bytes = bytes; Ok(()) @@ -3958,10 +4157,9 @@ impl Session { doc.marker_scan = OnceLock::new(); } doc.info.file_size = bytes.len(); - if !doc.signatures.is_empty() { - doc.signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); - } + let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) }; doc.display = display_bytes(Some(editor), &bytes); + doc.signatures = Arc::new(signatures); doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); doc.bytes = bytes; doc.fonts_stale = true; @@ -3982,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), @@ -4034,8 +4234,9 @@ impl Session { if invalidate_links { doc.links = OnceLock::new(); } - doc.signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); + let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); doc.display = display_bytes(Some(editor), &bytes); + doc.signatures = Arc::new(signatures); doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); doc.bytes = bytes; Ok(()) @@ -4043,11 +4244,16 @@ impl Session { /// The bytes to write for Save: an incremental update of the file as opened/last saved, /// with `/ModDate` stamped. Call `mark_saved` after writing them successfully. + /// + /// A file whose cross-reference data had to be rebuilt is written in full even when nothing + /// was edited: its bytes as opened are the damaged file, and this save is how the repair the + /// user was told about reaches the disk (#1937). Not a signed one: a full rewrite would + /// break its signatures, so with no edits it keeps its bytes, damage and all. pub fn save_bytes(&self, id: DocId) -> Result>, EditError> { let doc = self.get(id).ok_or(EditError::NoDocument)?; let Some(editor) = doc.editor.as_ref() else { return Ok(doc.bytes.clone()) }; let on_saved = Arc::ptr_eq(editor.cos.bytes(), &editor.saved); - if !editor.cos.is_modified() && on_saved { + if !editor.cos.is_modified() && on_saved && (!editor.cos.reconstructed() || doc.is_signed()) { return Ok(editor.saved.clone()); } let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; @@ -4102,6 +4308,7 @@ impl Session { pdfcraft_cos::Document::open_with_password(bytes.clone(), keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; editor.saved = bytes; editor.saved_keys = keys; + editor.unsaved_file = false; } if let Some(p) = path { doc.name = std::path::Path::new(&p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| p.clone()); @@ -4165,7 +4372,7 @@ impl Session { editor.undo.clear(); editor.redo.clear(); Self::adopt_keys(doc); - doc.dirty = false; + Self::sync_dirty(doc); doc.generation += 1; Self::refresh(doc).inspect_err(|_| Self::show_released(doc)) } @@ -4184,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; @@ -4290,7 +4506,7 @@ impl Session { } guard(|| match format { DataFormat::Xfdf => Ok(pdfcraft_xfdf::export_xfdf(&cos, comments, fields, &doc.name).into_bytes()), - DataFormat::Fdf => Ok(pdfcraft_xfdf::export_fdf(&cos, comments, fields, &doc.name)), + DataFormat::Fdf => pdfcraft_xfdf::export_fdf(&cos, comments, fields, &doc.name).map_err(|e| e.to_string()), DataFormat::Xml | DataFormat::Xdp if xfa => { pdfcraft_xfa::export_data(&cos, format == DataFormat::Xdp, &doc.name).map(String::into_bytes).map_err(|e| e.to_string()) } @@ -4326,12 +4542,18 @@ impl Session { }; return pdfcraft_print::impose(&pictures, &remapped).map_err(|e| EditError::Print(e.to_string())); } - let cos = match doc.editor.as_ref() { + let mut cos = match doc.editor.as_ref() { Some(e) => e.cos.clone(), None => { pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))? } }; + // #3046: the layers print as they are shown. Once the Layers panel (or a set-layer + // action) has changed one, the print copy's default configuration says which are on, so a + // hidden layer stays off on paper too; untouched, the document's own configuration prints. + if !doc.config.layers.is_empty() { + set_default_layer_state(&mut cos, &doc.info.layers); + } pdfcraft_print::impose(&cos, settings).map_err(|e| EditError::Print(e.to_string())) } @@ -4639,6 +4861,9 @@ impl Session { let id = self.open(name, None, bytes, None)?; if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) { d.dirty = true; + if let Some(e) = d.editor.as_mut() { + e.unsaved_file = true; + } // Unsaved work with no edit yet still needs its first recovery snapshot. d.generation += 1; } @@ -4666,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 { @@ -4691,6 +4921,9 @@ impl Session { if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) { d.path = path; d.dirty = true; + if let Some(e) = d.editor.as_mut() { + e.unsaved_file = true; + } d.generation += 1; d.snapshot_generation = d.generation; // its bytes are already in the recovery store } @@ -4723,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 { @@ -4754,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> { @@ -4767,14 +5022,20 @@ impl Session { &self.trust.certs } - /// Replace the trusted certificates and revalidate every open document's signatures. + /// Replace the trusted certificates, revalidate every open document's signatures, + /// and rebuild its display copy and renderer to match (working file untouched, + /// generation/dirty unchanged: trust alone is not an edit). pub fn set_trusted_certificates(&mut self, certs: Vec) { self.trust = Arc::new(TrustStore { certs }); for doc in &mut self.docs { doc.trust = self.trust.clone(); - if let Some(e) = doc.editor.as_ref() { - doc.signatures = signatures_of(&e.cos, &doc.bytes, &doc.trust, &doc.sig_cache); - } + let Some(editor) = doc.editor.as_mut() else { continue }; + // Keep verdict refresh and renderer refresh together. Viewer caches are + // invalidated by the UI trust path; trust alone changes no document artwork. + let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &doc.bytes, &doc.trust, &doc.sig_cache) }; + doc.display = display_bytes(Some(editor), &doc.bytes); + doc.signatures = Arc::new(signatures); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); } } @@ -4894,7 +5155,7 @@ fn embed_image( name: &str, bytes: &[u8], ) -> Result<(pdfcraft_cos::ObjRef, (f64, f64)), pdfcraft_create::CreateError> { - pdfcraft_create::image_xobject(doc, name, &upright_image::upright(bytes)) + pdfcraft_create::image_xobject(doc, name, bytes) } /// Bring a mark's picture into `doc`: a PDF page as a form XObject, or an image. diff --git a/crates/engine/src/links.rs b/crates/engine/src/links.rs index 5dd9aff5b..4902d72f5 100644 --- a/crates/engine/src/links.rs +++ b/crates/engine/src/links.rs @@ -137,7 +137,8 @@ pub enum RemoteDocumentPathError { Empty, TooLong, Hidden, - UrlOrNetwork, + /// A URL, a network or device path, or a path that climbs above its root. + NotLocal(craft_docpath::Refused), MissingSourcePath, /// The target doesn't end in `.pdf`. NotPdf, @@ -155,7 +156,7 @@ impl std::fmt::Display for RemoteDocumentPathError { Self::Empty => f.write_str("the file specification is empty"), Self::TooLong => f.write_str("the file specification is too long"), Self::Hidden => f.write_str("the file specification contains hidden or control characters"), - Self::UrlOrNetwork => f.write_str("only local document paths are supported"), + Self::NotLocal(r) => write!(f, "only local document paths are supported: {r}"), Self::MissingSourcePath => f.write_str("a relative link needs a source document path"), Self::NotPdf => f.write_str("the linked file is not a .pdf file"), Self::NotAFile => f.write_str("the linked path is not a regular file"), @@ -173,7 +174,9 @@ pub fn remote_document_path_is_absolute(raw: &str) -> bool { } /// Resolve a PDF remote-Go-To file specification to a local path without opening it. -/// Relative paths are based on the source document; an in-memory source has no fallback to CWD. +/// Relative paths are based on the source document, which should be an absolute path; an +/// in-memory source has no fallback to CWD. URLs (including `file:`), network and device paths are +/// refused ([`craft_docpath::resolve`]). pub fn resolve_remote_document_path(source: Option<&Path>, raw: &str) -> Result { if raw.is_empty() || raw.trim().is_empty() { return Err(RemoteDocumentPathError::Empty); @@ -184,25 +187,22 @@ pub fn resolve_remote_document_path(source: Option<&Path>, raw: &str) -> Result< if raw.chars().any(hides_text) { return Err(RemoteDocumentPathError::Hidden); } - if raw.starts_with("//") || raw.starts_with("\\\\") || raw.starts_with("/\\") || raw.starts_with("\\/") { - return Err(RemoteDocumentPathError::UrlOrNetwork); - } - let path = Path::new(raw); - let drive_absolute = remote_document_path_is_absolute(raw); + // A GoToR file specification is a path, not a URL: any scheme of two or more characters is + // refused (one letter is a drive), `file:` and names such as `custom:target.pdf` included. if let Some((scheme, _)) = raw.split_once(':') { - let valid_scheme = scheme.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) + let is_scheme = scheme.len() > 1 + && scheme.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) && scheme.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')); - let windows_drive = scheme.len() == 1 && drive_absolute; - if valid_scheme && !windows_drive { - return Err(RemoteDocumentPathError::UrlOrNetwork); + if is_scheme { + return Err(RemoteDocumentPathError::NotLocal(craft_docpath::Refused::Url)); } } - if drive_absolute { - return Ok(path.to_path_buf()); + let path = craft_docpath::resolve(raw, source).map_err(RemoteDocumentPathError::NotLocal)?; + // A relative result would be read against the working directory. + if !path.is_absolute() { + return Err(RemoteDocumentPathError::MissingSourcePath); } - let source = source.ok_or(RemoteDocumentPathError::MissingSourcePath)?; - let parent = source.parent().ok_or(RemoteDocumentPathError::MissingSourcePath)?; - Ok(parent.join(path)) + Ok(path) } /// Largest linked PDF a GoToR link may open (1 GiB, the largest size the large-file work measures). @@ -503,23 +503,56 @@ mod tests { use super::{RemoteDocumentPathError as E, resolve_remote_document_path as resolve}; use std::path::Path; - let source = Path::new("/docs/chapter/source.pdf"); - assert_eq!(resolve(Some(source), "../target.pdf").as_deref(), Ok(Path::new("/docs/chapter/../target.pdf"))); + let source = std::env::temp_dir().join("chapter").join("source.pdf"); + let source = source.as_path(); + let folder = source.parent().unwrap(); + assert_eq!(resolve(Some(source), "target.pdf"), Ok(folder.join("target.pdf"))); + assert_eq!(resolve(Some(source), "../target.pdf").as_deref(), Ok(folder.parent().unwrap().join("target.pdf").as_path())); + #[cfg(unix)] assert_eq!(resolve(Some(source), "/manual/target.pdf").as_deref(), Ok(Path::new("/manual/target.pdf"))); assert_eq!(resolve(None, "target.pdf"), Err(E::MissingSourcePath)); + assert_eq!(resolve(Some(Path::new("source.pdf")), "target.pdf"), Err(E::MissingSourcePath), "a relative source is not used"); assert_eq!(resolve(Some(source), ""), Err(E::Empty)); assert_eq!(resolve(Some(source), " "), Err(E::Empty)); - for url in ["file:///tmp/a.pdf", "https://example.org/a.pdf", "smb://host/share/a.pdf", "ftp://host/a.pdf", "custom:target.pdf"] { - assert_eq!(resolve(Some(source), url), Err(E::UrlOrNetwork), "{url}"); - } - for network_path in ["//server/share/a.pdf", "\\\\server\\share\\a.pdf", "\\\\?\\C:\\a.pdf", "\\\\.\\device\\a.pdf", "/\\server/share/a.pdf"] + for url in + ["file:///tmp/a.pdf", "file://h/s/a.pdf", "https://example.org/a.pdf", "smb://host/share/a.pdf", "ftp://host/a.pdf", "custom:target.pdf"] { - assert_eq!(resolve(Some(source), network_path), Err(E::UrlOrNetwork), "{network_path:?}"); + assert!(matches!(resolve(Some(source), url), Err(E::NotLocal(_))), "{url}"); } assert_eq!(resolve(Some(source), "folder/hidden\u{200B}.pdf"), Err(E::Hidden)); assert_eq!(resolve(Some(source), &"a".repeat(super::MAX_REMOTE_DOCUMENT_PATH_BYTES + 1)), Err(E::TooLong)); } + #[test] + fn remote_document_paths_to_other_computers_and_devices_are_refused() { + use super::{RemoteDocumentPathError as E, resolve_remote_document_path as resolve}; + + let source = std::env::temp_dir().join("chapter").join("source.pdf"); + let source = source.as_path(); + // Refused before any filesystem access: none of these exist, and a stat of a network path + // would contact its host. + for not_local in [ + "//server/share/a.pdf", + "\\\\server\\share\\a.pdf", + "\\\\h\\s\\a.pdf", + "\\\\?\\UNC\\h\\s\\a.pdf", + "\\??\\UNC\\h\\s\\a.pdf", + "/??/x", + "\\??\\C:\\a.pdf", + "\\\\.\\device\\a.pdf", + "/\\server/share/a.pdf", + "NUL.pdf", + ] { + assert!(matches!(resolve(Some(source), not_local), Err(E::NotLocal(_))), "{not_local:?}"); + } + // Linux reads `//tmp/x` as `/tmp/x`, but on Windows it names a share. + #[cfg(unix)] + { + let doubled = format!("/{}", source.with_file_name("a.pdf").display()); + assert!(matches!(resolve(Some(source), &doubled), Err(E::NotLocal(_))), "{doubled}"); + } + } + #[test] fn urls_follow_the_artcraft_scheme() { assert_eq!(super::APP_PAGE, format!("{}/apps/{}", super::WEBSITE, super::APP)); 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 ed90eea41..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] @@ -519,6 +572,63 @@ fn save_is_incremental_stamps_mod_date_and_rebases() { assert_eq!(reopened.revisions().len(), 3); } +/// #1937: a file whose cross-reference data had to be rebuilt saved as its original bytes when +/// nothing was edited, so the damage Document Properties reported as repaired was still in the +/// file. Save writes the rebuilt file in full; reopened, it needs no repair. +#[test] +fn a_repaired_file_is_rewritten_by_save_even_without_edits() { + let good = fixture(2); + // Every offset in the table is off by the inserted line: the chain reads but leads nowhere. + let mut shifted = b"%PDF-1.7\n%shifted\n".to_vec(); + shifted.extend_from_slice(&good[b"%PDF-1.7\n".len()..]); + // No cross-reference table at all. + let mut lost = good.clone(); + lost.truncate(good.windows(9).rposition(|w| w == b"startxref").unwrap()); + lost.extend_from_slice(b"startxref\n999999\n%%EOF\n"); + for (name, damaged) in [("shifted", shifted), ("lost", lost)] { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("broken.pdf", Some("/tmp/broken.pdf".into()), Arc::new(damaged.clone()), None).unwrap(); + let doc = s.get(id).unwrap(); + assert!(doc.repair_log().iter().any(|l| l.contains("rebuilt cross-reference index")), "{name}: {:?}", doc.repair_log()); + assert_eq!(doc.info.pages.len(), 2, "{name}"); + assert!(!doc.dirty, "{name}: a repair is not an edit"); + let saved = s.save_bytes(id).unwrap(); + assert_ne!(&saved[..], &damaged[..], "{name}: the damaged bytes are not what Save writes"); + let reopened = pdfcraft_cos::Document::open(saved.clone()).unwrap(); + assert!(reopened.repair_log().is_empty(), "{name}: {:?}", reopened.repair_log()); + assert_eq!(reopened.revisions().len(), 1, "{name}"); + s.mark_saved(id, saved.clone(), None).unwrap(); + let doc = s.get(id).unwrap(); + assert!(doc.repair_log().is_empty(), "{name}: {:?}", doc.repair_log()); + assert_eq!(doc.info.pages.len(), 2, "{name}"); + // Sound now: saving again without edits writes the same bytes. + assert_eq!(s.save_bytes(id).unwrap(), saved, "{name}"); + } +} + +/// A damaged file that is signed keeps its bytes on a Save without edits: rewriting it in full +/// to put the repair on disk would invalidate its signatures without a word. +#[test] +fn a_signed_repaired_file_keeps_its_bytes_when_saved_without_edits() { + let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(); + let digital_id = sign::pkcs12::open(&p12, "test").unwrap(); + // After the test certificate's start date (2026-10-02). + let mut s = Session::new().with_clock(|| 1_800_000_000); + let id = s.open("fixture.pdf", None, Arc::new(fixture(1)), None).unwrap(); + let opts = SignOptions { page: 0, rect: Some([20.0, 20.0, 180.0, 60.0]), ..SignOptions::default() }; + let signed = s.sign(id, &digital_id, opts).unwrap(); + // A trailing `startxref` that points nowhere: the signed revision's bytes are untouched, but + // the file's cross-reference data has to be rebuilt. + let mut damaged = signed.to_vec(); + damaged.extend_from_slice(b"\nstartxref\n999999\n%%EOF\n"); + let mut t = Session::new().with_clock(|| 1_800_000_000); + let tid = t.open("damaged.pdf", Some("/tmp/damaged.pdf".into()), Arc::new(damaged.clone()), None).unwrap(); + let doc = t.get(tid).unwrap(); + assert!(doc.repair_log().iter().any(|l| l.contains("rebuilt cross-reference index")), "{:?}", doc.repair_log()); + assert!(doc.is_signed()); + assert_eq!(&t.save_bytes(tid).unwrap()[..], &damaged[..], "a signed file is not rewritten without edits"); +} + #[test] fn undo_still_works_after_save() { let (mut s, id) = session_with(2); @@ -628,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)]; @@ -704,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); @@ -813,7 +976,7 @@ fn form_with_field(name: &str) -> Vec { fn duplicating_a_page_with_a_field_keeps_the_field_name() { let mut s = Session::new().with_clock(|| 1_700_000_000); let id = s.open("a.pdf", None, Arc::new(form_with_field("Text1")), None).unwrap(); - s.apply(id, Edit::DuplicatePages { pages: vec![0] }).unwrap(); + s.apply(id, Edit::DuplicatePages { pages: vec![0], at: None }).unwrap(); let doc = s.get(id).unwrap(); assert_eq!(doc.info.pages.len(), 2); assert!(doc.form.iter().all(|f| f.name == "Text1"), "{:?}", doc.form.iter().map(|f| &f.name).collect::>()); @@ -1153,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, @@ -1186,6 +1367,22 @@ fn the_permissions_password_unlocks_an_open_document() { assert!(pdfcraft_cos::Document::open(plain).unwrap().security().is_none(), "security removed"); } +#[test] +fn remove_security_from_a_file_with_its_encrypt_dictionary_in_the_trailer() { + // MuPDF writes /Encrypt inline in the trailer rather than as an object (#2661). + let mut doc = pdfcraft_cos::Document::open_with_password(protected("user", "owner", -1), Some("owner")).unwrap(); + let r = doc.trailer().reference(b"Encrypt").unwrap(); + let dict = doc.get(r).as_dict().cloned().unwrap(); + doc.trailer_mut().set(b"Encrypt".to_vec(), pdfcraft_cos::Object::Dict(dict)); + let bytes = Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()); + let mut s = Session::new(); + let id = s.open("m.pdf", None, bytes, Some("owner")).unwrap(); + s.apply(id, Edit::RemoveProtection).unwrap(); + assert!(!s.get(id).unwrap().info.encrypted); + let plain = s.save_bytes(id).unwrap(); + assert!(pdfcraft_cos::Document::open(plain).unwrap().security().is_none(), "security removed"); +} + #[test] fn unlocking_needs_the_permissions_password_not_the_open_password() { let mut s = Session::new(); @@ -1230,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"); @@ -1298,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(); @@ -1867,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 { @@ -1987,6 +2255,59 @@ fn oversized_redaction_overlay_preserves_the_document_and_undo() { assert_eq!(page_texts(&s, id), ["Page 1"]); } +/// #3046: a layer hidden in the Layers panel stays off on paper, as it is on screen; with the +/// layers untouched, the document's own configuration prints. +#[test] +fn printing_keeps_hidden_layers_hidden() { + let content = b"/OC /A BDC 1 0 0 rg 10 10 80 80 re f EMC /OC /B BDC 0 1 0 rg 110 10 80 80 re f EMC"; + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R] /D << /Order [5 0 R 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 200 100] /Contents 4 0 R /Resources << /Properties << /A 5 0 R /B 6 0 R >> >> >>".to_vec(), + [format!("<< /Length {} >>\nstream\n", content.len()).as_bytes(), content, b"\nendstream"].concat(), + b"<< /Type /OCG /Name (Layer A) >>".to_vec(), + b"<< /Type /OCG /Name (Layer B) >>".to_vec(), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + pdf.extend_from_slice(o); + pdf.extend_from_slice(b"\nendobj\n"); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + + // Red and green pixels on the first printed sheet. + let printed = |s: &Session, id: DocId| { + let bytes = s.print_pdf(id, &print::Settings { pages: vec![0], ..Default::default() }).unwrap(); + let mut out = Session::new(); + let printed = out.open("print.pdf", None, Arc::new(bytes), None).unwrap(); + let page = render_page(&out, printed); + let px = page.rgba.as_chunks::<4>().0; + let red = px.iter().filter(|p| p[0] > 200 && p[1] < 60 && p[2] < 60).count(); + let green = px.iter().filter(|p| p[1] > 200 && p[0] < 60 && p[2] < 60).count(); + (red, green) + }; + let mut s = Session::new(); + let id = s.open("layers.pdf", None, Arc::new(pdf), None).unwrap(); + let (red, green) = printed(&s, id); + assert!(red > 100 && green > 100, "untouched layers both print: {red} red, {green} green"); + let a = s.get(id).unwrap().info.layers.iter().position(|l| l.name == "Layer A").unwrap(); + assert!(s.set_layer_visible(id, a, false)); + let (red, green) = printed(&s, id); + assert_eq!(red, 0, "Layer A was hidden, so it doesn't print"); + assert!(green > 100, "Layer B still prints: {green} green"); + assert!(s.set_layer_visible(id, a, true)); + let (red, _) = printed(&s, id); + assert!(red > 100, "shown again, Layer A prints again: {red} red"); +} + #[test] fn printing_lays_out_sheets_that_render() { let (s, id) = session_with(5); @@ -2057,7 +2378,7 @@ fn crop_and_duplicate_pages_show_in_the_viewer() { assert_eq!(d.info.pages[0].crop, [10.0, 20.0, 170.0, 260.0]); assert_eq!((d.info.pages[0].width, d.info.pages[0].height), (160.0, 240.0), "the viewer shows the cropped size"); assert_eq!(d.page_boxes()[1][1], [0.0, 0.0, 200.0, 300.0]); - s.apply(id, Edit::DuplicatePages { pages: vec![1] }).unwrap(); + s.apply(id, Edit::DuplicatePages { pages: vec![1], at: None }).unwrap(); assert_eq!(page_texts(&s, id), ["Page 1", "Page 2", "Page 2"]); assert_eq!(s.get(id).unwrap().can_undo(), Some("Duplicate page")); } @@ -2334,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); @@ -2448,6 +2823,48 @@ fn pages_export_as_png_jpeg_and_tiff() { let tif = ex.image(0, 144.0, ImageFormat::Tiff).unwrap(); let decoded = image::load_from_memory_with_format(&tif, image::ImageFormat::Tiff).unwrap(); assert_eq!((decoded.width(), decoded.height()), (400, 600)); + let mut tags = tiff::decoder::Decoder::new(std::io::Cursor::new(&tif)).unwrap(); + let compression = tags.get_tag_u32(tiff::tags::Tag::Compression).unwrap(); + let predictor = tags.get_tag_u32(tiff::tags::Tag::Predictor).unwrap(); + assert_eq!((compression, predictor), (5, 1), "LZW, without a predictor"); +} + +/// A TIFF's resolution unit (2 = inch) and its horizontal and vertical resolution. +fn tiff_resolution(bytes: &[u8]) -> (u32, f64, f64) { + use tiff::{decoder::ifd::Value, tags::Tag}; + let mut d = tiff::decoder::Decoder::new(std::io::Cursor::new(bytes)).unwrap(); + let mut res = |tag| match d.get_tag(tag).unwrap() { + Value::Rational(n, d) => f64::from(n) / f64::from(d), + v => panic!("{tag:?} is {v:?}"), + }; + let (x, y) = (res(Tag::XResolution), res(Tag::YResolution)); + (d.get_tag_u32(Tag::ResolutionUnit).unwrap(), x, y) +} + +/// An exported TIFF or JPEG says the resolution it was drawn at, so other programs show and print it +/// at the page's size. A TIFF said 1 × 1 with no unit, and a JPEG said nothing. +#[test] +fn exported_pages_record_the_resolution_they_were_drawn_at() { + use crate::export::{Exporter, ImageFormat}; + let (s, id) = session_with(1); + let mut ex = Exporter::new(s.get(id).unwrap()); + assert_eq!(tiff_resolution(&ex.image(0, 144.0, ImageFormat::Tiff).unwrap()), (2, 144.0, 144.0)); + let jpg = ex.image(0, 144.0, ImageFormat::Jpeg { quality: 85 }).unwrap(); + assert_eq!(&jpg[6..11], b"JFIF\0"); + assert_eq!(jpg[13..18], [1, 0, 144, 0, 144], "JFIF density in dots per inch"); + // pHYs counts pixels per metre: 72 dpi would read back as 72.009, and a page made from the PNG + // (doc_create from images) would come out 0.01% small. + let png = ex.image(0, 72.0, ImageFormat::Png).unwrap(); + assert!(png::Decoder::new(std::io::Cursor::new(png)).read_info().unwrap().info().pixel_dims.is_none(), "a PNG states no resolution"); + + // A page too large for the renderer at the asked resolution says the one it was drawn at. + let mut s = Session::new().with_clock(|| 1_700_000_000); + let bytes = s.create_blank(14_400.0, 792.0, 1).unwrap(); + let id = s.open("wide.pdf", None, bytes, None).unwrap(); + let mut ex = Exporter::new(s.get(id).unwrap()); + let used = ex.dpi_used(0, 600.0); + let (unit, x, y) = tiff_resolution(&ex.image(0, 600.0, ImageFormat::Tiff).unwrap()); + assert!(unit == 2 && (x - used).abs() < 0.01 && (y - used).abs() < 0.01, "{x} × {y}, drawn at {used}"); } #[test] @@ -2541,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); @@ -2567,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; @@ -2763,6 +3221,400 @@ fn signing_saving_trusting_and_commenting_afterwards() { assert!(!s.get(id).unwrap().dirty && s.get(id).unwrap().can_undo().map(str::to_owned) == before); } +#[test] +fn validated_signatures_without_layers_keep_their_appearance() { + let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(); + let digital_id = sign::pkcs12::open(&p12, "test").unwrap(); + // After the test certificate's start date (2026-10-02). + let mut s = Session::new().with_clock(|| 1_800_000_000); + let id = s.open("fixture.pdf", None, Arc::new(fixture(1)), None).unwrap(); + let opts = SignOptions { page: 0, rect: Some([20.0, 20.0, 180.0, 60.0]), ..SignOptions::default() }; + let signed = s.sign(id, &digital_id, opts).unwrap(); + s.mark_signed(id, signed, None).unwrap(); + s.set_trusted_certificates(vec![digital_id.certificate.clone()]); + let doc = s.get(id).unwrap(); + let sig = &doc.signatures[0]; + assert_eq!(sig.status, SignatureStatus::Valid); + // Our own flat appearance has no validation layers: nothing is substituted, and + // the display copy carries no generated caption. + let display = doc.display.clone(); + assert!(!display.windows(b"Signature valid".len()).any(|w| w == b"Signature valid")); + // With nothing to draw, the display copy aliases the file exactly as before. + assert!(Arc::ptr_eq(&doc.display, &doc.bytes)); +} + +#[test] +fn signed_pages_render_trust_save_and_reopen() { + let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(); + let digital_id = sign::pkcs12::open(&p12, "test").unwrap(); + // After the test certificate's start date (2026-10-02). + let mut s = Session::new().with_clock(|| 1_800_000_000); + let id = s.open("fixture.pdf", None, Arc::new(fixture(1)), None).unwrap(); + let opts = SignOptions { page: 0, rect: Some([20.0, 20.0, 180.0, 60.0]), ..SignOptions::default() }; + let signed = s.sign(id, &digital_id, opts).unwrap(); + // The signed bytes open elsewhere too: validity is in the file, not the session. + let mut t = Session::new().with_clock(|| 1_800_000_000); + let tid = t.open("signed.pdf", None, signed.clone(), None).unwrap(); + assert!(t.get(tid).unwrap().signatures.iter().any(|x| x.signed && x.status == SignatureStatus::Unknown)); + s.mark_signed(id, signed, None).unwrap(); + // The signed page renders, from the file and from the display copy alike. + for bytes in [s.get(id).unwrap().bytes.clone(), s.get(id).unwrap().display.clone()] { + let mut renderer = pdfcraft_render::PageRenderer::new(bytes, s.get(id).unwrap().config.clone()); + let rendered = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(rendered.error.is_none(), "{:?}", rendered.error); + assert!(rendered.width > 0 && rendered.height > 0); + } + // Trusting the signer makes it valid; saving keeps it valid across sessions. + s.set_trusted_certificates(vec![digital_id.certificate.clone()]); + assert_eq!(s.get(id).unwrap().signatures[0].status, SignatureStatus::Valid); + let saved = s.save_bytes(id).unwrap(); + let mut u = Session::new().with_clock(|| 1_800_000_000); + u.set_trusted_certificates(vec![digital_id.certificate.clone()]); + let uid = u.open("saved.pdf", None, saved.clone(), None).unwrap(); + assert_eq!(u.get(uid).unwrap().signatures[0].status, SignatureStatus::Valid); +} + +#[test] +fn signed_documents_round_trip_behind_a_user_password() { + let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(); + let digital_id = sign::pkcs12::open(&p12, "test").unwrap(); + // After the test certificate's start date (2026-10-02). + let mut doc = pdfcraft_cos::Document::open(Arc::new(fixture(1))).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes128, + user_password: "user", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [7; 32], + }) + .unwrap(); + let encrypted = Arc::new(pdfcraft_cos::write_full(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap()); + // Wrong passwords refuse; the right one signs, validates, saves and reopens. + let mut e = Session::new().with_clock(|| 1_800_000_000); + assert!(e.open("enc.pdf", None, encrypted.clone(), Some("wrong")).is_err(), "wrong passwords refuse"); + let eid = e.open("enc.pdf", None, encrypted, Some("user")).unwrap(); + let signed = e.sign(eid, &digital_id, SignOptions { page: 0, rect: Some([20.0, 20.0, 180.0, 60.0]), ..SignOptions::default() }).unwrap(); + e.mark_signed(eid, signed, None).unwrap(); + e.set_trusted_certificates(vec![digital_id.certificate.clone()]); + assert_eq!(e.get(eid).unwrap().signatures[0].status, SignatureStatus::Valid); + let saved = e.save_bytes(eid).unwrap(); + let mut f = Session::new().with_clock(|| 1_800_000_000); + f.set_trusted_certificates(vec![digital_id.certificate.clone()]); + assert!(f.open("enc-saved.pdf", None, saved.clone(), Some("wrong")).is_err()); + let fid = f.open("enc-saved.pdf", None, saved.clone(), Some("user")).unwrap(); + assert_eq!(f.get(fid).unwrap().signatures[0].status, SignatureStatus::Valid); +} + +/// ByteRange placeholder values: fixed width, patched after writing. +const LAYERED_BR_MARK: [i64; 3] = [1_111_111_111, 2_222_222_222, 3_333_333_333]; + +/// A one-page file whose signature field carries a layered appearance on the +/// page (background, words, vector glyph, border, unverified caption): object +/// numbers 1 catalog, 2 pages, 3 page, 4 field, 5 AcroForm, 6 /N, 7 frame, +/// 8 background, 9 words, 10 glyph, 11 caption, 12 border, 13 Helvetica, +/// 14 page content. The field is unsigned: `sign_prepared` adds the value. +fn layered_signable_fixture() -> Vec { + fn stream(bbox: &str, resources: &str, ops: &str) -> String { + format!("<< /Type /XObject /Subtype /Form /BBox {bbox} /Length {} /Resources {resources} >>\nstream\n{ops}\nendstream", ops.len()) + } + let helv = "<< /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >>"; + let objs = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 5 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 14 0 R /Resources << /Font << /F1 13 0 R >> >> /Annots [4 0 R] >>" + .to_string(), + "<< /FT /Sig /T (Signature1) /Subtype /Widget /Rect [10 10 110 60] /P 3 0 R /AP << /N 6 0 R >> >>".to_string(), + "<< /Fields [4 0 R] /SigFlags 3 >>".to_string(), + stream("[0 0 50 30]", "<< /XObject << /frame 7 0 R >> >>", "q /frame Do Q"), + stream( + "[0 0 50 30]", + "<< /XObject << /bg 8 0 R /words 9 0 R /glyph 10 0 R /bd 12 0 R /cap 11 0 R >> >>", + "q /bg Do /words Do /glyph Do /bd Do /cap Do Q", + ), + stream("[0 0 100 100]", "<< >>", "0 g 0 0 100 100 re f"), + stream("[0 0 100 100]", helv, "BT /F1 10 Tf 5 50 Td (Signed by Test) Tj ET"), + stream("[0 0 50 30]", "<< >>", "0.8 0.1 0.1 RG 3 w 5 5 m 20 5 l 12 22 l h S"), + stream("[0 21 50 30]", helv, "BT /F1 9 Tf 2 22 Td (Signature Not Verified) Tj ET"), + stream("[0 0 100 100]", "<< >>", "0 g 0 0 100 100 re f"), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(), + "<< /Length 44 >>\nstream\nBT /F1 14 Tf 20 150 Td (Contract text) Tj ET\nendstream".to_string(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (k, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", k + 1).as_bytes()); + } + let start = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n", objs.len() + 1).as_bytes()); + out.extend_from_slice(b"0000000000 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 {} /Root 1 0 R >>\nstartxref\n{start}\n%%EOF", objs.len() + 1).as_bytes()); + out +} + +/// Whether `haystack` holds `needle`. +fn bytes_has(haystack: &[u8], needle: &[u8]) -> bool { + !needle.is_empty() && haystack.windows(needle.len()).any(|w| w == needle) +} + +/// Sign `doc`, whose field already carries its layered appearance, without +/// touching that appearance: reserve `/ByteRange` and `/Contents`, hash the +/// ranges and embed a detached CMS over them. The signed bytes keep the +/// prepared appearance, so they validate exactly like a product signer's file. +fn sign_prepared(doc: &mut pdfcraft_cos::Document, id: &sign::pkcs12::DigitalId, date: &str) -> Vec { + use pdfcraft_cos::{Dict, Object, PdfString}; + let reserve = 8192 + id.certificate.raw.len() + id.chain.iter().map(|c| c.raw.len()).sum::(); + let mut v = Dict::new(); + v.set(b"Type".to_vec(), Object::name("Sig")); + v.set(b"Filter".to_vec(), Object::name("Adobe.PPKLite")); + v.set(b"SubFilter".to_vec(), Object::name("ETSI.CAdES.detached")); + v.set(b"ByteRange".to_vec(), Object::Array([0].iter().chain(LAYERED_BR_MARK.iter()).map(|n| Object::Int(*n)).collect())); + v.set(b"Contents".to_vec(), Object::String(PdfString { bytes: vec![0; reserve], hex: true })); + v.set(b"M".to_vec(), PdfString::literal(date.as_bytes().to_vec())); + let sig = doc.add(Object::Dict(v)); + // The field by name, never by number: a full save (encryption, repairs) is + // free to renumber objects, and a hardcoded reference would stamp the value + // onto whatever moves into that slot. + let root = doc.root().expect("catalog"); + let af = doc.get(root).as_dict().expect("catalog dict").get(b"AcroForm").cloned().expect("AcroForm"); + let fields = doc.resolve(&af).as_dict().expect("AcroForm dict").get(b"Fields").cloned().expect("Fields"); + let field = doc.resolve(&fields).as_array().expect("Fields array").iter().filter_map(|o| o.as_ref()).next().expect("signature field"); + doc.update_dict(field, |d| d.set(b"V".to_vec(), Object::Ref(sig))).unwrap(); + let save = pdfcraft_cos::SaveOptions { object_streams: false, ..pdfcraft_cos::SaveOptions::default() }; + let mut out = pdfcraft_cos::write_incremental(doc, &save).unwrap(); + let mark = format!("0 {} {} {}", LAYERED_BR_MARK[0], LAYERED_BR_MARK[1], LAYERED_BR_MARK[2]); + let br = out.windows(mark.len()).position(|w| w == mark.as_bytes()).expect("ByteRange placeholder"); + let start = out[..br].windows(3).rposition(|w| w == b"obj").expect("signature object"); + let end = br + out[br..].windows(6).position(|w| w == b"endobj").expect("signature end"); + let mut zeros = vec![b'<']; + zeros.extend(std::iter::repeat_n(b'0', reserve * 2)); + zeros.push(b'>'); + let region = &out[start..end]; + let c = region.windows(zeros.len()).position(|w| w == zeros.as_slice()).expect("Contents placeholder"); + let (cstart, cend) = (start + c, start + c + zeros.len()); + let mut br_text = format!("0 {cstart} {cend} {}", out.len() - cend).into_bytes(); + assert!(br_text.len() <= mark.len(), "ByteRange patch fits its placeholder"); + br_text.resize(mark.len(), b' '); + out[br..br + mark.len()].copy_from_slice(&br_text); + let alg = id.key.preferred_digest(); + let digest = alg.digest(&[&out[..cstart], &out[cend..]]); + let cms = sign::cms::sign_detached(&id.key, &id.certificate, &id.chain, alg, &digest).unwrap(); + assert!(cms.len() <= reserve, "signature fits its placeholder"); + let hex: Vec = cms.iter().flat_map(|b| format!("{b:02X}").into_bytes()).collect(); + out[cstart + 1..cstart + 1 + hex.len()].copy_from_slice(&hex); + out +} + +/// Replace one synthetic appearance stream while retaining its resource dictionary. +fn signature_layer_ops(doc: &mut pdfcraft_cos::Document, number: u32, ops: &[u8]) { + let r = pdfcraft_cos::ObjRef::new(number, 0); + let dict = doc.resolve(&pdfcraft_cos::Object::Ref(r)).as_dict().unwrap().clone(); + doc.set(r, pdfcraft_cos::Object::Stream(pdfcraft_cos::Stream::from_raw(dict, ops.to_vec()))); +} + +fn printed_pixels(bytes: Vec) -> (u32, u32, Vec) { + let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(bytes), Default::default()); + let r = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(r.error.is_none(), "{:?}", r.error); + (r.width, r.height, r.rgba.to_vec()) +} + +/// Validation belongs to viewer chrome, not to document artwork or export sources. +fn assert_signature_artwork_survives_trust(mut cos: pdfcraft_cos::Document, expected_text: &[&str]) { + let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(); + let identity = sign::pkcs12::open(&p12, "test").unwrap(); + let anchor = identity.chain.first().cloned().unwrap_or_else(|| identity.certificate.clone()); + let signed = Arc::new(sign_prepared(&mut cos, &identity, "D:20261002120000+01'00'")); + let mut session = Session::new().with_clock(|| 1_800_000_000); + let id = session.open("artwork.pdf", None, signed.clone(), None).unwrap(); + let original_display = session.get(id).unwrap().display.clone(); + let mut exporter = export::Exporter::new(session.get(id).unwrap()); + let original_text = exporter.text_of(&[0]).unwrap(); + let original_png = exporter.png(0, 72.0).unwrap(); + for text in expected_text { + assert!(original_text.contains(text), "fixture text {text:?} must be exported: {original_text:?}"); + } + let mut settings = print::Settings { pages: vec![0], paper: (200.0, 200.0), ..Default::default() }; + let original_vector_print = printed_pixels(session.print_pdf(id, &settings).unwrap()); + settings.raster = Some(print::Raster { dpi: 72.0, grayscale: false }); + let original_raster_print = printed_pixels(session.print_pdf(id, &settings).unwrap()); + session.set_trusted_certificates(vec![anchor.clone()]); + let doc = session.get(id).unwrap(); + assert_eq!(doc.signatures[0].status, SignatureStatus::Valid); + assert!(doc.display == original_display, "trust must preserve all original appearance bytes"); + let mut exporter = export::Exporter::new(doc); + assert_eq!(exporter.text_of(&[0]).unwrap(), original_text, "text exports retain signer details and baked caption"); + assert_eq!(exporter.png(0, 72.0).unwrap(), original_png, "image exports exclude the viewer overlay"); + assert_eq!(printed_pixels(session.print_pdf(id, &settings).unwrap()), original_raster_print, "Print as Image retains document artwork"); + settings.raster = None; + assert_eq!(printed_pixels(session.print_pdf(id, &settings).unwrap()), original_vector_print); + assert_eq!(session.save_bytes(id).unwrap(), signed, "unchanged saves retain the signed file exactly"); + session.apply(id, Edit::SetInfo { key: "Title".into(), value: "Preserved artwork".into() }).unwrap(); + let saved = session.save_bytes(id).unwrap(); + assert!(saved.starts_with(&signed), "a real save appends without replacing signed appearance streams"); + let mut reopened = Session::new().with_clock(|| 1_800_000_000); + reopened.set_trusted_certificates(vec![anchor]); + let id = reopened.open("saved.pdf", None, saved, None).unwrap(); + let doc = reopened.get(id).unwrap(); + assert_eq!(doc.signatures[0].status, SignatureStatus::Valid); + let mut exporter = export::Exporter::new(doc); + assert_eq!(exporter.text_of(&[0]).unwrap(), original_text); + assert_eq!(exporter.png(0, 72.0).unwrap(), original_png); +} + +#[test] +fn chamfered_signature_borders_survive_trust_exports_printing_and_save() { + for raster_glyph in [false, true] { + let mut doc = pdfcraft_cos::Document::open(Arc::new(layered_signable_fixture())).unwrap(); + signature_layer_ops(&mut doc, 12, b"0 G 1 w 10 1 m 90 1 l 99 10 l 99 90 l 90 99 l 10 99 l 1 90 l 1 10 l h S"); + signature_layer_ops(&mut doc, 10, b"0 G 2 w 0 0 50 30 re S"); + if raster_glyph { + let mut image = pdfcraft_cos::Dict::new(); + image.set(b"Subtype", pdfcraft_cos::Object::name("Image")); + image.set(b"Width", pdfcraft_cos::Object::Int(8)); + image.set(b"Height", pdfcraft_cos::Object::Int(8)); + image.set(b"ColorSpace", pdfcraft_cos::Object::name("DeviceRGB")); + image.set(b"BitsPerComponent", pdfcraft_cos::Object::Int(8)); + let image = doc.add(pdfcraft_cos::Object::Stream(pdfcraft_cos::Stream::from_raw(image, vec![0; 8 * 8 * 3]))); + let mut xobjects = pdfcraft_cos::Dict::new(); + xobjects.set(b"im", pdfcraft_cos::Object::Ref(image)); + xobjects.set(b"unused", pdfcraft_cos::Object::Ref(pdfcraft_cos::ObjRef::new(8, 0))); + let mut resources = pdfcraft_cos::Dict::new(); + resources.set(b"XObject", pdfcraft_cos::Object::Dict(xobjects)); + doc.update_dict(pdfcraft_cos::ObjRef::new(10, 0), |d| d.set(b"Resources", pdfcraft_cos::Object::Dict(resources))).unwrap(); + signature_layer_ops(&mut doc, 10, b"q 20 0 0 20 5 5 cm /im Do Q"); + } + assert_signature_artwork_survives_trust(doc, &["Not Verified"]); + } +} + +#[test] +fn mixed_signature_caption_details_survive_trust_exports_printing_and_save() { + let mut doc = pdfcraft_cos::Document::open(Arc::new(layered_signable_fixture())).unwrap(); + signature_layer_ops(&mut doc, 11, b"BT /F1 5 Tf 2 27 Td (Alice Signer) Tj 0 -3 Td (alice@example.org) Tj 0 -3 Td (Signature Not Verified) Tj ET"); + assert_signature_artwork_survives_trust(doc, &["Alice Signer", "alice@example.org", "Not Verified"]); +} + +/// A real layered signature through `Session`: validity, original artwork, +/// rendering, saving and reopening. `password` opens the signed bytes. +fn check_layered_round_trip(signed: Arc>, password: Option<&str>, anchor: &sign::x509::Certificate) { + let mut s = Session::new().with_clock(|| 1_800_000_000); + let vid = s.open("layered.pdf", None, signed.clone(), password).unwrap(); + let doc = s.get(vid).unwrap(); + let sig = &doc.signatures[0]; + assert!(sig.signed && sig.field == "Signature1" && sig.page == Some(0) && sig.visible); + assert_eq!(sig.status, SignatureStatus::Unknown, "untrusted at first"); + // Trust changes the verdict while preserving every appearance byte. The page + // overlay is UI chrome and is not part of the document's display/export source. + s.set_trusted_certificates(vec![anchor.clone()]); + let doc = s.get(vid).unwrap(); + assert_eq!(doc.signatures[0].status, SignatureStatus::Valid); + assert!(Arc::ptr_eq(&doc.display, &doc.bytes), "trust does not build a signature display copy"); + assert!(bytes_has(&doc.display, b"Not Verified")); + assert!(!bytes_has(&doc.display, b"Signature valid")); + let mut renderer = pdfcraft_render::PageRenderer::new(doc.display.clone(), doc.config.clone()); + let rendered = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(rendered.error.is_none(), "{:?}", rendered.error); + // Saving keeps the original appearance and the signature: the validated + // state never reaches the file. + let saved = s.save_bytes(vid).unwrap(); + assert!(bytes_has(&saved, b"Not Verified"), "saved bytes keep the baked appearance"); + assert!(!bytes_has(&saved, b"Signature valid")); + let mut t = Session::new().with_clock(|| 1_800_000_000); + t.set_trusted_certificates(vec![anchor.clone()]); + let tid = t.open("layered-saved.pdf", None, saved.clone(), password).unwrap(); + let rsig = &t.get(tid).unwrap().signatures[0]; + assert_eq!(rsig.status, SignatureStatus::Valid); +} + +/// A trusted layered signature whose original appearance remains document artwork. +fn trusted_layered_session() -> (Session, DocId) { + let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(); + let id = sign::pkcs12::open(&p12, "test").unwrap(); + let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone()); + let mut doc = pdfcraft_cos::Document::open(Arc::new(layered_signable_fixture())).unwrap(); + let signed = Arc::new(sign_prepared(&mut doc, &id, "D:20261002120000+01'00'")); + let mut s = Session::new().with_clock(|| 1_800_000_000); + let vid = s.open("layered.pdf", None, signed, None).unwrap(); + s.set_trusted_certificates(vec![anchor]); + assert_eq!(s.get(vid).unwrap().signatures[0].status, SignatureStatus::Valid); + (s, vid) +} + +/// Metadata edits retain the original caption; the UI can still show its verdict badge. +#[test] +fn a_metadata_edit_keeps_the_original_signature_appearance() { + let (mut s, vid) = trusted_layered_session(); + s.apply(vid, Edit::SetInfo { key: "Title".into(), value: "Contract".into() }).unwrap(); + let doc = s.get(vid).unwrap(); + assert_eq!(doc.signatures[0].status, SignatureStatus::Valid); + assert!(bytes_has(&doc.display, b"Not Verified")); + assert!(!bytes_has(&doc.display, b"Signature valid")); +} + +/// An unchanged document never reaches the writer, so the edit is what makes this the +/// place the display copy would leak if saving ever read it. +#[test] +fn saving_after_an_edit_keeps_the_baked_appearance() { + let (mut s, vid) = trusted_layered_session(); + s.apply(vid, Edit::SetInfo { key: "Title".into(), value: "Contract".into() }).unwrap(); + let saved = s.save_bytes(vid).unwrap(); + assert!(bytes_has(&saved, b"Not Verified"), "the writer keeps the baked appearance: {saved:?}"); + assert!(!bytes_has(&saved, b"Signature valid"), "the validated state never reaches the file"); +} + +#[test] +fn real_layered_signatures_keep_their_original_appearance() { + let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(); + let id = sign::pkcs12::open(&p12, "test").unwrap(); + let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone()); + let date = "D:20261002120000+01'00'"; + let mut doc = pdfcraft_cos::Document::open(Arc::new(layered_signable_fixture())).unwrap(); + let signed = Arc::new(sign_prepared(&mut doc, &id, date)); + check_layered_round_trip(signed, None, &anchor); +} + +#[test] +fn real_layered_signatures_round_trip_behind_a_user_password() { + let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(); + let id = sign::pkcs12::open(&p12, "test").unwrap(); + let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone()); + let date = "D:20261002120000+01'00'"; + let mut doc = pdfcraft_cos::Document::open(Arc::new(layered_signable_fixture())).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes128, + user_password: "user", + owner_password: "owner", + permissions: -1, + encrypt_metadata: true, + seed: [7; 32], + }) + .unwrap(); + let encrypted = Arc::new(pdfcraft_cos::write_full(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap()); + let mut doc = pdfcraft_cos::Document::open_with_password(encrypted, Some("user")).unwrap(); + let signed = Arc::new(sign_prepared(&mut doc, &id, date)); + let mut probe = Session::new().with_clock(|| 1_800_000_000); + assert!(probe.open("enc.pdf", None, signed.clone(), Some("wrong")).is_err(), "wrong passwords refuse"); + // Encrypted appearance bytes remain unchanged through trust and save/reopen. + let mut s = Session::new().with_clock(|| 1_800_000_000); + let vid = s.open("enc.pdf", None, signed.clone(), Some("user")).unwrap(); + assert_eq!(s.get(vid).unwrap().signatures[0].status, SignatureStatus::Unknown); + s.set_trusted_certificates(vec![anchor.clone()]); + let doc = s.get(vid).unwrap(); + assert_eq!(doc.signatures[0].status, SignatureStatus::Valid); + assert!(Arc::ptr_eq(&doc.display, &doc.bytes), "encrypted signature artwork remains original"); + let saved = s.save_bytes(vid).unwrap(); + let mut t = Session::new().with_clock(|| 1_800_000_000); + t.set_trusted_certificates(vec![anchor.clone()]); + assert!(t.open("enc-saved.pdf", None, saved.clone(), Some("wrong")).is_err()); + let tid = t.open("enc-saved.pdf", None, saved.clone(), Some("user")).unwrap(); + assert_eq!(t.get(tid).unwrap().signatures[0].status, SignatureStatus::Valid); +} + #[test] #[ignore = "timing probe"] fn probe_edit_latency_on_a_large_signed_document() { @@ -2892,6 +3744,35 @@ fn recognize_text_reads_the_euro_sign() { assert!(text.contains("€250"), "{text}"); } +const SYNTHETIC_SCAN_DPI: f64 = 300.0; + +/// A scan of the OCR sentence at the source page's physical size, without a text layer. +fn synthetic_ocr_scan(s: &mut Session) -> (DocId, [f32; 2]) { + let text = s.create_from_text("t", "The quick brown fox jumps over the lazy dog.").unwrap(); + let id = s.open("text.pdf", None, text, None).unwrap(); + let original = &s.get(id).unwrap().info.pages[0]; + let size = [original.width, original.height]; + // A typical 300 DPI scan keeps the small leading glyph in "quick" legible to OCR. + let png = export::Exporter::new(s.get(id).unwrap()).png(0, SYNTHETIC_SCAN_DPI).unwrap(); + // Exported PNGs have no resolution metadata. The 72 DPI import default would enlarge + // the page and change the PDF-point coordinates the placement assertions exercise. + let scan = s.create_from_images_with_resolution(&[("scan.png".into(), png)], ImageResolution::Dpi(SYNTHETIC_SCAN_DPI)).unwrap(); + (s.open("scan.pdf", None, scan, None).unwrap(), size) +} + +#[test] +fn synthetic_ocr_scan_keeps_the_original_page_size_without_models() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let (id, expected) = synthetic_ocr_scan(&mut s); + let page = &s.get(id).unwrap().info.pages[0]; + // Rounding to whole raster pixels may change a page edge by at most one pixel. + let tolerance = 72.0 / SYNTHETIC_SCAN_DPI; + for (actual, expected) in [page.width, page.height].into_iter().zip(expected) { + assert!(f64::from((actual - expected).abs()) <= tolerance, "page edge {actual} pt, expected {expected} pt"); + } + assert_eq!(page_texts(&s, id), [""], "the scan has no text until OCR runs"); +} + #[test] fn recognize_text_makes_a_scanned_page_searchable() { if !ocr::available() { @@ -2899,12 +3780,7 @@ fn recognize_text_makes_a_scanned_page_searchable() { return; } let mut s = Session::new().with_clock(|| 1_700_000_000); - let text = s.create_from_text("t", "The quick brown fox jumps over the lazy dog.").unwrap(); - let id = s.open("text.pdf", None, text, None).unwrap(); - // A typical 300 DPI scan keeps the small leading glyph in "quick" legible to OCR. - let png = export::Exporter::new(s.get(id).unwrap()).png(0, 300.0).unwrap(); - let scan = s.create_from_images(&[("scan.png".into(), png)]).unwrap(); - let id = s.open("scan.pdf", None, scan, None).unwrap(); + let (id, _) = synthetic_ocr_scan(&mut s); assert_eq!(page_texts(&s, id), [""], "a picture has no text"); let found = s.recognize_text(id, &[], ocr::OcrSettings::default()).unwrap(); @@ -3095,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 { @@ -3244,6 +4142,34 @@ fn detecting_fields_on_a_scanned_form() { } } +#[test] +fn detecting_comb_boxes_adds_one_comb_field_per_row() { + // The rows from #1887: five 18 x 22 cells and ten 16 x 20 cells, each a stroked `re`. + let mut body = String::from("BT /F1 10 Tf 40 205 Td (Postcode A) Tj ET BT /F1 10 Tf 40 105 Td (Postcode B) Tj ET\n"); + for i in 0..5 { + body.push_str(&format!("{} 200 18 22 re S\n", 120 + i * 18)); + } + for i in 0..10 { + body.push_str(&format!("{} 100 16 20 re S\n", 120 + i * 16)); + } + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("comb.pdf", None, Arc::new(one_line_page("/Font << /F1 4 0 R >>", &body)), None).unwrap(); + let found: Vec<(String, pdfcraft_forms::detect::Kind)> = s.detect_fields(id, &[]).into_iter().map(|(_, c)| (c.name, c.kind)).collect(); + assert_eq!( + found, + [ + ("Postcode A".to_string(), pdfcraft_forms::detect::Kind::Comb { cells: 5 }), + ("Postcode B".to_string(), pdfcraft_forms::detect::Kind::Comb { cells: 10 }) + ] + ); + s.auto_detect_fields(id, &[]).unwrap(); + let d = s.get(id).unwrap(); + let combs: Vec<(&str, FormFieldKind, bool, Option)> = + d.form.iter().map(|f| (f.name.as_str(), f.kind, f.has(pdfcraft_forms::flags::COMB), f.max_len)).collect(); + assert_eq!(combs, [("Postcode A", FormFieldKind::Text, true, Some(5)), ("Postcode B", FormFieldKind::Text, true, Some(10))]); + assert_eq!(d.can_undo(), Some("Detect form fields")); +} + #[test] fn comparing_two_versions_of_a_document() { let mut s = Session::new().with_clock(|| 1_700_000_000); @@ -3541,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")); @@ -3570,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. @@ -3665,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; @@ -3723,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. @@ -3746,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")); @@ -3766,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())); @@ -3798,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); @@ -3828,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 @@ -3848,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())); @@ -5214,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}"); @@ -5241,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 @@ -5407,3 +6580,523 @@ fn cropping_an_existing_image_clips_it_and_moves_with_it() { // No area, or a box off the image, is refused. assert!(s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Crop([10.0, 10.0, 10.0, 50.0]) }).is_err()); } + +/// A 60 x 20 JPEG at 144 x 72 dpi, tagged with EXIF Orientation 6 (shown turned 90 degrees clockwise). +fn phone_jpeg() -> Vec { + use image::codecs::jpeg::{JpegEncoder, PixelDensity, PixelDensityUnit}; + let mut jpeg = Vec::new(); + let mut encoder = JpegEncoder::new(&mut jpeg); + encoder.set_pixel_density(PixelDensity { density: (144, 72), unit: PixelDensityUnit::Inches }); + encoder.encode_image(&image::RgbImage::from_pixel(60, 20, image::Rgb([20, 20, 20]))).unwrap(); + let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0".to_vec(); + exif.extend_from_slice(&[6, 0, 0, 0, 0, 0, 0, 0]); + let mut out = jpeg[..2].to_vec(); + out.extend_from_slice(&[0xff, 0xe1]); + out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes()); + out.extend_from_slice(&exif); + out.extend_from_slice(&jpeg[2..]); + out +} + +#[test] +fn a_phone_photo_used_as_a_custom_stamp_is_embedded_upright() { + let mut session = Session::new(); + let blank = session.create_blank(300.0, 400.0, 1).unwrap(); + let id = session.open_new("form.pdf", blank).unwrap(); + // A click places the stamp at its natural size: 30 x 20 pt as stored, 20 x 30 pt upright. + // Named "Signature" so `signature_image` finds its picture. + let file = MarkFile { name: "photo.jpg".into(), bytes: Arc::new(phone_jpeg()), page: 0 }; + let stamp = Edit::AddCustomStamp { page: 0, rect: [100.0, 100.0, 100.0, 100.0], name: "Signature".into(), file, author: "Ada".into() }; + session.apply(id, stamp).unwrap(); + let cos = &session.get(id).unwrap().editor.as_ref().unwrap().cos; + let picture = pdfcraft_annot::signature_image(cos, 0, 0).unwrap().unwrap(); + let obj = cos.get(picture); + let pdfcraft_cos::Object::Stream(s) = &*obj else { panic!() }; + assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(20), Some(60))); + let rect = pdfcraft_annot::summaries(cos)[0].rect; + assert!(((rect[2] - rect[0]) - 20.0).abs() < 0.01 && ((rect[3] - rect[1]) - 30.0).abs() < 0.01, "{rect:?}"); +} + +/// Opening a paragraph and applying its edit read the page's text blocks; after an edit they are +/// still read once per document state, and undo and redo never get another state's blocks. +#[test] +fn text_blocks_stay_cached_after_an_edit_and_follow_undo_and_redo() { + let (mut session, id) = session_with(1); + let blocks = |s: &Session| s.get(id).unwrap().editor.as_ref().unwrap().text_blocks(0).unwrap(); + let before = blocks(&session); + assert_eq!(before[0].editor_text, "Page 1"); + let edit = Edit::EditTextBlock { page: 0, block: 0, text: "Edited".into(), style: pdfcraft_edit::BlockStyle::default() }; + session.apply(id, edit).unwrap(); + let edited = blocks(&session); + assert_eq!(edited[0].editor_text, "Edited"); + assert!(Arc::ptr_eq(&edited, &blocks(&session)), "the edited page is grouped once"); + session.undo(id).unwrap(); + assert_eq!(blocks(&session)[0].editor_text, "Page 1"); + session.redo(id).unwrap(); + let redone = blocks(&session); + assert_eq!(redone[0].editor_text, "Edited"); + assert!(Arc::ptr_eq(&redone, &blocks(&session))); +} + +/// A new document, or one restored from recovery, has never been written where it belongs: undo +/// back to the bytes it was opened from must not make it clean, or closing it would discard the +/// whole document without asking. Saving makes it clean; Revert of a saved one too. +#[test] +fn undo_never_makes_an_unsaved_document_clean() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let new = s.open_new("Combined.pdf", Arc::new(fixture(2))).unwrap(); + s.apply(new, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + s.undo(new).unwrap(); + assert!(s.get(new).unwrap().dirty, "a new document is unsaved until it is saved"); + assert_eq!(s.autosave_snapshots().len(), 1, "and still needs recovery"); + s.revert(new).unwrap(); + assert!(s.get(new).unwrap().dirty, "Revert goes back to the created bytes, which aren't saved anywhere"); + + let recovered = s.open("Report.pdf", None, Arc::new(fixture(2)), None).unwrap(); + s.mark_recovered(recovered, Some("Report.pdf".into())); + s.apply(recovered, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + s.undo(recovered).unwrap(); + assert!(s.get(recovered).unwrap().dirty, "the recovered work was never saved"); + + let saved = s.save_bytes(new).unwrap(); + s.mark_saved(new, saved, Some("Combined.pdf".into())).unwrap(); + assert!(!s.get(new).unwrap().dirty); + s.apply(new, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + 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(""), "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/engine/tests/document_fonts.rs b/crates/engine/tests/it/document_fonts.rs similarity index 99% rename from crates/engine/tests/document_fonts.rs rename to crates/engine/tests/it/document_fonts.rs index ebeff24ae..e4212f4a3 100644 --- a/crates/engine/tests/document_fonts.rs +++ b/crates/engine/tests/it/document_fonts.rs @@ -1,7 +1,5 @@ //! Document font selection and contextual glyph save/reopen acceptance on synthetic PDFs. -mod support { - pub mod font_fixture; -} +use crate::support; use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream}; use pdfcraft_engine::{BlockStyle, Edit, Session}; use pdfcraft_render::{PageRenderer, RenderRequest}; diff --git a/crates/engine/tests/form_rotation.rs b/crates/engine/tests/it/form_rotation.rs similarity index 100% rename from crates/engine/tests/form_rotation.rs rename to crates/engine/tests/it/form_rotation.rs diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs new file mode 100644 index 000000000..15e1a8548 --- /dev/null +++ b/crates/engine/tests/it/main.rs @@ -0,0 +1,12 @@ +//! The crate's integration tests, linked into one test binary. + +mod support { + pub mod font_fixture; +} + +mod document_fonts; +mod form_rotation; +mod marked_content_isolation; +mod ocr_edit; +mod private_image_resize; +mod private_text_glyphs; diff --git a/crates/engine/tests/it/marked_content_isolation.rs b/crates/engine/tests/it/marked_content_isolation.rs new file mode 100644 index 000000000..32a42d517 --- /dev/null +++ b/crates/engine/tests/it/marked_content_isolation.rs @@ -0,0 +1,274 @@ +//! Contributor-original pages. Added marks must not inherit an unfinished hidden layer. +use std::sync::Arc; + +use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream, write_incremental}; +use pdfcraft_engine::{AddedText, Edit, HeaderFooter, Session, Watermark}; +use pdfcraft_render::{PageRenderer, RenderRequest, RequestKind}; + +const VISIBLE: &str = "BT /F1 18 Tf 72 700 Td (Visible text) Tj ET\n"; +const HIDDEN: &str = "BT /F1 18 Tf 72 600 Td (Hidden note) Tj ET\n"; + +fn fixture(parts: &[String]) -> Arc> { + let mut doc = Document::new_empty(); + let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap(); + 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")); + let mut fonts = Dict::new(); + fonts.set(b"F1".to_vec(), Object::Ref(doc.add(font))); + let mut group = Dict::new(); + group.set(b"Type".to_vec(), Object::name("OCG")); + group.set(b"Name".to_vec(), Object::String(pdfcraft_cos::PdfString::text("Hidden layer"))); + let group = doc.add(group); + let mut properties = Dict::new(); + properties.set(b"L1".to_vec(), Object::Ref(group)); + let mut resources = Dict::new(); + resources.set(b"Font".to_vec(), Object::Dict(fonts)); + resources.set(b"Properties".to_vec(), Object::Dict(properties)); + let mut config = Dict::new(); + config.set(b"OFF".to_vec(), Object::Array(vec![Object::Ref(group)])); + let mut oc = Dict::new(); + oc.set(b"OCGs".to_vec(), Object::Array(vec![Object::Ref(group)])); + oc.set(b"D".to_vec(), Object::Dict(config)); + doc.update_dict(doc.root().unwrap(), |d| d.set(b"OCProperties".to_vec(), Object::Dict(oc))).unwrap(); + let streams = parts.iter().map(|s| Object::Ref(doc.add(Object::Stream(Stream::from_raw(Dict::new(), s.as_bytes().to_vec()))))).collect(); + let mut page = Dict::new(); + page.set(b"Type".to_vec(), Object::name("Page")); + page.set(b"Parent".to_vec(), Object::Ref(pages)); + page.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 612.into(), 792.into()])); + page.set(b"Resources".to_vec(), Object::Dict(resources)); + page.set(b"Contents".to_vec(), Object::Array(streams)); + let page = doc.add(page); + doc.update_dict(pages, |d| { + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])); + d.set(b"Count".to_vec(), Object::Int(1)); + }) + .unwrap(); + Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap()) +} + +fn render(bytes: Arc>) -> (Vec, String) { + let mut renderer = PageRenderer::new(bytes, Default::default()); + let pixels = renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(pixels.error.is_none(), "{:?}", pixels.error); + let text = renderer.render(RenderRequest { page: 0, kind: RequestKind::Text, ..Default::default() }); + assert!(text.error.is_none(), "{:?}", text.error); + let text = text.text.unwrap(); + (pixels.rgba.to_vec(), text.text_of(0..text.glyphs.len())) +} + +fn edit(kind: &str) -> Edit { + match kind { + "HEADER" | "000001" => { + let mut settings = HeaderFooter::default(); + if kind == "HEADER" { + settings.text[1] = "HEADER".into(); + } else { + settings.text[5] = "<>".into(); + } + Edit::AddHeaderFooter { pages: vec![0], settings, replace: true } + } + "WATERMARK" => Edit::AddWatermark { + pages: vec![0], + settings: Watermark { text: kind.into(), opacity: 1.0, rotation: 0.0, font_size: 18.0, ..Default::default() }, + replace: true, + file: None, + }, + "ADDED" => Edit::AddText { page: 0, text: AddedText { text: kind.into(), rect: [72.0, 300.0, 250.0, 330.0], ..Default::default() } }, + _ => panic!("unknown mark"), + } +} + +fn add_and_save(input: Arc>, kind: &str) -> Arc> { + let mut session = Session::new(); + let id = session.open("fixture.pdf", None, input.clone(), None).unwrap(); + session.apply(id, edit(kind)).unwrap(); + let saved = session.save_bytes(id).unwrap(); + assert!(saved.starts_with(&input), "incremental save preserves the original file bytes"); + assert_original_streams(&input, &saved); + let mut reopened = Session::new(); + let id = reopened.open("saved.pdf", None, saved.clone(), None).unwrap(); + assert_eq!(reopened.save_bytes(id).unwrap().as_slice(), saved.as_slice()); + saved +} + +#[test] +fn all_four_mark_paths_remain_visible_after_saved_unfinished_marked_content() { + let mut failures = Vec::new(); + for (case, opening, hidden) in [("hidden", "/OC /L1 BDC\n", true), ("artifact", "/Artifact BMC\n", false), ("balanced", "/OC /L1 BDC\n", true)] { + let closed = fixture(&[format!("{VISIBLE}{opening}{HIDDEN}EMC\n")]); + let input = if case == "balanced" { closed.clone() } else { fixture(&[format!("{VISIBLE}{opening}{HIDDEN}")]) }; + let original = render(input.clone()); + assert!(original.1.contains("Visible text")); + assert_eq!(original.1.contains("Hidden note"), !hidden); + for kind in ["HEADER", "000001", "WATERMARK", "ADDED"] { + let saved = add_and_save(input.clone(), kind); + + let actual = render(saved); + let expected_bytes = add_and_save(closed.clone(), kind); + + let expected = render(expected_bytes); + let mut wanted = vec!["Visible text", kind]; + if !hidden { + wanted.push("Hidden note"); + } + wanted.sort_unstable(); + let pass = actual.0 == expected.0 && actual.1 == expected.1 && lines(&actual.1) == wanted; + eprintln!("{case}/{kind}: visible={} closed-control-pixels={}", actual.1.contains(kind), actual.0 == expected.0); + if !pass { + failures.push(format!("{case}/{kind}: {:?}", actual.1)); + } + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +fn stream_list(doc: &Document) -> Vec { + let page = pdfcraft_model::pages(doc).remove(0); + let contents = doc.resolve(page.dict.get(b"Contents").unwrap()); + match &*contents { + Object::Array(items) => items.clone(), + _ => vec![page.dict.get(b"Contents").unwrap().clone()], + } +} + +fn assert_original_streams(before: &Arc>, after: &Arc>) { + let original = Document::open(before.clone()).unwrap(); + let saved = Document::open(after.clone()).unwrap(); + let saved_list = stream_list(&saved); + for reference in stream_list(&original) { + assert!(saved_list.contains(&reference), "the original stream stays on the page"); + assert_eq!(original.resolve(&reference), saved.resolve(&reference), "original encoded stream object remains unchanged"); + } +} + +fn lines(text: &str) -> Vec<&str> { + let mut lines: Vec<_> = text.lines().filter(|line| !line.is_empty()).collect(); + lines.sort_unstable(); + lines +} + +#[test] +fn nested_stream_edges_and_malformed_graphics_keep_hidden_content_hidden() { + let cases: Vec<(&str, Vec, usize)> = vec![ + ("nested", vec![format!("{VISIBLE}/OC /L1 BDC /Artifact BMC {HIDDEN}")], 2), + ("split operands", vec![format!("{VISIBLE}/OC"), format!("/L1 BDC {HIDDEN}")], 1), + ("split close", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}"), "EMC\n".into()], 0), + ("trailing comment", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}% EMC")], 1), + ("cross-stream comment", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}% EMC"), "EMC BMC\n".into()], 1), + ("unclosed graphics", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}q")], 1), + ("balanced graphics open marked", vec![format!("q {VISIBLE}/OC /L1 BDC {HIDDEN}Q")], 1), + ("malformed graphics", vec![format!("Q {VISIBLE}/OC /L1 BDC {HIDDEN}")], 1), + ("invalid begins", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}123 BMC /Artifact BDC")], 1), + ("extra begins", vec![format!("{VISIBLE}123 /OC /L1 BDC {HIDDEN}123 /Artifact BMC")], 2), + ("extra close", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}123 EMC")], 0), + ("underflow", vec![format!("EMC {VISIBLE}/OC /L1 BDC {HIDDEN}")], 1), + ("operator strings", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}/Note <> DP")], 1), + ("inline samples", vec![format!("{VISIBLE}/OC /L1 BDC {HIDDEN}BI /W 11 /H 1 /BPC 8 /CS /G ID BMC BDC EMC EI\n")], 1), + ]; + for (case, parts, close_count) in cases { + let input = fixture(&parts); + let mut closed = parts.clone(); + closed.push(format!("\n{}", "EMC\n".repeat(close_count))); + let actual = render(add_and_save(input, "HEADER")); + let expected = render(add_and_save(fixture(&closed), "HEADER")); + assert_eq!(lines(&actual.1), lines("Visible text\nHEADER"), "{case}"); + assert!(actual.0 == expected.0, "{case}: pixels equal the explicitly closed control"); + } +} + +#[test] +fn replacing_and_removing_marks_restores_the_original_stream_list() { + let input = fixture(&[format!("{VISIBLE}/OC /L1 BDC {HIDDEN}")]); + let original = Document::open(input.clone()).unwrap(); + for kind in ["HEADER", "000001", "WATERMARK"] { + let mut session = Session::new(); + let mut id = session.open("fixture.pdf", None, input.clone(), None).unwrap(); + for _ in 0..3 { + session.apply(id, edit(kind)).unwrap(); + let saved = session.save_bytes(id).unwrap(); + let page = Document::open(saved.clone()).unwrap(); + assert_eq!(stream_list(&page).len(), 4, "{kind}: updating does not add wrappers"); + assert_original_streams(&input, &saved); + assert_eq!(lines(&render(saved.clone()).1), lines(&format!("Visible text\n{kind}"))); + session = Session::new(); + id = session.open("saved.pdf", None, saved, None).unwrap(); + } + let mark_kind = if kind == "WATERMARK" { pdfcraft_edit::MarkKind::Watermark } else { pdfcraft_edit::MarkKind::HeaderFooter }; + session.apply(id, Edit::RemoveMarks { kind: mark_kind }).unwrap(); + let saved = session.save_bytes(id).unwrap(); + let page = Document::open(saved.clone()).unwrap(); + assert_eq!(stream_list(&page), stream_list(&original), "{kind}: removal restores original references"); + assert_original_streams(&input, &saved); + assert!(render(saved) == render(input.clone()), "{kind}: removal restores original pixels and text"); + session.undo(id).unwrap(); + assert_eq!(lines(&render(session.save_bytes(id).unwrap()).1), lines(&format!("Visible text\n{kind}"))); + session.redo(id).unwrap(); + assert!(render(session.save_bytes(id).unwrap()) == render(input.clone()), "redo removal restores the original rendering"); + } +} + +#[test] +fn added_update_delete_and_readd_preserve_existing_wrapper_behavior() { + let input = fixture(&[format!("{VISIBLE}/OC /L1 BDC {HIDDEN}")]); + let mut session = Session::new(); + let mut id = session.open("fixture.pdf", None, input.clone(), None).unwrap(); + for _ in 0..3 { + session.apply(id, edit("ADDED")).unwrap(); + assert_eq!(session.get(id).unwrap().added().len(), 1); + let text = AddedText { text: "UPDATED".into(), rect: [72.0, 300.0, 250.0, 330.0], ..Default::default() }; + session.apply(id, Edit::UpdateContent { page: 0, index: 0, content: pdfcraft_edit::Content::Text(text) }).unwrap(); + let saved = session.save_bytes(id).unwrap(); + assert_eq!(lines(&render(saved.clone()).1), lines("Visible text\nUPDATED")); + assert_eq!(stream_list(&Document::open(saved.clone()).unwrap()).len(), 4); + assert_original_streams(&input, &saved); + session = Session::new(); + id = session.open("saved.pdf", None, saved, None).unwrap(); + session.apply(id, Edit::DeleteContent { page: 0, index: 0 }).unwrap(); + let saved = session.save_bytes(id).unwrap(); + assert_eq!(stream_list(&Document::open(saved.clone()).unwrap()).len(), 3, "Added deletion retains the established wrappers"); + assert!(render(saved.clone()) == render(input.clone()), "deleting Added restores the original rendering"); + assert_original_streams(&input, &saved); + session = Session::new(); + id = session.open("saved.pdf", None, saved, None).unwrap(); + } +} + +#[test] +fn an_existing_behind_watermark_does_not_inherit_or_reveal_hidden_content() { + let input = fixture(&[format!("{VISIBLE}/OC /L1 BDC {HIDDEN}")]); + let mut session = Session::new(); + let id = session.open("fixture.pdf", None, input.clone(), None).unwrap(); + session + .apply( + id, + Edit::AddWatermark { + pages: vec![0], + settings: Watermark { text: "BEHIND".into(), behind: true, font_size: 18.0, opacity: 1.0, rotation: 0.0, ..Default::default() }, + replace: false, + file: None, + }, + ) + .unwrap(); + session.apply(id, edit("HEADER")).unwrap(); + let saved = session.save_bytes(id).unwrap(); + assert_eq!(lines(&render(saved.clone()).1), lines("Visible text\nBEHIND\nHEADER")); + assert_original_streams(&input, &saved); +} + +#[test] +fn new_mark_closes_hidden_content_appended_after_an_old_wrapper() { + let bytes = fixture(&["q %PdfCraft\n".into(), VISIBLE.into(), "Q %PdfCraft\n".into(), format!("/OC /L1 BDC {HIDDEN}")]); + let mut doc = Document::open(bytes).unwrap(); + let list = stream_list(&doc); + for index in [0, 2] { + let reference = list[index].as_ref().unwrap(); + let Object::Stream(mut stream) = doc.get(reference).as_ref().clone() else { panic!("stream") }; + stream.dict.set(b"PCMark".to_vec(), Object::name("Wrap")); + doc.set(reference, Object::Stream(stream)); + } + let input = Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap()); + let saved = add_and_save(input, "HEADER"); + assert_eq!(lines(&render(saved).1), lines("Visible text\nHEADER")); +} diff --git a/crates/engine/tests/ocr_edit.rs b/crates/engine/tests/it/ocr_edit.rs similarity index 100% rename from crates/engine/tests/ocr_edit.rs rename to crates/engine/tests/it/ocr_edit.rs diff --git a/crates/engine/tests/private_image_resize.rs b/crates/engine/tests/it/private_image_resize.rs similarity index 100% rename from crates/engine/tests/private_image_resize.rs rename to crates/engine/tests/it/private_image_resize.rs diff --git a/crates/engine/tests/private_text_glyphs.rs b/crates/engine/tests/it/private_text_glyphs.rs similarity index 100% rename from crates/engine/tests/private_text_glyphs.rs rename to crates/engine/tests/it/private_text_glyphs.rs diff --git a/crates/engine/tests/support/font_fixture.rs b/crates/engine/tests/it/support/font_fixture.rs similarity index 100% rename from crates/engine/tests/support/font_fixture.rs rename to crates/engine/tests/it/support/font_fixture.rs diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 16dd2b06d..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 @@ -114,6 +117,34 @@ const COL_TOL: f64 = 4.0; /// its columns, so a hostile page laid out as a staircase of blocks would otherwise make /// (blocks / 2)² cells. const MAX_COLS: usize = 63; +/// Two blocks in a row this close (as a fraction of their font size) are one line of running +/// text that changed font or colour, such as a link inside a sentence, not two table cells. +const INLINE_GAP: f64 = 0.5; +/// How many blocks ending near a block's start are looked at to find the one it continues; a +/// page laid out so that many end at the same place stays quick. +const CONTINUATION_PROBE: usize = 64; +/// Per block, whether it continues another on the same line: it starts where that one ends (up to +/// [`INLINE_GAP`] later, or a point earlier for rounding), so it is the same line of text running +/// on (a link inside a sentence, a superscript), not a cell of its own. +fn continuations(blocks: &[Block]) -> Vec { + let mut by_end: Vec<(f64, usize)> = blocks.iter().enumerate().map(|(i, b)| (b.rect[2], i)).collect(); + by_end.sort_by(|a, b| a.0.total_cmp(&b.0)); + blocks + .iter() + .enumerate() + .map(|(bi, b)| { + let k = by_end.partition_point(|(end, _)| *end < b.rect[0] - INLINE_GAP * b.size); + by_end + .get(k..) + .unwrap_or_default() + .iter() + .take_while(|(end, _)| *end <= b.rect[0] + 1.0) + .take(CONTINUATION_PROBE) + .filter_map(|(_, ai)| blocks.get(*ai).filter(|_| *ai != bi)) + .any(|a| a.rect[3].min(b.rect[3]) - a.rect[1].max(b.rect[1]) > 0.5 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1])) + }) + .collect() +} /// A cell is short: taller blocks are body text (or a multi-column layout), not table cells. fn is_cell_like(b: &Block) -> bool { (b.rect[3] - b.rect[1]) <= b.size * 5.0 && !b.text.trim().is_empty() @@ -264,13 +295,20 @@ pub(crate) fn tables(blocks: &[Block]) -> (Vec
, Vec) { es.dedup_by(|a, b| (*a - *b).abs() <= COL_TOL); es }; + let cont = continuations(blocks); let qual: Vec = rows .iter() .map(|row| { - let mut cs: Vec = row.iter().map(|(c, _)| *c).collect(); + // A block that continues another adds no column of its own, and the cells of a table row + // sit side by side: blocks that overlap are a paragraph's lines, not cells (#2806). + let cells: Vec<(usize, usize)> = row.iter().copied().filter(|(_, bi)| !cont.get(*bi).copied().unwrap_or(false)).collect(); + let mut spans: Vec<[f64; 2]> = cells.iter().filter_map(|(_, bi)| blocks.get(*bi)).map(|b| [b.rect[0], b.rect[2]]).collect(); + spans.sort_by(|a, b| a[0].total_cmp(&b[0])); + let apart = spans.windows(2).all(|w| w[1][0] >= w[0][1] - COL_TOL); + let mut cs: Vec = cells.iter().map(|(c, _)| *c).collect(); cs.sort_unstable(); cs.dedup(); - cs.len() >= 2 + apart && cs.len() >= 2 }) .collect(); let mut i = 0; @@ -627,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 { @@ -693,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( @@ -707,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\ \ @@ -721,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\ \ @@ -738,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); } @@ -877,6 +937,7 @@ mod tests { size: [200.0, 150.0], rotation: 0.0, }], + background: None, } } @@ -972,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); @@ -1099,6 +1184,7 @@ mod tests { }, ], images: Vec::new(), + background: None, } } @@ -1179,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(" Block { + Block { text: t.into(), rect: [x0, y0, x1, y1], size: 12.0, bold: false, italic: false, color: [0.0; 3], rotation: 0.0 } + } + + /// #2806: a sentence whose link is set in another colour or font runs on as separate blocks + /// on one line. They are not table columns, even when several such lines share left edges. + #[test] + fn a_link_inside_a_sentence_is_not_a_table_column() { + let blue = |t: &str, x0: f64, y0: f64, x1: f64, y1: f64| Block { color: [0.02, 0.388, 0.757], ..block(t, x0, y0, x1, y1) }; + let p = Page { + width: 612.0, + height: 792.0, + blocks: vec![ + block("6.3. 2. All content shown at", 121.7, 710.3, 272.0, 724.9), + blue("http", 273.5, 710.3, 294.1, 724.9), + blue("s", 294.1, 709.8, 298.8, 725.8), + blue("://women", 298.8, 710.3, 348.0, 724.9), + blue("g", 348.1, 709.8, 353.7, 725.8), + blue("randprix.fide.com", 353.7, 710.3, 443.2, 724.9), + block("shall be reviewed and approved by the Press Officer.", 164.3, 622.5, 533.1, 724.9), + block("6.3. 3. The Organiser shall warrant that", 121.7, 601.8, 345.1, 616.5), + blue("https://womengrandprix.fide.com", 350.2, 601.3, 519.9, 617.4), + block("is managed in a professional way and furthermore that it is capable of", 164.3, 557.9, 533.0, 616.5), + block("6. 3. 4. The Press Officer shall report to the FIDE Head of PR.", 121.7, 516.6, 530.3, 551.9), + block("conducted during a WGP tournament.", 164.3, 502.0, 352.5, 516.6), + ], + images: Vec::new(), + background: None, + }; + let h = html(std::slice::from_ref(&p), "Regulations"); + assert!(!h.contains("
"), "running text became a table: {h}"); + for b in &p.blocks { + assert!(h.contains(&b.text), "{} was lost: {h}", b.text); + } + } + + /// #2806: lines that each run on into a link starting at the same place are sentences, not a + /// two-column table, even three in a row. + #[test] + fn sentences_that_run_on_into_links_are_not_a_table() { + let blue = |t: &str, x0: f64, y0: f64, x1: f64, y1: f64| Block { color: [0.02, 0.388, 0.757], ..block(t, x0, y0, x1, y1) }; + let mut blocks = Vec::new(); + for (n, lead) in ["Rules are published at", "Pairings are posted at", "Results are listed at"].iter().enumerate() { + let y = 700.0 - 16.0 * n as f64; + 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(), 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}"); + } + + /// #2806: a superscript right after a number ("1" "st") continues its cell; the prize table + /// around it stays a table. + #[test] + fn a_superscript_beside_a_number_keeps_its_table() { + let mut blocks = vec![block("PLACE", 100.0, 700.0, 140.0, 712.0), block("PRIZE (euros)", 300.0, 700.0, 380.0, 712.0)]; + for (n, (row, sup)) in [("1", "st"), ("2", "nd"), ("3", "rd"), ("4", "th")].iter().enumerate() { + let y = 680.0 - 20.0 * n as f64; + blocks.push(block(row, 100.0, y, 106.0, y + 12.0)); + 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(), 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}"); + } + #[test] fn two_column_body_text_is_not_mistaken_for_a_table() { let para = |t: &str, x: f64, y: f64| Block { @@ -1207,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/filters/README.md b/crates/filters/README.md index de7f8d223..df64e4b7c 100644 --- a/crates/filters/README.md +++ b/crates/filters/README.md @@ -24,7 +24,7 @@ encode_flate(data) -> Vec // zlib, level 6 - `chain` is applied first to last, as in a `/Filter` array. - Image codecs (DCT, JPX, JBIG2, CCITT), `Crypt` and unknown filters anywhere in a chain give `Unsupported`: callers keep such data encoded. Decryption belongs to `crypt`. -- `max_output` bounds every intermediate buffer (including the pre-predictor buffer) and gives `LimitExceeded(max_output)`. +- `max_output` bounds every intermediate buffer (including the pre-predictor buffer) and gives `LimitExceeded(max_output)`. This also holds when Flate retries raw deflate after a misleading zlib header, in both strict and tolerant decoding. - `decode_tolerant` turns `Corrupt` into "what was decoded so far" plus `partial = true`, then runs the rest of the chain on it (predictors are applied to partial output too). `LimitExceeded` and `Unsupported` are still errors. ## Behaviour diff --git a/crates/filters/src/flate.rs b/crates/filters/src/flate.rs index 5aa6443f6..dac0851e0 100644 --- a/crates/filters/src/flate.rs +++ b/crates/filters/src/flate.rs @@ -26,7 +26,12 @@ pub(crate) fn decode(data: &[u8], max: usize) -> Step { Some((_, flg)) if flg & 0x20 != 0 => Err(Failure::corrupt(NAME, "zlib preset dictionary is not allowed in PDF", Vec::new())), Some(_) => match inflate_raw(&data[2..], max) { // A header that only looked valid: retry the whole input as raw deflate. - Err(f) if f.partial.is_empty() && matches!(f.error, crate::FilterError::Corrupt { .. }) => inflate_raw(data, max).map_err(|_| f), + Err(f) if f.partial.is_empty() && matches!(f.error, crate::FilterError::Corrupt { .. }) => match inflate_raw(data, max) { + // Keep the original corruption diagnostic only if the retry is corrupt + // too. A limit error must remain fatal even for tolerant decoding. + Err(retry) if matches!(retry.error, crate::FilterError::Corrupt { .. }) => Err(f), + retry => retry, + }, r => r, }, None => inflate_raw(data, max), diff --git a/crates/filters/src/lib.rs b/crates/filters/src/lib.rs index ffaf08ca1..9a0124d85 100644 --- a/crates/filters/src/lib.rs +++ b/crates/filters/src/lib.rs @@ -295,6 +295,26 @@ mod tests { assert_eq!(decode(&chain, &hex, 100).unwrap().len(), 100); } + #[test] + fn raw_flate_fallback_preserves_output_limits() { + // RFC 1951 stored blocks: the first is nonfinal with LEN=257 and + // NLEN=!257, followed by a final empty block. Its ignored alignment + // bits make the first two bytes also look like a valid zlib header. + let data = vec![b'A'; 257]; + let mut raw = vec![0x78, 0x01, 0x01, 0xfe, 0xfe]; + raw.extend_from_slice(&data); + raw.extend_from_slice(&[0x01, 0x00, 0x00, 0xff, 0xff]); + let chain = [(Filter::Flate, Params::default())]; + for max in [0, 1, 128, 256] { + assert!(matches!(decode(&chain, &raw, max), Err(FilterError::LimitExceeded(m)) if m == max)); + assert!(matches!(decode_tolerant(&chain, &raw, max), Err(FilterError::LimitExceeded(m)) if m == max)); + } + for max in [257, 258] { + assert_eq!(decode(&chain, &raw, max).unwrap(), data); + assert_eq!(decode_tolerant(&chain, &raw, max).unwrap(), (data.clone(), false)); + } + } + #[test] fn tolerant_flate_returns_partial() { let data: Vec = (0..20_000u32).map(|i| (i % 97) as u8 ^ (i / 300) as u8).collect(); 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/embedding.rs b/crates/fonts/src/embedding.rs index 84801d5fd..a5bb011e0 100644 --- a/crates/fonts/src/embedding.rs +++ b/crates/fonts/src/embedding.rs @@ -195,7 +195,7 @@ pub fn embed_mapping( // page; source subset programs stay untouched. let existing = written(doc, fonts).find(|w| w.has_program(doc, bytes)).map(|w| w.program).or_else(|| { doc.scan_objects().take(8192).find_map(|(r, o)| match o.as_ref() { - Object::Stream(s) if s.dict.get(b"PCFullFont").is_some() && s.raw.as_ref() == bytes => Some(Object::Ref(r)), + Object::Stream(s) if is_program(doc, s, bytes) => Some(Object::Ref(r)), _ => None, }) }); @@ -206,7 +206,7 @@ pub fn embed_mapping( d.set(b"Subtype".to_vec(), Object::name("OpenType")); } d.set(b"PCFullFont".to_vec(), Object::Bool(true)); - Object::Ref(doc.add(Object::Stream(Stream::from_raw(d, bytes.to_vec())))) + Object::Ref(doc.add(Object::Stream(Stream::flate(d, bytes)))) }); descriptor.set(if cff { b"FontFile3".to_vec() } else { b"FontFile2".to_vec() }, program); let desc = Object::Ref(doc.add(Object::Dict(descriptor))); @@ -264,15 +264,20 @@ struct Written { } impl Written { - /// Whether its program is `bytes`. The length it was written with is compared before decoding. + /// Whether its program is `bytes`. fn has_program(&self, doc: &Document, bytes: &[u8]) -> bool { - let Object::Stream(s) = doc.resolve(&self.program).as_ref().clone() else { return false }; - s.dict.get(b"PCFullFont").is_some() - && s.dict.get(b"Length1").and_then(|l| doc.resolve(l).as_int()) == i64::try_from(bytes.len()).ok() - && s.decoded_within(17 << 20).is_ok_and(|data| data == bytes) + matches!(doc.resolve(&self.program).as_ref(), Object::Stream(s) if is_program(doc, s, bytes)) } } +/// Whether `s` is a full program written for `bytes`, compressed or not. The length it was +/// written with is compared before decoding. +fn is_program(doc: &Document, s: &Stream, bytes: &[u8]) -> bool { + s.dict.get(b"PCFullFont").is_some() + && s.dict.get(b"Length1").and_then(|l| doc.resolve(l).as_int()) == i64::try_from(bytes.len()).ok() + && s.decoded_within(17 << 20).is_ok_and(|data| data == bytes) +} + /// The `PCFull` resources in `fonts`, in order. fn written<'a>(doc: &'a Document, fonts: &'a Dict) -> impl Iterator + 'a { fonts.iter().filter(|(key, _)| key.starts_with(b"PCFull")).take(256).filter_map(move |(key, value)| { @@ -379,4 +384,31 @@ mod tests { assert!(!fonts.contains(medium.as_bytes()), "{medium} is new"); assert_eq!(programs(&doc), 2); } + + #[test] + fn the_full_program_is_written_compressed_and_another_page_reuses_it() { + let bytes = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); + let mut doc = Document::new_empty(); + let mut first = Dict::new(); + embed(&mut doc, &mut first, bytes, "Inter").unwrap(); + let program = || { + doc.scan_objects() + .find_map(|(_, o)| match o.as_ref() { + Object::Stream(s) if s.dict.get(b"PCFullFont").is_some() => Some(s.clone()), + _ => None, + }) + .unwrap() + }; + let stream = program(); + assert_eq!(stream.dict.name(b"Filter"), Some(b"FlateDecode".as_slice())); + assert!(stream.raw.len() < bytes.len(), "{} of {} bytes", stream.raw.len(), bytes.len()); + assert_eq!(stream.decoded().unwrap(), bytes); + // Another page's resources don't have it, so it is found among the document's objects. + let objects = doc.scan_objects().count(); + let mut second = Dict::new(); + embed(&mut doc, &mut second, bytes, "Inter").unwrap(); + let programs = doc.scan_objects().filter(|(_, o)| matches!(o.as_ref(), Object::Stream(s) if s.dict.get(b"PCFullFont").is_some())).count(); + assert_eq!(programs, 1, "one program for both pages"); + assert!(doc.scan_objects().count() > objects, "the second page gets its own resource"); + } } diff --git a/crates/fonts/src/encodings.rs b/crates/fonts/src/encodings.rs index 98666802b..88f0de5d1 100644 --- a/crates/fonts/src/encodings.rs +++ b/crates/fonts/src/encodings.rs @@ -5,11 +5,13 @@ pub(crate) static WIN_ANSI: [u32; 256] = [ 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 0, 8364, 0, 8218, 402, 8222, 8230, 8224, 8225, 710, 8240, 352, 8249, 338, - 0, 381, 0, 0, 8216, 8217, 8220, 8221, 8226, 8211, 8212, 732, 8482, 353, 8250, 339, 0, 382, 376, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, - // 0xAD is `hyphen`, not U+00AD: WinAnsiEncoding's table (Annex D.2) gives codes 0x2D and 0xAD - // the same glyph name, so the byte a Windows codepage calls a soft hyphen draws, and extracts - // as, an ordinary hyphen. Emitting U+00AD instead hides the character from search and from - // redaction's pattern matching, because a soft hyphen is invisible and matches nothing. + // 0xA0 is `space` and 0xAD is `hyphen`, not U+00A0 and U+00AD: WinAnsiEncoding's table + // (Annex D.2) gives those codes the same glyph names as 0x20 and 0x2D, so the bytes a Windows + // codepage calls a non-breaking space and a soft hyphen draw, and extract as, an ordinary + // space and hyphen. Emitting the formatting characters instead hides them from search and + // from redaction's pattern matching: a soft hyphen is invisible and matches nothing, and a + // non-breaking space does not match the space a reader typed. + 0, 381, 0, 0, 8216, 8217, 8220, 8221, 8226, 8211, 8212, 732, 8482, 353, 8250, 339, 0, 382, 376, 32, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 45, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255, @@ -21,7 +23,8 @@ pub(crate) static MAC_ROMAN: [u32; 256] = [ 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 0, 196, 197, 199, 201, 209, 214, 220, 225, 224, 226, 228, 227, 229, 231, 233, 232, 234, 235, 237, 236, 238, 239, 241, 243, 242, 244, 246, 245, 250, 249, 251, 252, 8224, 176, 162, 163, 167, 8226, 182, 223, 174, 169, 8482, 180, 168, 8800, 198, 216, 8734, 177, 8804, 8805, 165, 181, 8706, 8721, 8719, 960, 8747, 170, 186, 937, 230, 248, 191, 161, 172, 8730, 402, - 8776, 8710, 171, 187, 8230, 160, 192, 195, 213, 338, 339, 8211, 8212, 8220, 8221, 8216, 8217, 247, 9674, 255, 376, 8260, 8364, 8249, 8250, 64257, + // 0xCA is `space`, the same glyph name Annex D.2 gives 0x20, for the reason WinAnsi 0xA0 is. + 8776, 8710, 171, 187, 8230, 32, 192, 195, 213, 338, 339, 8211, 8212, 8220, 8221, 8216, 8217, 247, 9674, 255, 376, 8260, 8364, 8249, 8250, 64257, 64258, 8225, 183, 8218, 8222, 8240, 194, 202, 193, 203, 200, 205, 206, 207, 204, 211, 212, 63743, 210, 218, 219, 217, 305, 710, 732, 175, 728, 729, 730, 184, 733, 731, 711, ]; diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 73b4fbb08..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,15 +90,22 @@ 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; for word in para.split(' ') { - let candidate = if line.is_empty() { word.to_string() } else { format!("{line} {word}") }; - if measure(&candidate, size) <= width || line.is_empty() && measure(word, size) <= width { + let candidate = if fresh { word.to_string() } else { format!("{line} {word}") }; + if measure(&candidate, size) <= width || fresh && measure(word, size) <= width { line = candidate; + fresh = false; continue; } - if !line.is_empty() { + // Indentation alone is not a line: drop it before an over-long word. + if line.trim().is_empty() { + line.clear(); + } else { lines.push(std::mem::take(&mut line)); } for ch in word.chars() { @@ -95,6 +114,7 @@ pub fn wrap_with(text: &str, size: f64, width: f64, measure: impl Fn(&str, f64) } line.push(ch); } + fresh = line.is_empty(); } lines.push(line); } @@ -256,6 +276,29 @@ pub fn literal(bytes: &[u8]) -> Vec { mod tests { use super::*; + #[test] + fn wrap_keeps_leading_spaces() { + assert_eq!(wrap(" indented", 11.0, 500.0), [" indented"]); + assert_eq!(wrap(" a b\n c", 11.0, 500.0), [" a b", " c"]); + assert_eq!(wrap("", 11.0, 500.0), [""]); + // A single leading space before a word that must be broken leaves no blank first line. + let broken = wrap(" abcdefghijklmnopqrstuvwxyz", 11.0, 40.0); + assert!(broken.len() > 1 && broken.iter().all(|l| !l.is_empty()), "{broken:?}"); + // So does indentation of two or more spaces: no whitespace-only line, same as one space. + let broken = wrap(" abcdefghijklmnopqrstuvwxyz", 11.0, 40.0); + assert!(broken.len() > 1 && broken.iter().all(|l| !l.trim().is_empty()), "{broken:?}"); + 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', '?'] { @@ -292,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); @@ -305,31 +356,45 @@ mod tests { assert_eq!(literal(b"a(b)\\c"), b"(a\\(b\\)\\\\c)"); } - /// WinAnsiEncoding's 0xAD is the `hyphen` glyph, not U+00AD. + /// WinAnsiEncoding's 0xAD is the `hyphen` glyph and its 0xA0 is `space`, not U+00AD and + /// U+00A0; MacRomanEncoding's 0xCA is `space` too. /// - /// Annex D.2 gives codes 0x2D and 0xAD the same glyph name, so the byte a Windows codepage - /// calls a soft hyphen draws an ordinary hyphen and must extract as one. Reading it as U+00AD - /// hid the character: a soft hyphen is invisible, so a search for "well-known" missed text - /// that drew exactly that, and `redact`'s pattern matching missed it too. One corpus fixture - /// alone extracted 104 of them. + /// Annex D.2 gives each of those codes the same glyph name as the ordinary one (0x2D, 0x20), + /// so the bytes a Windows codepage calls a soft hyphen and a non-breaking space draw ordinary + /// ink and must extract as it. Reading them as the formatting characters hid them: a soft + /// hyphen is invisible, so a search for "well-known" missed text that drew exactly that, and + /// `redact`'s pattern matching missed it too; a non-breaking space does not match the space a + /// reader types. Over the 989-file pdf.js corpus this takes the soft hyphens we extract from + /// 117 to 13 (104 of them in `XiaoBiaoSong.pdf` alone) and the non-breaking spaces from 30 to + /// 26; what remains comes from `/ToUnicode` maps, which say so explicitly and are not touched. + /// pdftotext reads both codes as the ordinary character. #[test] - fn win_ansi_soft_hyphen_byte_reads_as_an_ordinary_hyphen() { + fn the_alias_codes_read_as_the_glyph_they_draw() { assert_eq!(encodings::WIN_ANSI.get(0xAD), Some(&u32::from('-'))); assert_eq!(encodings::WIN_ANSI.get(0x2D), Some(&u32::from('-'))); - // Writing is unaffected: '-' takes the canonical low code, and a caller holding a literal - // U+00AD still writes the 0xAD byte, which is the one that draws a hyphen. + assert_eq!(encodings::WIN_ANSI.get(0xA0), Some(&u32::from(' '))); + assert_eq!(encodings::WIN_ANSI.get(0x20), Some(&u32::from(' '))); + assert_eq!(encodings::MAC_ROMAN.get(0xCA), Some(&u32::from(' '))); + assert_eq!(encodings::MAC_ROMAN.get(0x20), Some(&u32::from(' '))); + // MacRoman has no soft hyphen; 0xAD there is `notequal`, and keeps it. + assert_eq!(encodings::MAC_ROMAN.get(0xAD), Some(&0x2260)); + // Writing is unaffected: '-' and ' ' take the canonical low codes, and a caller holding a + // literal U+00AD or U+00A0 still writes the high byte, which is the one that draws them. assert_eq!(win_ansi_byte('-'), Some(0x2D)); + assert_eq!(win_ansi_byte(' '), Some(0x20)); assert_eq!(win_ansi_byte('\u{00ad}'), Some(0xAD)); - assert_eq!(win_ansi("a-b"), b"a-b"); + assert_eq!(win_ansi_byte('\u{00a0}'), Some(0xA0)); + assert_eq!(win_ansi("a-b c"), b"a-b c"); } #[test] fn win_ansi_covers_the_0x80_to_0x9f_glyphs() { // Every character in the WinAnsiEncoding table encodes to its code and back (#347), except - // where two codes share one glyph: 0xAD is `hyphen`, the same glyph as 0x2D, so it decodes - // to '-' and '-' encodes back to the canonical 0x2D. Round-tripping the *code* is what + // where two codes share one glyph: 0xAD is `hyphen`, the same glyph as 0x2D, and 0xA0 is + // `space`, the same glyph as 0x20, so each decodes to the ordinary character and that + // character encodes back to the canonical low code. Round-tripping the *code* is what // cannot hold there; round-tripping the character still does. - const ALIASES: &[u8] = &[0xAD]; + const ALIASES: &[u8] = &[0xA0, 0xAD]; for (code, &u) in encodings::WIN_ANSI.iter().enumerate() { let Some(c) = char::from_u32(u).filter(|_| u != 0) else { continue }; if ALIASES.contains(&(code as u8)) { diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 3ad34eead..0aaed6939 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -1,8 +1,8 @@ //! What content editing and redaction need from a PDF font: how to split a string into character //! codes, each code's advance width, the glyph height (ascent, descent), and what each code means //! (Unicode) — and back, which text the font can show. Widths come from the font dictionary -//! (`/Widths`, `/W`, `/DW`, `/MissingWidth`); a standard-14 font with no `/Widths` uses the exact -//! published metrics ([`crate::Std14`]). +//! (`/Widths`, `/W`, `/DW`, `/MissingWidth`); a simple font with no `/Widths` uses its embedded +//! sfnt program's advances, else the exact published standard-14 metrics ([`crate::Std14`]). //! //! Meanings come from `/ToUnicode`, else the encoding (`/Encoding` base and `/Differences` glyph //! names, ISO 32000-2 Annex D). A code may deliberately have **no** meaning: where the font's own @@ -167,6 +167,44 @@ fn cids_with_outlines(doc: &Document, font: &Dict) -> Vec { } } +/// A simple font with no `/Widths` is drawn (and found by text search) with its embedded +/// program's own advances, so measure with them too; a standard-14 guess from the name can be +/// wider and leave the end of a line outside a redaction mark (#2130). The glyph is looked up +/// the way the renderer does: the encoding's character in the Unicode cmap, else the code itself +/// (as in a symbolic cmap), else `.notdef`. Only sfnt programs are read; others keep the guess. +fn embedded_widths(doc: &Document, font: &Dict, m: &mut Metrics) { + use skrifa::instance::{LocationRef, Size}; + use skrifa::{GlyphId, MetadataProvider as _, raw::TableProvider as _}; + let Some(descriptor) = dict(doc, font.get(b"FontDescriptor")) else { return }; + for key in [b"FontFile2".as_slice(), b"FontFile3".as_slice()] { + let Some(object) = descriptor.get(key) else { continue }; + let object = doc.resolve(object); + let Object::Stream(stream) = object.as_ref() else { continue }; + let Ok(bytes) = stream.decoded_within(16 << 20) else { continue }; + let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { continue }; + let upem = f64::from(face.metrics(Size::unscaled(), LocationRef::default()).units_per_em); + if upem == 0.0 { + continue; + } + let advances = face.glyph_metrics(Size::unscaled(), LocationRef::default()); + let (charmap, cmap) = (face.charmap(), face.cmap().ok()); + let found = |g: Option| g.filter(|g| *g != GlyphId::NOTDEF); + m.widths = (0..=255u32) + .map(|code| { + let glyph = found(m.unicode.encoding.get(&code).and_then(|t| only_char(t)).and_then(|c| charmap.map(c))) + .or_else(|| [0, 0xF000, 0xF100, 0xF200].into_iter().find_map(|o| found(cmap.as_ref()?.map_codepoint(code + o)))); + // A subset holds only the glyphs it draws: 0 keeps `has_glyph` false for the rest. + match glyph { + None if m.subset => 0.0, + _ => advances.advance_width(glyph.unwrap_or(GlyphId::NOTDEF)).map_or(0.0, |w| f64::from(w) * 1000.0 / upem), + } + }) + .collect(); + m.first = 0; + return; + } +} + /// Replace an existing cmap directory entry with a bounded Unicode format-12 table. /// Checked ranges prevent damaged sfnt offsets from indexing outside the source program. fn append_editor_cmap(bytes: &mut Vec, mappings: &std::collections::BTreeMap) -> Option<()> { @@ -607,6 +645,9 @@ impl Metrics { Codes::Ranges(r) => r.last().map_or(2, |x| x.0), }; m.unicode = unicode_map(doc, font, m.composite); + if m.std14.is_some() { + embedded_widths(doc, font, &mut m); + } let mut reverse = HashMap::new(); for (&code, text) in m.unicode.explicit.iter().map(|(c, (_, t))| (c, t)).chain(m.unicode.encoding.iter()) { if let Some(ch) = only_char(text) @@ -766,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, }, @@ -774,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 @@ -798,6 +850,11 @@ impl Metrics { fn has_glyph(&self, code: u32) -> bool { if self.composite { let cid = self.cid(code); + // CID 0 is `.notdef` (an unmapped code lands there too), whatever ToUnicode says: Word + // maps it to a space and gives it a width, and typing with it draws boxes (#3223). + if cid == 0 { + return false; + } // A CID /W leaves out takes /DW, and Skia (Chrome) leaves out every CID of that width: for // those, only the subset's program says whether the glyph is there. return self.cid_widths.contains_key(&cid) @@ -1097,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 { @@ -1160,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}; @@ -1218,6 +1306,37 @@ mod tests { assert!(embedded_sfnt(&doc, &Dict::new()).is_none()); } + /// #2130: with no `/Widths`, an embedded program's advances win over a guess from the name + /// ("Mono" would mean Courier, 600 units for every glyph). + #[test] + fn a_font_without_widths_measures_with_its_embedded_program() { + use pdfcraft_cos::Stream; + use skrifa::MetadataProvider as _; + let mut doc = Document::new_empty(); + let bytes = include_bytes!("../../../assets/fonts/Inter-Regular.ttf"); + let face = skrifa::FontRef::new(bytes).unwrap(); + let (size, location) = (skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()); + let upem = f64::from(face.metrics(size, location).units_per_em); + let advance = |c: char| f64::from(face.glyph_metrics(size, location).advance_width(face.charmap().map(c).unwrap()).unwrap()) / upem; + let program = doc.add(Object::Stream(Stream::flate(Dict::new(), bytes))); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("TrueType")), + ("BaseFont", Object::name("HouseMono")), + ("Encoding", Object::name("WinAnsiEncoding")), + ("FontDescriptor", Object::Dict(descriptor)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + for c in ['i', 'l', 'W', ' '] { + assert!((m.width(u32::from(c)) - advance(c)).abs() < 1e-9, "{c}: {} vs {}", m.width(u32::from(c)), advance(c)); + } + assert!(m.width(u32::from('i')) < 0.4, "not Courier's 0.6"); + } + #[test] fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() { use skrifa::{MetadataProvider as _, raw::TableProvider as _}; @@ -1325,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(); @@ -1384,6 +1534,69 @@ mod tests { assert_eq!(m.encode("Z"), None); } + /// Word's Type 0 subsets map CID 0 as well as the real space glyph to U+0020 and give CID 0 a + /// width; CID 0 is `.notdef`, so a typed space takes the real one (#3223). + #[test] + fn composite_fonts_never_encode_with_cid_0() { + let mut doc = Document::new_empty(); + let cmap = b"/CIDInit /ProcSet findresource begin 1 begincodespacerange <0000> endcodespacerange \ + 1 beginbfrange <0000> <0002> [<0020> <004D> <00E1>] endbfrange \ + 1 beginbfrange <0005> <0007> [<0065> <0072> <0020>] endbfrange end"; + let tu = doc.add(Object::Stream(Stream::from_raw(Dict::new(), cmap.to_vec()))); + let mut desc = Dict::new(); + desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2")); + let widths = [889, 444, 389, 278, 444, 333, 250].map(Object::Int).to_vec(); + desc.set(b"W".to_vec(), Object::Array(vec![Object::Int(0), Object::Array(vec![Object::Int(778)]), Object::Int(1), Object::Array(widths)])); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("Type0")), + ("BaseFont", Object::name("BCDEEE+TimesNewRomanPSMT")), + ("Encoding", Object::name("Identity-H")), + ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])), + ("ToUnicode", Object::Ref(tu)), + ], + ); + let m = Metrics::from_dict(&doc, &f); + assert_eq!(m.encode("Me er").as_deref(), Some(&[0, 1, 0, 5, 0, 7, 0, 5, 0, 6][..])); + 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] @@ -1682,7 +1895,8 @@ mod tests { assert_eq!(got, expected.get(&code).cloned(), "code {code:#06x} in {cmap}"); } let mut reverse: HashMap = HashMap::new(); - for (&code, text) in &expected { + // Code 0 is CID 0, `.notdef`, which no character is encoded with. + for (&code, text) in expected.iter().filter(|&(&code, _)| code != 0) { if let Some(c) = only_char(text) { keep(&mut reverse, c, code); } @@ -1863,6 +2077,32 @@ mod encoding_tests { } } + /// The Annex D.2 alias codes read, through the whole decode path, as the ink they draw. + /// + /// This is the map `redact` matches its patterns on (`unicode_map`), so a code that decodes + /// to an invisible formatting character is a silent miss, not a cosmetic one: "well-known" + /// written with the 0xAD byte matched no search and no redaction pattern. + #[test] + fn the_alias_codes_decode_as_ordinary_ink_and_encode_back_to_the_low_code() { + let (doc, d) = simple("Helvetica", None, Some(Object::name("WinAnsiEncoding"))); + let m = Metrics::from_dict(&doc, &d); + assert_eq!(m.decode(b"well\xadknown"), "well-known"); + assert_eq!(m.decode(b"a\xa0b"), "a b"); + assert_eq!(m.encode("well-known a b").as_deref(), Some(&b"well-known a b"[..]), "the low code is the one written"); + + let (doc, d) = simple("Helvetica", None, Some(Object::name("MacRomanEncoding"))); + let m = Metrics::from_dict(&doc, &d); + assert_eq!(m.decode(b"a\xcab"), "a b"); + assert_eq!(m.decode(b"a\xadb"), "a\u{2260}b", "MacRoman 0xAD is `notequal`, not a hyphen"); + assert_eq!(m.encode("a b").as_deref(), Some(&b"a b"[..])); + + // An explicit /Differences name still says what it says: this is a property of those + // codes in those tables, not of the glyph names. + let (doc, d) = simple("Helvetica", None, Some(differences(vec![Object::Int(0x41), Object::name("sfthyphen"), Object::name("nbspace")]))); + let m = Metrics::from_dict(&doc, &d); + assert_eq!(m.decode(b"AB"), "\u{ad}\u{a0}"); + } + /// Pins a deliberate restraint, so it is not "fixed" into a regression: a great many ordinary /// Latin TrueType subsets set the `/FontDescriptor` symbolic flag and still rely on a Latin /// encoding. Suppressing the implicit table on the flag would lose real text on a large share 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/Cargo.toml b/crates/forms/Cargo.toml index 3f085ee6c..fcecbb5f8 100644 --- a/crates/forms/Cargo.toml +++ b/crates/forms/Cargo.toml @@ -17,3 +17,4 @@ thiserror = { workspace = true } [dev-dependencies] hayro-syntax = { workspace = true } +proptest = { workspace = true } diff --git a/crates/forms/README.md b/crates/forms/README.md index 3280cc243..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 @@ -56,6 +72,28 @@ delete_field(&mut doc, "email")?; Button1, Date1, Signature1; `/Helv` is added to `/DR`; appearances are drawn at once. Radio buttons joining a group become kid widgets with their own export state. Renaming changes only the last part of a dotted name. +- Renaming a field renames it in every Calculate script that reads it + (`af::rename_in_calculation`): `AFSimple_Calculate` field lists, simplified field notation + (the `BVCALC` comment and its translation), and `getField` calls whose argument is the name as + one string literal. The rest of each script is kept as written. Names built at run time, a + 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 + just that widget, copying shared/indirect `/MK` and appearance dictionaries before editing. + Other widgets, actions, icons, rotation and unknown entries are preserved. Existing icon-only + layouts keep hiding the caption; caption rendering retains the existing Helvetica/WinAnsi + limits and reports unavailable characters as above. ## Not yet diff --git a/crates/forms/src/af.rs b/crates/forms/src/af.rs index b50ed4c1e..39e3b8a6d 100644 --- a/crates/forms/src/af.rs +++ b/crates/forms/src/af.rs @@ -9,7 +9,9 @@ //! writes them back when a form is authored. Other scripts are kept untouched and reported as //! unsupported; they need the JavaScript engine (M6.4). +use std::collections::VecDeque; use std::fmt::Write as _; +use std::ops::Range; /// The Format tab. #[derive(Clone, Debug, PartialEq, Default)] @@ -195,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; @@ -452,6 +466,278 @@ pub fn calculate_js(c: &Calculate) -> Option { } } +// ── renaming fields in calculations ───────────────────────────────────────────────────────── + +/// A JavaScript token, as far as finding the field names a calculation reads needs. +#[derive(Clone, Debug, PartialEq)] +enum Js { + Ident, + /// A string literal, its escapes undone. + Str(String), + Punct(char), + /// A number, a regular expression or a template literal. + Other, +} + +/// Words after which `/` starts a regular expression instead of dividing. +const BEFORE_REGEX: [&str; 13] = ["return", "typeof", "case", "do", "else", "in", "of", "new", "delete", "void", "throw", "instanceof", "yield"]; + +/// JavaScript's tokens with their byte ranges, comments left out: enough of the language to tell +/// code from strings, comments and regular expressions. The tokens stop where a string, comment +/// or template literal never ends. +fn js_tokens(js: &str) -> Vec<(Range, Js)> { + let s = js.as_bytes(); + // Bytes past ASCII are parts of identifiers, so a range never ends inside a character. + let word = |b: u8| b.is_ascii_alphanumeric() || b == b'_' || b == b'$' || !b.is_ascii(); + let mut out: Vec<(Range, Js)> = Vec::new(); + let mut i = 0; + // Where a regular expression that never ended stopped being read: every `/` before it + // divides, so a line of them is read once, not once for each. + let mut divides_until = 0; + 'scan: while let Some(&c) = s.get(i) { + let start = i; + let token = match c { + _ if c.is_ascii_whitespace() => { + i += 1; + continue; + } + b'/' if s.get(i + 1) == Some(&b'/') => { + i = s.iter().skip(i).position(|b| *b == b'\n').map_or(s.len(), |n| i + n); + continue; + } + b'/' if s.get(i + 1) == Some(&b'*') => match js.get(i + 2..).and_then(|rest| rest.find("*/")) { + Some(n) => { + i += n + 4; + continue; + } + None => break 'scan, + }, + b'"' | b'\'' => { + let mut p = Parser { s, i, depth: 0 }; + let Some(text) = p.string() else { break 'scan }; + i = p.i; + Js::Str(text) + } + b'`' => { + let mut j = i + 1; + loop { + match s.get(j) { + None => break 'scan, + Some(b'\\') => j += 2, + Some(b'`') => break, + Some(_) => j += 1, + } + } + i = j + 1; + Js::Other + } + b'/' if i >= divides_until && regex_may_follow(js, &out) => { + // A regular expression ends at a `/` outside a class; a line break first means it + // was a division after all. + let (mut j, mut class) = (i + 1, false); + let end = loop { + match s.get(j) { + None | Some(b'\n') => break None, + Some(b'\\') => j += 2, + Some(b'/') if !class => break Some(j), + Some(b) => { + class = match b { + b'[' => true, + b']' => false, + _ => class, + }; + j += 1; + } + } + }; + match end { + Some(e) => { + i = e + 1; + while s.get(i).is_some_and(|b| word(*b)) { + i += 1; + } + Js::Other + } + None => { + divides_until = j; + i += 1; + Js::Punct('/') + } + } + } + _ if c.is_ascii_digit() => { + while s.get(i).is_some_and(|b| word(*b) || *b == b'.') { + i += 1; + } + Js::Other + } + _ if word(c) => { + while s.get(i).is_some_and(|b| word(*b)) { + i += 1; + } + Js::Ident + } + _ => { + i += 1; + Js::Punct(char::from(c)) + } + }; + out.push((start..i, token)); + } + out +} + +/// Whether a `/` after `tokens` starts a regular expression: after an operator, an opening +/// bracket or a keyword such as `return`, not after a value. +fn regex_may_follow(js: &str, tokens: &[(Range, Js)]) -> bool { + match tokens.last() { + None => true, + Some((range, Js::Ident)) => js.get(range.clone()).is_some_and(|w| BEFORE_REGEX.contains(&w)), + Some((_, Js::Punct(c))) => !matches!(c, ')' | ']' | '}'), + Some((_, Js::Str(_) | Js::Other)) => false, + } +} + +/// A place where a Calculate script names a field. +#[derive(Clone, Debug, PartialEq)] +enum Site { + /// A string literal holding one name: `getField("a")`, or an item of an + /// `AFSimple_Calculate` list. + Literal(String), + /// An `AFSimple_Calculate` list written as one string of comma-separated names. + Commas(String), + /// A name in the simplified field notation of a `BVCALC` comment. + Notation(String), +} + +impl Site { + fn names(&self) -> Vec<&str> { + match self { + Site::Literal(name) | Site::Notation(name) => vec![name.as_str()], + Site::Commas(list) => list.split(',').map(str::trim).filter(|n| !n.is_empty()).collect(), + } + } + + /// The site written again with field `old` named `new`, or `None` when it doesn't name `old`. + fn renamed(&self, old: &str, new: &str) -> Option { + match self { + Site::Literal(name) => (name == old).then(|| js_str(new)), + Site::Notation(name) => (name == old).then(|| notation_name(new)), + Site::Commas(list) => { + let parts: Vec<&str> = list.split(',').collect(); + parts.iter().any(|p| p.trim() == old).then(|| { + // The spaces around each name stay as they were. + let parts: Vec = parts.iter().map(|p| if p.trim() == old { p.replacen(old, new, 1) } else { (*p).to_owned() }).collect(); + js_str(&parts.join(",")) + }) + } + } + } +} + +/// Where a Calculate script names fields, in order: the simplified field notation between +/// `BVCALC` and `EVCALC` (where [`parse_calculate`] reads it), the field list of +/// `AFSimple_Calculate`, and every `getField` whose whole argument is one string literal. +/// A name built at run time (`getField("row" + i)`) is not a site. +fn calculation_sites(js: &str) -> Vec<(Range, Site)> { + let mut sites = Vec::new(); + if let (Some(a), Some(b)) = (js.find("BVCALC"), js.find("EVCALC")) + && let Some(expr) = js.get(a + 6..b) + { + for (range, token) in notation_tokens(expr) { + if let Notation::Name(name) = token { + sites.push((a + 6 + range.start..a + 6 + range.end, Site::Notation(name))); + } + } + } + let tokens = js_tokens(js); + let ident = |k: usize, word: &str| tokens.get(k).is_some_and(|(range, t)| *t == Js::Ident && js.get(range.clone()) == Some(word)); + let punct = |k: usize, c: char| tokens.get(k).is_some_and(|(_, t)| *t == Js::Punct(c)); + let string = |k: usize| match tokens.get(k) { + Some((range, Js::Str(text))) => Some((range.clone(), text.clone())), + _ => None, + }; + for k in 0..tokens.len() { + if ident(k, "getField") + && punct(k + 1, '(') + && punct(k + 3, ')') + && let Some((range, name)) = string(k + 2) + { + sites.push((range, Site::Literal(name))); + } + if !(ident(k, "AFSimple_Calculate") && punct(k + 1, '(') && string(k + 2).is_some() && punct(k + 3, ',')) { + continue; + } + if let Some((range, list)) = string(k + 4) { + sites.push((range, Site::Commas(list))); + continue; + } + // `new Array("a", "b")` or `["a", "b"]`: its items, up to the first that isn't a string. + // Nothing else is read, so no token is read for two calls. + let first = if punct(k + 4, '[') { + k + 5 + } else if ident(k + 4, "new") && ident(k + 5, "Array") && punct(k + 6, '(') { + k + 7 + } else { + continue; + }; + for (range, token) in tokens.iter().skip(first) { + match token { + Js::Str(name) => sites.push((range.clone(), Site::Literal(name.clone()))), + Js::Punct(',') => {} + _ => break, + } + } + } + sites.sort_by_key(|(range, _)| range.start); + sites +} + +/// A Calculate script with field `old` named `new` everywhere it reads it (see +/// [`calculation_sites`]), the rest kept as written; `None` when it doesn't read `old`. Also +/// `None` when the new name would read as more than a name there (one holding `EVCALC` would end +/// the notation early), so a script is never left meaning something else. +pub fn rename_in_calculation(js: &str, old: &str, new: &str) -> Option { + let sites = calculation_sites(js); + let edits: Vec<(Range, String)> = sites.iter().filter_map(|(range, site)| Some((range.clone(), site.renamed(old, new)?))).collect(); + if edits.is_empty() { + return None; + } + let out = splice(js, edits); + let expected: Vec<&str> = sites.iter().flat_map(|(_, site)| site.names()).map(|n| if n == old { new } else { n }).collect(); + let written = calculation_sites(&out); + let got: Vec<&str> = written.iter().flat_map(|(_, site)| site.names()).collect(); + (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 @@ -545,24 +831,55 @@ fn weekday(y: i32, m: u32, d: u32) -> usize { (y + y / 4 - y / 100 + y / 400 + t + i64::from(d)).rem_euclid(7) as usize } -/// The order of the year/month/day fields in a format, as letters. A run of literal digits -/// (the fixed year in "dd.mm.26") is `'#'` with its value; the others carry 0. -fn order(fmt: &str) -> Vec<(char, u32)> { - let mut out: Vec<(char, u32)> = Vec::new(); +/// One field of a date format, in the order the format has them. +struct Part { + /// `'y'`, `'m'`, `'d'`, `'H'`, `'M'` or `'s'`; a run of literal digits (the fixed year in + /// "dd.mm.26") is `'#'`. + kind: char, + /// The value of a `'#'` run; 0 for the others. + literal: u32, + /// The digits the field takes when typed run together with the next one: 4 for "yyyy", 2 for + /// "yy", "mm", "dd", "HH", "MM" and "ss". 0 when the width varies ("m", "d") or the field is + /// a name ("mmm"). + width: usize, + /// The next field follows with nothing between them ("yyyymmdd"), so one run of typed digits + /// can hold both. + joined: bool, +} + +/// The fields of a format (year, month, day, hours, minutes, seconds and literal digits), in +/// order. +fn order(fmt: &str) -> Vec { + let mut out: Vec = Vec::new(); // Weekday names (ddd, dddd) are shown, not typed: leave them out. let mut cleaned = fmt.replace("dddd", " "); cleaned = cleaned.replace("ddd", " "); let mut prev = ' '; + // The letter of the last field's current run and its length so far. + let mut run: Option<(char, usize)> = None; + let fixed = |k: char, n: usize| match (k, n) { + ('y', 2 | 4) | ('m' | 'd' | 'H' | 'M' | 's', 2) => n, + _ => 0, + }; for c in cleaned.chars() { + let continues = run.is_some_and(|(r, _)| r == c); + if let Some((r, n)) = run + && !continues + { + // The run ended; it was the last field's width. + if let Some(last) = out.last_mut().filter(|p| p.kind != '#') { + last.width = fixed(if r == 'h' { 'H' } else { r }, n); + } + run = None; + } if let Some(digit) = c.to_digit(10) { match out.last_mut() { - Some(('#', n)) if prev.is_ascii_digit() => *n = n.saturating_mul(10).saturating_add(digit), - _ => out.push(('#', digit)), + Some(p) if p.kind == '#' && prev.is_ascii_digit() => p.literal = p.literal.saturating_mul(10).saturating_add(digit), + _ => out.push(Part { kind: '#', literal: digit, width: 0, joined: false }), } prev = c; continue; } - prev = c; let k = match c { 'y' => 'y', 'm' => 'm', @@ -570,11 +887,28 @@ fn order(fmt: &str) -> Vec<(char, u32)> { 'H' | 'h' => 'H', 'M' => 'M', 's' => 's', - _ => continue, + _ => { + prev = c; + continue; + } }; - if out.last().map(|&(l, _)| l) != Some(k) { - out.push((k, 0)); + if continues { + run = run.map(|(r, n)| (r, n.saturating_add(1))); + } else { + // A field starting right where the last one's letters stopped is joined to it. + let adjacent = matches!(prev, 'y' | 'm' | 'd' | 'H' | 'h' | 'M' | 's'); + if out.last().map(|p| p.kind) != Some(k) { + if let Some(last) = out.last_mut().filter(|p| p.kind != '#') { + last.joined = adjacent; + } + out.push(Part { kind: k, literal: 0, width: 0, joined: false }); + } + run = Some((c, 1)); } + prev = c; + } + if let (Some((r, n)), Some(last)) = (run, out.last_mut().filter(|p| p.kind != '#')) { + last.width = fixed(if r == 'h' { 'H' } else { r }, n); } out } @@ -587,18 +921,22 @@ pub fn parse_date(text: &str, fmt: &str) -> Option { return None; } let lower = t.to_lowercase(); - let pm = lower.contains("pm") || lower.ends_with('p'); - let am = lower.contains("am"); - // Tokens: numbers and month names. - let mut nums: Vec = Vec::new(); + // 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 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(); let mut month_name: Option = None; let mut cur = String::new(); - let flush = |cur: &mut String, nums: &mut Vec, month: &mut Option| { + let flush = |cur: &mut String, nums: &mut VecDeque, month: &mut Option| { if cur.is_empty() { return; } - if let Ok(n) = cur.parse::() { - nums.push(n); + if cur.starts_with(|c: char| c.is_ascii_digit()) { + nums.push_back(std::mem::take(cur)); } else if cur.len() >= 3 && let Some(i) = MONTHS.iter().position(|m| m.to_lowercase().starts_with(&cur.to_lowercase())) { @@ -620,34 +958,76 @@ pub fn parse_date(text: &str, fmt: &str) -> Option { } flush(&mut cur, &mut nums, &mut month_name); let mut dt = DateTime { y: 0, m: 1, d: 1, hh: 0, mm: 0, ss: 0 }; - let mut it = nums.into_iter().peekable(); + // The next number for a field. A field joined to the next one in the format ("yyyymmdd") + // takes only its own width from a longer run of digits and leaves the rest for the next. + // A run typed on its own is split only when fewer runs were typed than the fields of its kind + // still to fill (`need`, this one included: date fields for a date field, time fields for a + // time field, since times may be left out), and only when its rest can fill the next field + // (`next_width`): with separators each run is one field's number, so "2026-10-18" keeps its + // four-digit year in a yymmdd or yymmddHHMM field, and "2026 10 018" is the 18th. What is left + // of a split run (`tail`: the front of `nums` is one) goes on being split along the fields + // written side by side, and must fill its last field exactly, so "202610018" leaves "018" for + // dd and is refused. `None` when there is no number left, it doesn't fit a u32, or such a + // rest has the wrong width. + let next_number = |nums: &mut VecDeque, tail: &mut bool, part: &Part, need: usize, next_width: usize| -> Option { + let run = nums.pop_front()?; + let was_tail = std::mem::replace(tail, false); + let short = nums.len() < need.saturating_sub(1); + let digits = match (run.get(..part.width), run.get(part.width..)) { + (Some(head), Some(rest)) if part.joined && part.width > 0 && !rest.is_empty() && (was_tail || (short && rest.len() >= next_width)) => { + nums.push_front(rest.to_string()); + *tail = true; + head + } + _ if was_tail && part.width > 0 && run.len() != part.width => return None, + _ => run.as_str(), + }; + digits.parse::().ok() + }; + let parts = order(fmt); + let takes_number = |p: &Part| matches!(p.kind, 'y' | 'd' | 'H' | 'M' | 's') || (p.kind == 'm' && month_name.is_none()); + let is_time = |p: &Part| matches!(p.kind, 'H' | 'M' | 's'); + // The fields of each kind still to fill: [date, time]. + let mut wanted = [parts.iter().filter(|p| takes_number(p) && !is_time(p)).count(), parts.iter().filter(|p| is_time(p)).count()]; + let mut tail = false; let mut have_y = false; - for (k, literal) in order(fmt) { - match k { + for (i, part) in parts.iter().enumerate() { + let kind = usize::from(is_time(part)); + let need = wanted.get(kind).copied().unwrap_or(0); + let next_width = parts.get(i + 1).map_or(0, |p| p.width); + if takes_number(part) + && let Some(left) = wanted.get_mut(kind) + { + *left = left.saturating_sub(1); + } + let take = |nums: &mut VecDeque, tail: &mut bool| next_number(nums, tail, part, need, next_width); + match part.kind { // Literal digits in the format may be typed or left out ("15.05.26" or "15.05" // for "dd.mm.26"); a different number there is left over and rejected below. - '#' if it.peek() == Some(&literal) => { - it.next(); + '#' if nums.front().and_then(|n| n.parse::().ok()) == Some(part.literal) => { + nums.pop_front(); + tail = false; } 'm' => { dt.m = match month_name { Some(m) => m, - None => it.next()?, + None => take(&mut nums, &mut tail)?, } } - 'd' => dt.d = it.next()?, + 'd' => dt.d = take(&mut nums, &mut tail)?, 'y' => { - let y = i32::try_from(it.next()?).ok()?; + let y = i32::try_from(take(&mut nums, &mut tail)?).ok()?; dt.y = if y < 100 { if y < 50 { 2000 + y } else { 1900 + y } } else { y }; have_y = true; } - 'H' => dt.hh = it.next().unwrap_or(0), - 'M' => dt.mm = it.next().unwrap_or(0), - 's' => dt.ss = it.next().unwrap_or(0), + // Times may be left out; a number that is there must be readable. + 'H' => dt.hh = if nums.is_empty() { 0 } else { take(&mut nums, &mut tail)? }, + 'M' => dt.mm = if nums.is_empty() { 0 } else { take(&mut nums, &mut tail)? }, + 's' => dt.ss = if nums.is_empty() { 0 } else { take(&mut nums, &mut tail)? }, _ => {} } } - if it.next().is_some() { + if !nums.is_empty() { return None; } if !have_y { @@ -866,77 +1246,107 @@ pub fn validate(v: &Validate, value: &str) -> Result<(), String> { }) } -/// Simplified field notation as the JavaScript Acrobat writes after its `BVCALC` comment: -/// each field name becomes `AFMakeNumber(this.getField("name").value)`, the rest stays. -pub fn notation_js(expr: &str) -> String { - let mut out = String::new(); - let cs: Vec = expr.chars().collect(); - let mut i = 0; - while let Some(&c) = cs.get(i) { - if c.is_whitespace() || "+-*/()".contains(c) { - out.push(c); - i += 1; +/// A token of simplified field notation. +#[derive(Clone, Debug, PartialEq)] +enum Notation { + /// A number, as written. + Number, + /// `+ - * / ( )`. + Op(char), + /// A field name, its `\` escapes undone. + Name(String), +} + +/// Simplified field notation's tokens, each with its byte range in `src`. Whitespace separates +/// them, and `\` escapes a space or an operator inside a name, as Acrobat writes them. +fn notation_tokens(src: &str) -> Vec<(Range, Notation)> { + let breaks = |c: char| c.is_whitespace() || "+-*/()".contains(c); + let mut out = Vec::new(); + let mut cs = src.char_indices().peekable(); + while let Some(&(start, c)) = cs.peek() { + let mut end = start + c.len_utf8(); + if c.is_whitespace() { + cs.next(); + } else if breaks(c) { + cs.next(); + out.push((start..end, Notation::Op(c))); } else if c.is_ascii_digit() || c == '.' { - while let Some(&d) = cs.get(i).filter(|d| d.is_ascii_digit() || **d == '.') { - out.push(d); - i += 1; + while let Some((i, d)) = cs.next_if(|(_, d)| d.is_ascii_digit() || *d == '.') { + end = i + d.len_utf8(); } + out.push((start..end, Notation::Number)); } else { let mut name = String::new(); - while let Some(&n) = cs.get(i).filter(|n| !(n.is_whitespace() || "+-*/()".contains(**n))) { - // `\` escapes a space or an operator inside a name, as Acrobat writes them. + while let Some((i, n)) = cs.next_if(|(_, n)| !breaks(*n)) { + end = i + n.len_utf8(); if n == '\\' - && let Some(&next) = cs.get(i + 1) + && let Some((j, next)) = cs.next() { + end = j + next.len_utf8(); name.push(next); - i += 2; - continue; + } else { + name.push(n); } - name.push(n); - i += 1; } - out.push_str(&format!("AFMakeNumber(this.getField({}).value)", js_str(&name))); + out.push((start..end, Notation::Name(name))); } } out } -/// Evaluate simplified field notation: + - * / and parentheses over numbers and field names -/// (`\` escapes spaces and operators in names, as Acrobat writes them). +/// A field name as simplified field notation writes it: `\` before a space, an operator or a +/// `\`, and before a first character that would start a number. +fn notation_name(name: &str) -> String { + let mut out = String::new(); + for (i, c) in name.chars().enumerate() { + if c.is_whitespace() || "+-*/()\\".contains(c) || (i == 0 && (c.is_ascii_digit() || c == '.')) { + out.push('\\'); + } + out.push(c); + } + out +} + +/// `src` with each range replaced. The ranges come in order and don't overlap; one that does +/// is left out rather than written over another. +fn splice(src: &str, edits: impl IntoIterator, String)>) -> String { + let mut out = String::with_capacity(src.len()); + let mut done = 0; + for (range, with) in edits { + let Some(kept) = src.get(done..range.start).filter(|_| range.start <= range.end) else { continue }; + out.push_str(kept); + out.push_str(&with); + done = range.end; + } + out.push_str(src.get(done..).unwrap_or_default()); + out +} + +/// Simplified field notation as the JavaScript Acrobat writes after its `BVCALC` comment: +/// each field name becomes `AFMakeNumber(this.getField("name").value)`, the rest stays. +pub fn notation_js(expr: &str) -> String { + let names = notation_tokens(expr).into_iter().filter_map(|(range, t)| match t { + Notation::Name(name) => Some((range, format!("AFMakeNumber(this.getField({}).value)", js_str(&name)))), + _ => None, + }); + splice(expr, names) +} + +/// Evaluate simplified field notation: + - * / and parentheses over numbers and field names. fn eval_notation(src: &str, value_of: &dyn Fn(&str) -> f64) -> Option { #[derive(Debug, Clone, PartialEq)] enum T { N(f64), Op(char), } - let mut toks = Vec::new(); - let cs: Vec = src.chars().collect(); - let mut i = 0; - while i < cs.len() { - let c = cs[i]; - if c.is_whitespace() { - i += 1; - } else if "+-*/()".contains(c) { - toks.push(T::Op(c)); - i += 1; - } else if c.is_ascii_digit() || c == '.' { - let s = i; - while i < cs.len() && (cs[i].is_ascii_digit() || cs[i] == '.') { - i += 1; - } - toks.push(T::N(cs[s..i].iter().collect::().parse().ok()?)); - } else { - let mut name = String::new(); - while i < cs.len() && !(cs[i].is_whitespace() || "+-*/()".contains(cs[i])) { - if cs[i] == '\\' && i + 1 < cs.len() { - i += 1; - } - name.push(cs[i]); - i += 1; - } - toks.push(T::N(value_of(&name))); - } - } + let toks = notation_tokens(src) + .into_iter() + .map(|(range, t)| match t { + Notation::Number => src.get(range)?.parse().ok().map(T::N), + Notation::Op(c) => Some(T::Op(c)), + Notation::Name(name) => Some(T::N(value_of(&name))), + }) + .collect::>>()?; // Recursive descent, `d` levels deep (at most MAX_NESTING). fn expr(t: &[T], i: &mut usize, d: usize) -> Option { let mut v = term(t, i, d)?; @@ -1017,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!( @@ -1069,6 +1488,123 @@ mod tests { ); } + #[test] + fn a_rename_rewrites_only_where_a_calculation_reads_the_field() { + let renamed = |js: &str| rename_in_calculation(js, "a", "qty"); + // The field lists Acrobat and other tools write for AFSimple_Calculate. + assert_eq!( + renamed("AFSimple_Calculate(\"SUM\", new Array (\"a\", \"b\"));").as_deref(), + Some("AFSimple_Calculate(\"SUM\", new Array (\"qty\", \"b\"));") + ); + assert_eq!(renamed("AFSimple_Calculate('AVG', ['b', 'a']);").as_deref(), Some("AFSimple_Calculate('AVG', ['b', \"qty\"]);")); + assert_eq!(renamed("AFSimple_Calculate(\"SUM\", \"a, b,a\");").as_deref(), Some("AFSimple_Calculate(\"SUM\", \"qty, b,qty\");")); + assert_eq!(renamed("AFSimple_Calculate(\"SUM\", new Array(\"ab\", \"b.a\"));"), None, "other names that contain it"); + // Notation: the expression in the comment and Acrobat's translation after it. + let notation = |expr: &str| calculate_js(&Calculate::Notation(expr.into())).unwrap(); + assert_eq!(rename_in_calculation(¬ation("(a + ab) * a / 2"), "a", "Unit Price"), Some(notation("(Unit\\ Price + ab) * Unit\\ Price / 2"))); + assert_eq!(rename_in_calculation(¬ation("Unit\\ Price * 2"), "Unit Price", "price"), Some(notation("price * 2"))); + // Scripts: a getField whose argument is one string literal. Comments, strings, template + // literals, regular expressions and names built at run time are left as written. + let script = "// getField(\"a\")\nvar s = \"getField('a')\", t = `getField(\"a\")`; /* getField(\"a\") */\n\ + if (/\"/.test(s)) event.value = this.getField('a').value / 2 * getField(\"a\" + 1).value + getField ( \"ab\" ).value;"; + assert_eq!(renamed(script), Some(script.replacen("this.getField('a')", "this.getField(\"qty\")", 1))); + assert_eq!(renamed("event.value = getField(\"b\").value;"), None); + // A new name is escaped as the script needs it. + assert_eq!( + rename_in_calculation("event.value = getField(\"a\").value;", "a", "say \"é\"").as_deref(), + Some("event.value = getField(\"say \\\"\\u00E9\\\"\").value;") + ); + // A name that would end the notation early is not written in. + assert_eq!(rename_in_calculation(¬ation("a * 2"), "a", "qtyEVCALC"), None); + // Unfinished scripts end their tokens, not the program. + for js in ["getField(\"a", "/* getField(\"a\")", "`getField(\"a\")", "x = /[/", "getField('a\\u12"] { + assert_eq!(renamed(js), None, "{js}"); + } + } + + /// Scripts are read in one pass: a line of unfinished regular expressions, or calls nested in + /// one another's field lists, don't make a rename read the rest of the script again for each. + #[test] + fn hostile_scripts_are_read_in_one_pass() { + let regexes = format!("x = {} getField(\"a\")", "/[".repeat(500_000)); + assert!(rename_in_calculation(®exes, "a", "b").is_some_and(|js| js.ends_with("getField(\"b\")"))); + let nested = format!("{}\"a\"", "AFSimple_Calculate(\"SUM\", new Array(".repeat(100_000)); + assert_eq!(rename_in_calculation(&nested, "a", "b"), Some(format!("{}\"b\"", "AFSimple_Calculate(\"SUM\", new Array(".repeat(100_000)))); + } + + /// Notation is read through one lexer: field names with escapes, numbers and operators. + #[test] + fn notation_is_read_by_one_lexer() { + let tokens: Vec = notation_tokens("(Unit\\ Price+1.5)*qty_2\\").into_iter().map(|(_, t)| t).collect(); + assert_eq!( + tokens, + [ + Notation::Op('('), + Notation::Name("Unit Price".into()), + Notation::Op('+'), + Notation::Number, + Notation::Op(')'), + Notation::Op('*'), + Notation::Name("qty_2\\".into()) + ] + ); + assert_eq!(notation_js("2 * a\\ b"), "2 * AFMakeNumber(this.getField(\"a b\").value)"); + let values = |name: &str| if name == "Unit Price" { 4.0 } else { 0.5 }; + assert_eq!(eval_notation("(Unit\\ Price + 1.5) * qty", &values), Some(2.75)); + assert_eq!(eval_notation("1.2.3 + a", &values), None, "a number that isn't one"); + assert_eq!(notation_name("2nd + (x)\\"), "\\2nd\\ \\+\\ \\(x\\)\\\\"); + } + + proptest::proptest! { + /// Any field name written in notation reads back as that one name. + #[test] + fn any_name_written_in_notation_reads_back(name in "\\PC{1,12}") { + let written = notation_name(&name); + proptest::prop_assert_eq!(notation_tokens(&written), vec![(0..written.len(), Notation::Name(name))]); + } + + /// Renaming a field in a formula and back gives the formula as it was. + #[test] + fn a_rename_round_trips(old in "\\PC{1,8}", new in "\\PC{1,8}", others in proptest::collection::vec("\\PC{1,8}", 0..4), sum in proptest::bool::ANY) { + proptest::prop_assume!(old != new && !others.contains(&new) && ![&old, &new].into_iter().chain(&others).any(|n| n.contains("VCALC"))); + let names: Vec = std::iter::once(old.clone()).chain(others).collect(); + let js = if sum { + calculate_js(&Calculate::Simple { op: CalcOp::Sum, fields: names }).unwrap() + } else { + calculate_js(&Calculate::Notation(names.iter().map(|n| notation_name(n)).collect::>().join(" * "))).unwrap() + }; + let renamed = rename_in_calculation(&js, &old, &new); + proptest::prop_assert!(renamed.is_some(), "{js}"); + 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] fn numbers_format_like_acrobat() { let n = |d, s, ng, c: &str, p| Format::Number { decimals: d, sep: s, neg: ng, currency: c.into(), prepend: p }; @@ -1285,3 +1821,118 @@ mod never_crash_tests { let _ = format_date(dt, "dddd ddd"); } } + +#[cfg(test)] +mod pm_marker_tests { + use super::*; + + /// A month name ending in "p" ("Sep") is not a PM marker; only a "p" attached to a time is. + #[test] + fn month_name_ending_in_p_is_not_pm() { + let dt = parse_date("2026 05 Sep", "yyyy dd mmm HH:MM").expect("valid date"); + assert_eq!((dt.m, dt.d, dt.hh, dt.mm), (9, 5, 0, 0)); + let f = Format::Date("yyyy dd mmm HH:MM".into()); + assert_eq!(format_value(&f, "2026 05 Sep"), "2026 05 Sep 00:00"); + assert!(keystroke(&f, "date", "2026 05 Sep").is_ok()); + } + + #[test] + fn bare_p_after_a_time_is_still_pm() { + assert_eq!(parse_date("05/09/2026 5:30p", "mm/dd/yyyy h:MM").map(|d| d.hh), Some(17)); + assert_eq!(parse_date("05/09/2026 5:30 p", "mm/dd/yyyy h:MM").map(|d| d.hh), Some(17)); + assert_eq!(parse_date("05/09/2026 5:30 pm", "mm/dd/yyyy h:MM").map(|d| d.hh), Some(17)); + } +} + +#[cfg(test)] +mod undelimited_tests { + use super::*; + + fn ymd(dt: Option) -> Option<(i32, u32, u32)> { + dt.map(|d| (d.y, d.m, d.d)) + } + + /// Fields written side by side in the format ("yyyymmdd") take their fixed widths from one + /// run of typed digits. Every such date used to be refused, because the run was read as a + /// single number. + #[test] + fn digits_typed_without_separators_fill_adjacent_fields() { + assert_eq!(ymd(parse_date("20261018", "yyyymmdd")), Some((2026, 10, 18))); + assert_eq!(ymd(parse_date("261018", "yymmdd")), Some((2026, 10, 18))); + assert_eq!(ymd(parse_date("10182026", "mmddyyyy")), Some((2026, 10, 18))); + assert_eq!(ymd(parse_date("18102026", "ddmmyyyy")), Some((2026, 10, 18))); + let f = Format::Date("yyyymmdd".into()); + assert_eq!(keystroke(&f, "Date", "20261018"), Ok("20261018".into())); + assert_eq!(format_value(&f, "20261018"), "20261018"); + } + + #[test] + fn separators_are_still_accepted_in_an_undelimited_format() { + assert_eq!(ymd(parse_date("2026-10-18", "yyyymmdd")), Some((2026, 10, 18))); + assert_eq!(ymd(parse_date("2026 1018", "yyyymmdd")), Some((2026, 10, 18))); + } + + /// Separators mean one number per field, as before: a four-digit year typed with them stays + /// whole in a two-digit-year format. Only a run covering several fields is split. + #[test] + fn a_run_is_split_only_when_it_has_to_fill_several_fields() { + assert_eq!(ymd(parse_date("2026-10-18", "yymmdd")), Some((2026, 10, 18))); + assert_eq!(ymd(parse_date("2026-10", "yymm")), Some((2026, 10, 1))); + assert_eq!(ymd(parse_date("2610", "yymm")), Some((2026, 10, 1))); + assert_eq!(ymd(parse_date("202610 18", "yyyymmdd")), Some((2026, 10, 18))); + assert_eq!(ymd(parse_date("18Oct2026", "ddmmmyyyy")), Some((2026, 10, 18))); + } + + #[test] + fn impossible_undelimited_dates_are_refused() { + let f = Format::Date("yyyymmdd".into()); + assert!(keystroke(&f, "Date", "20261340").is_err(), "no month 13"); + assert!(keystroke(&f, "Date", "20260230").is_err(), "no 30 February"); + assert!(keystroke(&f, "Date", "2026101899").is_err(), "digits left over make no day"); + assert!(keystroke(&f, "Date", "2026").is_err(), "a year alone is not a date"); + } + + /// What is left of a split run fills its field exactly: "202610018" leaves "018" for the + /// day, one digit too many, and used to pass as 18 October. Numbers typed on their own + /// keep their leeway. + #[test] + fn the_rest_of_a_split_run_must_fill_its_field_exactly() { + assert_eq!(parse_date("202610018", "yyyymmdd"), None, "one digit too many"); + assert_eq!(parse_date("2026101", "yyyymmdd"), None, "one digit too few"); + assert_eq!(parse_date("2026101814305", "yyyymmddHHMM"), None, "one digit too many in the time"); + assert_eq!(ymd(parse_date("2026 10 018", "yyyymmdd")), Some((2026, 10, 18)), "typed on its own, 018 is still a day"); + assert_eq!(parse_date("202610181430", "yyyymmddHHMM").map(|d| (d.d, d.hh, d.mm)), Some((18, 14, 30))); + } + + /// Times may be left out, so a number typed on its own isn't split for them: "018" is the + /// day in "2026 10 018" even when the format goes on to HHMM. + #[test] + fn a_number_typed_on_its_own_is_split_only_when_the_rest_fills_the_next_field() { + let dt = parse_date("2026 10 018", "yyyymmddHHMM").expect("a date with the time left out"); + assert_eq!((dt.y, dt.m, dt.d, dt.hh, dt.mm), (2026, 10, 18, 0, 0)); + assert_eq!(ymd(parse_date("20261018", "yyyymmddHHMM")), Some((2026, 10, 18))); + assert_eq!(parse_date("2026 10 18 1430", "yyyymmdd HHMM").map(|d| (d.hh, d.mm)), Some((14, 30))); + } + + /// The date's runs are matched against the date's fields, the time's against the time's: + /// optional times left out don't make a separated four-digit year look like it needs + /// splitting, and a time typed in one run still fills HHMM. + #[test] + fn date_and_time_fields_are_counted_apart() { + let dt = parse_date("2026-10-18", "yymmddHHMM").expect("a date with the time left out"); + assert_eq!((dt.y, dt.m, dt.d, dt.hh, dt.mm), (2026, 10, 18, 0, 0)); + assert_eq!(parse_date("20261018 1430", "yyyymmddHHMM").map(|d| (d.y, d.m, d.d, d.hh, d.mm)), Some((2026, 10, 18, 14, 30))); + assert_eq!(parse_date("2026-10-18 1430", "yyyy-mm-dd HHMM").map(|d| (d.hh, d.mm)), Some((14, 30))); + assert_eq!(parse_date("20261018143005", "yyyymmddHHMMss").map(|d| (d.d, d.hh, d.mm, d.ss)), Some((18, 14, 30, 5))); + } + + /// A run of more digits than fit in a u32 ("yyyymmddHHMMss") is split, not dropped. + #[test] + fn long_runs_of_digits_split_into_dates_and_times() { + let dt = parse_date("20261018143005", "yyyymmddHHMMss").expect("valid date and time"); + assert_eq!((dt.y, dt.m, dt.d, dt.hh, dt.mm, dt.ss), (2026, 10, 18, 14, 30, 5)); + assert_eq!(parse_date("1430", "HHMM").map(|d| (d.hh, d.mm)), Some((14, 30))); + assert_eq!(parse_date("99999999999999999999", "yyyymmdd"), None); + assert_eq!(parse_date("2026 99999999999", "yyyy"), None, "an unreadable number is left over, not ignored"); + } +} 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 bb45f38d9..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 { @@ -63,6 +65,9 @@ impl NewField { pub struct FieldProps { pub name: Option, pub tooltip: Option, + /// Normal push-button label (`/MK /CA`) of one widget (0-based index). + /// An empty string clears the label; other widgets and icon layouts are preserved. + pub caption: Option<(usize, String)>, pub read_only: Option, pub required: Option, pub multiline: Option, @@ -512,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); @@ -656,7 +661,9 @@ 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, }; let ap = appearance::check_box_states(doc, &w, FieldKind::Radio, export); @@ -677,9 +684,19 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel /// (`pdfcraft_fonts::UNDRAWN_MARK`) and other viewers are asked to redraw the form (see /// `ask_viewers_to_redraw`), as when a value is set. 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; - for w in &f.widgets { + for (index, w) in f.widgets.iter().enumerate() { + if only.is_some_and(|only| only != index) { + continue; + } let mut missing = None; let ap = match f.kind { FieldKind::Text | FieldKind::Combo | FieldKind::List => { @@ -695,7 +712,7 @@ pub fn redraw_field(doc: &mut Document, name: &str) -> Result<(), FormError> { 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(); @@ -754,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); @@ -781,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() { @@ -845,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. @@ -861,6 +874,15 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result> = None; @@ -900,13 +922,19 @@ 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() { @@ -1178,13 +1187,56 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Result<(), FormError> { + let aa_of = + |doc: &Document, r: ObjRef| doc.get(r).as_dict().and_then(|d| d.get(b"AA").map(|a| doc.resolve(a))).and_then(|a| a.as_dict().cloned()); + for f in all { + // The script is the field's, or its only widget's when the field has none, as + // `actions_of` reads it. + let only_widget = || match f.widgets.as_slice() { + [w] => aa_of(doc, w.obj).map(|aa| (w.obj, aa)), + _ => None, + }; + let Some((owner, mut aa)) = aa_of(doc, f.obj).map(|aa| (f.obj, aa)).or_else(only_widget) else { continue }; + let Some(mut action) = aa.get(b"C").and_then(|a| doc.resolve(a).as_dict().cloned()) else { continue }; + let js = match action.get(b"JS").map(|js| doc.resolve(js)).as_deref() { + Some(Object::String(s)) => s.to_text(), + // A stream that isn't UTF-8 reads with replacement characters, which must not be + // written back in place of what it holds. + Some(Object::Stream(s)) => match s.decoded().ok().and_then(|b| String::from_utf8(b).ok()) { + Some(js) => js, + None => continue, + }, + _ => continue, + }; + let Some(js) = crate::af::rename_in_calculation(&js, old, new) else { continue }; + action.set(b"JS".to_vec(), PdfString::text(&js)); + aa.set(b"C".to_vec(), Object::Dict(action)); + doc.update_dict(owner, |d| d.set(b"AA".to_vec(), Object::Dict(aa)))?; + } + Ok(()) +} + /// Delete a field: its widgets leave their pages and the field leaves the form. pub fn delete_field(doc: &mut Document, name: &str) -> Result<(), FormError> { let all = fields(doc); 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 dbee788e3..8c980c09f 100644 --- a/crates/forms/src/detect.rs +++ b/crates/forms/src/detect.rs @@ -1,11 +1,11 @@ //! Prepare a form ▸ automatic field detection: find where a printed form expects answers — -//! underscore runs, rules (horizontal lines), empty boxes and small squares — and name each new -//! field from the label next to it (Acrobat's auto-naming). +//! underscore runs, rules (horizontal lines), empty boxes, rows of comb cells and small squares — +//! and name each new field from the label next to it (Acrobat's auto-naming). //! //! [`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; @@ -30,6 +30,11 @@ pub struct Shapes { pub enum Kind { Text, CheckBox, + /// A row of touching cells, one character each: a text field with the comb flag and + /// `cells` as its character limit. + Comb { + cells: usize, + }, } #[derive(Clone, Debug, PartialEq)] @@ -40,6 +45,10 @@ pub struct Candidate { pub name: String, } +/// The most points a subpath may have and still be read as a rectangle: its four corners plus +/// points along its sides (#2942: a cell drawn with a midpoint on each vertical side). +const MAX_RECT_POINTS: usize = 16; + /// The rectangles and horizontal segments a page draws (stroked or filled), in user space. #[derive(Default)] struct DetectionPath { @@ -65,12 +74,33 @@ impl DetectionPath { return None; } let near = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6; - let mut points = self.points.as_slice(); + let mut points = self.points.clone(); let repeated_end = points.len() > 1 && near(points[0], *points.last()?); if repeated_end { - points = &points[..points.len() - 1]; + points.pop(); } - if points.len() != 4 || !(self.closed || implicitly_closed || repeated_end) { + if !(self.closed || implicitly_closed || repeated_end) { + return None; + } + // A point on a straight side, between its neighbours, doesn't change the shape: drop it, + // so a cell drawn with a midpoint on a side is the rectangle it looks like (#2942). A + // point that turns back along the side is not between its neighbours and stays, so a + // path that doubles back is still not a rectangle. + let between = |a: f64, m: f64, b: f64| a.min(b) - 1e-6 <= m && m <= a.max(b) + 1e-6; + let mut i = 0; + while points.len() > 4 && i < points.len() { + let n = points.len(); + let (Some(&a), Some(&m), Some(&b)) = (points.get((i + n - 1) % n), points.get(i), points.get((i + 1) % n)) else { break }; + let on_row = (a[1] - m[1]).abs() < 1e-6 && (m[1] - b[1]).abs() < 1e-6 && between(a[0], m[0], b[0]); + let on_column = (a[0] - m[0]).abs() < 1e-6 && (m[0] - b[0]).abs() < 1e-6 && between(a[1], m[1], b[1]); + if on_row || on_column { + points.remove(i); + i = 0; + } else { + i += 1; + } + } + if points.len() != 4 { return None; } let mut horizontal = [false; 4]; @@ -106,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; @@ -119,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), @@ -157,7 +186,7 @@ pub fn page_shapes(doc: &Document, page: usize) -> Shapes { let (bx, by) = ctm.apply(x, y); let b = [bx, by]; if !path.complex { - if path.points.len() >= 5 { + if path.points.len() > MAX_RECT_POINTS { path.complex = true; } else { if path.points.is_empty() { @@ -284,6 +313,42 @@ fn label_for(words: &[Word], rect: [f64; 4], right_of: bool) -> String { clean(&on.iter().map(|w| w.text.as_str()).collect::>().join(" ")) } +/// Rows of two or more touching cells of one size, as (the row's rect, cell count). Two is +/// enough: dates split into day and month groups of two cells. Cells are square or taller +/// than wide; an empty table of wider cells is not a comb. +fn combs(boxes: &[[f64; 4]]) -> Vec<([f64; 4], usize)> { + let cells: Vec<[f64; 4]> = boxes + .iter() + .copied() + .filter(|r| { + let (w, h) = (r[2] - r[0], r[3] - r[1]); + (5.0..=30.0).contains(&w) && (8.0..=40.0).contains(&h) && w <= h * 1.2 + }) + .collect(); + // `b` comes right after `a` in a row: same size, its left edge on a's right edge. + let follows = |a: &[f64; 4], b: &[f64; 4]| { + (b[0] - a[2]).abs() <= 1.0 && (b[1] - a[1]).abs() <= 0.5 && (b[3] - a[3]).abs() <= 0.5 && ((b[2] - b[0]) - (a[2] - a[0])).abs() <= 0.5 + }; + let mut out = Vec::new(); + for first in &cells { + if cells.iter().any(|a| follows(a, first)) { + continue; + } + let (mut last, mut n) = (first, 1usize); + // Each step moves at least 4 pt right, so this ends; the bound is for safety. + while let Some(b) = cells.iter().find(|b| follows(last, b)) + && n < cells.len() + { + last = b; + n += 1; + } + if n >= 2 { + out.push(([first[0], first[1], last[2], first[3]], n)); + } + } + out +} + /// Propose fields for a page. `existing` are the page's widget rectangles (no candidate /// overlaps them); names are unique against `taken`. pub fn detect(words: &[Word], shapes: &Shapes, existing: &[[f64; 4]], taken: &[String]) -> Vec { @@ -304,6 +369,15 @@ pub fn detect(words: &[Word], shapes: &Shapes, existing: &[[f64; 4]], taken: &[S let l = label_for(&labels, rect, false); push(Kind::Text, rect, l, &mut out); } + // Comb boxes (postcodes, dates, IBANs): one text field per row of cells, found before + // check boxes so the cells don't each become one. + for (rect, cells) in combs(&shapes.boxes) { + if labels.iter().any(|x| overlaps(x.rect, rect)) { + continue; + } + let l = label_for(&labels, rect, false); + push(Kind::Comb { cells }, rect, l, &mut out); + } // Small squares: check boxes, labelled by the text to their right. for r in &shapes.boxes { let (w, h) = (r[2] - r[0], r[3] - r[1]); @@ -356,7 +430,7 @@ pub fn detect(words: &[Word], shapes: &Shapes, existing: &[[f64; 4]], taken: &[S for c in &mut out { let base = if c.name.is_empty() { match c.kind { - Kind::Text => "Text".to_string(), + Kind::Text | Kind::Comb { .. } => "Text".to_string(), Kind::CheckBox => "Check Box".to_string(), } } else { diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 07f4c5484..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,8 +105,13 @@ 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. + pub caption: Option, /// List boxes: where the options are drawn, so a click can pick the one under the pointer. pub list: Option, } @@ -450,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. @@ -716,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, @@ -752,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(); @@ -786,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)) @@ -801,7 +845,15 @@ 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(|| { + let mk = doc.resolve(wd.get(b"MK")?); + let value = doc.resolve(mk.as_dict()?.get(b"CA")?); + value.as_string().map(|s| s.to_text()) + }) + .flatten(), list: (kind == FieldKind::List).then(|| appearance::list_rows_of(doc, inh.da.as_deref().unwrap_or("/Helv 0 Tf 0 g"), Some(d), wd)), }) }) @@ -911,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() @@ -952,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; @@ -961,6 +1002,17 @@ pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result Ok(changed) } +/// The fields whose calculation differs between two readings of a form, matched by field object +/// and named as in `after`: the formulas an edit rewrote, such as a rename's. +pub fn calculations_changed(before: &[Field], after: &[Field]) -> Vec { + let before: std::collections::HashMap = before.iter().map(|f| (f.obj, &f.actions)).collect(); + after + .iter() + .filter(|f| before.get(&f.obj).is_some_and(|b| b.calculate != f.actions.calculate || b.scripts.calculate != f.actions.scripts.calculate)) + .map(|f| f.name.clone()) + .collect() +} + fn invalid(m: impl Into) -> Result { Err(FormError::Invalid(m.into())) } @@ -1223,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 ef545cd43..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,160 @@ 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] { + let mut doc = one_page(); + let a = add_field(&mut doc, 0, [40.0, 600.0, 200.0, 630.0], &NewField::Button { caption: "Original".into() }, Some("first")).unwrap(); + let b = add_field(&mut doc, 0, [40.0, 500.0, 200.0, 530.0], &NewField::Button { caption: "Original".into() }, Some("second")).unwrap(); + let all = fields(&doc); + let (first, second) = (field(&all, &a).obj, field(&all, &b).obj); + let mut parent = Dict::new(); + parent.set(b"FT".to_vec(), Object::name("Btn")); + parent.set(b"Ff".to_vec(), Object::Int(flags::PUSH_BUTTON as i64)); + parent.set(b"T".to_vec(), PdfString::text("clear_button")); + parent.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(first), Object::Ref(second)])); + let mut action = Dict::new(); + action.set(b"S".to_vec(), Object::name("ResetForm")); + parent.set(b"A".to_vec(), Object::Dict(action.clone())); + let parent = doc.add(parent); + let mut mk = Dict::new(); + let ca = Object::String(PdfString::text("Original")); + let ca = if indirect_caption { Object::Ref(doc.add(ca)) } else { ca }; + mk.set(b"CA".to_vec(), ca); + mk.set(b"TP".to_vec(), Object::Int(0)); + mk.set(b"R".to_vec(), Object::Int(90)); + mk.set(b"VendorNote".to_vec(), PdfString::text("kept")); + let mk = doc.add(mk); + let mut shared_ap = doc.get(first).as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap().clone(); + let normal = shared_ap.get(b"N").unwrap().clone(); + shared_ap.set(b"R".to_vec(), normal.clone()); + shared_ap.set(b"D".to_vec(), normal); + shared_ap.set(b"VendorAP".to_vec(), PdfString::text("kept")); + let shared_ap = doc.add(shared_ap); + for widget in [first, second] { + doc.update_dict(widget, |d| { + for key in [b"FT".as_slice(), b"Ff", b"T"] { + d.remove(key); + } + d.set(b"Parent".to_vec(), Object::Ref(parent)); + d.set(b"MK".to_vec(), Object::Ref(mk)); + d.set(b"AP".to_vec(), Object::Ref(shared_ap)); + }) + .unwrap(); + } + let af = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"AcroForm").unwrap(); + doc.update_dict(af, |d| d.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(parent)]))).unwrap(); + let original = pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap(); + let original = Document::open(Arc::new(original)).unwrap(); + // Full saves can renumber objects: reacquire references from the reopened model. + let f = field(&fields(&original), "clear_button").clone(); + let first = f.widgets[0].obj; + let mk = original.get(first).as_dict().unwrap().reference(b"MK").unwrap(); + for full in [false, true] { + let mut doc = original.clone(); + let untouched = doc.get(first).clone(); + let shared_mk = doc.get(mk).clone(); + assert_eq!(field(&fields(&doc), "clear_button").widgets[1].caption.as_deref(), Some("Original")); + set_props(&mut doc, "clear_button", &FieldProps { caption: Some((1, "Clear Form".into())), ..Default::default() }).unwrap(); + assert_eq!(doc.get(first), untouched, "the other widget and all its appearances are untouched"); + assert_eq!(doc.get(mk), shared_mk, "shared MK is not patched in place"); + let bytes = if full { + pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap() + } else { + let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert!(bytes.starts_with(original.bytes().as_slice())); + assert!(bytes.len() > original.bytes().len()); + bytes + }; + let doc = Document::open(Arc::new(bytes)).unwrap(); + let f = field(&fields(&doc), "clear_button").clone(); + assert_eq!(f.widgets.iter().map(|w| w.caption.as_deref()).collect::>(), [Some("Original"), Some("Clear Form")]); + assert_eq!(f.button, Some(af::ButtonAction::Reset { fields: Vec::new(), exclude: false })); + assert_eq!(doc.get(f.obj).as_dict().unwrap().get(b"A"), Some(&Object::Dict(action.clone()))); + let w = doc.get(f.widgets[1].obj); + let mk = w.as_dict().unwrap().get(b"MK").unwrap().as_dict().unwrap(); + assert_eq!(mk.get(b"VendorNote").unwrap().as_string().unwrap().to_text(), "kept"); + assert_eq!(mk.get(b"R").unwrap().as_int(), Some(90)); + assert_eq!(mk.get(b"TP").unwrap().as_int(), Some(0)); + let apd = w.as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap(); + assert!(apd.contains(b"VendorAP")); + assert!(!apd.contains(b"R") && !apd.contains(b"D"), "stale hover/pressed text must not survive"); + assert!(ap(&doc, &f.widgets[1]).contains("(Clear Form) Tj")); + } + } +} + +#[test] +fn button_caption_rejects_wrong_type_and_index_before_mutation() { + let mut doc = fixture(); + for (name, wi) in [("name", 0), ("agree", 0), ("go", usize::MAX)] { + let before = pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap(); + let props = FieldProps { caption: Some((wi, "Clear Form".into())), name: Some("renamed".into()), ..Default::default() }; + assert!(set_props(&mut doc, name, &props).is_err()); + assert_eq!(pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap(), before); + } + set_props(&mut doc, "go", &FieldProps { caption: Some((0, "提交".into())), ..Default::default() }).unwrap(); + assert_eq!(field(&fields(&doc), "go").widgets[0].caption.as_deref(), Some("提交")); + assert_eq!(undrawn_fields(&doc), [("go".to_string(), '提')]); + set_props(&mut doc, "go", &FieldProps { caption: Some((0, String::new())), ..Default::default() }).unwrap(); + assert_eq!(field(&fields(&doc), "go").widgets[0].caption.as_deref(), Some("")); + assert!(undrawn_fields(&doc).is_empty()); + assert!(!ap(&doc, &field(&fields(&doc), "go").widgets[0]).contains(" Tj")); +} + /// Read the drawn strings together with the appearance's actual font after saving and reopening. fn appearance_text(doc: &Document, w: &Widget, font_name: &[u8]) -> (Object, Vec>) { let widget = doc.get(w.obj); @@ -277,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. @@ -364,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 @@ -833,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(); @@ -946,6 +1289,237 @@ fn formats_validation_and_calculations_run_like_acrobat() { assert_eq!(field(&fields(&doc), "Price").actions.format, Format::None); } +/// Renaming a field renames it in the calculations that read it: a sum dropped it, notation +/// read it as 0 and a script read a field that no longer existed. +#[test] +fn renaming_a_field_renames_it_in_the_calculations_that_read_it() { + use crate::af::{CalcOp, Calculate}; + let mut doc = one_page(); + let text = NewField::Text { multiline: false }; + for (i, name) in ["a", "b", "sum", "product", "script"].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() }; + set_props(&mut doc, "sum", &calculate(Calculate::Simple { op: CalcOp::Sum, fields: vec!["a".into(), "b".into()] })).unwrap(); + set_props(&mut doc, "product", &calculate(Calculate::Notation("a * b + ab".into()))).unwrap(); + set_field_script(&mut doc, "script", "calculate", Some("event.value = getField(\"a\").value + getField(\"b\").value;")).unwrap(); + set_props(&mut doc, "a", &FieldProps { name: Some("qty".into()), ..FieldProps::default() }).unwrap(); + let check = |doc: &Document| { + let all = fields(doc); + assert_eq!(field(&all, "sum").actions.calculate, Calculate::Simple { op: CalcOp::Sum, fields: vec!["qty".into(), "b".into()] }); + assert_eq!(field(&all, "product").actions.calculate, Calculate::Notation("qty * b + ab".into())); + let script = field(&all, "script").actions.scripts.calculate.clone(); + assert_eq!(script.as_deref(), Some("event.value = getField(\"qty\").value + getField(\"b\").value;")); + }; + check(&doc); + let mut doc = reopen(&doc); + check(&doc); + set_value(&mut doc, "qty", &FieldValue::Text("10".into())).unwrap(); + set_value(&mut doc, "b", &FieldValue::Text("2".into())).unwrap(); + let all = fields(&doc); + assert_eq!((field(&all, "sum").value.clone(), field(&all, "product").value.clone()), (vec!["12".to_string()], vec!["20".to_string()])); +} + +/// The rename reaches a script stored as a stream, an indirect action and the only widget's +/// actions; it leaves a script that isn't UTF-8 as it is, and a name another field still has. +#[test] +fn a_rename_reaches_every_stored_form_of_a_calculation() { + 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 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 11 0 R 12 0 R 13 0 R 15 0 R] >>".into(), + "<< /Fields [5 0 R 6 0 R 7 0 R 11 0 R 12 0 R 13 0 R 14 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), + widget("a", 700, ""), + widget("stream", 660, "/AA << /C 8 0 R >>"), + widget("bytes", 620, "/AA << /C << /S /JavaScript /JS 10 0 R >> >>"), + "<< /S /JavaScript /JS 9 0 R >>".into(), + "<< /Length 34 >>\nstream\nevent.value = getField(\"a\").value;\nendstream".into(), + "<< /Length 85 /Filter /ASCIIHexDecode >>\nstream\n2F2A20FF202A2F206576656E742E76616C7565203D206765744669656C6428226122292E76616C75653B>\nendstream".into(), + widget("dup", 580, ""), + widget("dup", 540, ""), + widget("both", 500, "/AA << /C << /S /JavaScript /JS (AFSimple_Calculate\\(\"SUM\", new Array\\(\"dup\", \"a\"\\)\\);) >> >>"), + "<< /FT /Tx /T (kid) /Kids [15 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /Parent 14 0 R /Rect [50 460 250 480] /P 3 0 R /AA << /C << /S /JavaScript /JS (event.value = getField\\('a'\\).value;) >> >> >>".into(), + ]; + let mut doc = document(&objs); + let calc = |doc: &Document, name: &str| { + let all = fields(doc); + let f = field(&all, name); + (f.actions.calculate.clone(), f.actions.scripts.calculate.clone().unwrap_or_default()) + }; + assert!(calc(&doc, "stream").1.contains("getField(\"a\")"), "{:?}", calc(&doc, "stream")); + set_props(&mut doc, "a", &FieldProps { name: Some("qty".into()), ..FieldProps::default() }).unwrap(); + let doc = reopen(&doc); + assert_eq!(calc(&doc, "stream").1, "event.value = getField(\"qty\").value;"); + assert_eq!(calc(&doc, "kid").1, "event.value = getField(\"qty\").value;"); + assert!(calc(&doc, "bytes").1.contains("getField(\"a\")"), "a script that isn't UTF-8 is not written back"); + assert_eq!(calc(&doc, "both").0, af::Calculate::Simple { op: af::CalcOp::Sum, fields: vec!["dup".into(), "qty".into()] }); + let mut doc = doc; + set_props(&mut doc, "dup", &FieldProps { name: Some("dup2".into()), ..FieldProps::default() }).unwrap(); + assert_eq!( + calc(&doc, "both").0, + af::Calculate::Simple { op: af::CalcOp::Sum, fields: vec!["dup".into(), "qty".into()] }, + "the other dup keeps the name" + ); +} + +/// 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 @@ -1411,6 +1985,54 @@ fn detection_finds_blanks_rules_boxes_and_names_them() { assert!(again.is_empty(), "{again:?}"); } +#[test] +fn detection_makes_a_row_of_touching_cells_one_comb_field() { + use crate::detect::*; + let w = |t: &str, x0: f64, y0: f64, x1: f64| Word { text: t.into(), rect: [x0, y0, x1, y0 + 10.0] }; + let words = vec![ + w("Postcode", 40.0, 205.0, 85.0), + w("A", 88.0, 205.0, 95.0), + w("Postcode", 40.0, 105.0, 85.0), + w("B", 88.0, 105.0, 95.0), + w("Yes", 70.0, 41.0, 90.0), + ]; + let row = |n: usize, x: f64, y: f64, cw: f64, ch: f64| (0..n).map(move |i| [x + i as f64 * cw, y, x + (i + 1) as f64 * cw, y + ch]); + // 18 x 22 cells (too tall for a check box), 16 x 20 cells (each one check-box sized) drawn + // right to left, and a lone square. + let mut boxes: Vec<[f64; 4]> = row(5, 120.0, 200.0, 18.0, 22.0).collect(); + boxes.extend(row(10, 120.0, 100.0, 16.0, 20.0).rev()); + boxes.push([50.0, 40.0, 62.0, 52.0]); + let found = detect(&words, &Shapes { boxes, rules: Vec::new() }, &[], &[]); + let got: Vec<(&str, &Kind, [f64; 4])> = found.iter().map(|c| (c.name.as_str(), &c.kind, c.rect)).collect(); + assert_eq!( + got, + [ + ("Postcode A", &Kind::Comb { cells: 5 }, [120.0, 200.0, 210.0, 222.0]), + ("Postcode B", &Kind::Comb { cells: 10 }, [120.0, 100.0, 280.0, 120.0]), + ("Yes", &Kind::CheckBox, [50.0, 40.0, 62.0, 52.0]), + ], + "{found:?}" + ); + // Squares with a gap between them stay check boxes, and so do touching squares of + // different sizes. + let apart = Shapes { + boxes: vec![[50.0, 40.0, 62.0, 52.0], [66.0, 40.0, 78.0, 52.0], [80.0, 40.0, 92.0, 52.0], [92.0, 40.0, 102.0, 52.0]], + rules: Vec::new(), + }; + let kinds: Vec = detect(&[], &apart, &[], &[]).into_iter().map(|c| c.kind).collect(); + assert_eq!(kinds, [Kind::CheckBox, Kind::CheckBox, Kind::CheckBox, Kind::CheckBox]); + // An empty table of cells wider than tall (an attendance sheet) is not a comb. + let grid = |cw: f64, ch: f64| { + (0..3).flat_map(move |r| { + (0..3).map(move |c| [100.0 + c as f64 * cw, 300.0 + r as f64 * ch, 100.0 + (c + 1) as f64 * cw, 300.0 + (r + 1) as f64 * ch]) + }) + }; + for (cw, ch) in [(24.0, 18.0), (28.0, 20.0)] { + let table = detect(&[], &Shapes { boxes: grid(cw, ch).collect(), rules: Vec::new() }, &[], &[]); + assert!(table.is_empty(), "{cw} x {ch}: {table:?}"); + } +} + #[test] fn page_shapes_reads_boxes_and_rules() { let mut doc = fixture(); @@ -1425,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 } @@ -2023,6 +2677,29 @@ fn page_shapes_accepts_rectangle_closure_and_paint_variants() { } } +/// #2942: a cell drawn with a point in the middle of each vertical side (the French 2042 form's +/// letter cells), or on every side, is the rectangle it looks like. A point that turns back +/// along a side is not on the way between its neighbours, so such a path is still not a box. +#[test] +fn page_shapes_reads_rectangles_with_points_along_their_sides() { + for content in [ + "100 100 m 111.1 100 l 111.1 107.35 l 111.1 114.7 l 100 114.7 l 100 107.35 l h f", + "100 100 m 105 100 l 111.1 100 l 111.1 107.35 l 111.1 114.7 l 105 114.7 l 100 114.7 l 100 107.35 l h S", + "100 107.35 m 100 100 l 111.1 100 l 111.1 114.7 l 100 114.7 l h f", + ] { + let shapes = detection_test_shapes(content.as_bytes()); + assert_eq!(shapes.boxes, [[100.0, 100.0, 111.1, 114.7]], "{content}"); + } + for content in [ + // Out past the corner and back: a spike, not a side. + "100 100 m 130 100 l 111.1 100 l 111.1 114.7 l 100 114.7 l h S", + // A sixth point off the sides. + "100 100 m 111.1 100 l 115 107 l 111.1 114.7 l 100 114.7 l 100 107.35 l h S", + ] { + assert!(detection_test_shapes(content.as_bytes()).boxes.is_empty(), "{content}"); + } +} + #[test] fn page_shapes_rejects_open_diagonal_curved_and_complex_rectangles() { for content in [ @@ -2066,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; @@ -2104,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/Cargo.toml b/crates/js/Cargo.toml index ee09b8a8e..c0df85057 100644 --- a/crates/js/Cargo.toml +++ b/crates/js/Cargo.toml @@ -12,6 +12,9 @@ workspace = true [dependencies] boa_engine = { version = "0.22.0", default-features = false, features = ["annex-b"] } +# FormCalc Date/Num2Date calendar conversion goes through chrono (no hand-rolled calendar code); +# std-only, so it stays wasm-safe. +chrono = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] boa_engine = { version = "0.22.0", default-features = false, features = ["annex-b", "js"] } diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs index 059276e93..a29397339 100644 --- a/crates/js/src/formcalc.rs +++ b/crates/js/src/formcalc.rs @@ -712,6 +712,13 @@ fn num_text(n: f64) -> String { if s.is_empty() || s == "-0" { "0".into() } else { s.to_string() } } +/// `v` with `decimals` places, rounding exact halves away from zero (Rust's formatter rounds them to even). +fn fixed(v: f64, decimals: usize) -> String { + let k = 10f64.powi(decimals.min(20) as i32); + let scaled = (v * k).round() / k; + format!("{:.decimals$}", if scaled.is_finite() { scaled } else { v }) +} + /// The number a string holds: its leading numeric part (`12abc` → 12), `None` for none. fn parse_num(s: &str) -> Option { let t = s.trim(); @@ -1090,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()), @@ -1831,30 +1844,50 @@ impl Interp<'_> { // ── built-in functions ────────────────────────────────────────────────────────────────────── +/// `NaiveDate::num_days_from_ce` of 1900-01-01 minus one: a civil date's day count rebased so +/// that 1900-01-01 is FormCalc day 1 (and 1970-01-01 is day 25_568). +const FORMCALC_EPOCH_FROM_CE: i64 = 693_595; + +/// Days in one 400-year Gregorian cycle; the calendar repeats after it. +const DAYS_PER_400_YEARS: i64 = 146_097; + /// Days from 1900-01-01 (day 1, as FormCalc counts) to a civil date. +/// +/// chrono's `NaiveDate` stops near ±262143, but FormCalc accepts day numbers up to `MAX_DAYS` +/// (years past 270000). The calendar repeats every 400 years, so chrono converts the date +/// within its cycle and whole cycles are added back; the result matches the proleptic Gregorian +/// count for every year. Callers pass dates `parse_date`/`civil_from_days` already validated; +/// anything else reads as 0. fn days_from_civil(y: i64, m: u32, d: u32) -> i64 { - // Howard Hinnant's algorithm, epoch 1970-01-01, shifted to FormCalc's 1900-01-01 = 1. - let y = if m <= 2 { y - 1 } else { y }; - let era = if y >= 0 { y } else { y - 399 } / 400; - let yoe = y - era * 400; - let mp = (i64::from(m) + 9) % 12; - let doy = (153 * mp + 2) / 5 + i64::from(d) - 1; - let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; - let unix = era * 146_097 + doe - 719_468; - unix + 25_568 + use chrono::Datelike; + let (cycles, year) = (i128::from(y.div_euclid(400)), y.rem_euclid(400)); + i32::try_from(year) + .ok() + .and_then(|year| chrono::NaiveDate::from_ymd_opt(year, m, d)) + .and_then(|date| { + let days = i128::from(date.num_days_from_ce()) - i128::from(FORMCALC_EPOCH_FROM_CE) + cycles * i128::from(DAYS_PER_400_YEARS); + i64::try_from(days).ok() + }) + .unwrap_or(0) } +/// The civil date (year, month, day) at FormCalc day number `n` (day 1 is 1900-01-01), for any +/// `n`: chrono converts the day within its 400-year cycle and the cycles are added back as years. fn civil_from_days(n: i64) -> (i64, u32, u32) { - let z = n - 25_568 + 719_468; - let era = if z >= 0 { z } else { z - 146_096 } / 146_097; - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let y = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = (doy - (153 * mp + 2) / 5 + 1) as u32; - let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; - (if m <= 2 { y + 1 } else { y }, m, d) + use chrono::Datelike; + // Days since CE day 0 (0000-12-31), in i128 so no `n` overflows. + let from_ce = i128::from(n) + i128::from(FORMCALC_EPOCH_FROM_CE); + let (cycles, rest) = (from_ce.div_euclid(i128::from(DAYS_PER_400_YEARS)), from_ce.rem_euclid(i128::from(DAYS_PER_400_YEARS))); + // `rest` is 0..146_097, so CE day `rest` lies in years 0..=400 (day 0 is 0000-12-31). + let date = i32::try_from(rest).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + match date { + Some(date) => { + let y = i128::from(date.year()) + cycles * 400; + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), date.month(), date.day()) + } + // Unreachable: every `rest` in 0..146_097 is a valid CE day. + None => (1900, 1, 1), + } } /// Day numbers (and years) beyond this are refused rather than computed. @@ -1918,14 +1951,21 @@ fn parse_date(text: &str, picture: &str) -> Option<(i64, u32, u32)> { /// Number of days in month `m` (1-12) of year `y`, leap years included. fn days_in(y: i64, m: u32) -> u32 { - match m { - 2 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29, - 2 => 28, - 4 | 6 | 9 | 11 => 30, + // The length of the month from chrono (the first of the next month, less the first of this + // one). An invalid month keeps the old 31-day default. + // Month lengths repeat every 400 years, so the year within its cycle is enough (and always + // in chrono's range, whatever `y` is). + let y = y.rem_euclid(400); + let first = |y: i64, m: u32| i32::try_from(y).ok().and_then(|y| chrono::NaiveDate::from_ymd_opt(y, m, 1)); + let next = if m == 12 { first(y + 1, 1) } else { first(y, m + 1) }; + match (first(y, m), next) { + (Some(a), Some(b)) => (b - a).num_days() as u32, _ => 31, } } +const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + fn format_date(y: i64, m: u32, d: u32, picture: &str) -> String { let pic: Vec = picture.chars().collect(); let mut out = String::new(); @@ -1948,6 +1988,19 @@ fn format_date(y: i64, m: u32, d: u32, picture: &str) -> String { ('D', 1) => out.push_str(&d.to_string()), (_, _) => out.push_str(&format!("{d:02}")), } + } else if pic[i] == 'E' { + let mut run = 1; + while i + run < pic.len() && pic[i + run] == 'E' { + run += 1; + } + i += run; + // Day 1 (1900-01-01) was a Monday, so the serial modulo 7 indexes from Sunday. + let weekday = days_from_civil(y, m, d).rem_euclid(7) as usize; + match run { + 1 | 2 => out.push_str(&(weekday + 1).to_string()), + 3 => out.push_str(WEEKDAYS.get(weekday).map_or("", |n| &n[..3])), + _ => out.push_str(WEEKDAYS.get(weekday).copied().unwrap_or("")), + } } else { out.push(pic[i]); i += 1; @@ -2005,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; @@ -2166,7 +2223,7 @@ impl Interp<'_> { let v = self.arg_num(args, 0)?; let width = if args.len() > 1 { self.arg_num(args, 1)?.clamp(0.0, 1000.0) as usize } else { 10 }; let prec = if args.len() > 2 { self.arg_num(args, 2)?.clamp(0.0, 20.0) as usize } else { 0 }; - let text = format!("{v:.prec$}"); + let text = fixed(v, prec); Val::Str(if text.len() > width { "*".repeat(width) } else { format!("{text:>width$}") }) } "substr" => { @@ -2254,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 = format!("{:.decimals$}", num.abs()); + 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(); @@ -2347,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" => { @@ -2569,11 +2636,29 @@ mod tests { assert_eq!(days_from_civil(1900, 1, 1), 1); assert_eq!(days_from_civil(1970, 1, 1), 25_568); assert_eq!(civil_from_days(days_from_civil(2024, 2, 29)), (2024, 2, 29)); + // Past chrono's ±262143 the 400-year cycles carry the year; values from the pre-chrono + // formula, both directions, both sides of the epoch. + for (n, date) in [ + (99_000_000, (272_952, 10, 16)), + (99_999_999, (275_690, 9, 11)), + (-99_000_000, (-269_153, 3, 16)), + (-99_999_999, (-271_891, 4, 20)), + (72_354_905, (200_000, 12, 31)), + (-73_742_460, (-200_000, 1, 1)), + ] { + assert_eq!(civil_from_days(n), date, "{n}"); + assert_eq!(days_from_civil(date.0, date.1, date.2), n, "{date:?}"); + } + assert_eq!(days_in(272_952, 2), 29, "272952 is a leap year"); + assert_eq!(days_in(-269_153, 2), 28); + assert_eq!(days_in(i64::MAX, 12), 31); + let _ = (civil_from_days(i64::MAX), civil_from_days(i64::MIN), days_from_civil(i64::MAX, 12, 31), days_from_civil(i64::MIN, 1, 1)); assert_eq!(parse_date("2001-02-03", "YYYY-MM-DD"), Some((2001, 2, 3))); assert_eq!(parse_date("Feb 3, 2001", "MMM D, YYYY"), Some((2001, 2, 3))); 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 d8d9fa8ba..785345c67 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, 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(()) } @@ -1098,6 +1100,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); @@ -1119,12 +1128,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 55fa45760..7a9efb5df 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"); @@ -636,6 +688,45 @@ mod formcalc { assert_eq!(calc("; a comment\n// another\nSum(1,\n 2,\n 3)"), "6"); } + #[test] + fn date_pictures_render_the_weekday_letters() { + assert_eq!(calc(r#"Num2Date(45350, DateFmt(4))"#), "Thursday, February 29, 2024"); + assert_eq!(calc(r#"Num2Date(45350, "EEE, YYYY-MM-DD")"#), "Thu, 2024-02-29"); + assert_eq!(calc(r#"Num2Date(45350, "EEEE")"#), "Thursday"); + assert_eq!(calc(r#"Num2Date(45350, "E")"#), "5"); + assert_eq!(calc(r#"Num2Date(45351, "EEEE")"#), "Friday"); + assert_eq!(calc(r#"Num2Date(45352, "EEEE")"#), "Saturday"); + assert_eq!(calc(r#"Num2Date(45353, "EEEE")"#), "Sunday"); + assert_eq!(calc(r#"Num2Date(1, "EEEE")"#), "Monday"); + assert_eq!(calc(r#"Format("EEEE, YYYY-MM-DD", 45350)"#), "Thursday, 2024-02-29"); + // Other letters are still literal text. + assert_eq!(calc(r#"Num2Date(45350, "YYYY-MM-DD x")"#), "2024-02-29 x"); + } + + /// Accepted day numbers past chrono's own date range (about year 262143) keep the dates the + /// pre-chrono algorithm gave, on both sides of the epoch, through Num2Date and Format. + #[test] + fn date_numbers_beyond_chronos_range_keep_their_dates() { + assert_eq!(calc(r#"Num2Date(99000000, "YYYY-MM-DD")"#), "272952-10-16"); + assert_eq!(calc(r#"Format("YYYY-MM-DD", 99000000)"#), "272952-10-16"); + assert_eq!(calc(r#"Num2Date(99999999, "YYYY-MM-DD")"#), "275690-09-11"); + assert_eq!(calc(r#"Num2Date(-99000000, "YYYY-MM-DD")"#), "-269153-03-16"); + assert_eq!(calc(r#"Num2Date(-99999999, "YYYY-MM-DD")"#), "-271891-04-20"); + } + + #[test] + fn str_and_format_round_exact_ties_away_from_zero() { + assert_eq!(calc("Str(2.5, 3, 0)"), " 3"); + assert_eq!(calc("Str(0.5, 3, 0)"), " 1"); + assert_eq!(calc("Str(-2.5, 4, 0)"), " -3"); + assert_eq!(calc("Str(1.125, 6, 2)"), " 1.13"); + assert_eq!(calc(r#"Format("9", 2.5)"#), "3"); + assert_eq!(calc(r#"Format("9.99", 0.125)"#), "0.13"); + assert_eq!(calc(r#"Format("9.9", 0.25)"#), "0.3"); + assert_eq!(calc(r#"Format("9.9", -0.25)"#), "-0.3"); + assert_eq!(calc(r#"Format("9.99", 2.344)"#), "2.34"); + } + #[test] fn built_in_functions_cover_the_common_set() { assert_eq!(calc("Abs(-2.5)"), "2.5"); @@ -680,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"); @@ -692,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"); @@ -1104,3 +1207,37 @@ mod session { assert!(o.abandoned || o.error.as_deref().is_some_and(|e| e.contains("too long")), "{o:?}"); } } + +#[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()); +} diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index a2361da7e..5a693db9f 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/ocr/src/lib.rs b/crates/ocr/src/lib.rs index 8d56fa4aa..affa4ab16 100644 --- a/crates/ocr/src/lib.rs +++ b/crates/ocr/src/lib.rs @@ -215,7 +215,10 @@ fn num(v: f64) -> String { /// WinAnsiEncoding), each word stretched to its box so selection and search highlight the /// right place. Marked content `/OCR` so it can be told apart from the page's own text. pub fn text_layer(words: &[PlacedWord]) -> Vec { - let mut out = b"/OCR BMC\nBT\n3 Tr\n/PCHelv 1 Tf\n".to_vec(); + // `3 Tr` and the font are graphics state, which `ET` doesn't reset: without `q … Q` they + // would stay set for every later content stream on the page, so text added after OCR (a + // header or footer) drew invisibly (#3029). + let mut out = b"/OCR BMC\nq\nBT\n3 Tr\n/PCHelv 1 Tf\n".to_vec(); for w in words { let width = helvetica_width(&w.text, 1.0); let height = w.up[0].hypot(w.up[1]); @@ -235,7 +238,7 @@ pub fn text_layer(words: &[PlacedWord]) -> Vec { out.extend(pdfcraft_fonts::literal(&pdfcraft_fonts::win_ansi(&w.text))); out.extend_from_slice(b" Tj\n"); } - out.extend_from_slice(b"ET\nEMC\n"); + out.extend_from_slice(b"ET\nQ\nEMC\n"); out } @@ -272,6 +275,26 @@ mod tests { assert!(s.starts_with("/OCR BMC") && s.ends_with("EMC\n")); } + /// #3029: the layer's invisible rendering mode and font end with the layer. Every operator + /// that sets them sits inside a `q … Q` pair that the layer closes itself, so content drawn + /// after it on the page starts from the graphics state it had before. + #[test] + fn the_layer_restores_the_graphics_state_it_changes() { + let w = PlacedWord { text: "Hi".into(), origin: [10.0, 20.0], across: [30.0, 0.0], up: [0.0, 9.3] }; + let s = String::from_utf8(text_layer(&[w])).unwrap(); + let mut depth = 0_i32; + for token in s.split_ascii_whitespace() { + match token { + "q" => depth += 1, + "Q" => depth -= 1, + "Tr" | "Tf" | "Tm" => assert!(depth > 0, "`{token}` outside q … Q in {s:?}"), + _ => {} + } + assert!(depth >= 0, "unbalanced Q in {s:?}"); + } + assert_eq!(depth, 0, "the layer leaves a q open: {s:?}"); + } + /// A word box is tight around the ink, so the text's size and baseline come from the letters: /// capitals, lowercase without ascenders, descenders. #[test] 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/oracle/README.md b/crates/oracle/README.md index 7d2996b77..01a53360d 100644 --- a/crates/oracle/README.md +++ b/crates/oracle/README.md @@ -15,7 +15,7 @@ Every test that wanted an oracle used to probe for it by hand and decide on its it was missing. The common choice — print a line and return — reports as a **pass**, so a suite with no oracles installed looked exactly like one in which every oracle agreed. -That was not hypothetical. No CI runner installed `qpdf`, so `cos/tests/encryption.rs::qpdf_agrees` +That was not hypothetical. No CI runner installed `qpdf`, so `cos/tests/it/encryption.rs::qpdf_agrees` had never executed on a runner, while `README.md` said saved output was "independently checked with qpdf". For R2–R4 that test is the *only* check of owner-password authentication (Algorithm 7) that does not go through our own code. @@ -38,7 +38,7 @@ match pdfcraft_oracle::pdftotext() { |---|---|---| | `qpdf()` | qpdf | `--check` structural validation; the independent owner-password oracle | | `pdftoppm()` | Poppler | page pixels, for comparing against our own raster output | -| `pdftotext()` | Poppler | text extraction (`cargo xtask text-oracle` scores word F1 against it) | +| `pdftotext()` | Poppler | text extraction (`cargo xtask text-oracle` scores word F1 and reading order against it) | Each returns `Result`. `Oracle::command()` hands back a fresh `Command`; `Absent` renders as an actionable sentence naming the install command. @@ -50,10 +50,10 @@ Each returns `Result`. `Oracle::command()` hands back a fresh `C install the tools, so a skip notice there becomes a red build. ``` -$ cargo test -p pdfcraft-cos --test encryption qpdf_agrees +$ cargo test -p pdfcraft-cos --test it encryption::qpdf_agrees test result: ok. 1 passed # qpdf absent: skipped, with a notice on stderr -$ PDFCRAFT_ORACLES_REQUIRED=1 cargo test -p pdfcraft-cos --test encryption qpdf_agrees +$ PDFCRAFT_ORACLES_REQUIRED=1 cargo test -p pdfcraft-cos --test it encryption::qpdf_agrees oracle `qpdf` is required but is not on PATH (program not found): PDFCRAFT_ORACLES_REQUIRED is set, so this is a failure rather than a skip (apt install qpdf / brew install qpdf) test result: FAILED. 1 failed @@ -90,7 +90,31 @@ appearance we do draw. **Poppler is not a usable oracle for JBIG2.** The 96 `bitmap-*.pdf` corpus fixtures encode one picture through every corner of the format, so a correct decoder renders them all identically. We render 1 distinct image across all 96; Poppler renders 8, drawing a blank page for seven of them. -`crates/render/tests/jbig2_invariants.rs` pins that invariant, and needs no oracle to do it. +`crates/render/tests/it/jbig2_invariants.rs` pins that invariant, and needs no oracle to do it. + +## Comparing text + +`cargo xtask text-oracle` scores two different things against `pdftotext`, because one number +hides the other. + +**Word F1** is a bag of words: how much of what each side extracted the other also extracted, +with multiplicity. It catches a character read wrong, a glyph not read at all, a font we cannot +map. It is blind to *order* — a page emitted back to front scores 1.000. + +**Reading order** is the longest common subsequence of the two word sequences, as a share of the +longer one. It catches the failures F1 cannot see: a column read into the middle of its +neighbour, a figure's labels threaded through a paragraph, a form's value parted from its label. +Both sides are cut to the first 3,000 words, because the comparison is quadratic and three pages +is plenty of signal; the run says how many files that bit. + +The second score exists because of a measurement. Reviewing #2431, a change that moves text and +alters nothing else, word F1 called 26 of the 29 files whose output changed *identical*, while +the order score separated 18 improvements from 6 regressions — the regressions on forms, where +a checkbox's number was parted from its label. + +Read it the same way as the pixel score, and then once more: Poppler's reading order is a second +opinion, not the truth, and the same split can score as a win on one file and a loss on another +purely because `pdftotext` happens to make it in one place and not the other. ## Still to do diff --git a/crates/oracle/src/lib.rs b/crates/oracle/src/lib.rs index e9e7ae372..16c7192b1 100644 --- a/crates/oracle/src/lib.rs +++ b/crates/oracle/src/lib.rs @@ -23,6 +23,8 @@ //! This crate is test scaffolding (`Class::Testkit` in `xtask/src/layers.rs`): a dev-dependency //! only, never compiled into the product. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + pub mod pixels; use std::fmt; diff --git a/crates/oracle/src/pixels.rs b/crates/oracle/src/pixels.rs index 2b1171981..4ff0ebaf1 100644 --- a/crates/oracle/src/pixels.rs +++ b/crates/oracle/src/pixels.rs @@ -201,6 +201,10 @@ pub fn decode_pam_gray(bytes: &[u8]) -> Option<(usize, usize, Vec)> { } fn find(haystack: &[u8], needle: &[u8]) -> Option { + // `windows(0)` panics. + if needle.is_empty() { + return None; + } haystack.windows(needle.len()).position(|w| w == needle) } @@ -404,6 +408,14 @@ mod tests { assert_eq!((w, h, px.len()), (4, 2, 8)); } + /// An empty needle is not found, rather than panicking in `windows(0)`. + #[test] + fn find_with_an_empty_needle_is_none() { + assert_eq!(find(b"abc", b""), None); + assert_eq!(find(b"", b""), None); + assert_eq!(find(b"abcabc", b"ca"), Some(2)); + } + /// Malformed or hostile headers return `None` rather than panicking or inventing a size. #[test] fn hostile_pgm_headers_are_rejected() { 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 3b2873abe..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. @@ -113,13 +151,17 @@ pub fn pages(doc: &Document) -> Result, OrganizeError> { pub fn page_rotation(doc: &Document, index: usize) -> Result { let pages = walk(doc)?; let (_, attrs) = pages.get(index).ok_or(OrganizeError::NoSuchPage(index))?; - Ok(attrs.int(b"Rotate").unwrap_or(0).rem_euclid(360)) + Ok(resolved_rotation(doc, attrs).rem_euclid(360)) } pub fn page_count(doc: &Document) -> Result { Ok(walk(doc)?.len()) } +fn resolved_rotation(doc: &Document, attrs: &Dict) -> i64 { + attrs.get(b"Rotate").and_then(|value| doc.resolve(value).as_int()).unwrap_or(0) +} + /// Rebuild the page tree as one flat node with `order` as its kids (attributes pushed down). fn rebuild(doc: &mut Document, order: &[(ObjRef, Dict)]) -> Result<(), OrganizeError> { let root = pages_root(doc)?; @@ -201,7 +243,10 @@ pub fn rotate_pages(doc: &mut Document, indices: &[usize], degrees: i64) -> Resu let delta = degrees.rem_euclid(360); for &i in indices { let (page, inherited) = &all[i]; - let current = doc.get(*page).as_dict().and_then(|d| d.int(b"Rotate")).or_else(|| inherited.int(b"Rotate")).unwrap_or(0); + // Repeated indices must see earlier edits instead of the page walk's snapshot. + let current_page = doc.get(*page); + let current_attrs = current_page.as_dict().filter(|attrs| attrs.get(b"Rotate").is_some()).unwrap_or(inherited); + let current = resolved_rotation(doc, current_attrs); let new = (current.rem_euclid(360) + delta).rem_euclid(360); doc.update_dict(*page, |d| d.set(b"Rotate".to_vec(), Object::Int(new)))?; } @@ -209,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())?; @@ -358,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 { @@ -411,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 @@ -472,9 +525,30 @@ pub fn move_pages(doc: &mut Document, indices: &[usize], to: usize) -> Result<() rebuild(doc, &rest) } -/// Duplicate pages: copies of `indices` (in order) are inserted after the last of them, sharing +/// Reverse the order of the pages at `indices` among the positions they occupy; every other +/// page stays where it is. Fewer than two distinct pages is refused, since nothing would change. +pub fn reverse_pages(doc: &mut Document, indices: &[usize]) -> Result<(), OrganizeError> { + let mut all = walk(doc)?; + check(indices, all.len())?; + let mut slots = indices.to_vec(); + slots.sort_unstable(); + slots.dedup(); + if slots.len() < 2 { + return Err(OrganizeError::Invalid("reversing needs at least two pages".into())); + } + let pages: Vec<(ObjRef, Dict)> = slots.iter().filter_map(|i| all.get(*i).cloned()).collect(); + for (slot, page) in slots.iter().zip(pages.into_iter().rev()) { + if let Some(entry) = all.get_mut(*slot) { + *entry = page; + } + } + rebuild(doc, &all) +} + +/// Duplicate pages: copies of `indices` (in order) are inserted at `at` in the page order +/// (clamped to the document's end), or after the last of them when `at` is `None`, sharing /// fonts and images with the originals. -pub fn duplicate_pages(doc: &mut Document, indices: &[usize]) -> Result<(), OrganizeError> { +pub fn duplicate_pages(doc: &mut Document, indices: &[usize], at: Option) -> Result<(), OrganizeError> { let n = page_count(doc)?; check(indices, n)?; let Some(&last) = indices.iter().max() else { return Ok(()) }; @@ -482,7 +556,7 @@ pub fn duplicate_pages(doc: &mut Document, indices: &[usize]) -> Result<(), Orga order.sort_unstable(); order.dedup(); let src = doc.clone(); - import_pages(doc, &src, &order, last + 1).map(|_| ()) + import_pages(doc, &src, &order, at.unwrap_or(last + 1)).map(|_| ()) } /// Replace Pages: the content of `targets` (in order) is replaced by that of `src_pages` of @@ -536,6 +610,54 @@ pub fn insert_blank_page(doc: &mut Document, at: usize, width: f64, height: f64) /// Document information keys editable in Document Properties ▸ Description. pub const INFO_KEYS: [&str; 4] = ["Title", "Author", "Subject", "Keywords"]; +/// The document-information entries the spec names (ISO 32000-2 table 349). Any other key in +/// `/Info` is a custom property (Document Properties ▸ Custom). +pub const STANDARD_INFO_KEYS: [&str; 9] = ["Title", "Author", "Subject", "Keywords", "Creator", "Producer", "CreationDate", "ModDate", "Trapped"]; + +/// Longest custom property name accepted, in bytes: the name length every reader is asked to +/// accept (§7.3.5, Annex C). +pub const MAX_CUSTOM_KEY_LEN: usize = 127; + +/// The custom document properties: every `/Info` entry whose key the spec does not name and whose +/// value is text, in the dictionary's order, as (name, text), the text without NUL or whitespace +/// padding and entries that are only padding left out. At most 256 are listed. +pub fn custom_info(doc: &Document) -> Vec<(String, String)> { + let Some(info) = doc.trailer().get(b"Info").map(|o| doc.resolve(o)) else { return Vec::new() }; + let Some(d) = info.as_dict() else { return Vec::new() }; + d.iter() + .filter(|(k, _)| !STANDARD_INFO_KEYS.iter().any(|s| s.as_bytes() == k.as_slice())) + .filter_map(|(k, v)| { + // Padding (\0s and whitespace, in any mix) is not part of the value, and a value that + // is only padding is no property. Filtered before the cap, so blank entries can't + // crowd real ones out. + let text = doc.resolve(v).as_string()?.to_text(); + let text = text.trim_matches(|c: char| c == '\0' || c.is_whitespace()); + (!text.is_empty()).then(|| (String::from_utf8_lossy(k).into_owned(), text.to_owned())) + }) + .take(256) + .collect() +} + +/// Whether `name` may be a custom property's key: not one the spec names (those are the +/// Description fields), not empty, at most [`MAX_CUSTOM_KEY_LEN`] bytes, and without the +/// characters a name cannot carry unescaped (whitespace, the delimiters `()<>[]{}/%` and `#`), +/// so every reader shows the same name. Acrobat refuses the standard and empty names too. +pub fn check_custom_key(name: &str) -> Result<(), OrganizeError> { + if name.is_empty() { + return Err(OrganizeError::Invalid("a custom property needs a name".into())); + } + if STANDARD_INFO_KEYS.contains(&name) { + return Err(OrganizeError::Invalid(format!("{name} is a standard document property, not a custom one"))); + } + if name.len() > MAX_CUSTOM_KEY_LEN { + return Err(OrganizeError::Invalid(format!("a custom property name is at most {MAX_CUSTOM_KEY_LEN} bytes"))); + } + if let Some(c) = name.chars().find(|c| c.is_whitespace() || c.is_control() || "()<>[]{}/%#".contains(*c)) { + return Err(OrganizeError::Invalid(format!("a custom property name can't contain {c:?}"))); + } + Ok(()) +} + /// Read a document-information entry as text. pub fn info(doc: &Document, key: &str) -> Option { let info = doc.trailer().get(b"Info").map(|o| doc.resolve(o))?; @@ -548,6 +670,9 @@ pub fn info(doc: &Document, key: &str) -> Option { /// in the XMP packet, and viewers that read XMP first show its value: the packet's property is /// changed (or removed) to match (see `xmp`). pub fn set_info(doc: &mut Document, key: &str, value: &str) -> Result<(), OrganizeError> { + if !STANDARD_INFO_KEYS.contains(&key) { + check_custom_key(key)?; + } let value = value.trim(); let entry = (!value.is_empty()).then(|| Object::String(PdfString::text(value))); set_info_entry(doc, key.as_bytes(), entry)?; @@ -579,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 d34b1e431..7a80e38e4 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -16,6 +16,53 @@ fn page_rotation_resolves_inheritance_overrides_and_missing_pages() { assert_eq!(page_rotation(&doc, 3), Err(OrganizeError::NoSuchPage(3))); } +#[test] +fn indirect_page_rotation_is_read_and_updated_from_its_effective_value() { + let large = i64::MAX - i64::MAX % 90; + for (current, degrees, expected) in [(270, 90, 0), (-90, 90, 0), (large, 90, 90), (90, -180, 270)] { + for level in 0..3 { + let mut doc = open(fixture()); + let rotation = doc.add(Object::Int(current)); + let page = pages(&doc).unwrap()[0].obj; + let target = match level { + 0 => page, + 1 => doc.get(page).as_dict().unwrap().reference(b"Parent").unwrap(), + _ => pages_root(&doc).unwrap(), + }; + doc.update_dict(target, |d| d.set(b"Rotate".to_vec(), Object::Ref(rotation))).unwrap(); + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + let mut doc = open(bytes); + let rotation = walk(&doc).unwrap()[0].1.reference(b"Rotate").unwrap(); + let initial = page_rotation(&doc, 0).unwrap(); + rotate_pages(&mut doc, &[0], degrees).unwrap(); + assert_eq!(*doc.get(rotation), Object::Int(current), "shared rotation object unchanged"); + for saved in [write_incremental(&doc, &SaveOptions::default()).unwrap(), write_full(&doc, &SaveOptions::default()).unwrap()] { + let reopened = open(saved.clone()); + let pdf = hayro_syntax::Pdf::new(saved).unwrap(); + let dimensions = if expected % 180 == 0 { (300.0, 400.0) } else { (400.0, 300.0) }; + assert_eq!(pdf.pages()[0].render_dimensions(), dimensions, "written PDF has the correct orientation"); + assert_eq!(page_rotation(&reopened, 0).unwrap(), expected); + assert_eq!(page_rotation(&reopened, 1).unwrap(), if level == 0 { 90 } else { current.rem_euclid(360) }, "sibling unchanged"); + assert_eq!(page_rotation(&reopened, 2).unwrap(), if level == 2 { current.rem_euclid(360) } else { 90 }, "other subtree unchanged"); + } + assert_eq!(initial, current.rem_euclid(360), "effective indirect rotation at level {level}"); + } + } +} + +#[test] +fn malformed_indirect_rotations_keep_the_zero_fallback() { + for value in [Object::Null, Object::name("invalid"), Object::Ref(ObjRef::new(99_999, 0))] { + let mut doc = open(fixture()); + let rotation = doc.add(value); + let page = pages(&doc).unwrap()[0].obj; + doc.update_dict(page, |d| d.set(b"Rotate".to_vec(), Object::Ref(rotation))).unwrap(); + assert_eq!(page_rotation(&doc, 0).unwrap(), 0); + rotate_pages(&mut doc, &[0], 90).unwrap(); + assert_eq!(page_rotation(&doc, 0).unwrap(), 90); + } +} + #[test] fn rotate_rejects_angles_that_are_not_multiples_of_90() { // The page tree only turns in quarter steps, so anything else used to be silently truncated @@ -155,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 @@ -244,6 +404,25 @@ fn move_pages_reorders() { assert_eq!(labels(&doc), ["Page 2", "Page 3", "Page 1"]); } +#[test] +fn reverse_pages_reorders() { + let mut doc = open(fixture()); + reverse_pages(&mut doc, &[0, 1, 2]).unwrap(); + assert_eq!(labels(&doc), ["Page 3", "Page 2", "Page 1"]); + // Unsorted, repeated indices name the same slots; page 1's slot stays put. + reverse_pages(&mut doc, &[2, 1, 2]).unwrap(); + let doc = save_and_reopen(&doc); + assert_eq!(labels(&doc), ["Page 3", "Page 1", "Page 2"]); +} + +#[test] +fn cannot_reverse_missing_or_fewer_than_two_pages() { + let mut doc = open(fixture()); + assert_eq!(reverse_pages(&mut doc, &[0, 3]), Err(OrganizeError::NoSuchPage(3))); + assert!(matches!(reverse_pages(&mut doc, &[1, 1]), Err(OrganizeError::Invalid(_)))); + assert!(!doc.is_modified(), "failed operations leave the document untouched"); +} + #[test] fn inserts_blank_page() { let mut doc = open(fixture()); @@ -317,6 +496,59 @@ fn page_transitions_are_set_removed_and_checked() { assert_eq!(page_dict(&doc, 2).get(b"Dur").and_then(Object::as_f64), Some(5.0)); } +/// Custom document properties (Document Properties ▸ Custom): any `/Info` key the spec does not +/// name, set and cleared like the standard ones, listed apart from them, and with the names +/// Acrobat refuses refused here too. +#[test] +fn custom_document_properties_are_listed_set_removed_and_their_names_checked() { + let mut doc = open(fixture()); + assert!(custom_info(&doc).is_empty()); + set_info(&mut doc, "Title", "Report").unwrap(); + set_info(&mut doc, "Project", "Apollo").unwrap(); + set_info(&mut doc, "Revision", "7").unwrap(); + let mut listed = custom_info(&doc); + listed.sort(); + assert_eq!(listed, [("Project".to_string(), "Apollo".to_string()), ("Revision".to_string(), "7".to_string())], "standard keys are not custom"); + assert_eq!(info(&doc, "Project").as_deref(), Some("Apollo")); + set_info(&mut doc, "Project", "").unwrap(); + let doc = save_and_reopen(&doc); + assert_eq!(custom_info(&doc), [("Revision".to_string(), "7".to_string())], "cleared, and the rest survives a save"); + assert_eq!(info(&doc, "Title").as_deref(), Some("Report")); + + for bad in ["", "Title", "ModDate", "Bad Name", "a/b", "tab\tbed", "hash#tag", "(x)"] { + assert!(check_custom_key(bad).is_err(), "{bad:?} must be refused"); + } + let long = "x".repeat(MAX_CUSTOM_KEY_LEN + 1); + assert!(check_custom_key(&long).is_err()); + for good in ["Project", "Révision", "部門", "key-2.0", &"y".repeat(MAX_CUSTOM_KEY_LEN)] { + assert!(check_custom_key(good).is_ok(), "{good:?} is a usable name"); + } + let mut doc = open(fixture()); + assert!(set_info(&mut doc, "Bad Name", "x").is_err(), "set_info checks custom names"); + assert!(custom_info(&doc).is_empty()); +} + +/// Padding is not part of a custom value, in any mix of NULs and whitespace, a value that is only +/// padding is not listed, and such entries don't count towards the 256 listed: a real property +/// after 300 blank ones is still there. +#[test] +fn custom_document_properties_drop_padding_before_the_cap() { + let mut doc = open(fixture()); + set_info(&mut doc, "Title", "Report").unwrap(); + let Some(Object::Ref(info_ref)) = doc.trailer().get(b"Info").cloned() else { panic!("an /Info reference") }; + let text = |s: &str| Object::String(pdfcraft_cos::PdfString { bytes: s.as_bytes().to_vec(), hex: false }); + doc.update_dict(info_ref, |d| { + d.set(b"Mixed".to_vec(), text(" \0Lab\0 ")); + d.set(b"Pad".to_vec(), text(" \0 \0")); + for i in 0..300 { + d.set(format!("Blank{i}").into_bytes(), text(" ")); + } + d.set(b"Keep".to_vec(), text("yes")); + }) + .unwrap(); + assert_eq!(custom_info(&doc), [("Mixed".to_string(), "Lab".to_string()), ("Keep".to_string(), "yes".to_string())]); +} + /// A one-page document whose catalog has `xmp` as its (Flate-compressed) XMP packet, and the /// same title and author in `/Info`, as LibreOffice writes them. fn doc_with_xmp(xmp: &[u8]) -> Document { @@ -655,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(); @@ -928,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()); @@ -1852,11 +2142,24 @@ fn page_boxes_default_inherit_and_set() { #[test] fn duplicate_pages_inserts_copies_after_the_last() { let mut doc = open(fixture()); - duplicate_pages(&mut doc, &[0, 1]).unwrap(); + duplicate_pages(&mut doc, &[0, 1], None).unwrap(); let doc = open(write_full(&doc, &SaveOptions::default()).unwrap()); assert_eq!(labels(&doc), ["Page 1", "Page 2", "Page 1", "Page 2", "Page 3"]); } +#[test] +fn duplicate_pages_at_inserts_the_copies_there() { + let mut doc = open(fixture()); + duplicate_pages(&mut doc, &[2], Some(0)).unwrap(); + let doc = open(write_full(&doc, &SaveOptions::default()).unwrap()); + assert_eq!(labels(&doc), ["Page 3", "Page 1", "Page 2", "Page 3"]); + // An index past the end lands at the end. + let mut doc = open(fixture()); + duplicate_pages(&mut doc, &[0], Some(99)).unwrap(); + let doc = open(write_full(&doc, &SaveOptions::default()).unwrap()); + assert_eq!(labels(&doc), ["Page 1", "Page 2", "Page 3", "Page 1"]); +} + #[test] fn replace_pages_swaps_content_but_keeps_annotations() { // Page 2 of the target gets an annotation; replacing its content keeps it. @@ -2463,7 +2766,7 @@ fn import_can_merge_into_same_named_fields_instead() { #[test] fn duplicating_a_page_with_a_field_keeps_the_field_name() { let mut doc = doc_with_field("Text1"); - crate::duplicate_pages(&mut doc, &[0]).unwrap(); + crate::duplicate_pages(&mut doc, &[0], None).unwrap(); assert_eq!(crate::page_count(&doc).unwrap(), 2); assert_eq!(form_field_names(&doc), ["Text1", "Text1"], "nothing renamed"); } @@ -2618,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) @@ -2657,3 +2964,171 @@ fn a_chain_of_objects_too_deep_to_copy_is_refused_and_leaves_the_destination_alo .join(); 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 { + for indices in [vec![0, 0], vec![0, 1, 0]] { + let mut doc = open(fixture()); + let rotation = doc.add(Object::Int(90)); + let page = pages(&doc).unwrap()[0].obj; + let target = match level { + 0 => page, + 1 => doc.get(page).as_dict().unwrap().reference(b"Parent").unwrap(), + _ => pages_root(&doc).unwrap(), + }; + doc.update_dict(target, |d| d.set(b"Rotate".to_vec(), Object::Ref(rotation))).unwrap(); + let mut doc = open(write_full(&doc, &SaveOptions::default()).unwrap()); + let rotation = walk(&doc).unwrap()[0].1.reference(b"Rotate").unwrap(); + let original_labels = labels(&doc); + rotate_pages(&mut doc, &indices, 90).unwrap(); + assert_eq!(page_rotation(&doc, 0).unwrap(), 270, "level {level}, indices {indices:?}"); + assert_eq!(*doc.get(rotation), Object::Int(90), "shared source angle is unchanged"); + for saved in [write_incremental(&doc, &SaveOptions::default()).unwrap(), write_full(&doc, &SaveOptions::default()).unwrap()] { + let reopened = open(saved.clone()); + assert_eq!(page_rotation(&reopened, 0).unwrap(), 270); + assert_eq!(page_rotation(&reopened, 1).unwrap(), if indices.len() == 3 { 180 } else { 90 }); + assert_eq!(page_rotation(&reopened, 2).unwrap(), 90); + assert_eq!(labels(&reopened), original_labels); + let independent = hayro_syntax::Pdf::new(saved).unwrap(); + assert_eq!(independent.pages()[0].render_dimensions(), (400.0, 300.0)); + } + } + } +} + +#[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 f7200efb9..0b727449c 100644 --- a/crates/platform/src/staging.rs +++ b/crates/platform/src/staging.rs @@ -27,6 +27,20 @@ pub fn create_staging( name: &str, form: StagingName, suffixes: impl IntoIterator, +) -> std::io::Result<(PathBuf, std::fs::File)> { + create_staging_as(dir, name, form, suffixes, None) +} + +/// [`create_staging`], giving the new file the permissions `keep` on Unix. It is created readable +/// and writable by its owner only and then given `keep`, so its mode is never more open than that +/// of the file it will replace, not even while it is still empty (a handle opened then would keep +/// reading what is written later). Only the mode is copied: the owner and group are the saver's. +fn create_staging_as( + dir: &Path, + name: &str, + form: StagingName, + suffixes: impl IntoIterator, + keep: Option<&std::fs::Permissions>, ) -> std::io::Result<(PathBuf, std::fs::File)> { // At most 128 bytes of the target's name, cut between characters, so the staging name fits // the 255-byte (Linux, macOS) and 255-unit (Windows) limits however long that name is. @@ -42,8 +56,22 @@ pub fn create_staging( StagingName::SuffixThenTag => format!(".{stem}.{suffix:016x}.pdfcraft-tmp"), StagingName::TagThenSuffix => format!(".{stem}.pdfcraft-{suffix:016x}.tmp"), }); - match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) { - Ok(file) => return Ok((tmp, file)), + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + if keep.is_some() { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + match options.open(&tmp) { + Ok(file) => { + if let Some(keep) = keep { + // A volume that can't change modes (FAT, some network shares) leaves the file + // owner-only: stricter than the file it replaces, never looser. + let _ = file.set_permissions(keep.clone()); + } + return Ok((tmp, file)); + } Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} // Windows reports a folder at the name as "access denied"; it is taken all the same. Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied && tmp.symlink_metadata().is_ok() => {} @@ -69,14 +97,19 @@ pub fn staging_suffixes() -> impl Iterator { /// crash, a full disk or a refused flush never leaves a half-written file where a good one was, /// and the target is untouched until the new file is complete. The target's folder must exist; /// a folder at the target is refused by the rename. Flushing is the staging file's own -/// (`sync_all`); parent-directory durability after a power loss is not promised. +/// (`sync_all`); parent-directory durability after a power loss is not promised. On Unix a file +/// 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) } /// [`write_atomically`] with the staging-name suffixes and the flush given, so tests can plant /// names and refuse flushes. A failure at any step removes the staging file and leaves the -/// target as it was. +/// target as it was. A read-only target is refused before anything is written. pub fn write_atomically_with( path: &Path, bytes: &[u8], @@ -85,14 +118,34 @@ pub fn write_atomically_with( sync_all: impl FnOnce(&std::fs::File) -> std::io::Result<()>, ) -> std::io::Result<()> { use std::io::Write as _; + // A read-only file is not replaced. Windows refuses the rename anyway, but on Unix a rename + // ignores the target's permissions: the file would be overwritten without a word, and come + // out writable (#2658). + if std::fs::metadata(path).is_ok_and(|m| m.is_file() && m.permissions().readonly()) { + return Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "the file is read-only; save a copy with Save As")); + } let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new(".")); let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("save"); - let (tmp, file) = create_staging(dir, name, form, suffixes)?; + // A file that is replaced keeps its permissions (#1790). Unix modes only: Windows is as before. + #[cfg(unix)] + let keep = std::fs::metadata(path).ok().map(|m| m.permissions()); + #[cfg(not(unix))] + let keep = None; + // A saved file is the same document: it keeps its creation date (#2658). + #[cfg(any(windows, target_os = "macos"))] + let created = std::fs::metadata(path).ok().filter(|m| m.is_file()).and_then(|m| m.created().ok()); + let (tmp, file) = create_staging_as(dir, name, form, suffixes, keep.as_ref())?; let result = (|| { // Closed at the end of the block, before the rename (required on Windows). { 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)?; } std::fs::rename(&tmp, path) @@ -103,6 +156,87 @@ 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. +#[cfg(any(windows, target_os = "macos"))] +fn keep_created(file: &std::fs::File, created: Option) { + if let Some(created) = created { + #[cfg(target_os = "macos")] + use std::os::macos::fs::FileTimesExt as _; + #[cfg(windows)] + use std::os::windows::fs::FileTimesExt as _; + let _ = file.set_times(std::fs::FileTimes::new().set_created(created)); + } +} + /// Flush the staging file to the disk before it replaces the target. On macOS `sync_all` asks /// for a full flush (`F_FULLFSYNC`), which SMB and FUSE volumes, such as a mounted Cryptomator /// vault, refuse as unsupported: there a plain `fsync` is the strongest flush the volume offers. @@ -162,6 +296,33 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// #2658: a read-only file is refused before anything is staged, on Unix (where a rename would + /// replace it) as on Windows (where the rename fails). + #[test] + fn a_read_only_target_is_refused_and_kept() { + let dir = test_dir("readonly"); + let target = dir.join("out.pdf"); + std::fs::write(&target, "OLD").unwrap(); + let mut perms = std::fs::metadata(&target).unwrap().permissions(); + perms.set_readonly(true); + std::fs::set_permissions(&target, perms.clone()).unwrap(); + let staged = std::cell::Cell::new(false); + let e = write_atomically_with(&target, b"NEW", StagingName::TagThenSuffix, [7], |_: &std::fs::File| { + staged.set(true); + Ok(()) + }) + .unwrap_err(); + assert_eq!(e.kind(), std::io::ErrorKind::PermissionDenied, "{e}"); + assert!(!staged.get(), "nothing was staged"); + assert_eq!(std::fs::read(&target).unwrap(), b"OLD"); + assert!(std::fs::metadata(&target).unwrap().permissions().readonly(), "still read-only"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "no staging file is left"); + #[allow(clippy::permissions_set_readonly_false)] + perms.set_readonly(false); + std::fs::set_permissions(&target, perms).unwrap(); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn running_out_of_suffixes_refuses_and_creates_nothing() { let dir = test_dir("empty"); @@ -215,4 +376,238 @@ mod tests { assert_eq!(names(&dir), ["out.pdf"]); 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)] + #[test] + fn a_replaced_file_keeps_its_permissions() { + use std::os::unix::fs::PermissionsExt as _; + let dir = test_dir("modes"); + let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777; + let target = dir.join("out.pdf"); + for keep in [0o600, 0o640, 0o664] { + std::fs::write(&target, b"OLD").unwrap(); + std::fs::set_permissions(&target, std::fs::Permissions::from_mode(keep)).unwrap(); + let mut staged = 0; + write_atomically_with(&target, b"NEW", StagingName::SuffixThenTag, [keep as u64], |f| { + staged = f.metadata()?.permissions().mode() & 0o777; + Ok(()) + }) + .unwrap(); + assert_eq!(staged, keep, "the staging file has the target's mode before it is flushed"); + assert_eq!(mode(&target), keep, "the saved file keeps mode {keep:o}"); + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + } + let fresh = dir.join("new.pdf"); + write_atomically(&fresh, b"NEW", StagingName::TagThenSuffix).unwrap(); + let plain = dir.join("plain"); + std::fs::write(&plain, b"").unwrap(); + assert_eq!(mode(&fresh), mode(&plain), "a new file is not made private"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// #2658: a save replaces the file, but the document is the same one: it keeps its creation + /// date where the system lets a program set it. + #[cfg(any(windows, target_os = "macos"))] + #[test] + fn a_replaced_file_keeps_its_creation_date() { + #[cfg(target_os = "macos")] + use std::os::macos::fs::FileTimesExt as _; + #[cfg(windows)] + use std::os::windows::fs::FileTimesExt as _; + let dir = test_dir("created"); + let target = dir.join("doc.pdf"); + std::fs::write(&target, b"OLD").unwrap(); + // 2020-01-02 03:04:05 UTC. + let then = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_577_934_245); + let file = std::fs::OpenOptions::new().write(true).open(&target).unwrap(); + file.set_times(std::fs::FileTimes::new().set_created(then)).unwrap(); + drop(file); + assert_eq!(std::fs::metadata(&target).unwrap().created().unwrap(), then, "the fixture's date is set"); + write_atomically(&target, b"NEW", StagingName::SuffixThenTag).unwrap(); + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + assert_eq!(std::fs::metadata(&target).unwrap().created().unwrap(), then, "the saved file keeps its creation date"); + // A new file gets a date of its own. + let fresh = dir.join("new.pdf"); + write_atomically(&fresh, b"NEW", StagingName::SuffixThenTag).unwrap(); + assert!(std::fs::metadata(&fresh).unwrap().created().unwrap() > then); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/preflight/Cargo.toml b/crates/preflight/Cargo.toml index b79575fc0..082e44fc5 100644 --- a/crates/preflight/Cargo.toml +++ b/crates/preflight/Cargo.toml @@ -14,3 +14,7 @@ workspace = true pdfcraft-content = { workspace = true } pdfcraft-cos = { workspace = true } pdfcraft-model = { workspace = true } +roxmltree = "0.20" + +[dev-dependencies] +flate2 = { workspace = true } diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index a4d90d874..2cda29fea 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -165,7 +165,7 @@ fn metadata(doc: &Document) -> Option { let cat = catalog(doc); let m = doc.resolve(cat.get(b"Metadata")?); match &*m { - Object::Stream(s) => s.decoded().ok().map(|b| String::from_utf8_lossy(&b).into_owned()), + Object::Stream(s) => s.decoded_within(xmp::MAX_XMP_BYTES).ok().map(|b| String::from_utf8_lossy(&b).into_owned()), _ => None, } } @@ -174,10 +174,10 @@ fn metadata(doc: &Document) -> Option { pub fn declared(doc: &Document) -> Declared { let mut d = Declared::default(); if let Some(x) = metadata(doc) { - let part = xmp::value(&x, "pdfaid:part").and_then(|p| p.parse().ok()); - let conf = xmp::value(&x, "pdfaid:conformance").unwrap_or_default(); + let part = xmp::identifier(&x, xmp::PDFA_ID, "part").and_then(|p| p.parse().ok()); + let conf = xmp::identifier(&x, xmp::PDFA_ID, "conformance").unwrap_or_default(); d.pdfa = part.map(|p| (p, conf.to_uppercase())); - d.pdfua = xmp::value(&x, "pdfuaid:part").and_then(|p| p.parse().ok()); + d.pdfua = xmp::identifier(&x, xmp::PDFUA_ID, "part").and_then(|p| p.parse().ok()); } let cat = catalog(doc); if let Some(list) = cat.get(b"OutputIntents").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()) { @@ -423,8 +423,8 @@ pub fn verify(doc: &Document, level: Level) -> Vec { match metadata(doc) { None => issue("6.6.2.1", "The document has no XMP metadata", None, None, true), Some(x) => { - let part = xmp::value(&x, "pdfaid:part"); - let conf = xmp::value(&x, "pdfaid:conformance"); + let part = xmp::identifier(&x, xmp::PDFA_ID, "part"); + let conf = xmp::identifier(&x, xmp::PDFA_ID, "conformance"); if part.as_deref() != Some(&level.part().to_string()) || conf.as_deref().map(str::to_uppercase).as_deref() != Some("B") { issue("6.6.4", "The metadata doesn't identify the file as {}", Some(level.label().to_owned()), None, true); } diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 25d06669e..80b1c63c4 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -371,3 +371,49 @@ fn device_colour_in_forms_patterns_and_glyphs_is_found() { assert!(declared(&doc).output_intents.is_empty(), "{what}: an sRGB output intent was added to a CMYK document: {:?}", r.fixed); } } + +#[test] +fn standards_identification_uses_namespace_not_prefix() { + fn with_xmp(xmp: &str) -> Document { + let stream = format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len()); + build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], "") + } + let attribute = r#""#; + let element = r#"2B"#; + for xml in [attribute, element] { + let doc = with_xmp(xml); + assert_eq!(declared(&doc).pdfa, Some((2, "B".into()))); + assert!(!verify(&doc, Level::A2b).iter().any(|i| i.clause == "6.6.4")); + } + assert_eq!(declared(&with_xmp(attribute)).pdfua, Some(1)); + let wrong = r#""#; + let doc = with_xmp(wrong); + assert_eq!(declared(&doc).pdfa, None); + assert!(verify(&doc, Level::A2b).iter().any(|i| i.clause == "6.6.4")); + let nested = format!("{}{}", "".repeat(1100), "".repeat(1100)); + assert_eq!(declared(&with_xmp(&nested)).pdfa, None); +} + +#[test] +fn oversized_xmp_is_refused_while_decoding() { + let xmp = " ".repeat(xmp::MAX_XMP_BYTES + 1); + let stream = format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len()); + let doc = build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], ""); + assert!(metadata(&doc).is_none()); + assert_eq!(declared(&doc).pdfa, None); + + use std::io::Write as _; + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + z.write_all(xmp.as_bytes()).unwrap(); + let compressed = z.finish().unwrap(); + let hex: String = compressed.iter().map(|byte| format!("{byte:02X}")).collect(); + let data = format!("{hex}>"); + let stream = + format!("<< /Type /Metadata /Subtype /XML /Filter [/ASCIIHexDecode /FlateDecode] /Length {} >>\nstream\n{data}\nendstream", data.len()); + let doc = build(&["<< /Type /Catalog /Pages 2 0 R /Metadata 3 0 R >>", "<< /Type /Pages /Kids [] /Count 0 >>", &stream], ""); + let cat = catalog(&doc); + let raw = doc.resolve(cat.get(b"Metadata").unwrap()); + let Object::Stream(stream) = &*raw else { panic!("fixture has no metadata stream") }; + assert_eq!(stream.decoded().unwrap().len(), xmp::MAX_XMP_BYTES + 1, "fixture must actually decompress"); + assert!(metadata(&doc).is_none(), "compressed metadata must be limited during decoding"); +} diff --git a/crates/preflight/src/xmp.rs b/crates/preflight/src/xmp.rs index 3057213d1..0da4869cc 100644 --- a/crates/preflight/src/xmp.rs +++ b/crates/preflight/src/xmp.rs @@ -3,6 +3,116 @@ use pdfcraft_cos::Document; +/// The largest XMP packet read; a larger one is treated as missing. +pub(crate) const MAX_XMP_BYTES: usize = 8 * 1024 * 1024; +/// Namespace URIs of the PDF/A and PDF/UA identification schemas. +pub(crate) const PDFA_ID: &str = "http://www.aiim.org/pdfa/ns/id/"; +pub(crate) const PDFUA_ID: &str = "http://www.aiim.org/pdfua/ns/id/"; + +/// Read an identification property by its expanded XML name, not its arbitrary prefix. A packet +/// that is not well-formed XML (an undeclared prefix, an illegal character) or is too deep or wide +/// to parse safely is read by the conventional prefix (`pdfaid:`, `pdfuaid:`) instead, as before. +pub(crate) fn identifier(xmp: &str, namespace: &str, local: &str) -> Option { + if xmp.len() > MAX_XMP_BYTES { + return None; + } + match parsed_identifier(xmp, namespace, local) { + Ok(found) => found, + Err(()) => { + let prefix = match namespace { + PDFA_ID => "pdfaid", + PDFUA_ID => "pdfuaid", + _ => return None, + }; + value(xmp, &format!("{prefix}:{local}")) + } + } +} + +/// [`identifier`] through an XML parser; `Err` when the packet can't be parsed within the limits. +fn parsed_identifier(xmp: &str, namespace: &str, local: &str) -> Result, ()> { + if !xml_limits_ok(xmp) { + return Err(()); + } + let options = roxmltree::ParsingOptions { allow_dtd: false, nodes_limit: 100_000 }; + let doc = roxmltree::Document::parse_with_options(xmp, options).map_err(|_| ())?; + for node in doc.descendants().filter(roxmltree::Node::is_element) { + if let Some(value) = node.attribute((namespace, local)) { + return Ok(Some(value.trim().to_owned())); + } + if node.tag_name().namespace() == Some(namespace) && node.tag_name().name() == local { + return Ok(node.text().map(|value| value.trim().to_owned())); + } + } + Ok(None) +} + +/// Reject deep XML and start tags with too many attributes before roxmltree checks duplicates. +/// Each unquoted `=` in a valid start tag introduces an attribute, including namespace bindings. +/// Every `b[i]` and `hay[p..]` below is guarded by `i < b.len()` / `p < hay.len()`. +fn xml_limits_ok(text: &str) -> bool { + fn find(hay: &[u8], from: usize, needle: &[u8]) -> Option { + (from..hay.len()).find(|&p| hay[p..].starts_with(needle)) + } + let b = text.as_bytes(); + let (mut i, mut depth, mut total_attributes) = (0usize, 0usize, 0usize); + while i < b.len() { + if b[i] != b'<' { + i += 1; + } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 3); + } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 3); + } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 2); + } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 1); + } else if b[i..].starts_with(b"").map_or(b.len(), |p| p + 1); + } else { + depth += 1; + if depth > 1024 { + return false; + } + // Skip the rest of the start tag, over any quoted attribute value, and remember its last + // significant byte: a tag that ends in `/` closes itself, so it is one level deep while it + // is read rather than a level left open. + i += 1; + let mut quote = 0u8; + let mut last = 0u8; + let mut attributes = 0usize; + while i < b.len() { + let c = b[i]; + if quote != 0 { + if c == quote { + quote = 0; + } + } else if c == b'"' || c == b'\'' { + quote = c; + } else if c == b'>' { + i += 1; + break; + } else if c == b'=' { + attributes += 1; + total_attributes += 1; + if attributes > 128 || total_attributes > 4096 { + return false; + } + last = c; + } else if !c.is_ascii_whitespace() { + last = c; + } + i += 1; + } + if last == b'/' { + depth -= 1; + } + } + } + true +} + /// The value of a simple XMP property, written either as an attribute (`pdfaid:part="2"`) or /// as an element (`2`, or inside an `rdf:Alt`/`rdf:Seq`). pub fn value(xmp: &str, key: &str) -> Option { @@ -133,3 +243,28 @@ mod tests { assert_eq!(iso_date("junk"), None); } } + +#[cfg(test)] +mod limit_tests { + use super::*; + + #[test] + fn attribute_and_namespace_budgets_bound_parser_work() { + let prefix = r#"(); + let at_limit = format!("{prefix}{}/>", attrs(126)); + assert_eq!(identifier(&at_limit, PDFA_ID, "part").as_deref(), Some("2")); + let over_limit = format!("{prefix}{}/>", attrs(127)); + assert_eq!(identifier(&over_limit, PDFA_ID, "part"), None); + let namespaces = (0..129).map(|i| format!("xmlns:n{i}=\"urn:{i}\" ")).collect::(); + assert_eq!(identifier(&format!(""), PDFA_ID, "part"), None); + let many_elements = format!("{}", r#""#.repeat(4097)); + assert!(!xml_limits_ok(&many_elements)); + // Not well-formed (the prefix is never declared): read by the conventional prefix, as before. + let undeclared = r#""#; + assert_eq!(identifier(undeclared, PDFA_ID, "part").as_deref(), Some("3")); + assert_eq!(identifier(undeclared, PDFA_ID, "conformance").as_deref(), Some("u")); + let over_bytes = format!("{}", " ".repeat(8 * 1024 * 1024)); + assert_eq!(identifier(&over_bytes, PDFA_ID, "part"), None); + } +} 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 d4b0c8ee1..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,7 +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(); - println!("{}", std::env::var(ANSWER).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{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 { @@ -556,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())]); @@ -597,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(); @@ -644,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; @@ -743,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 3b2d54e81..9c2793e24 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -25,14 +25,16 @@ clear_marks(&mut doc, None)?; // remove marks without app - **vectors**: covered paths are removed; partly covered paths and shadings are clipped so nothing paints inside the areas; - **form XObjects**: rewritten recursively into new objects (pages sharing the original keep it); -- **annotations** whose rectangle overlaps (with their pop-ups), and **form fields** with a +- **annotations** whose rectangle overlaps (with their pop-ups and their replies, `/IRT`), and **form fields** with a widget under a mark; - the marks themselves, replaced by boxes in their fill colour (and overlay text) drawn into the page as a `/PCMark /Redaction` stream. 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,16 @@ 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 +and hidden-content cleanup, including aliases in shared/inherited resources. Descriptions still +used by surviving pages or forms are kept. A redacted page that inherits `/Resources` from the page tree gets them pinned (by reference; a direct inherited dictionary is made one shared object first) before it is rewritten. The parent's diff --git a/crates/redact/src/image.rs b/crates/redact/src/image.rs index 23ef2559a..9a19ba2f0 100644 --- a/crates/redact/src/image.rs +++ b/crates/redact/src/image.rs @@ -1,5 +1,5 @@ //! Clearing the pixels of an image under redaction regions. The image is decoded, every pixel -//! whose centre maps into a region is set to zero (no ink for image masks), and the result is +//! whose footprint intersects a region is set to zero (no ink for image masks), and the result is //! written as a new Flate image; the original object is untouched (other pages may use it). //! Images whose codec PdfCraft can't decode (DCT, JPX, JBIG2, CCITT) return `None`, and the //! caller removes the whole image instead; that is fail-closed. @@ -33,7 +33,7 @@ fn components(doc: &Document, s: &Stream) -> Option { }) } -/// `Ok(Some(copy))` with the covered pixels cleared, `Ok(None)` when no pixel centre is covered, +/// `Ok(Some(copy))` with the covered pixels cleared, `Ok(None)` when no pixel intersects, /// `Err(())` when the image can't be cleared (the caller removes it). #[allow(clippy::result_unit_err)] pub(crate) fn clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]) -> Result, ()> { @@ -91,18 +91,20 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]] }; let mut cleared = 0usize; for j in 0..h { - // Image space: (0,0) is the bottom-left of the unit square; row 0 is the top. - let v = 1.0 - (j as f64 + 0.5) / h as f64; - let (ax, ay) = ctm.apply(0.0, v); - let (bx, by) = ctm.apply(1.0, v); - let row_box = [ax.min(bx), ay.min(by), ax.max(bx), ay.max(by)]; - if !rects.iter().any(|r| overlaps(*r, [row_box[0], row_box[1] - 0.01, row_box[2], row_box[3] + 0.01], 0.0)) { + // Image space has its origin at the bottom left; row 0 is at the top. + let v0 = 1.0 - (j + 1) as f64 / h as f64; + let v1 = 1.0 - j as f64 / h as f64; + let row_box = ctm.bbox([0.0, v0, 1.0, v1]); + if !rects.iter().any(|r| overlaps(*r, row_box, 0.0)) { continue; } for i in 0..w { - let u = (i as f64 + 0.5) / w as f64; - let (x, y) = ctm.apply(u, v); - if !rects.iter().any(|r| x >= r[0] && x <= r[2] && y >= r[1] && y <= r[3]) { + let u0 = i as f64 / w as f64; + let u1 = (i + 1) as f64 / w as f64; + // The bounding box may cover extra pixels for a rotated or skewed image; that + // conservative over-clear prevents a touched sample surviving in the file. + let pixel_box = ctm.bbox([u0, v0, u1, v1]); + if !rects.iter().any(|r| overlaps(*r, pixel_box, 0.0)) { continue; } cleared += 1; diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index c76e11995..c9a404b93 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -21,7 +21,7 @@ use std::collections::{HashMap, HashSet}; use std::rc::Rc; use pdfcraft_content::{Matrix, Op, Pieces, contains, num, overlaps, serialize_ops, string}; -use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; +use pdfcraft_cos::{CosError, Dict, Document, ObjRef, Object, Stream}; use crate::{RedactError, Report, image, page_streams, tags}; use pdfcraft_fonts::pdf::Metrics; @@ -66,12 +66,16 @@ pub(crate) struct Output { /// dictionaries may name them too (an inherited or shared /Resources, or the caller's when a /// form has none of its own): [`unlink_doomed`] removes those entries. pub doomed: Vec, + /// Original property values retired by this scope or a nested form. Other resource holders + /// may still name them; unlink only entries no surviving content uses. + pub retired_properties: Vec, /// Glyphs / inline images still overlapping a region (verify mode). pub residue: usize, /// Apply mode met vertical-writing text (which it cannot remove) while a redaction region was /// in force: the operation must fail closed rather than leave that text under the mark. pub vertical: bool, pub tag_changes: Vec<(ObjRef, ObjRef, HashSet, HashSet)>, + pub error: Option, } pub(crate) struct Scope<'a> { @@ -80,9 +84,19 @@ pub(crate) struct Scope<'a> { pub report: &'a mut Report, /// Sanitize: remove hidden text (invisible render modes, or wholly outside this page box). pub hidden_text: Option<[f64; 4]>, - /// Sanitize: optional content groups (and membership dictionaries) that are off; content - /// marked with them is removed. - pub hidden_layers: Vec, + /// Sanitize: optional content groups that are off; content marked with them is removed. + pub hidden_layers: HashSet, + /// Whether this pass is evaluating optional-content rules. An empty off set is meaningful: + /// policies such as `/AllOff` can still hide content when every group is on. + pub layers_enabled: bool, + /// Off groups referenced by optional content that remains visible and therefore still needs + /// its original default state after sanitization. + pub retained_off_layers: HashSet, + /// Cache membership results so repeated blocks cannot repeatedly walk hostile expressions. + ocmd_cache: HashMap, + /// Direct `/OC` dictionaries on XObjects have no object reference of their own. Cache them + /// by the containing XObject so repeated `Do` calls share the same bounded evaluation. + xobject_oc_cache: HashMap, /// Content removed from hidden layers (blocks and XObjects). pub layer_blocks: usize, fonts: HashMap, Rc>, @@ -97,7 +111,11 @@ impl<'a> Scope<'a> { mode, report, hidden_text: None, - hidden_layers: Vec::new(), + hidden_layers: HashSet::new(), + layers_enabled: false, + retained_off_layers: HashSet::new(), + ocmd_cache: HashMap::new(), + xobject_oc_cache: HashMap::new(), layer_blocks: 0, fonts: HashMap::new(), used_names: Vec::new(), @@ -107,20 +125,26 @@ impl<'a> Scope<'a> { /// Sanitizing visits every form XObject (not only those under a region). fn everywhere(&self) -> bool { - self.hidden_text.is_some() || !self.hidden_layers.is_empty() + self.hidden_text.is_some() || self.layers_enabled } /// Is this optional-content reference (an OCG or OCMD) hidden? - fn layer_hidden(&self, doc: &Document, o: &Object) -> bool { - if self.hidden_layers.is_empty() { + fn layer_hidden(&mut self, doc: &Document, o: &Object) -> bool { + if !self.layers_enabled { return false; } - if let Some(r) = o.as_ref() - && self.hidden_layers.contains(&r) - { - return true; + let reference = o.as_ref(); + if let Some(r) = reference { + if let Some(hidden) = self.ocmd_cache.get(&r) { + return *hidden; + } + if self.hidden_layers.contains(&r) { + self.ocmd_cache.insert(r, true); + return true; + } } - // A membership dictionary (default policy AnyOn): hidden when all its groups are. + // Evaluate the policy against the same default configuration used to select hidden + // groups. A membership dictionary's default policy is AnyOn. let d = doc.resolve(o); let Some(d) = d.as_dict() else { return false }; if d.name(b"Type") != Some(b"OCMD") { @@ -131,7 +155,188 @@ impl<'a> Scope<'a> { Some(_) => d.get(b"OCGs").and_then(Object::as_ref).into_iter().collect(), None => Vec::new(), }; - !groups.is_empty() && groups.iter().all(|g| self.hidden_layers.contains(g)) + let fail_closed = || groups.iter().any(|g| self.hidden_layers.contains(g)); + let hidden = if let Some(expression) = d.get(b"VE") { + let visible = self.visibility_expression(doc, expression, 0, &mut HashSet::new(), &mut 4096); + match visible { + Some(visible) => !visible, + None => fail_closed() || self.expression_mentions_hidden(doc, expression, 0, &mut HashSet::new(), &mut 4096), + } + } else if groups.is_empty() { + false + } else { + let any_on = groups.iter().any(|g| !self.hidden_layers.contains(g)); + let all_on = groups.iter().all(|g| !self.hidden_layers.contains(g)); + let policy = d.get(b"P").map(|p| doc.resolve(p)).and_then(|p| p.as_name().map(|name| name.to_vec())); + match (d.get(b"P"), policy.as_deref()) { + (None, _) | (Some(_), Some(b"AnyOn")) => !any_on, + (Some(_), Some(b"AllOn")) => !all_on, + (Some(_), Some(b"AnyOff")) => all_on, + (Some(_), Some(b"AllOff")) => any_on, + // An invalid policy must not make a candidate hidden block survive sanitization. + _ => fail_closed(), + } + }; + if !hidden { + self.retained_off_layers.extend(groups.iter().filter(|g| self.hidden_layers.contains(g)).copied()); + if let Some(expression) = d.get(b"VE") { + let mut referenced = HashSet::new(); + if Self::collect_hidden_expression_groups(doc, expression, &self.hidden_layers, 0, &mut HashSet::new(), &mut 4096, &mut referenced) { + self.retained_off_layers.extend(referenced); + } else { + // If an expression cannot be fully walked, preserve every off group so that + // the content we kept does not change visibility after configuration cleanup. + self.retained_off_layers.extend(self.hidden_layers.iter().copied()); + } + } + } + if let Some(r) = reference { + self.ocmd_cache.insert(r, hidden); + } + hidden + } + + fn xobject_layer_hidden(&mut self, doc: &Document, xobject: ObjRef, oc: &Object) -> bool { + if oc.as_ref().is_some() { + return self.layer_hidden(doc, oc); + } + if let Some(hidden) = self.xobject_oc_cache.get(&xobject) { + return *hidden; + } + let hidden = self.layer_hidden(doc, oc); + self.xobject_oc_cache.insert(xobject, hidden); + hidden + } + + fn collect_hidden_expression_groups( + doc: &Document, + expression: &Object, + hidden_layers: &HashSet, + depth: usize, + path: &mut HashSet, + budget: &mut usize, + found: &mut HashSet, + ) -> bool { + if depth > 64 || *budget == 0 { + return false; + } + *budget -= 1; + let reference = expression.as_ref(); + if let Some(r) = reference + && !path.insert(r) + { + return false; + } + let resolved = doc.resolve(expression); + let complete = match &*resolved { + Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => { + if let Some(r) = reference + && hidden_layers.contains(&r) + { + found.insert(r); + } + reference.is_some() + } + Object::Array(items) => { + let Some(Object::Name(operator)) = items.first() else { return false }; + let operands = match operator.as_slice() { + b"And" | b"Or" if (2..=4097).contains(&items.len()) => items.get(1..), + b"Not" if items.len() == 2 => items.get(1..), + _ => None, + }; + operands.is_some_and(|operands| { + operands.iter().all(|item| Self::collect_hidden_expression_groups(doc, item, hidden_layers, depth + 1, path, budget, found)) + }) + } + _ => false, + }; + if let Some(r) = reference { + path.remove(&r); + } + complete + } + + /// Evaluate an optional-content visibility expression (`/VE`) against the default config. + /// Malformed, cyclic, or excessive expressions return `None` so the caller can fail closed. + fn visibility_expression( + &self, + doc: &Document, + expression: &Object, + depth: usize, + path: &mut HashSet, + budget: &mut usize, + ) -> Option { + if depth > 64 || *budget == 0 { + return None; + } + *budget -= 1; + let reference = expression.as_ref(); + if let Some(r) = reference + && !path.insert(r) + { + return None; + } + let resolved = doc.resolve(expression); + let result = match &*resolved { + Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => reference.map(|r| !self.hidden_layers.contains(&r)), + Object::Array(items) if (2..=4097).contains(&items.len()) => { + let operator = match items.first() { + Some(Object::Name(n)) => n.as_slice(), + _ => return None, + }; + match operator { + b"And" if items.len() >= 2 => { + let mut visible = true; + for item in items.iter().skip(1) { + visible &= self.visibility_expression(doc, item, depth + 1, path, budget)?; + } + Some(visible) + } + b"Or" if items.len() >= 2 => { + let mut visible = false; + for item in items.iter().skip(1) { + visible |= self.visibility_expression(doc, item, depth + 1, path, budget)?; + } + Some(visible) + } + b"Not" if items.len() == 2 => Some(!self.visibility_expression(doc, &items[1], depth + 1, path, budget)?), + _ => None, + } + } + _ => None, + }; + if let Some(r) = reference { + path.remove(&r); + } + result + } + + /// Conservatively find hidden groups in an expression that could not be evaluated. Unknown + /// syntax, cycles, and exhausted budgets count as a possible hidden reference. + fn expression_mentions_hidden(&self, doc: &Document, expression: &Object, depth: usize, path: &mut HashSet, budget: &mut usize) -> bool { + if depth > 64 || *budget == 0 { + return true; + } + *budget -= 1; + if let Some(r) = expression.as_ref() { + if self.hidden_layers.contains(&r) { + return true; + } + if !path.insert(r) { + return true; + } + } + let resolved = doc.resolve(expression); + let found = match &*resolved { + Object::Array(items) => items.iter().any(|item| self.expression_mentions_hidden(doc, item, depth + 1, path, budget)), + Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => expression.as_ref().is_none_or(|r| self.hidden_layers.contains(&r)), + Object::Dict(_) => true, + _ => false, + }; + if let Some(r) = expression.as_ref() { + path.remove(&r); + } + found } fn hits(&self, b: [f64; 4]) -> bool { @@ -246,9 +451,10 @@ fn content_draws(doc: &Document, content: &[Vec], xo: &Dict, name: &[u8], de /// An entry goes unless a page that draws through that dictionary still draws the name (the same /// image shown on another page, or again outside a mark). A dictionary that loses an entry is /// replaced by a private copy on its node, so a dictionary object shared with other nodes is -/// left as it is for them. Fails closed when a page's use of a name cannot be told. -pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef]) -> Result<(), RedactError> { - if doomed.is_empty() { +/// left as it is for them. Retired properties are likewise unlinked only when none of the +/// holder's pages or inheriting forms names them. Fails closed when use cannot be determined. +pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef], retired_properties: &[Object]) -> Result<(), RedactError> { + if doomed.is_empty() && retired_properties.is_empty() { return Ok(()); } let doomed: HashSet = doomed.iter().copied().collect(); @@ -278,11 +484,16 @@ pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef]) -> Result<(), } } let index: HashMap = pdfcraft_model::pages(doc).iter().enumerate().map(|(i, p)| (p.obj, i)).collect(); + let holder_nodes: Vec = holders.iter().map(|(node, _)| *node).collect(); for (node, users) in holders { - let Some(res) = doc.get(node).as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| doc.resolve(r).as_dict().cloned()) else { continue }; + let Some(mut res) = doc.get(node).as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| doc.resolve(r).as_dict().cloned()) else { + continue; + }; let mut xo = res_dict(doc, &res, b"XObject"); let names: Vec> = xo.iter().filter(|(_, v)| v.as_ref().is_some_and(|r| doomed.contains(&r))).map(|(n, _)| n.to_vec()).collect(); - if names.is_empty() { + let properties = res_dict(doc, &res, b"Properties"); + let property_names: Vec> = properties.iter().filter(|(_, v)| retired_properties.contains(v)).map(|(n, _)| n.to_vec()).collect(); + if names.is_empty() && property_names.is_empty() { continue; } let mut contents = Vec::with_capacity(users.len()); @@ -310,11 +521,84 @@ pub(crate) fn unlink_doomed(doc: &mut Document, doomed: &[ObjRef]) -> Result<(), } } if pruned { - let mut res = res; res.set(b"XObject".to_vec(), Object::Dict(xo)); + } + pruned |= prune_retired_properties(doc, &mut res, &property_names, &contents)?; + if pruned { doc.update_dict(node, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?; } } + // A surviving form can own the same old resource dictionary as a rewritten page. Its + // unused descriptions remain reachable too, even after every page holder is cleaned. + unlink_form_properties(doc, &holder_nodes, retired_properties)?; + Ok(()) +} + +/// Retire only properties removed from a rewritten scope, preserving names still used by any +/// content sharing this holder. Different pages' streams must never be concatenated together. +fn prune_retired_properties(doc: &Document, res: &mut Dict, names: &[Vec], contents: &[Vec>]) -> Result { + if names.is_empty() { + return Ok(false); + } + let mut used = HashSet::new(); + for content in contents { + for op in resource_ops(doc, res, content)? { + if matches!(op.op.as_slice(), b"BDC" | b"DP") + && let Some(name) = op.name(1) + { + used.insert(name.to_vec()); + } + } + } + let mut properties = res_dict(doc, res, b"Properties"); + let mut pruned = false; + for name in names { + if !used.contains(name) { + pruned |= properties.remove(name).is_some(); + } + } + if pruned { + if properties.is_empty() { + res.remove(b"Properties"); + } else { + res.set(b"Properties".to_vec(), Object::Dict(properties)); + } + } + Ok(pruned) +} + +fn unlink_form_properties(doc: &mut Document, holders: &[ObjRef], retired: &[Object]) -> Result<(), CosError> { + if retired.is_empty() { + return Ok(()); + } + let mut pending: Vec = holders + .iter() + .filter_map(|r| doc.get(*r).as_dict().and_then(|d| d.get(b"Resources")).and_then(|r| doc.resolve(r).as_dict().cloned())) + .collect(); + let mut seen = HashSet::new(); + while let Some(resources) = pending.pop() { + for (_, value) in res_dict(doc, &resources, b"XObject").iter() { + let Some(reference) = value.as_ref() else { continue }; + if !seen.insert(reference) { + continue; + } + if seen.len() > 1_000_000 { + return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect shared form resources".into() }); + } + let object = doc.get(reference); + let Object::Stream(form) = &*object else { continue }; + if form.dict.name(b"Subtype") != Some(b"Form") { + continue; + } + let Some(mut res) = form.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()) else { continue }; + let names: Vec> = + res_dict(doc, &res, b"Properties").iter().filter(|(_, value)| retired.contains(value)).map(|(name, _)| name.to_vec()).collect(); + if !names.is_empty() && prune_retired_properties(doc, &mut res, &names, &[vec![form.decoded()?]])? { + doc.update_dict(reference, |d| d.set(b"Resources".to_vec(), Object::Dict(res.clone())))?; + } + pending.push(res); + } + } Ok(()) } @@ -341,6 +625,71 @@ fn clip_out(rects: &[[f64; 4]], ctm: &Matrix, around: [f64; 4]) -> Option]) -> Result, CosError> { + let parsed = Pieces::join(content).parse(); + if parsed.skipped > 0 || parsed.ops.len() > 2_000_000 { + return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect property resources".into() }); + } + let mut ops = parsed.ops; + let xobjects = res_dict(doc, res, b"XObject"); + let mut seen = HashSet::new(); + let mut cursor = 0; + while cursor < ops.len() { + let op = &ops[cursor]; + if op.is("Do") + && let Some(r) = op.name(0).and_then(|n| xobjects.get(n)).and_then(Object::as_ref) + && seen.insert(r) + && let Object::Stream(s) = &*doc.get(r) + && s.dict.name(b"Subtype") == Some(b"Form") + && s.dict.get(b"Resources").is_none_or(|r| doc.resolve(r).as_dict().is_none()) + { + let parsed = pdfcraft_content::parse(&s.decoded()?); + if seen.len() > 2_000_000 || ops.len().saturating_add(parsed.ops.len()) > 2_000_000 || parsed.skipped > 0 { + return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect inherited form resources".into() }); + } + ops.extend(parsed.ops); + } + cursor += 1; + } + Ok(ops) +} + +pub(crate) fn prune_properties(doc: &Document, res: &mut Dict, content: &[Vec]) -> Result, CosError> { + let mut properties = res_dict(doc, res, b"Properties"); + if properties.is_empty() { + return Ok(Vec::new()); + } + let mut retired = Vec::new(); + let ops = resource_ops(doc, res, content)?; + let names: Vec> = properties.iter().map(|(n, _)| n.to_vec()).collect(); + for name in names { + if !ops.iter().any(|op| matches!(op.op.as_slice(), b"BDC" | b"DP") && op.name(1) == Some(&name)) + && let Some(value) = properties.remove(&name) + { + retired.push(value); + } + } + if properties.is_empty() { + res.remove(b"Properties"); + } else { + res.set(b"Properties".to_vec(), Object::Dict(properties)); + } + Ok(retired) +} + +fn prune_form_resources(doc: &Document, res: &mut Dict, content: &[Vec]) -> Result<(), CosError> { + let ops = resource_ops(doc, res, content)?; + let mut xo = res_dict(doc, res, b"XObject"); + let names: Vec> = xo.iter().map(|(n, _)| n.to_vec()).collect(); + for name in names { + if !ops.iter().any(|op| op.is("Do") && op.name(0) == Some(&name)) { + xo.remove(&name); + } + } + res.set(b"XObject".to_vec(), Object::Dict(xo)); + Ok(()) +} + /// One glyph of a shown string. struct Glyph { bytes: std::ops::Range, @@ -380,7 +729,13 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec< // next. Parse them joined, then give each operator back to the piece its keyword is in. let joined = Pieces::join(streams); let mut parsed = joined.parse(); - if !scope.hidden_layers.is_empty() { + // A rewritten piece loses its comments, so keep where they began and ended: a comment that + // ran on into the next piece still hides that piece's first line. + let comment_edges = joined.comment_edges(&parsed); + // Layer removal also changes enclosing replacement spans, so remember the operators before + // any cleanup, not just the survivors of the layer pass. + let original = parsed.ops.clone(); + if scope.layers_enabled { let props = res_dict(doc, resources, b"Properties"); let (kept, removed) = strip_hidden_layers(doc, scope, parsed.ops, &props); parsed.ops = kept; @@ -405,6 +760,7 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec< } } + let mut processed = Vec::new(); for (piece, mut changed) in pieces.into_iter().zip(rewrite) { let mut ops: Vec = Vec::with_capacity(piece.len()); for op in piece { @@ -544,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 @@ -622,11 +981,68 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec< if !path.is_empty() { ops.append(&mut path); } - out.streams.push(changed.then(|| serialize_ops(&ops))); + processed.push((ops, changed)); + } + if scope.mode == Mode::Apply { + clean_replacements(doc, resources, &original, &mut processed, scope.report); + } + for ((ops, changed), (comment_in, comment_out)) in processed.into_iter().zip(comment_edges) { + out.streams.push(changed.then(|| { + // The piece before still ends inside a comment: end it before the first operator. + let mut bytes = if comment_in { b"\n".to_vec() } else { Vec::new() }; + bytes.extend(serialize_ops(&ops)); + // The piece ended inside a comment that hid the start of the next one: keep it open. + if comment_out { + bytes.push(b'%'); + } + bytes + })); } out } +fn clean_replacements(doc: &Document, resources: &Dict, original: &[Op], processed: &mut [(Vec, bool)], report: &mut Report) { + let originals: HashMap = original.iter().map(|op| (op.span.start, op)).collect(); + let unchanged: HashSet = + processed.iter().flat_map(|(ops, _)| ops).filter(|op| originals.get(&op.span.start) == Some(op)).map(|op| op.span.start).collect(); + let mut stack = Vec::new(); + let mut affected = HashSet::new(); + for op in original { + match op.op.as_slice() { + b"BDC" | b"BMC" => stack.push(op.span.start), + b"EMC" => { + stack.pop(); + } + _ if !unchanged.contains(&op.span.start) => affected.extend(stack.iter().copied()), + _ => {} + } + } + let properties = res_dict(doc, resources, b"Properties"); + for (ops, changed) in processed { + for op in ops { + if !op.is("BDC") || !affected.contains(&op.span.start) { + continue; + } + let property = match op.operands.get(1) { + Some(Object::Name(name)) => properties.get(name).map(|p| doc.resolve(p)), + Some(p) => Some(doc.resolve(p)), + None => None, + }; + if let Some(mut dict) = property.and_then(|p| p.as_dict().cloned()) { + let mut stripped = false; + for key in [&b"ActualText"[..], b"Alt", b"E", b"T"] { + stripped |= dict.remove(key).is_some(); + } + if stripped && let Some(property) = op.operands.get_mut(1) { + *property = Object::Dict(dict); + *changed = true; + report.replacement_text += 1; + } + } + } + } +} + /// Lay out a shown string (or `TJ` array) glyph by glyph, advancing `tm`. Returns the `TJ` /// items with the glyphs under a region replaced by displacements, and how many were removed. fn show(scope: &Scope<'_>, gs: &Gs, tm: &mut Matrix, items: &[Object]) -> (Vec, usize) { @@ -716,11 +1132,13 @@ fn xobject( let obj = doc.get(r); let Object::Stream(s) = &*obj else { return None }; match s.dict.name(b"Subtype") { - _ if s.dict.get(b"OC").is_some_and(|oc| scope.layer_hidden(doc, oc)) => { + _ if s.dict.get(b"OC").is_some_and(|oc| scope.xobject_layer_hidden(doc, r, oc)) => { + // Verify mode must count hidden XObjects too: scan uses that count to decide + // whether the HiddenLayers pass is necessary. + scope.layer_blocks += 1; if scope.mode == Mode::Verify { return None; } - scope.layer_blocks += 1; out.replaced.push(name); out.doomed.push(r); Some(None) @@ -771,6 +1189,11 @@ fn xobject( } if scope.depth >= MAX_DEPTH { if scope.mode == Mode::Verify { + // Sanitization removes a Form it cannot enter, so the verification scan + // must count that conservative removal or it may decide to skip the pass. + if scope.everywhere() { + scope.layer_blocks += 1; + } return None; } scope.report.forms_removed += 1; @@ -786,7 +1209,14 @@ fn xobject( out.doomed.push(r); return Some(None); } - Err(_) => return None, + Err(_) => { + // Apply drops an undecodable Form while sanitizing. Count it during Verify + // so a zero-count preflight cannot leave its potentially hidden bytes behind. + if scope.everywhere() { + scope.layer_blocks += 1; + } + return None; + } }; let own = s.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()); let res = own.clone().unwrap_or_else(|| resources.clone()); @@ -798,7 +1228,9 @@ fn xobject( out.residue += inner.residue; out.vertical |= inner.vertical; out.doomed.extend_from_slice(&inner.doomed); + out.retired_properties.extend(inner.retired_properties); out.tag_changes.extend(inner.tag_changes); + out.error = inner.error.or(out.error.take()); if scope.mode == Mode::Verify { return None; } @@ -808,19 +1240,35 @@ fn xobject( } let mut dict = s.dict.clone(); dict.remove(b"Length"); - // Rebuild the copy's /Resources whenever inner content gained or lost an XObject, so a - // `Do` the copy no longer makes stops keeping the original image or form reachable. + let mut cleaned_res = res.clone(); + // Install rewritten nested forms before finding live property names: a new form may + // still inherit its caller's resources, and its new Do name must resolve here. if !inner.xobjects.is_empty() || !inner.replaced.is_empty() { - let mut res = res.clone(); - let mut xo = res_dict(doc, &res, b"XObject"); - if let Some(d) = &new_data { - prune(&mut xo, &inner.replaced, std::slice::from_ref(d)); + let mut xo = res_dict(doc, &cleaned_res, b"XObject"); + if let Some(data) = &new_data { + prune(&mut xo, &inner.replaced, std::slice::from_ref(data)); } - for (n, r) in &inner.xobjects { - xo.set(n.clone(), Object::Ref(*r)); + for (name, reference) in &inner.xobjects { + xo.set(name.clone(), Object::Ref(*reference)); } - res.set(b"XObject".to_vec(), Object::Dict(xo)); - dict.set(b"Resources".to_vec(), Object::Dict(res)); + cleaned_res.set(b"XObject".to_vec(), Object::Dict(xo)); + } + if let Some(data) = &new_data { + let content = std::slice::from_ref(data); + match prune_properties(doc, &mut cleaned_res, content) { + Ok(retired) => out.retired_properties.extend(retired), + Err(error) => { + out.error = Some(error); + return None; + } + } + if let Err(error) = prune_form_resources(doc, &mut cleaned_res, content) { + out.error = Some(error); + return None; + } + } + if cleaned_res != res { + dict.set(b"Resources".to_vec(), Object::Dict(cleaned_res)); } let (changed, empty) = tags::touched(doc, &res, &data, new_data.as_deref().unwrap_or(&data)); let stream = match new_data { @@ -843,7 +1291,7 @@ fn xobject( /// Remove marked-content blocks of hidden layers (`/OC /name BDC … EMC`, nested blocks /// included). Returns the remaining operators and how many blocks went. -fn strip_hidden_layers(doc: &Document, scope: &Scope<'_>, ops: Vec, props: &Dict) -> (Vec, usize) { +fn strip_hidden_layers(doc: &Document, scope: &mut Scope<'_>, ops: Vec, props: &Dict) -> (Vec, usize) { let mut out = Vec::with_capacity(ops.len()); // For each open marked-content block: does it hide its contents? let mut stack: Vec = Vec::new(); diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 61231bd98..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. @@ -51,6 +57,13 @@ pub enum RedactError { "redaction appearance has invalid geometry or exceeds the 8 MiB budget; reduce the marked area, turn off repeated text or apply fewer pages at once (nothing was redacted)" )] OverlayLimit, + /// The structure tree couldn't be cleaned completely (#3302): an element written in place that + /// would need changing, an element whose inherited page can't be found, or a tree too large to + /// walk. Tags that kept the removed text or annotation would undo the redaction. + #[error( + "the document's tags can't be cleaned completely (an element written in place, or a structure tree too large or too deep to follow); the operation was refused" + )] + Tags, #[error(transparent)] Edit(#[from] pdfcraft_edit::EditError), #[error(transparent)] @@ -119,7 +132,9 @@ pub struct Report { pub forms_removed: usize, pub annotations: usize, pub fields: usize, - /// Structure elements that lost alternate/actual text or emptied marked content. + pub replacement_text: usize, + /// 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. @@ -145,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 }; @@ -182,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). @@ -304,8 +332,20 @@ fn localize_resources(doc: &mut Document, selected: &[usize]) -> Result<(), pdfc /// Apply every redaction mark (or only those on `pages`, 0-based). Irreversible for the saved /// file; callers keep the previous document for undo. +/// +/// # Errors +/// +/// This operation is not transactional: an error may leave `doc` partially modified, with +/// 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); @@ -334,8 +374,10 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result = Vec::new(); // The original image and form XObjects the rewrite drops or replaces, on any page. let mut doomed_xobjects: Vec = Vec::new(); + let mut retired_properties = Vec::new(); let widget_owner: Vec<(ObjRef, String)> = 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); @@ -353,10 +395,14 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result) -> Result) -> Result) -> Result = annots + .iter() + .filter_map(Object::as_ref) + .filter_map(|r| { + let obj = doc.get(r); + let d = obj.as_dict()?; + (d.name(b"Subtype") != Some(b"Widget")).then_some(())?; + Some((r, d.get(b"IRT").and_then(Object::as_ref)?)) + }) + .collect(); + loop { + let before = removed.len(); + for (r, parent) in &replies { + if !removed.contains(r) && removed.contains(parent) { + removed.push(*r); + report.annotations += 1; + } + } + if removed.len() == before { + break; + } + } let kept: Vec = annots .into_iter() .filter(|a| { @@ -467,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 @@ -487,7 +576,7 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result (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. @@ -140,14 +199,16 @@ fn address_char(c: char) -> bool { fn email(s: &[char], i: usize) -> Option { let local = |c: &char| address_char(*c) || "._%+-".contains(*c); - if !address_char(s[i]) || i.checked_sub(1).and_then(|p| s.get(p)).is_some_and(local) { + // A match starts at the first character of a run of local characters, which may be + // punctuation ("_admin@host.com"), but the run needs a letter or digit. + if !local(&s[i]) || i.checked_sub(1).and_then(|p| s.get(p)).is_some_and(local) { return None; } let mut j = i; while j < s.len() && local(&s[j]) { j += 1; } - if s.get(j) != Some(&'@') { + if s.get(j) != Some(&'@') || !s[i..j].iter().any(|c| address_char(*c)) { return None; } let d0 = j + 1; @@ -181,12 +242,33 @@ fn credit_card(s: &[char], i: usize) -> Option { if !s[i].is_ascii_digit() || digit(i.checked_sub(1).and_then(|p| s.get(p))) { return None; } - let (end, n) = digit_run(s, i, &[' ', '-'], 23); - if !(13..=19).contains(&n) || digit(s.get(end)) { + // Room for the longest card (19 digits in groups, 23 characters) and a tail of up to four digits. + let (end, n) = digit_run(s, i, &[' ', '-'], 29); + // A run longer than that is some other, longer number. + if end == i || run_continues(s, end - 1, 1, &[' ', '-']) { return None; } - let digits: Vec = s[i..end].iter().filter_map(|c| c.to_digit(10)).collect(); - luhn(&digits).then_some(end - i) + // The longest run may carry a trailing number (an expiry, a year, a CVV), so try it and then + // each shorter run ending at a group boundary, longest first. The dropped tail is at most four + // digits, so a longer number (an IBAN's digits) doesn't match on a prefix. + let mut count = n; + for e in (i + 1..=end).rev() { + if n - count > 4 { + break; + } + if !s.get(e - 1).is_some_and(char::is_ascii_digit) { + continue; + } + let boundary = if e == end { !digit(s.get(end)) } else { matches!(s.get(e), Some(' ' | '-')) }; + if boundary && (13..=19).contains(&count) { + let digits: Vec = s[i..e].iter().filter_map(|c| c.to_digit(10)).collect(); + if luhn(&digits) { + return Some(e - i); + } + } + count = count.saturating_sub(1); + } + None } fn ssn(s: &[char], i: usize) -> Option { @@ -381,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 @@ -401,6 +525,18 @@ mod tests { ); } + #[test] + fn emails_with_a_leading_punctuation_character() { + for lead in ["_", "-", ".", "%", "+"] { + let address = format!("{lead}john@example.com"); + assert_eq!(found(Pattern::Email, &format!("Write to {address} now")), [address.as_str()], "lead {lead:?}"); + } + // A run of local characters is still matched whole, never from its middle. + assert_eq!(found(Pattern::Email, "a.b@x.com, foo_john@example.com"), ["a.b@x.com", "foo_john@example.com"]); + // Punctuation alone is no local part. + assert_eq!(found(Pattern::Email, "Write to ..@example.com or -@example.com"), Vec::::new()); + } + #[test] fn internationalized_emails() { assert_eq!( @@ -421,6 +557,18 @@ mod tests { ); } + #[test] + fn credit_card_followed_by_another_number() { + for tail in ["12/26", "2026", "123", "12 26"] { + assert_eq!(found(Pattern::CreditCard, &format!("Card 4111 1111 1111 1111 {tail}")), ["4111 1111 1111 1111"], "tail {tail}"); + } + assert_eq!(found(Pattern::CreditCard, "Card 3782-822463-10005 123"), ["3782-822463-10005"]); + // A run that is not a card at any group boundary still matches nothing. + assert_eq!(found(Pattern::CreditCard, "Card 4111 1111 1111 1112 12/26"), Vec::::new()); + // A longer grouped number doesn't match on a card-shaped prefix. + assert_eq!(found(Pattern::CreditCard, "IBAN 4111 1111 1111 1111 0000 0000"), Vec::::new()); + } + #[test] fn social_security_numbers() { assert_eq!( diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 64d6ceefc..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 @@ -138,14 +166,16 @@ fn hidden_layers(doc: &Document) -> Vec { } /// Glyphs of hidden text, or content of hidden layers, on every page (`write` = remove them). -fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Result { +fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Result<(usize, std::collections::HashSet), RedactError> { let off = if layers { hidden_layers(doc) } else { Vec::new() }; - if !text && off.is_empty() { - return Ok(0); + if !text && !layers { + return Ok((0, std::collections::HashSet::new())); } let mut total = 0; + let mut retained_off_layers = std::collections::HashSet::new(); // The original image and form XObjects the rewrite drops or replaces, on any page. let mut doomed = Vec::new(); + let mut retired_properties = Vec::new(); let n = pdfcraft_model::pages(doc).len(); for pi in 0..n { let page = pdfcraft_model::pages(doc).swap_remove(pi); @@ -163,14 +193,23 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re let c = page.crop(doc); scope.hidden_text = Some([c[0].min(c[2]), c[1].min(c[3]), c[0].max(c[2]), c[1].max(c[3])]); } - scope.hidden_layers = off.clone(); + scope.hidden_layers = off.iter().copied().collect(); + scope.layers_enabled = layers; let out = process(doc, &mut scope, &data, &resources, Matrix::IDENTITY); + if let Some(error) = out.error { + return Err(error.into()); + } let blocks = scope.layer_blocks; - total += out.residue + blocks + report.glyphs; + let glyphs = scope.report.glyphs; + let retained_for_page = std::mem::take(&mut scope.retained_off_layers); + drop(scope); + total += out.residue + blocks + glyphs; + retained_off_layers.extend(retained_for_page); if !write { continue; } doomed.extend_from_slice(&out.doomed); + retired_properties.extend(out.retired_properties); let mut new_list = list.clone(); let mut changed = false; for (i, new) in out.streams.into_iter().enumerate() { @@ -184,24 +223,24 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re changed = true; } if changed || !out.xobjects.is_empty() || !out.replaced.is_empty() { + // Pin inherited resources before replacing them. When no page still inherits the + // original holder, retiring it also retires descriptions no longer used anywhere. + crate::localize_resources(doc, &[pi])?; + let mut cleaned_page = page.dict.clone(); + cleaned_page.set(b"Contents".to_vec(), Object::Array(new_list.clone())); + let (_, page_content) = page_streams(doc, &cleaned_page, pi)?; let mut res = resources; if !out.xobjects.is_empty() || !out.replaced.is_empty() { let mut xo = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); // A full save keeps whatever is still referenced: unlink the originals this page no // longer draws so the removed form or image bytes cannot survive in the saved file. - let page_content: Vec> = new_list - .iter() - .map(|o| match &*doc.resolve(o) { - Object::Stream(s) => s.decoded().unwrap_or_default(), - _ => Vec::new(), - }) - .collect(); crate::interp::prune(&mut xo, &out.replaced, &page_content); for (n, r) in &out.xobjects { xo.set(n.clone(), Object::Ref(*r)); } res.set(b"XObject".to_vec(), Object::Dict(xo)); } + retired_properties.extend(crate::interp::prune_properties(doc, &mut res, &page_content)?); doc.update_dict(page.obj, |d| { d.set(b"Contents".to_vec(), Object::Array(new_list)); d.set(b"Resources".to_vec(), Object::Dict(res)); @@ -210,8 +249,8 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re } // What a page no longer draws must not stay reachable through an inherited or shared // /Resources either. - crate::interp::unlink_doomed(doc, &doomed)?; - Ok(total) + crate::interp::unlink_doomed(doc, &doomed, &retired_properties)?; + Ok((total, retained_off_layers)) } /// How many items each category would remove (categories with nothing are included as 0). @@ -234,10 +273,10 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { Hidden::Attachments => sub(doc, &names, b"EmbeddedFiles").map_or(0, |t| name_tree_len(doc, &t, 0)) + count_sub(b"FileAttachment"), Hidden::Comments => annots.iter().filter(|a| is_comment(a.name(b"Subtype").unwrap_or(b""))).count(), Hidden::FormFields => pdfcraft_forms::fields(doc).len(), - Hidden::HiddenText => content_pass(&mut doc2, true, false, false).unwrap_or(0), + Hidden::HiddenText => content_pass(&mut doc2, true, false, false).map(|(n, _)| n).unwrap_or(0), Hidden::HiddenLayers => { let off = hidden_layers(doc).len(); - if off == 0 { 0 } else { off + content_pass(&mut doc2, false, true, false).unwrap_or(0) } + off + content_pass(&mut doc2, false, true, false).map(|(n, _)| n).unwrap_or(0) } Hidden::Bookmarks => sub(doc, &cat, b"Outlines").map_or(0, |o| outline_len(doc, o.get(b"First"), 0)), Hidden::LinksActionsScripts => { @@ -256,6 +295,12 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { } /// Remove the chosen categories. Returns what was removed per category. +/// +/// # Errors +/// +/// This operation is not transactional: an error may leave `doc` partially modified, with +/// 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 remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result, RedactError> { let counts = scan(doc); let count = |h: Hidden| counts.iter().find(|c| c.0 == h).map_or(0, |c| c.1); @@ -263,9 +308,14 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result 0) || (layers && count(Hidden::HiddenLayers) > 0) { - content_pass(doc, text, layers, true)?; - } + let retained_off_layers = if (text && count(Hidden::HiddenText) > 0) || (layers && count(Hidden::HiddenLayers) > 0) { + content_pass(doc, text, layers, true)?.1 + } else { + std::collections::HashSet::new() + }; + // Every annotation the pages list now: whatever is no longer listed at the end (flattened + // widgets included) leaves the structure tree too. + 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) { @@ -286,16 +336,16 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result = pdfcraft_model::pages(doc) + .iter() + .flat_map(|p| annots_of(doc, &p.dict)) + .filter_map(|a| a.as_ref()) + .filter(|&r| doc.get(r).as_dict().is_some_and(drop_annot)) + .collect(); for p in pdfcraft_model::pages(doc) { let list = annots_of(doc, &p.dict); - let mut removed: Vec = Vec::new(); - for a in &list { - if let Some(r) = a.as_ref() - && doc.get(r).as_dict().is_some_and(drop_annot) - { - removed.push(r); - } - } let mut kept = Vec::new(); for a in list { let Some(r) = a.as_ref() else { @@ -335,6 +385,12 @@ 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); let mut cat = doc.get(root).as_dict().cloned().unwrap_or_default(); let mut names = sub(doc, &cat, b"Names"); @@ -371,7 +427,7 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result { // The off layers leave the configuration (their content is gone already). if let Some(mut oc) = sub(doc, &cat, b"OCProperties") { - let keep = |o: &Object| !o.as_ref().is_some_and(|r| off.contains(&r)); + let keep = |o: &Object| !o.as_ref().is_some_and(|r| off.contains(&r) && !retained_off_layers.contains(&r)); if let Some(Object::Array(a)) = oc.get(b"OCGs").map(|g| (*doc.resolve(g)).clone()) { oc.set(b"OCGs".to_vec(), Object::Array(a.into_iter().filter(keep).collect())); } diff --git a/crates/redact/src/tags.rs b/crates/redact/src/tags.rs index 02aae12aa..ab5735e06 100644 --- a/crates/redact/src/tags.rs +++ b/crates/redact/src/tags.rs @@ -9,6 +9,8 @@ use std::collections::{HashMap, HashSet}; use pdfcraft_cos::{Dict, Document, ObjRef, Object}; +use crate::RedactError; + /// Each MCID's operators (serialized), and whether anything is still drawn inside it. fn by_mcid(doc: &Document, resources: &Dict, data: &[u8]) -> HashMap, bool)> { let mut out: HashMap, bool)> = HashMap::new(); @@ -103,7 +105,23 @@ pub(crate) fn clean( stream: Option<(ObjRef, ObjRef)>, changed: &HashSet, empty: &HashSet, -) -> Result { +) -> Result { + clean_within(doc, page, stream, changed, empty, MAX_STRUCT_REFS) +} + +/// [`clean`], visiting at most `limit` structure-tree references. Fail-closed (#3302): when the +/// cleanup can't be shown complete, it is [`RedactError::Tags`] and the redaction isn't made. That +/// is a tree too large to walk in full (it could hide an element that repeats the redacted text), +/// an element whose inherited page can't be found (its ancestry too deep, or looping), and an +/// element written in place that would need changing (only the object holding it could be). +pub(crate) fn clean_within( + doc: &mut Document, + page: ObjRef, + stream: Option<(ObjRef, ObjRef)>, + changed: &HashSet, + empty: &HashSet, + limit: usize, +) -> Result { if changed.is_empty() && stream.is_none() { return Ok(0); } @@ -114,17 +132,44 @@ pub(crate) fn clean( else { return Ok(0); }; - // Indirect elements with the page they inherit. - let mut todo: Vec<(Object, Option)> = root.get(b"K").map(|k| vec![(k.clone(), None)]).unwrap_or_default(); + // Indirect elements with the page they inherit. Elements are reached from the root's /K, and + // also from the parent tree and the ID tree: one listed only there still owns marked content + // (#3302). The stack pops /K first, so an element reached through /K gets the page it inherits + // from its ancestors before a tree reaches it without one. + let mut todo: Vec<(Object, Option)> = + [&b"IDTree"[..], b"ParentTree", b"K"].iter().filter_map(|key| root.get(key).cloned()).map(|k| (k, None)).collect(); let mut seen = HashSet::new(); + let mut pages = HashMap::new(); let mut hits: Vec<(ObjRef, Dict, Option, bool)> = Vec::new(); + // Whether marked content `x` (a kid on page `pg`) is one this cleanup must change. + let touches = |doc: &Document, x: &Object, pg: Option| { + mcid_of(doc, x, pg, page, stream).is_some_and(|m| stream.is_some() || changed.contains(&m) || empty.contains(&m)) + }; while let Some((k, pg)) = todo.pop() { match k { Object::Array(a) => todo.extend(a.into_iter().map(|x| (x, pg))), + // A number or name tree node written in place. + Object::Dict(d) if !d.contains(b"S") => todo.extend(tree_node_entries(&d).into_iter().map(|x| (x, pg))), + // An element written in place: its kids are followed, but if it owns marked content + // this cleanup changes, only rewriting what holds it could change it. + Object::Dict(d) => { + let pg = match d.get(b"Pg").and_then(Object::as_ref).or(pg) { + Some(p) => Some(p), + None => inherited_page(doc, &d, &mut pages)?, + }; + let kids = kids_of(doc, &d); + if kids.iter().any(|x| touches(doc, x, pg)) { + return Err(RedactError::Tags); + } + todo.extend(kids.into_iter().map(|x| (x, pg))); + } Object::Ref(r) => { - if !seen.insert(r) || seen.len() > 2_000_000 { + if !seen.insert(r) { continue; } + if seen.len() > limit { + return Err(RedactError::Tags); + } let object = doc.get(r); if let Object::Array(a) = &*object { todo.extend(a.iter().cloned().map(|x| (x, pg))); @@ -132,16 +177,15 @@ pub(crate) fn clean( } let Some(d) = object.as_dict().cloned() else { continue }; if !d.contains(b"S") { + // A parent tree or ID tree node: its values may be elements. + todo.extend(tree_node_entries(&d).into_iter().map(|x| (x, pg))); continue; } - let pg = d.get(b"Pg").and_then(Object::as_ref).or(pg); - let kids: Vec = match d.get(b"K").map(|k| doc.resolve(k)) { - Some(o) => match &*o { - Object::Array(a) => a.clone(), - _ => d.get(b"K").cloned().into_iter().collect(), - }, - None => Vec::new(), + let pg = match d.get(b"Pg").and_then(Object::as_ref).or(pg) { + Some(p) => Some(p), + None => inherited_page(doc, &d, &mut pages)?, }; + let kids = kids_of(doc, &d); let sensitive = kids.iter().any(|x| mcid_of(doc, x, pg, page, stream).is_some_and(|m| changed.contains(&m))); let remap = stream.is_some() && kids.iter().any(|x| mcid_of(doc, x, pg, page, stream).is_some()); if sensitive || remap { @@ -202,3 +246,170 @@ pub(crate) fn clean( } Ok(n) } + +/// The page an element inherits (§14.7.4.4): the nearest `/Pg` among its ancestors, following +/// `/P`. `Ok(None)` when none has one; [`RedactError::Tags`] when the lookup gives up (more than +/// [`MAX_PARENTS`] ancestors, or a loop), which isn't the same as having no page. `pages` keeps +/// each ancestor's answer, so siblings don't climb the same chain again. +fn inherited_page(doc: &Document, d: &Dict, pages: &mut HashMap>) -> Result, RedactError> { + let mut chain: Vec = Vec::new(); + let mut parent = d.get(b"P").and_then(Object::as_ref); + let found = loop { + let Some(r) = parent else { break None }; + if let Some(known) = pages.get(&r) { + break *known; + } + if chain.len() >= MAX_PARENTS || chain.contains(&r) { + return Err(RedactError::Tags); + } + chain.push(r); + let object = doc.get(r); + let Some(p) = object.as_dict() else { break None }; + if let Some(pg) = p.get(b"Pg").and_then(Object::as_ref) { + break Some(pg); + } + parent = p.get(b"P").and_then(Object::as_ref); + }; + for r in chain { + pages.insert(r, found); + } + Ok(found) +} + +/// An element's kids: its `/K` array, or the one kid it has. +fn kids_of(doc: &Document, d: &Dict) -> Vec { + match d.get(b"K").map(|k| doc.resolve(k)) { + Some(o) => match &*o { + Object::Array(a) => a.clone(), + _ => d.get(b"K").cloned().into_iter().collect(), + }, + None => Vec::new(), + } +} + +/// The most ancestors [`inherited_page`] looks through; deeper trees, and cycles, go unanswered. +const MAX_PARENTS: usize = 256; + +/// Whether `o` is an object reference (`/Type /OBJR`, direct or indirect) to one of `gone`. +fn refers_to(doc: &Document, o: &Object, gone: &HashSet) -> bool { + doc.resolve(o) + .as_dict() + .is_some_and(|d| d.name(b"Type") == Some(b"OBJR") && d.get(b"Obj").and_then(Object::as_ref).is_some_and(|r| gone.contains(&r))) +} + +/// What a number or name tree node leads on to: its `/Nums` or `/Names` array (keys and values +/// alike; the walk ignores numbers and strings) and its `/Kids`. +fn tree_node_entries(d: &Dict) -> Vec { + [&b"Nums"[..], b"Names", b"Kids"].iter().filter_map(|key| d.get(key).cloned()).collect() +} + +/// Drop the structure tree's object references (OBJR) to `gone`, annotations removed from their +/// pages (#2447). A tagged PDF's tree points at each link and comment it tags; left there, the +/// reference keeps the annotation, its URL or text included, in a full rewrite after the page +/// no longer lists it. Returns how many references were dropped. +/// +/// Only elements that are objects of their own are edited: changing a structure element written in +/// place (a direct dictionary in a `/K` array or a tree) means rewriting the object that holds it, +/// so when one of those refers to a removed annotation it is [`RedactError::Tags`] (fail-closed). +/// Producers rarely write elements that way. +pub(crate) fn drop_object_refs(doc: &mut Document, gone: &HashSet) -> Result { + drop_object_refs_within(doc, gone, MAX_STRUCT_REFS) +} + +/// The most structure-tree references [`drop_object_refs`] visits. +const MAX_STRUCT_REFS: usize = 2_000_000; + +/// [`drop_object_refs`], visiting at most `limit` references. Fail-closed: a tree too large to +/// walk in full could keep a reference the walk never reached, and an element written in place +/// can only be changed by rewriting what holds it, so either is [`RedactError::Tags`] rather than +/// a cleanup that leaves the annotation reachable (removing only the tree would not: an outline +/// item's `/SE` or a link's `/SD` still reaches its elements). +pub(crate) fn drop_object_refs_within(doc: &mut Document, gone: &HashSet, limit: usize) -> Result { + if gone.is_empty() { + return Ok(0); + } + let Some(root) = doc + .root() + .and_then(|r| doc.get(r).as_dict().cloned()) + .and_then(|c| c.get(b"StructTreeRoot").map(|s| doc.resolve(s)).and_then(|s| s.as_dict().cloned())) + else { + return Ok(0); + }; + // Elements are reached from the root's /K, and also from the parent tree and the ID tree: + // one listed only there (left behind by an editor, say) still points at its annotation. + let mut todo: Vec = [&b"K"[..], b"ParentTree", b"IDTree"].iter().filter_map(|key| root.get(key).cloned()).collect(); + let mut seen = HashSet::new(); + let mut hits: Vec = Vec::new(); + while let Some(k) = todo.pop() { + match k { + Object::Array(a) => todo.extend(a), + // A structure element written in place: its kids are followed; one that tags a + // removed annotation can't be changed here (see above). + Object::Dict(d) if d.contains(b"S") => { + let kids = kids_of(doc, &d); + if kids.iter().any(|x| refers_to(doc, x, gone)) { + return Err(RedactError::Tags); + } + todo.extend(kids); + } + // A number or name tree node written in place (§7.9.6, §7.9.7): its values and kids. + Object::Dict(d) => todo.extend(tree_node_entries(&d)), + Object::Ref(r) => { + if !seen.insert(r) { + continue; + } + if seen.len() > limit { + return Err(RedactError::Tags); + } + let object = doc.get(r); + if let Object::Array(a) = &*object { + todo.extend(a.iter().cloned()); + continue; + } + let Some(d) = object.as_dict() else { continue }; + if !d.contains(b"S") { + // A parent tree or ID tree node: its values may be elements. + todo.extend(tree_node_entries(d)); + continue; + } + let kids = kids_of(doc, d); + if kids.iter().any(|x| refers_to(doc, x, gone)) { + hits.push(r); + } + todo.extend(kids); + } + _ => {} + } + } + let mut n = 0; + for r in hits { + let Some(d) = doc.get(r).as_dict().cloned() else { continue }; + let kids = d.get(b"K").map(|k| doc.resolve(k)); + let array = kids.as_ref().is_some_and(|k| matches!(&**k, Object::Array(_))); + let list: Vec = match kids.as_deref() { + Some(Object::Array(a)) => a.clone(), + Some(_) => d.get(b"K").cloned().into_iter().collect(), + None => Vec::new(), + }; + let before = list.len(); + let keep: Vec = list.into_iter().filter(|x| !refers_to(doc, x, gone)).collect(); + n += before - keep.len(); + doc.update_dict(r, |e| { + if array { + e.set(b"K".to_vec(), Object::Array(keep.clone())); + } else if let Some(k) = keep.first().cloned() { + e.set(b"K".to_vec(), k); + } else { + e.remove(b"K"); + } + // An element left with nothing to tag keeps no text describing what it tagged: + // a link's alternate text often repeats the URL, an annotation's its comment. + if keep.is_empty() { + for key in [&b"Alt"[..], b"ActualText", b"T", b"E"] { + e.remove(key); + } + } + })?; + } + Ok(n) +} diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index b9a230fc4..286667937 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -79,6 +79,30 @@ fn glyphs_under_a_mark_go_and_the_rest_stays_put() { assert!(!content(&doc, 0).contains("1234")); } +/// #2130: a font with no /Widths is drawn with its embedded program's advances. Measured with a +/// guess from its name instead (Courier's 600 units for "Mono"), the end of the line seemed to +/// lie past a mark that covers all of it on screen, and was kept. +#[test] +fn a_whole_line_under_a_mark_goes_even_without_widths() { + let text = b"illicit little lilies"; + let font = "<< /Type /Font /Subtype /TrueType /BaseFont /HouseMono /Encoding /WinAnsiEncoding /FontDescriptor 6 0 R >>"; + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 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 /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), + stream("", [b"BT /F1 10 Tf 10 100 Td (".as_slice(), text, b") Tj ET"].concat()), + font.as_bytes().to_vec(), + b"<< /Type /FontDescriptor /FontName /HouseMono /Flags 32 /Ascent 900 /Descent -200 /FontFile2 7 0 R >>".to_vec(), + stream(&format!("/Length1 {}", pdfcraft_fonts::INTER_REGULAR.len()), pdfcraft_fonts::INTER_REGULAR), + ]); + // Inter draws these 21 narrow glyphs in about 60 pt; Courier's guess would need 126. + let line = [5.0, 95.0, 90.0, 112.0]; + mark(&mut doc, 0, &[line], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.glyphs, text.len(), "every glyph of the line goes"); + assert_eq!(under(&mut doc, 0, &[[0.0, 0.0, 300.0, 300.0]]), 0, "{}", content(&doc, 0)); +} + #[test] fn added_text_parameters_do_not_keep_redacted_text() { // An Edit ▸ Add content item: its stream dictionary keeps the source text under /PCAdded. @@ -375,6 +399,25 @@ fn named_image(doc: &Document, page: usize, name: &[u8]) -> Option> { } } +#[test] +fn redaction_clears_pixels_intersected_without_covering_their_centres() { + for (content, rect) in + [(&b"q 200 0 0 100 0 0 cm /Im Do Q"[..], [10.0, 10.0, 20.0, 90.0]), (&b"q 0 100 -200 0 200 0 cm /Im Do Q"[..], [10.0, 5.0, 190.0, 10.0])] + { + let mut doc = one_page( + content, + "/XObject << /Im 7 0 R >>", + vec![stream("/Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", [123, 234])], + ); + mark(&mut doc, 0, &[rect], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!(report.images_cleared, 1); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(named_image(&saved, 0, b"Im"), None); + assert_eq!(named_image(&saved, 0, b"PCRedacted1"), Some(vec![0, 234])); + } +} + /// Apply Redactions gives the page its own pruned /Resources, but the dictionary it drew through /// before (inherited from the /Pages node, or shared with another page) still named the removed /// image and form, so a full-rewrite save kept them. @@ -449,6 +492,420 @@ fn a_hidden_layers_image_does_not_survive_through_inherited_resources() { assert!(!bytes.windows(16).any(|w| w == b"HIDDENLAYERIMAGE"), "the off layer's image survived the save"); } +#[test] +fn hidden_ocmd_all_on_content_is_removed_before_its_off_group_is_unlinked() { + use crate::sanitize::{Hidden, remove_hidden}; + + let mut doc = one_page( + b"BT /F1 10 Tf 10 200 Td (PUBLIC) Tj ET /OC /M BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC", + "/Properties << /M 9 0 R >>", + vec![ + b"<< /Type /OCG /Name (On) >>".to_vec(), + b"<< /Type /OCG /Name (Off) >>".to_vec(), + b"<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P /AllOn >>".to_vec(), + ], + ); + let root = doc.root().unwrap(); + doc.update_dict(root, |d| { + d.set( + b"OCProperties".to_vec(), + Object::Dict(pdfcraft_cos::Dict::from_iter([ + (b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])), + ( + b"D".to_vec(), + Object::Dict(pdfcraft_cos::Dict::from_iter([ + (b"Order".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])), + (b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))])), + ])), + ), + ])), + ); + }) + .unwrap(); + + let before = pdfcraft_edit::text_lines(&doc, 0).unwrap(); + assert!(before.iter().any(|line| line.text.contains("PUBLIC"))); + assert!(before.iter().any(|line| line.text.contains("SECRET")), "extractor sees source text before sanitization"); + remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap(); + + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + let lines = pdfcraft_edit::text_lines(&saved, 0).unwrap(); + assert!(lines.iter().any(|line| line.text.contains("PUBLIC")), "visible content remains: {lines:?}"); + assert!(!lines.iter().any(|line| line.text.contains("SECRET")), "hidden text is extractable: {lines:?}"); + assert!(!write_full(&saved, &SaveOptions::default()).unwrap().windows(6).any(|w| w == b"SECRET")); +} + +#[test] +fn hidden_ocmd_content_in_a_form_and_nested_marked_blocks_is_removed() { + use crate::sanitize::{Hidden, remove_hidden}; + + let mut doc = one_page( + b"/Fm Do", + "/Properties << /M 9 0 R >> /XObject << /Fm 10 0 R >>", + vec![ + b"<< /Type /OCG /Name (On) >>".to_vec(), + b"<< /Type /OCG /Name (Off) >>".to_vec(), + b"<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P /AllOn >>".to_vec(), + stream( + "/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> /Properties << /M 9 0 R >> >>", + b"/OC /M BDC /OC /M BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC EMC", + ), + ], + ); + let root = doc.root().unwrap(); + doc.update_dict(root, |d| { + d.set( + b"OCProperties".to_vec(), + Object::Dict(Dict::from_iter([ + (b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])), + (b"D".to_vec(), Object::Dict(Dict::from_iter([(b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))]))]))), + ])), + ); + }) + .unwrap(); + + remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap(); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert!(!pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("SECRET"))); +} + +#[test] +fn hidden_ocmd_alloff_is_sanitized_when_every_group_is_on() { + use crate::sanitize::{Hidden, remove_hidden, scan}; + + let mut doc = one_page( + b"/OC /M BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC", + "/Properties << /M 9 0 R >>", + vec![ + b"<< /Type /OCG /Name (On) >>".to_vec(), + b"<< /Type /OCG /Name (Unused) >>".to_vec(), + b"<< /Type /OCMD /OCGs [7 0 R] /P /AllOff >>".to_vec(), + ], + ); + let counts = scan(&doc); + assert!(counts.iter().any(|(h, n)| *h == Hidden::HiddenLayers && *n > 0), "{counts:?}"); + remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap(); + + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert!(!pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("SECRET"))); +} + +#[test] +fn hidden_ve_not_is_sanitized_when_its_group_is_on() { + use crate::sanitize::{Hidden, remove_hidden, scan}; + + let mut doc = one_page( + b"/OC /M BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC", + "/Properties << /M 9 0 R >>", + vec![b"<< /Type /OCG /Name (On) >>".to_vec(), b"<< /Type /OCG /Name (Unused) >>".to_vec(), b"<< /Type /OCMD /VE [/Not 7 0 R] >>".to_vec()], + ); + let counts = scan(&doc); + assert!(counts.iter().any(|(h, n)| *h == Hidden::HiddenLayers && *n > 0), "{counts:?}"); + remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap(); + + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert!(!pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("SECRET"))); +} + +fn all_on_ocmd_xobject(subtype: &str, policy: &str) -> Document { + let membership = match policy { + "AllOff" => "<< /Type /OCMD /OCGs [7 0 R] /P /AllOff >>", + "Not" => "<< /Type /OCMD /VE [/Not 7 0 R] >>", + _ => "<< /Type /OCMD /OCGs [7 0 R] /P /AllOff >>", + }; + let (object_number, oc) = if policy == "DirectAllOff" { (8, membership) } else { (9, "8 0 R") }; + let xobject = match subtype { + "Image" => stream( + &format!("/Type /XObject /Subtype /Image /Width 4 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /OC {oc}"), + b"PRIVATE_HIDDEN_XOBJECT_IMAGE", + ), + _ => stream( + &format!("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> >> /OC {oc}"), + b"BT /F1 10 Tf 10 100 Td (PRIVATE_HIDDEN_XOBJECT_FORM) Tj ET", + ), + }; + let mut extras = vec![b"<< /Type /OCG /Name (On) >>".to_vec()]; + if object_number == 9 { + extras.push(membership.as_bytes().to_vec()); + } + extras.push(xobject); + let mut doc = one_page(b"/X Do", &format!("/XObject << /X {object_number} 0 R >>"), extras); + let root = doc.root().unwrap(); + doc.update_dict(root, |d| { + d.set( + b"OCProperties".to_vec(), + Object::Dict(Dict::from_iter([ + (b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0))])), + (b"D".to_vec(), Object::Dict(Dict::from_iter([(b"BaseState".to_vec(), Object::name("ON"))]))), + ])), + ); + }) + .unwrap(); + doc +} + +#[test] +fn hidden_all_on_ocmd_image_and_form_xobjects_are_counted_and_removed() { + use crate::sanitize::{Hidden, remove_hidden, scan}; + + let mut failures = Vec::new(); + for subtype in ["Image", "Form"] { + for policy in ["AllOff", "Not"] { + let mut doc = all_on_ocmd_xobject(subtype, policy); + let before = scan(&doc); + let hidden_count = before.iter().find(|(h, _)| *h == Hidden::HiddenLayers).map_or(0, |(_, n)| *n); + if hidden_count == 0 { + failures.push(format!("{subtype} /{policy} was not counted: {before:?}")); + } + + match remove_hidden(&mut doc, &[Hidden::HiddenLayers]) { + Ok(removed) if removed.iter().any(|(h, n)| *h == Hidden::HiddenLayers && *n > 0) => {} + Ok(removed) => failures.push(format!("{subtype} /{policy} remove report is empty: {removed:?}")), + Err(error) => failures.push(format!("{subtype} /{policy} remove failed: {error}")), + } + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + if bytes.windows(b"PRIVATE_HIDDEN_XOBJECT_IMAGE".len()).any(|w| w == b"PRIVATE_HIDDEN_XOBJECT_IMAGE") { + failures.push(format!("{subtype} /{policy} image payload remains in full save")); + } + if bytes.windows(b"PRIVATE_HIDDEN_XOBJECT_FORM".len()).any(|w| w == b"PRIVATE_HIDDEN_XOBJECT_FORM") { + failures.push(format!("{subtype} /{policy} Form payload remains in full save")); + } + let saved = Document::open(Arc::new(bytes)).unwrap(); + if pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("PRIVATE_HIDDEN_XOBJECT_FORM")) { + failures.push(format!("{subtype} /{policy} Form text remains extractable")); + } + if !pdfcraft_edit::page_images(&saved, 0).unwrap().is_empty() { + failures.push(format!("{subtype} /{policy} image remains on the page")); + } + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn repeated_image_with_direct_alloff_membership_is_counted_per_draw() { + use crate::sanitize::{Hidden, scan}; + + let mut doc = all_on_ocmd_xobject("Image", "DirectAllOff"); + let page = pdfcraft_model::pages(&doc)[0].obj; + let contents = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), b"/X Do /X Do /X Do"))); + doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Ref(contents))).unwrap(); + + let original = write_full(&doc, &SaveOptions::default()).unwrap(); + let counts = scan(&doc); + assert_eq!(counts.iter().find(|(h, _)| *h == Hidden::HiddenLayers).map(|(_, n)| *n), Some(3), "{counts:?}"); + assert_eq!(write_full(&doc, &SaveOptions::default()).unwrap(), original, "Verify scan must not mutate the document"); +} + +#[test] +fn undecodable_form_is_counted_and_removed_by_hidden_layer_sanitization() { + use crate::sanitize::{Hidden, remove_hidden, scan}; + + let mut doc = one_page( + b"/Bad Do", + "/XObject << /Bad 7 0 R >>", + vec![stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Filter /UnknownFilter", b"PRIVATE_UNDECODABLE_FORM")], + ); + let counts = scan(&doc); + assert!(counts.iter().any(|(h, n)| *h == Hidden::HiddenLayers && *n > 0), "{counts:?}"); + remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap(); + + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + assert!(!bytes.windows(b"PRIVATE_UNDECODABLE_FORM".len()).any(|w| w == b"PRIVATE_UNDECODABLE_FORM")); +} + +#[test] +fn form_chain_at_max_depth_is_counted_and_removed_by_hidden_layer_sanitization() { + use crate::sanitize::{Hidden, remove_hidden, scan}; + + const PRIVATE: &[u8] = b"PRIVATE_FORM_BEYOND_MAX_DEPTH"; + // MAX_DEPTH is 12. The final Form is encountered at that depth and is removed + // conservatively without being entered. Verify must count that removal or sanitize + // can skip the HiddenLayers pass entirely. + let mut forms = Vec::new(); + for depth in 0..=12 { + let content = if depth == 12 { + b"BT /F1 10 Tf 10 100 Td (PRIVATE_FORM_BEYOND_MAX_DEPTH) Tj ET".to_vec() + } else { + format!("/F{} Do", depth + 1).into_bytes() + }; + forms.push(stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", &content)); + } + let xobjects = (0..=12).map(|depth| format!("/F{depth} {} 0 R", 7 + depth)).collect::>().join(" "); + let mut doc = one_page(b"/F0 Do", &format!("/XObject << {xobjects} >>"), forms); + assert!( + write_full(&doc, &SaveOptions::default()).unwrap().windows(PRIVATE.len()).any(|w| w == PRIVATE), + "fixture lacked nested payload before sanitization" + ); + + let counts = scan(&doc); + assert!(counts.iter().any(|(hidden, count)| *hidden == Hidden::HiddenLayers && *count > 0), "depth-limited Form was not counted: {counts:?}"); + let removed = remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap(); + assert!( + removed.iter().any(|(hidden, count)| *hidden == Hidden::HiddenLayers && *count > 0), + "depth-limited Form was not reported removed: {removed:?}" + ); + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + assert!(!bytes.windows(PRIVATE.len()).any(|w| w == PRIVATE), "depth-limited Form payload remains in full save"); +} + +#[test] +fn hidden_ocmd_resolves_an_indirect_policy_name() { + use crate::sanitize::{Hidden, remove_hidden}; + + let mut doc = one_page( + b"/OC /M BDC BT /F1 10 Tf 10 100 Td (VISIBLE) Tj ET EMC", + "/Properties << /M 9 0 R >>", + vec![ + b"<< /Type /OCG /Name (On) >>".to_vec(), + b"<< /Type /OCG /Name (Off) >>".to_vec(), + b"<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P 12 0 R >>".to_vec(), + ], + ); + doc.set(ObjRef::new(12, 0), Object::name("AnyOn")); + let root = doc.root().unwrap(); + doc.update_dict(root, |d| { + d.set( + b"OCProperties".to_vec(), + Object::Dict(Dict::from_iter([ + (b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])), + (b"D".to_vec(), Object::Dict(Dict::from_iter([(b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))]))]))), + ])), + ); + }) + .unwrap(); + + remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap(); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert!(pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("VISIBLE"))); +} + +#[test] +fn surviving_anyoff_and_not_content_keeps_its_off_group_state() { + use crate::sanitize::{Hidden, remove_hidden}; + + for use_not in [false, true] { + let mut doc = one_page( + b"/OC /M BDC BT /F1 10 Tf 10 100 Td (VISIBLE) Tj ET EMC", + "/Properties << /M 9 0 R >>", + vec![ + b"<< /Type /OCG /Name (On) >>".to_vec(), + b"<< /Type /OCG /Name (Off) >>".to_vec(), + if use_not { b"<< /Type /OCMD /VE [/Not 8 0 R] >>".to_vec() } else { b"<< /Type /OCMD /OCGs [8 0 R] /P /AnyOff >>".to_vec() }, + ], + ); + let root = doc.root().unwrap(); + doc.update_dict(root, |d| { + d.set( + b"OCProperties".to_vec(), + Object::Dict(Dict::from_iter([ + (b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])), + (b"D".to_vec(), Object::Dict(Dict::from_iter([(b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))]))]))), + ])), + ); + }) + .unwrap(); + + remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap(); + let root = doc.root().unwrap(); + let catalog = doc.get(root); + let oc = catalog.as_dict().and_then(|d| d.get(b"OCProperties")).map(|o| doc.resolve(o)); + let default = oc.as_deref().and_then(|o| o.as_dict()).and_then(|d| d.get(b"D")).map(|o| doc.resolve(o)); + let off = default.as_deref().and_then(|o| o.as_dict()).and_then(|d| d.get(b"OFF")).map(|o| doc.resolve(o)); + assert!( + off.as_deref().and_then(Object::as_array).is_some_and(|a| a.iter().any(|o| o.as_ref() == Some(ObjRef::new(8, 0)))), + "off group lost for /VE Not={use_not}" + ); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert!(pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("VISIBLE"))); + } +} + +#[test] +fn hidden_ocmd_policy_matches_the_default_configuration() { + use crate::sanitize::{Hidden, remove_hidden}; + + let cases = [ + (None, false, None, false), + (Some("AnyOn"), false, None, false), + (Some("AllOn"), false, None, true), + (Some("AnyOff"), false, None, false), + (Some("AllOff"), false, None, true), + (Some("UnknownPolicy"), false, None, true), + (None, true, None, true), + (None, false, Some("and"), true), + (None, false, Some("and_single_not"), true), + (None, false, Some("and_single_off"), true), + (None, false, Some("or_single_on"), false), + (None, false, Some("or_single_off"), true), + (Some("AllOn"), false, Some("not_off"), false), + (None, false, Some("malformed_not"), true), + (None, false, Some("malformed_without_ocgs"), true), + ]; + for (policy, malformed_policy, visibility, should_remove) in cases { + let mut doc = one_page( + b"/OC /M BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC", + "/Properties << /M 9 0 R >>", + vec![ + b"<< /Type /OCG /Name (On) >>".to_vec(), + b"<< /Type /OCG /Name (Off) >>".to_vec(), + b"<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P /AllOn >>".to_vec(), + ], + ); + let mut membership = Dict::new(); + membership.set(b"Type".to_vec(), Object::name("OCMD")); + if visibility != Some("malformed_without_ocgs") { + membership.set(b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])); + } + if let Some(policy) = policy { + membership.set(b"P".to_vec(), Object::name(policy)); + } else if malformed_policy { + membership.set(b"P".to_vec(), Object::Int(17)); + } + match visibility { + Some("and") => membership + .set(b"VE".to_vec(), Object::Array(vec![Object::name("And"), Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])), + Some("and_single_not") => membership.set( + b"VE".to_vec(), + Object::Array(vec![Object::name("Not"), Object::Array(vec![Object::name("And"), Object::Ref(ObjRef::new(7, 0))])]), + ), + Some("and_single_off") => membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("And"), Object::Ref(ObjRef::new(8, 0))])), + Some("or_single_on") => membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("Or"), Object::Ref(ObjRef::new(7, 0))])), + Some("or_single_off") => membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("Or"), Object::Ref(ObjRef::new(8, 0))])), + Some("not_off") => membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("Not"), Object::Ref(ObjRef::new(8, 0))])), + Some("malformed_not") => membership + .set(b"VE".to_vec(), Object::Array(vec![Object::name("Not"), Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])), + Some("malformed_without_ocgs") => { + membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("UnknownOperator"), Object::Ref(ObjRef::new(8, 0))])) + } + _ => {} + } + doc.set(ObjRef::new(9, 0), Object::Dict(membership)); + let root = doc.root().unwrap(); + doc.update_dict(root, |d| { + d.set( + b"OCProperties".to_vec(), + Object::Dict(Dict::from_iter([ + (b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])), + ( + b"D".to_vec(), + Object::Dict(Dict::from_iter([ + (b"Order".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])), + (b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))])), + ])), + ), + ])), + ); + }) + .unwrap(); + + remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap(); + let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + let has_secret = pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("SECRET")); + assert_eq!(has_secret, !should_remove, "policy {policy:?}, malformed={malformed_policy}, VE={visibility:?}"); + } +} + #[test] fn redacting_an_xfa_field_drops_the_xfa_packet_so_its_value_cannot_survive() { // A static AcroForm+XFA form whose field value lives both in the widget /V and in the XFA @@ -490,6 +947,59 @@ fn redacting_an_xfa_field_drops_the_xfa_packet_so_its_value_cannot_survive() { assert!(cat.get(b"NeedsRendering").is_none(), "/NeedsRendering is cleared"); } +#[test] +fn replies_to_a_redacted_comment_go_with_it() { + // #2659: a highlight under the mark has a reply placed elsewhere, and that reply a reply of + // its own. The replies' /IRT would keep the highlight (its comment and QuadPoints) in the file. + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 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 /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >>".to_vec(), + stream("", b"BT ET"), + b"<< /Type /Annot /Subtype /Highlight /Rect [20 20 60 30] /QuadPoints [20 30 60 30 20 20 60 20] /Contents (HIGHLIGHT-NOTE) >>".to_vec(), + b"<< /Type /Annot /Subtype /Text /Rect [250 250 270 270] /IRT 5 0 R /Contents (FIRST-REPLY) >>".to_vec(), + b"<< /Type /Annot /Subtype /Text /Rect [250 200 270 220] /IRT 6 0 R /Contents (SECOND-REPLY) >>".to_vec(), + b"<< /Type /Annot /Subtype /Text /Rect [250 100 270 120] /Contents (UNRELATED) >>".to_vec(), + ]; + let mut doc = pdf(objs); + mark(&mut doc, 0, &[[10.0, 10.0, 70.0, 40.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.annotations, 3, "the highlight and both replies: {r:?}"); + let p = pdfcraft_model::pages(&doc).swap_remove(0); + assert_eq!(annots_of(&doc, &p.dict).len(), 1, "only the unrelated note stays"); + // A classic table, so the objects' strings are plain in the file. + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + for gone in [&b"HIGHLIGHT-NOTE"[..], b"FIRST-REPLY", b"SECOND-REPLY"] { + assert!(!bytes.windows(gone.len()).any(|w| w == gone), "{} survived the save", String::from_utf8_lossy(gone)); + } + assert!(bytes.windows(9).any(|w| w == b"UNRELATED")); +} + +/// The replies listed before the annotations they answer, deepest first, and a pop-up on a +/// reply: one pass in `/Annots` order would keep them, the repeated passes don't (review of +/// #2697). +#[test] +fn replies_listed_before_their_parent_and_their_pop_ups_go_too() { + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 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 /Annots [7 0 R 9 0 R 6 0 R 5 0 R 8 0 R] >>".to_vec(), + stream("", b"BT ET"), + b"<< /Type /Annot /Subtype /Highlight /Rect [20 20 60 30] /QuadPoints [20 30 60 30 20 20 60 20] /Contents (HIGHLIGHT-NOTE) >>".to_vec(), + b"<< /Type /Annot /Subtype /Text /Rect [250 250 270 270] /IRT 5 0 R /Contents (FIRST-REPLY) /Popup 9 0 R >>".to_vec(), + b"<< /Type /Annot /Subtype /Text /Rect [250 200 270 220] /IRT 6 0 R /Contents (SECOND-REPLY) >>".to_vec(), + b"<< /Type /Annot /Subtype /Text /Rect [250 100 270 120] /Contents (UNRELATED) >>".to_vec(), + b"<< /Type /Annot /Subtype /Popup /Rect [200 150 290 190] /Parent 6 0 R >>".to_vec(), + ]; + let mut doc = pdf(objs); + mark(&mut doc, 0, &[[10.0, 10.0, 70.0, 40.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.annotations, 3, "the highlight and both replies: {r:?}"); + let p = pdfcraft_model::pages(&doc).swap_remove(0); + let left = annots_of(&doc, &p.dict); + assert_eq!(left, vec![Object::Ref(ObjRef::new(8, 0))], "only the unrelated note stays, the reply's pop-up goes too"); +} + #[test] fn comments_links_and_fields_under_a_mark_go() { let mut doc = one_page(b"", "", vec![]); @@ -547,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"; @@ -631,6 +1210,556 @@ 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. +#[test] +fn removed_links_and_comments_leave_the_structure_tree_and_the_saved_file() { + use crate::sanitize::{Hidden, remove_hidden}; + let objs: Vec> = vec![ + // 1 catalog, with a structure tree + b"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 8 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + // 3 page: a tagged paragraph (MCID 0), a link and a comment, both tagged + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R 7 0 R] /StructParents 0 >>".to_vec(), + stream("", b"/P << /MCID 0 >> BDC BT /F1 10 Tf 10 230 Td (See the site) Tj ET EMC"), + FONT.replace("95 0 R", "13 0 R").into_bytes(), + // 6 link, 7 comment + b"<< /Type /Annot /Subtype /Link /Rect [10 228 80 240] /StructParent 1 /A << /S /URI /URI (https://secret.example/token=ZX81) >> >>".to_vec(), + b"<< /Type /Annot /Subtype /Text /Rect [200 200 220 220] /StructParent 2 /Contents (COMMENTSECRETNOTE) >>".to_vec(), + // 8 struct tree root, 9 document element, 10 paragraph, 11 link element (direct OBJR), + // 12 annotation element (OBJR by reference, as K's only kid) + b"<< /Type /StructTreeRoot /K 9 0 R /ParentTree << /Nums [0 [10 0 R] 1 11 0 R 2 12 0 R] >> /ParentTreeNextKey 3 >>".to_vec(), + b"<< /Type /StructElem /S /Document /P 8 0 R /K [10 0 R 11 0 R 12 0 R] >>".to_vec(), + b"<< /Type /StructElem /S /P /P 9 0 R /Pg 3 0 R /K 0 >>".to_vec(), + // the link element's alternate text repeats the URL + b"<< /Type /StructElem /S /Link /P 9 0 R /Pg 3 0 R /Alt (secret.example link) /K [<< /Type /OBJR /Obj 6 0 R /Pg 3 0 R >>] >>".to_vec(), + b"<< /Type /StructElem /S /Annot /P 9 0 R /Pg 3 0 R /K 14 0 R >>".to_vec(), + widths(), + b"<< /Type /OBJR /Obj 7 0 R /Pg 3 0 R >>".to_vec(), + ]; + let mut doc = pdf(objs); + let done = remove_hidden(&mut doc, &[Hidden::LinksActionsScripts, Hidden::Comments]).unwrap(); + assert!(done.contains(&(Hidden::LinksActionsScripts, 1)) && done.contains(&(Hidden::Comments, 1)), "{done:?}"); + // 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"(See the site)"), "the search sees the saved objects"); + assert!(!has(b"secret.example"), "the removed link's URL survived the save"); + assert!(!has(b"COMMENTSECRETNOTE"), "the removed comment's text survived the save"); + // The tagged paragraph and its content stay; the elements lose only the references. + let saved = Document::open(Arc::new(bytes)).unwrap(); + assert!(content(&saved, 0).contains("(See the site)")); + let objrs = saved.object_numbers().into_iter().filter(|&n| { + let o = saved.get(ObjRef::new(n, saved.generation(n))); + let direct = o.as_dict().is_some_and(|d| d.name(b"Type") == Some(b"OBJR")); + let nested = o.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().is_some_and(|d| d.name(b"Type") == Some(b"OBJR"))), + _ => false, + }); + direct || nested + }); + assert_eq!(objrs.count(), 0, "no object reference to a removed annotation is left"); + let root = saved.get(saved.root().unwrap()).as_dict().cloned().unwrap(); + assert!(root.contains(b"StructTreeRoot"), "the structure tree itself stays"); +} + +/// #2532 review: a tagged form's widgets, flattened when form fields are removed, leave the +/// structure tree too (their field value with them), and a pop-up listed on another page than +/// its removed comment goes with it. +#[test] +fn flattened_widgets_and_far_pop_ups_leave_the_structure_tree() { + use crate::sanitize::{Hidden, remove_hidden}; + 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] >> /StructTreeRoot 8 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R 13 0 R] /Count 2 >>".to_vec(), + // 3 page 1: a text field's widget and a comment + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R 7 0 R] /StructParents 0 >>".to_vec(), + stream("", b"BT /F1 10 Tf 10 230 Td (Name:) Tj ET"), + FONT.replace("95 0 R", "12 0 R").into_bytes(), + // 6 the field and its widget, with an appearance + b"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V (FIELDSECRETVALUE) /Rect [50 225 200 240] /StructParent 1 /AP << /N 11 0 R >> >>".to_vec(), + // 7 comment, its pop-up (15) listed on page 2 + b"<< /Type /Annot /Subtype /Text /Rect [200 200 220 220] /Contents (note) /Popup 15 0 R >>".to_vec(), + // 8 struct tree root, 9 document, 10 form element tagging the widget + b"<< /Type /StructTreeRoot /K 9 0 R /ParentTree << /Nums [1 10 0 R] >> /ParentTreeNextKey 2 >>".to_vec(), + b"<< /Type /StructElem /S /Document /P 8 0 R /K [10 0 R] >>".to_vec(), + b"<< /Type /StructElem /S /Form /P 9 0 R /Pg 3 0 R /K [<< /Type /OBJR /Obj 6 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(), + // 13 page 2, listing the pop-up of page 1's comment + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Annots [15 0 R 16 0 R] >>".to_vec(), + b"<< >>".to_vec(), + b"<< /Type /Annot /Subtype /Popup /Rect [0 0 100 50] /Parent 7 0 R >>".to_vec(), + // 16 an unrelated link on page 2 stays + b"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] >>".to_vec(), + ]; + let mut doc = pdf(objs); + remove_hidden(&mut doc, &[Hidden::FormFields, Hidden::Comments]).unwrap(); + let page2 = pdfcraft_model::pages(&doc)[1].dict.clone(); + let left: Vec = page2.get(b"Annots").map(|a| doc.resolve(a).as_array().map(|a| a.to_vec()).unwrap_or_default()).unwrap_or_default(); + assert_eq!(left, [Object::Ref(ObjRef::new(16, 0))], "the far pop-up went with its comment"); + let form = doc.get(ObjRef::new(10, 0)).as_dict().cloned().unwrap(); + let kids = form.get(b"K").map(|k| doc.resolve(k).as_array().map(Vec::len).unwrap_or(1)); + assert!(kids.unwrap_or(0) == 0, "the form element no longer references the flattened widget"); + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + let saved = Document::open(Arc::new(bytes)).unwrap(); + let field_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") || d.contains(b"FT")) + }); + 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 +/// would leave an outline's `/SE` reaching them, review on #3285); under the limit only the +/// reference goes. +#[test] +fn a_structure_tree_too_large_to_walk_is_refused() { + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 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] >>".to_vec(), + // 4: the removed link + b"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /URI /URI (https://secret.example) >> >>".to_vec(), + // 5 root, 6 document, 7 section, 8 link element with the OBJR + b"<< /Type /StructTreeRoot /K 6 0 R >>".to_vec(), + b"<< /Type /StructElem /S /Document /P 5 0 R /K [7 0 R] >>".to_vec(), + b"<< /Type /StructElem /S /Sect /P 6 0 R /K [8 0 R] >>".to_vec(), + b"<< /Type /StructElem /S /Link /P 7 0 R /K [<< /Type /OBJR /Obj 4 0 R >>] >>".to_vec(), + ]; + let gone: std::collections::HashSet = [ObjRef::new(4, 0)].into(); + let catalog = |doc: &Document| doc.get(doc.root().unwrap()).as_dict().cloned().unwrap(); + + let mut doc = pdf(objs.clone()); + assert_eq!(crate::tags::drop_object_refs_within(&mut doc, &gone, 100).unwrap(), 1); + assert!(catalog(&doc).contains(b"StructTreeRoot") && catalog(&doc).contains(b"MarkInfo"), "under the limit the tree stays"); + + let mut doc = pdf(objs); + assert!(matches!(crate::tags::drop_object_refs_within(&mut doc, &gone, 2), Err(RedactError::Tags)), "a tree the walk couldn't finish is refused"); + assert!(catalog(&doc).contains(b"StructTreeRoot"), "nothing changed"); +} + +/// #3302: an element listed only in the parent tree (not under the root's /K) still owns the +/// marked content redaction changed, so its /ActualText and /Alt, which repeat the redacted +/// words, go as they do for an element under /K. +#[test] +fn an_element_listed_only_in_the_parent_tree_loses_its_redacted_text() { + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 0 R /MarkInfo << /Marked true >> >>".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 /StructParents 0 /Resources << /Font << /F1 9 0 R >> >> >>".to_vec(), + stream("", b"/P <> BDC BT /F1 14 Tf 20 150 Td (Codename Bluefin) Tj ET EMC"), + // The root's /K reaches only the empty document element 6; the paragraph 7 is in the + // parent tree only. + b"<< /Type /StructTreeRoot /K 6 0 R /ParentTree << /Nums [0 [7 0 R]] >> /ParentTreeNextKey 1 >>".to_vec(), + b"<< /Type /StructElem /S /Document /P 5 0 R >>".to_vec(), + b"<< /Type /StructElem /S /P /P 6 0 R /Pg 3 0 R /K 0 /ActualText (Codename Bluefin) /Alt (Codename Bluefin) >>".to_vec(), + b"<< /Type /StructElem /S /Note /P 6 0 R >>".to_vec(), + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec(), + ]; + let mut doc = pdf(objs); + mark(&mut doc, 0, &[[10.0, 140.0, 290.0, 170.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert!(r.glyphs > 0 && r.tags >= 1, "{r:?}"); + let element = doc.get(ObjRef::new(7, 0)).as_dict().cloned().unwrap(); + assert!(!element.contains(b"ActualText") && !element.contains(b"Alt"), "{element:?}"); +} + +/// #3302: a structure tree too large to walk in full fails closed: an error, so the redaction +/// isn't made, instead of the walk stopping and reporting success with elements never checked. +#[test] +fn a_tag_walk_cut_short_is_refused() { + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 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] /StructParents 0 >>".to_vec(), + b"<< >>".to_vec(), + b"<< /Type /StructTreeRoot /K 6 0 R >>".to_vec(), + b"<< /Type /StructElem /S /Document /P 5 0 R /K [7 0 R 8 0 R] >>".to_vec(), + b"<< /Type /StructElem /S /P /P 6 0 R /Pg 3 0 R /K 0 /ActualText (Codename Bluefin) >>".to_vec(), + b"<< /Type /StructElem /S /Figure /P 6 0 R /Alt (A chart) /E (chart) >>".to_vec(), + ]; + let page = ObjRef::new(3, 0); + let changed: std::collections::HashSet = [0].into(); + let mut doc = pdf(objs.clone()); + assert_eq!(crate::tags::clean_within(&mut doc, page, None, &changed, &Default::default(), 100).unwrap(), 1); + let figure = doc.get(ObjRef::new(8, 0)).as_dict().cloned().unwrap(); + assert!(figure.contains(b"Alt"), "a walk that finishes keeps an unrelated element's text"); + + let mut doc = pdf(objs); + assert!(matches!(crate::tags::clean_within(&mut doc, page, None, &changed, &Default::default(), 1), Err(RedactError::Tags))); +} + +/// #2532 follow-up: an element reached only through the parent tree or the ID tree, not from +/// the root's /K, still points at a removed annotation, and the root keeps it reachable in a full +/// rewrite. Both trees are walked too, written in place or as their own objects. +#[test] +fn elements_listed_only_in_the_parent_tree_or_the_id_tree_lose_their_references() { + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 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] >>".to_vec(), + // 4: the removed link; 8: the removed comment + b"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /URI /URI (https://secret.example) >> >>".to_vec(), + // 5: the root; its /K reaches only the empty document element 6. The parent tree is + // written in place, the ID tree's kid (9) as its own object. + b"<< /Type /StructTreeRoot /K 6 0 R /ParentTree << /Nums [0 7 0 R] >> /IDTree << /Kids [9 0 R] >> >>".to_vec(), + b"<< /Type /StructElem /S /Document /P 5 0 R >>".to_vec(), + // 7: a link element only the parent tree lists + b"<< /Type /StructElem /S /Link /P 6 0 R /K << /Type /OBJR /Obj 4 0 R >> /Alt (secret.example link) >>".to_vec(), + b"<< /Type /Annot /Subtype /Text /Rect [0 0 10 10] /Contents (hushhush note) >>".to_vec(), + b"<< /Names [(note1) 10 0 R] >>".to_vec(), + // 10: an annotation element only the ID tree lists + b"<< /Type /StructElem /S /Annot /P 6 0 R /ID (note1) /K [<< /Type /OBJR /Obj 8 0 R >>] >>".to_vec(), + ]; + let gone: std::collections::HashSet = [ObjRef::new(4, 0), ObjRef::new(8, 0)].into(); + let mut doc = pdf(objs); + assert_eq!(crate::tags::drop_object_refs(&mut doc, &gone).unwrap(), 2, "both references go"); + let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + for secret in [&b"secret.example"[..], b"hushhush"] { + assert!(!bytes.windows(secret.len()).any(|w| w == secret), "{} survived the rewrite", String::from_utf8_lossy(secret)); + } +} + +/// #3285 review: an element without its own /Pg takes its ancestor's page, also when the parent +/// tree lists it. One under /K must not be reached first through the parent tree without that +/// page, and one only the parent tree lists finds it through /P. +#[test] +fn an_element_inheriting_its_page_loses_its_redacted_text_when_the_parent_tree_lists_it() { + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 0 R /MarkInfo << /Marked true >> >>".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 /StructParents 0 /Resources << /Font << /F1 9 0 R >> >> >>".to_vec(), + stream("", b"/P <> BDC BT /F1 14 Tf 20 150 Td (Codename Bluefin) Tj ET EMC /P <> BDC BT /F1 14 Tf 20 100 Td (Codename Bluefin) Tj ET EMC"), + // The document element 6 carries the page; 7 sits under it, 8 only points at it. + b"<< /Type /StructTreeRoot /K 6 0 R /ParentTree << /Nums [0 [7 0 R 8 0 R]] >> /ParentTreeNextKey 1 >>".to_vec(), + b"<< /Type /StructElem /S /Document /P 5 0 R /Pg 3 0 R /K [7 0 R] >>".to_vec(), + b"<< /Type /StructElem /S /P /P 6 0 R /K 0 /ActualText (Codename Bluefin) >>".to_vec(), + b"<< /Type /StructElem /S /P /P 6 0 R /K 1 /Alt (Codename Bluefin) >>".to_vec(), + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec(), + ]; + let mut doc = pdf(objs); + mark(&mut doc, 0, &[[10.0, 90.0, 290.0, 170.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert!(r.glyphs > 0, "{r:?}"); + for n in [7, 8] { + let element = doc.get(ObjRef::new(n, 0)).as_dict().cloned().unwrap(); + assert!(!element.contains(b"ActualText") && !element.contains(b"Alt"), "{n}: {element:?}"); + } +} + +/// #3285 review: a structure element written in place, here as an ID tree value, can't be +/// edited without rewriting what holds it; when it tags a removed annotation, removing hidden +/// information is refused instead of keeping the annotation reachable in a full rewrite. +/// Direct callers discard the failed working copy to preserve the original document. +#[test] +fn an_element_written_in_place_that_tags_a_removed_annotation_is_refused() { + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 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] /Annots [4 0 R] >>".to_vec(), + b"<< /Type /Annot /Subtype /Text /Rect [0 0 10 10] /Contents (hushhush note) >>".to_vec(), + b"<< /Type /StructTreeRoot /K 6 0 R /IDTree << /Names [(note1) << /Type /StructElem /S /Annot /P 6 0 R /K [<< /Type /OBJR /Obj 4 0 R >>] >>] >> >>".to_vec(), + b"<< /Type /StructElem /S /Document /P 5 0 R >>".to_vec(), + ]; + let original = pdf(objs); + let before = write_full(&original, &SaveOptions::default()).unwrap(); + let mut working = original.clone(); + assert!(matches!(sanitize::remove_hidden(&mut working, &[sanitize::Hidden::Comments]), Err(RedactError::Tags))); + assert!(!working.get(ObjRef::new(3, 0)).as_dict().unwrap().contains(b"Annots"), "the failed working copy was partially changed"); + drop(working); + assert_eq!(write_full(&original, &SaveOptions::default()).unwrap(), before, "discarding the working copy preserves the original"); +} + +/// Whether a full rewrite of `doc` still holds the redacted word. +fn bluefin_survives(doc: &Document) -> bool { + let bytes = write_full(doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap(); + bytes.windows(7).any(|w| w == b"Bluefin") +} + +/// #3285 review (the maintainer's probes): an element written in place in the parent tree that +/// owns the redacted text can't be changed, so applying is refused; the same element as an object +/// of its own is cleaned, and nothing of the word is left in a full rewrite. +/// Refusal does not promise rollback: direct callers discard the failed working copy. +#[test] +fn an_element_written_in_place_owning_redacted_text_is_refused() { + let objs = |element: &str| -> Vec> { + vec![ + b"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 0 R /MarkInfo << /Marked true >> >>".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 /StructParents 0 /Resources << /Font << /F1 9 0 R >> >> >>" + .to_vec(), + stream("", b"/P <> BDC BT /F1 14 Tf 20 150 Td (Codename Bluefin) Tj ET EMC"), + format!("<< /Type /StructTreeRoot /K 6 0 R /ParentTree << /Nums [0 [{element}]] >> /ParentTreeNextKey 1 >>").into_bytes(), + b"<< /Type /StructElem /S /Document /P 5 0 R >>".to_vec(), + b"<< /Type /StructElem /S /P /P 6 0 R /Pg 3 0 R /K 0 /ActualText (Codename Bluefin) >>".to_vec(), + b"<< >>".to_vec(), + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec(), + ] + }; + let mut doc = pdf(objs("<< /Type /StructElem /S /P /P 6 0 R /Pg 3 0 R /K 0 /ActualText (Codename Bluefin) >>")); + mark(&mut doc, 0, &[[10.0, 140.0, 290.0, 170.0]], ""); + let before = write_full(&doc, &SaveOptions::default()).unwrap(); + let mut working = doc.clone(); + let error = apply(&mut working, None).unwrap_err(); + assert!(matches!(error, RedactError::Tags)); + assert_eq!( + error.to_string(), + "the document's tags can't be cleaned completely (an element written in place, or a structure tree too large or too deep to follow); the operation was refused" + ); + assert!(!content(&working, 0).contains("Bluefin"), "the failed working copy was partially changed"); + assert!(bluefin_survives(&working), "the failed working copy still holds the word in its tags"); + drop(working); + assert_eq!(write_full(&doc, &SaveOptions::default()).unwrap(), before, "discarding the working copy preserves the original"); + let mut doc = pdf(objs("7 0 R")); + mark(&mut doc, 0, &[[10.0, 140.0, 290.0, 170.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert!(r.glyphs > 0 && !bluefin_survives(&doc), "{r:?}"); +} + +/// #3285 review: an element whose inherited page can't be found (more ancestors than the lookup +/// follows) isn't taken for one with no page: applying is refused. Within the limit the page is +/// found and the text goes. +#[test] +fn an_element_whose_inherited_page_is_out_of_reach_is_refused() { + let objs = |depth: u32| -> Vec> { + let mut objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 0 R /MarkInfo << /Marked true >> >>".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 /StructParents 0 /Resources << /Font << /F1 9 0 R >> >> >>" + .to_vec(), + stream("", b"/P <> BDC BT /F1 14 Tf 20 150 Td (Codename Bluefin) Tj ET EMC"), + b"<< /Type /StructTreeRoot /K 6 0 R /ParentTree << /Nums [0 [7 0 R]] >> /ParentTreeNextKey 1 >>".to_vec(), + b"<< /Type /StructElem /S /Document /P 5 0 R /Pg 3 0 R >>".to_vec(), + format!("<< /Type /StructElem /S /P /P {} 0 R /K 0 /ActualText (Codename Bluefin) >>", 10 + depth - 1).into_bytes(), + b"<< >>".to_vec(), + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec(), + ]; + for i in 0..depth { + let parent = if i == 0 { 6 } else { 10 + i - 1 }; + objs.push(format!("<< /Type /StructElem /S /Div /P {parent} 0 R >>").into_bytes()); + } + objs + }; + let mut doc = pdf(objs(300)); + mark(&mut doc, 0, &[[10.0, 140.0, 290.0, 170.0]], ""); + assert!(matches!(apply(&mut doc, None), Err(RedactError::Tags))); + let mut doc = pdf(objs(100)); + mark(&mut doc, 0, &[[10.0, 140.0, 290.0, 170.0]], ""); + apply(&mut doc, None).unwrap(); + assert!(!bluefin_survives(&doc)); +} + /// Issue #1325: removing comments keeps the annotations that are not comments: the multimedia /// and print-production ones (Watermark, PrinterMark, TrapNet, Screen, Movie, RichMedia, 3D). #[test] @@ -1056,6 +2185,100 @@ fn redacting_every_inheriting_page_retires_the_parent_resources() { } } +#[test] +fn replacement_text_and_property_resources_do_not_survive_redaction() { + let mut doc = one_page( + b"/Span /P0 BDC /Span <> BDC BT /F1 10 Tf 10 200 Td (SECRET)", + "/Properties << /P0 7 0 R >>", + vec![ + b"<< /MCID 0 /ActualText (LEAK_PROPERTY) /Alt (LEAK_ALT) /E (LEAK_EXPANSION) >>".to_vec(), + stream("", b"Tj ET EMC EMC /Span <> DP BT /F1 10 Tf 10 100 Td (Public) Tj ET"), + ], + ); + doc.update_dict(ObjRef::new(3, 0), |d| { + d.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(4, 0)), Object::Ref(ObjRef::new(8, 0))])); + }) + .unwrap(); + mark(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]], ""); + apply(&mut doc, None).unwrap(); + assert_no_secret(&doc, b"LEAK_"); + let saved = reopen(&doc); + assert!(content(&saved, 0).contains("Public")); + let p = pdfcraft_model::pages(&saved).swap_remove(0); + assert!(!saved.resolve(p.dict.get(b"Resources").unwrap()).as_dict().unwrap().contains(b"Properties")); +} + +#[test] +fn inherited_form_properties_lose_replacement_text() { + let mut doc = one_page( + b"/Fm Do", + "/XObject << /Fm 7 0 R >> /Properties << /P0 8 0 R >>", + vec![ + stream( + "/Type /XObject /Subtype /Form /BBox [0 0 300 300]", + b"/Span /P0 BDC BT /F1 10 Tf 10 200 Td (SECRET) Tj ET EMC BT /F1 10 Tf 10 100 Td (Public) Tj ET", + ), + b"<< /ActualText (LEAK_FORM_PROPERTY) >>".to_vec(), + ], + ); + mark(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]], ""); + apply(&mut doc, None).unwrap(); + assert_no_secret(&doc, b"LEAK_"); + assert_no_secret(&doc, b"SECRET"); + let mut saved = reopen(&doc); + assert_eq!(under(&mut saved, 0, &[[0.0, 90.0, 100.0, 110.0]]), 6); +} + +#[test] +fn properties_used_by_unmodified_inheriting_forms_stay() { + let mut doc = one_page( + b"BT /F1 10 Tf 10 200 Td (SECRET) Tj ET /Fm Do", + "/XObject << /Fm 7 0 R >> /Properties << /P0 8 0 R >>", + vec![stream("/Type /XObject /Subtype /Form /BBox [0 0 10 10]", b"/Span /P0 BDC 0 0 10 10 re f EMC"), b"<< /ActualText (Public) >>".to_vec()], + ); + mark(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]], ""); + apply(&mut doc, None).unwrap(); + let saved = reopen(&doc); + let page = pdfcraft_model::pages(&saved).swap_remove(0); + let resources = saved.resolve(page.dict.get(b"Resources").unwrap()); + let properties = saved.resolve(resources.as_dict().unwrap().get(b"Properties").unwrap()); + let property = saved.resolve(properties.as_dict().unwrap().get(b"P0").unwrap()); + assert_eq!(property.as_dict().unwrap().get(b"ActualText").unwrap().as_string().unwrap().to_text(), "Public"); +} + +#[test] +fn untouched_replacement_descriptions_survive_redaction_and_hidden_text_cleanup() { + for hidden in [false, true] { + let mode = if hidden { 3 } else { 0 }; + let data = format!( + "/Span <> BDC BT /F1 10 Tf {mode} Tr 10 200 Td (SECRET) Tj ET EMC \ + /Span /Public BDC BT /F1 10 Tf 0 Tr 10 100 Td (Public) Tj ET EMC" + ); + let mut doc = one_page( + data.as_bytes(), + "/Properties << /Public 7 0 R >>", + vec![b"<< /ActualText (PUBLIC_ACTUAL) /Alt (PUBLIC_ALT) /E (PUBLIC_EXPANSION) /T (PUBLIC_TITLE) >>".to_vec()], + ); + if hidden { + sanitize::remove_hidden(&mut doc, &[sanitize::Hidden::HiddenText]).unwrap(); + } else { + mark(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]], ""); + apply(&mut doc, None).unwrap(); + } + assert_no_secret(&doc, b"LEAK_"); + assert_no_secret(&doc, b"SECRET"); + let saved = reopen(&doc); + let page = pdfcraft_model::pages(&saved).swap_remove(0); + let resources = saved.resolve(page.dict.get(b"Resources").unwrap()); + let properties = saved.resolve(resources.as_dict().unwrap().get(b"Properties").unwrap()); + let property = saved.resolve(properties.as_dict().unwrap().get(b"Public").unwrap()); + let dict = property.as_dict().unwrap(); + for key in [&b"ActualText"[..], b"Alt", b"E", b"T"] { + assert!(dict.contains(key), "surviving description removed: {key:?}, hidden={hidden}"); + } + } +} + #[test] fn a_hostile_icc_component_count_is_refused_not_multiplied_into_a_crash() { // /N comes from the file. 2^60 × 8 bpc × width 2 overflows the row size; a negative /N wrapped @@ -1117,3 +2340,135 @@ fn masks_and_alternates_outside_redaction_stay() { assert!(image.dict.contains(b"SMask") && image.dict.contains(b"Alternates")); assert_eq!(image.decoded().unwrap(), b"PUBLIC_TOPBOTTOM"); } + +#[test] +fn stream_boundary_comment_stays_inert_after_unrelated_redaction() { + for tail in [b"BT /F1 10 Tf 10 200 Td (HIDDEN) Tj ET".as_slice(), b"BT /F1 10 Tf 10 200 Td (HIDDEN) Tj ET\nBT /F1 10 Tf 10 250 Td (LIVE) Tj ET"] { + let mut doc = one_page(b"BT /F1 10 Tf 10 100 Td (REMOVE) Tj ET % note", "", vec![]); + let hidden = doc.add(Object::Stream(Stream::from_raw(Dict::new(), tail.to_vec()))); + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(4, 0)), Object::Ref(hidden)]))).unwrap(); + mark(&mut doc, 0, &[[9.0, 95.0, 50.0, 110.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 6); + let reopened = reopen(&doc); + let page = &pdfcraft_model::pages(&reopened)[0]; + let (_, data) = page_streams(&reopened, &page.dict, 0).unwrap(); + // Independent of Pieces::join: the renderer concatenates streams with spaces. + let ops = pdfcraft_content::parse(&data.join(&b' ')).ops; + assert!(!ops.iter().any(|o| o.operands.iter().any(|v| v.as_string().is_some_and(|s| s.bytes == b"HIDDEN"))), "hidden text became executable"); + let live = ops.iter().any(|o| o.operands.iter().any(|v| v.as_string().is_some_and(|s| s.bytes == b"LIVE"))); + assert_eq!(live, tail.contains(&b'\n')); + } +} + +#[test] +fn rewriting_a_piece_keeps_its_leading_comment_terminator() { + for eol in [b"\n".as_slice(), b"\r", b"\r\n"] { + let mut doc = one_page(b"q Q % note", "", vec![]); + let mut tail = eol.to_vec(); + tail.extend(b"BT /F1 10 Tf 10 100 Td (REMOVE) Tj ET BT /F1 10 Tf 10 200 Td (KEEP) Tj ET"); + let second = doc.add(Object::Stream(Stream::from_raw(Dict::new(), tail))); + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(4, 0)), Object::Ref(second)]))).unwrap(); + mark(&mut doc, 0, &[[9.0, 95.0, 50.0, 110.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 6); + let p = &pdfcraft_model::pages(&doc)[0]; + let (_, data) = page_streams(&doc, &p.dict, 0).unwrap(); + let ops = pdfcraft_content::parse(&data.join(&b' ')).ops; + assert_eq!(ops.iter().filter(|o| o.is("BT")).count(), 2, "leading EOL lost; first live operator swallowed"); + assert!(ops.iter().any(|o| o.operands.iter().any(|v| v.as_string().is_some_and(|s| s.bytes == b"KEEP")))); + } +} + +#[test] +fn named_hidden_text_descriptions_are_removed_from_saved_bytes() { + let mut doc = one_page( + b"/Span /P0 BDC BT /F1 10 Tf 3 Tr 10 200 Td (SECRET) Tj ET EMC", + "/Properties << /P0 7 0 R >>", + vec![b"<< /ActualText (LEAK_ACTUAL) /Alt (LEAK_ALT) /E (LEAK_EXPANSION) /T (LEAK_TITLE) >>".to_vec()], + ); + sanitize::remove_hidden(&mut doc, &[sanitize::Hidden::HiddenText]).unwrap(); + assert_no_secret(&doc, b"SECRET"); + assert_no_secret(&doc, b"LEAK_"); + let saved = reopen(&doc); + let page = pdfcraft_model::pages(&saved).swap_remove(0); + assert!(!saved.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().contains(b"Properties")); +} + +#[test] +fn unused_descriptions_in_shared_or_inherited_resources_are_retired() { + for hidden in [false, true] { + for inherited in [false, true] { + let mode = if hidden { 3 } else { 0 }; + let data = format!("/Span /P0 BDC BT /F1 10 Tf {mode} Tr 10 200 Td (SECRET) Tj ET EMC /Fm Do"); + let mut doc = one_page( + data.as_bytes(), + "/Properties << /P0 7 0 R /Public 8 0 R >> /XObject << /Fm 9 0 R >>", + vec![ + b"<< /ActualText (LEAK_ACTUAL) /Alt (LEAK_ALT) /E (LEAK_EXPANSION) /T (LEAK_TITLE) >>".to_vec(), + b"<< /ActualText (PUBLIC_ACTUAL) /Alt (PUBLIC_ALT) /E (PUBLIC_EXPANSION) /T (PUBLIC_TITLE) >>".to_vec(), + stream("/Type /XObject /Subtype /Form /BBox [0 0 100 120]", b"/Span /Public BDC BT /F1 10 Tf 0 Tr 10 100 Td (Public) Tj ET EMC"), + ], + ); + let first = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().clone(); + let resources = doc.add(first.get(b"Resources").unwrap().clone()); + let second_content = doc.add(Object::Stream(Stream::from_raw(Dict::new(), b"/Fm Do".to_vec()))); + let mut second = first; + second.set(b"Contents".to_vec(), Object::Ref(second_content)); + if inherited { + second.remove(b"Resources"); + } else { + second.set(b"Resources".to_vec(), Object::Ref(resources)); + } + let second = doc.add(Object::Dict(second)); + doc.update_dict(ObjRef::new(3, 0), |d| { + if inherited { + d.remove(b"Resources"); + } else { + d.set(b"Resources".to_vec(), Object::Ref(resources)); + } + }) + .unwrap(); + doc.update_dict(ObjRef::new(2, 0), |d| { + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(3, 0)), Object::Ref(second)])); + d.set(b"Count".to_vec(), Object::Int(2)); + if inherited { + d.set(b"Resources".to_vec(), Object::Ref(resources)); + } + }) + .unwrap(); + if hidden { + sanitize::remove_hidden(&mut doc, &[sanitize::Hidden::HiddenText]).unwrap(); + } else { + mark(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]], ""); + apply(&mut doc, Some(&[0])).unwrap(); + } + assert_no_secret(&doc, b"LEAK_"); + assert_no_secret(&doc, b"SECRET"); + let mut saved = reopen(&doc); + for page_index in 0..2 { + assert_eq!(under(&mut saved, page_index, &[[0.0, 90.0, 100.0, 110.0]]), 6); + let page = pdfcraft_model::pages(&saved).swap_remove(page_index); + let resources = saved.resolve(page.dict.get(b"Resources").unwrap()); + let properties = saved.resolve(resources.as_dict().unwrap().get(b"Properties").unwrap()); + assert!(!properties.as_dict().unwrap().contains(b"P0")); + let property = saved.resolve(properties.as_dict().unwrap().get(b"Public").unwrap()); + for key in [&b"ActualText"[..], b"Alt", b"E", b"T"] { + assert!(property.as_dict().unwrap().contains(key), "shared form lost {key:?}: hidden={hidden}, inherited={inherited}"); + } + } + } + } +} + +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/redact/src/tests/property_retirement.rs b/crates/redact/src/tests/property_retirement.rs new file mode 100644 index 000000000..ad5197637 --- /dev/null +++ b/crates/redact/src/tests/property_retirement.rs @@ -0,0 +1,212 @@ +use super::*; + +const SECRET: &[u8] = b"/Span /P0 BDC BT /F1 10 Tf 10 200 Td (SECRET) Tj ET EMC"; +const DESCRIPTION: &[u8] = b"<< /ActualText (LEAK_ACTUAL) /Alt (LEAK_ALT) /E (LEAK_EXPANSION) /T (LEAK_TITLE) >>"; +const PUBLIC: &[u8] = b"<< /ActualText (PUBLIC_ACTUAL) /Alt (PUBLIC_ALT) /E (PUBLIC_EXPANSION) /T (PUBLIC_TITLE) >>"; + +fn resources(doc: &Document, page: usize) -> Dict { + let page = pdfcraft_model::pages(doc).swap_remove(page); + doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone() +} + +fn property(doc: &Document, resources: &Dict, name: &[u8]) -> Option { + let properties = doc.resolve(resources.get(b"Properties")?); + doc.resolve(properties.as_dict()?.get(name)?).as_dict().cloned() +} + +fn assert_description(dict: &Dict) { + for key in [&b"ActualText"[..], b"Alt", b"E", b"T"] { + assert!(dict.contains(key), "surviving description lost {key:?}"); + } +} + +fn add_second_page(doc: &mut Document, data: &[u8], resources: Object) -> ObjRef { + let mut page = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().clone(); + let contents = doc.add(Object::Stream(Stream::from_raw(Dict::new(), data.to_vec()))); + page.set(b"Contents".to_vec(), Object::Ref(contents)); + page.set(b"Resources".to_vec(), resources); + let second = doc.add(Object::Dict(page)); + doc.update_dict(ObjRef::new(2, 0), |d| { + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(3, 0)), Object::Ref(second)])); + d.set(b"Count".to_vec(), Object::Int(2)); + }) + .unwrap(); + second +} + +fn redact_first(doc: &mut Document) -> Result { + mark(doc, 0, &[[5.0, 195.0, 60.0, 212.0]], ""); + apply(doc, Some(&[0])) +} + +#[test] +fn surviving_form_own_resources_do_not_retain_unused_shared_descriptions() { + for hidden in [false, true] { + let mode = if hidden { 3 } else { 0 }; + let data = format!("/Span /P0 BDC BT /F1 10 Tf {mode} Tr 10 200 Td (SECRET) Tj ET EMC /Fm Do"); + let mut doc = one_page( + data.as_bytes(), + "/Properties << /P0 7 0 R /Public 8 0 R >> /XObject << /Fm 9 0 R >>", + vec![ + DESCRIPTION.to_vec(), + PUBLIC.to_vec(), + stream("/Type /XObject /Subtype /Form /BBox [0 0 100 120]", b"/Span /Public BDC BT /F1 10 Tf 0 Tr 10 100 Td (Public) Tj ET EMC"), + ], + ); + // The page and its unchanged Form both reference the same resource object. The + // dictionary names the Form itself, but the Form's content never invokes that name. + let shared = doc.add(Object::Dict(resources(&doc, 0))); + for owner in [ObjRef::new(3, 0), ObjRef::new(9, 0)] { + doc.update_dict(owner, |d| d.set(b"Resources".to_vec(), Object::Ref(shared))).unwrap(); + } + if hidden { + sanitize::remove_hidden(&mut doc, &[sanitize::Hidden::HiddenText]).unwrap(); + } else { + redact_first(&mut doc).unwrap(); + } + assert_no_secret(&doc, b"LEAK_"); + assert_no_secret(&doc, b"SECRET"); + let mut saved = reopen(&doc); + assert_eq!(under(&mut saved, 0, &[[0.0, 90.0, 100.0, 110.0]]), 6); + let page_resources = resources(&saved, 0); + let xobjects = saved.resolve(page_resources.get(b"XObject").unwrap()); + let form = saved.resolve(xobjects.as_dict().unwrap().get(b"Fm").unwrap()); + let form_resources = saved.resolve(form.as_dict().unwrap().get(b"Resources").unwrap()); + let form_resources = form_resources.as_dict().unwrap(); + assert!(property(&saved, form_resources, b"P0").is_none()); + assert_description(&property(&saved, form_resources, b"Public").unwrap()); + } +} + +#[test] +fn unused_property_aliases_are_retired_by_value_not_resource_name() { + for direct in [false, true] { + let value = if direct { std::str::from_utf8(DESCRIPTION).unwrap() } else { "7 0 R" }; + let mut doc = one_page(SECRET, &format!("/Properties << /P0 {value} >>"), vec![DESCRIPTION.to_vec()]); + let mut second_resources = resources(&doc, 0); + let original_properties = doc.resolve(second_resources.get(b"Properties").unwrap()); + let mut alias = Dict::new(); + alias.set(b"Alias".to_vec(), original_properties.as_dict().unwrap().get(b"P0").unwrap().clone()); + second_resources.set(b"Properties".to_vec(), Object::Dict(alias)); + add_second_page(&mut doc, b"q Q", Object::Dict(second_resources)); + redact_first(&mut doc).unwrap(); + assert_no_secret(&doc, b"LEAK_"); + let saved = reopen(&doc); + assert!(property(&saved, &resources(&saved, 1), b"Alias").is_none(), "direct={direct}"); + } +} + +#[test] +fn unrelated_unused_property_with_the_same_name_is_not_changed() { + let mut doc = one_page(SECRET, "/Properties << /P0 7 0 R >>", vec![DESCRIPTION.to_vec(), PUBLIC.to_vec()]); + let mut second_resources = resources(&doc, 0); + let mut properties = Dict::new(); + properties.set(b"P0".to_vec(), Object::Ref(ObjRef::new(8, 0))); + second_resources.set(b"Properties".to_vec(), Object::Dict(properties)); + let second = add_second_page(&mut doc, b"q Q", Object::Dict(second_resources.clone())); + let before = doc.get(second); + redact_first(&mut doc).unwrap(); + assert_eq!(doc.get(second), before, "an unrelated page was rewritten"); + assert_no_secret(&doc, b"LEAK_"); + let saved = reopen(&doc); + assert_description(&property(&saved, &resources(&saved, 1), b"P0").unwrap()); +} + +#[test] +fn null_resources_forms_preserve_a_retired_property_still_used_by_nested_dp() { + let mut doc = one_page( + SECRET, + "/Properties << /P0 7 0 R >> /XObject << /Outer 8 0 R /Inner 9 0 R >>", + vec![ + PUBLIC.to_vec(), + stream("/Type /XObject /Subtype /Form /BBox [0 0 100 120] /Resources null", b"/Inner Do"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 100 120]", b"/Span /P0 DP BT /F1 10 Tf 10 100 Td (Public) Tj ET"), + ], + ); + let shared = doc.add(Object::Dict(resources(&doc, 0))); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Resources".to_vec(), Object::Ref(shared))).unwrap(); + add_second_page(&mut doc, b"/Outer Do", Object::Ref(shared)); + redact_first(&mut doc).unwrap(); + assert_no_secret(&doc, b"SECRET"); + let mut saved = reopen(&doc); + assert!(property(&saved, &resources(&saved, 0), b"P0").is_none()); + assert_description(&property(&saved, &resources(&saved, 1), b"P0").unwrap()); + assert_eq!(under(&mut saved, 1, &[[0.0, 90.0, 100.0, 110.0]]), 6); +} + +#[test] +fn uninspectable_forms_with_candidate_properties_fail_closed() { + for own in [false, true] { + for malformed in [false, true] { + let filter = if malformed { "" } else { "/Filter /UnsupportedPropertyTest" }; + let data = if malformed { b")".as_slice() } else { b"opaque" }; + if malformed { + assert!(pdfcraft_content::parse(data).skipped > 0, "fixture must need parser recovery"); + } + let mut doc = one_page( + SECRET, + "/Properties << /P0 7 0 R >> /XObject << /Fm 8 0 R >>", + vec![DESCRIPTION.to_vec(), stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 100 120] {filter}"), data)], + ); + let shared = doc.add(Object::Dict(resources(&doc, 0))); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Resources".to_vec(), Object::Ref(shared))).unwrap(); + if own { + doc.update_dict(ObjRef::new(8, 0), |d| d.set(b"Resources".to_vec(), Object::Ref(shared))).unwrap(); + } + add_second_page(&mut doc, b"/Fm Do", Object::Ref(shared)); + assert_eq!(under(&mut doc, 0, &[[5.0, 195.0, 60.0, 212.0]]), 6); + let error = redact_first(&mut doc).unwrap_err(); + assert!(matches!(error, RedactError::Cos(_)), "own Resources={own}, malformed={malformed}: {error:?}"); + } + } +} + +#[test] +fn hidden_text_in_nested_forms_retires_named_descriptions_and_keeps_public_text() { + let mut doc = one_page( + b"/Outer Do", + "/Properties << /P0 7 0 R /Public 8 0 R >> /XObject << /Outer 9 0 R /Inner 10 0 R >>", + vec![ + DESCRIPTION.to_vec(), + PUBLIC.to_vec(), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"/Inner Do"), + stream( + "/Type /XObject /Subtype /Form /BBox [0 0 300 300]", + b"/Span /P0 BDC BT /F1 10 Tf 3 Tr 10 200 Td (SECRET) Tj ET EMC \ + /Span /Public BDC BT /F1 10 Tf 0 Tr 10 100 Td (Public) Tj ET EMC", + ), + ], + ); + sanitize::remove_hidden(&mut doc, &[sanitize::Hidden::HiddenText]).unwrap(); + assert_no_secret(&doc, b"SECRET"); + assert_no_secret(&doc, b"LEAK_"); + let mut saved = reopen(&doc); + assert_eq!(under(&mut saved, 0, &[[0.0, 90.0, 100.0, 110.0]]), 6); + // The rewritten inner Form owns the surviving /Public description after resources are + // localized; no page-level property lookup is sufficient to check it. + assert!(saved.scan_objects().any(|(_, object)| { + object.as_dict().and_then(|d| d.get(b"ActualText")).and_then(Object::as_string).is_some_and(|s| s.to_text() == "PUBLIC_ACTUAL") + })); +} + +#[test] +fn removing_hidden_layer_content_cleans_its_surviving_outer_description() { + let mut doc = one_page( + b"/Span /P0 BDC /OC /Off BDC BT /F1 10 Tf 10 200 Td (SECRET) Tj ET EMC EMC \ + /Span /Public BDC BT /F1 10 Tf 10 100 Td (Public) Tj ET EMC", + "/Properties << /P0 7 0 R /Public 8 0 R /Off 9 0 R >>", + vec![DESCRIPTION.to_vec(), PUBLIC.to_vec(), b"<< /Type /OCG /Name (Hidden test layer) >>".to_vec()], + ); + let mut defaults = Dict::new(); + defaults.set(b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(9, 0))])); + let mut optional_content = Dict::new(); + optional_content.set(b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(9, 0))])); + optional_content.set(b"D".to_vec(), Object::Dict(defaults)); + doc.update_dict(ObjRef::new(1, 0), |d| d.set(b"OCProperties".to_vec(), Object::Dict(optional_content))).unwrap(); + sanitize::remove_hidden(&mut doc, &[sanitize::Hidden::HiddenLayers]).unwrap(); + assert_no_secret(&doc, b"SECRET"); + assert_no_secret(&doc, b"LEAK_"); + let mut saved = reopen(&doc); + assert_description(&property(&saved, &resources(&saved, 0), b"Public").unwrap()); + assert_eq!(under(&mut saved, 0, &[[0.0, 90.0, 100.0, 110.0]]), 6); +} diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 3c3988b7b..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; @@ -56,6 +60,9 @@ pub struct DocInfo { pub keywords: Option, pub creator: Option, pub producer: Option, + /// Custom document properties: `/Info` entries with a key the spec does not name and a text + /// value, as (name, text), in the dictionary's order (at most 256). + pub custom: Vec<(String, String)>, pub pdf_version: String, pub file_size: usize, pub encrypted: bool, @@ -87,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, } @@ -216,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 @@ -383,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 { @@ -422,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)] @@ -616,6 +640,23 @@ impl<'a> Inspector<'a> { info.keywords = self.text(d, b"Keywords"); info.creator = self.text(d, b"Creator"); info.producer = self.text(d, b"Producer"); + const STANDARD: [&[u8]; 9] = + [b"Title", b"Author", b"Subject", b"Keywords", b"Creator", b"Producer", b"CreationDate", b"ModDate", b"Trapped"]; + // The same rule as the engine's list after an edit (`pdfcraft_organize::custom_info`, + // trimmed and without empty values): text strings only, so a name value is not a + // custom property, and the cap counts listed entries. + info.custom = d + .iter() + .filter(|(k, _)| !STANDARD.contains(&k.as_slice())) + .filter_map(|(k, v)| match self.resolve(v) { + Object::String(bytes, _) => { + let v = text_string(bytes).trim_matches(|c: char| c == '\0' || c.is_whitespace()).to_string(); + (!v.is_empty()).then(|| (String::from_utf8_lossy(k).into_owned(), v)) + } + _ => None, + }) + .take(256) + .collect(); } let Ok(catalog) = self.doc.catalog() else { return }; info.tagged = catalog @@ -623,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) { @@ -663,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); @@ -678,6 +726,12 @@ impl<'a> Inspector<'a> { } info.has_javascript |= names.get(b"JavaScript").is_ok(); } + if let Ok(action) = catalog.get(b"OpenAction") + && let Some(dict) = self.dict(action) + && dict.get(b"S").ok().and_then(|o| self.resolve(o).as_name().ok()) == Some(&b"JavaScript"[..]) + { + info.has_javascript = true; + } } // ── object helpers ────────────────────────────────────────────────────────────────────── @@ -729,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) } @@ -1123,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, @@ -1141,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, }); } } @@ -1402,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 }); } } @@ -1614,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] @@ -2302,6 +2499,27 @@ trailer << /Root 1 0 R >> ); } + const CUSTOM_INFO: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R >> endobj +4 0 obj << /Title (Report) /Producer (Test) /Project (Apollo) /Revision 7 /Dept (R&D) /Trapped /False /Note >> endobj +trailer << /Root 1 0 R /Info 4 0 R >> +%%EOF"; + + #[test] + fn custom_document_properties_are_listed_apart_from_the_standard_ones() { + let info = inspect(Arc::new(CUSTOM_INFO.to_vec()), None).expect("opens"); + assert_eq!((info.title.as_deref(), info.producer.as_deref()), (Some("Report"), Some("Test"))); + let mut custom = info.custom.clone(); + custom.sort(); + // Revision is a number, not text, so it is not listed; Trapped is a standard entry. + assert_eq!( + custom, + [("Dept".to_string(), "R&D".to_string()), ("Note".to_string(), "部".to_string()), ("Project".to_string(), "Apollo".to_string())] + ); + } + #[test] fn radio_button_layer_groups_are_read() { let info = inspect(Arc::new(LAYERS.to_vec()), None).expect("opens"); @@ -2311,6 +2529,24 @@ trailer << /Root 1 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 @@ -2392,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. @@ -2616,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