diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac276f13c9..9d168c7aa6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,9 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - name: Windows font notice regressions + shell: pwsh + run: ./packaging/windows/test-font-notices.ps1 - name: Git dependencies come from storytold run: | set -euo pipefail @@ -73,7 +76,7 @@ jobs: # the org's 5 macOS slots are kept for release builds, and release.yml builds macOS. os: ${{ (github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'full-ci')) && '["ubuntu-24.04","windows-latest"]' || '["ubuntu-24.04"]' }} # Same craft-fonts commit as release.yml and freebsd.yml. - craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 # egui/winit/rfd, and the independent oracles (crates/oracle). packages: >- libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index fba20b1cc7..268da70ca1 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -26,4 +26,4 @@ jobs: # 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/freebsd.yml@main with: - craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c6ee0e1c31..a5c2ba7f7f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,7 @@ jobs: with: app-name: pdfcraft app-title: PdfCraft - craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 + craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 version: ${{ inputs.version }} secrets: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 7ca1124ce2..c5be9d7bc1 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -4,37 +4,38 @@ 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 the SHA-256 of every asset but the translation catalogs, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE). -## In this repository (288) +## In this repository (289) | 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice 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; 2026-10-22 secured-document notice 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; 2026-10-22 secured-document notice 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; 2026-10-22 secured-document notice labels 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; 2026-10-22 secured-document notice labels 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; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice 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; 2026-10-22 secured-document notice 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; 2026-10-22 secured-document notice 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; 2026-10-22 secured-document notice 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; 2026-10-22 secured-document notice 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; 2026-10-22 secured-document notice 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; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice 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; 2026-10-22 secured-document notice 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; 2026-10-22 secured-document notice 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; 2026-10-22 secured-document notice 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; 2026-10-22 secured-document notice 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; 2026-10-22 secured-document notice 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; 2026-10-22 secured-document notice 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; 2026-10-22 secured-document notice 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; 2026-10-22 secured-document notice 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; 2026-10-22 secured-document notice 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; 2026-10-22 secured-document notice 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; 2026-10-22 secured-document notice 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; 2026-10-22 secured-document notice 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; 2026-10-22 secured-document notice 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; 2026-10-22 secured-document notice 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) 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 | +| `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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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/fa.tsv` | Persian (Farsi) 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-25 initial catalog covering the current 2,776-entry key union, AI-assisted translations from English, not separately native-reviewed | Persian 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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 | @@ -285,26 +286,27 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `packaging/macos/dmg/background.tiff` | PdfCraft macOS DMG window background (rendered) | @XusBadia | MIT OR Apache-2.0 | Rendered from packaging/macos/dmg/background.svg by packaging/macos/dmg/generate.py | 1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packaging/macos/package.sh; includes the app icon and the free LittleCMS built-in sRGB profile | | `crates/sign/tests/data/x509-rsa-sha1.pdf` | One-page PDF with a legacy adbe.x509.rsa_sha1 signature by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl dgst -sha1 -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/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/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; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice 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; 2026-10-22 secured-document notice 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 | +| `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; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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; 2026-10-22 secured-document notice 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 | +| `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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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; 2026-10-22 secured-document notice 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; 2026-10-22 secured-document notice 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; 2026-10-22 secured-document notice 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 | +| `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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Catalan UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -## Compiled in through dependencies (5) +## Compiled in through dependencies (6) | Crate | File | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---|---| -| `epaint_default_fonts` 0.36.2 | `fonts/Ubuntu-Light.ttf` | Ubuntu Light | Canonical Ltd. / Dalton Maag | Ubuntu-font-1.0 | https://design.ubuntu.com/font | egui fallback fonts (eframe feature default_fonts) | -| `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) | +| `epaint_default_fonts` 0.37.0 | `fonts/Ubuntu-Light.ttf` | Ubuntu Light | Canonical Ltd. / Dalton Maag | Ubuntu-font-1.0 | https://design.ubuntu.com/font | egui fallback fonts (eframe feature default_fonts) | +| `epaint_default_fonts` 0.37.0 | `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.37.0 | `fonts/NotoEmoji-Regular.ttf` | Noto Emoji (monochrome) | Google Inc. | OFL-1.1 | https://github.com/googlefonts/noto-emoji | egui monochrome fallback font (egui feature monochrome_emoji_fonts) | +| `epaint_default_fonts` 0.37.0 | `fonts/emoji-icon-font.ttf` | emoji-icon-font | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | egui monochrome fallback font (egui feature monochrome_emoji_fonts) | +| `epaint_default_fonts` 0.37.0 | `fonts/egui-icons.ttf` | egui icons (monochrome emoji-icon-font subset) | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | Small special-icon subset embedded by egui default_fonts; retained in FontDefinitions::default(). Provenance: https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; complete MIT copyright and permission text in NOTICE. | | `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) @@ -342,4 +344,4 @@ Not in this repository and never downloaded by it: compiled in only when the bui | Input | Option | Title | Author | Licence | Source | Attribution | Used for | |---|---|---|---|---|---|---|---| -| `craft-fonts` | `CRAFT_FONTS_DIR` | Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; Arabic font: Noto Sans Arabic | The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), The Noto Project Authors (Noto Sans Arabic) | OFL-1.1 | https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds only BIZ UDPGothic Regular and Noto Sans Arabic. The checkout also holds Noto Sans CJK SC, which crates/fonts/build.rs leaves out for size | +| `craft-fonts` | `CRAFT_FONTS_DIR` | Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; native Simplified Chinese font: LXGW WenKai Lite; Arabic font: Noto Sans Arabic | The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), LXGW and the original Klee Project Authors / Fontworks (LXGW WenKai Lite), The Noto Project Authors (Noto Sans Arabic) | OFL-1.1 | https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml and .github/workflows/ci.yml); LXGW WenKai Lite from https://github.com/lxgw/LxgwWenKai-Lite/releases/tag/v1.530 (commit b712c83d98acf977bacfb7fb010207802808bda9) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Simplified Chinese interface fallback on native builds only (LXGW WenKai Lite Regular, font SHA-256 f799a538a3355877e597a0ae04050e897932c9ae7689eb9227c2953c5dd549ea, OFL SHA-256 0333a85fe077b6849f8e417acc44e8bd879442ceb681e271acf93e2d57fd4e2e, read from fonts/simplified-chinese-manifest.txt and never embedded on wasm); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds BIZ UDPGothic Regular and the pinned small Arabic, Hebrew, Thai and Devanagari faces; the selected craft-fonts revision contains no Telugu face. Other non-Japanese CJK faces retain the general 4 MiB cap; the reviewed native-only LXGW fallback has a separate 16 MiB cap. Open-source OFL releases are permitted under current AGENTS.md §1.1 | diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 9ca337e396..8b2186d5b4 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -2,7 +2,7 @@ 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -26,7 +26,7 @@ usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +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 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -36,7 +36,7 @@ usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/m 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +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 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -46,7 +46,7 @@ usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/ 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; 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -56,7 +56,7 @@ usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" 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; 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -66,7 +66,7 @@ usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n 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; 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; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +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; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -76,7 +76,7 @@ usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +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 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -86,7 +86,7 @@ usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +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 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -96,7 +96,7 @@ usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -106,7 +106,7 @@ usage = "Lao UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +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 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -116,7 +116,7 @@ usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +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 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -126,7 +126,7 @@ usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +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 wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -136,7 +136,7 @@ usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +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 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -146,7 +146,7 @@ usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +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 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -156,7 +156,7 @@ usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +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 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -166,7 +166,7 @@ usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -176,7 +176,7 @@ usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +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 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -186,7 +186,7 @@ usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +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 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -196,7 +196,7 @@ usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +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 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -206,17 +206,27 @@ usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, 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 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation 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" +[[asset]] +path = "crates/ui-egui/src/i18n/fa.tsv" +title = "Persian (Farsi) interface translation catalog" +author = "PdfCraft contributors" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-25 initial catalog covering the current 2,776-entry key union, AI-assisted translations from English, not separately native-reviewed" +licence = "MIT OR Apache-2.0" +licence_file = "LICENSE-MIT" +kind = "translation" +usage = "Persian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" + [[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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +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 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -226,7 +236,7 @@ usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -236,7 +246,7 @@ usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" path = "crates/ui-egui/src/i18n/hi.tsv" title = "Hindi interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -245,7 +255,7 @@ usage = "Hindi UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -254,7 +264,7 @@ usage = "Indonesian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -264,7 +274,7 @@ usage = "Korean UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -275,7 +285,7 @@ usage = "Romanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed" +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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -2429,7 +2439,7 @@ sha256 = "f225e0b044875b8fa6375b6b5ad935f0d99fcc1ad03ecf9f0baae2cedbfca4d2" [[bundled]] crate = "epaint_default_fonts" -version = "0.36.2" +version = "0.37.0" path = "fonts/Ubuntu-Light.ttf" title = "Ubuntu Light" author = "Canonical Ltd. / Dalton Maag" @@ -2441,7 +2451,7 @@ sha256 = "80307b8da7649aa4ee4d484b232140e3ce1ec0ca093073d3c53c8f5a5ced7a70" [[bundled]] crate = "epaint_default_fonts" -version = "0.36.2" +version = "0.37.0" path = "fonts/Hack-Regular.ttf" title = "Hack Regular" author = "Source Foundry Authors (derived from Bitstream Vera / DejaVu)" @@ -2453,28 +2463,40 @@ sha256 = "15f55cc0c85a2988d2b4b3a8cdb5d77fdfbaf319e1bb5309d725db9818fb7125" [[bundled]] crate = "epaint_default_fonts" -version = "0.36.2" +version = "0.37.0" path = "fonts/NotoEmoji-Regular.ttf" title = "Noto Emoji (monochrome)" author = "Google Inc." source = "https://github.com/googlefonts/noto-emoji" licence = "OFL-1.1" kind = "font" -usage = "egui fallback fonts (eframe feature default_fonts)" +usage = "egui monochrome fallback font (egui feature monochrome_emoji_fonts)" sha256 = "415dc6290378574135b64c808dc640c1df7531973290c4970c51fdeb849cb0c5" [[bundled]] crate = "epaint_default_fonts" -version = "0.36.2" +version = "0.37.0" path = "fonts/emoji-icon-font.ttf" title = "emoji-icon-font" author = "John Slegers" source = "https://github.com/jslegers/emoji-icon-font" licence = "MIT" kind = "font" -usage = "egui fallback fonts (eframe feature default_fonts)" +usage = "egui monochrome fallback font (egui feature monochrome_emoji_fonts)" sha256 = "f426bec371f484646d002717e2c555a95cd5145e635d83a48bbef83f3abf18ca" +[[bundled]] +crate = "epaint_default_fonts" +version = "0.37.0" +path = "fonts/egui-icons.ttf" +title = "egui icons (monochrome emoji-icon-font subset)" +author = "John Slegers" +source = "https://github.com/jslegers/emoji-icon-font" +licence = "MIT" +kind = "font" +usage = "Small special-icon subset embedded by egui default_fonts; retained in FontDefinitions::default(). Provenance: https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; complete MIT copyright and permission text in NOTICE." +sha256 = "122806555c73df7cef55aa1d945289ae27fbc4dbbd31abbb4204ee0cda829cc8" + [[asset]] path = "vendor/hayro-cmap/assets/cmaps.brotli" title = "Adobe CMap resources (predefined CJK CMaps)" @@ -3362,13 +3384,13 @@ sha256 = "e484866d4cce403175bd8d00b128feb08ab42e208de30e42cd9889d8f1735a6e" [[build_input]] name = "craft-fonts" option = "CRAFT_FONTS_DIR" -title = "Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; Arabic font: Noto Sans Arabic" -author = "The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), The Noto Project Authors (Noto Sans Arabic)" -source = "https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml)" +title = "Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; native Simplified Chinese font: LXGW WenKai Lite; Arabic font: Noto Sans Arabic" +author = "The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), LXGW and the original Klee Project Authors / Fontworks (LXGW WenKai Lite), The Noto Project Authors (Noto Sans Arabic)" +source = "https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml and .github/workflows/ci.yml); LXGW WenKai Lite from https://github.com/lxgw/LxgwWenKai-Lite/releases/tag/v1.530 (commit b712c83d98acf977bacfb7fb010207802808bda9)" licence = "OFL-1.1" attribution = "https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md" kind = "font" -usage = "Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds only BIZ UDPGothic Regular and Noto Sans Arabic. The checkout also holds Noto Sans CJK SC, which crates/fonts/build.rs leaves out for size" +usage = "Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Simplified Chinese interface fallback on native builds only (LXGW WenKai Lite Regular, font SHA-256 f799a538a3355877e597a0ae04050e897932c9ae7689eb9227c2953c5dd549ea, OFL SHA-256 0333a85fe077b6849f8e417acc44e8bd879442ceb681e271acf93e2d57fd4e2e, read from fonts/simplified-chinese-manifest.txt and never embedded on wasm); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds BIZ UDPGothic Regular and the pinned small Arabic, Hebrew, Thai and Devanagari faces; the selected craft-fonts revision contains no Telugu face. Other non-Japanese CJK faces retain the general 4 MiB cap; the reviewed native-only LXGW fallback has a separate 16 MiB cap. Open-source OFL releases are permitted under current AGENTS.md §1.1" [[asset]] path = "crates/sign/tests/data/x509-rsa-sha1.pdf" @@ -3396,7 +3418,7 @@ sha256 = "d1a443233345724859de8e4fb48968ba91e885e12ddbd710701548bd91b39428" path = "crates/ui-egui/src/i18n/vi.tsv" title = "Vietnamese interface translation catalog" author = "PdfCraft contributors" -source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -3406,7 +3428,7 @@ usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +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 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -3428,7 +3450,7 @@ sha256 = "78c59a0cfd2c829909d77006f94e34dac1ae5d1f90e7f10f72e84de541fef697" 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, 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 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -3482,7 +3504,7 @@ sha256 = "864ab2f19254e6ac7240bc6a98323197332e8f714d4d3f94831f41309e5ef2f7" 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, 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 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -3492,7 +3514,7 @@ usage = "Albanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, 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 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" @@ -3502,7 +3524,7 @@ usage = "Thai UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" 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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed" +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; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" diff --git a/CLAUDE.md b/CLAUDE.md index 085e8356f6..f51a96d56e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,6 +21,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado - See `plan/README.md` §Clean-room and `plan/adr/0001`. - **Privacy.** When observing Acrobat, use synthetic fixtures only. Never capture the Home view, recent files or account info. Capture by window id (`plan/acrobat/tools/`). Never commit Acrobat outputs, corpus files or personal data. - **Layering.** Nothing below L7 depends on egui/winit/eframe/rfd (`plan/architecture.md` §3). `cos`/`filters`/`crypt`/`arlington` stay standalone. +- **Page input.** Page modules read `PagePointer` (`crates/ui-egui/src/page_pointer.rs`), never egui's raw pointer state (`hover_pos`, `interact_pos`, `latest_pos`, `press_origin`, button reads); `page_pointer::tests::page_modules_read_the_pointer_only_through_page_pointer` enforces it. - **Fidelity.** The PDF object graph is the model. Preserve unknown data. Saves are incremental unless a full rewrite is required. Never silently drop or repair data without recording it. - **Never panic.** Every PDF, script, MCP call, settings file and keystroke is untrusted input, and none of it may crash the app or lose the user's work. This outranks feature work. Return `Result` (the crate's error enum) and propagate with `?`, or fall back leniently and record it. In non-test code: - No `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` unless it is provably infallible, with a comment saying why. diff --git a/Cargo.lock b/Cargo.lock index 9072b1042e..7957416fe3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "accesskit" -version = "0.24.1" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932" dependencies = [ "enumn", "serde", @@ -15,57 +15,51 @@ dependencies = [ [[package]] name = "accesskit_atspi_common" -version = "0.18.1" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +checksum = "52c182f9c282ac9c5638d876d551d15e5f7d397ec263349a0c6a2b61595dd5e4" dependencies = [ "accesskit", - "accesskit_consumer 0.36.0", + "accesskit_consumer", "atspi-common", - "phf 0.13.1", + "phf 0.14.0", "serde", "zvariant", ] [[package]] name = "accesskit_consumer" -version = "0.35.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344" dependencies = [ "accesskit", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] -name = "accesskit_consumer" -version = "0.36.0" +name = "accesskit_ios" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937" dependencies = [ "accesskit", - "hashbrown 0.16.1", -] - -[[package]] -name = "accesskit_consumer" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" -dependencies = [ - "accesskit", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", ] [[package]] name = "accesskit_macos" -version = "0.26.3" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4" dependencies = [ "accesskit", - "accesskit_consumer 0.38.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -73,9 +67,9 @@ dependencies = [ [[package]] name = "accesskit_unix" -version = "0.21.1" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +checksum = "202f24df034a7476d07b7f74284de84f6d62aabd858dbe7ee9cad3b7ad6f8f9d" dependencies = [ "accesskit", "accesskit_atspi_common", @@ -91,13 +85,13 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.32.1" +version = "0.35.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +checksum = "ce63f35d6bdcf59f26b76b3379063f738e6412cef46999cc772d46aa3de35adb" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "static_assertions", "windows", "windows-core", @@ -105,11 +99,12 @@ dependencies = [ [[package]] name = "accesskit_winit" -version = "0.32.2" +version = "0.34.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +checksum = "ee96dd150375c05912d8a473f98c2fb155fb3234fac88292d396548a2460fa77" dependencies = [ "accesskit", + "accesskit_ios", "accesskit_macos", "accesskit_unix", "accesskit_windows", @@ -220,21 +215,22 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arboard" -version = "3.6.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917" dependencies = [ "clipboard-win", "image", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", + "windows-sys 0.59.0", + "wl-clipboard-rs", "x11rb", ] @@ -447,9 +443,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.18.1" +version = "1.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -458,9 +454,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" dependencies = [ "cc", "cmake", @@ -493,15 +489,6 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec 0.8.0", -] - [[package]] name = "bit-set" version = "0.10.0" @@ -512,10 +499,13 @@ dependencies = [ ] [[package]] -name = "bit-vec" -version = "0.8.0" +name = "bit-set" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c" +dependencies = [ + "bit-vec 0.10.1", +] [[package]] name = "bit-vec" @@ -523,6 +513,16 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +[[package]] +name = "bit-vec" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269" +dependencies = [ + "borsh", + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -580,7 +580,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -649,7 +649,7 @@ dependencies = [ "oneshot", "pastey", "portable-atomic", - "rand 0.10.3", + "rand", "regress", "rustc-hash 2.1.3", "ryu-js", @@ -740,6 +740,30 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "bp256" version = "0.14.0" @@ -1147,11 +1171,35 @@ name = "craft-docpath" version = "0.1.0" source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" +[[package]] +name = "craft-keymap" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb" +dependencies = [ + "egui", + "serde", + "serde_json", +] + +[[package]] +name = "craft-menu" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb" +dependencies = [ + "craft-keymap", + "craft-quit", + "egui", + "muda", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", +] + [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=666c358065261a2816b376fa3bb10538dbf53040#666c358065261a2816b376fa3bb10538dbf53040" +source = "git+https://github.com/storytold/craft-libs?rev=42e30cd5b3f4b114270dd053fccbc33bedc47f68#42e30cd5b3f4b114270dd053fccbc33bedc47f68" dependencies = [ + "craft-winprint-gdi", "miniz_oxide 0.8.9", "roxmltree", "tempfile", @@ -1162,24 +1210,60 @@ dependencies = [ [[package]] name = "craft-quit" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=f58bc1a3df490992d633393d0898af0588ce882c#f58bc1a3df490992d633393d0898af0588ce882c" +source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb" dependencies = [ "dispatch2", "egui", - "muda", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", ] [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675" +source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb" dependencies = [ "egui", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "serde", ] +[[package]] +name = "craft-update" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +dependencies = [ + "chrono", + "dunce", + "egui", + "flate2", + "getrandom 0.3.4", + "log", + "plist", + "semver", + "serde", + "serde_json", + "sha2 0.10.9", + "tar", + "tempfile", + "ureq", + "verifysign", + "winreg", + "winsafe", + "zip", +] + +[[package]] +name = "craft-winprint-gdi" +version = "0.1.0" +source = "git+https://github.com/storytold/winprint.rs?rev=417ac3c4d14d3da5485a99c7d5ce8f05664ec312#417ac3c4d14d3da5485a99c7d5ce8f05664ec312" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -1439,7 +1523,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.6.2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -1535,9 +1619,8 @@ dependencies = [ [[package]] name = "ecolor" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "emath", @@ -1561,16 +1644,15 @@ checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" dependencies = [ "curve25519-dalek", "ed25519", - "sha2 0.11.0", + "sha2 0.11.1", "subtle", "zeroize", ] [[package]] name = "eframe" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1585,7 +1667,7 @@ dependencies = [ "image", "js-sys", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "parking_lot", @@ -1594,6 +1676,7 @@ dependencies = [ "profiling", "raw-window-handle", "ron", + "send_wrapper", "serde", "static_assertions", "wasm-bindgen", @@ -1606,9 +1689,8 @@ dependencies = [ [[package]] name = "egui" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit", "ahash", @@ -1628,7 +1710,8 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.36.2" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1646,32 +1729,35 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit_winit", "arboard", + "block2 0.6.2", "bytemuck", "egui", + "image", "log", - "objc2 0.6.4", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", + "parking_lot", "profiling", "raw-window-handle", "serde", "smithay-clipboard", "web-time", "webbrowser", + "windows-sys 0.61.2", "winit", ] [[package]] name = "egui_extras" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05cbdc785020861553f230f23d330ba1fb72613b9d8e0e457f3bdc8ba6db6dcf" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "egui", @@ -1684,9 +1770,8 @@ dependencies = [ [[package]] name = "egui_glow" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "egui", @@ -1698,9 +1783,8 @@ dependencies = [ [[package]] name = "egui_kittest" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "eframe", "egui", @@ -1742,9 +1826,8 @@ dependencies = [ [[package]] name = "emath" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "serde", @@ -1772,22 +1855,22 @@ checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" [[package]] name = "enum-map" -version = "2.7.3" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" +checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06" dependencies = [ "enum-map-derive", ] [[package]] name = "enum-map-derive" -version = "0.17.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" +checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] @@ -1824,9 +1907,8 @@ dependencies = [ [[package]] name = "epaint" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1843,16 +1925,17 @@ dependencies = [ "serde", "skrifa 0.44.0", "smallvec", + "unicode-bidi", "unicode-general-category", + "unicode-properties", "unicode-segmentation", - "vello_cpu 0.1.0", + "vello_cpu 0.2.0", ] [[package]] name = "epaint_default_fonts" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" [[package]] name = "equator" @@ -1887,7 +1970,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -2057,7 +2140,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", ] @@ -2068,6 +2151,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -2318,7 +2407,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "log", "peniko", @@ -2330,18 +2419,18 @@ dependencies = [ [[package]] name = "glifo" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" +checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "log", "peniko", "skrifa 0.44.0", "smallvec", - "vello_common 0.1.0", + "vello_common 0.2.0", ] [[package]] @@ -2370,7 +2459,7 @@ dependencies = [ "glutin_glx_sys", "glutin_wgl_sys", "libloading", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -2505,6 +2594,15 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -2513,7 +2611,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -2524,7 +2622,7 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -3065,12 +3163,12 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kittest" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5" +checksum = "0bb81a721ba76aa095b7f45deb180504f786904e56ca4b8e141f66eb89a78b75" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", + "accesskit_consumer", ] [[package]] @@ -3193,10 +3291,10 @@ dependencies = [ "log", "md-5", "nom", - "rand 0.10.3", + "rand", "rangemap", "rayon", - "sha2 0.11.0", + "sha2 0.11.1", "stringprep", "thiserror 2.0.21", "weezl 0.2.1", @@ -3298,13 +3396,13 @@ dependencies = [ "crossbeam-channel", "dpi", "keyboard-types", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", "png 0.18.1", "thiserror 2.0.21", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3381,6 +3479,18 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nohash-hasher" version = "0.2.0" @@ -3491,9 +3601,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" dependencies = [ "objc2-encode", ] @@ -3522,7 +3632,7 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -3572,7 +3682,7 @@ checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.13.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -3583,7 +3693,7 @@ checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ "bitflags 2.13.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-io-surface", ] @@ -3639,7 +3749,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -3650,7 +3760,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -3686,7 +3796,7 @@ checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", ] @@ -3710,7 +3820,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -3755,7 +3865,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", ] @@ -3846,6 +3956,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "p256" version = "0.14.0" @@ -3856,7 +3976,7 @@ dependencies = [ "elliptic-curve", "primefield", "primeorder", - "sha2 0.11.0", + "sha2 0.11.1", ] [[package]] @@ -3870,7 +3990,7 @@ dependencies = [ "fiat-crypto", "primefield", "primeorder", - "sha2 0.11.0", + "sha2 0.11.1", ] [[package]] @@ -3884,7 +4004,7 @@ dependencies = [ "elliptic-curve", "primefield", "primeorder", - "sha2 0.11.0", + "sha2 0.11.1", ] [[package]] @@ -3934,10 +4054,10 @@ dependencies = [ [[package]] name = "pdfcraft" -version = "0.13.0" +version = "0.14.0" dependencies = [ "chrono", - "craft-quit", + "craft-update", "eframe", "egui", "fmv-macos-events", @@ -3946,10 +4066,8 @@ dependencies = [ "pdfcraft-ui-egui", "pollster 1.0.1", "raw-window-handle", - "rustls-native-certs", "serde_json", "uds_windows", - "ureq", "wayland-client", "wayland-protocols", "windows", @@ -3960,7 +4078,7 @@ dependencies = [ [[package]] name = "pdfcraft-a11y" -version = "0.13.0" +version = "0.14.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -3972,7 +4090,7 @@ dependencies = [ [[package]] name = "pdfcraft-annot" -version = "0.13.0" +version = "0.14.0" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -3983,7 +4101,7 @@ dependencies = [ [[package]] name = "pdfcraft-audit" -version = "0.13.0" +version = "0.14.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -3994,9 +4112,9 @@ dependencies = [ [[package]] name = "pdfcraft-automation" -version = "0.13.0" +version = "0.14.0" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "flate2", "image", "pdfcraft-cos", @@ -4010,15 +4128,15 @@ dependencies = [ "png 0.18.1", "rustix 1.1.5", "serde_json", - "sha2 0.11.0", + "sha2 0.11.1", "tempfile", ] [[package]] name = "pdfcraft-cli" -version = "0.13.0" +version = "0.14.0" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "pdfcraft-audit", "pdfcraft-automation", "pdfcraft-cos", @@ -4034,11 +4152,11 @@ dependencies = [ [[package]] name = "pdfcraft-compare" -version = "0.13.0" +version = "0.14.0" [[package]] name = "pdfcraft-content" -version = "0.13.0" +version = "0.14.0" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -4046,7 +4164,7 @@ dependencies = [ [[package]] name = "pdfcraft-cos" -version = "0.13.0" +version = "0.14.0" dependencies = [ "chrono", "hayro-syntax", @@ -4061,7 +4179,7 @@ dependencies = [ [[package]] name = "pdfcraft-create" -version = "0.13.0" +version = "0.14.0" dependencies = [ "encoding_rs", "hayro-syntax", @@ -4077,19 +4195,19 @@ dependencies = [ [[package]] name = "pdfcraft-crypt" -version = "0.13.0" +version = "0.14.0" dependencies = [ "aes", "md-5", "proptest", - "sha2 0.11.0", + "sha2 0.11.1", "stringprep", "thiserror 2.0.21", ] [[package]] name = "pdfcraft-edit" -version = "0.13.0" +version = "0.14.0" dependencies = [ "hayro-syntax", "kurbo 0.13.1", @@ -4105,7 +4223,7 @@ dependencies = [ [[package]] name = "pdfcraft-engine" -version = "0.13.0" +version = "0.14.0" dependencies = [ "chrono", "craft-docpath", @@ -4146,14 +4264,14 @@ dependencies = [ [[package]] name = "pdfcraft-export" -version = "0.13.0" +version = "0.14.0" dependencies = [ "flate2", ] [[package]] name = "pdfcraft-filters" -version = "0.13.0" +version = "0.14.0" dependencies = [ "flate2", "proptest", @@ -4163,7 +4281,7 @@ dependencies = [ [[package]] name = "pdfcraft-fonts" -version = "0.13.0" +version = "0.14.0" dependencies = [ "harfrust", "pdfcraft-cos", @@ -4173,7 +4291,7 @@ dependencies = [ [[package]] name = "pdfcraft-forms" -version = "0.13.0" +version = "0.14.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4186,11 +4304,11 @@ dependencies = [ [[package]] name = "pdfcraft-geom" -version = "0.13.0" +version = "0.14.0" [[package]] name = "pdfcraft-js" -version = "0.13.0" +version = "0.14.0" dependencies = [ "boa_engine", "chrono", @@ -4198,7 +4316,7 @@ dependencies = [ [[package]] name = "pdfcraft-measure" -version = "0.13.0" +version = "0.14.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4211,14 +4329,14 @@ dependencies = [ [[package]] name = "pdfcraft-model" -version = "0.13.0" +version = "0.14.0" dependencies = [ "pdfcraft-cos", ] [[package]] name = "pdfcraft-ocr" -version = "0.13.0" +version = "0.14.0" dependencies = [ "ocrs", "pdfcraft-fonts", @@ -4228,7 +4346,7 @@ dependencies = [ [[package]] name = "pdfcraft-optimize" -version = "0.13.0" +version = "0.14.0" dependencies = [ "image", "pdfcraft-annot", @@ -4243,11 +4361,11 @@ dependencies = [ [[package]] name = "pdfcraft-oracle" -version = "0.13.0" +version = "0.14.0" [[package]] name = "pdfcraft-organize" -version = "0.13.0" +version = "0.14.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4258,7 +4376,7 @@ dependencies = [ [[package]] name = "pdfcraft-platform" -version = "0.13.0" +version = "0.14.0" dependencies = [ "async-channel", "futures-lite", @@ -4268,7 +4386,7 @@ dependencies = [ [[package]] name = "pdfcraft-preflight" -version = "0.13.0" +version = "0.14.0" dependencies = [ "flate2", "pdfcraft-content", @@ -4279,7 +4397,7 @@ dependencies = [ [[package]] name = "pdfcraft-print" -version = "0.13.0" +version = "0.14.0" dependencies = [ "craft-print", "pdfcraft-content", @@ -4291,7 +4409,7 @@ dependencies = [ [[package]] name = "pdfcraft-redact" -version = "0.13.0" +version = "0.14.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4307,7 +4425,7 @@ dependencies = [ [[package]] name = "pdfcraft-render" -version = "0.13.0" +version = "0.14.0" dependencies = [ "bytemuck", "flate2", @@ -4326,11 +4444,11 @@ dependencies = [ [[package]] name = "pdfcraft-sign" -version = "0.13.0" +version = "0.14.0" dependencies = [ "aes", "aws-lc-rs", - "base64 0.22.1", + "base64 0.23.1", "bp256", "bp384", "cbc", @@ -4355,26 +4473,29 @@ dependencies = [ "rustls-cng", "security-framework", "sha1 0.11.0", - "sha2 0.11.0", + "sha2 0.11.1", "sha3", "thiserror 2.0.21", ] [[package]] name = "pdfcraft-testkit" -version = "0.13.0" +version = "0.14.0" [[package]] name = "pdfcraft-ui-egui" -version = "0.13.0" +version = "0.14.0" dependencies = [ "arboard", - "base64 0.22.1", + "base64 0.23.1", "block2 0.6.2", "bytemuck", "chrono", + "craft-keymap", + "craft-menu", "craft-quit", "craft-ui", + "craft-update", "eframe", "egui", "egui_extras", @@ -4384,7 +4505,7 @@ dependencies = [ "image", "js-sys", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "pdfcraft-cos", @@ -4408,13 +4529,14 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "web-time", "winsafe", "xattr", ] [[package]] name = "pdfcraft-web" -version = "0.13.0" +version = "0.14.0" dependencies = [ "eframe", "egui", @@ -4426,21 +4548,21 @@ dependencies = [ [[package]] name = "pdfcraft-xfa" -version = "0.13.0" +version = "0.14.0" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "image", "pdfcraft-cos", "pdfcraft-fonts", "png 0.18.1", "roxmltree", - "sha2 0.11.0", + "sha2 0.11.1", "thiserror 2.0.21", ] [[package]] name = "pdfcraft-xfdf" -version = "0.13.0" +version = "0.14.0" dependencies = [ "pdfcraft-annot", "pdfcraft-cos", @@ -4497,6 +4619,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + [[package]] name = "phf" version = "0.13.1" @@ -4505,7 +4638,6 @@ checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ "phf_macros 0.13.1", "phf_shared 0.13.1", - "serde", ] [[package]] @@ -4516,6 +4648,7 @@ checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ "phf_macros 0.14.0", "phf_shared 0.14.0", + "serde", ] [[package]] @@ -4669,9 +4802,9 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "plist" -version = "1.10.2" +version = "1.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65dca778f41c79ebbe8b2b2139fee86f01b766b67130d108c7c2b8ffbd949f77" +checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285" dependencies = [ "base64 0.23.1", "indexmap", @@ -4771,15 +4904,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "presser" version = "0.3.1" @@ -4863,16 +4987,17 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" [[package]] name = "proptest" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0" dependencies = [ - "bit-set 0.8.0", - "bit-vec 0.8.0", + "bit-set 0.11.1", + "bit-vec 0.10.1", "bitflags 2.13.2", + "chacha20", + "core_detect", "num-traits", - "rand 0.9.5", - "rand_chacha", + "rand", "rand_xorshift", "regex-syntax", "rusty-fork", @@ -4946,7 +5071,7 @@ dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand 0.10.3", + "rand", "rand_pcg", "ring", "rustc-hash 2.1.3", @@ -4970,7 +5095,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -4994,16 +5119,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" -dependencies = [ - "rand_chacha", - "rand_core 0.9.5", -] - [[package]] name = "rand" version = "0.10.3" @@ -5015,31 +5130,12 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - [[package]] name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - [[package]] name = "rand_core" version = "0.10.1" @@ -5057,11 +5153,11 @@ dependencies = [ [[package]] name = "rand_xorshift" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" dependencies = [ - "rand_core 0.9.5", + "rand_core 0.10.1", ] [[package]] @@ -5088,7 +5184,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", "objc2-quartz-core 0.3.2", @@ -5281,7 +5377,7 @@ dependencies = [ "js-sys", "libc", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -5507,7 +5603,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5520,7 +5616,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -5552,9 +5648,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -5590,7 +5686,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -5719,6 +5815,16 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" [[package]] name = "serde" @@ -5752,9 +5858,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -5851,9 +5957,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ "cfg-if", "cpufeatures 0.3.1", @@ -6009,7 +6115,7 @@ dependencies = [ "const-oid 0.9.6", "der 0.7.10", "hex", - "rand 0.10.3", + "rand", "reqwest", "rustls-pki-types", "rustls-webpki", @@ -6245,7 +6351,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6464,10 +6570,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -6801,6 +6907,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -6816,6 +6933,12 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "typed-path" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e" + [[package]] name = "typeid" version = "1.0.3" @@ -6836,7 +6959,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -6904,24 +7027,26 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" -version = "3.4.2" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b" dependencies = [ "base64 0.23.1", + "flate2", "log", "percent-encoding", "rustls", "rustls-pki-types", + "rustls-platform-verifier", "ureq-proto", "utf8-zero", ] [[package]] name = "ureq-proto" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" +checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" dependencies = [ "base64 0.23.1", "http", @@ -7026,9 +7151,9 @@ dependencies = [ [[package]] name = "vello_common" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" +checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd" dependencies = [ "bytemuck", "fearless_simd", @@ -7054,14 +7179,24 @@ dependencies = [ [[package]] name = "vello_cpu" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" +checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" dependencies = [ "bytemuck", - "glifo 0.2.0", + "glifo 0.3.0", "hashbrown 0.17.1", - "vello_common 0.1.0", + "vello_common 0.2.0", +] + +[[package]] +name = "verifysign" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6acfcc42a8d44f340d001193d97ef56bf33210b51ad760ae8308c97d5241ca51" +dependencies = [ + "core-foundation 0.10.1", + "windows-sys 0.61.2", ] [[package]] @@ -7333,7 +7468,7 @@ dependencies = [ "jni", "log", "ndk-context", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "url", @@ -7489,7 +7624,7 @@ dependencies = [ "naga", "naga-types", "ndk-sys", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -7550,7 +7685,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -7671,16 +7806,16 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] name = "windows-sys" -version = "0.60.2" +version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -7698,31 +7833,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -7740,99 +7858,52 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winit" version = "0.30.13" +source = "git+https://github.com/storytold/winit.git?rev=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc" dependencies = [ "ahash", "android-activity", @@ -7910,10 +7981,20 @@ dependencies = [ ] [[package]] -name = "winresource" -version = "0.1.31" +name = "winreg" +version = "0.55.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "winresource" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b" dependencies = [ "toml", "version_check", @@ -7931,6 +8012,24 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.5", + "thiserror 2.0.21", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "wnaf" version = "0.14.1" @@ -8077,7 +8176,7 @@ dependencies = [ "png 0.18.1", "serde", "serde_json", - "sha2 0.11.0", + "sha2 0.11.1", "toml", ] @@ -8304,6 +8403,23 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "zip" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704" +dependencies = [ + "crc32fast", + "flate2", + "getrandom 0.4.3", + "indexmap", + "memchr", + "nix", + "typed-path", + "windows-sys 0.61.2", + "zopfli", +] + [[package]] name = "zlib-rs" version = "0.6.8" @@ -8316,6 +8432,18 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zopfli" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zune-core" version = "0.5.3" @@ -8371,3 +8499,18 @@ dependencies = [ "syn 3.0.6", "winnow", ] + +[[patch.unused]] +name = "egui_demo_lib" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_inspection" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_system_font_provider" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" diff --git a/Cargo.toml b/Cargo.toml index e7acfd31f9..8eb38b29af 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.13.0" +version = "0.14.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -13,47 +13,51 @@ repository = "https://github.com/storytold/pdfcraft" [workspace.dependencies] # Shared frontend components; engines remain independent of egui. -craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } +craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb", features = ["serde"] } # Quit the macOS way (#1136): ⌘Q asks about unsaved changes, and closing quits through `terminate:`. -craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "f58bc1a3df490992d633393d0898af0588ce882c" } +craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb" } +# One menu definition for the native macOS menu bar and the in-window bar elsewhere. +craft-menu = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb" } +craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb", default-features = false } # Shared system spooler; document composition remains in pdfcraft-print. -craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "666c358065261a2816b376fa3bb10538dbf53040" } +craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "42e30cd5b3f4b114270dd053fccbc33bedc47f68" } # 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" } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] } # internal -pdfcraft-geom = { path = "crates/geom", version = "0.13.0" } -pdfcraft-filters = { path = "crates/filters", version = "0.13.0" } -pdfcraft-cos = { path = "crates/cos", version = "0.13.0" } -pdfcraft-crypt = { path = "crates/crypt", version = "0.13.0" } -pdfcraft-organize = { path = "crates/organize", version = "0.13.0" } -pdfcraft-measure = { path = "crates/measure", version = "0.13.0" } -pdfcraft-annot = { path = "crates/annot", version = "0.13.0" } -pdfcraft-audit = { path = "crates/audit", version = "0.13.0" } -pdfcraft-fonts = { path = "crates/fonts", version = "0.13.0" } -pdfcraft-model = { path = "crates/model", version = "0.13.0" } -pdfcraft-content = { path = "crates/content", version = "0.13.0" } -pdfcraft-redact = { path = "crates/redact", version = "0.13.0" } -pdfcraft-print = { path = "crates/print", version = "0.13.0" } -pdfcraft-xfdf = { path = "crates/xfdf", version = "0.13.0" } -pdfcraft-sign = { path = "crates/sign", version = "0.13.0" } -pdfcraft-optimize = { path = "crates/optimize", version = "0.13.0" } -pdfcraft-a11y = { path = "crates/a11y", version = "0.13.0" } -pdfcraft-oracle = { path = "crates/oracle", version = "0.13.0" } -pdfcraft-testkit = { path = "crates/testkit", version = "0.13.0" } -pdfcraft-ocr = { path = "crates/ocr", version = "0.13.0" } -pdfcraft-js = { path = "crates/js", version = "0.13.0" } -pdfcraft-compare = { path = "crates/compare", version = "0.13.0" } -pdfcraft-preflight = { path = "crates/preflight", version = "0.13.0" } -pdfcraft-export = { path = "crates/export", version = "0.13.0" } -pdfcraft-edit = { path = "crates/edit", version = "0.13.0" } -pdfcraft-create = { path = "crates/create", version = "0.13.0" } -pdfcraft-forms = { path = "crates/forms", version = "0.13.0" } -pdfcraft-xfa = { path = "crates/xfa", version = "0.13.0" } -pdfcraft-render = { path = "crates/render", version = "0.13.0" } -pdfcraft-engine = { path = "crates/engine", version = "0.13.0" } -pdfcraft-platform = { path = "crates/platform", version = "0.13.0" } -pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.13.0" } -pdfcraft-automation = { path = "crates/automation", version = "0.13.0", default-features = false } +pdfcraft-geom = { path = "crates/geom", version = "0.14.0" } +pdfcraft-filters = { path = "crates/filters", version = "0.14.0" } +pdfcraft-cos = { path = "crates/cos", version = "0.14.0" } +pdfcraft-crypt = { path = "crates/crypt", version = "0.14.0" } +pdfcraft-organize = { path = "crates/organize", version = "0.14.0" } +pdfcraft-measure = { path = "crates/measure", version = "0.14.0" } +pdfcraft-annot = { path = "crates/annot", version = "0.14.0" } +pdfcraft-audit = { path = "crates/audit", version = "0.14.0" } +pdfcraft-fonts = { path = "crates/fonts", version = "0.14.0" } +pdfcraft-model = { path = "crates/model", version = "0.14.0" } +pdfcraft-content = { path = "crates/content", version = "0.14.0" } +pdfcraft-redact = { path = "crates/redact", version = "0.14.0" } +pdfcraft-print = { path = "crates/print", version = "0.14.0" } +pdfcraft-xfdf = { path = "crates/xfdf", version = "0.14.0" } +pdfcraft-sign = { path = "crates/sign", version = "0.14.0" } +pdfcraft-optimize = { path = "crates/optimize", version = "0.14.0" } +pdfcraft-a11y = { path = "crates/a11y", version = "0.14.0" } +pdfcraft-oracle = { path = "crates/oracle", version = "0.14.0" } +pdfcraft-testkit = { path = "crates/testkit", version = "0.14.0" } +pdfcraft-ocr = { path = "crates/ocr", version = "0.14.0" } +pdfcraft-js = { path = "crates/js", version = "0.14.0" } +pdfcraft-compare = { path = "crates/compare", version = "0.14.0" } +pdfcraft-preflight = { path = "crates/preflight", version = "0.14.0" } +pdfcraft-export = { path = "crates/export", version = "0.14.0" } +pdfcraft-edit = { path = "crates/edit", version = "0.14.0" } +pdfcraft-create = { path = "crates/create", version = "0.14.0" } +pdfcraft-forms = { path = "crates/forms", version = "0.14.0" } +pdfcraft-xfa = { path = "crates/xfa", version = "0.14.0" } +pdfcraft-render = { path = "crates/render", version = "0.14.0" } +pdfcraft-engine = { path = "crates/engine", version = "0.14.0" } +pdfcraft-platform = { path = "crates/platform", version = "0.14.0" } +pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.14.0" } +pdfcraft-automation = { path = "crates/automation", version = "0.14.0", default-features = false } # pdf hayro = "0.7.1" @@ -75,11 +79,13 @@ chrono = { version = "0.4.45", default-features = false, features = ["std"] } proptest = "1" # ui -egui = "0.36" -egui_extras = { version = "0.36", default-features = false, features = ["svg"] } -eframe = { version = "0.36", default-features = false } -egui_kittest = "0.36" +egui = "0.37" +egui_extras = { version = "0.37", default-features = false, features = ["svg"] } +eframe = { version = "0.37", default-features = false } +egui_kittest = "0.37" rfd = "0.17" +# Monotonic time for logic-only work on native and web (`App::logic` runs without a UI pass). +web-time = "1.1" [workspace.lints.rust] unsafe_code = "forbid" @@ -118,6 +124,24 @@ hayro-cmap = { path = "vendor/hayro-cmap" } hayro-postscript = { path = "vendor/hayro-postscript" } hayro-jbig2 = { path = "vendor/hayro-jbig2" } lopdf = { path = "vendor/lopdf" } -winit = { path = "vendor/winit" } -egui-wgpu = { path = "vendor/egui-wgpu" } vello_common = { path = "vendor/vello_common" } +# The egui and winit crates below are pinned to immutable storytold git revisions (egui +# 6d518fc799681571963842f95b55a6b68431b15a, winit a517b3846ee8457d84ee4975110bfda6797c44dc). +# Only the retained `vendor/winit` and `vendor/egui-wgpu` copies are provenance for the app's +# earlier patches and their source guards, not path patches; the other `vendor/` entries above +# remain real path patches. See vendor/README.md and the migration plan. +ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" } diff --git a/NOTICE b/NOTICE index 8cacb0b585..63a8fe672c 100644 --- a/NOTICE +++ b/NOTICE @@ -58,3 +58,21 @@ dependencies change (plan/adr/0002, 0006). Vendored (vendor/, patched; see vendor/README.md): - hayro 0.7.1 — Apache-2.0 OR MIT, Copyright the hayro authors (licence files kept in vendor/hayro). - hayro-interpret 0.7.0 — Apache-2.0 OR MIT, Copyright the hayro authors (LICENSE-APACHE, LICENSE-MIT kept in vendor/hayro-interpret). + +Bundled egui font icons + +The pinned egui dependency includes egui-icons.ttf, a small subset of John Slegers' +emoji-icon-font, through default_fonts. The parent emoji-icon-font.ttf face is +included through monochrome_emoji_fonts. Their upstream project is +https://github.com/jslegers/emoji-icon-font; both use the same exact copyright +and permission notice below. + +MIT License + +Copyright (c) 2014 John Slegers + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index d39bec5aa9..f319ffeadb 100644 --- a/README.md +++ b/README.md @@ -332,15 +332,13 @@ pdfcraft-cli ui --control ~/.pdfcraft-control.json inspect focused=true pdfcraft-cli ui --control ~/.pdfcraft-control.json quit ``` -`ui.dock` arranges the `tools` and `inspector` panels; `canvas` is the protected document view. -Each per-panel request requires a `panel` ID and an `operation`: `open`, `close`, -`activate`, `float` with `rect: [x, y, width, height]`, or `move` with `target` and -`zone: center|left|right|top|bottom`. For example, -`{"operation":"float","panel":"tools","rect":[100,100,320,440]}` floats Tools. -`ui.dock.reset` restores the default -arrangement. The control channel accepts these methods directly; `ui.command` also accepts -`{"id":"ui.dock","params":{...}}`. Saved preferences retain groups, active tabs, visibility -and floating geometry. Invalid requests return an error without changing the document or layout. +`ui.panel` opens, closes or sizes a side panel: `{"panel":"tools"|"side","open":true|false,"width":320}`. +`tools` is the Tools panel and `side` the panel the rail opens (reopening it shows the one open last). +`width` is clamped to the panel's limits (Tools 240–520, side panel 260–520 points). `ui.dock.reset` +resets the side panels: Tools open on the left, default widths. The control channel accepts these +methods directly; `ui.command` also accepts `{"id":"ui.panel","params":{...}}`. Saved preferences keep +the Tools side, the open panels and their widths. Invalid requests return an error without changing +the document or the panels. `resize` requests a native inner-window size in logical points (each dimension must be between 320 and 8192); the window manager may constrain it. `state` reports the observed `window.width`, `window.height` and `window.pixels_per_point`. `focus` accepts exactly one widget `id` or unique enabled, focusable `label`; subsequent `key` requests traverse or activate the actual widgets. `inspect` includes focusability and accepts a `focused` filter. `quit` takes no parameters: clean documents exit normally, while unsaved edits or typing open the existing Save/Don't save/Cancel prompt and return `quitting: false, needs_confirmation: true`. It never silently discards work. Window resizing and application quit are native-only. @@ -595,13 +593,4 @@ Forks and modified versions must remove them. Made by the ArtCraft team and community.

-Tools and Inspector panels can be dragged into tab groups, split beside the PDF, or floated within the app window. Read and full-screen modes keep their existing focused document view. Drag a panel header to a target edge or another header. Its context menu offers Float panel, Move to group, and Close panel. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window. - -Advanced `ui.dock` operations use the same validated transaction as pointer gestures: -`moveFloating` updates `rect`, `resizeSplit` accepts a boolean child `path` and -`size` (`{"Ratio":0.5}`, `{"FixedFirst":280}`, or `{"FixedSecond":280}`), -`setStackOpen` takes `open`, and `resizeStack` takes a point `height` or null. -A center `move` can include `before` (panel ID or null to append) for tab order. -The equivalent `{"action": ...}` form accepts a serialized shared docking action; -this is also the form emitted by the renderer. Protected-canvas and app-specific panel -rules apply equally to both forms. +The Tools panel sits on the left, or on the right with Preferences ▸ Tools panel side. The rail stays at the right edge and opens one side panel beside it. Drag a panel's inner edge to resize it; widths are remembered. F4 shows or hides the side panel and Shift+F4 the Tools panel (also in the View menu); View ▸ Reset side panels restores the default arrangement. diff --git a/ROADMAP.md b/ROADMAP.md index 857232b12f..ebd00d2d1b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away -> **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M8, #3205 follow-up: labelled Italian international phone numbers with a slash match whole; estimates unchanged) +> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged) > **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M10: Word table cells use the widths of their own grid columns; estimates unchanged) @@ -165,10 +165,10 @@ Portuguese and Spanish vs the current union). Detail and script notes: [localiza | Language | Code | Strings | Status | |---|---|---|---| | English | `en` | 100% | full | -| Simplified Chinese | `zh-hans` | 97.4% | partial (no Hans UI face in releases, #826) | +| Simplified Chinese | `zh-hans` | 97.4% | partial (native LXGW WenKai Lite; no Hans face in the web build, #826) | | Spanish | `es` | 100% of current union | partial (dialogs / tooltips; no help) | | Hindi | `hi` | 0% | none | -| Arabic | `ar` | 100% | partial (layout not mirrored) | +| Arabic | `ar` | 100% | partial (logical catalog, explicit RTL labels, mirrored stock chrome; native/browser/IME workflows unqualified) | | French | `fr` | 99.3% | partial | | Portuguese (Brazil) | `pt-br` | 100% of current union | partial | | Indonesian | `id` | 0% | none | @@ -188,7 +188,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | Rank | Milestone | Estimate (h) | |---|---|---| | 0 | Convert and share: Create PDF from Office, Word export that keeps layout (#773) | 32–55 | -| 1 | Backlog and platform: crash and wrong-result reports, Chinese UI face (#826) | 50–90 | +| 1 | Backlog and platform: crash and wrong-result reports, Chinese web UI face (#826) | 50–90 | | 2 | Fidelity harness against Acrobat and the renderer decision (M2; owner decision on #841) | 110–190 | | 3 | Editing existing content (M7) | 100–180 | | 4 | OCR languages and Office export/import (M10) | 95–175 | @@ -199,6 +199,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged. +- **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged. - **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged. - **2026-10-23 (M7, #4280):** Edit text names each standard stand-in font after its face (`PCEdTimesBold`, ...) instead of one shared `PCEdHelv`. Rewriting a bold paragraph whose subset lacks an accent no longer repoints the paragraphs set earlier in a regular stand-in at Times-Bold, and the editor's frame stays on the paragraph being edited. Pages saved by older builds keep their shared name and are not repaired. Estimates unchanged. @@ -225,6 +227,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **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 (M5, comment threads):** Replies join the comment their `/IRT` references, not every comment sharing its `/NM`; PyMuPDF names each page's first comment `fitz-A0`, so all replies used to land under page 1. The Comments header counts comments without their replies, with the number of replies in small text under it (in every catalogue), and a reply's thread line runs from the avatar above it to its own. A reply to a reply shows in its thread in the panel too (it was listed nowhere), an `/IRT` that leads to no comment (itself, a loop, a pop-up, an unlisted object) leaves its annotation a comment of its own instead of hiding it (a status or checkmark that leads nowhere is listed nowhere), and a Replace Text caret isn't counted as a reply. The comment tools refuse an `id` several comments share instead of acting on the first (page 1's), and take `page` + `index` for those; `comment_list` reads a status or checkmark from the annotation it was set on, as the panel does, and reports a reply's own on the reply. UI, engine and automation regressions cover it. 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. @@ -319,6 +322,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **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-15 (M3, closable community card):** The ArtCraft community card on Home has a close button that hides it for the rest of the run; it returns on the next launch, and its links stay in the top bar's Discord button and the Help menu. A UI regression covers closing and the fresh-launch default. 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. @@ -673,6 +677,8 @@ Newest first: the date, what moved, and the new overall estimate. ## Revision history +**2026-10-25 · minor:** M14 localization current state: Arabic catalogs stay logical, Arabic/Kurdish labels take an explicit RTL direction, and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native Simplified Chinese uses LXGW WenKai Lite; the web build has no Hans face. Estimates unchanged. + **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 | diff --git a/apps/pdfcraft-cli/Cargo.toml b/apps/pdfcraft-cli/Cargo.toml index 7b5f9a82f3..3d9d87a812 100644 --- a/apps/pdfcraft-cli/Cargo.toml +++ b/apps/pdfcraft-cli/Cargo.toml @@ -21,7 +21,7 @@ pdfcraft-cos = { workspace = true } pdfcraft-preflight = { workspace = true } pdfcraft-automation = { workspace = true } pdfcraft-platform = { workspace = true } -base64 = "0.22" +base64 = "0.23" [dev-dependencies] # Decodes `render` output in tests/it/owner_password.rs; already a dependency of the engine. diff --git a/apps/pdfcraft-web/Cargo.toml b/apps/pdfcraft-web/Cargo.toml index a64d729dcd..ad8243d594 100644 --- a/apps/pdfcraft-web/Cargo.toml +++ b/apps/pdfcraft-web/Cargo.toml @@ -12,7 +12,7 @@ workspace = true [dependencies] pdfcraft-ui-egui = { workspace = true } eframe = { workspace = true, features = ["default_fonts", "glow", "persistence", "accesskit"] } -egui = { workspace = true } +egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] } [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen-futures = "0.4" diff --git a/apps/pdfcraft/Cargo.toml b/apps/pdfcraft/Cargo.toml index 6943ad6e44..966af354d1 100644 --- a/apps/pdfcraft/Cargo.toml +++ b/apps/pdfcraft/Cargo.toml @@ -11,15 +11,13 @@ workspace = true [dependencies] # Help ▸ Check for updates (issue #28): one HTTPS request to GitHub, over rustls with the OS's # trusted roots. No bundled root list (webpki-roots and webpki-root-certs are CDLA-licensed). -ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "_ring"] } -rustls-native-certs = "0.8" pdfcraft-ui-egui = { workspace = true } # `build_info`: the version and compile-time build label for `--version` and the log. pdfcraft-engine = { workspace = true } # wgpu draws; OpenGL (glow) is the fallback when wgpu can't start on an old or unusual GPU or # driver (#461, #435, #392), and can be chosen with PDFCRAFT_RENDERER=gl. -eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "wayland", "x11", "persistence", "links"] } -egui = { workspace = true } +eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "wayland", "x11", "persistence", "links", "clipboard"] } +egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] } serde_json = { workspace = true } # Records from every crate go to standard error and the log file (src/logging.rs). log = { workspace = true } @@ -31,9 +29,6 @@ chrono = { workspace = true } # crate registers the handler (Objective-C interop needs unsafe, which our workspace forbids) # behind a safe API. Same version as PhotoCraft. Pinned exactly: review the diff before bumping. fmv-macos-events = "=0.1.0" -# ⌘Q asks about unsaved changes like the close button (#1136): an app menu whose Quit asks the -# window to close instead of winit's, which ends the process without asking. -craft-quit = { workspace = true, features = ["menu"] } [target.'cfg(target_os = "linux")'.dependencies] # Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback), so the window is @@ -71,3 +66,6 @@ uds_windows = "=1.2.1" [build-dependencies] # Icon + VERSIONINFO for pdfcraft.exe; build.rs only uses it when targeting Windows. winresource = "0.1" + +[target.'cfg(windows)'.dependencies] +craft-update = { workspace = true } diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index 96725cd8d5..ee8ae152cc 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -41,7 +41,6 @@ mod reopen; mod settings; mod single_instance; mod text_scale; -mod updates; #[cfg(test)] #[path = "windows_manifest.rs"] mod windows_manifest; @@ -195,6 +194,11 @@ fn rejoin_split_paths(args: Vec, exists: impl Fn(&str) -> bool) -> Vec eframe::Result { + // Windows: the update helper is a copy of this program started with `--finish-update`. + #[cfg(windows)] + if let Some(result) = craft_update::self_update::finish(&craft_update::suite::PDFCRAFT, &std::env::args_os().skip(1).collect::>()) { + std::process::exit(i32::from(result.is_err())); + } // First, so the panic hook and every start-up warning are recorded (`logging`). let logger = logging::install(); // Last-resort guard (AGENTS.md §4): commands, edits, opens and saves catch panics and report @@ -357,6 +361,9 @@ fn native_options(integrated: bool, app_frame: bool, renderer: eframe::Renderer) let persistence_path = settings_dir().map(|d| d.join("app.ron")); // Set explicitly, so the renderer never depends on which one eframe defaults to. let mut native = eframe::NativeOptions { viewport, persistence_path, renderer, ..Default::default() }; + // PdfCraft installs its own last-resort system face (`PDFCRAFT_SYSTEM_FONTS`); eframe's + // provider would read every installed face, including proprietary ones, into every UI pass. + native.system_font_fallback = false; if renderer == eframe::Renderer::Wgpu { configure_gpu(&mut native); } @@ -416,16 +423,17 @@ fn app_creator<'a>( { app.motion.source = Some(motion::source()); } - app.update_source = Some(std::sync::Arc::new(updates::latest_release)); + app.set_startup_update_check(true); app.printer_source = Some(pdfcraft_ui_egui::system_printers()); app.os_key_store_ids = cfg!(any(target_os = "macos", target_os = "windows")); #[cfg(target_os = "macos")] { app.os_events = Some(apple_events.connect(&cc.egui_ctx)); - // winit's Quit (⌘Q) ends the process without asking about unsaved changes (#1136). This - // menu's Quit closes the window instead, so `guard_quit` asks, as for the close button. - if let Err(e) = craft_quit::install_app_menu(&cc.egui_ctx, "PdfCraft") { - log::warn!("couldn't install the app menu, so ⌘Q quits without asking to save: {e}"); + // The native menu bar. Its Quit (⌘Q) closes the window rather than ending the process as + // winit's does (#1136), so `guard_quit` asks about unsaved changes, as for the close + // button. + if !app.install_native_menu(&cc.egui_ctx) { + log::warn!("couldn't install the macOS menu bar; the menus stay in the window"); } } if let Some(server) = instance { @@ -795,6 +803,41 @@ fn pick_adapter(adapters: &[(u32, u32, eframe::wgpu::DeviceType)], displays: &[D #[cfg(test)] mod tests { + /// The lockfile package block for `name`, or a panic naming the missing package. + fn locked_package(root: &std::path::Path, name: &str) -> String { + let lock = std::fs::read_to_string(root.join("Cargo.lock")).expect("Cargo.lock"); + lock.split("[[package]]") + .find(|p| p.contains(&format!("\nname = \"{name}\"\n"))) + .map(str::to_owned) + .unwrap_or_else(|| panic!("{name} is not in Cargo.lock")) + } + + /// Interim graph check for the window stack while the reviewed fork revision is not yet + /// published. The old `vendor/winit` / `vendor/egui-wgpu` path patches were removed from the + /// published manifest; local integration supplies the reviewed worktrees through an external + /// Cargo config patch. A git source must name the public storytold fork; a path source is the + /// local integration graph. Behaviour (surface fitting, error scopes, scroll phases) is tested + /// directly elsewhere; the retained vendor copies keep provenance until the fork revision is + /// pinned. No public revision is invented here. + fn assert_window_stack_graph(root: &std::path::Path, name: &str, version: &str) -> String { + let package = locked_package(root, name); + assert!(package.contains(&format!("\nversion = \"{version}\"\n")), "{name} must resolve to {version}: {package}"); + if let Some(source) = package.lines().find_map(|line| line.strip_prefix("source = ")) { + assert!(source.contains("github.com/storytold/"), "{name} must come from the reviewed storytold fork once published, not {source}"); + assert!( + source.split(|c: char| !c.is_ascii_hexdigit()).any(|part| part.len() == 40), + "{name} must pin the fork's 40-character revision, not {source}" + ); + } + // A path patch has no `source =` in Cargo.lock. The local integration config is the only + // allowed path source; a machine-local path must never be in the published manifest, + // which the root-manifest assertion below checks. A path entry carries no remote identity + // to verify, so this test does not claim the resolved code is the published fork. + let manifest = std::fs::read_to_string(root.join("Cargo.toml")).expect("root Cargo.toml"); + assert!(!manifest.contains(&format!("{name} = {{ path = \"vendor/")), "vendor/{name} must not be a published path patch"); + package + } + use pdfcraft_ui_egui::control::Endpoint; fn endpoint() -> std::io::Result { @@ -1063,28 +1106,19 @@ mod tests { } } + /// Regression test: the lockfile resolves egui-wgpu 0.37.0 through an allowed source, the root manifest + /// does not path-patch the vendor copy, and that copy stays as provenance for the surface-fit patch. #[test] - fn egui_wgpu_carries_the_surface_size_fit() { - // Issue #577: egui-wgpu 0.36.2 configures a window's surface at the window's size, and + fn vendor_provenance_egui_wgpu_surface_fit_identity() { + // Issue #577: egui-wgpu configured a window's surface at the window's size, and // `Surface::configure` panics when that's beyond the device's `max_texture_dimension_2d` - // (emilk/egui#8361). vendor/egui-wgpu fits it within the limit. A dependency bump that - // resolves egui-wgpu from crates.io again, or a re-vendored copy without the patch, would - // bring the crash back: re-apply the patch, or drop the copy once an egui release has a - // fix (vendor/README.md). + // (emilk/egui#8361). The reviewed egui 0.37 worktree carries E2 (`surface_fit`); the + // behavioural test above exercises it. The old 0.36 vendor copy is retained for + // provenance only and is no longer a published path patch, so reading it would not prove + // what the app runs. let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); - let lock = std::fs::read_to_string(root.join("Cargo.lock")).unwrap(); - let egui_wgpu = lock.split("[[package]]").find(|p| p.contains("\nname = \"egui-wgpu\"\n")).expect("egui-wgpu is in Cargo.lock"); - assert!(!egui_wgpu.contains("\nsource = "), "egui-wgpu must resolve to vendor/egui-wgpu, not:{egui_wgpu}"); - // `surface_fit` is tested above; these keep it in the paths that size a surface. - let painter = std::fs::read_to_string(root.join("vendor/egui-wgpu/src/winit.rs")).unwrap().replace("\r\n", "\n"); - for patch in [ - "let (width, height, render_scale) = surface_fit(window_width, window_height, max_side);", - "let (fit_width, fit_height, _) = surface_fit(width, height, self.max_surface_side());", - "pixels_per_point: pixels_per_point * surface_state.render_scale,", - "old_state.window_width,\n old_state.window_height,", - ] { - assert!(painter.contains(patch), "vendor/egui-wgpu lost its surface size patch: {patch}"); - } + assert_window_stack_graph(&root, "egui-wgpu", "0.37.0"); + assert!(root.join("vendor/egui-wgpu/src/winit.rs").exists(), "the vendor copy and its provenance stay until the fork revision is pinned"); } #[test] @@ -1140,24 +1174,20 @@ mod tests { assert!(matches!(caught, Err(wgpu::Error::Validation { .. })), "outer: {caught:?}"); } + /// Regression test: the lockfile resolves egui-wgpu 0.37.0 through an allowed source, the root manifest + /// does not path-patch the vendor copy, and that copy stays as provenance for the catch-errors patch. #[test] - fn egui_wgpu_returns_a_surface_it_cannot_configure_as_an_error() { - // Issue #519: `catch_errors` is tested above; this keeps it around the first configure of - // every new window's surface, and its error reaching eframe as `WgpuError` (eframe then - // returns `Error::Wgpu`, which `main` retries with OpenGL). Without it a failed configure - // panics before the app is created, and the OpenGL retry never runs. + fn vendor_provenance_egui_wgpu_catch_errors_identity() { + // Issue #519: E3 (`catch_errors`) is tested above against a real device. The reviewed 0.37 + // worktree carries it around the first configure of every new window's surface, and its + // error reaches eframe as `WgpuError` (eframe then returns `Error::Wgpu`, which `main` + // retries with OpenGL). Without it a failed configure panics before the app is created. + // `catch_errors` is exercised against a real device in the test above and below; the + // graph check makes sure the app runs the reviewed 0.37 worktree (the retained vendor + // copy is not read as proof of behaviour). let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); - let lock = std::fs::read_to_string(root.join("Cargo.lock")).unwrap(); - let egui_wgpu = lock.split("[[package]]").find(|p| p.contains("\nname = \"egui-wgpu\"\n")).expect("egui-wgpu is in Cargo.lock"); - assert!(!egui_wgpu.contains("\nsource = "), "egui-wgpu must resolve to vendor/egui-wgpu, not:{egui_wgpu}"); - let painter = std::fs::read_to_string(root.join("vendor/egui-wgpu/src/winit.rs")).unwrap().replace("\r\n", "\n"); - let add_surface = painter.split("async fn add_surface(").nth(1).and_then(|s| s.split("\n fn ").next()).expect("add_surface"); - for patch in [ - "let installed = catch_errors(&device, || {\n self.install_surface(surface, viewport_id, size.width, size.height, false);\n })", - "return Err(crate::WgpuError::ConfigureSurface(error));", - ] { - assert!(add_surface.contains(patch), "vendor/egui-wgpu lost its surface configure patch: {patch}"); - } + assert_window_stack_graph(&root, "egui-wgpu", "0.37.0"); + assert!(root.join("vendor/egui-wgpu/src/winit.rs").exists(), "the vendor copy and its provenance stay until the fork revision is pinned"); } #[test] @@ -1191,6 +1221,8 @@ mod tests { } } + /// Regression test: winit resolves to 0.30.13 without a published vendor path patch, the retained copy + /// still carries the W1 Windows 11 monitor-scale fix, and its upstream base is recorded. #[test] fn winit_carries_the_windows_11_monitor_scale_fix() { // Issue #324: winit 0.30.13 as released nudges a window dragged onto a monitor with another @@ -1199,13 +1231,23 @@ mod tests { // dependency bump that resolves winit from crates.io again, or a re-vendored copy without // the patch, would silently bring the bug back: re-apply the patch, or drop the copy once a // winit 0.30 release has the fix (vendor/README.md). + // W1 (and W2–W13) were audited against the reviewed winit worktree: the vendor rows in + // `vendor/README.md` match the fork's PATCHES.md entries W1–W13. The vendor source is no + // longer a published path patch, so this checks the actual graph, not the old copy. let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); - let lock = std::fs::read_to_string(root.join("Cargo.lock")).unwrap(); - let winit = lock.split("[[package]]").find(|p| p.contains("\nname = \"winit\"\n")).expect("winit is in Cargo.lock"); - assert!(!winit.contains("\nsource = "), "winit must resolve to vendor/winit, not:{winit}"); - let dpi_changed = std::fs::read_to_string(root.join("vendor/winit/src/platform_impl/windows/event_loop.rs")).unwrap(); - let patch = "if !WIN10_BUILD_VERSION.is_some_and(|build| build < 22000) {\n new_outer_rect = suggested_rect;"; - assert!(dpi_changed.contains(patch), "vendor/winit lost its WM_DPICHANGED patch"); + assert_window_stack_graph(&root, "winit", "0.30.13"); + let event_loop = std::fs::read_to_string(root.join("vendor/winit/src/platform_impl/windows/event_loop.rs")).unwrap(); + // The audited W1 hunk itself, not merely the file's presence: Windows 11 applies the + // rect Windows suggests, while Windows 10 keeps the old adjustment. + for marker in [ + "PdfCraft patch: from Windows 11 (build 22000) on, apply the rect Windows suggests", + "if !WIN10_BUILD_VERSION.is_some_and(|build| build < 22000) {", + "new_outer_rect = suggested_rect;", + ] { + assert!(event_loop.contains(marker), "vendor/winit lost the W1 monitor-scale patch: {marker}"); + } + let provenance = std::fs::read_to_string(root.join("vendor/winit/.cargo_vcs_info.json")).unwrap(); + assert!(provenance.contains("e9809ef54b18499bb4f2cac945719ecc2a61061b"), "vendor/winit keeps its upstream base provenance"); } const NVIDIA: (u32, u32) = (0x10de, 0x2684); diff --git a/apps/pdfcraft/src/updates.rs b/apps/pdfcraft/src/updates.rs deleted file mode 100644 index 686c8cee84..0000000000 --- a/apps/pdfcraft/src/updates.rs +++ /dev/null @@ -1,127 +0,0 @@ -//! Asks GitHub for the latest PdfCraft release (Help ▸ Check for updates, issue #28). - -use std::time::Duration; - -use pdfcraft_ui_egui::updates::{RELEASES_PAGE, Release}; - -const LATEST: &str = "https://api.github.com/repos/storytold/pdfcraft/releases/latest"; - -/// The latest release. The answer is untrusted: its size is capped, and only a page under -/// [`RELEASES_PAGE`] is ever offered for download (anything else falls back to that list). -pub fn latest_release() -> Result { - let agent = ureq::Agent::config_builder() - .timeout_global(Some(Duration::from_secs(10))) - .tls_config(ureq::tls::TlsConfig::builder().root_certs(os_roots()?).build()) - .build() - .new_agent(); - let mut response = agent - .get(LATEST) - .header("Accept", "application/vnd.github+json") - .header("User-Agent", concat!("PdfCraft/", env!("CARGO_PKG_VERSION"))) - // Read error statuses ourselves: a 403 from GitHub usually means its anonymous - // rate limit ran out, and the retry time rides along in a response header. - .config() - .http_status_as_error(false) - .build() - .call() - .map_err(|e| format!("couldn't reach GitHub ({e})"))?; - let status = response.status().as_u16(); - if status == 403 { - let reset = response.headers().get("x-ratelimit-reset").and_then(|v| v.to_str().ok()); - return Err(rate_limited(reset, now_unix())); - } - if !(200..300).contains(&status) { - return Err(format!("couldn't reach GitHub (http status: {status})")); - } - let body = response.body_mut().with_config().limit(1 << 20).read_to_string().map_err(|e| format!("unreadable answer ({e})"))?; - parse(&body) -} - -/// The certificate authorities the operating system trusts. -fn os_roots() -> Result { - let found = rustls_native_certs::load_native_certs(); - let certs: Vec> = found.certs.iter().map(|c| ureq::tls::Certificate::from_der(c.as_ref()).to_owned()).collect(); - if certs.is_empty() { - return Err("no trusted certificates found on this system".into()); - } - Ok(ureq::tls::RootCerts::new_with_certs(&certs)) -} - -/// The current Unix time in seconds (0 when the clock gives nothing usable). -fn now_unix() -> u64 { - std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) -} - -/// What to tell the user when GitHub answers 403: its anonymous quota (60 checks an hour, -/// shared by everyone behind the same address) ran out. `reset` is the `x-ratelimit-reset` -/// header, the Unix time the quota refills; `now` is the current Unix time. Anything -/// surprising (no header, not a number, already past) falls back to asking for patience. -fn rate_limited(reset: Option<&str>, now: u64) -> String { - const FALLBACK: &str = "GitHub is limiting update checks for now (shared hourly quota); try again later"; - let wait = reset.and_then(|r| r.trim().parse::().ok()).and_then(|reset| reset.checked_sub(now)).filter(|&wait| wait > 0); - match wait { - Some(wait) => { - let minutes = wait.div_ceil(60).max(1); - let plural = if minutes == 1 { "" } else { "s" }; - format!("GitHub is limiting update checks for now (shared hourly quota); try again in about {minutes} minute{plural}") - } - None => FALLBACK.into(), - } -} - -fn parse(body: &str) -> Result { - let v: serde_json::Value = serde_json::from_str(body).map_err(|e| format!("unreadable answer ({e})"))?; - let version = v["tag_name"].as_str().filter(|t| !t.is_empty() && t.len() <= 64).ok_or("no release found")?.to_string(); - let url = v["html_url"] - .as_str() - .filter(|u| u.strip_prefix(RELEASES_PAGE).is_some_and(|rest| rest.starts_with('/') && !rest.contains(['?', '#', '\\']))) - .unwrap_or(RELEASES_PAGE) - .to_string(); - Ok(Release { version, url }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn answers_are_read_and_only_our_release_pages_are_offered() { - let r = parse(r#"{"tag_name":"v0.2.0","html_url":"https://github.com/storytold/pdfcraft/releases/tag/v0.2.0"}"#).unwrap(); - assert_eq!(r, Release { version: "v0.2.0".into(), url: "https://github.com/storytold/pdfcraft/releases/tag/v0.2.0".into() }); - for elsewhere in ["https://example.com/pdfcraft.exe", "https://github.com/storytold/pdfcraft/releases.evil/x", "javascript:alert(1)"] { - let r = parse(&format!(r#"{{"tag_name":"v9.9.9","html_url":"{elsewhere}"}}"#)).unwrap(); - assert_eq!(r.url, RELEASES_PAGE, "{elsewhere}"); - } - assert!(parse(r#"{"message":"Not Found"}"#).is_err()); - assert!(parse("").is_err()); - } - - #[test] - fn rate_limit_messages_name_the_wait() { - let limited = "GitHub is limiting update checks for now (shared hourly quota); try again in about 20 minutes"; - assert_eq!(rate_limited(Some("1800001200"), 1800000000), limited); - // Rounds up: 61 seconds out reads as 2 minutes. - assert_eq!( - rate_limited(Some("1800000061"), 1800000000), - "GitHub is limiting update checks for now (shared hourly quota); try again in about 2 minutes" - ); - assert_eq!( - rate_limited(Some("1800000060"), 1800000000), - "GitHub is limiting update checks for now (shared hourly quota); try again in about 1 minute" - ); - // No header, not a number, or already past: ask for patience instead of a time. - let later = "GitHub is limiting update checks for now (shared hourly quota); try again later"; - for bad in [None, Some(""), Some("soon"), Some("1799999999"), Some("1800000000")] { - assert_eq!(rate_limited(bad, 1800000000), later); - } - } - - /// Live: asks GitHub over TLS with the OS's roots (`cargo test -p pdfcraft -- --ignored`). - #[test] - #[ignore = "needs network access"] - fn github_answers_with_the_latest_release() { - let r = latest_release().unwrap(); - assert!(pdfcraft_ui_egui::updates::is_newer(&r.version, "0.0.0"), "{r:?}"); - assert!(r.url.starts_with(RELEASES_PAGE), "{r:?}"); - } -} diff --git a/apps/pdfcraft/tests/it/main.rs b/apps/pdfcraft/tests/it/main.rs index bc05099a51..b09ace5f40 100644 --- a/apps/pdfcraft/tests/it/main.rs +++ b/apps/pdfcraft/tests/it/main.rs @@ -7,6 +7,7 @@ mod event { pub use winit::event::TouchPhase; } +mod vendor_graph; mod winit_macos_emoji_picker; mod winit_macos_ime_commit; mod winit_macos_ime_discard; diff --git a/apps/pdfcraft/tests/it/vendor_graph.rs b/apps/pdfcraft/tests/it/vendor_graph.rs new file mode 100644 index 0000000000..a6a4d4b292 --- /dev/null +++ b/apps/pdfcraft/tests/it/vendor_graph.rs @@ -0,0 +1,81 @@ +//! Dependency-graph verification for the egui 0.37 window stack. +//! +//! The twelve `winit_*` modules next to this one read the retained `vendor/winit` / +//! `vendor/egui-wgpu` copies. They are provenance for W1–W13 and E2+E3, not proof of what the app +//! runs: the root manifest pins both crates to immutable storytold git revisions, and the retained +//! copies are not path patches. These tests check the resolved graph and the retained evidence +//! instead of treating the copies as the runtime source. The winit fork's own W17 tests own patch +//! behaviour. + +/// Return the workspace root above the PdfCraft app crate. +fn root() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +/// Every `[[package]]` block in `Cargo.lock` whose `name` is exactly `name`. +fn locked_packages(name: &str) -> Vec { + let lock = std::fs::read_to_string(root().join("Cargo.lock")).expect("Cargo.lock"); + let needle = format!("name = \"{name}\""); + lock.split("[[package]]").filter(|package| package.lines().any(|line| line.trim() == needle.as_str())).map(str::to_owned).collect() +} + +/// The one resolved package called `name`. A duplicate name is a lockfile problem, not something +/// to resolve by taking the first match. +fn locked(name: &str) -> String { + let mut packages = locked_packages(name); + assert_eq!(packages.len(), 1, "Cargo.lock must contain exactly one `{name}` package, found {}", packages.len()); + packages.remove(0) +} + +/// The resolved package must be the exact canonical storytold git source at the reviewed +/// 40-character revision, including the `Cargo.lock` URL and fragment. A path source (no `source` +/// line) is refused: it would make the graph depend on a developer's checkout. A published path +/// patch is refused as well. +fn assert_resolved(name: &str, version: &str, url: &str, rev: &str) { + let package = locked(name); + let expected_version = format!("version = \"{version}\""); + assert!(package.lines().any(|line| line.trim() == expected_version.as_str()), "{name} must resolve to {version}: {package}"); + let sources: Vec<&str> = package.lines().filter_map(|line| line.trim().strip_prefix("source = ")).collect(); + assert_eq!(sources.len(), 1, "{name} must have exactly one `source` in Cargo.lock; a local path source is not accepted: {package}"); + let expected_source = format!("\"git+{url}?rev={rev}#{rev}\""); + assert_eq!(sources[0], expected_source.as_str(), "{name} must resolve to the exact canonical git URL, revision and fragment, not {}", sources[0]); + + let manifest = std::fs::read_to_string(root().join("Cargo.toml")).expect("root Cargo.toml"); + assert!(!manifest.contains(&format!("{name} = {{ path = \"vendor/")), "vendor/{name} cannot stay a published path patch"); +} + +/// Regression test: the resolved window stack points at the reviewed forks and the retained vendor provenance +/// files remain. +#[test] +fn vendor_provenance_window_stack_graph_is_resolved() { + assert_resolved("winit", "0.30.13", "https://github.com/storytold/winit.git", "a517b3846ee8457d84ee4975110bfda6797c44dc"); + assert_resolved("egui-wgpu", "0.37.0", "https://github.com/storytold/egui.git", "6d518fc799681571963842f95b55a6b68431b15a"); + // Retained provenance: the audited copies and their recorded W1–W13 / E2+E3 rows. + let readme = std::fs::read_to_string(root().join("vendor/README.md")).expect("vendor/README.md"); + for marker in ["W1–W13", "surface_fit", "catch_errors", "e9809ef54b18499bb4f2cac945719ecc2a61061b"] { + assert!(readme.contains(marker), "vendor/README.md lost window-stack provenance: {marker}"); + } + let vcs = std::fs::read_to_string(root().join("vendor/winit/.cargo_vcs_info.json")).expect("vendor/winit provenance"); + assert!(vcs.contains("e9809ef54b18499bb4f2cac945719ecc2a61061b"), "vendor/winit keeps its upstream base"); + for path in ["vendor/winit/src/platform_impl", "vendor/egui-wgpu/src/winit.rs"] { + assert!(root().join(path).exists(), "retain {path} as provenance for the pinned fork"); + } +} + +/// Renderer, security and PostScript vendors are unrelated to the UI port and must stay path patches. +#[test] +fn renderer_and_security_vendors_remain() { + let manifest = std::fs::read_to_string(root().join("Cargo.toml")).expect("root Cargo.toml"); + for vendor in [ + "vendor/hayro", + "vendor/hayro-interpret", + "vendor/hayro-syntax", + "vendor/hayro-cmap", + "vendor/hayro-jbig2", + "vendor/hayro-postscript", + "vendor/lopdf", + "vendor/vello_common", + ] { + assert!(manifest.contains(&format!("path = \"{vendor}\"")), "{vendor} must stay a path patch"); + } +} diff --git a/apps/pdfcraft/tests/it/winit_macos_emoji_picker.rs b/apps/pdfcraft/tests/it/winit_macos_emoji_picker.rs index 6cedf57e33..b95058ba94 100644 --- a/apps/pdfcraft/tests/it/winit_macos_emoji_picker.rs +++ b/apps/pdfcraft/tests/it/winit_macos_emoji_picker.rs @@ -1,7 +1,11 @@ -//! vendor/winit patch (8) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (8) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit commits text inserted outside a key press and leaves `selectedRange` +/// unmarked. #[test] -fn winit_commits_macos_text_inserted_outside_a_key_press() { +fn vendor_provenance_winit_macos_text_inserted_outside_a_key_press() { // The macOS emoji picker inserts its text with `insertText` outside any key press and without // a composition. winit 0.30.13 reports `insertText` only as part of a key press or a // composition, so an emoji picked into a PdfCraft text field was dropped. vendor/winit commits diff --git a/apps/pdfcraft/tests/it/winit_macos_ime_commit.rs b/apps/pdfcraft/tests/it/winit_macos_ime_commit.rs index 8701fc20a1..877f9e2a22 100644 --- a/apps/pdfcraft/tests/it/winit_macos_ime_commit.rs +++ b/apps/pdfcraft/tests/it/winit_macos_ime_commit.rs @@ -1,7 +1,11 @@ -//! vendor/winit patch (6) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (6) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit commits a composition that ended without marked text and keeps the +/// marked-text underline. #[test] -fn winit_commits_macos_compositions_that_end_without_marked_text() { +fn vendor_provenance_winit_macos_compositions_that_end_without_marked_text() { // winit 0.30.13 treats `insertText` as an IME commit only while marked text is left. Input // methods that end a composition with `unmarkText` first lost the committed text, and Apple // Korean, which clears its marked text before inserting the key typed after a syllable (`한` diff --git a/apps/pdfcraft/tests/it/winit_macos_ime_discard.rs b/apps/pdfcraft/tests/it/winit_macos_ime_discard.rs index 52e65622f6..89efbd3d81 100644 --- a/apps/pdfcraft/tests/it/winit_macos_ime_discard.rs +++ b/apps/pdfcraft/tests/it/winit_macos_ime_discard.rs @@ -1,7 +1,10 @@ -//! vendor/winit patch (7) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (7) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: disallowing IME in the vendored winit discards the macOS input method's marked text. #[test] -fn winit_discards_the_macos_composition_when_ime_is_disallowed() { +fn vendor_provenance_winit_discards_the_macos_composition_when_ime_is_disallowed() { // egui disallows IME when a text field loses focus. winit 0.30.13 then forgets the marked text // but doesn't tell the input method, which keeps the composition and continues it once IME is // allowed again: the abandoned syllable comes back in the next field. vendor/winit calls diff --git a/apps/pdfcraft/tests/it/winit_macos_korean_first_key.rs b/apps/pdfcraft/tests/it/winit_macos_korean_first_key.rs index 76bb32ccbd..f18dde0f2a 100644 --- a/apps/pdfcraft/tests/it/winit_macos_korean_first_key.rs +++ b/apps/pdfcraft/tests/it/winit_macos_korean_first_key.rs @@ -1,7 +1,10 @@ -//! vendor/winit patch (10) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (10) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit retries the first Apple Korean key once so it starts a composition. #[test] -fn winit_retries_the_first_apple_korean_key_once() { +fn vendor_provenance_winit_retries_the_first_apple_korean_key_once() { // On the first key in a freshly started app, Apple Korean can answer with `insertText` and the // raw compatibility jamo instead of starting a composition, so typing g k s in PdfCraft gave // ㅎㅏㄴ instead of 한. vendor/winit interprets that one key again before sending it to the diff --git a/apps/pdfcraft/tests/it/winit_macos_korean_space.rs b/apps/pdfcraft/tests/it/winit_macos_korean_space.rs index 30c9d4779c..f5f2aada2f 100644 --- a/apps/pdfcraft/tests/it/winit_macos_korean_space.rs +++ b/apps/pdfcraft/tests/it/winit_macos_korean_space.rs @@ -1,7 +1,10 @@ -//! vendor/winit patch (9) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (9) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit sends a key committed through macOS IME only once. #[test] -fn winit_sends_a_key_committed_through_macos_ime_only_once() { +fn vendor_provenance_winit_sends_a_key_committed_through_macos_ime_only_once() { // Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`, // `insertText(" ")` and `setMarkedText("")`. The last call resets winit 0.30.13's IME state, so // the key went to the app both as `Ime::Commit(" ")` and as `KeyboardInput` with text " ", and diff --git a/apps/pdfcraft/tests/it/winit_macos_title_bar_drag.rs b/apps/pdfcraft/tests/it/winit_macos_title_bar_drag.rs index 5422af59b5..5c8e892f25 100644 --- a/apps/pdfcraft/tests/it/winit_macos_title_bar_drag.rs +++ b/apps/pdfcraft/tests/it/winit_macos_title_bar_drag.rs @@ -1,7 +1,11 @@ -//! vendor/winit patch (12) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (12) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit lets title-bar drags reach the app while the empty tab strip still +/// moves the window. #[test] -fn winit_lets_macos_title_bar_drags_reach_the_app() { +fn vendor_provenance_winit_lets_macos_title_bar_drags_reach_the_app() { // On macOS the tab strip is the title bar: a transparent title bar over a full-size content // view. NSView answers YES to `mouseDownCanMoveWindow` for a view that isn't opaque, so AppKit // moved the whole window for a drag that began on a document tab, and tabs could not be diff --git a/apps/pdfcraft/tests/it/winit_wayland_scroll.rs b/apps/pdfcraft/tests/it/winit_wayland_scroll.rs index 96686f7ecf..8087219b21 100644 --- a/apps/pdfcraft/tests/it/winit_wayland_scroll.rs +++ b/apps/pdfcraft/tests/it/winit_wayland_scroll.rs @@ -1,3 +1,5 @@ +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. #![cfg(target_os = "linux")] use egui::{Event, Modifiers, MouseWheelUnit, RawInput, TouchPhase, vec2}; @@ -16,6 +18,7 @@ fn egui_phase(phase: event::TouchPhase) -> TouchPhase { } } +/// Run three wheel frames through egui and return each frame's zoom and smooth-scroll deltas. fn wheel_trace(source: AxisSource, discrete: bool) -> Vec<(f32, egui::Vec2)> { let ctx = egui::Context::default(); let mut previous = event::TouchPhase::Ended; @@ -27,7 +30,13 @@ fn wheel_trace(source: AxisSource, discrete: bool) -> Vec<(f32, egui::Vec2)> { let (unit, dy) = if discrete { (MouseWheelUnit::Line, 1.0) } else { (MouseWheelUnit::Point, 24.0) }; let input = RawInput { time: Some(frame as f64 / 60.0), - events: vec![Event::MouseWheel { unit, delta: vec2(0.0, dy), phase: egui_phase(phase), modifiers }], + events: vec![Event::MouseWheel { + unit, + delta: vec2(0.0, dy), + phase: egui_phase(phase), + modifiers, + source: egui::MouseWheelSource::Unknown, + }], ..Default::default() }; ctx.run_ui(input, |ui| seen.push(ui.input(|i| (i.zoom_delta(), i.smooth_scroll_delta)))).textures_delta.clear(); @@ -35,8 +44,10 @@ fn wheel_trace(source: AxisSource, discrete: bool) -> Vec<(f32, egui::Vec2)> { seen } +/// Regression test: releasing Ctrl after a pixel-wheel zoom makes the next wheel event scroll instead of +/// zooming. #[test] -fn releasing_ctrl_after_pixel_wheel_zoom_returns_to_scrolling() { +fn vendor_provenance_releasing_ctrl_after_pixel_wheel_zoom_returns_to_scrolling() { for source in [AxisSource::Wheel, AxisSource::WheelTilt, AxisSource::Continuous] { let seen = wheel_trace(source, false); assert!(seen[1].0 > 1.0, "Ctrl must zoom: {source:?}"); @@ -45,16 +56,18 @@ fn releasing_ctrl_after_pixel_wheel_zoom_returns_to_scrolling() { } } +/// Regression test: discrete wheels follow the modifiers of the current event, just like pixel wheels. #[test] -fn discrete_wheels_still_follow_current_modifiers() { +fn vendor_provenance_discrete_wheels_still_follow_current_modifiers() { let seen = wheel_trace(AxisSource::Wheel, true); assert!(seen[1].0 > 1.0); assert_eq!(seen[2].0, 1.0); assert!(seen[2].1.y > 0.0); } +/// Regression test: a finger gesture keeps its Ctrl zoom through momentum until the gesture ends. #[test] -fn finger_gestures_keep_their_modifier_until_the_gesture_ends() { +fn vendor_provenance_finger_gestures_keep_their_modifier_until_the_gesture_ends() { // A finger scroll's momentum may arrive after Ctrl is released. let seen = wheel_trace(AxisSource::Finger, false); assert!(seen[1].0 > 1.0); @@ -64,8 +77,9 @@ fn finger_gestures_keep_their_modifier_until_the_gesture_ends() { assert_eq!(scroll::phase(event::TouchPhase::Ended, Some(AxisSource::Finger), false, false), event::TouchPhase::Started); } +/// Regression test: wheel sources without a phase keep the existing start, move and end policy. #[test] -fn unknown_sources_keep_the_existing_phase_policy() { +fn vendor_provenance_unknown_sources_keep_the_existing_phase_policy() { assert_eq!(scroll::phase(event::TouchPhase::Ended, None, false, false), event::TouchPhase::Started); assert_eq!(scroll::phase(event::TouchPhase::Started, None, false, false), event::TouchPhase::Moved); assert_eq!(scroll::phase(event::TouchPhase::Ended, None, true, false), event::TouchPhase::Moved); diff --git a/apps/pdfcraft/tests/it/winit_wayland_text_input.rs b/apps/pdfcraft/tests/it/winit_wayland_text_input.rs index 691659e5a6..6469354cdc 100644 --- a/apps/pdfcraft/tests/it/winit_wayland_text_input.rs +++ b/apps/pdfcraft/tests/it/winit_wayland_text_input.rs @@ -1,7 +1,10 @@ -//! vendor/winit patch (2) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (2) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit keeps a Wayland seat's text input until the seat itself is removed. #[test] -fn winit_keeps_the_wayland_text_input_until_the_seat_goes() { +fn vendor_provenance_winit_keeps_the_wayland_text_input_until_the_seat_goes() { // winit 0.30.13 destroys a Wayland seat's text input whenever the seat loses any capability (a // touch screen or tablet unplugged, a remote-desktop session ending) and recreates it only when // a capability is added, so IME stops working in a running PdfCraft until it is restarted. diff --git a/apps/pdfcraft/tests/it/winit_windows_hidden_maximized.rs b/apps/pdfcraft/tests/it/winit_windows_hidden_maximized.rs index a1bdd11840..a8009591bf 100644 --- a/apps/pdfcraft/tests/it/winit_windows_hidden_maximized.rs +++ b/apps/pdfcraft/tests/it/winit_windows_hidden_maximized.rs @@ -1,7 +1,10 @@ -//! vendor/winit patch (4) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (4) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit maximizes a hidden Windows window only when it is shown. #[test] -fn winit_maximizes_a_hidden_windows_window_only_when_it_is_shown() { +fn vendor_provenance_winit_maximizes_a_hidden_windows_window_only_when_it_is_shown() { // eframe creates PdfCraft's window hidden, restores the saved maximized state and shows the // window once its first frame is drawn. winit 0.30.13 applies the maximized state with // `ShowWindow(SW_MAXIMIZE)`, which shows a hidden window, so a window closed maximized came back diff --git a/apps/pdfcraft/tests/it/winit_windows_ime_cursor.rs b/apps/pdfcraft/tests/it/winit_windows_ime_cursor.rs index aef55877d7..1df11d8a68 100644 --- a/apps/pdfcraft/tests/it/winit_windows_ime_cursor.rs +++ b/apps/pdfcraft/tests/it/winit_windows_ime_cursor.rs @@ -1,7 +1,10 @@ -//! vendor/winit patch (3) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (3) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit reads the Windows IME cursor only when the composition reports it. #[test] -fn winit_reads_the_windows_ime_cursor_only_when_it_is_reported() { +fn vendor_provenance_winit_reads_the_windows_ime_cursor_only_when_it_is_reported() { // winit 0.30.13 reads `GCS_CURSORPOS` for every composition without a target clause, whether // or not `WM_IME_COMPOSITION` says the IME set it. The Microsoft Korean IME never sets it, so // the composition's cursor read as 0, before the syllable being composed. vendor/winit puts it diff --git a/apps/pdfcraft/tests/it/winit_x11_dpi.rs b/apps/pdfcraft/tests/it/winit_x11_dpi.rs index fed0cf3e42..a9bde6a1ee 100644 --- a/apps/pdfcraft/tests/it/winit_x11_dpi.rs +++ b/apps/pdfcraft/tests/it/winit_x11_dpi.rs @@ -1,5 +1,9 @@ +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +/// Regression test: the vendored winit keeps the X11 server-DPI fallback and the desktop file does not +/// override scaling. #[test] -fn winit_carries_the_x11_server_dpi_fallback() { +fn vendor_provenance_winit_carries_the_x11_server_dpi_fallback() { // The behavior tests live beside the policy in winit (util::randr::tests). // This guards PdfCraft's vendored copy, which workspace tests do not test itself. let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); diff --git a/apps/pdfcraft/tests/it/winit_x11_ime_focus.rs b/apps/pdfcraft/tests/it/winit_x11_ime_focus.rs index f244d1387a..61e5c8ebfa 100644 --- a/apps/pdfcraft/tests/it/winit_x11_ime_focus.rs +++ b/apps/pdfcraft/tests/it/winit_x11_ime_focus.rs @@ -1,7 +1,10 @@ -//! vendor/winit patch (5) (vendor/README.md). +//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is +//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity. +//! Retained vendor provenance for winit patch (5) (vendor/README.md); `vendor_graph.rs` checks the resolved graph. +/// Regression test: the vendored winit focuses the X11 input context it replaces. #[test] -fn winit_focuses_the_x11_input_context_it_replaces() { +fn vendor_provenance_winit_focuses_the_x11_input_context_it_replaces() { // egui allows IME when a text field gains focus and disallows it when the field loses focus. // On X11, winit 0.30.13 replaces the window's input context each time and leaves focusing the // new one to the next focus event, which never comes while PdfCraft's window keeps the focus: diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index c00f442856..6c9fc5dce8 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -14,6 +14,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +use std::collections::{HashMap, HashSet}; + use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; pub mod appearance; @@ -774,8 +776,9 @@ thread_local! { static ANNOTS_ENTRIES_COPIED: std::cell::Cell = const { std::cell::Cell::new(0) }; } -/// Replace a page's `/Annots`, writing into the shared array object when it is indirect. -fn set_annots(doc: &mut Document, page: ObjRef, list: Vec) -> Result<(), AnnotError> { +/// Replace a page's `/Annots`, writing into the shared array object when it is indirect. An empty +/// list removes a direct `/Annots`. +pub fn set_annots(doc: &mut Document, page: ObjRef, list: Vec) -> Result<(), pdfcraft_cos::CosError> { let existing = doc.get(page).as_dict().and_then(|d| d.reference(b"Annots")); match existing { Some(r) if doc.get(r).as_array().is_some() => doc.set(r, Object::Array(list)), @@ -1665,36 +1668,52 @@ pub fn delete_annotation(doc: &mut Document, page: usize, index: usize) -> Resul if let Some(r) = target.as_ref() { unlocked(doc, r)?; } - let mut doomed: Vec = target.as_ref().into_iter().collect(); - // Replies (`/IRT`) and pop-ups (`/Parent`) of anything doomed, to a fixpoint. - loop { - let before = doomed.len(); - for e in &list { - let Some(r) = e.as_ref() else { continue }; - if doomed.contains(&r) { - continue; - } - let obj = doc.get(r); - let Some(d) = obj.as_dict() else { continue }; - let points_at = |k: &[u8]| d.reference(k).is_some_and(|t| doomed.contains(&t)); - if points_at(b"IRT") || points_at(b"Parent") { - doomed.push(r); - } - } - for r in doomed.clone() { - if let Some(pop) = doc.get(r).as_dict().and_then(|d| d.reference(b"Popup")) - && !doomed.contains(&pop) - { - doomed.push(pop); - } - } - if doomed.len() == before { - break; + // An inline comment has no reference to collect; the pop-up it names still goes. + let seed = target.as_ref().or_else(|| target.as_dict().and_then(|d| d.reference(b"Popup"))); + let doomed = with_replies_and_popups(doc, &list, seed); + let kept: Vec = list.into_iter().enumerate().filter(|(i, e)| *i != index && !goes_with(e, &doomed)).map(|(_, e)| e).collect(); + Ok(set_annots(doc, p, kept)?) +} + +/// Whether the `/Annots` entry `entry` goes with `going` (from [`with_replies_and_popups`]): it is +/// one of them, or it is written inline (a file can, though the standard asks for references) and +/// replies to (`/IRT`) or pops up for (`/Parent`) one of them. An inline entry has no reference +/// for the walk to collect, so it is matched here. +pub fn goes_with(entry: &Object, going: &HashSet) -> bool { + match entry.as_ref() { + Some(r) => going.contains(&r), + None => entry.as_dict().is_some_and(|d| [d.reference(b"IRT"), d.reference(b"Parent")].into_iter().flatten().any(|r| going.contains(&r))), + } +} + +/// `doomed` and what goes with it, as Acrobat deletes a comment: the entries of `list` (one or +/// more pages' `/Annots`) that reply to (`/IRT`) or pop up for (`/Parent`) anything going, their +/// own replies, and the pop-ups going annotations name (`/Popup`). An inline reply has no +/// reference to collect, but the pop-up it names is. Each annotation is visited once, so a long or +/// cyclic chain costs no more than its length. Filter a list with [`goes_with`], which also +/// matches inline entries. +pub fn with_replies_and_popups(doc: &Document, list: &[Object], doomed: impl IntoIterator) -> HashSet { + let mut hanging: HashMap> = HashMap::new(); + for e in list { + let obj = doc.resolve(e); + let Some(d) = obj.as_dict() else { continue }; + // What goes with the annotation this entry hangs off: the entry, or for an inline one the + // pop-up it names. + let Some(goes) = e.as_ref().or_else(|| d.reference(b"Popup")) else { continue }; + for on in [d.reference(b"IRT"), d.reference(b"Parent")].into_iter().flatten() { + hanging.entry(on).or_default().push(goes); } } - let kept: Vec = - list.into_iter().enumerate().filter(|(i, e)| *i != index && !e.as_ref().is_some_and(|r| doomed.contains(&r))).map(|(_, e)| e).collect(); - set_annots(doc, p, kept) + let mut going = HashSet::new(); + let mut todo: Vec = doomed.into_iter().collect(); + while let Some(r) = todo.pop() { + if !going.insert(r) { + continue; + } + todo.extend(hanging.get(&r).into_iter().flatten()); + todo.extend(doc.get(r).as_dict().and_then(|d| d.reference(b"Popup"))); + } + going } /// Change a comment's text. Text boxes are redrawn to show it, and their rectangle follows the @@ -2272,6 +2291,10 @@ pub struct Summary { pub modified: Option, pub name: Option, pub in_reply_to: Option, + /// The page and `/Annots` position `/IRT` points at, by reference (`/NM` need not be unique). + pub parent: Option<(usize, usize)>, + /// `/RT /Group`: grouped with the `/IRT` annotation (Replace Text's caret), not a reply to it. + pub grouped: bool, pub rect: [f32; 4], pub color: Option<[f32; 3]>, pub state: Option, @@ -2305,13 +2328,35 @@ pub fn is_comment_subtype(subtype: &str) -> bool { !matches!(subtype, "Link" | "Widget" | "Popup" | "Screen" | "Movie" | "RichMedia" | "3D" | "PrinterMark" | "TrapNet" | "Watermark") } +/// Where a reference to a reference (`5 0 obj 6 0 R endobj`) ends, as `pdfcraft_render::inspect` +/// follows it, within a few steps (a cycle stops there). +fn final_ref(doc: &Document, mut r: ObjRef) -> ObjRef { + for _ in 0..8 { + match &*doc.get(r) { + Object::Ref(next) => r = *next, + _ => break, + } + } + r +} + /// Every comment (see [`is_comment_subtype`]), ordered by page and then top edge. pub fn summaries(doc: &Document) -> Vec { let mut out = Vec::new(); let Ok(pages) = page_refs(doc) else { return out }; - for (page, p) in pages.iter().enumerate() { - for (index, entry) in annots(doc, *p).iter().enumerate() { - let obj = doc.resolve(entry); + let lists: Vec> = pages.iter().map(|p| annots(doc, *p)).collect(); + // Where each annotation is first listed, so a reply's `/IRT` finds its parent by reference. + let mut slots: std::collections::HashMap = std::collections::HashMap::new(); + for (page, list) in lists.iter().enumerate() { + for (index, entry) in list.iter().enumerate() { + if let Some(r) = entry.as_ref() { + slots.entry(final_ref(doc, r)).or_insert((page, index)); + } + } + } + for (page, list) in lists.iter().enumerate() { + for (index, entry) in list.iter().enumerate() { + let obj = entry.as_ref().map_or_else(|| doc.resolve(entry), |r| doc.get(final_ref(doc, r))); let Some(d) = obj.as_dict() else { continue }; let Some(subtype) = d.name(b"Subtype").map(|s| String::from_utf8_lossy(s).into_owned()) else { continue }; if !is_comment_subtype(&subtype) { @@ -2345,7 +2390,9 @@ pub fn summaries(doc: &Document) -> Vec { "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")); + let irt = d.get(b"IRT").map(|o| o.as_ref().map_or_else(|| doc.resolve(o), |r| doc.get(final_ref(doc, r)))); + let in_reply_to = irt.and_then(|o| o.as_dict().cloned()).and_then(|p| text_value(doc, &p, b"NM")); + let parent = d.get(b"IRT").and_then(Object::as_ref).and_then(|r| slots.get(&final_ref(doc, r)).copied()); out.push(Summary { page, index, @@ -2357,6 +2404,8 @@ pub fn summaries(doc: &Document) -> Vec { modified: text_value(doc, d, b"M"), name: text_value(doc, d, b"NM"), in_reply_to, + parent, + grouped: d.name(b"RT") == Some(b"Group"), rect, color, state: text_value(doc, d, b"State"), diff --git a/crates/annot/src/links.rs b/crates/annot/src/links.rs index 53c31d83a6..7cf4328c7c 100644 --- a/crates/annot/src/links.rs +++ b/crates/annot/src/links.rs @@ -320,7 +320,7 @@ pub fn delete(doc: &mut Document, page: usize, index: usize) -> Result<(), Annot let (p, _) = link_at(doc, page, index)?; let mut list = annots(doc, p); list.remove(index); - set_annots(doc, p, list) + Ok(set_annots(doc, p, list)?) } /// Remove every link on `pages` (all when `None`). Returns how many went. diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 4f0e0fb1fc..b9e47e5fb9 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -229,6 +229,47 @@ fn delete_takes_popup_and_reply_chain() { assert_eq!(delete_annotation(&mut doc, 0, 9), Err(AnnotError::NoSuchAnnotation { page: 0, index: 9 })); } +/// A reply or pop-up written inline in `/Annots` (a file can, though the standard asks for +/// references) goes with the comment it points at, and so does a pop-up an inline reply or an +/// inline comment names. An unrelated inline comment stays until it is deleted itself. +#[test] +fn delete_takes_inline_replies_and_popups() { + let mut doc = fixture(); + let mut popup = |note: &str| { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Annot")); + d.set(b"Subtype".to_vec(), Object::name("Popup")); + d.set(b"Contents".to_vec(), PdfString::text(note)); + doc.add(Object::Dict(d)) + }; + let (reply_popup, square_popup) = (popup("of the reply"), popup("of the square")); + let inline = |subtype: &str, links: &[(&[u8], ObjRef)]| { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Annot")); + d.set(b"Subtype".to_vec(), Object::name(subtype)); + links.iter().for_each(|(key, r)| d.set(key.to_vec(), Object::Ref(*r))); + Object::Dict(d) + }; + let stamp = ObjRef::new(6, 0); + let entries = vec![ + Object::Ref(stamp), + inline("Text", &[(b"IRT", stamp), (b"Popup", reply_popup)]), + Object::Ref(reply_popup), + inline("Popup", &[(b"Parent", stamp)]), + inline("Square", &[(b"Popup", square_popup)]), + Object::Ref(square_popup), + ]; + doc.set(ObjRef::new(5, 0), Object::Array(entries)); + let subtypes = |doc: &Document| -> Vec { + list(doc, 1).iter().map(|d| String::from_utf8_lossy(d.name(b"Subtype").unwrap()).into_owned() + ":" + &text(d, b"Contents")).collect() + }; + delete_annotation(&mut doc, 1, 0).unwrap(); + let mut doc = reopen(&doc); + assert_eq!(subtypes(&doc), ["Square:", "Popup:of the square"]); + delete_annotation(&mut doc, 1, 0).unwrap(); + assert!(subtypes(&reopen(&doc)).is_empty(), "the inline comment's pop-up went with it"); +} + #[test] fn deleting_the_last_comment_removes_annots() { let mut doc = fixture(); diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index bed579ab84..a6e43de1f0 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -17,7 +17,7 @@ pdfcraft-platform = { workspace = true } serde_json = { workspace = true } sha2 = "0.11" tempfile = "3.27" -base64 = { version = "0.22", optional = true } +base64 = { version = "0.23", optional = true } [features] diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index a29354f0b1..0150001096 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -3,6 +3,8 @@ //! Geometry follows the automation convention: points from the top-left of the displayed page, //! y down. It is converted to PDF user space (crop box, `/Rotate`) here. +use std::collections::HashMap; + use pdfcraft_engine::{Edit, LineEnding, Markup, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, SummarySort}; use pdfcraft_render::{Annotation, PageInfo}; use serde_json::{Value, json}; @@ -152,8 +154,13 @@ impl Automation { fn comment_target(&self, a: &Args) -> Result<(usize, usize)> { let all = self.comments(a)?; if let Some(id) = a.opt_str("id")? { - let c = - all.iter().find(|c| c.name.as_deref() == Some(id)).ok_or_else(|| failed(format!("no comment with id {id:?} (see comment_list)")))?; + // `/NM` need not be unique (PyMuPDF names the first comment on every page `fitz-A0`): + // the first match could be another page's comment, so a shared id is refused. + let mut named = all.iter().filter(|c| c.name.as_deref() == Some(id)); + let c = named.next().ok_or_else(|| failed(format!("no comment with id {id:?} (see comment_list)")))?; + if named.next().is_some() { + return Err(failed(format!("several comments have the id {id:?}: pass page and index instead (see comment_list)"))); + } return Ok((c.page, c.index)); } let (Some(page), Some(index)) = (a.opt_int("page")?, a.opt_int("index")?) else { @@ -193,33 +200,44 @@ impl Automation { } v }; + // Every reply under the comment its thread starts from, at any depth, in document order: + // a reply to a reply keeps its `/IRT` on the direct parent, so following one level would + // drop it. Parents are found by reference, as `/NM` need not be unique. A status or + // checkmark belongs to the annotation its `/IRT` names (ISO 32000-2 §12.5.6.2), as in the + // Comments panel: one set on a reply is not the comment's. + let tops = pdfcraft_render::thread_tops(&all); + let mut threads: HashMap<(usize, usize), Vec<&Annotation>> = HashMap::new(); + for r in &all { + let Some(&top) = tops.get(&(r.page, r.index)) else { continue }; + let key = match r.parent { + Some(parent) if r.state.is_some() => parent, + _ => top, + }; + threads.entry(key).or_default().push(r); + } + // An annotation's review status and checkmark: the latest of each set on it. + let states = |c: &Annotation, v: &mut Value| { + let set = threads.get(&(c.page, c.index)).map(Vec::as_slice).unwrap_or(&[]); + v["status"] = json!(set.iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.clone())); + v["marked"] = json!(set.iter().rev().find(|r| r.is_mark()).is_some_and(|r| r.state.as_deref() == Some("Marked"))); + }; let roots: Vec = all .iter() - .filter(|c| c.in_reply_to.is_none() && only.is_none_or(|p| c.page == p)) + .filter(|c| c.is_comment() && only.is_none_or(|p| c.page == p)) .map(|c| { let mut v = view(c); - // Every reply under this comment, at any depth, in document order. A reply to a - // reply keeps its `/IRT` on the direct parent, so following one level would drop it. - let descends_from = |r: &Annotation, root: &str| -> bool { - let mut parent = r.in_reply_to.as_deref(); - for _ in 0..64 { - let Some(nm) = parent else { return false }; - if nm == root { - return true; - } - parent = all.iter().find(|x| x.name.as_deref() == Some(nm)).and_then(|x| x.in_reply_to.as_deref()); - } - false - }; - let thread: Vec<&Annotation> = match c.name.as_deref() { - Some(nm) => all.iter().filter(|r| r.in_reply_to.is_some() && descends_from(r, nm)).collect(), - None => Vec::new(), - }; - let status = thread.iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.clone()); - v["status"] = json!(status); - v["marked"] = json!(thread.iter().rev().find(|r| r.is_mark()).is_some_and(|r| r.state.as_deref() == Some("Marked"))); + states(c, &mut v); v["locked"] = json!(c.locked); - v["replies"] = thread.iter().filter(|r| r.state.is_none()).map(|r| view(r)).collect(); + let thread = threads.get(&(c.page, c.index)).map(Vec::as_slice).unwrap_or(&[]); + v["replies"] = thread + .iter() + .filter(|r| r.state.is_none()) + .map(|r| { + let mut reply = view(r); + states(r, &mut reply); + reply + }) + .collect(); v }) .collect(); diff --git a/crates/automation/src/content.rs b/crates/automation/src/content.rs index d0b0b69fd2..c860298d98 100644 --- a/crates/automation/src/content.rs +++ b/crates/automation/src/content.rs @@ -188,8 +188,10 @@ impl Automation { i.flip_v = !i.flip_v; } if let Some(c) = rect_arg(a, "crop")? { - if !c.iter().all(|v| (0.0..0.5).contains(v)) { - return Err(bad("crop takes fractions from 0 to under 0.5 (left, bottom, right, top)")); + if !pdfcraft_engine::AddedImage::crop_valid(c) { + return Err(bad( + "crop takes fractions from 0 to under 1 (left, bottom, right, top); left and right together, and bottom and top together, trim at most 0.95", + )); } i.crop = c; } diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index b9f906f189..5e7653931f 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -174,6 +174,12 @@ impl Automation { self } + /// Multiply the time XFA form scripts may take (tests). See [`Session::with_xfa_time_scale`]. + pub fn with_xfa_time_scale(mut self, factor: u32) -> Self { + self.session = std::mem::take(&mut self.session).with_xfa_time_scale(factor); + self + } + /// The clock and time zone of a host without its own. See [`Session::with_wall_clock`]. pub fn with_wall_clock(mut self, clock: fn() -> i64, utc_offset: fn() -> i64) -> Self { self.session = std::mem::take(&mut self.session).with_wall_clock(clock, utc_offset); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index ccc2ee85cc..85a9680800 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -60,7 +60,10 @@ fn color() -> Value { fn comment_ref(mut extra: Value) -> Value { if let Some(props) = extra.as_object_mut() { props.insert("doc".into(), doc()); - props.insert("id".into(), json!({ "type": "string", "description": "The comment's id (from comment_list)." })); + props.insert( + "id".into(), + json!({ "type": "string", "description": "The comment's id (from comment_list). An id several comments share is refused: pass page and index." }), + ); props.insert("page".into(), json!({ "type": "integer", "minimum": 1, "description": "With `index`, instead of `id`." })); props.insert( "index".into(), @@ -624,7 +627,7 @@ pub fn tools() -> Vec { "align": { "type": "string", "enum": ["left", "center", "right", "justify"] }, "char_spacing": { "type": "number", "minimum": -500, "maximum": 500, "description": "Points after each character." }, "rotate": { "type": "integer" }, "flip_h": { "type": "boolean" }, "flip_v": { "type": "boolean" }, - "crop": { "type": "array", "items": { "type": "number", "minimum": 0, "maximum": 0.49 }, "minItems": 4, "maxItems": 4 }, + "crop": { "type": "array", "items": { "type": "number", "minimum": 0, "maximum": 0.95 }, "minItems": 4, "maxItems": 4, "description": "Fractions trimmed: left, bottom, right, top. Left and right together, and bottom and top together, trim at most 0.95." }, "image": { "type": "string", "description": "Replace the picture with this file." }, }), &["doc", "page", "index"], @@ -700,7 +703,7 @@ pub fn tools() -> Vec { .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.") .cmd("measure.export").with(schema(json!({"doc":doc(),"out":path_arg()}), &["doc","out"])), - t("comment_list", "List comments", "Every comment (annotation other than links, form widgets and pop-ups) with its page, index, id, type, author, text, date, rectangle, colour, review status and replies.") + t("comment_list", "List comments", "Every comment (annotation other than links, form widgets and pop-ups) with its page, index, id, type, author, text, date, rectangle, colour, review status, checkmark and replies (each with its own status and checkmark).") .ro() .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1, "description": "Only this page." } }), &["doc"])), t( @@ -953,7 +956,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 <>. 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.", + "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( diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 9049299702..23351db9aa 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -252,6 +252,19 @@ fn doc_info_reports_the_open_destination() { "{open}" ); assert_eq!(open["destination"]["zoom"].as_f64(), Some(2.0)); + // The initial view reads the named destination's page, and choosing page 1 replaces it (ISO + // 32000-2 §12.3.2.4): one undo step, kept by the save. + let page = |a: &mut Automation, doc: u64| ok(a, "doc_info", json!({ "doc": doc }))["open_destination"]["page"].clone(); + assert_eq!(ok(&mut a, "doc_initial_view", json!({ "doc": doc }))["initial_view"]["page"], 2); + assert_eq!(ok(&mut a, "doc_initial_view", json!({ "doc": doc, "page": 1 }))["initial_view"]["page"], 1); + assert_eq!(page(&mut a, doc), 1); + ok(&mut a, "edit_undo", json!({ "doc": doc })); + assert_eq!(page(&mut a, doc), 2); + ok(&mut a, "edit_redo", json!({ "doc": doc })); + assert_eq!(page(&mut a, doc), 1); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "named-page1.pdf" })); + let saved = ok(&mut a, "doc_open", json!({ "path": "named-page1.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(page(&mut a, saved), 1); let doc = ok(&mut a, "doc_open", json!({ "path": "script.pdf" }))["doc"].as_u64().unwrap(); assert!(ok(&mut a, "doc_info", json!({ "doc": doc }))["open_destination"].is_null()); } @@ -2121,6 +2134,83 @@ fn nested_replies_stay_in_the_listing() { assert!(replies.contains(&"DIRECT_REPLY") && replies.contains(&"NESTED_REPLY"), "the nested reply is listed: {replies:?}"); } +/// Replies thread by their `/IRT` reference, not `/NM`: PyMuPDF names the first comment on every +/// page `fitz-A0`. A shared id is refused rather than taken to mean page 1's comment. +#[test] +fn replies_thread_by_reference_when_ids_repeat_across_pages() { + let dir = workdir("repeated-ids"); + // Contributor-original PDF, shaped like PyMuPDF output. + std::fs::write( + dir.join("fitz.pdf"), + b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R] >> endobj +4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [6 0 R 7 0 R] >> endobj +5 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (fitz-A0) /T (Alpha) /Contents (COVER) >> endobj +6 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (fitz-A0) /T (Beta) /Contents (SECOND) >> endobj +7 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /NM (fitz-A1) /IRT 6 0 R /T (Gamma) /Contents (ANSWER) >> endobj +trailer << /Root 1 0 R >> +%%EOF", + ) + .unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "fitz.pdf" }))["doc"].as_u64().unwrap(); + let replies = |a: &mut Automation| -> Vec<(String, Vec)> { + let list = ok(a, "comment_list", json!({ "doc": doc })); + list["comments"] + .as_array() + .unwrap() + .iter() + .map(|c| { + let mut texts: Vec = + c["replies"].as_array().unwrap().iter().filter_map(|r| r["contents"].as_str().map(String::from)).collect(); + texts.sort(); + (c["contents"].as_str().unwrap().to_string(), texts) + }) + .collect() + }; + assert_eq!(replies(&mut a), [("COVER".into(), vec![]), ("SECOND".into(), vec!["ANSWER".to_string()])]); + let refused = err(&mut a, "comment_reply", json!({ "doc": doc, "id": "fitz-A0", "text": "LATER" })); + assert!(refused.to_string().contains("several comments"), "{refused}"); + ok(&mut a, "comment_reply", json!({ "doc": doc, "page": 2, "index": 1, "text": "LATER" })); + assert_eq!(replies(&mut a), [("COVER".into(), vec![]), ("SECOND".into(), vec!["ANSWER".to_string(), "LATER".to_string()])]); +} + +/// A status or checkmark belongs to the annotation its `/IRT` names: one set on a reply is not +/// its comment's, as the Comments panel shows it, and is reported on the reply. One whose +/// annotation can't be found is not a comment of its own. +#[test] +fn a_checkmark_on_a_reply_is_not_its_comments() { + let dir = workdir("reply-checkmark"); + // Contributor-original PDF: a comment, a reply, a checkmark and a status on the reply, and a + // status whose annotation (object 9) doesn't exist. + std::fs::write( + dir.join("checked.pdf"), + b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R 5 0 R 6 0 R 7 0 R 8 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Text /Rect [100 100 120 120] /T (Alpha) /Contents (COMMENT) >> endobj +5 0 obj << /Type /Annot /Subtype /Text /Rect [100 60 120 80] /IRT 4 0 R /T (Beta) /Contents (REPLY) >> endobj +6 0 obj << /Type /Annot /Subtype /Text /Rect [100 60 120 80] /IRT 5 0 R /T (Beta) /StateModel /Marked /State /Marked >> endobj +7 0 obj << /Type /Annot /Subtype /Text /Rect [100 60 120 80] /IRT 5 0 R /T (Beta) /StateModel /Review /State /Accepted >> endobj +8 0 obj << /Type /Annot /Subtype /Text /Rect [200 20 220 40] /F 30 /IRT 9 0 R /T (Gamma) /StateModel /Review /State /Rejected >> endobj +trailer << /Root 1 0 R >> +%%EOF", + ) + .unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "checked.pdf" }))["doc"].as_u64().unwrap(); + let list = ok(&mut a, "comment_list", json!({ "doc": doc })); + assert_eq!(list["count"], 1, "{list}"); + let c = &list["comments"][0]; + assert_eq!((&c["marked"], &c["status"]), (&json!(false), &Value::Null), "{c}"); + assert_eq!(c["replies"].as_array().map(Vec::len), Some(1), "the reply, not its states: {c}"); + let reply = &c["replies"][0]; + assert_eq!((&reply["marked"], &reply["status"]), (&json!(true), &json!("Accepted")), "{reply}"); +} + #[test] fn protecting_through_tools() { let dir = workdir("protect"); @@ -4394,6 +4484,12 @@ fn adding_content_through_tools() { assert!(text.contains("DRAFT") && !text.contains("CONFIDENTIAL"), "{text}"); // Image tools: rotate, flip, crop, replace. ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 2, "rotate": 90, "flip_h": true, "crop": [0.1, 0, 0.1, 0] })); + // One side may trim most of the picture; together an axis keeps at least 5 %. + ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 2, "crop": [0.7, 0, 0, 0] })); + assert!(matches!( + a.call("content_update", &json!({ "doc": doc, "page": 1, "index": 2, "crop": [0.6, 0, 0.4, 0] })), + Err(ToolError::InvalidArgs(_)) + )); ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 2, "image": "logo.png" })); assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc }))["document"]["dirty"], true); assert!(matches!(a.call("content_update", &json!({ "doc": doc, "page": 1, "index": 2, "rotate": 45 })), Err(ToolError::InvalidArgs(_)))); @@ -5765,6 +5861,28 @@ fn signing_with_an_image_typed_or_drawn_graphic_through_tools() { assert!(inked(60, 110), "the strokes are drawn"); } +/// Signing a laid-out XFA form's signature box through the tools signs the field named by its +/// SOM path, which is how another viewer, laying the form out again, finds the signature; the +/// signed file reopens with the signature intact and on that field. +#[test] +fn a_dynamic_xfa_forms_signature_box_is_signed_under_its_som_path() { + let dir = workdir("xfa-sign"); + std::fs::write(dir.join("form.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::fields_template(""))).unwrap(); + let mut a = auto(&dir); + ok(&mut a, "sign_id_create", json!({ "name": "Ada Lovelace", "key": "p256", "password": "secret12", "path": "ada.p12" })); + let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap(); + let som = "form[0].page1[0].sign[0]"; + let fields = ok(&mut a, "form_fields", json!({ "doc": doc })); + assert!(fields["fields"].as_array().unwrap().iter().any(|f| f["name"] == som), "{fields}"); + ok(&mut a, "sign_document", json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "field": som, "out": "signed.pdf" })); + let signed = ok(&mut a, "doc_open", json!({ "path": "signed.pdf" }))["doc"].as_u64().unwrap(); + let list = ok(&mut a, "sign_list", json!({ "doc": signed })); + let s = list["signatures"].as_array().unwrap().iter().find(|s| s["signed"] == true).cloned().unwrap_or_else(|| panic!("{list}")); + assert_eq!(s["field"], som, "{s}"); + assert_eq!(s["status"], "unknown", "intact, the signer not trusted: {s}"); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn digital_ids_signing_and_validation_through_tools() { let dir = workdir("signing"); @@ -6771,12 +6889,79 @@ BT /F1 11 Tf 20 20 Td (Total due: 150.00) Tj ET"; assert!(rendered.iter().any(|c| matches!(c, Content::Png { .. }))); } +/// #4158: a word set in italic inside a sentence ("Before *Chevron* after.") reaches Word as an +/// italic run of the sentence's paragraph, not as a paragraph of its own between two halves. #[test] -fn hundreds_of_xfa_calculations_open_and_recalculate_within_their_budgets() { +fn exporting_to_word_keeps_an_italic_word_inside_its_sentence() { + let dir = workdir("italic-run-word"); + let content = "BT /F1 12 Tf 72 700 Td (Before ) Tj /F2 12 Tf (Chevron) Tj /F1 12 Tf ( after.) Tj ET \ +BT /F1 12 Tf 72 640 Td (A paragraph further down.) Tj ET"; + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Times-Roman >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Times-Italic >>".into(), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + std::fs::write(dir.join("opinion.pdf"), pdf).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "opinion.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": "opinion.docx" })); + let zip = std::fs::read(dir.join("opinion.docx")).unwrap(); + let mut at = 0; + let mut xml = String::new(); + while zip[at..].starts_with(b"PK\x03\x04") { + let size = u32::from_le_bytes(zip[at + 18..at + 22].try_into().unwrap()) as usize; + let len = u16::from_le_bytes(zip[at + 26..at + 28].try_into().unwrap()) as usize; + let data = at + 30 + len; + if &zip[at + 30..data] == b"word/document.xml" { + std::io::Read::read_to_string(&mut flate2::read::DeflateDecoder::new(&zip[data..data + size]), &mut xml).unwrap(); + break; + } + at = data + size; + } + // The paragraphs that hold text, each as its runs: (text, italic). + let paragraphs: Vec> = xml + .split("") + .skip(1) + .map(|p| { + let p = p.split_once("").map_or(p, |(inside, _)| inside); + p.split("") + .skip(1) + .filter_map(|r| Some((r.split_once("")?.1.split_once("")?.0.to_string(), r.contains("")))) + .collect::>() + }) + .filter(|runs| !runs.is_empty()) + .collect(); + let sentence = paragraphs.iter().find(|p| p.iter().any(|(t, _)| t.contains("Before"))).unwrap_or_else(|| panic!("{xml}")); + assert_eq!(sentence.len(), 3, "one paragraph, three runs: {xml}"); + assert_eq!(sentence.iter().map(|(t, _)| t.as_str()).collect::(), "Before Chevron after.", "{xml}"); + assert_eq!(sentence.iter().map(|(_, i)| *i).collect::>(), [false, true, false], "{xml}"); + assert_eq!(paragraphs.len(), 2, "{xml}"); +} + +/// Hundreds of calculations run on opening and again after a change through the tools. The +/// time limits are raised so that a busy machine does not stop them (the engine's tests check +/// that the limits stop them). +#[test] +fn hundreds_of_xfa_calculations_open_and_recalculate_through_tools() { let dir = workdir("xfa-many"); let (sections, rows) = (10, 20); std::fs::write(dir.join("many.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::many_calculations_template(sections, rows))).unwrap(); - let mut a = auto(&dir); + let mut a = auto(&dir).with_xfa_time_scale(100); let started = std::time::Instant::now(); let opened = ok(&mut a, "doc_open", json!({ "path": "many.pdf" })); let open_took = started.elapsed(); @@ -7199,7 +7384,8 @@ 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); + // Raised time limits: a busy machine does not stop the click scripts this checks. + let mut a = auto(&dir).with_xfa_time_scale(100); 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 })); @@ -7224,7 +7410,8 @@ fn repeated_xfa_print_buttons_work_through_tools_and_after_reopen() { fn xfa_scripts_run_for_buttons_and_field_changes_through_tools() { let dir = workdir("xfa-scripts"); std::fs::write(dir.join("scripted.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template())).unwrap(); - let mut a = auto(&dir); + // Raised time limits: a busy machine does not stop the scripts this checks. + let mut a = auto(&dir).with_xfa_time_scale(100); let doc = ok(&mut a, "doc_open", json!({ "path": "scripted.pdf" }))["doc"].as_u64().unwrap(); let field = |a: &mut Automation, n: &str| { let f = ok(a, "form_fields", json!({ "doc": doc })); diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index c7f2cb0156..3c7f5fbe18 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -1317,6 +1317,17 @@ impl Document { self.overlay.insert(r.num, Slot::Freed(r.generation.saturating_add(1))); } + /// Free what was added since the file was read and nothing refers to any more: the objects + /// [`SaveOptions::drop_unlinked`](crate::SaveOptions::drop_unlinked) leaves out of an + /// incremental save, freed as it writes them (the generation stays). For a writer about to + /// add an object that nothing refers to either, which that option would leave out too. + pub fn free_unlinked_additions(&mut self) { + for num in crate::writer::unlinked_additions(self) { + let generation = self.generation(num); + self.overlay.insert(num, Slot::Freed(generation)); + } + } + /// Record `current`'s object graph as unsaved edits of this document. /// /// Used when the working snapshot was restored from an earlier revision and the next diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index b151399fef..627f716495 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -2,17 +2,20 @@ //! //! * `write_incremental` appends the edited objects, a cross-reference section in the same style //! as the file's last one (table or stream) and a trailer with `/Prev`. The original bytes are -//! never modified, which keeps digital signatures over earlier revisions valid. +//! never modified, which keeps digital signatures over earlier revisions valid. With +//! `SaveOptions::drop_unlinked`, objects added since the file was read that nothing refers to +//! any more are left out (written as free). //! * `write_full` writes only objects reachable from the trailer, renumbered densely, with a //! cross-reference table or stream — the "Save As" / garbage-collecting path. Unknown trailer //! entries are copied, including objects reachable only from them. use std::borrow::Cow; -use std::collections::{BTreeMap, HashMap, VecDeque}; +use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use std::io::Write; +use std::sync::Arc; use crate::CosError; -use crate::document::{Document, ObjectReader}; +use crate::document::{Document, ObjectReader, XrefEntry}; use crate::object::{Dict, ObjRef, Object, PdfString, Stream}; /// Serialize one object. Deterministic: same object → same bytes. @@ -156,11 +159,18 @@ pub struct SaveOptions { /// Full saves: pack non-stream objects into compressed object streams with a cross-reference /// stream (PDF 1.5+, §7.5.7–7.5.8). Off writes a classic table that any reader accepts. pub object_streams: bool, + /// Incremental saves: leave out what was added since the file was read and nothing refers to + /// any more, such as a comment added and deleted again or a redaction mark added and cleared + /// (written as free). Off by default, because a writer may add an object only it finds again + /// by scanning, as a standalone document timestamp is; such a writer frees the leftovers + /// before adding it, with [`Document::free_unlinked_additions`]. Full saves always leave + /// these out. + pub drop_unlinked: bool, } impl Default for SaveOptions { fn default() -> Self { - Self { mod_date: None, id_seed: 0x5052_494E_5443_5241, object_streams: true } + Self { mod_date: None, id_seed: 0x5052_494E_5443_5241, object_streams: true, drop_unlinked: false } } } @@ -209,8 +219,14 @@ pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result, 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) + let unlinked = if opts.drop_unlinked { unlinked_additions(&doc) } else { HashSet::new() }; for (num, generation, obj) in doc.overlay_entries() { match obj { + // Written as free, so `/Size` still covers every number this document handed out. The + // generation stays: the number was never in use, and an undo can bring it back as is. + Some(_) if unlinked.contains(&num) => { + offsets.insert(num, (0, generation, false)); + } Some(o) => { offsets.insert(num, (base + out.len() as u64, generation, true)); write_indirect(num, generation, &prepared(&doc, num, num, generation, &o), &mut out); @@ -387,7 +403,7 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosErro /// Object `r` read again for writing. The traversal read it once and only kept its number, so /// a second read that fails, finds nothing or finds a different kind of object must fail the /// save rather than write `null` in its place. -fn reread(reader: &mut ObjectReader, r: ObjRef, is_stream: bool) -> Result, CosError> { +fn reread(reader: &mut ObjectReader, r: ObjRef, is_stream: bool) -> Result, CosError> { let o = reader.try_get(r)?; if matches!(*o, Object::Null) || matches!(*o, Object::Stream(_)) != is_stream { return Err(CosError::Syntax { offset: 0, detail: format!("object {} read differently while saving; nothing was written", r.num) }); @@ -439,6 +455,37 @@ fn output_number(index: usize) -> Result { u32::try_from(index).ok().and_then(|n| n.checked_add(1)).ok_or_else(|| CosError::Syntax { offset: 0, detail: "too many objects to save".into() }) } +/// Objects added since the file was read (their numbers are not in use in it) that nothing an +/// incremental update writes refers to: what one edit added and a later one unlinked, such as a +/// deleted comment or a cleared redaction mark. The file never held them, so leaving them out +/// loses nothing, while writing them would keep what the user removed. Only the update is walked: +/// from the trailer and the file's changed objects through additions. An unchanged object of the +/// file is not followed: a reference it holds to such a number already reads as null in the file +/// (a damaged file's dangling reference), and it still does. +pub(crate) fn unlinked_additions(doc: &Document) -> HashSet { + let added = |num: u32| matches!(doc.xref_entry(num), None | Some(XrefEntry::Free { .. })); + let mut pending: HashMap> = + doc.overlay_entries().filter_map(|(num, _, o)| Some((num, o?))).filter(|(num, _)| added(*num)).collect(); + if pending.is_empty() { + return HashSet::new(); + } + let mut stack: Vec = Vec::new(); + doc.trailer().iter().for_each(|(_, v)| collect_refs(v, &mut |r| stack.push(r))); + for (num, _, o) in doc.overlay_entries() { + if let Some(o) = o + && !added(num) + { + collect_refs(&o, &mut |r| stack.push(r)); + } + } + while let Some(r) = stack.pop() { + if let Some(o) = pending.remove(&r.num) { + collect_refs(&o, &mut |r| stack.push(r)); + } + } + pending.into_keys().collect() +} + fn collect_refs(o: &Object, f: &mut impl FnMut(ObjRef)) { match o { Object::Ref(r) => f(*r), diff --git a/crates/cos/tests/it/writer.rs b/crates/cos/tests/it/writer.rs index ac04be2e94..1b98664255 100644 --- a/crates/cos/tests/it/writer.rs +++ b/crates/cos/tests/it/writer.rs @@ -228,6 +228,63 @@ fn incremental_save_keeps_a_trailer_only_edit() { assert_qpdf_clean(&saved); } +/// What one edit added and a later one unlinked (a deleted comment, a cleared redaction mark) is +/// not written: the file never held it, and writing it would keep what was removed. Additions the +/// update still refers to, from the trailer or a changed object and through other additions, are. +#[test] +fn incremental_save_leaves_out_additions_nothing_refers_to() { + let mut doc = Document::open(Arc::new(classic_table())).unwrap(); + let text = |s: &str| Object::String(pdfcraft_cos::PdfString::literal(s.as_bytes().to_vec())); + let dict = |entries: &[(&str, Object)]| { + let mut d = Dict::new(); + entries.iter().for_each(|(k, v)| d.set(k.as_bytes().to_vec(), v.clone())); + Object::Dict(d) + }; + let page_obj = ObjRef::new(3, 0); + let leaf = doc.add(text("kept leaf")); + let kept = doc.add(dict(&[("Leaf", Object::Ref(leaf))])); + doc.update_dict(page_obj, |d| d.set(b"PieceInfo".to_vec(), Object::Ref(kept))).unwrap(); + let by_trailer = doc.add(text("kept by the trailer")); + doc.trailer_mut().set(b"Custom".to_vec(), Object::Ref(by_trailer)); + // Linked from the page and unlinked again, as clearing a mark does. + let dropped_leaf = doc.add(text("dropped leaf")); + let dropped = doc.add(dict(&[("Leaf", Object::Ref(dropped_leaf))])); + doc.update_dict(page_obj, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(dropped)]))).unwrap(); + doc.update_dict(page_obj, |d| { + d.remove(b"Annots"); + }) + .unwrap(); + // Unlinked additions that refer to each other. + let first = doc.add(Object::Null); + let second = doc.add(dict(&[("Next", Object::Ref(first)), ("Note", text("dropped cycle"))])); + doc.set(first, dict(&[("Next", Object::Ref(second))])); + + // Off by default: a writer may add what only it finds again, by scanning. + let kept_all = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert!(kept_all.windows(12).any(|w| w == b"dropped leaf")); + let saved = write_incremental(&doc, &SaveOptions { drop_unlinked: true, ..SaveOptions::default() }).unwrap(); + let holds = |s: &str| saved.windows(s.len()).any(|w| w == s.as_bytes()); + assert!(holds("kept leaf") && holds("kept by the trailer")); + assert!(!holds("dropped leaf") && !holds("dropped cycle"), "unlinked additions are written"); + let back = Document::open(Arc::new(saved.clone())).unwrap(); + assert!(back.repair_log().is_empty(), "{:?}", back.repair_log()); + assert_eq!(back.get(leaf).as_string().map(|s| s.bytes.as_slice()), Some(b"kept leaf".as_slice())); + for gone in [dropped, dropped_leaf, first, second] { + assert_eq!(*back.get(gone), Object::Null); + } + assert!(!page(&back).contains(b"Annots")); + assert_qpdf_clean(&saved); + + // Freed beforehand, they are written the same way without the option, and what is added + // after that, nothing referring to it either (a document timestamp), is kept. + let mut freed = doc.clone(); + freed.free_unlinked_additions(); + assert_eq!(write_incremental(&freed, &SaveOptions::default()).unwrap(), saved, "the same objects, freed the same way"); + freed.add(text("added after")); + let stamped = write_incremental(&freed, &SaveOptions::default()).unwrap(); + assert!(stamped.windows(11).any(|w| w == b"added after") && !stamped.windows(12).any(|w| w == b"dropped leaf")); +} + #[test] fn qpdf_accepts_object_stream_output() { let qpdf = pdfcraft_oracle::or_skip!(pdfcraft_oracle::qpdf()); diff --git a/crates/edit/src/added.rs b/crates/edit/src/added.rs index 4157c57e40..d292c0869f 100644 --- a/crates/edit/src/added.rs +++ b/crates/edit/src/added.rs @@ -149,7 +149,8 @@ pub struct AddedImage { pub rotation: u8, pub flip_h: bool, pub flip_v: bool, - /// The fraction trimmed from each side of the image: left, bottom, right, top. + /// The fraction trimmed from each side of the image: left, bottom, right, top (see + /// [`AddedImage::crop_valid`]). pub crop: [f64; 4], } @@ -158,12 +159,43 @@ impl AddedImage { AddedImage { rect, image, rotation: 0, flip_h: false, flip_v: false, crop: [0.0; 4] } } + /// The least of the image's width and of its height that a crop keeps. + pub const MIN_CROP_KEEP: f64 = 0.05; + + /// How far a sum of two sides may pass the most a crop trims: `0.05 + 0.9` is + /// `0.9500000000000001` in floating point and still keeps 5 %. + const CROP_ROUNDING: f64 = 1e-9; + + /// Whether `crop` trims every side by a fraction from 0 to under 1 and keeps at least + /// [`AddedImage::MIN_CROP_KEEP`] across each axis: one side may trim 95 % when its opposite + /// trims nothing. + pub fn crop_valid(crop: [f64; 4]) -> bool { + let [l, b, r, t] = crop; + let most = 1.0 - Self::MIN_CROP_KEEP; + crop.iter().all(|v| v.is_finite() && (0.0..1.0).contains(v)) && l + r <= most + Self::CROP_ROUNDING && b + t <= most + Self::CROP_ROUNDING + } + + /// `crop` brought into [`AddedImage::crop_valid`] range, for one read from a file: each side + /// in range, and an axis that trims too much keeps its two sides' proportion. + pub fn clamp_crop(crop: [f64; 4]) -> [f64; 4] { + let most = 1.0 - Self::MIN_CROP_KEEP; + let [l, b, r, t] = crop.map(|v| if v.is_finite() { v.max(0.0) } else { 0.0 }); + // Scaled from the halves, whose sum can't overflow, so the pair keeps its proportion; + // a pair within the limit keeps each side within it too. + let fit = |a: f64, c: f64| { + let half = a / 2.0 + c / 2.0; + if half > (most + Self::CROP_ROUNDING) / 2.0 { (a / 2.0 / half * most, c / 2.0 / half * most) } else { (a, c) } + }; + let ((l, r), (b, t)) = (fit(l, r), fit(b, t)); + [l, b, r, t] + } + /// The matrix from image space (the unit square) to display space: crop, flip, rotate, /// then fill the box. fn matrix(&self) -> pdfcraft_content::Matrix { use pdfcraft_content::Matrix as M; - let [l, b, r, t] = self.crop.map(|v| v.clamp(0.0, 0.45)); - let (cw, ch) = ((1.0 - l - r).max(0.05), (1.0 - b - t).max(0.05)); + let [l, b, r, t] = Self::clamp_crop(self.crop); + let (cw, ch) = (1.0 - l - r, 1.0 - b - t); let mut m = M([1.0 / cw, 0.0, 0.0, 1.0 / ch, -l / cw, -b / ch]); if self.flip_h { m = m.then(&M([-1.0, 0.0, 0.0, 1.0, 1.0, 0.0])); @@ -1225,6 +1257,21 @@ pub fn delete_content(doc: &mut Document, page: usize, index: usize) -> Result<( Ok(()) } +/// Leave only item `index` on `page`: its contents become the item's own stream and its +/// annotations go, so a render of the page (on a transparent backdrop) is the item's pixels +/// alone. For previews on a copy of the document; the page's resources stay as they are. +pub fn isolate_content(doc: &mut Document, page: usize, index: usize) -> Result<(), EditError> { + let a = find(doc, page, index)?; + let p = page_list(doc).swap_remove(page); + // An item's stream starts from the page's initial graphics state: `place_tagged` closes + // everything drawn before it, so it draws the same on its own. + doc.update_dict(p.obj, |d| { + d.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(a.obj)])); + d.remove(b"Annots"); + })?; + Ok(()) +} + /// The Type 3 fonts an Arabic item's stream shows text with. Each item has its own (`draw_shaped` /// names them so), so they go when the item is replaced or deleted. fn own_fonts(doc: &Document, obj: ObjRef) -> Vec> { diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 6f14271be4..5180e76018 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -539,6 +539,11 @@ pub(crate) fn selectable_images( /// Find the saved graphics-state scope containing just this image and its crop. Applying /// the transform before that scope moves the actual clipping path as well as the pixels. /// A shared clip must never be moved along with unrelated artwork or text. +/// +/// Under a clip set outside every scope that holds only this image (a report or table drawing +/// each picture as `q re W n cm Do Q` inside a section's clip), no scope contains every active +/// clip. The outermost scope holding only the image is used then: it contains all of the +/// image's private clips, which move with it, while the outer clip is shared and stays. fn image_transform_scope(ops: &[Op], target: usize) -> Result, EditError> { struct Scope { start: usize, @@ -556,6 +561,7 @@ fn image_transform_scope(ops: &[Op], target: usize) -> Result { @@ -566,9 +572,13 @@ fn image_transform_scope(ops: &[Op], target: usize) -> Result { if let Some(scope) = stack.pop() { - if scope.selected && !scope.other_paint && !scope.inherited_clip && !scope.inherited_path { - // Prefer the innermost scope that contains every active clipping path. - isolated.get_or_insert((scope.start, index, scope.matrix)); + if scope.selected && !scope.other_paint && !scope.inherited_path { + if !scope.inherited_clip { + // Prefer the innermost scope that contains every active clipping path. + isolated.get_or_insert((scope.start, index, scope.matrix)); + } + // Scopes close inner first, so the last one recorded is the outermost. + outermost = Some((scope.start, index, scope.matrix)); } matrix = scope.matrix; clipped = scope.inherited_clip; @@ -611,7 +621,7 @@ fn image_transform_scope(ops: &[Op], target: usize) -> Result>` page number, `<>` page count, `<<1 of n>>`, `<<1/n>>`, `<>`, /// `<>`, dates `<>`, `<>`, `<>`, `<>`, - /// `<>`, `<>`, `<>`, `<>`, and Bates numbers `<>` + /// `<>`, `<>`, `<>`, `<>`, the dotted day-first + /// dates of German-speaking and much of Central and Eastern Europe `<>`, + /// `<>`, `<>`, `<>`, 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. @@ -303,6 +305,10 @@ pub fn expand(template: &str, page_number: u32, count: usize, bates: u64, cx: &C "d/m/yy" => format!("{d}/{m}/{:02}", y.rem_euclid(100)), "d/m/yyyy" => format!("{d}/{m}/{y}"), "dd/mm/yyyy" => format!("{d:02}/{m:02}/{y}"), + "d.m.yy" => format!("{d}.{m}.{:02}", y.rem_euclid(100)), + "d.m.yyyy" => format!("{d}.{m}.{y}"), + "dd.mm.yy" => format!("{d:02}.{m:02}.{:02}", y.rem_euclid(100)), + "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}"), @@ -1056,8 +1062,8 @@ pub use text::{ }; pub mod added; pub use added::{ - Added, AddedImage, AddedText, Align, Content, Family, MarkerScan, add_content, delete_content, first_undrawable, list_added, scan_markers, - update_content, + Added, AddedImage, AddedText, Align, Content, Family, MarkerScan, add_content, delete_content, first_undrawable, isolate_content, list_added, + scan_markers, update_content, }; #[cfg(test)] diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 3ad46e8643..0f8adc6514 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -75,6 +75,31 @@ fn an_image_turns_by_any_angle_about_its_centre_and_saves_the_matrix() { assert!(images::turn_by_degrees(r0, f64::NAN).is_none() && images::turn_by_degrees(r0, f64::INFINITY).is_none()); } +#[test] +fn an_added_image_crop_keeps_some_of_each_axis() { + use crate::added::AddedImage; + assert!(AddedImage::crop_valid([0.7, 0.0, 0.0, 0.95])); + assert!(AddedImage::crop_valid([0.5, 0.45, 0.45, 0.5])); + // 0.05 + 0.9 rounds to just above 0.95: still the 5 % kept, accepted and left as it is. + assert!(AddedImage::crop_valid([0.05, 0.0, 0.9, 0.0])); + assert_eq!(AddedImage::clamp_crop([0.05, 0.0, 0.9, 0.0]), [0.05, 0.0, 0.9, 0.0]); + assert!(!AddedImage::crop_valid([0.06, 0.0, 0.9, 0.0]), "more than 95 % across"); + assert!(!AddedImage::crop_valid([0.6, 0.0, 0.4, 0.0]), "nothing left across"); + assert!(!AddedImage::crop_valid([-0.1, 0.0, 0.0, 0.0])); + assert!(!AddedImage::crop_valid([f64::NAN, 0.0, 0.0, 0.0])); + assert_eq!(AddedImage::clamp_crop([0.7, 0.0, 0.0, 0.0]), [0.7, 0.0, 0.0, 0.0], "a valid crop stays as it is"); + // From a file: too much across keeps the two sides' proportion; nonsense trims nothing. + let [l, b, r, t] = AddedImage::clamp_crop([0.6, f64::INFINITY, 0.6, -1.0]); + assert!((l - 0.475).abs() < 1e-12 && (r - 0.475).abs() < 1e-12 && b == 0.0 && t == 0.0); + // Sides beyond the whole image keep their proportion too, and huge ones don't overflow. + let [l, _, r, _] = AddedImage::clamp_crop([1.5, 0.0, 0.5, 0.0]); + assert!((l - 0.7125).abs() < 1e-12 && (r - 0.2375).abs() < 1e-12, "{l} {r}"); + let [l, _, r, _] = AddedImage::clamp_crop([f64::MAX, 0.0, f64::MAX, 0.0]); + assert!((l - 0.475).abs() < 1e-12 && (r - 0.475).abs() < 1e-12, "{l} {r}"); + let [l, _, r, _] = AddedImage::clamp_crop([1.5, 0.0, 0.0, 0.0]); + assert!((l - 0.95).abs() < 1e-12 && r == 0.0, "one side alone is capped at the limit"); +} + #[test] fn tokens_expand() { let c = cx(); @@ -82,6 +107,7 @@ fn tokens_expand() { 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, 0, &c), "01.10.2026 1.10.2026 01.10.26 1.10.26"); assert_eq!(expand("<>", 1, 1, 104, &c), "ABC000104-X"); assert_eq!(expand("keep <> and <> and <>"), Some(100)); @@ -983,6 +1009,28 @@ fn added_text_and_images_are_page_content_that_stays_editable() { assert!(marks_present(&doc).is_empty()); } +/// #4464: a page left with one added item draws that item and nothing else. +#[test] +fn an_isolated_item_is_all_that_is_left_on_its_page() { + let mut doc = fixture(); + let first = AddedText { rect: [72.0, 600.0, 300.0, 700.0], text: "First".into(), size: 14.0, ..AddedText::default() }; + let second = AddedText { rect: [72.0, 400.0, 300.0, 500.0], text: "Second".into(), ..first.clone() }; + add_content(&mut doc, 0, &Content::Text(first)).unwrap(); + add_content(&mut doc, 0, &Content::Text(second)).unwrap(); + let before = streams(&doc, 0).join("\n"); + assert!(before.contains("(First) Tj") && before.contains("(Second) Tj"), "{before}"); + assert!(isolate_content(&mut doc, 0, 2).is_err(), "no third item"); + assert!(isolate_content(&mut doc, 9, 0).is_err(), "no such page"); + let page = pdfcraft_model::pages(&doc).swap_remove(0); + doc.update_dict(page.obj, |d| d.set(b"Annots".to_vec(), Object::Array(Vec::new()))).unwrap(); + isolate_content(&mut doc, 0, 1).unwrap(); + let after = streams(&doc, 0).join("\n"); + assert!(after.contains("(Second) Tj") && !after.contains("(First) Tj"), "{after}"); + let page = pdfcraft_model::pages(&doc).swap_remove(0); + assert!(page.dict.get(b"Annots").is_none(), "no annotations are drawn over it"); + assert_eq!(list_added(&doc).len(), 1); +} + /// Edit ▸ Paste (#2636): where a pasted item lands. #[test] fn a_paste_lands_where_the_copy_was_offset_per_paste_and_stays_on_the_page() { @@ -4978,6 +5026,40 @@ fn cropped_image_resizing_moves_private_clips_and_keeps_shared_ones() { assert_eq!(text::text_lines(&doc, 0).unwrap().len(), 1); } +/// Report generators draw each picture as `q re W n cm Do Q` inside a section's clip that the +/// section's text shares. Resizing the picture scales its own clip with it (the enlarged picture +/// used to show only through its old box) while the section's clip and text stay put. +#[test] +fn resizing_an_image_under_a_shared_clip_moves_its_own_clip() { + // The picture's own clip as wide as the picture, and wider (both seen in one report). + for clip_width in [61.2, 85.0] { + let mut doc = image_page_with(&format!( + "q 20 20 560 760 re W n q 162 79 {clip_width} 28.3 re W n 61.2 0 0 28.3 162 79 cm /Im0 Do Q BT /F1 12 Tf 30 50 Td (Section text) Tj ET Q" + )); + let before = images::page_images(&doc, 0).unwrap()[0].clone(); + assert!(close(before.visible_rect.unwrap(), [162.0, 79.0, 223.2, 107.3]), "{:?}", before.visible_rect); + let caption = text::text_lines(&doc, 0).unwrap(); + let target = [162.0, 79.0, 284.4, 135.6]; + let transform = images::rect_to_rect(before.visible_rect.unwrap(), target); + images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(transform)).unwrap(); + doc = reopen(&doc); + let after = images::page_images(&doc, 0).unwrap()[0].clone(); + let near = |a: [f64; 4], b: [f64; 4]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.05); + assert!(near(after.visible_rect.unwrap(), target), "clip {clip_width}: the whole enlarged picture shows, {:?}", after.visible_rect); + assert_eq!(after.object, before.object, "pixels are never resampled or replaced"); + assert_eq!( + text::text_lines(&doc, 0).unwrap().iter().map(|l| (&l.text, l.rect, l.baseline())).collect::>(), + caption.iter().map(|l| (&l.text, l.rect, l.baseline())).collect::>(), + "the section's text stays where it was" + ); + // The section's clip is shared, so it still bounds the picture: past it, nothing shows. + let past = images::rect_to_rect(after.visible_rect.unwrap(), [500.0, 79.0, 700.0, 135.6]); + images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(past)).unwrap(); + let clipped = images::page_images(&reopen(&doc), 0).unwrap()[0].visible_rect.unwrap(); + assert!(clipped[2] <= 580.05, "the section's clip stays in force: {clipped:?}"); + } +} + #[test] fn private_image_crop_with_intervening_state_resizes_across_streams() { let variants: Vec> = vec![ diff --git a/crates/engine/src/bookmarks.rs b/crates/engine/src/bookmarks.rs new file mode 100644 index 0000000000..deaece22e4 --- /dev/null +++ b/crates/engine/src/bookmarks.rs @@ -0,0 +1,61 @@ +//! The bookmark list. + +/// A bookmark as the bookmark list shows it. +#[derive(Clone, Debug, PartialEq)] +pub struct BookmarkRow { + /// Child indices from the top level, 0-based, as the edit functions take them. + pub path: Vec, + pub title: String, + /// Shown expanded (a positive `/Count`). + pub open: bool, + /// The 0-based page it goes to. + pub page: Option, + /// Where on that page it goes ([`pdfcraft_render::DestView::Top`] when it doesn't say). + pub view: pdfcraft_render::DestView, + /// The title's colour (`/C`), `None` for the default black, and its style (`/F`). + pub color: Option<[f32; 3]>, + pub italic: bool, + pub bold: bool, +} + +/// One page of the bookmark list, in outline order. +#[derive(Clone, Debug, PartialEq)] +pub struct BookmarkPage { + pub rows: Vec, + /// Where the next page starts, or `None` at the end. + pub next: Option, + /// Set only when the document's structure could not be read for editing: its listing then + /// comes from the viewer's copy of the outline, which is shorter than the file's. + pub truncated: bool, +} + +/// The rows of a viewer outline, in outline order, with their paths. +pub(crate) fn outline_rows(items: &[pdfcraft_render::OutlineItem]) -> Vec { + let mut rows = Vec::new(); + // One entry per open level: its items, and how many of them have been listed. + let mut levels: Vec<(&[pdfcraft_render::OutlineItem], usize)> = vec![(items, 0)]; + while let Some(&(list, next)) = levels.last() { + let Some(item) = list.get(next) else { + levels.pop(); + continue; + }; + if let Some(top) = levels.last_mut() { + top.1 += 1; + } + let path: Vec = levels.iter().map(|(_, listed)| listed.saturating_sub(1)).collect(); + rows.push(BookmarkRow { + path, + title: item.title.clone(), + open: item.open, + page: item.page, + view: item.view, + color: item.color, + italic: item.italic, + bold: item.bold, + }); + if !item.children.is_empty() { + levels.push((&item.children, 0)); + } + } + rows +} diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 0134d39af8..e479232bbb 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -550,6 +550,23 @@ pub fn group(id: &str) -> Option<&'static ToolGroup> { TOOL_GROUPS.iter().find(|g| g.id == id) } +/// A heading of the expanded All tools view, as Acrobat files its tools under "Create & Edit", +/// "Forms & Signatures" and so on. Every group belongs to exactly one category. +#[derive(Clone, Copy, Debug)] +pub struct ToolCategory { + pub label: &'static str, + /// [`ToolGroup::id`]s, in display order. + pub groups: &'static [&'static str], +} + +pub static TOOL_CATEGORIES: &[ToolCategory] = &[ + ToolCategory { label: "Create & Edit", groups: &["create", "edit", "export", "combine", "organize", "scan", "compress"] }, + ToolCategory { label: "Review & Compare", groups: &["comment", "stamp", "compare", "measure"] }, + ToolCategory { label: "Forms & Signatures", groups: &["fill_sign", "form", "certificate"] }, + ToolCategory { label: "Protect & Standardize", groups: &["protect", "redact", "accessibility", "standards", "print_production"] }, + ToolCategory { label: "Customize & Automate", groups: &["actions", "javascript", "search_index", "ai"] }, +]; + #[cfg(test)] mod tests { use super::*; @@ -564,6 +581,18 @@ mod tests { } } + #[test] + fn every_group_is_in_exactly_one_category() { + let mut seen = HashSet::new(); + for c in TOOL_CATEGORIES { + for id in c.groups { + assert!(group(id).is_some(), "{} lists unknown group {id}", c.label); + assert!(seen.insert(*id), "{id} is in two categories"); + } + } + assert_eq!(seen.len(), TOOL_GROUPS.len(), "a group is in no category"); + } + #[test] fn commands_are_namespaced() { for g in TOOL_GROUPS { diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 4ebfecafc7..1f25c23c98 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -231,7 +231,23 @@ pub const COMMANDS: &[CommandSpec] = &[ c("view.previous_view", "Previous view", None, Some(Shortcut::cmd("[")), Document, "chevron-left"), c("view.next_view", "Next view", None, Some(Shortcut::cmd("]")), Document, "chevron-right"), c("view.focus_page_input", "Go to page…", VIEW, Some(Shortcut::cmd_shift("N")), Document, "text-cursor-input"), - c("ui.dock.reset", "Reset panel layout", VIEW, None, Nothing, "settings"), + c( + "view.side_panel", + "Show or hide the side panel", + VIEW, + Some(Shortcut { command: false, shift: false, mac_ctrl: false, key: "F4" }), + Nothing, + "panel-right", + ), + c( + "view.tools_pane", + "Show or hide the Tools panel", + VIEW, + Some(Shortcut { command: false, shift: true, mac_ctrl: false, key: "F4" }), + Nothing, + "panel-left", + ), + c("ui.dock.reset", "Reset side panels", VIEW, None, Nothing, "settings"), c("view.theme", "Switch light / dark theme", None, None, Nothing, "moon"), c("view.theme.system", "Use system setting", None, None, Nothing, "settings"), c("view.theme.light", "Light gray", None, None, Nothing, "sun"), diff --git a/crates/engine/src/comments.rs b/crates/engine/src/comments.rs new file mode 100644 index 0000000000..eee799ed2f --- /dev/null +++ b/crates/engine/src/comments.rs @@ -0,0 +1,163 @@ +//! The comment list and its summary. + +/// The comment list, read from the object graph (as `inspect` lists comments). +pub(crate) fn comment_list(doc: &pdfcraft_cos::Document) -> Vec { + let mut list: Vec = pdfcraft_annot::summaries(doc) + .into_iter() + .map(|s| pdfcraft_render::Annotation { + page: s.page, + subtype: s.subtype, + author: s.author, + contents: s.contents, + modified: s.modified.map(|m| pdfcraft_render::pretty_date(&m)), + name: s.name, + in_reply_to: s.in_reply_to, + parent: s.parent, + grouped: s.grouped, + rect: s.rect, + color: s.color, + index: s.index, + state: s.state, + quads: s.quads, + locked: s.locked, + intent: s.intent, + fill_sign: s.fill_sign, + no_rotate: s.no_rotate, + points: s.points, + }) + .collect(); + pdfcraft_render::link_replies(&mut list); + list +} + +/// Order of Summarize Comments. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SummarySort { + #[default] + Page, + Author, + Date, + Type, +} + +impl SummarySort { + pub const ALL: [SummarySort; 4] = [SummarySort::Page, SummarySort::Author, SummarySort::Date, SummarySort::Type]; + pub fn name(self) -> &'static str { + match self { + SummarySort::Page => "Page", + SummarySort::Author => "Author", + SummarySort::Date => "Date", + SummarySort::Type => "Type", + } + } + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|k| k.name().eq_ignore_ascii_case(s)) + } +} + +/// Acrobat's name for a comment type, as comment lists show it. +pub fn comment_type_name(subtype: &str) -> &str { + match subtype { + "Text" => "Sticky Note", + "FreeText" => "Text Box", + "Highlight" => "Highlight", + "Underline" => "Underline", + "StrikeOut" => "Strikethrough", + "Squiggly" => "Squiggly", + "Square" => "Rectangle", + "Circle" => "Oval", + "Line" => "Line", + "Ink" => "Pencil", + "Polygon" => "Polygon", + "PolyLine" => "Polygonal Line", + "Stamp" => "Stamp", + "Caret" => "Inserted Text", + "FileAttachment" => "File Attachment", + "Sound" => "Sound", + "Redact" => "Redaction", + other => other, + } +} + +/// [`comment_type_name`], telling callouts, clouds and typewriter text apart by `/IT`. +pub fn comment_kind(a: &pdfcraft_render::Annotation) -> &str { + match a.intent.as_deref() { + Some("FreeTextCallout") => "Callout", + Some("PolygonCloud") => "Cloud", + Some("FreeTextTypeWriter") => "Typewriter", + _ => comment_type_name(&a.subtype), + } +} + +/// The text of a comment summary: one block per comment (replies indented under it), with a +/// "Page N" heading when sorted by page. Checkmarks and status replies are left out, as are +/// pop-ups; the number is the comment's position on its page. +pub fn comment_summary(name: &str, all: &[pdfcraft_render::Annotation], sort: SummarySort) -> String { + use std::fmt::Write; + let top: Vec<&pdfcraft_render::Annotation> = all.iter().filter(|a| a.is_comment()).collect(); + // Each reply under the annotation its `/IRT` points at, in document order, gathered once. + type Children<'a> = std::collections::HashMap<(usize, usize), Vec<&'a pdfcraft_render::Annotation>>; + let mut children = Children::new(); + for r in all.iter().filter(|r| r.state.is_none()) { + if let Some(parent) = r.parent { + children.entry(parent).or_default().push(r); + } + } + // Write a comment's replies, indenting each level. A reply to a reply keeps its `/IRT` on the + // direct parent, so following one level would leave the deeper replies out of the summary. + fn write_replies(out: &mut String, children: &Children<'_>, parent: &pdfcraft_render::Annotation, depth: usize) { + use std::fmt::Write; + if depth > 64 { + return; + } + for r in children.get(&(parent.page, parent.index)).into_iter().flatten() { + let indent = " ".repeat(depth + 1); + let _ = + writeln!(out, "{indent}Author: {} Subject: Reply Date: {}", r.author.as_deref().unwrap_or(""), r.modified.as_deref().unwrap_or("")); + for line in r.contents.as_deref().unwrap_or("").lines() { + let _ = writeln!(out, "{indent}{line}"); + } + write_replies(out, children, r, depth + 1); + } + } + let mut numbered: Vec<(usize, &pdfcraft_render::Annotation)> = Vec::new(); + let mut last = usize::MAX; + let mut n = 0; + for a in &top { + if a.page != last { + last = a.page; + n = 0; + } + n += 1; + numbered.push((n, a)); + } + match sort { + SummarySort::Page => {} + SummarySort::Author => numbered.sort_by(|x, y| x.1.author.cmp(&y.1.author)), + SummarySort::Date => numbered.sort_by(|x, y| x.1.modified.cmp(&y.1.modified)), + SummarySort::Type => numbered.sort_by(|x, y| comment_kind(x.1).cmp(comment_kind(y.1))), + } + let mut out = format!("Summary of Comments on {name}\n\n"); + if numbered.is_empty() { + out.push_str("This document has no comments.\n"); + return out; + } + let mut heading = usize::MAX; + for (n, a) in numbered { + if sort == SummarySort::Page && a.page != heading { + heading = a.page; + let _ = writeln!(out, "Page: {}", a.page + 1); + } + let _ = write!(out, "Number: {n} Author: {} Subject: {}", a.author.as_deref().unwrap_or(""), comment_kind(a)); + if sort != SummarySort::Page { + let _ = write!(out, " Page: {}", a.page + 1); + } + let _ = writeln!(out, " Date: {}", a.modified.as_deref().unwrap_or("")); + if let Some(c) = a.contents.as_deref().filter(|c| !c.is_empty()) { + let _ = writeln!(out, "{c}"); + } + write_replies(&mut out, &children, a, 0); + out.push('\n'); + } + out +} diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs index 1326ccb5e8..044e40e2e2 100644 --- a/crates/engine/src/compare.rs +++ b/crates/engine/src/compare.rs @@ -220,7 +220,10 @@ impl crate::Document { .filter(|b| !b.text.trim().is_empty()) .map(|b| { let f = b.base_font.to_ascii_lowercase(); + // `text` is trimmed; the editor text keeps the source's own spaces. + let space_around = [b.editor_text.starts_with(char::is_whitespace), b.editor_text.ends_with(char::is_whitespace)]; pdfcraft_export::Block { + space_around, text: b.text, rect: visible_rect(b.rect), size: b.size * unit, diff --git a/crates/engine/src/dates.rs b/crates/engine/src/dates.rs index 606ca53c65..c9382c1b02 100644 --- a/crates/engine/src/dates.rs +++ b/crates/engine/src/dates.rs @@ -63,7 +63,7 @@ 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; 34] = [ +pub static DATE_LANGUAGES: [DateLanguage; 35] = [ DateLanguage { code: "en", name: "English", @@ -267,6 +267,17 @@ pub static DATE_LANGUAGES: [DateLanguage; 34] = [ days: CKB_DAYS, days_short: CKB_DAYS, }, + // Persian (Farsi); Gregorian month and weekday names, right to left like Arabic and Kurdish. + // Persian does not change the month form after a day number, so `months_with_day` is `None`. + DateLanguage { + code: "fa", + name: "فارسی", + months: ["ژانویه", "فوریه", "مارس", "آوریل", "مه", "ژوئن", "ژوئیه", "اوت", "سپتامبر", "اکتبر", "نوامبر", "دسامبر"], + months_with_day: None, + months_short: ["ژانویه", "فوریه", "مارس", "آوریل", "مه", "ژوئن", "ژوئیه", "اوت", "سپتامبر", "اکتبر", "نوامبر", "دسامبر"], + days: ["یکشنبه", "دوشنبه", "سه‌شنبه", "چهارشنبه", "پنج‌شنبه", "جمعه", "شنبه"], + days_short: ["یک", "دو", "سه", "چه", "پن", "جم", "شن"], + }, DateLanguage { code: "pl", name: "Polski", diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs new file mode 100644 index 0000000000..7655943564 --- /dev/null +++ b/crates/engine/src/document.rs @@ -0,0 +1,770 @@ +//! An open document: its working file, view data and edit state. + +use std::sync::{Arc, OnceLock}; + +use pdfcraft_cos::{SaveOptions, write_incremental}; +use pdfcraft_render::{DocInfo, RenderConfig, RenderPool}; + +use crate::history::Editor; +use crate::{ + CalculationOrder, CheckStyle, DocId, Edit, EditError, EditableObject, FormFieldKind, Hidden, InitialView, Label, MarkKind, SecuritySummary, + SignatureInfo, TabOrder, TrustStore, XfaLayout, a11y, form_appearance, guard, js, measure, optimize, renderer_password, unrestricted, +}; +#[cfg(test)] +use crate::{record_link_scan, record_marker_scan}; + +pub struct Document { + pub id: DocId, + pub name: String, + pub path: Option, + /// The working file: what Save writes. + pub bytes: Arc>, + /// What is displayed (the page view, `page_render`, page images): `bytes`, plus appearances + /// drawn for display only for comments that have none ([`display_bytes`]). Never saved. + pub display: Arc>, + pub info: DocInfo, + pub renderer: RenderPool, + /// The password the document was opened with (needed to read attachments, etc.). + pub password: Option, + /// `true` when there are edits that have not been saved. + pub dirty: bool, + /// Bumped on every change to the working file (edit, undo, redo, save); autosave compares it. + pub(crate) generation: u64, + /// The generation last handed out by `autosave_snapshots`. + pub(crate) snapshot_generation: u64, + /// Why the document cannot be edited (e.g. encryption), if so. + pub read_only_reason: Option, + /// Interactive form fields of the current state (empty without a form). + pub form: Arc>, + pub(crate) marker_scan: OnceLock, + pub(crate) links: OnceLock>, + /// Signature fields, validated against the session's trust store (Signatures panel). + pub signatures: Arc>, + pub(crate) trust: Arc, + pub(crate) sig_cache: Arc, + pub(crate) editor: Option, + pub(crate) config: RenderConfig, + /// What field scripts printed or asked for (see [`Session::take_js_output`]). + pub(crate) js_output: js::JsOutput, + /// Dynamic XFA forms: what laying the template out produced (pages and fields are + /// PdfCraft's; Adobe's viewers draw the form from the XFA packets themselves). + pub xfa: Option, + /// The parsed template of a laid-out XFA form, for its scripts. + pub(crate) xfa_template: Option>, + /// A script of the XFA form ran away, so its scripts are off (see [`Document::xfa_scripts_off`]). + pub(crate) xfa_scripts_off: bool, + /// A content-only refresh keeps `info` from before the edit; an edit may have added or + /// embedded fonts, so the font list is read again on demand ([`Session::refresh_fonts`]). + pub(crate) fonts_stale: bool, + /// XFA forms: what was approximated, rewritten or could not be written to the XFA data + /// (also in `info.warnings`, kept there when the document is re-read). + pub xfa_warnings: Vec, + /// What the last successful [`Session::apply`](crate::Session::apply) wants said (see + /// [`EditNotice`]): text its fonts can't draw, pages whose own backdrop stays off. The + /// interface shows it; tools return it. + pub edit_notices: Vec, +} + +/// Something the last successful edit did that the user should hear about, never left silent +/// (AGENTS.md §4). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum EditNotice { + /// Text stored as typed but drawn with `?`. + Undrawn(TextNotice), + /// Remove background couldn't turn back on the white backdrop these pages (0-based, + /// ascending) paint themselves: their content changed after the background replaced it, so + /// turning it on could paint over something (`pdfcraft_edit::backdrops_left_off`). They look + /// the same on white paper. + BackdropsLeftOff { pages: Vec }, +} + +impl std::fmt::Display for EditNotice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + EditNotice::Undrawn(n) => n.fmt(f), + EditNotice::BackdropsLeftOff { pages } => { + let (which, own) = if pages.len() == 1 { ("page", "its") } else { ("pages", "their") }; + write!( + f, + "{which} {} changed after the background was added, so {own} own white backdrop was not turned back on (it looks the same on white paper)", + page_runs(pages) + ) + } + } + } +} + +/// 0-based pages as 1-based runs: `[0, 1, 2, 5]` is "1-3, 6". +pub(crate) fn page_runs(pages: &[usize]) -> String { + let mut runs: Vec<(usize, usize)> = Vec::new(); + for &p in pages { + match runs.last_mut() { + Some((_, end)) if end.checked_add(1) == Some(p) => *end = p, + _ => runs.push((p, p)), + } + } + let one = |p: usize| p.saturating_add(1); + runs.iter().map(|&(a, b)| if a == b { one(a).to_string() } else { format!("{}-{}", one(a), one(b)) }).collect::>().join(", ") +} + +/// Text an edit stored as typed but drew with `?` in place of `ch`, because the font it is drawn +/// with (a form field's, or the standard fonts for comments) can't draw that character. Acrobat +/// keeps such text too, drawing it with fonts PdfCraft can't embed yet. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TextNotice { + /// The form field's full name, or `None` for a comment. + pub field: Option, + /// The page (0-based) of the comment or of the field's widget. + pub page: usize, + pub ch: char, + /// Other PDF viewers redraw it from the stored text (a form field, with `/NeedAppearances` + /// set); otherwise the `?` shows in every viewer. + pub redrawn_elsewhere: bool, +} + +impl std::fmt::Display for TextNotice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let ch = format!("\"{}\" (U+{:04X})", self.ch.escape_debug(), u32::from(self.ch)); + let page = self.page + 1; + match (&self.field, self.redrawn_elsewhere) { + (Some(name), true) => write!( + f, + "form field {name:?} (page {page}) holds {ch}, which its font can't draw: the value is saved, PdfCraft shows ? in its place, and other PDF viewers redraw the field from the value" + ), + (Some(name), false) => write!( + f, + "form field {name:?} (page {page}) holds {ch}, which its font can't draw: the value is saved, but this PDF/A document can't ask other viewers to redraw it, so ? shows in its place everywhere" + ), + (None, _) => write!( + f, + "a comment on page {page} has {ch}, which the standard fonts can't draw: its text is saved, but its appearance shows ? in its place in every viewer" + ), + } + } +} + +/// What `next` drew with `?` that `prev` didn't (a new or redrawn appearance carrying +/// `pdfcraft_fonts::UNDRAWN_MARK`), one notice per field or comment, in page order. +pub(crate) fn undrawn_notices(prev: &pdfcraft_cos::Document, next: &pdfcraft_cos::Document) -> Vec { + let Ok(pages) = pdfcraft_annot::page_refs(next) else { return Vec::new() }; + let redrawn_elsewhere = pdfcraft_forms::need_appearances(next); + let mut names: Option> = None; + let mut out: Vec = Vec::new(); + for (page, p) in pages.iter().enumerate() { + let annots = next.get(*p).as_dict().and_then(|d| d.get(b"Annots").cloned()).map(|a| next.resolve(&a)); + for r in annots.as_deref().and_then(pdfcraft_cos::Object::as_array).into_iter().flatten().filter_map(pdfcraft_cos::Object::as_ref) { + let now = next.get(r); + let Some(d) = now.as_dict() else { continue }; + let Some(ch) = pdfcraft_fonts::undrawn_mark(d) else { continue }; + // Only what this edit drew: a new annotation, or a new appearance. + let was = prev.get(r); + if was.as_dict().is_some_and(|w| w.get(b"AP") == d.get(b"AP") && pdfcraft_fonts::undrawn_mark(w) == Some(ch)) { + continue; + } + let field = (d.name(b"Subtype") == Some(b"Widget")).then(|| { + let names = names.get_or_insert_with(|| { + pdfcraft_forms::fields(next) + .into_iter() + .flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.name.clone())).collect::>()) + .collect() + }); + names.get(&r).cloned().unwrap_or_default() + }); + if field.is_some() && out.iter().any(|n| n.field == field) { + continue; + } + out.push(TextNotice { redrawn_elsewhere: redrawn_elsewhere && field.is_some(), field, page, ch }); + } + } + out +} + +impl Document { + /// How the document opens (Document Properties ▸ Initial View). + /// Accessibility ▸ Check for accessibility: the full check (`None` if the document can't be + /// read for editing). + pub fn accessibility_check(&self, options: &a11y::Options) -> Option { + self.editor.as_ref().map(|e| a11y::check(&e.cos, options)) + } + + /// A script of this XFA form ran past its time limit and was abandoned, so the form's + /// scripts no longer run in this document (reopening it runs them again). The script's + /// error is in the JavaScript output; this is the state, for a viewer to show. + pub fn xfa_scripts_off(&self) -> bool { + self.xfa_scripts_off + } + + /// PDF Optimizer ▸ Audit space usage. + pub fn audit_space(&self) -> Vec { + self.editor.as_ref().map(|e| optimize::audit_space(&e.cos, self.bytes.len() as u64)).unwrap_or_default() + } + + /// The backdrop `page` (0-based) paints itself, which hides a background (Add Background). + pub fn page_backdrop(&self, page: usize) -> Option { + self.editor.as_ref().and_then(|e| pdfcraft_edit::page_backdrop(&e.cos, page).ok().flatten()) + } + + /// [`Self::page_backdrop`] for each of `pages`, in order, walking the page tree once: the + /// way to check many pages. A page out of range has none. + pub fn page_backdrops(&self, pages: &[usize]) -> Vec> { + match &self.editor { + Some(e) => pdfcraft_edit::page_backdrops(&e.cos, pages), + None => vec![None; pages.len()], + } + } + + /// Edit a PDF: the images `page` draws (0-based). + pub fn page_images(&self, page: usize) -> Vec { + self.editor.as_ref().and_then(|e| pdfcraft_edit::page_images(&e.cos, page).ok()).unwrap_or_default() + } + + /// Mixed editable objects on one page. Source inventories are zero-based and added items + /// appear once. Rectangles are user-space points, independent of the viewer's zoom. + pub fn editable_objects(&self, page: usize) -> Result, String> { + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + pdfcraft_edit::editable_objects(&editor.cos, page).map_err(|e| e.to_string()) + } + + /// Displayed top-left-origin motion to a user-space vector. Use the linear map only, + /// so even a large crop-box origin cannot erase a small drag by cancellation. + pub fn object_move_offset(&self, page: usize, delta: [f64; 2]) -> Result<[f64; 2], String> { + let p = self.info.pages.get(page).ok_or("the page no longer exists")?; + let [x0, y0, x1, y1] = p.crop.map(f64::from); + let (a, b) = (delta[0] / f64::from(p.width.max(1e-3)), delta[1] / f64::from(p.height.max(1e-3))); + let (u, v) = match p.rotation { + 90 => (b, -a), + 180 => (-a, -b), + 270 => (-b, a), + _ => (a, b), + }; + let offset = [u * (x1 - x0), -v * (y1 - y0)]; + if !offset.iter().all(|v| v.is_finite()) { + return Err("the move must be finite and non-zero".into()); + } + Ok(offset) + } + + /// Save image as: image `index` on `page` as a file (extension, bytes). + pub fn page_image_file(&self, page: usize, index: usize) -> Result<(&'static str, Vec), String> { + self.check_copying().map_err(|e| e.to_string())?; + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + let img = self.page_images(page).into_iter().nth(index).ok_or_else(|| format!("page {} has no image {}", page + 1, index + 1))?; + if img.is_form { + return Err("grouped Form artwork is not a bitmap; Save Image As is only available for raster images".into()); + } + pdfcraft_create::image_file(&editor.cos, img.object.ok_or("the image has no object")?) + } + + /// Edit a PDF ▸ Edit text: the paragraphs on `page` (0-based). + pub fn text_blocks(&self, page: usize) -> Vec { + self.editor.as_ref().and_then(|e| e.text_blocks(page).ok()).map(|b| b.as_ref().clone()).unwrap_or_default() + } + + /// Saved measurement annotations, calculated from their geometry and PDF scales, plus + /// the ones that couldn't be read (unsupported formats are skipped, not fatal). + pub fn measurements(&self) -> Result { + let e = self.editor.as_ref().ok_or("the document can't be read")?; + Ok(measure::list(&e.cos)) + } + pub fn measurement_scale(&self, page: usize, at: measure::Point) -> Result { + let e = self.editor.as_ref().ok_or("the document can't be read")?; + measure::scale_at(&e.cos, page, at).map_err(|e| e.to_string()) + } + /// [`Self::measurement_scale`], measuring true size in `unit` (`per_point` of them in a + /// point) where the page sets no scale. + pub fn measurement_scale_or(&self, page: usize, at: measure::Point, per_point: f64, unit: &str) -> Result { + let e = self.editor.as_ref().ok_or("the document can't be read")?; + measure::scale_at_or(&e.cos, page, at, per_point, unit).map_err(|e| e.to_string()) + } + pub fn measurement_to_user(&self, page: usize, point: measure::Point) -> Result { + let e = self.editor.as_ref().ok_or("the document can't be read")?; + measure::view_to_user(&e.cos, page, point).map_err(|e| e.to_string()) + } + pub fn measurement_to_view(&self, page: usize, point: measure::Point) -> Result { + let e = self.editor.as_ref().ok_or("the document can't be read")?; + measure::user_to_view(&e.cos, page, point).map_err(|e| e.to_string()) + } + pub fn measurement_paths(&self, page: usize) -> Result { + let e = self.editor.as_ref().ok_or("the document can't be read")?; + measure::snap::geometry(&e.cos, page).map_err(|e| e.to_string()) + } + + /// A counter that changes with every edit (for caches of derived data). + pub fn edit_generation(&self) -> u64 { + self.generation + } + + /// Edit a PDF ▸ Edit text: the lines of existing text on `page` (0-based). + pub fn text_lines(&self, page: usize) -> Vec { + 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() + } + + /// Reachable indirect structure tags, in document order. + pub fn tags(&self) -> Vec { + self.editor.as_ref().map(|e| a11y::tags(&e.cos)).unwrap_or_default() + } + + /// Marked-content items directly owned by one reachable indirect structure tag. + pub fn tag_content(&self, object: pdfcraft_cos::ObjRef) -> Result, a11y::TagError> { + self.editor.as_ref().map(|e| a11y::tag_content(&e.cos, object)).unwrap_or_else(|| Ok(Vec::new())) + } + + /// The edit that fixes `rule`, for the rules with an automatic fix: the document language + /// (`value` is the language), the title (`value` replaces it; else the current title or the + /// file name) and the tab order. + pub fn accessibility_fix(&self, rule: a11y::Rule, value: Option<&str>) -> Result { + let value = value.map(str::trim).filter(|v| !v.is_empty()); + match rule { + a11y::Rule::PrimaryLanguage => { + let mut v = self.initial_view(); + v.language = Some(value.ok_or("give the document language (for example en-US)")?.to_owned()); + Ok(Edit::SetInitialView(Box::new(v))) + } + a11y::Rule::Title => { + let title = value + .map(str::to_owned) + .or_else(|| self.info_value("Title").filter(|t| !t.trim().is_empty())) + .unwrap_or_else(|| self.name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_owned()); + let mut v = self.initial_view(); + v.display_title = true; + Ok(Edit::Batch { + label: "Set document title".into(), + edits: vec![Edit::SetInfo { key: "Title".into(), value: title }, Edit::SetInitialView(Box::new(v))], + }) + } + a11y::Rule::TabOrder => Ok(Edit::SetTabOrder { pages: (0..self.info.pages.len()).collect(), order: TabOrder::Structure }), + other => Err(format!("\"{}\" has no automatic fix", other.name())), + } + } + + pub fn initial_view(&self) -> InitialView { + self.editor.as_ref().map(|e| pdfcraft_organize::initial_view(&e.cos)).unwrap_or_default() + } + + /// Signed: at least one signature field holds a signature. + pub fn is_signed(&self) -> bool { + self.signatures.iter().any(|s| s.signed) + } + + /// Whether comments are hidden (Comments ▸ Hide all comments). + pub fn comments_hidden(&self) -> bool { + self.config.hide_comments + } + + pub fn can_undo(&self) -> Option<&str> { + self.undo_label().map(Label::as_str) + } + + pub fn can_redo(&self) -> Option<&str> { + self.redo_label().map(Label::as_str) + } + + /// What Undo would undo, in parts, for an interface that says it in another language. + pub fn undo_label(&self) -> Option<&Label> { + self.editor.as_ref().and_then(|e| e.undo.last()).map(|(l, ..)| l) + } + + /// What Redo would redo, in parts. + pub fn redo_label(&self) -> Option<&Label> { + self.editor.as_ref().and_then(|e| e.redo.last()).map(|(l, ..)| l) + } + + pub fn editable(&self) -> bool { + self.editor.is_some() + } + + /// What the opening password allows; `None` when the document is not encrypted. + pub fn permissions(&self) -> Option { + self.editor.as_ref().and_then(|e| e.cos.permissions()) + } + + /// Printing is allowed (Table 22, bit 3). + pub fn allows_printing(&self) -> bool { + self.permissions().is_none_or(|p| p.print()) + } + + /// Copying or otherwise extracting text and images is allowed (Table 22, bit 5): copying + /// selected text, snapshots, Save Image As, exporting. Assistive technology may read the + /// content regardless (§7.6.4.2), so reading aloud and accessibility tools don't ask. + pub fn allows_copying(&self) -> bool { + self.permissions().is_none_or(|p| p.copy()) + } + + /// [`Self::allows_copying`], as the error to report when it isn't. + pub fn check_copying(&self) -> Result<(), EditError> { + if self.allows_copying() { Ok(()) } else { Err(EditError::NotPermitted("copying text and images")) } + } + + /// Page changes (insert, delete, rotate, move, extract) are allowed. + pub fn allows_assembly(&self) -> bool { + self.editable() && self.permissions().is_none_or(|p| p.assemble()) + } + + /// Redaction marks waiting to be applied. + pub fn redaction_marks(&self) -> usize { + self.info.annotations.iter().filter(|a| a.subtype == "Redact").count() + } + + /// Page marks present (headers and footers, watermarks, backgrounds), for Update/Remove. + pub fn marks(&self) -> &[MarkKind] { + &self + .marker_scan + .get_or_init(|| { + self.editor + .as_ref() + .map(|e| { + #[cfg(test)] + record_marker_scan(); + pdfcraft_edit::scan_markers(&e.cos) + }) + .unwrap_or_default() + }) + .marks + } + + /// Text and images added with Edit a PDF ▸ Add content (still editable). + pub fn added(&self) -> &[pdfcraft_edit::Added] { + &self + .marker_scan + .get_or_init(|| { + self.editor + .as_ref() + .map(|e| { + #[cfg(test)] + record_marker_scan(); + pdfcraft_edit::scan_markers(&e.cos) + }) + .unwrap_or_default() + }) + .added + } + + /// Link annotations, for Edit a PDF ▸ Link. + pub fn links(&self) -> &[pdfcraft_annot::links::LinkItem] { + self.links.get_or_init(|| { + self.editor + .as_ref() + .map(|e| { + #[cfg(test)] + record_link_scan(); + pdfcraft_annot::links::list(&e.cos) + }) + .unwrap_or_default() + }) + } + + /// Changes to content and document information are allowed. + pub fn allows_modification(&self) -> bool { + self.editable() && self.permissions().is_none_or(|p| p.modify()) + } + + /// How widget `widget` of text field `name` lays its value out, for asking whether a typed + /// value fits it ([`form_appearance::TextBox::fits`]): a field that doesn't scroll takes no + /// more than fits. `None` without such a field or widget, or when the document can't be + /// edited. + pub fn field_text_box(&self, name: &str, widget: usize) -> Option { + let cos = &self.editor.as_ref()?.cos; + let f = self.form.iter().find(|f| f.name == name)?; + let w = f.widgets.get(widget)?; + Some(form_appearance::text_box(cos, f, w)) + } + + /// Filling in form fields is allowed (Table 22, bits 6 and 9). + pub fn allows_form_filling(&self) -> bool { + self.editable() && self.permissions().is_none_or(|p| p.fill_forms()) + } + + /// Adding and changing comments is allowed (Table 22, bit 6). + pub fn allows_annotation(&self) -> bool { + self.editable() && self.permissions().is_none_or(|p| p.annotate()) + } + + /// The document's security may be changed (Protect, Remove security): it is editable and, + /// if encrypted, was opened with the owner password. + pub fn allows_security_change(&self) -> bool { + self.editable() && self.permissions().is_none_or(|p| unrestricted(&p)) + } + + /// A password restricts the document and it wasn't opened with the owner password: entering + /// that password ([`Session::unlock`](crate::Session::unlock)) lifts the restrictions. + pub fn can_unlock(&self) -> bool { + self.editable() && self.permissions().is_some_and(|p| !unrestricted(&p)) + } + + /// A summary of the document's security for Document Properties ▸ Security, including + /// protection applied in this session (written by the next save). + pub fn security_summary(&self) -> Option { + let editor = self.editor.as_ref()?; + let h = editor.cos.output_handler()?; + let d = h.dict(); + let stream = d.crypt_filters.iter().find(|(name, _)| *name == d.stm_f).map(|(_, m)| *m); + let method = match (d.v, stream) { + (1..=3, _) if d.length_bits <= 40 => "RC4, 40-bit", + (1..=3, _) | (_, Some(pdfcraft_cos::CryptMethod::Rc4)) => "RC4, 128-bit", + (_, Some(pdfcraft_cos::CryptMethod::Aes128)) => "AES, 128-bit", + (_, Some(pdfcraft_cos::CryptMethod::Aes256)) => "AES, 256-bit", + _ => "Attachments only", + }; + let pending = editor.cos.encryption_changed(); + let permissions = if pending { pdfcraft_cos::Permissions { bits: h.permissions().bits, owner: false } } else { h.permissions() }; + Some(SecuritySummary { method: method.into(), owner: h.auth() == pdfcraft_cos::Auth::Owner && !pending, permissions, pending }) + } + + /// Comment properties of the comment at `(page, index)` (Comment Properties dialog). + pub fn comment_props(&self, page: usize, index: usize) -> Option { + self.editor.as_ref().and_then(|e| pdfcraft_annot::props(&e.cos, page, index)) + } + + /// A form field's Appearance-tab look (borders, colours, font). + pub fn field_look(&self, name: &str) -> Option { + let e = self.editor.as_ref()?; + let f = self.form.iter().find(|f| f.name == name)?; + Some(pdfcraft_forms::look(&e.cos, f)) + } + + /// A check box's or radio button's mark (Field Properties ▸ Options), `None` for other fields. + pub fn field_check_style(&self, name: &str) -> Option { + let e = self.editor.as_ref()?; + let f = self.form.iter().find(|f| f.name == name)?; + matches!(f.kind, FormFieldKind::CheckBox | FormFieldKind::Radio).then(|| pdfcraft_forms::check_style(&e.cos, f)) + } + + /// Whether text field `name`'s value is cut off by its box at the field's font size (#4018): + /// the page (0-based) of the first widget that cuts it, and how. + pub fn field_overflow(&self, name: &str) -> Option<(Option, pdfcraft_forms::appearance::Overflow)> { + let e = self.editor.as_ref()?; + let f = self.form.iter().find(|f| f.name == name)?; + f.widgets.iter().find_map(|w| pdfcraft_forms::appearance::value_overflow(&e.cos, f, w).map(|o| (w.page, o))) + } + + /// Remove Hidden Information: what each category would remove. + pub fn hidden_info(&self) -> Vec<(Hidden, usize)> { + self.editor.as_ref().map(|e| pdfcraft_redact::sanitize::scan(&e.cos)).unwrap_or_default() + } + + /// Verification: faux redactions (opaque covers over extractable text) and redaction + /// marks that were never applied. Fails closed: any read error is returned, never an + /// empty list. + pub fn audit_redactions(&self) -> Result, String> { + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + pdfcraft_audit::audit_redactions(&editor.cos).map_err(|e| e.to_string()) + } + + /// Every page's media, crop, bleed, trim and art boxes (user space), for Set Page Boxes. + pub fn page_boxes(&self) -> Vec<[[f64; 4]; 5]> { + self.editor.as_ref().and_then(|e| pdfcraft_organize::page_boxes(&e.cos).ok()).unwrap_or_default() + } + + /// Current value of a document-information entry (Title, Author, …). + pub fn info_value(&self, key: &str) -> Option { + self.editor.as_ref().and_then(|e| pdfcraft_organize::info(&e.cos, key)) + } + + /// Whether the document information has an entry `key`, of any type, listed or not. + pub fn has_info(&self, key: &str) -> bool { + self.editor.as_ref().is_some_and(|e| pdfcraft_organize::has_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 { + self.editor + .as_ref() + .filter(|e| pdfcraft_organize::displays_doc_title(&e.cos)) + .and_then(|e| pdfcraft_organize::info(&e.cos, "Title")) + .map(|t| t.trim().to_owned()) + .filter(|t| !t.is_empty() && !is_placeholder_title(t)) + .unwrap_or_else(|| self.name.clone()) + } + + /// Where each saved revision of the file ends (oldest first; `bytes[..end]` is that + /// revision). Unsaved edits aren't a revision yet. + pub fn revision_ends(&self) -> Vec { + self.editor.as_ref().map(|e| e.cos.revision_ends()).unwrap_or_default() + } + + /// The file as last opened or saved, without unsaved edits: what an incremental save appends + /// to. This stays that file when undo has restored an older snapshot. + pub fn saved_bytes(&self) -> Arc> { + self.editor.as_ref().map_or_else(|| self.bytes.clone(), |e| e.saved.clone()) + } + + /// Notes about damage repaired while opening (Document Properties ▸ Advanced, notices). + pub fn repair_log(&self) -> Vec { + self.editor.as_ref().map(|e| e.cos.repair_log().to_vec()).unwrap_or_default() + } +} + +/// A title that names no document: what a web browser writes when it saves a blank page or a +/// pop-up as PDF (`about:blank`), another browser-internal address, or a bare "Untitled". Such a +/// title says less than the file name, so the file name is shown instead. An address is one +/// word: "About: our company" is a real title. +pub(crate) fn is_placeholder_title(title: &str) -> bool { + let t = title.trim().to_lowercase(); + let address = !t.contains(char::is_whitespace) && ["about:", "blob:", "data:"].iter().any(|p| t.starts_with(p)); + address || t == "untitled" +} + +/// 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. +pub(crate) 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. +pub(crate) 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) +} + +/// Each document-information field of `info` with its value in `cos` (`/Info`, else XMP), read +/// the way the full inspection reads them: NULs and spaces trimmed, empty is none. +pub(crate) fn info_fields<'a>(info: &'a mut DocInfo, cos: &pdfcraft_cos::Document) -> [(&'a mut Option, Option); 6] { + let read = |key| pdfcraft_organize::info(cos, key).map(|v| v.trim_matches('\0').trim().to_string()).filter(|v| !v.is_empty()); + [ + (&mut info.title, read("Title")), + (&mut info.author, read("Author")), + (&mut info.subject, read("Subject")), + (&mut info.keywords, read("Keywords")), + (&mut info.creator, read("Creator")), + (&mut info.producer, read("Producer")), + ] +} + +/// The copy PDFCraft displays when opening `bytes`: with appearances drawn, in memory only, for +/// 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| with_field_appearances(pdfcraft_annot::with_missing_appearances(&cos), &cos)) + .and_then(|display| write_incremental(&display, &SaveOptions::default()).ok()) + .map(Arc::new) + }) + .ok() + .flatten() + .unwrap_or(bytes) +} + +/// The password to give the renderer (`inspect`, `PageRenderer`) for `bytes` opened with +/// `password`. The renderer authenticates only the user password of R2–R4 files, so for their +/// owner password this is the user password that owner authentication recovers; otherwise it is +/// `password` itself. Render paths outside a [`Session`](crate::Session), such as the CLI, use this so the owner +/// password works there as it does in `doc_open`. A file that doesn't open with `password` keeps +/// it, so the renderer reports the same error it always did. +pub fn render_password_for(bytes: Arc>, 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. +pub(crate) 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 +/// its verify pass reuses them. Where they differ from the renderer's (a standard font without +/// /Widths, whose advances the interpreter approximates; a CMap +/// that it reads differently), a mark drawn over the text as displayed lies over glyphs the +/// interpreter believes are elsewhere: they stay in the file and nothing notices. The renderer is +/// what the user saw when placing the marks, so it is asked. `after` is written as a save would +/// write it, without the marks' own `overlays` (the box and its label are drawn on purpose), its +/// text is extracted, and a glyph still mostly inside a mark fails the edit: the caller keeps +/// the document as it was. +pub(crate) fn shown_under_redactions( + after: &pdfcraft_cos::Document, + marks: &[pdfcraft_redact::Mark], + overlays: &[pdfcraft_cos::ObjRef], + pages: &[pdfcraft_render::PageInfo], + password: Option<&str>, +) -> Result<(), EditError> { + use pdfcraft_cos::Object; + use pdfcraft_redact::RedactError; + let mut marked: Vec = marks.iter().map(|m| m.page).collect(); + marked.sort_unstable(); + marked.dedup(); + if marked.is_empty() { + return Ok(()); + } + let mut shown = after.clone(); + let model = pdfcraft_model::pages(&shown); + for &pi in &marked { + let Some(page) = model.get(pi) else { continue }; + let Some(Object::Array(list)) = page.dict.get(b"Contents").map(|c| (*shown.resolve(c)).clone()) else { continue }; + let kept: Vec = list.into_iter().filter(|o| !o.as_ref().is_some_and(|r| overlays.contains(&r))).collect(); + shown.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(kept))).map_err(|e| EditError::Write(e.to_string()))?; + } + let bytes = write_incremental(&shown, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?; + let config = RenderConfig { password: password.map(Arc::from), ..Default::default() }; + let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(bytes), config); + let mut residue = 0; + for &pi in &marked { + let request = pdfcraft_render::RenderRequest { page: pi, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }; + // A page that cannot be read back cannot be confirmed clean. + let (Some(info), Some(text)) = (pages.get(pi), renderer.render(request).text) else { return Err(RedactError::Unreadable(pi + 1).into()) }; + let rects: Vec<[f64; 4]> = marks + .iter() + .filter(|m| m.page == pi) + .flat_map(|m| m.rects.iter()) + .map(|r| [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]) + .collect(); + for glyph in text.glyphs.iter().filter(|g| !g.text.trim().is_empty()) { + let [x0, y0, x1, y1] = info.view_rect_to_user(glyph.rect).map(f64::from); + let area = (x1 - x0) * (y1 - y0); + if area.is_nan() || area <= 0.0 { + continue; + } + // Redaction itself takes any glyph a fifth under a mark; one that is half under it + // and still drawn was not where redaction thought it was. + let under = |r: &[f64; 4]| (x1.min(r[2]) - x0.max(r[0])).max(0.0) * (y1.min(r[3]) - y0.max(r[1])).max(0.0) >= 0.5 * area; + if rects.iter().any(under) { + residue += 1; + } + } + } + if residue > 0 { + return Err(RedactError::Residue(residue).into()); + } + Ok(()) +} diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs new file mode 100644 index 0000000000..faf4712078 --- /dev/null +++ b/crates/engine/src/edit.rs @@ -0,0 +1,1577 @@ +//! The edits a document takes: what each is called, whether its permissions allow it, and running it. + +use std::sync::Arc; + +use crate::{ + AddedContent, AddedText, Background, BoxSpec, EditError, FieldProps, FieldValue, FillMark, HeaderFooter, Hidden, ImageEdit, InitialView, Label, + LineEnding, LinkAction, LinkStyle, MarkFile, MarkKind, Markup, NewAnnotation, NewField, NoteIcon, ObjectTarget, PageBox, Protection, ReviewState, + Rgb, Shape, Standard, Style, TabOrder, TransitionSpec, Watermark, a11y, copy_image, decode_data_file, embed_image, field_table, fit_picture, + image_content_type, is_xfa_data_file, js, mark_source, measure, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change, +}; + +/// Edits that can be applied to a document. Page indices are 0-based. +#[derive(Clone, Debug, PartialEq)] +pub enum Edit { + RotatePages { + pages: Vec, + degrees: i64, + }, + DeletePages { + pages: Vec, + }, + MovePages { + 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 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. + ReplacePages { + pages: Vec, + name: String, + bytes: Arc>, + src_pages: Vec, + }, + /// Set Page Boxes / Crop: set (or reset) one box on `pages`. + SetPageBox { + pages: Vec, + which: PageBox, + spec: BoxSpec, + }, + /// Set or remove the page transition and display time of pages (ISO 32000-2 §12.4.4), + /// Organize ▸ Page Transitions. + SetTransition { + pages: Vec, + spec: TransitionSpec, + }, + SetInfo { + key: String, + value: String, + }, + /// Insert pages from another PDF (all pages when `pages` is `None`) at position `at`. + InsertPagesFrom { + name: String, + bytes: Arc>, + pages: Option>, + at: usize, + /// How a form field whose name the document already uses is kept separate. + 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, + title: String, + }, + /// Delete a bookmark and the bookmarks under it. + DeleteBookmark { + path: Vec, + }, + /// Move a bookmark to child `index` of `to_parent` (indices after removing it). + MoveBookmark { + from: Vec, + to_parent: Vec, + index: usize, + }, + /// Point a bookmark at another page. + SetBookmarkPage { + path: Vec, + page: usize, + }, + /// Colour a bookmark's title (`/C`); `None` or black is the default black. + SetBookmarkColor { + path: Vec, + color: Option<[f64; 3]>, + }, + /// Make a bookmark's title bold or italic or not (`/F`); `None` keeps that one. + SetBookmarkStyle { + path: Vec, + bold: Option, + italic: Option, + }, + /// Bookmarks for the tagged headings (H, H1–H6), nested by level under a new first + /// "Untitled" bookmark (New Bookmarks from Structure). + BookmarksFromStructure, + /// Label pages `from..=to` (0-based) as Acrobat's "Number pages" does; later pages keep their labels. + NumberPages { + from: usize, + to: usize, + style: pdfcraft_organize::LabelStyle, + prefix: String, + first: u32, + }, + /// Add a calibrated distance, perimeter or area annotation. + AddMeasurement(measure::NewMeasurement), + /// Store a drawing scale for a rectangular viewport (PDF user space). + SetMeasurementScale { + page: usize, + bbox: [f64; 4], + name: String, + scale: measure::Scale, + }, + /// Add a comment (sticky note, highlight, shape, drawing, text box…). + AddAnnotation(NewAnnotation), + /// A custom stamp from a picture file (a PDF page or an image) on `page`. A zero-size + /// `rect` is a point: the stamp is centred there at its natural size (at most 200 pt). + /// Any other `rect` is a box: the picture is scaled to fit inside it, keeping its shape, + /// and centred in it (#4077). + AddCustomStamp { + page: usize, + rect: [f64; 4], + name: String, + file: MarkFile, + author: String, + }, + /// Delete the comment at `index` in the page's `/Annots`, with its pop-up and replies. + DeleteAnnotation { + page: usize, + index: usize, + }, + SetAnnotationContents { + page: usize, + index: usize, + text: String, + }, + ReplyToAnnotation { + page: usize, + index: usize, + text: String, + author: String, + }, + /// Acrobat's "Set status" (a state reply by `author`). + SetAnnotationStatus { + page: usize, + index: usize, + state: ReviewState, + author: String, + }, + /// The Eraser: rub out the parts of drawing `(page, index)` near `path` (user space). + EraseInk { + page: usize, + index: usize, + path: Vec<[f64; 2]>, + radius: f64, + }, + /// Replace Text: strike out `quads` and add a grouped caret holding `text`. + ReplaceText { + page: usize, + quads: Vec<[f64; 8]>, + text: String, + author: String, + strike: Style, + caret: Style, + }, + /// Acrobat's "Mark with checkmark" (a hidden `/StateModel /Marked` reply by `author`). + MarkAnnotation { + page: usize, + index: usize, + marked: bool, + author: String, + }, + /// Properties ▸ Locked. + LockAnnotation { + page: usize, + index: usize, + locked: bool, + }, + MoveAnnotation { + page: usize, + index: usize, + 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, + index: usize, + rect: [f64; 4], + }, + StyleAnnotation { + page: usize, + index: usize, + color: Option, + opacity: Option, + width: Option, + /// Line or polyline: two endings. Callout: one. `None` leaves `/LE` unchanged. + endings: Option>, + }, + /// Fill a rectangle, oval or polygon comment, or remove its fill (`None`). + FillAnnotation { + page: usize, + index: usize, + fill: Option, + }, + /// Change the font size (pt) of a text box, typewriter text or callout. + FontSizeAnnotation { + page: usize, + index: usize, + size: f64, + }, + /// Comment properties ▸ General / note icon. + SetAnnotationInfo { + page: usize, + index: usize, + author: Option, + subject: Option, + icon: Option, + }, + /// Fill in a form field. + SetFieldValue { + name: String, + value: FieldValue, + }, + /// Clear form: the named fields (all when `None`) back to their defaults. + ResetForm { + names: Option>, + }, + /// Prepare a form: add a field on a page (`rect` in PDF user space); `name` defaults to + /// Acrobat's next free name ("Text1", "Check Box2", …). + AddField { + page: usize, + rect: [f64; 4], + kind: NewField, + name: Option, + }, + /// Field Properties ▸ General / Options. + SetFieldProps { + name: String, + props: Box, + }, + /// An image field (or any button): show this image file (PNG, JPEG, TIFF, GIF, BMP). + SetFieldImage { + name: String, + image: Arc>, + }, + /// Duplicate a field onto other pages (same field, shared value). + DuplicateField { + name: String, + pages: Vec, + }, + DeleteField { + name: String, + }, + /// Page properties ▸ Tab order, for pages (0-based). + SetTabOrder { + pages: Vec, + order: TabOrder, + }, + /// Document Properties ▸ Initial View (and the language and binding). + SetInitialView(Box), + /// Edit ▸ Attach a file: a document-level attachment (`/Names /EmbeddedFiles`) under + /// `name`, made unique; `mime` is the embedded file's `/Subtype` when known. + AddAttachment { + name: String, + bytes: Vec, + description: Option, + mime: Option, + }, + /// Remove the document-level attachment whose name-tree key is `key`. + DeleteAttachment { + key: String, + }, + /// Build a tagged structure tree for an untagged, text-only document. + AutoTagText, + /// Add alternate text: set (or clear, with `None`) a figure's alternate text. `figure` is + /// the figure element's object number (from `Document::figures`). + SetAltText { + figure: u32, + alt: Option, + }, + /// Add `/Form` tags and object references for all widgets in an untagged AcroForm. + AutoTagFormFields, + /// Change one property on a reachable indirect structure tag. + SetTagProperty { + object: pdfcraft_cos::ObjRef, + property: a11y::TagProperty, + value: Option, + }, + /// Move a structure tag before or after a direct sibling in reading order. + MoveTag { + object: pdfcraft_cos::ObjRef, + anchor: pdfcraft_cos::ObjRef, + placement: a11y::TagPlacement, + }, + /// Add alternate text ▸ Decorative figure: the figure's content becomes an artifact. + MarkDecorative { + figure: u32, + }, + /// Tags panel ▸ Mark as artifact: retire a leaf tag and mark its content as an artifact. + MarkTagArtifact { + object: pdfcraft_cos::ObjRef, + }, + /// Document JavaScripts: add, replace (`script`) or remove (`None`) the document-level + /// script `name`. + SetDocumentScript { + name: String, + script: Option, + }, + /// Field Properties ▸ a custom script: set or remove field `name`'s JavaScript for `event` + /// (keystroke, format, validate, calculate or mouse_up). + SetFieldScript { + name: String, + event: String, + script: Option, + }, + /// Standards ▸ Save as PDF/A: fix what can be fixed for `level` (metadata, output intent, + /// forbidden actions, annotation flags, …). + 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 { + changes: Vec, + }, + /// An XFA form's scripted event (a button's `click`, say) on the object at `som`: what the + /// script changes (values, rows, visibility) is applied and the form laid out again. + XfaEvent { + som: String, + activity: String, + }, + /// Scan & OCR ▸ Recognize text: put recognised words on `page` as invisible text over the + /// image (from [`ocr::OcrJob::run`](crate::ocr::OcrJob::run)). + AddOcrText { + page: usize, + words: Vec, + }, + /// Edit a PDF ▸ Edit text: replace the text of line `line` (from `Document::text_lines`) on + /// `page`, in its own font when it can show it, else in Helvetica. + EditTextLine { + page: usize, + line: usize, + text: String, + }, + /// Edit a PDF ▸ an existing image on `page` (index from `Document::page_images`). + EditPageImage { + page: usize, + index: usize, + change: ImageEdit, + }, + /// Move mixed existing and added objects together. References resolve before any change; + /// offset is a user-space vector. No retyping, reflow or font substitution is involved. + MoveObjects { + page: usize, + objects: Vec, + offset: [f64; 2], + }, + /// Edit text in a paragraph box: replace paragraph `block` (from `Document::text_blocks`), + /// rewrapped to the box's width. + EditTextBlock { + page: usize, + block: usize, + text: String, + /// Formatting changes (font, size, colour, alignment); default keeps the paragraph's. + style: pdfcraft_edit::BlockStyle, + }, + /// An explicit open-licensed full font program, completing subsets or selected by a user. + RegisterTextFont { + page: usize, + bytes: Arc>, + }, + /// Order tabs manually: move a field one place earlier or later on its page. + MoveInTabOrder { + 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`](crate::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, + settings: HeaderFooter, + replace: bool, + }, + /// `file`: a picture from a file (an image, or a page of a PDF) instead of text. + AddWatermark { + pages: Vec, + settings: Watermark, + replace: bool, + file: Option, + }, + /// `file`: a picture from a file instead of a colour. + AddBackground { + pages: Vec, + settings: Background, + replace: bool, + file: Option, + }, + /// Remove every mark of a kind from every page. + RemoveMarks { + kind: MarkKind, + }, + /// Edit a PDF ▸ Add content ▸ Text (`text.rect` in display space). + AddText { + page: usize, + text: AddedText, + }, + /// Edit a PDF ▸ Add content ▸ Image: an image file placed at `rect` (display space), or at + /// its natural size centred on the page (shrunk to fit). + AddImage { + page: usize, + rect: Option<[f64; 4]>, + name: String, + bytes: Arc>, + }, + /// Move, resize, retype or reformat an added item (`index` among the page's added items). + UpdateContent { + page: usize, + index: usize, + content: AddedContent, + }, + DeleteContent { + page: usize, + index: usize, + }, + /// Replace an added image with another file (keeping its box and transforms). + ReplaceImage { + page: usize, + index: usize, + name: String, + bytes: Arc>, + }, + /// Edit ▸ Paste: an added item copied in this document or another, as it was: text with its + /// style, an image in its box (display space) with its turns, flips and crop. An image's + /// picture is `file` (from [`Document::added_image_file`](crate::Document::added_image_file)): the copied image object is reused + /// while it is still that picture in this document, else one an added image here already draws + /// it from, else the file is embedded. + PasteContent { + page: usize, + content: AddedContent, + file: Option<(String, Arc>)>, + }, + /// Apply redaction marks (all, or those on `pages`): remove what they cover for good. + ApplyRedactions { + pages: Option>, + }, + /// Remove redaction marks without applying them. + ClearRedactions, + /// Edit a PDF ▸ Link: a new link over `rect` (user space). + AddLink { + page: usize, + rect: [f64; 4], + action: LinkAction, + style: LinkStyle, + }, + /// Link Properties (`index` in the page's `/Annots`). + SetLink { + page: usize, + index: usize, + rect: Option<[f64; 4]>, + action: Option, + style: Option, + }, + DeleteLink { + page: usize, + index: usize, + }, + /// Remove all links (on `pages`, or everywhere). + RemoveLinks { + pages: Option>, + }, + /// Create links from URLs in the text: (page, line rects in user space, URI). + AddLinks { + links: Vec<(usize, Vec<[f64; 4]>, String)>, + style: LinkStyle, + }, + /// Import comments and/or form data (XFDF, FDF, XML, CSV, tab-delimited text). + ImportData { + name: String, + bytes: Arc>, + }, + /// Remove Hidden Information: the chosen categories. + RemoveHidden { + which: Vec, + }, + /// Sanitize Document: every category, then a full rewrite on save. + Sanitize, + /// An XFA form made an ordinary (static) PDF form: the XFA packets, `/NeedsRendering` and + /// PdfCraft's private keys go, the pages as laid out and the fields with their values + /// stay, so every viewer shows the same form and none lays it out again. Its scripts no + /// longer run. + XfaToStatic, + /// Flatten comments and/or form fields on every page into page content. + Flatten { + comments: bool, + fields: bool, + }, + /// Bake Fill & Sign text, marks and signatures into the page. Other comments and fields stay. + /// A no-op (no undo step) when the document has none. + FlattenFillSign, + /// Protect with passwords and permissions (written by the next save, which is a full rewrite). + Protect(Protection), + /// Remove password security (needs the owner password). + RemoveProtection, + /// Several edits applied as one undoable step (all or nothing). + Batch { + label: String, + edits: Vec, + }, +} + +/// Every wording [`Edit::label`] can produce, for an interface to translate. `Edit::Batch` is not +/// here: its wording is only the hole, because a batch carries its name as text (see +/// [`BATCH_LABELS`]). +pub const EDIT_LABELS: &[&str] = &[ + "Rotate page", + "Change tag properties", + "Rotate pages", + "Delete page", + "Delete pages", + "Move page", + "Move pages", + "Reverse pages", + "Insert blank page", + "Duplicate page", + "Duplicate pages", + "Replace page", + "Replace pages", + "Crop page", + "Crop pages", + "Set page boxes", + "Set page transitions", + "Change Title", + "Change Author", + "Change Subject", + "Change Keywords", + "Change {key}", + "Insert pages from {name}", + "Insert pages from {name} — 1 field renamed", + "Insert pages from {name} — fields renamed", + "Attach {name}", + "Delete attachment {key}", + "Add bookmark", + "Rename bookmark", + "Delete bookmark", + "Move bookmark", + "Set bookmark destination", + "Colour", + "Style", + "New bookmarks from structure", + "Number pages", + "Measure distance", + "Measure perimeter", + "Measure area", + "Set measurement scale", + "Add sticky note", + "Add highlight", + "Add underline", + "Add strikethrough", + "Add squiggly underline", + "Add rectangle", + "Add oval", + "Add arrow", + "Add line", + "Add drawing", + "Add text box", + "Add checkmark", + "Add cross", + "Add dot", + "Add signature", + "Add redaction mark", + "Add cloud", + "Add polygon", + "Add connected lines", + "Add callout", + "Add inserted text", + "Add file attachment", + "Add stamp", + "Delete comment", + "Edit comment", + "Reply", + "Set status None", + "Set status Accepted", + "Set status Rejected", + "Set status Cancelled", + "Set status Completed", + "Replace text", + "Erase", + "Mark with checkmark", + "Remove checkmark", + "Lock comment", + "Unlock comment", + "Move comment", + "Reshape comment", + "Resize comment", + "Change comment properties", + "Fill in {name}", + "Set the image of {name}", + "Clear form", + "Add field", + "Change field properties", + "Delete field", + "Duplicate field", + "Set tab order", + "Set calculation order", + "Change initial view", + "Set alternate text", + "Mark figure as decorative", + "Recognize text", + "Run JavaScript", + "Run form script", + "Save as {level}", + "Remove {standard} identification", + "Edit script of {name}", + "Delete document JavaScript", + "Edit document JavaScript", + "Edit text", + "Load document font", + "Move object", + "Move objects", + "Move image", + "Rotate image", + "Flip image", + "Replace image", + "Delete image", + "Add header & footer", + "Update header & footer", + "Add watermark", + "Update watermark", + "Add background", + "Update background", + "Remove header & footer", + "Remove watermark", + "Remove background", + "Add text", + "Add image", + "Edit content", + "Delete content", + "Paste", + "Apply redactions", + "Remove redaction marks", + "Remove hidden information", + "Import {name}", + "Add link", + "Change link properties", + "Delete link", + "Remove all links", + "Create link", + "Create links", + "Sanitize document", + "Flatten comments", + "Flatten form fields", + "Flatten", + "Flatten Fill & Sign", + "Protect with password", + "Remove security", +]; + +/// 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", field_table::LABEL, "Style"]; + +impl Edit { + /// Label for the Edit menu and history ("Undo Rotate pages"). + pub fn label(&self) -> Label { + match self { + 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::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"), + Edit::SetTransition { .. } => Label::of("Set page transitions"), + // The four `/Info` keys PdfCraft itself offers get a wording of their own, so each can + // be said in another language; any other key is the document's and goes in as it stands. + Edit::SetInfo { key, .. } => match key.as_str() { + "Title" => Label::of("Change Title"), + "Author" => Label::of("Change Author"), + "Subject" => Label::of("Change Subject"), + "Keywords" => Label::of("Change Keywords"), + _ => Label::with("Change {key}", key.as_str()), + }, + Edit::InsertPagesFrom { name, .. } => Label::with("Insert pages from {name}", name.as_str()), + Edit::AddBookmark { .. } => Label::of("Add bookmark"), + Edit::RenameBookmark { .. } => Label::of("Rename bookmark"), + Edit::DeleteBookmark { .. } => Label::of("Delete bookmark"), + Edit::MoveBookmark { .. } => Label::of("Move bookmark"), + Edit::SetBookmarkPage { .. } => Label::of("Set bookmark destination"), + // The bookmark Style and Colour menus' own names, already said in every language. + Edit::SetBookmarkColor { .. } => Label::of("Colour"), + Edit::SetBookmarkStyle { .. } => Label::of("Style"), + Edit::BookmarksFromStructure => Label::of("New bookmarks from structure"), + Edit::NumberPages { .. } => Label::of("Number pages"), + // What was measured is part of the wording too, for the same reason. + Edit::AddMeasurement(m) => Label::of(match m.kind { + pdfcraft_measure::Kind::Distance => "Measure distance", + pdfcraft_measure::Kind::Perimeter => "Measure perimeter", + pdfcraft_measure::Kind::Area => "Measure area", + }), + Edit::SetMeasurementScale { .. } => Label::of("Set measurement scale"), + Edit::AddAnnotation(a) => Label::of(add_annotation(&a.shape)), + Edit::AddCustomStamp { .. } => Label::of("Add stamp"), + Edit::DeleteAnnotation { .. } => Label::of("Delete comment"), + Edit::SetAnnotationContents { .. } => Label::of("Edit comment"), + Edit::ReplyToAnnotation { .. } => Label::of("Reply"), + // The state's name is part of the wording: a language may need to agree it with the rest. + Edit::SetAnnotationStatus { state, .. } => Label::of(match state { + ReviewState::None => "Set status None", + ReviewState::Accepted => "Set status Accepted", + ReviewState::Rejected => "Set status Rejected", + ReviewState::Cancelled => "Set status Cancelled", + ReviewState::Completed => "Set status Completed", + }), + Edit::ReplaceText { .. } => Label::of("Replace text"), + Edit::EraseInk { .. } => Label::of("Erase"), + Edit::MarkAnnotation { marked: true, .. } => Label::of("Mark with checkmark"), + Edit::MarkAnnotation { .. } => Label::of("Remove checkmark"), + 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") + } + Edit::SetFieldValue { name, .. } => Label::with("Fill in {name}", name.as_str()), + Edit::SetFieldImage { name, .. } => Label::with("Set the image of {name}", name.as_str()), + Edit::ResetForm { .. } => Label::of("Clear form"), + Edit::AddField { .. } => Label::of("Add field"), + Edit::SetFieldProps { .. } => Label::of("Change field properties"), + 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::AddAttachment { name, .. } => Label::with("Attach {name}", name.as_str()), + Edit::DeleteAttachment { key } => Label::with("Delete attachment {key}", key.as_str()), + Edit::AutoTagText => Label::of("Automatically tag text"), + Edit::SetAltText { .. } => Label::of("Set alternate text"), + Edit::AutoTagFormFields => Label::of("Tag form fields"), + Edit::SetTagProperty { .. } => Label::of("Change tag properties"), + Edit::MoveTag { .. } => Label::of("Move object"), + Edit::MarkDecorative { .. } => Label::of("Mark figure as decorative"), + Edit::MarkTagArtifact { .. } => Label::of("Mark content as artifact"), + Edit::AddOcrText { .. } => Label::of("Recognize text"), + Edit::ApplyScriptChanges { .. } => Label::of("Run JavaScript"), + 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"), + Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } => Label::of("Edit text"), + Edit::RegisterTextFont { .. } => Label::of("Load document font"), + Edit::MoveObjects { objects, .. } => plural("Move object", "Move objects", objects.len()), + Edit::EditPageImage { change, .. } => Label::of(match change { + ImageEdit::Move(_) => "Move image", + ImageEdit::Rotate(_) | ImageEdit::RotateBy(_) => "Rotate image", + ImageEdit::Crop(_) => "Crop image", + ImageEdit::Flip { .. } => "Flip image", + ImageEdit::Replace { .. } => "Replace image", + ImageEdit::Delete => "Delete image", + }), + Edit::AddHeaderFooter { replace: false, .. } => Label::of("Add header & footer"), + Edit::AddHeaderFooter { .. } => Label::of("Update header & footer"), + Edit::AddWatermark { replace: false, .. } => Label::of("Add watermark"), + Edit::AddWatermark { .. } => Label::of("Update watermark"), + Edit::AddBackground { replace: false, .. } => Label::of("Add background"), + Edit::AddBackground { .. } => Label::of("Update background"), + Edit::RemoveMarks { kind: MarkKind::HeaderFooter } => Label::of("Remove header & footer"), + Edit::RemoveMarks { kind: MarkKind::Watermark } => Label::of("Remove watermark"), + Edit::RemoveMarks { kind: MarkKind::Background } => Label::of("Remove background"), + Edit::AddText { .. } => Label::of("Add text"), + Edit::AddImage { .. } => Label::of("Add image"), + Edit::UpdateContent { .. } => Label::of("Edit content"), + Edit::DeleteContent { .. } => Label::of("Delete content"), + Edit::ReplaceImage { .. } => Label::of("Replace image"), + Edit::PasteContent { .. } => Label::of("Paste"), + Edit::ApplyRedactions { .. } => Label::of("Apply redactions"), + Edit::ClearRedactions => Label::of("Remove redaction marks"), + Edit::RemoveHidden { .. } => Label::of("Remove hidden information"), + Edit::ImportData { name, .. } => Label::with("Import {name}", name.as_str()), + Edit::AddLink { .. } => Label::of("Add link"), + Edit::SetLink { .. } => Label::of("Change link properties"), + Edit::DeleteLink { .. } => Label::of("Delete link"), + Edit::RemoveLinks { .. } => Label::of("Remove all links"), + Edit::AddLinks { links, .. } => plural("Create link", "Create links", links.len()), + Edit::Sanitize => Label::of("Sanitize document"), + Edit::XfaToStatic => Label::of("Convert to a static form"), + Edit::Flatten { comments: true, fields: false } => Label::of("Flatten comments"), + Edit::Flatten { comments: false, fields: true } => Label::of("Flatten form fields"), + Edit::Flatten { .. } => Label::of("Flatten"), + Edit::FlattenFillSign => Label::of("Flatten Fill & Sign"), + Edit::Protect(_) => Label::of("Protect with password"), + Edit::RemoveProtection => Label::of("Remove security"), + // A batch carries its own name, already worded by whoever built it. + // A batch carries its name as text, so the wording is only the hole (see `BATCH_LABELS`). + Edit::Batch { label, .. } => Label::with("{batch}", label.as_str()), + } + } +} + +/// What the Edit menu calls a new comment ("Undo Add highlight"). A whole wording for each kind, +/// so a language can give the noun the article and the gender its own grammar asks for. +fn add_annotation(s: &Shape) -> &'static str { + match s { + Shape::Note { .. } => "Add sticky note", + Shape::TextMarkup { kind: Markup::Highlight, .. } => "Add highlight", + Shape::TextMarkup { kind: Markup::Underline, .. } => "Add underline", + Shape::TextMarkup { kind: Markup::StrikeOut, .. } => "Add strikethrough", + Shape::TextMarkup { kind: Markup::Squiggly, .. } => "Add squiggly underline", + Shape::Rectangle { .. } => "Add rectangle", + Shape::Oval { .. } => "Add oval", + Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "Add arrow", + Shape::Line { .. } => "Add line", + Shape::Ink { .. } => "Add drawing", + Shape::TextBox { .. } => "Add text box", + Shape::Typewriter { .. } => "Add text", + Shape::Mark { mark: FillMark::Check, .. } => "Add checkmark", + Shape::Mark { mark: FillMark::Cross, .. } => "Add cross", + Shape::Mark { mark: FillMark::Dot, .. } => "Add dot", + Shape::Mark { mark: FillMark::Line, .. } => "Add line", + Shape::Signature { .. } | Shape::TypedSignature { .. } => "Add signature", + Shape::Redact { .. } => "Add redaction mark", + Shape::Stamp { .. } | Shape::CustomStamp { .. } => "Add stamp", + Shape::Polygon { cloud: true, .. } => "Add cloud", + Shape::Polygon { .. } => "Add polygon", + Shape::PolyLine { .. } => "Add connected lines", + Shape::Callout { .. } => "Add callout", + Shape::Caret { .. } => "Add inserted text", + Shape::Attachment { .. } => "Add file attachment", + } +} + +/// Whether the opening password allows an edit (§7.6.4.2, Table 22). +pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), EditError> { + match edit { + Edit::RotatePages { .. } + | Edit::DeletePages { .. } + | Edit::MovePages { .. } + | Edit::ReversePages { .. } + | Edit::InsertBlankPage { .. } + | Edit::DuplicatePages { .. } + | Edit::ReplacePages { .. } + | Edit::SetPageBox { .. } + | Edit::SetTransition { .. } + | Edit::InsertPagesFrom { .. } + // "Assemble the document: insert, rotate or delete pages and create bookmarks" (Table 22). + | Edit::AddBookmark { .. } + | Edit::RenameBookmark { .. } + | Edit::DeleteBookmark { .. } + | Edit::MoveBookmark { .. } + | Edit::SetBookmarkPage { .. } + | Edit::SetBookmarkColor { .. } + | Edit::SetBookmarkStyle { .. } + | Edit::BookmarksFromStructure + | Edit::NumberPages { .. } => { + if p.assemble() { + Ok(()) + } else { + Err(EditError::NotPermitted("page changes")) + } + } + Edit::AddMeasurement(_) + | Edit::AddAnnotation(_) + | Edit::AddCustomStamp { .. } + | Edit::DeleteAnnotation { .. } + | Edit::SetAnnotationContents { .. } + | Edit::ReplyToAnnotation { .. } + | Edit::SetAnnotationStatus { .. } + | Edit::MarkAnnotation { .. } + | Edit::ReplaceText { .. } + | Edit::EraseInk { .. } + | Edit::LockAnnotation { .. } + | Edit::MoveAnnotation { .. } + | Edit::ReshapeAnnotation { .. } + | Edit::ResizeAnnotation { .. } + | Edit::StyleAnnotation { .. } + | Edit::FillAnnotation { .. } + | Edit::FontSizeAnnotation { .. } + | Edit::SetAnnotationInfo { .. } + | Edit::SetMeasurementScale { .. } => { + if p.annotate() { + Ok(()) + } else { + Err(EditError::NotPermitted("comments")) + } + } + Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } | Edit::XfaEvent { .. } => { + if p.fill_forms() { + Ok(()) + } else { + Err(EditError::NotPermitted("filling in form fields")) + } + } + Edit::Protect(_) | Edit::RemoveProtection => { + if unrestricted(p) { + Ok(()) + } else { + Err(EditError::NotPermitted("changing security")) + } + } + // Converting to PDF/A takes the encryption off (PDF/A forbids it), so on an encrypted + // document it is a change of security (#3921). + Edit::ConvertPdfA { .. } => { + if unrestricted(p) { + Ok(()) + } else { + Err(EditError::NotPermitted("removing the encryption, which PDF/A forbids")) + } + } + Edit::SetInfo { .. } + | Edit::AddHeaderFooter { .. } + | Edit::AddWatermark { .. } + | Edit::AddBackground { .. } + | Edit::RemoveMarks { .. } + | Edit::AddField { .. } + | Edit::ApplyRedactions { .. } + | Edit::AddText { .. } + | Edit::AddImage { .. } + | Edit::UpdateContent { .. } + | Edit::MoveObjects { .. } + | Edit::DeleteContent { .. } + | Edit::ReplaceImage { .. } + | Edit::PasteContent { .. } + | Edit::ClearRedactions + | Edit::RemoveHidden { .. } + | Edit::ImportData { .. } + | Edit::AddLink { .. } + | Edit::SetLink { .. } + | Edit::DeleteLink { .. } + | Edit::RemoveLinks { .. } + | Edit::AddLinks { .. } + | Edit::Sanitize + | Edit::XfaToStatic + | Edit::SetFieldProps { .. } + | Edit::DeleteField { .. } + | Edit::DuplicateField { .. } + | Edit::SetTabOrder { .. } + | Edit::MoveInTabOrder { .. } + | Edit::SetCalculationOrder { .. } + | Edit::SetInitialView(_) + | Edit::AddAttachment { .. } + | Edit::DeleteAttachment { .. } + | Edit::AutoTagText + | Edit::SetAltText { .. } + | Edit::AutoTagFormFields + | Edit::SetTagProperty { .. } + | Edit::MoveTag { .. } + | Edit::MarkDecorative { .. } + | Edit::MarkTagArtifact { .. } + | Edit::AddOcrText { .. } + | Edit::SetDocumentScript { .. } + | Edit::SetFieldScript { .. } + | Edit::RemoveStandardId { .. } + | Edit::EditTextLine { .. } + | Edit::EditTextBlock { .. } + | Edit::RegisterTextFont { .. } + | Edit::EditPageImage { .. } + | Edit::Flatten { .. } + | Edit::FlattenFillSign => { + if p.modify() { + Ok(()) + } else { + Err(EditError::NotPermitted("changes to the document")) + } + } + Edit::Batch { edits, .. } => edits.iter().try_for_each(|e| check_permission(e, p)), + } +} + +/// Dates and unique ids stamped onto what an edit creates. +pub(crate) struct EditCtx { + /// Field scripts run (`None`: JavaScript is off) and what they produced. + pub(crate) js: Option, + /// A laid-out XFA form's template (`None`: not an XFA form, or JavaScript is off) and what + /// its scripts produced. + pub(crate) xfa: Option>, + pub(crate) xfa_out: js::JsOutput, + /// The datasets stream this edit wrote (later writes in the edit replace it in place). + pub(crate) xfa_datasets: Option, + /// A form script ran too long and was abandoned: the document's scripts go off. + pub(crate) xfa_ran_away: bool, + /// What the XFA scripts' time limits are multiplied by ([`Session::with_xfa_time_scale`]). + pub(crate) xfa_time_scale: u32, + /// The edit took the XFA out of the form: what went with it, for the document's warnings. + pub(crate) xfa_removed: Vec, + /// Pages in the document, for `xfa.layout.pageCount()`. + pub(crate) pages: usize, + pub(crate) date: Option, + /// Today in local time, for date tokens. + pub(crate) today: (i64, u32, u32), + pub(crate) seed: u64, + pub(crate) count: u64, + /// Fields the edit renamed to keep imported names separate (old → new), for the repair + /// log and the undo label. + pub(crate) renamed_fields: Vec<(String, String)>, + /// The overlay streams the edit's applied redactions added, which alone the renderer's check + /// leaves out. + pub(crate) redaction_overlays: Vec, +} + +impl EditCtx { + pub(crate) fn new(now: Option, salt: u64) -> Self { + let mut seed = 0x9E37_79B9_7F4A_7C15u64 ^ salt; + if let Some(t) = now { + seed ^= (t as u64).rotate_left(17); + } + #[cfg(not(target_arch = "wasm32"))] + if now.is_some() { + // Real clock: mix in sub-second time so ids from two sessions don't collide. + seed ^= std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos() as u64).unwrap_or(0) << 32; + } + Self { + js: None, + xfa: None, + xfa_out: Default::default(), + xfa_datasets: None, + xfa_ran_away: false, + xfa_time_scale: 1, + xfa_removed: Vec::new(), + pages: 0, + date: now.map(pdfcraft_cos::pdf_date), + today: (1970, 1, 1), + seed, + count: 0, + renamed_fields: Vec::new(), + redaction_overlays: Vec::new(), + } + } + + /// 32 bytes of entropy for new encryption keys and salts. `RandomState` is seeded by the + /// operating system, so no extra dependency is needed. + fn entropy(&mut self) -> [u8; 32] { + use std::hash::{BuildHasher, Hasher}; + let mut out = [0u8; 32]; + for (i, chunk) in out.chunks_mut(8).enumerate() { + let mut h = std::collections::hash_map::RandomState::new().build_hasher(); + h.write_u64(self.seed ^ i as u64); + h.write_u64(self.count); + self.count += 1; + chunk.copy_from_slice(&h.finish().to_le_bytes()); + } + out + } + + /// A fresh `/NM`: a random-looking UUID (version 4 layout) from a splitmix64 stream. + fn meta(&mut self) -> pdfcraft_annot::Meta { + let mut next = || { + self.count += 1; + let mut z = self.seed.wrapping_add(self.count.wrapping_mul(0x9E37_79B9_7F4A_7C15)); + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + }; + let (a, b) = (next(), next()); + let id = format!( + "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}", + a >> 32, + (a >> 16) & 0xffff, + a & 0xfff, + 0x8000 | (b >> 48) & 0x3fff, + b & 0xffff_ffff_ffff + ); + pdfcraft_annot::Meta { date: self.date.clone(), id } + } +} + +/// Perform an edit on a working copy (the caller discards it on error). +pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Result<(), EditError> { + match edit { + 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, 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)?; + } + Edit::SetPageBox { pages, which, spec } => pdfcraft_organize::set_page_box(doc, pages, *which, *spec)?, + Edit::SetTransition { pages, spec } => pdfcraft_organize::set_transition(doc, pages, spec)?, + Edit::InsertPagesFrom { name, bytes, pages, at, field_conflicts } => { + let src = open_source(name, bytes)?; + let pages = match pages { + Some(p) => p.clone(), + None => (0..pdfcraft_organize::page_count(&src)?).collect(), + }; + // The file's bookmarks come along under one named after it, as in Combine Files. + let title = std::path::Path::new(name).file_stem().map_or_else(|| name.clone(), |s| s.to_string_lossy().into_owned()); + 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, 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)?, + Edit::MoveBookmark { from, to_parent, index } => { + pdfcraft_organize::move_bookmark(doc, from, to_parent, *index)?; + } + Edit::SetBookmarkPage { path, page } => pdfcraft_organize::set_bookmark_page(doc, path, *page)?, + Edit::SetBookmarkColor { path, color } => pdfcraft_organize::set_bookmark_color(doc, path, *color)?, + Edit::SetBookmarkStyle { path, bold, italic } => pdfcraft_organize::set_bookmark_style(doc, path, *bold, *italic)?, + Edit::BookmarksFromStructure => { + let entries: Vec<_> = pdfcraft_a11y::headings(doc) + .into_iter() + .map(|h| pdfcraft_organize::OutlineEntry { level: h.level, title: h.title, page: h.page, element: h.obj }) + .collect(); + pdfcraft_organize::add_bookmark_tree(doc, "Untitled", &entries)?; + } + Edit::NumberPages { from, to, style, prefix, first } => pdfcraft_organize::number_pages(doc, *from, *to, *style, prefix, *first)?, + Edit::AddMeasurement(m) => { + measure::add(doc, m, &cx.meta())?; + } + Edit::SetMeasurementScale { page, bbox, name, scale } => measure::set_scale(doc, *page, *bbox, name, scale)?, + Edit::AddAnnotation(a) => { + pdfcraft_annot::add_annotation(doc, a, &cx.meta())?; + } + Edit::AddCustomStamp { page, rect, name, file, author } => { + let src = mark_source(doc, file)?; + let (sw, sh) = (src.size.0.max(1.0), src.size.1.max(1.0)); + let natural = rect[2] == rect[0] && rect[3] == rect[1]; + let rotation = pdfcraft_organize::page_rotation(doc, *page)?; + // The picture as displayed: a quarter turn swaps its sides in user space. + let (dw, dh) = if matches!(rotation, 90 | 270) { (sh, sw) } else { (sw, sh) }; + let rect = if natural { + let k = (200.0 / sw.max(sh)).min(1.0); + let (w, h) = (dw * k, dh * k); + [rect[0] - w / 2.0, rect[1] - h / 2.0, rect[0] + w / 2.0, rect[1] + h / 2.0] + } else { + fit_picture(*rect, dw, dh) + }; + let shape = Shape::CustomStamp { rect, name: name.clone(), picture: src.xobject, image: src.image, size: (sw, sh) }; + let style = pdfcraft_annot::Style::default_for(&shape); + let new = NewAnnotation { page: *page, shape, style, contents: name.clone(), author: author.clone() }; + let index = pdfcraft_annot::add_annotation(doc, &new, &cx.meta())?; + pdfcraft_annot::orient_image_stamp(doc, *page, index, rotation)?; + } + Edit::DeleteAnnotation { page, index } => pdfcraft_annot::delete_annotation(doc, *page, *index)?, + Edit::SetAnnotationContents { page, index, text } => pdfcraft_annot::set_contents(doc, *page, *index, text, &cx.meta())?, + Edit::ReplyToAnnotation { page, index, text, author } => { + pdfcraft_annot::add_reply(doc, *page, *index, text, author, &cx.meta())?; + } + Edit::SetAnnotationStatus { page, index, state, author } => { + pdfcraft_annot::set_review_state(doc, *page, *index, *state, author, &cx.meta())?; + } + Edit::EraseInk { page, index, path, radius } => { + pdfcraft_annot::erase_ink(doc, *page, *index, path, *radius, &cx.meta())?; + } + Edit::AddAttachment { name, bytes, description, mime } => { + pdfcraft_organize::add_attachment(doc, name, bytes, description.as_deref(), mime.as_deref(), cx.date.as_deref())?; + } + Edit::DeleteAttachment { key } => pdfcraft_organize::delete_attachment(doc, key)?, + Edit::ReplaceText { page, quads, text, author, strike, caret } => { + // 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())?; + } + Edit::FillAnnotation { page, index, fill } => pdfcraft_annot::set_fill(doc, *page, *index, *fill, &cx.meta())?, + Edit::FontSizeAnnotation { page, index, size } => pdfcraft_annot::set_font_size(doc, *page, *index, *size, &cx.meta())?, + Edit::SetAnnotationInfo { page, index, author, subject, icon } => { + pdfcraft_annot::set_info(doc, *page, *index, author.as_deref(), subject.as_deref(), *icon, &cx.meta())?; + } + Edit::SetFieldValue { name, value } => { + let mut set = |doc: &mut pdfcraft_cos::Document, value: &FieldValue| match cx.js.as_mut() { + Some(js) => pdfcraft_forms::set_value_with(doc, name, value, js), + None => pdfcraft_forms::set_value(doc, name, value), + }; + match set(doc, value) { + Ok(()) => {} + // A date the field's format doesn't read may be typed by another of an XFA date + // field's patterns: it is kept in the first. Only a format refusal is tried + // again: it comes before any of the field's scripts run, so they run once. + Err(refused @ pdfcraft_forms::FormError::Format(_)) => match xfa::typed_date(doc, cx.xfa.as_deref(), name, value) { + Some(typed) => set(doc, &typed)?, + None => return Err(refused.into()), + }, + Err(refused) => return Err(refused.into()), + } + } + Edit::ApplyScriptChanges { changes } => match cx.js.as_mut() { + Some(js) => pdfcraft_forms::apply_script_changes(doc, changes, js)?, + None => pdfcraft_forms::apply_script_changes(doc, changes, &mut pdfcraft_forms::NoScripts)?, + }, + Edit::XfaEvent { som, activity } => { + let tpl = cx.xfa.clone().ok_or_else(|| EditError::Invalid("this is not a laid-out XFA form, or JavaScript is off".into()))?; + let mut run = + xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false, time_scale: cx.xfa_time_scale }; + let ran = xfa::on_event(doc, &tpl, som, activity, &mut run); + cx.xfa_datasets = run.datasets; + cx.xfa_ran_away |= run.ran_away; + ran.map_err(EditError::Invalid)?; + } + Edit::SetFieldImage { name, image } => { + let (img, _) = embed_image(doc, name, image)?; + let px = match &*doc.get(img) { + pdfcraft_cos::Object::Stream(s) => (s.dict.int(b"Width").unwrap_or(1) as u32, s.dict.int(b"Height").unwrap_or(1) as u32), + _ => (1, 1), + }; + // An XFA image field keeps the picture in the data too (as Designer saves it), and a + // laid-out form shows it from there: so only pictures the layout can show, and only + // for image fields (a laid-out one carries its SOM path; a static form's field name + // is its path). + let is_xfa = pdfcraft_xfa::read_packets(doc).ok().flatten().is_some(); + let image_field = is_xfa + && 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, 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()); + let path = match cx.xfa.as_deref() { + Some(tpl) => pdfcraft_xfa::data_path(tpl, &som), + None => pdfcraft_xfa::som_to_path(&som), + }; + let r = + pdfcraft_xfa::write_data_image(doc, &path, content_type, image, cx.xfa_datasets).map_err(|e| EditError::Write(e.to_string()))?; + if !r.written && r.warnings.is_empty() { + return Err(EditError::Write(format!("{name}: the picture could not be written into the form's data"))); + } + cx.xfa_datasets = r.stream.or(cx.xfa_datasets); + cx.xfa_out.errors.extend(r.warnings); + xfa_after_data_change(doc, cx, false)?; + } else { + pdfcraft_forms::set_button_icon(doc, name, img, px)?; + } + } + Edit::ResetForm { names } => { + pdfcraft_forms::reset(doc, names.as_deref())?; + } + Edit::AddField { page, rect, kind, name } => { + pdfcraft_forms::add_field(doc, *page, *rect, kind, name.as_deref())?; + } + Edit::SetFieldProps { name, props } => { + pdfcraft_forms::set_props(doc, name, props)?; + } + Edit::DuplicateField { name, pages } => { + if pdfcraft_forms::duplicate_field(doc, name, pages)? == 0 { + return Err(EditError::Form(pdfcraft_forms::FormError::Invalid(format!("{name} is already on those pages")))); + } + } + 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()))?; + } + Edit::SetAltText { figure, alt } => { + let r = pdfcraft_cos::ObjRef::new(*figure, doc.generation(*figure)); + a11y::set_alt(doc, r, alt.as_deref()).map_err(|e| EditError::Accessibility(e.to_string()))?; + } + Edit::AutoTagFormFields => { + a11y::tag_form_fields(doc).map_err(EditError::Accessibility)?; + } + Edit::SetTagProperty { object, property, value } => { + a11y::set_tag_property(doc, *object, *property, value.as_deref()).map_err(|e| EditError::Accessibility(e.to_string()))?; + } + Edit::MoveTag { object, anchor, placement } => { + a11y::move_tag(doc, *object, *anchor, *placement).map_err(|e| EditError::Accessibility(e.to_string()))?; + } + Edit::EditTextLine { page, line, text } => { + pdfcraft_edit::replace_line(doc, *page, *line, text)?; + } + Edit::SetFieldScript { name, event, script } => { + pdfcraft_forms::set_field_script(doc, name, event, script.as_deref())?; + match cx.js.as_mut() { + Some(js) => pdfcraft_forms::recalculate_with(doc, js)?, + None => pdfcraft_forms::recalculate(doc)?, + }; + } + 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))?; + } + Edit::EditPageImage { page, index, change } => { + let img = pdfcraft_edit::page_images(doc, *page)? + .into_iter() + .nth(*index) + .ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1))))?; + let c = match change { + ImageEdit::Move(to) => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::rect_to_rect(img.rect, *to)), + 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, + *horizontal, + !*horizontal, + )), + ImageEdit::Replace { name, bytes } => { + if img.is_form { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid( + "grouped Form artwork cannot be replaced with a bitmap".into(), + ))); + } + pdfcraft_edit::ImageChange::Replace(embed_image(doc, name, bytes)?.0) + } + ImageEdit::Crop(keep) => pdfcraft_edit::ImageChange::Crop(*keep), + ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete, + }; + pdfcraft_edit::change_image(doc, *page, *index, &c)?; + } + Edit::MoveObjects { page, objects, offset } => pdfcraft_edit::move_objects(doc, *page, objects, *offset)?, + Edit::EditTextBlock { page, block, text, style } => { + pdfcraft_edit::rewrite_block(doc, *page, *block, Some(text), style)?; + } + Edit::RegisterTextFont { page, bytes } => { + pdfcraft_edit::text::register_text_font(doc, *page, bytes)?; + } + Edit::MarkDecorative { figure } => { + let r = pdfcraft_cos::ObjRef::new(*figure, doc.generation(*figure)); + a11y::mark_decorative(doc, r).map_err(|e| EditError::Accessibility(e.to_string()))?; + } + Edit::MarkTagArtifact { object } => { + a11y::mark_tag_artifact(doc, *object).map_err(|e| EditError::Accessibility(e.to_string()))?; + } + Edit::AddHeaderFooter { pages, settings, replace } => { + let date = cx.today; + pdfcraft_edit::add_header_footer(doc, pages, settings, *replace, &pdfcraft_edit::Context { date })?; + } + Edit::AddWatermark { pages, settings, replace, file } => { + let mut s = settings.clone(); + if let Some(f) = file { + s.source = Some(mark_source(doc, f)?); + } + pdfcraft_edit::add_watermark(doc, pages, &s, *replace)? + } + Edit::AddBackground { pages, settings, replace, file } => { + let mut s = settings.clone(); + if let Some(f) = file { + s.source = Some(mark_source(doc, f)?); + } + pdfcraft_edit::add_background(doc, pages, &s, *replace)? + } + Edit::RemoveMarks { kind } => { + let n = pdfcraft_model::pages(doc).len(); + if pdfcraft_edit::remove_marks(doc, &(0..n).collect::>(), *kind)? == 0 { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("there is nothing to remove".into()))); + } + } + Edit::AddText { page, text } => { + pdfcraft_edit::add_content(doc, *page, &AddedContent::Text(text.clone()))?; + } + Edit::AddImage { page, rect, name, bytes } => { + let (image, natural) = embed_image(doc, name, bytes)?; + let rect = match rect { + Some(r) => *r, + None => { + let p = pdfcraft_model::pages(doc).swap_remove(*page); + let (pw, ph) = p.display_size(doc); + let k = ((pw * 0.8) / natural.0).min((ph * 0.8) / natural.1).min(1.0); + let (w, h) = (natural.0 * k, natural.1 * k); + [(pw - w) / 2.0, (ph - h) / 2.0, (pw + w) / 2.0, (ph + h) / 2.0] + } + }; + pdfcraft_edit::add_content(doc, *page, &AddedContent::Image(pdfcraft_edit::AddedImage::new(rect, image)))?; + } + Edit::UpdateContent { page, index, content } => pdfcraft_edit::update_content(doc, *page, *index, content)?, + Edit::DeleteContent { page, index } => pdfcraft_edit::delete_content(doc, *page, *index)?, + Edit::ReplaceImage { page, index, name, bytes } => { + let item = pdfcraft_edit::list_added(doc).into_iter().filter(|a| a.page == *page).nth(*index); + let Some(AddedContent::Image(old)) = item.map(|a| a.content) else { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("that item is not an image".into()))); + }; + let (image, _) = embed_image(doc, name, bytes)?; + pdfcraft_edit::update_content(doc, *page, *index, &AddedContent::Image(pdfcraft_edit::AddedImage { image, ..old }))?; + } + Edit::PasteContent { page, content, file } => { + let content = match content { + AddedContent::Text(_) => content.clone(), + AddedContent::Image(img) => { + let Some((name, bytes)) = file else { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("a pasted image needs its picture".into()))); + }; + // The same picture in this document (copied here, pasted here before, or an + // identical file): drawn again from the one image object rather than stored twice. + let image = if copy_image::is_picture(doc, img.image, bytes) { + img.image + } else { + match copy_image::added_picture(doc, bytes) { + Some(image) => image, + None => embed_image(doc, name, bytes)?.0, + } + }; + AddedContent::Image(pdfcraft_edit::AddedImage { image, ..img.clone() }) + } + }; + pdfcraft_edit::add_content(doc, *page, &content)?; + } + Edit::ApplyRedactions { pages } => { + cx.redaction_overlays = pdfcraft_redact::apply(doc, pages.as_deref())?.overlays; + } + Edit::ClearRedactions => { + pdfcraft_redact::clear_marks(doc, None)?; + } + Edit::AddLink { page, rect, action, style } => { + pdfcraft_annot::links::add(doc, *page, *rect, action, style)?; + } + Edit::SetLink { page, index, rect, action, style } => pdfcraft_annot::links::set(doc, *page, *index, *rect, action.as_ref(), style.as_ref())?, + Edit::DeleteLink { page, index } => pdfcraft_annot::links::delete(doc, *page, *index)?, + Edit::RemoveLinks { pages } => { + if pdfcraft_annot::links::remove_all(doc, pages.as_deref())? == 0 { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("there are no links to remove".into()))); + } + } + Edit::AddLinks { links, style } => { + if pdfcraft_annot::links::add_many(doc, links, style)? == 0 { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("no web addresses were found".into()))); + } + } + Edit::ImportData { bytes, .. } => { + // An XFA form takes XFA data files (XDP, datasets, or a data document) as its data; + // XFDF, FDF, Acrobat's XML and tables go the usual way. + let text = (pdfcraft_xfa::read_packets(doc).ok().flatten().is_some()).then(|| decode_data_file(bytes)).flatten(); + match text { + Some(Ok(text)) if is_xfa_data_file(&text) => { + let expect_root = cx.xfa.as_ref().and_then(|t| t.root.common.name.clone()); + let r = pdfcraft_xfa::import_data(doc, &text, expect_root.as_deref(), cx.xfa_datasets) + .map_err(|e| EditError::Write(e.to_string()))?; + cx.xfa_datasets = r.stream.or(cx.xfa_datasets); + cx.xfa_out.errors.extend(r.warnings); + cx.xfa_out.console.push(format!("Imported XFA data: {} node(s)", r.nodes)); + xfa_after_data_change(doc, cx, true)?; + } + Some(Err(why)) => return Err(EditError::Invalid(why)), + _ => { + pdfcraft_xfdf::import(doc, bytes)?; + } + } + } + Edit::RemoveHidden { which } => { + pdfcraft_redact::sanitize::remove_hidden(doc, which)?; + } + Edit::Sanitize => { + pdfcraft_redact::sanitize::sanitize(doc)?; + } + Edit::Flatten { comments, fields } => { + // An XFA form's fields baked into the pages: the XFA goes too, or Adobe's viewers + // would lay the form out again from its template over the baked pages. A dynamic + // form that was never laid out keeps its XFA: without it only placeholders remain. + let shape = (*fields).then(|| pdfcraft_xfa::xfa_shape(doc)).flatten(); + let n = pdfcraft_model::pages(doc).len(); + pdfcraft_edit::flatten(doc, &(0..n).collect::>(), *comments, *fields)?; + match shape { + Some(shape) if shape.strippable() => strip_xfa(doc, cx)?, + Some(_) => cx.xfa_removed.push("The XFA was kept: this dynamic form was not laid out, so its fields were not baked in".into()), + None => {} + } + } + Edit::XfaToStatic => { + let Some(shape) = pdfcraft_xfa::xfa_shape(doc) else { + return Err(EditError::Invalid("this document is not an XFA form".into())); + }; + if !shape.strippable() { + return Err(EditError::Invalid("this dynamic XFA form was not laid out, so there is no static form to keep".into())); + } + strip_xfa(doc, cx)?; + } + Edit::FlattenFillSign => { + let n = pdfcraft_model::pages(doc).len(); + pdfcraft_edit::flatten_fill_sign(doc, &(0..n).collect::>())?; + } + Edit::Protect(p) => { + p.validate()?; + let seed = cx.entropy(); + // Without a permissions password nothing is restricted, so the owner password is a + // random one nobody needs. + let random_owner: String = cx.entropy().iter().map(|b| format!("{b:02x}")).collect(); + let params = pdfcraft_cos::NewEncryption { + algorithm: p.algorithm, + user_password: p.open_password.as_deref().unwrap_or(""), + owner_password: p.permissions_password.as_deref().unwrap_or(&random_owner), + permissions: p.permission_bits(), + encrypt_metadata: p.encrypt_metadata, + seed, + }; + doc.set_encryption(¶ms).map_err(|e| EditError::Protection(e.to_string()))?; + } + Edit::RemoveProtection => { + if doc.output_handler().is_none() { + return Err(EditError::Protection("the document isn't protected".into())); + } + doc.remove_encryption(); + } + Edit::Batch { edits, .. } => { + for e in edits { + run_edit(doc, e, cx)?; + } + } + } + Ok(()) +} + +/// The singular or the plural wording, by count. Both are written out: a language forms its plural +/// its own way, and `one` and `many` may differ in more than an "s". +fn plural(one: &'static str, many: &'static str, n: usize) -> Label { + Label::of(if n == 1 { one } else { many }) +} diff --git a/crates/engine/src/error.rs b/crates/engine/src/error.rs new file mode 100644 index 0000000000..5ee915fcdf --- /dev/null +++ b/crates/engine/src/error.rs @@ -0,0 +1,80 @@ +//! The errors editing and combining report. + +use crate::measure; + +/// Why a file can't be a source for Combine Files (or Insert / Replace pages). +#[derive(Debug, Clone, PartialEq, thiserror::Error)] +pub enum SourceProblem { + #[error("it is password-protected")] + Password, + #[error("the password is wrong")] + WrongPassword, + #[error("its security settings don't allow copying pages")] + NotPermitted, + #[error("{0}")] + Unreadable(String), +} + +#[derive(Debug, thiserror::Error, PartialEq)] +pub enum EditError { + #[error("no such document")] + NoDocument, + #[error("this document can't be edited: {0}")] + ReadOnly(String), + #[error("the document's security settings don't allow {0}; open it with the owner password to make this change")] + NotPermitted(&'static str), + #[error("{0}")] + Organize(#[from] pdfcraft_organize::OrganizeError), + #[error("{0}")] + Bookmark(#[from] pdfcraft_organize::OutlineError), + #[error("{0}")] + Comment(#[from] pdfcraft_annot::AnnotError), + #[error(transparent)] + Measure(#[from] measure::MeasureError), + #[error("{0}")] + Protection(String), + #[error("the password is incorrect")] + WrongPassword, + #[error("{0}")] + Form(#[from] pdfcraft_forms::FormError), + #[error(transparent)] + Redact(#[from] pdfcraft_redact::RedactError), + #[error("{0}")] + Print(String), + #[error("{0}")] + Accessibility(String), + #[error(transparent)] + Data(#[from] pdfcraft_xfdf::DataError), + #[error("{0}")] + Edit(#[from] pdfcraft_edit::EditError), + #[error("{0}")] + Create(#[from] pdfcraft_create::CreateError), + #[error("the edited document could not be written: {0}")] + Write(String), + #[error("the edited document could not be reopened: {0}")] + Reopen(String), + #[error("couldn't use {0}")] + Source(String), + #[error("nothing to undo")] + NothingToUndo, + #[error("nothing to redo")] + NothingToRedo, + #[error("{0}")] + Sign(String), + #[error("{0}")] + Optimize(String), + #[error("cancelled")] + Cancelled, + #[error("{0} isn't possible in a signed document: it would rewrite the file and invalidate the signatures")] + SignedRewrite(String), + #[error("this document is signed: rewriting it would invalidate its signatures (save it incrementally instead)")] + Signed, + #[error("{0}")] + Invalid(String), +} + +impl From for EditError { + fn from(e: pdfcraft_sign::SignError) -> Self { + EditError::Sign(e.to_string()) + } +} diff --git a/crates/engine/src/fill_sign.rs b/crates/engine/src/fill_sign.rs new file mode 100644 index 0000000000..1bd8c68d05 --- /dev/null +++ b/crates/engine/src/fill_sign.rs @@ -0,0 +1,47 @@ +//! Fill & Sign geometry: typed signatures, and boxes placed as a turned page displays them. + +use crate::{Shape, script_outline, signature_image}; + +/// Fill & Sign: `text` in the script font as a typed signature, its left edge at `at` (user +/// space, vertically centred) and `height` points tall. `None` for text with no outlines. +/// +/// Left, centred and upright are as displayed on a page turned by `rotation` (its `/Rotate`): +/// the box runs along the displayed axes, like an image signature's +/// ([`SignatureImage::rect`](crate::SignatureImage::rect)), and the outlines are turned back so the name reads across. +pub fn typed_signature_shape(at: [f64; 2], text: &str, height: f64, rotation: i64) -> Option { + let o = script_outline(text); + let [left, bottom, right, top] = o.bounds(); + let span = (top - bottom).max(0.1); + let width = right - left; + if o.contours.is_empty() || o.width <= 0.0 || !(width > 0.0 && height > 0.0) { + return None; + } + let k = height / span; + let rect = signature_image::upright_box(rotation, at, width * k, height)?; + // Displayed right and up as user-space unit vectors: a point at (nx, ny) of the displayed box + // is at this fraction of the user-space box. Exact for an unturned page. + let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(rotation, [0.0; 4]); + let to_user = |nx: f64, ny: f64| [a * nx + c * ny + (-a).max(0.0) + (-c).max(0.0), b * nx + d * ny + (-b).max(0.0) + (-d).max(0.0)]; + let contours = o.contours.iter().map(|c| c.iter().map(|p| to_user((p[0] - left) / width, (p[1] - bottom) / span)).collect()).collect(); + Some(Shape::TypedSignature { rect, contours }) +} + +/// `at` moved `right` and `up` points as displayed on a page turned by `rotation` (its `/Rotate`). +pub fn displayed_offset(rotation: i64, at: [f64; 2], right: f64, up: f64) -> [f64; 2] { + let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(rotation, [0.0; 4]); + [at[0] + a * right + c * up, at[1] + b * right + d * up] +} + +/// The user-space box `width` x `height` points as displayed on a page turned by `rotation`, +/// with its displayed top-left corner at `at`: where typed text and text boxes go. `None` for a +/// non-finite point. +pub fn text_box_at(rotation: i64, at: [f64; 2], width: f64, height: f64) -> Option<[f64; 4]> { + signature_image::upright_box(rotation, displayed_offset(rotation, at, 0.0, -height / 2.0), width, height) +} + +/// `text` in the script font as the graphic of a visible digital signature, as Fill & Sign types +/// it. `None` for text with no outlines. +pub fn typed_signature_graphic(text: &str) -> Option { + let o = script_outline(text); + o.contours.iter().any(|c| c.len() > 2).then_some(pdfcraft_sign::Graphic::Outlines(o.contours)) +} diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs new file mode 100644 index 0000000000..fafa65b4c2 --- /dev/null +++ b/crates/engine/src/history.rs @@ -0,0 +1,233 @@ +//! Undo/redo: the editing state of a document, its snapshots and their labels. + +use std::sync::Arc; + +use crate::{AppearanceCache, Edit, EditError, Shape}; + +/// Undo/redo depth. Snapshots share unchanged data, so this is memory-cheap. +pub(crate) const MAX_UNDO: usize = 100; + +/// The passwords that go with one state of a document (protection can change them). +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct Keys { + /// What the renderer and inspector open the working file with (the user password). + pub(crate) render: Option, + /// What the editor re-opens a saved file with: the strongest password known (owner if any). + pub(crate) reopen: Option, +} + +/// What an edit can change, and so what the view data must be rebuilt from. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Scope { + /// Anything: re-inspect the whole document. + Full, + /// Document-information entries; page content and appearances are unchanged. + Metadata, + /// Only comments: re-read the comment list from the object graph. + Comments, + /// Only form field values (and their widget appearances). + Form, + /// Link annotations; the full inspection is refreshed, but page-content indexes stay valid. + Links, + /// Only page content streams, resources and added content/marks: the page tree, + /// annotations, form fields, links, outline and fonts are unchanged, so the full + /// re-inspection is skipped (seconds on very large files). + Content, +} + +/// Edits during which field scripts run. +pub(crate) fn uses_scripts(edit: &Edit) -> bool { + match edit { + Edit::SetFieldValue { .. } | Edit::ApplyScriptChanges { .. } | Edit::SetFieldScript { .. } | Edit::SetCalculationOrder { .. } => true, + Edit::Batch { edits, .. } => edits.iter().any(uses_scripts), + _ => false, + } +} + +pub(crate) fn scope_of(edit: &Edit) -> Scope { + match edit { + Edit::SetInfo { .. } => Scope::Metadata, + // The Attachments list is read from the whole document. + Edit::AddAttachment { .. } | Edit::DeleteAttachment { .. } => Scope::Full, + // A file attachment also changes the Attachments list. + Edit::AddAnnotation(a) if matches!(a.shape, Shape::Attachment { .. }) => Scope::Full, + Edit::AddMeasurement(_) + | Edit::AddAnnotation(_) + | Edit::AddCustomStamp { .. } + | Edit::DeleteAnnotation { .. } + | Edit::SetAnnotationContents { .. } + | Edit::ReplyToAnnotation { .. } + | Edit::SetAnnotationStatus { .. } + | Edit::MarkAnnotation { .. } + | Edit::LockAnnotation { .. } + | Edit::ReplaceText { .. } + | Edit::EraseInk { .. } + | Edit::MoveAnnotation { .. } + | Edit::ReshapeAnnotation { .. } + | Edit::ResizeAnnotation { .. } + | Edit::StyleAnnotation { .. } + | Edit::FillAnnotation { .. } + | Edit::FontSizeAnnotation { .. } + | Edit::SetAnnotationInfo { .. } => Scope::Comments, + Edit::AddLink { .. } | Edit::SetLink { .. } | Edit::DeleteLink { .. } | Edit::RemoveLinks { .. } | Edit::AddLinks { .. } => Scope::Links, + Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } => Scope::Form, + // Page content, resources and added content/marks only: the page tree, annotations, + // fields, links and document metadata are untouched (see `refresh_content`). + Edit::MoveObjects { .. } + | Edit::EditTextLine { .. } + | Edit::EditTextBlock { .. } + | Edit::EditPageImage { .. } + | Edit::AddText { .. } + | Edit::AddImage { .. } + | Edit::UpdateContent { .. } + | Edit::DeleteContent { .. } + | Edit::ReplaceImage { .. } + | Edit::PasteContent { .. } + | Edit::AddHeaderFooter { .. } + | Edit::AddWatermark { .. } + | Edit::AddBackground { .. } + | Edit::RemoveMarks { .. } => Scope::Content, + Edit::Batch { edits, .. } => { + let mut scopes = edits.iter().map(scope_of); + let first = scopes.next().unwrap_or(Scope::Full); + if scopes.all(|s| s == first) { first } else { Scope::Full } + } + _ => Scope::Full, + } +} + +/// One undo/redo step: its label, the document state, its passwords, and what it changed. +pub(crate) type Snapshot = (Label, pdfcraft_cos::Document, Keys, Scope); + +/// Editing state of a document (absent when the document cannot be edited yet, e.g. encrypted). +#[derive(Clone)] +pub(crate) struct Editor { + pub(crate) cos: pdfcraft_cos::Document, + /// The file as last opened or saved. Incremental saves append to it, and Revert opens it. + /// Undo may restore a snapshot whose own base is an earlier revision; this does not follow it. + pub(crate) saved: Arc>, + /// Passwords that open [`Self::saved`]. + pub(crate) 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. + pub(crate) unsaved_file: bool, + pub(crate) undo: Vec, + pub(crate) redo: Vec, + pub(crate) keys: Keys, + /// Display appearances built for annotations without any, reused while no annotation edit + /// runs (content edits leave every annotation dictionary alone). Cleared on any refresh + /// that may change annotations; see [`AppearanceCache`]. + pub(crate) 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`]. + pub(crate) field_appearances: pdfcraft_forms::FieldAppearanceCache, + /// The text blocks last computed for one page, reused while the document is unchanged. + pub(crate) text_blocks: TextBlocksCache, +} + +/// 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)] +pub(crate) struct TextBlocksCache(std::sync::Mutex>); + +/// The document state, the page (0-based) and its text blocks. +type CachedTextBlocks = (pdfcraft_cos::ReadState, usize, Arc>); + +impl Clone for TextBlocksCache { + fn clone(&self) -> Self { + // A poisoned cache only loses the cached blocks. + Self(std::sync::Mutex::new(self.0.lock().ok().and_then(|c| c.clone()))) + } +} + +impl Editor { + /// The text blocks on `page` (0-based), from the cache when the document hasn't changed. + pub(crate) fn text_blocks(&self, page: usize) -> Result>, EditError> { + 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 Ok(mut cache) = self.text_blocks.0.lock() { + *cache = Some((self.cos.read_state(), page, blocks.clone())); + } + Ok(blocks) + } +} + +/// What an edit is called in the Edit menu and in the history ("Undo Rotate pages"), in two parts: +/// a whole English wording, and the one value the document itself supplies for the wording's hole +/// (a file name, a field name). An interface in another language looks the wording up and says the +/// label with [`said`](Self::said); [`as_str`](Self::as_str) is the English, said the same way, so +/// the two cannot drift apart. +#[derive(Clone, Debug, PartialEq)] +pub struct Label { + /// The wording, with one named hole (`{name}`) where `value` goes. Listed in [`EDIT_LABELS`](crate::EDIT_LABELS). + pub wording: &'static str, + /// What the document supplies, when the wording has a hole for it. + pub value: Option, + text: String, +} + +impl Label { + /// A wording with nothing to fill in. + pub fn of(wording: &'static str) -> Self { + Self { wording, value: None, text: wording.to_string() } + } + + /// A wording whose hole the document fills in. + pub fn with(wording: &'static str, value: impl Into) -> Self { + let mut label = Self { wording, value: Some(value.into()), text: String::new() }; + label.text = label.said(wording); + label + } + + /// The label read with this wording: the interface's translation of [`wording`](Self::wording), + /// or the English wording itself. What the document supplies goes in as it stands and is not + /// read again, so a name with braces in it stays whole. + pub fn said(&self, wording: &str) -> String { + let Some(value) = &self.value else { return wording.to_owned() }; + // `{` and `}` are ASCII, so these are char boundaries. + match (wording.find('{'), wording.find('}')) { + (Some(open), Some(close)) if open < close => { + let mut said = String::with_capacity(wording.len() + value.len()); + said.push_str(&wording[..open]); + said.push_str(value); + said.push_str(&wording[close + 1..]); + said + } + // A translation that lost its hole is said as it stands rather than losing the value. + _ => wording.to_owned(), + } + } + + /// The label as English reads it. + pub fn as_str(&self) -> &str { + &self.text + } +} + +impl std::fmt::Display for Label { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.text) + } +} + +/// The passwords after `edit`, if it changes them. +pub(crate) fn keys_after(edit: &Edit) -> Option { + match edit { + Edit::Protect(p) => { + Some(Keys { render: p.open_password.clone(), reopen: p.permissions_password.clone().or_else(|| p.open_password.clone()) }) + } + Edit::RemoveProtection => Some(Keys::default()), + Edit::Batch { edits, .. } => edits.iter().rev().find_map(keys_after), + _ => None, + } +} diff --git a/crates/engine/src/layers.rs b/crates/engine/src/layers.rs new file mode 100644 index 0000000000..c2f4f63714 --- /dev/null +++ b/crates/engine/src/layers.rs @@ -0,0 +1,60 @@ +//! Layer (optional content) visibility: what the view shows and what printing writes. + +use std::sync::Arc; + +use pdfcraft_render::{Layer, LayerOp, RenderPool}; + +use crate::{Document, render_threads}; + +/// 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. +pub(crate) 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`. +pub(crate) fn use_layer_choices(doc: &mut Document) { + let overrides: Vec<(i32, i32, bool)> = doc.info.layers.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect(); + doc.config.layers = Arc::new(overrides); + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); +} + +/// Apply a set-layer-visibility action to `layers`, one change at a time, so a toggle flips the +/// state the changes before it left. Returns whether any layer ended up changed. +pub(crate) fn apply_layer_state(layers: &mut [Layer], groups: &[Vec<(u32, u16)>], changes: &[(LayerOp, (u32, u16))], preserve_rb: bool) -> bool { + let before: Vec = layers.iter().map(|l| l.visible).collect(); + let index: std::collections::HashMap<(u32, u16), usize> = layers.iter().enumerate().map(|(i, l)| (l.id, i)).collect(); + for &(op, ocg) in changes { + let Some(layer) = index.get(&ocg).and_then(|&i| layers.get_mut(i)) else { continue }; + let on = match op { + LayerOp::On => true, + LayerOp::Off => false, + LayerOp::Toggle => !layer.visible, + }; + layer.visible = on; + // Turning a layer off leaves the rest of its groups alone. + if on && preserve_rb { + for other in groups.iter().filter(|g| g.contains(&ocg)).flatten().filter(|&&o| o != ocg) { + if let Some(l) = index.get(other).and_then(|&i| layers.get_mut(i)) { + l.visible = false; + } + } + } + } + layers.iter().zip(before).any(|(l, was)| l.visible != was) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c764d6e311..75227e904c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -14,68 +14,91 @@ pub mod actions; pub mod autotag; +mod bookmarks; pub mod build_info; pub mod camera; pub mod camera_clean; pub mod camera_edges; pub mod catalog; pub mod commands; +mod comments; pub mod compare; mod copy_image; pub mod dates; +mod document; +mod edit; +mod error; pub mod export; pub mod field_table; +mod fill_sign; +mod history; pub mod js; +mod layers; pub mod links; pub mod ocr; +mod open; pub mod optimizer; +mod page_filter; +mod pictures; pub mod print_raster; pub mod printcheck; +mod protection; +mod reexports; mod scan_colours; mod scan_fields; +mod session; pub mod signature_image; pub mod signature_scan; +mod sources; mod stamp_preview; +#[cfg(test)] +mod test_hooks; pub mod text_fonts; mod text_preview; pub mod word_count; pub mod xfa; +mod xfa_form; +use std::sync::Arc; + +pub use links::{ + MAX_REMOTE_DOCUMENT_BYTES, RemoteDocumentPathError, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path, +}; 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, mime_for_name, split_ranges}; +pub use reexports::*; -/// 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::check_custom_key; -pub use pdfcraft_organize::view::{InitialView, Layout as InitialLayout, Magnification, Navigation}; - -pub use pdfcraft_cos::Algorithm; -pub use pdfcraft_create::{CONVERTIBLE, CreateError, ImageResolution, SourceKind, decode_text, source_kind}; -pub use pdfcraft_edit::added::{ - CHAR_SPACING_LIMIT as ADDED_CHAR_SPACING_LIMIT, paste_place as added_paste_place, paste_rect as added_paste_rect, text_rect as added_text_rect, - text_width as added_text_width, +use bookmarks::outline_rows; +pub use bookmarks::{BookmarkPage, BookmarkRow}; +use comments::comment_list; +pub use comments::{SummarySort, comment_kind, comment_summary, comment_type_name}; +#[cfg(test)] +use document::page_runs; +pub use document::{Document, EditNotice, TextNotice, display_bytes_for, render_password_for}; +use document::{display_bytes, info_fields, recovered_render_password, same_pages, shown_under_redactions, undrawn_notices}; +pub use edit::{BATCH_LABELS, EDIT_LABELS, Edit}; +pub use error::{EditError, SourceProblem}; +pub use fill_sign::{displayed_offset, text_box_at, typed_signature_graphic, typed_signature_shape}; +pub use history::Label; +use layers::{apply_layer_state, set_default_layer_state, use_layer_choices}; +pub use open::{OpenContext, PreparedDocument}; +pub use page_filter::{PageOrientation, PageParity, filter_pages}; +pub use pictures::MarkFile; +use pictures::{embed_image, fit_picture, mark_source}; +use protection::unrestricted; +pub use protection::{Changes, Printing, Protection, SecuritySummary}; +pub use session::Session; +pub use sources::{CombineSource, Combined, combine_source_check, source_page_count}; +use sources::{open_source, open_source_with}; +#[cfg(test)] +use test_hooks::{check_refresh_panic, panic_on_refresh, record_link_scan, record_marker_scan, reset_secondary_scan_counts, secondary_scan_counts}; +use xfa_form::{ + XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, decode_data_file, image_content_type, is_xfa_data_file, note_warnings, strip_xfa, sync_xfa_state, + undrawn_field_warnings, xfa_after_data_change, xfa_layout, xfa_redraw, xfa_sync_datasets, xfa_values_from_datasets, }; -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, 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::{Backdrop, BlockKey, BlockStyle, PageImage, RangeStyle, TextBlock, TextLine, block_overlapping, first_undrawable}; -pub use pdfcraft_measure as measure; -pub use pdfcraft_xfa::Report as XfaLayout; - -pub use pdfcraft_edit::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget}; /// A change to an existing page image. #[derive(Clone, Debug, PartialEq)] @@ -98,73 +121,6 @@ pub enum ImageEdit { }, Delete, } -pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, script_outline, undrawable_message}; - -/// Fill & Sign: `text` in the script font as a typed signature, its left edge at `at` (user -/// space, vertically centred) and `height` points tall. `None` for text with no outlines. -/// -/// Left, centred and upright are as displayed on a page turned by `rotation` (its `/Rotate`): -/// the box runs along the displayed axes, like an image signature's -/// ([`SignatureImage::rect`]), and the outlines are turned back so the name reads across. -pub fn typed_signature_shape(at: [f64; 2], text: &str, height: f64, rotation: i64) -> Option { - let o = script_outline(text); - let [left, bottom, right, top] = o.bounds(); - let span = (top - bottom).max(0.1); - let width = right - left; - if o.contours.is_empty() || o.width <= 0.0 || !(width > 0.0 && height > 0.0) { - return None; - } - let k = height / span; - let rect = signature_image::upright_box(rotation, at, width * k, height)?; - // Displayed right and up as user-space unit vectors: a point at (nx, ny) of the displayed box - // is at this fraction of the user-space box. Exact for an unturned page. - let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(rotation, [0.0; 4]); - let to_user = |nx: f64, ny: f64| [a * nx + c * ny + (-a).max(0.0) + (-c).max(0.0), b * nx + d * ny + (-b).max(0.0) + (-d).max(0.0)]; - let contours = o.contours.iter().map(|c| c.iter().map(|p| to_user((p[0] - left) / width, (p[1] - bottom) / span)).collect()).collect(); - Some(Shape::TypedSignature { rect, contours }) -} -/// `at` moved `right` and `up` points as displayed on a page turned by `rotation` (its `/Rotate`). -pub fn displayed_offset(rotation: i64, at: [f64; 2], right: f64, up: f64) -> [f64; 2] { - let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(rotation, [0.0; 4]); - [at[0] + a * right + c * up, at[1] + b * right + d * up] -} - -/// The user-space box `width` x `height` points as displayed on a page turned by `rotation`, -/// with its displayed top-left corner at `at`: where typed text and text boxes go. `None` for a -/// non-finite point. -pub fn text_box_at(rotation: i64, at: [f64; 2], width: f64, height: f64) -> Option<[f64; 4]> { - signature_image::upright_box(rotation, displayed_offset(rotation, at, 0.0, -height / 2.0), width, height) -} - -/// `text` in the script font as the graphic of a visible digital signature, as Fill & Sign types -/// it. `None` for text with no outlines. -pub fn typed_signature_graphic(text: &str) -> Option { - let o = script_outline(text); - o.contours.iter().any(|c| c.len() > 2).then_some(pdfcraft_sign::Graphic::Outlines(o.contours)) -} - -pub use links::{ - MAX_REMOTE_DOCUMENT_BYTES, RemoteDocumentPathError, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path, -}; -/// Comment geometry helpers (text-box line breaking) for frontends. -pub use pdfcraft_annot::appearance as annot_text; -pub use pdfcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle}; -pub use pdfcraft_annot::{ - AppearanceCache, AttachIcon, FillMark, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, Props as CommentProps, - ReviewState, Rgb, Shape, StampGroup, StampKind, Style, rect_quad, -}; -pub use pdfcraft_forms::detect; -pub use pdfcraft_optimize as optimize; -pub use pdfcraft_preflight as pdfa; -pub use pdfcraft_print as print; -pub use pdfcraft_redact::codes::{CODE_SETS as REDACTION_CODE_SETS, CodeSet as RedactionCodeSet}; -pub use pdfcraft_redact::patterns::{ - MAX_WORDS as REDACT_MAX_WORDS, PATTERNS as REDACT_PATTERNS, Pattern as RedactPattern, find as find_pattern, word_list as redact_word_list, -}; -pub use pdfcraft_redact::sanitize::{HIDDEN, Hidden}; -pub use pdfcraft_sign as sign; -pub use pdfcraft_sign::{SignOptions, SignatureInfo, Status as SignatureStatus, TrustStore}; -pub use pdfcraft_xfdf::Format as DataFormat; /// Forms ▸ Merge data files into spreadsheet: the field values of each file (FDF, XFDF or a /// filled-in PDF form), as CSV with one row per file. @@ -174,1110 +130,21 @@ pub fn merge_data_files(files: &[(String, Vec)]) -> Result { Ok(pdfcraft_xfdf::merge_csv(&rows)) } +/// One file produced by a split: (1-based first page, last page, PDF bytes). pub type SplitPart = (usize, usize, Arc>); -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)] -fn reset_secondary_scan_counts() { - SECONDARY_SCAN_COUNTS.with(|counts| counts.set((0, 0))); -} - -#[cfg(test)] -fn secondary_scan_counts() -> (usize, usize) { - SECONDARY_SCAN_COUNTS.with(std::cell::Cell::get) -} - -#[cfg(test)] -fn record_marker_scan() { - SECONDARY_SCAN_COUNTS.with(|counts| { - let (markers, links) = counts.get(); - counts.set((markers + 1, links)); - }); -} - -#[cfg(test)] -fn record_link_scan() { - SECONDARY_SCAN_COUNTS.with(|counts| { - let (markers, links) = counts.get(); - counts.set((markers, links + 1)); - }); -} - -use pdfcraft_cos::{SaveOptions, write_full, write_incremental}; -use pdfcraft_render::{DocInfo, Layer, LayerOp, OpenError, ReadingColors, RenderConfig, RenderPool, inspect}; - /// Stable identifier of an open document within a session. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct DocId(pub u64); -/// Undo/redo depth. Snapshots share unchanged data, so this is memory-cheap. -const MAX_UNDO: usize = 100; - /// The highest resolution a document that allows only low-resolution printing (Table 22, bit 12 /// clear) is printed at. const LOW_RESOLUTION_PRINT_DPI: f64 = 150.0; -/// The passwords that go with one state of a document (protection can change them). -#[derive(Clone, Debug, Default, PartialEq)] -struct Keys { - /// What the renderer and inspector open the working file with (the user password). - render: Option, - /// What the editor re-opens a saved file with: the strongest password known (owner if any). - reopen: Option, -} - -/// What an edit can change, and so what the view data must be rebuilt from. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Scope { - /// Anything: re-inspect the whole document. - Full, - /// Document-information entries; page content and appearances are unchanged. - Metadata, - /// Only comments: re-read the comment list from the object graph. - Comments, - /// Only form field values (and their widget appearances). - Form, - /// Link annotations; the full inspection is refreshed, but page-content indexes stay valid. - Links, - /// Only page content streams, resources and added content/marks: the page tree, - /// annotations, form fields, links, outline and fonts are unchanged, so the full - /// re-inspection is skipped (seconds on very large files). - Content, -} - -/// Edits during which field scripts run. -fn uses_scripts(edit: &Edit) -> bool { - match edit { - Edit::SetFieldValue { .. } | Edit::ApplyScriptChanges { .. } | Edit::SetFieldScript { .. } | Edit::SetCalculationOrder { .. } => true, - Edit::Batch { edits, .. } => edits.iter().any(uses_scripts), - _ => false, - } -} - -fn scope_of(edit: &Edit) -> Scope { - match edit { - Edit::SetInfo { .. } => Scope::Metadata, - // The Attachments list is read from the whole document. - Edit::AddAttachment { .. } | Edit::DeleteAttachment { .. } => Scope::Full, - // A file attachment also changes the Attachments list. - Edit::AddAnnotation(a) if matches!(a.shape, Shape::Attachment { .. }) => Scope::Full, - Edit::AddMeasurement(_) - | Edit::AddAnnotation(_) - | Edit::AddCustomStamp { .. } - | Edit::DeleteAnnotation { .. } - | Edit::SetAnnotationContents { .. } - | Edit::ReplyToAnnotation { .. } - | Edit::SetAnnotationStatus { .. } - | Edit::MarkAnnotation { .. } - | Edit::LockAnnotation { .. } - | Edit::ReplaceText { .. } - | Edit::EraseInk { .. } - | Edit::MoveAnnotation { .. } - | Edit::ReshapeAnnotation { .. } - | Edit::ResizeAnnotation { .. } - | Edit::StyleAnnotation { .. } - | Edit::FillAnnotation { .. } - | Edit::FontSizeAnnotation { .. } - | Edit::SetAnnotationInfo { .. } => Scope::Comments, - Edit::AddLink { .. } | Edit::SetLink { .. } | Edit::DeleteLink { .. } | Edit::RemoveLinks { .. } | Edit::AddLinks { .. } => Scope::Links, - Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } => Scope::Form, - // Page content, resources and added content/marks only: the page tree, annotations, - // fields, links and document metadata are untouched (see `refresh_content`). - Edit::MoveObjects { .. } - | Edit::EditTextLine { .. } - | Edit::EditTextBlock { .. } - | Edit::EditPageImage { .. } - | Edit::AddText { .. } - | Edit::AddImage { .. } - | Edit::UpdateContent { .. } - | Edit::DeleteContent { .. } - | Edit::ReplaceImage { .. } - | Edit::PasteContent { .. } - | Edit::AddHeaderFooter { .. } - | Edit::AddWatermark { .. } - | Edit::AddBackground { .. } - | Edit::RemoveMarks { .. } => Scope::Content, - Edit::Batch { edits, .. } => { - let mut scopes = edits.iter().map(scope_of); - let first = scopes.next().unwrap_or(Scope::Full); - if scopes.all(|s| s == first) { first } else { Scope::Full } - } - _ => Scope::Full, - } -} - -/// One undo/redo step: its label, the document state, its passwords, and what it changed. -type Snapshot = (Label, pdfcraft_cos::Document, Keys, Scope); - -/// Editing state of a document (absent when the document cannot be edited yet, e.g. encrypted). -#[derive(Clone)] -struct Editor { - cos: pdfcraft_cos::Document, - /// The file as last opened or saved. Incremental saves append to it, and Revert opens it. - /// Undo may restore a snapshot whose own base is an earlier revision; this does not follow it. - 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, - /// Display appearances built for annotations without any, reused while no annotation edit - /// 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 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::ReadState, usize, Arc>); - -impl Clone for TextBlocksCache { - fn clone(&self) -> Self { - // A poisoned cache only loses the cached blocks. - Self(std::sync::Mutex::new(self.0.lock().ok().and_then(|c| c.clone()))) - } -} - -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> { - 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 Ok(mut cache) = self.text_blocks.0.lock() { - *cache = Some((self.cos.read_state(), page, blocks.clone())); - } - Ok(blocks) - } -} - -pub struct Document { - pub id: DocId, - pub name: String, - pub path: Option, - /// The working file: what Save writes. - pub bytes: Arc>, - /// What is displayed (the page view, `page_render`, page images): `bytes`, plus appearances - /// drawn for display only for comments that have none ([`display_bytes`]). Never saved. - pub display: Arc>, - pub info: DocInfo, - pub renderer: RenderPool, - /// The password the document was opened with (needed to read attachments, etc.). - pub password: Option, - /// `true` when there are edits that have not been saved. - pub dirty: bool, - /// Bumped on every change to the working file (edit, undo, redo, save); autosave compares it. - generation: u64, - /// The generation last handed out by `autosave_snapshots`. - snapshot_generation: u64, - /// Why the document cannot be edited (e.g. encryption), if so. - pub read_only_reason: Option, - /// Interactive form fields of the current state (empty without a form). - pub form: Arc>, - marker_scan: OnceLock, - links: OnceLock>, - /// Signature fields, validated against the session's trust store (Signatures panel). - pub signatures: Arc>, - trust: Arc, - sig_cache: Arc, - editor: Option, - config: RenderConfig, - /// What field scripts printed or asked for (see [`Session::take_js_output`]). - js_output: js::JsOutput, - /// Dynamic XFA forms: what laying the template out produced (pages and fields are - /// PdfCraft's; Adobe's viewers draw the form from the XFA packets themselves). - pub xfa: Option, - /// The parsed template of a laid-out XFA form, for its scripts. - xfa_template: Option>, - /// A script of the XFA form ran away, so its scripts are off (see [`Document::xfa_scripts_off`]). - xfa_scripts_off: bool, - /// A content-only refresh keeps `info` from before the edit; an edit may have added or - /// embedded fonts, so the font list is read again on demand ([`Session::refresh_fonts`]). - fonts_stale: bool, - /// XFA forms: what was approximated, rewritten or could not be written to the XFA data - /// (also in `info.warnings`, kept there when the document is re-read). - pub xfa_warnings: Vec, - /// What the last successful [`Session::apply`] wants said (see [`EditNotice`]): text its fonts - /// can't draw, pages whose own backdrop stays off. The interface shows it; tools return it. - pub edit_notices: Vec, -} - -/// Something the last successful edit did that the user should hear about, never left silent -/// (AGENTS.md §4). -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum EditNotice { - /// Text stored as typed but drawn with `?`. - Undrawn(TextNotice), - /// Remove background couldn't turn back on the white backdrop these pages (0-based, - /// ascending) paint themselves: their content changed after the background replaced it, so - /// turning it on could paint over something (`pdfcraft_edit::backdrops_left_off`). They look - /// the same on white paper. - BackdropsLeftOff { pages: Vec }, -} - -impl std::fmt::Display for EditNotice { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - EditNotice::Undrawn(n) => n.fmt(f), - EditNotice::BackdropsLeftOff { pages } => { - let (which, own) = if pages.len() == 1 { ("page", "its") } else { ("pages", "their") }; - write!( - f, - "{which} {} changed after the background was added, so {own} own white backdrop was not turned back on (it looks the same on white paper)", - page_runs(pages) - ) - } - } - } -} - -/// 0-based pages as 1-based runs: `[0, 1, 2, 5]` is "1-3, 6". -fn page_runs(pages: &[usize]) -> String { - let mut runs: Vec<(usize, usize)> = Vec::new(); - for &p in pages { - match runs.last_mut() { - Some((_, end)) if end.checked_add(1) == Some(p) => *end = p, - _ => runs.push((p, p)), - } - } - let one = |p: usize| p.saturating_add(1); - runs.iter().map(|&(a, b)| if a == b { one(a).to_string() } else { format!("{}-{}", one(a), one(b)) }).collect::>().join(", ") -} - -/// Text an edit stored as typed but drew with `?` in place of `ch`, because the font it is drawn -/// with (a form field's, or the standard fonts for comments) can't draw that character. Acrobat -/// keeps such text too, drawing it with fonts PdfCraft can't embed yet. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct TextNotice { - /// The form field's full name, or `None` for a comment. - pub field: Option, - /// The page (0-based) of the comment or of the field's widget. - pub page: usize, - pub ch: char, - /// Other PDF viewers redraw it from the stored text (a form field, with `/NeedAppearances` - /// set); otherwise the `?` shows in every viewer. - pub redrawn_elsewhere: bool, -} - -impl std::fmt::Display for TextNotice { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let ch = format!("\"{}\" (U+{:04X})", self.ch.escape_debug(), u32::from(self.ch)); - let page = self.page + 1; - match (&self.field, self.redrawn_elsewhere) { - (Some(name), true) => write!( - f, - "form field {name:?} (page {page}) holds {ch}, which its font can't draw: the value is saved, PdfCraft shows ? in its place, and other PDF viewers redraw the field from the value" - ), - (Some(name), false) => write!( - f, - "form field {name:?} (page {page}) holds {ch}, which its font can't draw: the value is saved, but this PDF/A document can't ask other viewers to redraw it, so ? shows in its place everywhere" - ), - (None, _) => write!( - f, - "a comment on page {page} has {ch}, which the standard fonts can't draw: its text is saved, but its appearance shows ? in its place in every viewer" - ), - } - } -} - -/// What `next` drew with `?` that `prev` didn't (a new or redrawn appearance carrying -/// `pdfcraft_fonts::UNDRAWN_MARK`), one notice per field or comment, in page order. -fn undrawn_notices(prev: &pdfcraft_cos::Document, next: &pdfcraft_cos::Document) -> Vec { - let Ok(pages) = pdfcraft_annot::page_refs(next) else { return Vec::new() }; - let redrawn_elsewhere = pdfcraft_forms::need_appearances(next); - let mut names: Option> = None; - let mut out: Vec = Vec::new(); - for (page, p) in pages.iter().enumerate() { - let annots = next.get(*p).as_dict().and_then(|d| d.get(b"Annots").cloned()).map(|a| next.resolve(&a)); - for r in annots.as_deref().and_then(pdfcraft_cos::Object::as_array).into_iter().flatten().filter_map(pdfcraft_cos::Object::as_ref) { - let now = next.get(r); - let Some(d) = now.as_dict() else { continue }; - let Some(ch) = pdfcraft_fonts::undrawn_mark(d) else { continue }; - // Only what this edit drew: a new annotation, or a new appearance. - let was = prev.get(r); - if was.as_dict().is_some_and(|w| w.get(b"AP") == d.get(b"AP") && pdfcraft_fonts::undrawn_mark(w) == Some(ch)) { - continue; - } - let field = (d.name(b"Subtype") == Some(b"Widget")).then(|| { - let names = names.get_or_insert_with(|| { - pdfcraft_forms::fields(next) - .into_iter() - .flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.name.clone())).collect::>()) - .collect() - }); - names.get(&r).cloned().unwrap_or_default() - }); - if field.is_some() && out.iter().any(|n| n.field == field) { - continue; - } - out.push(TextNotice { redrawn_elsewhere: redrawn_elsewhere && field.is_some(), field, page, ch }); - } - } - out -} - -impl Document { - /// How the document opens (Document Properties ▸ Initial View). - /// Accessibility ▸ Check for accessibility: the full check (`None` if the document can't be - /// read for editing). - pub fn accessibility_check(&self, options: &a11y::Options) -> Option { - self.editor.as_ref().map(|e| a11y::check(&e.cos, options)) - } - - /// A script of this XFA form ran past its time limit and was abandoned, so the form's - /// scripts no longer run in this document (reopening it runs them again). The script's - /// error is in the JavaScript output; this is the state, for a viewer to show. - pub fn xfa_scripts_off(&self) -> bool { - self.xfa_scripts_off - } - - /// PDF Optimizer ▸ Audit space usage. - pub fn audit_space(&self) -> Vec { - self.editor.as_ref().map(|e| optimize::audit_space(&e.cos, self.bytes.len() as u64)).unwrap_or_default() - } - - /// The backdrop `page` (0-based) paints itself, which hides a background (Add Background). - pub fn page_backdrop(&self, page: usize) -> Option { - self.editor.as_ref().and_then(|e| pdfcraft_edit::page_backdrop(&e.cos, page).ok().flatten()) - } - - /// [`Self::page_backdrop`] for each of `pages`, in order, walking the page tree once: the - /// way to check many pages. A page out of range has none. - pub fn page_backdrops(&self, pages: &[usize]) -> Vec> { - match &self.editor { - Some(e) => pdfcraft_edit::page_backdrops(&e.cos, pages), - None => vec![None; pages.len()], - } - } - - /// Edit a PDF: the images `page` draws (0-based). - pub fn page_images(&self, page: usize) -> Vec { - self.editor.as_ref().and_then(|e| pdfcraft_edit::page_images(&e.cos, page).ok()).unwrap_or_default() - } - - /// Mixed editable objects on one page. Source inventories are zero-based and added items - /// appear once. Rectangles are user-space points, independent of the viewer's zoom. - pub fn editable_objects(&self, page: usize) -> Result, String> { - let editor = self.editor.as_ref().ok_or("the document can't be read")?; - pdfcraft_edit::editable_objects(&editor.cos, page).map_err(|e| e.to_string()) - } - - /// Displayed top-left-origin motion to a user-space vector. Use the linear map only, - /// so even a large crop-box origin cannot erase a small drag by cancellation. - pub fn object_move_offset(&self, page: usize, delta: [f64; 2]) -> Result<[f64; 2], String> { - let p = self.info.pages.get(page).ok_or("the page no longer exists")?; - let [x0, y0, x1, y1] = p.crop.map(f64::from); - let (a, b) = (delta[0] / f64::from(p.width.max(1e-3)), delta[1] / f64::from(p.height.max(1e-3))); - let (u, v) = match p.rotation { - 90 => (b, -a), - 180 => (-a, -b), - 270 => (-b, a), - _ => (a, b), - }; - let offset = [u * (x1 - x0), -v * (y1 - y0)]; - if !offset.iter().all(|v| v.is_finite()) { - return Err("the move must be finite and non-zero".into()); - } - Ok(offset) - } - - /// Save image as: image `index` on `page` as a file (extension, bytes). - pub fn page_image_file(&self, page: usize, index: usize) -> Result<(&'static str, Vec), String> { - self.check_copying().map_err(|e| e.to_string())?; - let editor = self.editor.as_ref().ok_or("the document can't be read")?; - let img = self.page_images(page).into_iter().nth(index).ok_or_else(|| format!("page {} has no image {}", page + 1, index + 1))?; - if img.is_form { - return Err("grouped Form artwork is not a bitmap; Save Image As is only available for raster images".into()); - } - pdfcraft_create::image_file(&editor.cos, img.object.ok_or("the image has no object")?) - } - - /// Edit a PDF ▸ Edit text: the paragraphs on `page` (0-based). - pub fn text_blocks(&self, page: usize) -> Vec { - self.editor.as_ref().and_then(|e| e.text_blocks(page).ok()).map(|b| b.as_ref().clone()).unwrap_or_default() - } - - /// Saved measurement annotations, calculated from their geometry and PDF scales, plus - /// the ones that couldn't be read (unsupported formats are skipped, not fatal). - pub fn measurements(&self) -> Result { - let e = self.editor.as_ref().ok_or("the document can't be read")?; - Ok(measure::list(&e.cos)) - } - pub fn measurement_scale(&self, page: usize, at: measure::Point) -> Result { - let e = self.editor.as_ref().ok_or("the document can't be read")?; - measure::scale_at(&e.cos, page, at).map_err(|e| e.to_string()) - } - /// [`Self::measurement_scale`], measuring true size in `unit` (`per_point` of them in a - /// point) where the page sets no scale. - pub fn measurement_scale_or(&self, page: usize, at: measure::Point, per_point: f64, unit: &str) -> Result { - let e = self.editor.as_ref().ok_or("the document can't be read")?; - measure::scale_at_or(&e.cos, page, at, per_point, unit).map_err(|e| e.to_string()) - } - pub fn measurement_to_user(&self, page: usize, point: measure::Point) -> Result { - let e = self.editor.as_ref().ok_or("the document can't be read")?; - measure::view_to_user(&e.cos, page, point).map_err(|e| e.to_string()) - } - pub fn measurement_to_view(&self, page: usize, point: measure::Point) -> Result { - let e = self.editor.as_ref().ok_or("the document can't be read")?; - measure::user_to_view(&e.cos, page, point).map_err(|e| e.to_string()) - } - pub fn measurement_paths(&self, page: usize) -> Result { - let e = self.editor.as_ref().ok_or("the document can't be read")?; - measure::snap::geometry(&e.cos, page).map_err(|e| e.to_string()) - } - - /// A counter that changes with every edit (for caches of derived data). - pub fn edit_generation(&self) -> u64 { - self.generation - } - - /// Edit a PDF ▸ Edit text: the lines of existing text on `page` (0-based). - pub fn text_lines(&self, page: usize) -> Vec { - 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() - } - - /// Reachable indirect structure tags, in document order. - pub fn tags(&self) -> Vec { - self.editor.as_ref().map(|e| a11y::tags(&e.cos)).unwrap_or_default() - } - - /// Marked-content items directly owned by one reachable indirect structure tag. - pub fn tag_content(&self, object: pdfcraft_cos::ObjRef) -> Result, a11y::TagError> { - self.editor.as_ref().map(|e| a11y::tag_content(&e.cos, object)).unwrap_or_else(|| Ok(Vec::new())) - } - - /// The edit that fixes `rule`, for the rules with an automatic fix: the document language - /// (`value` is the language), the title (`value` replaces it; else the current title or the - /// file name) and the tab order. - pub fn accessibility_fix(&self, rule: a11y::Rule, value: Option<&str>) -> Result { - let value = value.map(str::trim).filter(|v| !v.is_empty()); - match rule { - a11y::Rule::PrimaryLanguage => { - let mut v = self.initial_view(); - v.language = Some(value.ok_or("give the document language (for example en-US)")?.to_owned()); - Ok(Edit::SetInitialView(Box::new(v))) - } - a11y::Rule::Title => { - let title = value - .map(str::to_owned) - .or_else(|| self.info_value("Title").filter(|t| !t.trim().is_empty())) - .unwrap_or_else(|| self.name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_owned()); - let mut v = self.initial_view(); - v.display_title = true; - Ok(Edit::Batch { - label: "Set document title".into(), - edits: vec![Edit::SetInfo { key: "Title".into(), value: title }, Edit::SetInitialView(Box::new(v))], - }) - } - a11y::Rule::TabOrder => Ok(Edit::SetTabOrder { pages: (0..self.info.pages.len()).collect(), order: TabOrder::Structure }), - other => Err(format!("\"{}\" has no automatic fix", other.name())), - } - } - - pub fn initial_view(&self) -> InitialView { - self.editor.as_ref().map(|e| pdfcraft_organize::initial_view(&e.cos)).unwrap_or_default() - } - - /// Signed: at least one signature field holds a signature. - pub fn is_signed(&self) -> bool { - self.signatures.iter().any(|s| s.signed) - } - - /// Whether comments are hidden (Comments ▸ Hide all comments). - pub fn comments_hidden(&self) -> bool { - self.config.hide_comments - } - - pub fn can_undo(&self) -> Option<&str> { - self.undo_label().map(Label::as_str) - } - - pub fn can_redo(&self) -> Option<&str> { - self.redo_label().map(Label::as_str) - } - - /// What Undo would undo, in parts, for an interface that says it in another language. - pub fn undo_label(&self) -> Option<&Label> { - self.editor.as_ref().and_then(|e| e.undo.last()).map(|(l, ..)| l) - } - - /// What Redo would redo, in parts. - pub fn redo_label(&self) -> Option<&Label> { - self.editor.as_ref().and_then(|e| e.redo.last()).map(|(l, ..)| l) - } - - pub fn editable(&self) -> bool { - self.editor.is_some() - } - - /// What the opening password allows; `None` when the document is not encrypted. - pub fn permissions(&self) -> Option { - self.editor.as_ref().and_then(|e| e.cos.permissions()) - } - - /// Printing is allowed (Table 22, bit 3). - pub fn allows_printing(&self) -> bool { - self.permissions().is_none_or(|p| p.print()) - } - - /// Copying or otherwise extracting text and images is allowed (Table 22, bit 5): copying - /// selected text, snapshots, Save Image As, exporting. Assistive technology may read the - /// content regardless (§7.6.4.2), so reading aloud and accessibility tools don't ask. - pub fn allows_copying(&self) -> bool { - self.permissions().is_none_or(|p| p.copy()) - } - - /// [`Self::allows_copying`], as the error to report when it isn't. - pub fn check_copying(&self) -> Result<(), EditError> { - if self.allows_copying() { Ok(()) } else { Err(EditError::NotPermitted("copying text and images")) } - } - - /// Page changes (insert, delete, rotate, move, extract) are allowed. - pub fn allows_assembly(&self) -> bool { - self.editable() && self.permissions().is_none_or(|p| p.assemble()) - } - - /// Redaction marks waiting to be applied. - pub fn redaction_marks(&self) -> usize { - self.info.annotations.iter().filter(|a| a.subtype == "Redact").count() - } - - /// Page marks present (headers and footers, watermarks, backgrounds), for Update/Remove. - pub fn marks(&self) -> &[MarkKind] { - &self - .marker_scan - .get_or_init(|| { - self.editor - .as_ref() - .map(|e| { - #[cfg(test)] - record_marker_scan(); - pdfcraft_edit::scan_markers(&e.cos) - }) - .unwrap_or_default() - }) - .marks - } - - /// Text and images added with Edit a PDF ▸ Add content (still editable). - pub fn added(&self) -> &[pdfcraft_edit::Added] { - &self - .marker_scan - .get_or_init(|| { - self.editor - .as_ref() - .map(|e| { - #[cfg(test)] - record_marker_scan(); - pdfcraft_edit::scan_markers(&e.cos) - }) - .unwrap_or_default() - }) - .added - } - - /// Link annotations, for Edit a PDF ▸ Link. - pub fn links(&self) -> &[pdfcraft_annot::links::LinkItem] { - self.links.get_or_init(|| { - self.editor - .as_ref() - .map(|e| { - #[cfg(test)] - record_link_scan(); - pdfcraft_annot::links::list(&e.cos) - }) - .unwrap_or_default() - }) - } - - /// Changes to content and document information are allowed. - pub fn allows_modification(&self) -> bool { - self.editable() && self.permissions().is_none_or(|p| p.modify()) - } - - /// How widget `widget` of text field `name` lays its value out, for asking whether a typed - /// value fits it ([`form_appearance::TextBox::fits`]): a field that doesn't scroll takes no - /// more than fits. `None` without such a field or widget, or when the document can't be - /// edited. - pub fn field_text_box(&self, name: &str, widget: usize) -> Option { - let cos = &self.editor.as_ref()?.cos; - let f = self.form.iter().find(|f| f.name == name)?; - let w = f.widgets.get(widget)?; - Some(form_appearance::text_box(cos, f, w)) - } - - /// Filling in form fields is allowed (Table 22, bits 6 and 9). - pub fn allows_form_filling(&self) -> bool { - self.editable() && self.permissions().is_none_or(|p| p.fill_forms()) - } - - /// Adding and changing comments is allowed (Table 22, bit 6). - pub fn allows_annotation(&self) -> bool { - self.editable() && self.permissions().is_none_or(|p| p.annotate()) - } - - /// The document's security may be changed (Protect, Remove security): it is editable and, - /// if encrypted, was opened with the owner password. - pub fn allows_security_change(&self) -> bool { - self.editable() && self.permissions().is_none_or(|p| unrestricted(&p)) - } - - /// A password restricts the document and it wasn't opened with the owner password: entering - /// that password ([`Session::unlock`]) lifts the restrictions. - pub fn can_unlock(&self) -> bool { - self.editable() && self.permissions().is_some_and(|p| !unrestricted(&p)) - } - - /// A summary of the document's security for Document Properties ▸ Security, including - /// protection applied in this session (written by the next save). - pub fn security_summary(&self) -> Option { - let editor = self.editor.as_ref()?; - let h = editor.cos.output_handler()?; - let d = h.dict(); - let stream = d.crypt_filters.iter().find(|(name, _)| *name == d.stm_f).map(|(_, m)| *m); - let method = match (d.v, stream) { - (1..=3, _) if d.length_bits <= 40 => "RC4, 40-bit", - (1..=3, _) | (_, Some(pdfcraft_cos::CryptMethod::Rc4)) => "RC4, 128-bit", - (_, Some(pdfcraft_cos::CryptMethod::Aes128)) => "AES, 128-bit", - (_, Some(pdfcraft_cos::CryptMethod::Aes256)) => "AES, 256-bit", - _ => "Attachments only", - }; - let pending = editor.cos.encryption_changed(); - let permissions = if pending { pdfcraft_cos::Permissions { bits: h.permissions().bits, owner: false } } else { h.permissions() }; - Some(SecuritySummary { method: method.into(), owner: h.auth() == pdfcraft_cos::Auth::Owner && !pending, permissions, pending }) - } - - /// Comment properties of the comment at `(page, index)` (Comment Properties dialog). - pub fn comment_props(&self, page: usize, index: usize) -> Option { - self.editor.as_ref().and_then(|e| pdfcraft_annot::props(&e.cos, page, index)) - } - - /// A form field's Appearance-tab look (borders, colours, font). - pub fn field_look(&self, name: &str) -> Option { - let e = self.editor.as_ref()?; - let f = self.form.iter().find(|f| f.name == name)?; - Some(pdfcraft_forms::look(&e.cos, f)) - } - - /// A check box's or radio button's mark (Field Properties ▸ Options), `None` for other fields. - pub fn field_check_style(&self, name: &str) -> Option { - let e = self.editor.as_ref()?; - let f = self.form.iter().find(|f| f.name == name)?; - matches!(f.kind, FormFieldKind::CheckBox | FormFieldKind::Radio).then(|| pdfcraft_forms::check_style(&e.cos, f)) - } - - /// Whether text field `name`'s value is cut off by its box at the field's font size (#4018): - /// the page (0-based) of the first widget that cuts it, and how. - pub fn field_overflow(&self, name: &str) -> Option<(Option, pdfcraft_forms::appearance::Overflow)> { - let e = self.editor.as_ref()?; - let f = self.form.iter().find(|f| f.name == name)?; - f.widgets.iter().find_map(|w| pdfcraft_forms::appearance::value_overflow(&e.cos, f, w).map(|o| (w.page, o))) - } - - /// Remove Hidden Information: what each category would remove. - pub fn hidden_info(&self) -> Vec<(Hidden, usize)> { - self.editor.as_ref().map(|e| pdfcraft_redact::sanitize::scan(&e.cos)).unwrap_or_default() - } - - /// Verification: faux redactions (opaque covers over extractable text) and redaction - /// marks that were never applied. Fails closed: any read error is returned, never an - /// empty list. - pub fn audit_redactions(&self) -> Result, String> { - let editor = self.editor.as_ref().ok_or("the document can't be read")?; - pdfcraft_audit::audit_redactions(&editor.cos).map_err(|e| e.to_string()) - } - - /// Every page's media, crop, bleed, trim and art boxes (user space), for Set Page Boxes. - pub fn page_boxes(&self) -> Vec<[[f64; 4]; 5]> { - self.editor.as_ref().and_then(|e| pdfcraft_organize::page_boxes(&e.cos).ok()).unwrap_or_default() - } - - /// Current value of a document-information entry (Title, Author, …). - pub fn info_value(&self, key: &str) -> Option { - self.editor.as_ref().and_then(|e| pdfcraft_organize::info(&e.cos, key)) - } - - /// Whether the document information has an entry `key`, of any type, listed or not. - pub fn has_info(&self, key: &str) -> bool { - self.editor.as_ref().is_some_and(|e| pdfcraft_organize::has_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 { - self.editor - .as_ref() - .filter(|e| pdfcraft_organize::displays_doc_title(&e.cos)) - .and_then(|e| pdfcraft_organize::info(&e.cos, "Title")) - .map(|t| t.trim().to_owned()) - .filter(|t| !t.is_empty() && !is_placeholder_title(t)) - .unwrap_or_else(|| self.name.clone()) - } - - /// Where each saved revision of the file ends (oldest first; `bytes[..end]` is that - /// revision). Unsaved edits aren't a revision yet. - pub fn revision_ends(&self) -> Vec { - self.editor.as_ref().map(|e| e.cos.revision_ends()).unwrap_or_default() - } - - /// The file as last opened or saved, without unsaved edits: what an incremental save appends - /// to. This stays that file when undo has restored an older snapshot. - pub fn saved_bytes(&self) -> Arc> { - self.editor.as_ref().map_or_else(|| self.bytes.clone(), |e| e.saved.clone()) - } - - /// Notes about damage repaired while opening (Document Properties ▸ Advanced, notices). - pub fn repair_log(&self) -> Vec { - self.editor.as_ref().map(|e| e.cos.repair_log().to_vec()).unwrap_or_default() - } -} - -/// A title that names no document: what a web browser writes when it saves a blank page or a -/// pop-up as PDF (`about:blank`), another browser-internal address, or a bare "Untitled". Such a -/// title says less than the file name, so the file name is shown instead. An address is one -/// word: "About: our company" is a real title. -fn is_placeholder_title(title: &str) -> bool { - let t = title.trim().to_lowercase(); - let address = !t.contains(char::is_whitespace) && ["about:", "blob:", "data:"].iter().any(|p| t.starts_with(p)); - address || t == "untitled" -} - -/// 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) -} - -/// Each document-information field of `info` with its value in `cos` (`/Info`, else XMP), read -/// the way the full inspection reads them: NULs and spaces trimmed, empty is none. -fn info_fields<'a>(info: &'a mut DocInfo, cos: &pdfcraft_cos::Document) -> [(&'a mut Option, Option); 6] { - let read = |key| pdfcraft_organize::info(cos, key).map(|v| v.trim_matches('\0').trim().to_string()).filter(|v| !v.is_empty()); - [ - (&mut info.title, read("Title")), - (&mut info.author, read("Author")), - (&mut info.subject, read("Subject")), - (&mut info.keywords, read("Keywords")), - (&mut info.creator, read("Creator")), - (&mut info.producer, read("Producer")), - ] -} - -/// The copy PDFCraft displays when opening `bytes`: with appearances drawn, in memory only, for -/// 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| with_field_appearances(pdfcraft_annot::with_missing_appearances(&cos), &cos)) - .and_then(|display| write_incremental(&display, &SaveOptions::default()).ok()) - .map(Arc::new) - }) - .ok() - .flatten() - .unwrap_or(bytes) -} - -/// The password to give the renderer (`inspect`, `PageRenderer`) for `bytes` opened with -/// `password`. The renderer authenticates only the user password of R2–R4 files, so for their -/// owner password this is the user password that owner authentication recovers; otherwise it is -/// `password` itself. Render paths outside a [`Session`], such as the CLI, use this so the owner -/// password works there as it does in `doc_open`. A file that doesn't open with `password` keeps -/// it, so the renderer reports the same error it always did. -pub fn render_password_for(bytes: Arc>, 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 -/// its verify pass reuses them. Where they differ from the renderer's (a standard font without -/// /Widths, whose advances the interpreter approximates; a CMap -/// that it reads differently), a mark drawn over the text as displayed lies over glyphs the -/// interpreter believes are elsewhere: they stay in the file and nothing notices. The renderer is -/// what the user saw when placing the marks, so it is asked. `after` is written as a save would -/// write it, without the marks' own `overlays` (the box and its label are drawn on purpose), its -/// text is extracted, and a glyph still mostly inside a mark fails the edit: the caller keeps -/// the document as it was. -fn shown_under_redactions( - after: &pdfcraft_cos::Document, - marks: &[pdfcraft_redact::Mark], - overlays: &[pdfcraft_cos::ObjRef], - pages: &[pdfcraft_render::PageInfo], - password: Option<&str>, -) -> Result<(), EditError> { - use pdfcraft_cos::Object; - use pdfcraft_redact::RedactError; - let mut marked: Vec = marks.iter().map(|m| m.page).collect(); - marked.sort_unstable(); - marked.dedup(); - if marked.is_empty() { - return Ok(()); - } - let mut shown = after.clone(); - let model = pdfcraft_model::pages(&shown); - for &pi in &marked { - let Some(page) = model.get(pi) else { continue }; - let Some(Object::Array(list)) = page.dict.get(b"Contents").map(|c| (*shown.resolve(c)).clone()) else { continue }; - let kept: Vec = list.into_iter().filter(|o| !o.as_ref().is_some_and(|r| overlays.contains(&r))).collect(); - shown.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(kept))).map_err(|e| EditError::Write(e.to_string()))?; - } - let bytes = write_incremental(&shown, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?; - let config = RenderConfig { password: password.map(Arc::from), ..Default::default() }; - let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(bytes), config); - let mut residue = 0; - for &pi in &marked { - let request = pdfcraft_render::RenderRequest { page: pi, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }; - // A page that cannot be read back cannot be confirmed clean. - let (Some(info), Some(text)) = (pages.get(pi), renderer.render(request).text) else { return Err(RedactError::Unreadable(pi + 1).into()) }; - let rects: Vec<[f64; 4]> = marks - .iter() - .filter(|m| m.page == pi) - .flat_map(|m| m.rects.iter()) - .map(|r| [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]) - .collect(); - for glyph in text.glyphs.iter().filter(|g| !g.text.trim().is_empty()) { - let [x0, y0, x1, y1] = info.view_rect_to_user(glyph.rect).map(f64::from); - let area = (x1 - x0) * (y1 - y0); - if area.is_nan() || area <= 0.0 { - continue; - } - // Redaction itself takes any glyph a fifth under a mark; one that is half under it - // and still drawn was not where redaction thought it was. - let under = |r: &[f64; 4]| (x1.min(r[2]) - x0.max(r[0])).max(0.0) * (y1.min(r[3]) - y0.max(r[1])).max(0.0) >= 0.5 * area; - if rects.iter().any(under) { - residue += 1; - } - } - } - if residue > 0 { - return Err(RedactError::Residue(residue).into()); - } - Ok(()) -} - fn render_threads() -> usize { std::thread::available_parallelism().map(std::num::NonZeroUsize::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(); - doc.config.layers = Arc::new(overrides); - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); -} - -/// Apply a set-layer-visibility action to `layers`, one change at a time, so a toggle flips the -/// state the changes before it left. Returns whether any layer ended up changed. -fn apply_layer_state(layers: &mut [Layer], groups: &[Vec<(u32, u16)>], changes: &[(LayerOp, (u32, u16))], preserve_rb: bool) -> bool { - let before: Vec = layers.iter().map(|l| l.visible).collect(); - let index: std::collections::HashMap<(u32, u16), usize> = layers.iter().enumerate().map(|(i, l)| (l.id, i)).collect(); - for &(op, ocg) in changes { - let Some(layer) = index.get(&ocg).and_then(|&i| layers.get_mut(i)) else { continue }; - let on = match op { - LayerOp::On => true, - LayerOp::Off => false, - LayerOp::Toggle => !layer.visible, - }; - layer.visible = on; - // Turning a layer off leaves the rest of its groups alone. - if on && preserve_rb { - for other in groups.iter().filter(|g| g.contains(&ocg)).flatten().filter(|&&o| o != ocg) { - if let Some(l) = index.get(other).and_then(|&i| layers.get_mut(i)) { - l.visible = false; - } - } - } - } - layers.iter().zip(before).any(|(l, was)| l.visible != was) -} - -/// A file to combine: its name (the bookmark title), bytes, and page range (`None`: all). -pub type CombineSource = (String, Arc>, Option); - -/// What a combine produced: the new PDF bytes, and the fields renamed to keep colliding names -/// separate (old fully-qualified name → new one), in order across the sources. -#[derive(Debug)] -pub struct Combined { - pub bytes: Arc>, - pub renamed_fields: Vec<(String, String)>, -} - -/// A bookmark as the bookmark list shows it. -#[derive(Clone, Debug, PartialEq)] -pub struct BookmarkRow { - /// Child indices from the top level, 0-based, as the edit functions take them. - pub path: Vec, - pub title: String, - /// Shown expanded (a positive `/Count`). - pub open: bool, - /// The 0-based page it goes to. - pub page: Option, - /// Where on that page it goes ([`pdfcraft_render::DestView::Top`] when it doesn't say). - pub view: pdfcraft_render::DestView, - /// The title's colour (`/C`), `None` for the default black, and its style (`/F`). - pub color: Option<[f32; 3]>, - pub italic: bool, - pub bold: bool, -} - -/// One page of the bookmark list, in outline order. -#[derive(Clone, Debug, PartialEq)] -pub struct BookmarkPage { - pub rows: Vec, - /// Where the next page starts, or `None` at the end. - pub next: Option, - /// Set only when the document's structure could not be read for editing: its listing then - /// comes from the viewer's copy of the outline, which is shorter than the file's. - pub truncated: bool, -} - -/// The rows of a viewer outline, in outline order, with their paths. -fn outline_rows(items: &[pdfcraft_render::OutlineItem]) -> Vec { - let mut rows = Vec::new(); - // One entry per open level: its items, and how many of them have been listed. - let mut levels: Vec<(&[pdfcraft_render::OutlineItem], usize)> = vec![(items, 0)]; - while let Some(&(list, next)) = levels.last() { - let Some(item) = list.get(next) else { - levels.pop(); - continue; - }; - if let Some(top) = levels.last_mut() { - top.1 += 1; - } - let path: Vec = levels.iter().map(|(_, listed)| listed.saturating_sub(1)).collect(); - rows.push(BookmarkRow { - path, - title: item.title.clone(), - open: item.open, - page: item.page, - view: item.view, - color: item.color, - italic: item.italic, - bold: item.bold, - }); - if !item.children.is_empty() { - levels.push((&item.children, 0)); - } - } - rows -} - /// A document's working file captured for crash recovery. #[derive(Clone, Debug)] pub struct RecoverySnapshot { @@ -1289,1802 +156,6 @@ pub struct RecoverySnapshot { pub encrypted: bool, } -/// Document Properties ▸ Security. -#[derive(Clone, Debug, PartialEq)] -pub struct SecuritySummary { - /// "AES, 256-bit" etc. - pub method: String, - /// Opened with the owner password (no restrictions apply). - pub owner: bool, - /// For pending protection: the restrictions as they will apply to others. - pub permissions: pdfcraft_cos::Permissions, - /// Set in this session; written by the next save. - pub pending: bool, -} - -/// What printing a protected document allows (Acrobat: Printing allowed). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Printing { - None, - /// "Low Resolution (150 dpi)". - Low, - High, -} - -/// What changes a protected document allows (Acrobat: Changes allowed). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Changes { - None, - /// "Inserting, deleting, and rotating pages". - Pages, - /// "Filling in form fields and signing existing signature fields". - FillSign, - /// "Commenting, filling in form fields, and signing existing signature fields". - CommentFillSign, - /// "Any except extracting pages". - AnyExceptExtract, -} - -/// Password protection to apply (Protect Using Password and its Advanced options). -#[derive(Clone, PartialEq)] -pub struct Protection { - /// Required to open the document (the user password). - pub open_password: Option, - /// Required to change security and lift the restrictions below (the owner password). - pub permissions_password: Option, - pub printing: Printing, - pub changes: Changes, - /// "Enable copying of text, images, and other content". - pub copy: bool, - /// "Enable text access for screen reader devices". - pub accessibility: bool, - /// Compatibility level; AES-256 (Acrobat X and later) by default. - pub algorithm: pdfcraft_cos::Algorithm, - /// `false`: "Encrypt all document contents except metadata". - pub encrypt_metadata: bool, -} - -impl std::fmt::Debug for Protection { - // Never print passwords (edits end up in logs and error messages). - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Protection") - .field("open_password", &self.open_password.as_ref().map(|_| "…")) - .field("permissions_password", &self.permissions_password.as_ref().map(|_| "…")) - .field("printing", &self.printing) - .field("changes", &self.changes) - .field("copy", &self.copy) - .field("accessibility", &self.accessibility) - .field("algorithm", &self.algorithm) - .field("encrypt_metadata", &self.encrypt_metadata) - .finish() - } -} - -impl Default for Protection { - /// Acrobat's defaults: AES-256, printing and accessibility allowed, no changes, no copying. - fn default() -> Self { - Self { - open_password: None, - permissions_password: None, - printing: Printing::High, - changes: Changes::None, - copy: false, - accessibility: true, - algorithm: pdfcraft_cos::Algorithm::Aes256, - encrypt_metadata: true, - } - } -} - -impl Protection { - /// The `/P` permission bits (ISO 32000-2 Table 22), as Acrobat maps its choices. - pub fn permission_bits(&self) -> i32 { - let bit = |n: u32| 1i32 << (n - 1); - if self.permissions_password.is_none() { - return -1; // nothing restricted without a permissions password - } - let mut p = 0; - match self.printing { - Printing::None => {} - Printing::Low => p |= bit(3), - Printing::High => p |= bit(3) | bit(12), - } - p |= match self.changes { - Changes::None => 0, - Changes::Pages => bit(11), - Changes::FillSign => bit(9), - Changes::CommentFillSign => bit(6) | bit(9), - Changes::AnyExceptExtract => bit(4) | bit(6) | bit(9), - }; - if self.copy { - p |= bit(5) | bit(10); - } - if self.accessibility { - p |= bit(10); - } - p - } - - fn validate(&self) -> Result<(), EditError> { - let bad = |m: &str| Err(EditError::Protection(m.to_string())); - match (&self.open_password, &self.permissions_password) { - (None, None) => return bad("enter a password to open the document, a permissions password, or both"), - (Some(a), Some(b)) if a == b => return bad("the open password and the permissions password must be different"), - _ => {} - } - if [&self.open_password, &self.permissions_password].into_iter().flatten().any(String::is_empty) { - return bad("passwords can't be empty"); - } - if self.algorithm != pdfcraft_cos::Algorithm::Aes256 - && [&self.open_password, &self.permissions_password].into_iter().flatten().any(|p| p.chars().any(|c| !(' '..='~').contains(&c))) - { - return bad("this compatibility level supports only plain ASCII passwords; use AES-256 (Acrobat X and later)"); - } - // Encryption keys on a password as `effective_password` gives it (AES-256: SASLprep, - // which drops soft hyphens and folds compatibility forms; older levels: the first 32 - // bytes, padded). One that comes out as the empty password opens the file without asking, - // and an open password that comes out as the permissions password lifts the restrictions. - let effective = |p: &str| pdfcraft_cos::effective_password(self.algorithm, p); - let (open, permissions) = (self.open_password.as_deref().map(effective), self.permissions_password.as_deref().map(effective)); - let empty = effective(""); - if open.iter().chain(&permissions).any(|p| *p == empty) { - return bad("passwords can't be empty or made only of characters that passwords ignore, such as soft hyphens"); - } - if open.is_some() && open == permissions { - return bad("the open password and the permissions password must be different (PDF readers treat these two as the same password)"); - } - Ok(()) - } -} - -/// Edits that can be applied to a document. Page indices are 0-based. -#[derive(Clone, Debug, PartialEq)] -pub enum Edit { - RotatePages { - pages: Vec, - degrees: i64, - }, - DeletePages { - pages: Vec, - }, - MovePages { - 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 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. - ReplacePages { - pages: Vec, - name: String, - bytes: Arc>, - src_pages: Vec, - }, - /// Set Page Boxes / Crop: set (or reset) one box on `pages`. - SetPageBox { - pages: Vec, - which: PageBox, - spec: BoxSpec, - }, - /// Set or remove the page transition and display time of pages (ISO 32000-2 §12.4.4), - /// Organize ▸ Page Transitions. - SetTransition { - pages: Vec, - spec: TransitionSpec, - }, - SetInfo { - key: String, - value: String, - }, - /// Insert pages from another PDF (all pages when `pages` is `None`) at position `at`. - InsertPagesFrom { - name: String, - bytes: Arc>, - pages: Option>, - at: usize, - /// How a form field whose name the document already uses is kept separate. - 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, - title: String, - }, - /// Delete a bookmark and the bookmarks under it. - DeleteBookmark { - path: Vec, - }, - /// Move a bookmark to child `index` of `to_parent` (indices after removing it). - MoveBookmark { - from: Vec, - to_parent: Vec, - index: usize, - }, - /// Point a bookmark at another page. - SetBookmarkPage { - path: Vec, - page: usize, - }, - /// Colour a bookmark's title (`/C`); `None` or black is the default black. - SetBookmarkColor { - path: Vec, - color: Option<[f64; 3]>, - }, - /// Make a bookmark's title bold or italic or not (`/F`); `None` keeps that one. - SetBookmarkStyle { - path: Vec, - bold: Option, - italic: Option, - }, - /// Bookmarks for the tagged headings (H, H1–H6), nested by level under a new first - /// "Untitled" bookmark (New Bookmarks from Structure). - BookmarksFromStructure, - /// Label pages `from..=to` (0-based) as Acrobat's "Number pages" does; later pages keep their labels. - NumberPages { - from: usize, - to: usize, - style: pdfcraft_organize::LabelStyle, - prefix: String, - first: u32, - }, - /// Add a calibrated distance, perimeter or area annotation. - AddMeasurement(measure::NewMeasurement), - /// Store a drawing scale for a rectangular viewport (PDF user space). - SetMeasurementScale { - page: usize, - bbox: [f64; 4], - name: String, - scale: measure::Scale, - }, - /// Add a comment (sticky note, highlight, shape, drawing, text box…). - AddAnnotation(NewAnnotation), - /// A custom stamp from a picture file (a PDF page or an image) on `page`. A zero-size - /// `rect` is a point: the stamp is centred there at its natural size (at most 200 pt). - /// Any other `rect` is a box: the picture is scaled to fit inside it, keeping its shape, - /// and centred in it (#4077). - AddCustomStamp { - page: usize, - rect: [f64; 4], - name: String, - file: MarkFile, - author: String, - }, - /// Delete the comment at `index` in the page's `/Annots`, with its pop-up and replies. - DeleteAnnotation { - page: usize, - index: usize, - }, - SetAnnotationContents { - page: usize, - index: usize, - text: String, - }, - ReplyToAnnotation { - page: usize, - index: usize, - text: String, - author: String, - }, - /// Acrobat's "Set status" (a state reply by `author`). - SetAnnotationStatus { - page: usize, - index: usize, - state: ReviewState, - author: String, - }, - /// The Eraser: rub out the parts of drawing `(page, index)` near `path` (user space). - EraseInk { - page: usize, - index: usize, - path: Vec<[f64; 2]>, - radius: f64, - }, - /// Replace Text: strike out `quads` and add a grouped caret holding `text`. - ReplaceText { - page: usize, - quads: Vec<[f64; 8]>, - text: String, - author: String, - strike: Style, - caret: Style, - }, - /// Acrobat's "Mark with checkmark" (a hidden `/StateModel /Marked` reply by `author`). - MarkAnnotation { - page: usize, - index: usize, - marked: bool, - author: String, - }, - /// Properties ▸ Locked. - LockAnnotation { - page: usize, - index: usize, - locked: bool, - }, - MoveAnnotation { - page: usize, - index: usize, - 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, - index: usize, - rect: [f64; 4], - }, - StyleAnnotation { - page: usize, - index: usize, - color: Option, - opacity: Option, - width: Option, - /// Line or polyline: two endings. Callout: one. `None` leaves `/LE` unchanged. - endings: Option>, - }, - /// Fill a rectangle, oval or polygon comment, or remove its fill (`None`). - FillAnnotation { - page: usize, - index: usize, - fill: Option, - }, - /// Change the font size (pt) of a text box, typewriter text or callout. - FontSizeAnnotation { - page: usize, - index: usize, - size: f64, - }, - /// Comment properties ▸ General / note icon. - SetAnnotationInfo { - page: usize, - index: usize, - author: Option, - subject: Option, - icon: Option, - }, - /// Fill in a form field. - SetFieldValue { - name: String, - value: FieldValue, - }, - /// Clear form: the named fields (all when `None`) back to their defaults. - ResetForm { - names: Option>, - }, - /// Prepare a form: add a field on a page (`rect` in PDF user space); `name` defaults to - /// Acrobat's next free name ("Text1", "Check Box2", …). - AddField { - page: usize, - rect: [f64; 4], - kind: NewField, - name: Option, - }, - /// Field Properties ▸ General / Options. - SetFieldProps { - name: String, - props: Box, - }, - /// An image field (or any button): show this image file (PNG, JPEG, TIFF, GIF, BMP). - SetFieldImage { - name: String, - image: Arc>, - }, - /// Duplicate a field onto other pages (same field, shared value). - DuplicateField { - name: String, - pages: Vec, - }, - DeleteField { - name: String, - }, - /// Page properties ▸ Tab order, for pages (0-based). - SetTabOrder { - pages: Vec, - order: TabOrder, - }, - /// Document Properties ▸ Initial View (and the language and binding). - SetInitialView(Box), - /// Edit ▸ Attach a file: a document-level attachment (`/Names /EmbeddedFiles`) under - /// `name`, made unique; `mime` is the embedded file's `/Subtype` when known. - AddAttachment { - name: String, - bytes: Vec, - description: Option, - mime: Option, - }, - /// Remove the document-level attachment whose name-tree key is `key`. - DeleteAttachment { - key: String, - }, - /// Build a tagged structure tree for an untagged, text-only document. - AutoTagText, - /// Add alternate text: set (or clear, with `None`) a figure's alternate text. `figure` is - /// the figure element's object number (from `Document::figures`). - SetAltText { - figure: u32, - alt: Option, - }, - /// Add `/Form` tags and object references for all widgets in an untagged AcroForm. - AutoTagFormFields, - /// Change one property on a reachable indirect structure tag. - SetTagProperty { - object: pdfcraft_cos::ObjRef, - property: a11y::TagProperty, - value: Option, - }, - /// Move a structure tag before or after a direct sibling in reading order. - MoveTag { - object: pdfcraft_cos::ObjRef, - anchor: pdfcraft_cos::ObjRef, - placement: a11y::TagPlacement, - }, - /// Add alternate text ▸ Decorative figure: the figure's content becomes an artifact. - MarkDecorative { - figure: u32, - }, - /// Tags panel ▸ Mark as artifact: retire a leaf tag and mark its content as an artifact. - MarkTagArtifact { - object: pdfcraft_cos::ObjRef, - }, - /// Document JavaScripts: add, replace (`script`) or remove (`None`) the document-level - /// script `name`. - SetDocumentScript { - name: String, - script: Option, - }, - /// Field Properties ▸ a custom script: set or remove field `name`'s JavaScript for `event` - /// (keystroke, format, validate, calculate or mouse_up). - SetFieldScript { - name: String, - event: String, - script: Option, - }, - /// Standards ▸ Save as PDF/A: fix what can be fixed for `level` (metadata, output intent, - /// forbidden actions, annotation flags, …). - 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 { - changes: Vec, - }, - /// An XFA form's scripted event (a button's `click`, say) on the object at `som`: what the - /// script changes (values, rows, visibility) is applied and the form laid out again. - XfaEvent { - som: String, - activity: String, - }, - /// Scan & OCR ▸ Recognize text: put recognised words on `page` as invisible text over the - /// image (from [`ocr::OcrJob::run`]). - AddOcrText { - page: usize, - words: Vec, - }, - /// Edit a PDF ▸ Edit text: replace the text of line `line` (from `Document::text_lines`) on - /// `page`, in its own font when it can show it, else in Helvetica. - EditTextLine { - page: usize, - line: usize, - text: String, - }, - /// Edit a PDF ▸ an existing image on `page` (index from `Document::page_images`). - EditPageImage { - page: usize, - index: usize, - change: ImageEdit, - }, - /// Move mixed existing and added objects together. References resolve before any change; - /// offset is a user-space vector. No retyping, reflow or font substitution is involved. - MoveObjects { - page: usize, - objects: Vec, - offset: [f64; 2], - }, - /// Edit text in a paragraph box: replace paragraph `block` (from `Document::text_blocks`), - /// rewrapped to the box's width. - EditTextBlock { - page: usize, - block: usize, - text: String, - /// Formatting changes (font, size, colour, alignment); default keeps the paragraph's. - style: pdfcraft_edit::BlockStyle, - }, - /// An explicit open-licensed full font program, completing subsets or selected by a user. - RegisterTextFont { - page: usize, - bytes: Arc>, - }, - /// Order tabs manually: move a field one place earlier or later on its page. - MoveInTabOrder { - 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, - settings: HeaderFooter, - replace: bool, - }, - /// `file`: a picture from a file (an image, or a page of a PDF) instead of text. - AddWatermark { - pages: Vec, - settings: Watermark, - replace: bool, - file: Option, - }, - /// `file`: a picture from a file instead of a colour. - AddBackground { - pages: Vec, - settings: Background, - replace: bool, - file: Option, - }, - /// Remove every mark of a kind from every page. - RemoveMarks { - kind: MarkKind, - }, - /// Edit a PDF ▸ Add content ▸ Text (`text.rect` in display space). - AddText { - page: usize, - text: AddedText, - }, - /// Edit a PDF ▸ Add content ▸ Image: an image file placed at `rect` (display space), or at - /// its natural size centred on the page (shrunk to fit). - AddImage { - page: usize, - rect: Option<[f64; 4]>, - name: String, - bytes: Arc>, - }, - /// Move, resize, retype or reformat an added item (`index` among the page's added items). - UpdateContent { - page: usize, - index: usize, - content: AddedContent, - }, - DeleteContent { - page: usize, - index: usize, - }, - /// Replace an added image with another file (keeping its box and transforms). - ReplaceImage { - page: usize, - index: usize, - name: String, - bytes: Arc>, - }, - /// Edit ▸ Paste: an added item copied in this document or another, as it was: text with its - /// style, an image in its box (display space) with its turns, flips and crop. An image's - /// picture is `file` (from [`Document::added_image_file`]): the copied image object is reused - /// while it is still that picture in this document, else one an added image here already draws - /// it from, else the file is embedded. - PasteContent { - page: usize, - content: AddedContent, - file: Option<(String, Arc>)>, - }, - /// Apply redaction marks (all, or those on `pages`): remove what they cover for good. - ApplyRedactions { - pages: Option>, - }, - /// Remove redaction marks without applying them. - ClearRedactions, - /// Edit a PDF ▸ Link: a new link over `rect` (user space). - AddLink { - page: usize, - rect: [f64; 4], - action: LinkAction, - style: LinkStyle, - }, - /// Link Properties (`index` in the page's `/Annots`). - SetLink { - page: usize, - index: usize, - rect: Option<[f64; 4]>, - action: Option, - style: Option, - }, - DeleteLink { - page: usize, - index: usize, - }, - /// Remove all links (on `pages`, or everywhere). - RemoveLinks { - pages: Option>, - }, - /// Create links from URLs in the text: (page, line rects in user space, URI). - AddLinks { - links: Vec<(usize, Vec<[f64; 4]>, String)>, - style: LinkStyle, - }, - /// Import comments and/or form data (XFDF, FDF, XML, CSV, tab-delimited text). - ImportData { - name: String, - bytes: Arc>, - }, - /// Remove Hidden Information: the chosen categories. - RemoveHidden { - which: Vec, - }, - /// Sanitize Document: every category, then a full rewrite on save. - Sanitize, - /// An XFA form made an ordinary (static) PDF form: the XFA packets, `/NeedsRendering` and - /// PdfCraft's private keys go, the pages as laid out and the fields with their values - /// stay, so every viewer shows the same form and none lays it out again. Its scripts no - /// longer run. - XfaToStatic, - /// Flatten comments and/or form fields on every page into page content. - Flatten { - comments: bool, - fields: bool, - }, - /// Bake Fill & Sign text, marks and signatures into the page. Other comments and fields stay. - /// A no-op (no undo step) when the document has none. - FlattenFillSign, - /// Protect with passwords and permissions (written by the next save, which is a full rewrite). - Protect(Protection), - /// Remove password security (needs the owner password). - RemoveProtection, - /// Several edits applied as one undoable step (all or nothing). - Batch { - label: String, - edits: Vec, - }, -} - -/// What an edit is called in the Edit menu and in the history ("Undo Rotate pages"), in two parts: -/// a whole English wording, and the one value the document itself supplies for the wording's hole -/// (a file name, a field name). An interface in another language looks the wording up and says the -/// label with [`said`](Self::said); [`as_str`](Self::as_str) is the English, said the same way, so -/// the two cannot drift apart. -#[derive(Clone, Debug, PartialEq)] -pub struct Label { - /// The wording, with one named hole (`{name}`) where `value` goes. Listed in [`EDIT_LABELS`]. - pub wording: &'static str, - /// What the document supplies, when the wording has a hole for it. - pub value: Option, - text: String, -} - -impl Label { - /// A wording with nothing to fill in. - pub fn of(wording: &'static str) -> Self { - Self { wording, value: None, text: wording.to_string() } - } - - /// A wording whose hole the document fills in. - pub fn with(wording: &'static str, value: impl Into) -> Self { - let mut label = Self { wording, value: Some(value.into()), text: String::new() }; - label.text = label.said(wording); - label - } - - /// The label read with this wording: the interface's translation of [`wording`](Self::wording), - /// or the English wording itself. What the document supplies goes in as it stands and is not - /// read again, so a name with braces in it stays whole. - pub fn said(&self, wording: &str) -> String { - let Some(value) = &self.value else { return wording.to_owned() }; - // `{` and `}` are ASCII, so these are char boundaries. - match (wording.find('{'), wording.find('}')) { - (Some(open), Some(close)) if open < close => { - let mut said = String::with_capacity(wording.len() + value.len()); - said.push_str(&wording[..open]); - said.push_str(value); - said.push_str(&wording[close + 1..]); - said - } - // A translation that lost its hole is said as it stands rather than losing the value. - _ => wording.to_owned(), - } - } - - /// The label as English reads it. - pub fn as_str(&self) -> &str { - &self.text - } -} - -impl std::fmt::Display for Label { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.text) - } -} - -/// Every wording [`Edit::label`] can produce, for an interface to translate. `Edit::Batch` is not -/// here: its wording is only the hole, because a batch carries its name as text (see -/// [`BATCH_LABELS`]). -pub const EDIT_LABELS: &[&str] = &[ - "Rotate page", - "Change tag properties", - "Rotate pages", - "Delete page", - "Delete pages", - "Move page", - "Move pages", - "Reverse pages", - "Insert blank page", - "Duplicate page", - "Duplicate pages", - "Replace page", - "Replace pages", - "Crop page", - "Crop pages", - "Set page boxes", - "Set page transitions", - "Change Title", - "Change Author", - "Change Subject", - "Change Keywords", - "Change {key}", - "Insert pages from {name}", - "Insert pages from {name} — 1 field renamed", - "Insert pages from {name} — fields renamed", - "Attach {name}", - "Delete attachment {key}", - "Add bookmark", - "Rename bookmark", - "Delete bookmark", - "Move bookmark", - "Set bookmark destination", - "Colour", - "Style", - "New bookmarks from structure", - "Number pages", - "Measure distance", - "Measure perimeter", - "Measure area", - "Set measurement scale", - "Add sticky note", - "Add highlight", - "Add underline", - "Add strikethrough", - "Add squiggly underline", - "Add rectangle", - "Add oval", - "Add arrow", - "Add line", - "Add drawing", - "Add text box", - "Add checkmark", - "Add cross", - "Add dot", - "Add signature", - "Add redaction mark", - "Add cloud", - "Add polygon", - "Add connected lines", - "Add callout", - "Add inserted text", - "Add file attachment", - "Add stamp", - "Delete comment", - "Edit comment", - "Reply", - "Set status None", - "Set status Accepted", - "Set status Rejected", - "Set status Cancelled", - "Set status Completed", - "Replace text", - "Erase", - "Mark with checkmark", - "Remove checkmark", - "Lock comment", - "Unlock comment", - "Move comment", - "Reshape comment", - "Resize comment", - "Change comment properties", - "Fill in {name}", - "Set the image of {name}", - "Clear form", - "Add field", - "Change field properties", - "Delete field", - "Duplicate field", - "Set tab order", - "Set calculation order", - "Change initial view", - "Set alternate text", - "Mark figure as decorative", - "Recognize text", - "Run JavaScript", - "Run form script", - "Save as {level}", - "Remove {standard} identification", - "Edit script of {name}", - "Delete document JavaScript", - "Edit document JavaScript", - "Edit text", - "Load document font", - "Move object", - "Move objects", - "Move image", - "Rotate image", - "Flip image", - "Replace image", - "Delete image", - "Add header & footer", - "Update header & footer", - "Add watermark", - "Update watermark", - "Add background", - "Update background", - "Remove header & footer", - "Remove watermark", - "Remove background", - "Add text", - "Add image", - "Edit content", - "Delete content", - "Paste", - "Apply redactions", - "Remove redaction marks", - "Remove hidden information", - "Import {name}", - "Add link", - "Change link properties", - "Delete link", - "Remove all links", - "Create link", - "Create links", - "Sanitize document", - "Flatten comments", - "Flatten form fields", - "Flatten", - "Flatten Fill & Sign", - "Protect with password", - "Remove security", -]; - -/// 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", field_table::LABEL, "Style"]; - -impl Edit { - /// Label for the Edit menu and history ("Undo Rotate pages"). - pub fn label(&self) -> Label { - match self { - 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::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"), - Edit::SetTransition { .. } => Label::of("Set page transitions"), - // The four `/Info` keys PdfCraft itself offers get a wording of their own, so each can - // be said in another language; any other key is the document's and goes in as it stands. - Edit::SetInfo { key, .. } => match key.as_str() { - "Title" => Label::of("Change Title"), - "Author" => Label::of("Change Author"), - "Subject" => Label::of("Change Subject"), - "Keywords" => Label::of("Change Keywords"), - _ => Label::with("Change {key}", key.as_str()), - }, - Edit::InsertPagesFrom { name, .. } => Label::with("Insert pages from {name}", name.as_str()), - Edit::AddBookmark { .. } => Label::of("Add bookmark"), - Edit::RenameBookmark { .. } => Label::of("Rename bookmark"), - Edit::DeleteBookmark { .. } => Label::of("Delete bookmark"), - Edit::MoveBookmark { .. } => Label::of("Move bookmark"), - Edit::SetBookmarkPage { .. } => Label::of("Set bookmark destination"), - // The bookmark Style and Colour menus' own names, already said in every language. - Edit::SetBookmarkColor { .. } => Label::of("Colour"), - Edit::SetBookmarkStyle { .. } => Label::of("Style"), - Edit::BookmarksFromStructure => Label::of("New bookmarks from structure"), - Edit::NumberPages { .. } => Label::of("Number pages"), - // What was measured is part of the wording too, for the same reason. - Edit::AddMeasurement(m) => Label::of(match m.kind { - pdfcraft_measure::Kind::Distance => "Measure distance", - pdfcraft_measure::Kind::Perimeter => "Measure perimeter", - pdfcraft_measure::Kind::Area => "Measure area", - }), - Edit::SetMeasurementScale { .. } => Label::of("Set measurement scale"), - Edit::AddAnnotation(a) => Label::of(add_annotation(&a.shape)), - Edit::AddCustomStamp { .. } => Label::of("Add stamp"), - Edit::DeleteAnnotation { .. } => Label::of("Delete comment"), - Edit::SetAnnotationContents { .. } => Label::of("Edit comment"), - Edit::ReplyToAnnotation { .. } => Label::of("Reply"), - // The state's name is part of the wording: a language may need to agree it with the rest. - Edit::SetAnnotationStatus { state, .. } => Label::of(match state { - ReviewState::None => "Set status None", - ReviewState::Accepted => "Set status Accepted", - ReviewState::Rejected => "Set status Rejected", - ReviewState::Cancelled => "Set status Cancelled", - ReviewState::Completed => "Set status Completed", - }), - Edit::ReplaceText { .. } => Label::of("Replace text"), - Edit::EraseInk { .. } => Label::of("Erase"), - Edit::MarkAnnotation { marked: true, .. } => Label::of("Mark with checkmark"), - Edit::MarkAnnotation { .. } => Label::of("Remove checkmark"), - 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") - } - Edit::SetFieldValue { name, .. } => Label::with("Fill in {name}", name.as_str()), - Edit::SetFieldImage { name, .. } => Label::with("Set the image of {name}", name.as_str()), - Edit::ResetForm { .. } => Label::of("Clear form"), - Edit::AddField { .. } => Label::of("Add field"), - Edit::SetFieldProps { .. } => Label::of("Change field properties"), - 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::AddAttachment { name, .. } => Label::with("Attach {name}", name.as_str()), - Edit::DeleteAttachment { key } => Label::with("Delete attachment {key}", key.as_str()), - Edit::AutoTagText => Label::of("Automatically tag text"), - Edit::SetAltText { .. } => Label::of("Set alternate text"), - Edit::AutoTagFormFields => Label::of("Tag form fields"), - Edit::SetTagProperty { .. } => Label::of("Change tag properties"), - Edit::MoveTag { .. } => Label::of("Move object"), - Edit::MarkDecorative { .. } => Label::of("Mark figure as decorative"), - Edit::MarkTagArtifact { .. } => Label::of("Mark content as artifact"), - Edit::AddOcrText { .. } => Label::of("Recognize text"), - Edit::ApplyScriptChanges { .. } => Label::of("Run JavaScript"), - 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"), - Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } => Label::of("Edit text"), - Edit::RegisterTextFont { .. } => Label::of("Load document font"), - Edit::MoveObjects { objects, .. } => plural("Move object", "Move objects", objects.len()), - Edit::EditPageImage { change, .. } => Label::of(match change { - ImageEdit::Move(_) => "Move image", - ImageEdit::Rotate(_) | ImageEdit::RotateBy(_) => "Rotate image", - ImageEdit::Crop(_) => "Crop image", - ImageEdit::Flip { .. } => "Flip image", - ImageEdit::Replace { .. } => "Replace image", - ImageEdit::Delete => "Delete image", - }), - Edit::AddHeaderFooter { replace: false, .. } => Label::of("Add header & footer"), - Edit::AddHeaderFooter { .. } => Label::of("Update header & footer"), - Edit::AddWatermark { replace: false, .. } => Label::of("Add watermark"), - Edit::AddWatermark { .. } => Label::of("Update watermark"), - Edit::AddBackground { replace: false, .. } => Label::of("Add background"), - Edit::AddBackground { .. } => Label::of("Update background"), - Edit::RemoveMarks { kind: MarkKind::HeaderFooter } => Label::of("Remove header & footer"), - Edit::RemoveMarks { kind: MarkKind::Watermark } => Label::of("Remove watermark"), - Edit::RemoveMarks { kind: MarkKind::Background } => Label::of("Remove background"), - Edit::AddText { .. } => Label::of("Add text"), - Edit::AddImage { .. } => Label::of("Add image"), - Edit::UpdateContent { .. } => Label::of("Edit content"), - Edit::DeleteContent { .. } => Label::of("Delete content"), - Edit::ReplaceImage { .. } => Label::of("Replace image"), - Edit::PasteContent { .. } => Label::of("Paste"), - Edit::ApplyRedactions { .. } => Label::of("Apply redactions"), - Edit::ClearRedactions => Label::of("Remove redaction marks"), - Edit::RemoveHidden { .. } => Label::of("Remove hidden information"), - Edit::ImportData { name, .. } => Label::with("Import {name}", name.as_str()), - Edit::AddLink { .. } => Label::of("Add link"), - Edit::SetLink { .. } => Label::of("Change link properties"), - Edit::DeleteLink { .. } => Label::of("Delete link"), - Edit::RemoveLinks { .. } => Label::of("Remove all links"), - Edit::AddLinks { links, .. } => plural("Create link", "Create links", links.len()), - Edit::Sanitize => Label::of("Sanitize document"), - Edit::XfaToStatic => Label::of("Convert to a static form"), - Edit::Flatten { comments: true, fields: false } => Label::of("Flatten comments"), - Edit::Flatten { comments: false, fields: true } => Label::of("Flatten form fields"), - Edit::Flatten { .. } => Label::of("Flatten"), - Edit::FlattenFillSign => Label::of("Flatten Fill & Sign"), - Edit::Protect(_) => Label::of("Protect with password"), - Edit::RemoveProtection => Label::of("Remove security"), - // A batch carries its own name, already worded by whoever built it. - // A batch carries its name as text, so the wording is only the hole (see `BATCH_LABELS`). - Edit::Batch { label, .. } => Label::with("{batch}", label.as_str()), - } - } -} - -/// What the Edit menu calls a new comment ("Undo Add highlight"). A whole wording for each kind, -/// so a language can give the noun the article and the gender its own grammar asks for. -fn add_annotation(s: &Shape) -> &'static str { - match s { - Shape::Note { .. } => "Add sticky note", - Shape::TextMarkup { kind: Markup::Highlight, .. } => "Add highlight", - Shape::TextMarkup { kind: Markup::Underline, .. } => "Add underline", - Shape::TextMarkup { kind: Markup::StrikeOut, .. } => "Add strikethrough", - Shape::TextMarkup { kind: Markup::Squiggly, .. } => "Add squiggly underline", - Shape::Rectangle { .. } => "Add rectangle", - Shape::Oval { .. } => "Add oval", - Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "Add arrow", - Shape::Line { .. } => "Add line", - Shape::Ink { .. } => "Add drawing", - Shape::TextBox { .. } => "Add text box", - Shape::Typewriter { .. } => "Add text", - Shape::Mark { mark: FillMark::Check, .. } => "Add checkmark", - Shape::Mark { mark: FillMark::Cross, .. } => "Add cross", - Shape::Mark { mark: FillMark::Dot, .. } => "Add dot", - Shape::Mark { mark: FillMark::Line, .. } => "Add line", - Shape::Signature { .. } | Shape::TypedSignature { .. } => "Add signature", - Shape::Redact { .. } => "Add redaction mark", - Shape::Stamp { .. } | Shape::CustomStamp { .. } => "Add stamp", - Shape::Polygon { cloud: true, .. } => "Add cloud", - Shape::Polygon { .. } => "Add polygon", - Shape::PolyLine { .. } => "Add connected lines", - Shape::Callout { .. } => "Add callout", - Shape::Caret { .. } => "Add inserted text", - Shape::Attachment { .. } => "Add file attachment", - } -} - -/// Opened as owner, or nothing is restricted (no permissions password was set): security may -/// be changed, as in Acrobat. -fn unrestricted(p: &pdfcraft_cos::Permissions) -> bool { - const ALL: i32 = 0b1111_0011_1100; // bits 3–6 and 9–12 - p.owner || p.bits & ALL == ALL -} - -/// Whether the opening password allows an edit (§7.6.4.2, Table 22). -fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), EditError> { - match edit { - Edit::RotatePages { .. } - | Edit::DeletePages { .. } - | Edit::MovePages { .. } - | Edit::ReversePages { .. } - | Edit::InsertBlankPage { .. } - | Edit::DuplicatePages { .. } - | Edit::ReplacePages { .. } - | Edit::SetPageBox { .. } - | Edit::SetTransition { .. } - | Edit::InsertPagesFrom { .. } - // "Assemble the document: insert, rotate or delete pages and create bookmarks" (Table 22). - | Edit::AddBookmark { .. } - | Edit::RenameBookmark { .. } - | Edit::DeleteBookmark { .. } - | Edit::MoveBookmark { .. } - | Edit::SetBookmarkPage { .. } - | Edit::SetBookmarkColor { .. } - | Edit::SetBookmarkStyle { .. } - | Edit::BookmarksFromStructure - | Edit::NumberPages { .. } => { - if p.assemble() { - Ok(()) - } else { - Err(EditError::NotPermitted("page changes")) - } - } - Edit::AddMeasurement(_) - | Edit::AddAnnotation(_) - | Edit::AddCustomStamp { .. } - | Edit::DeleteAnnotation { .. } - | Edit::SetAnnotationContents { .. } - | Edit::ReplyToAnnotation { .. } - | Edit::SetAnnotationStatus { .. } - | Edit::MarkAnnotation { .. } - | Edit::ReplaceText { .. } - | Edit::EraseInk { .. } - | Edit::LockAnnotation { .. } - | Edit::MoveAnnotation { .. } - | Edit::ReshapeAnnotation { .. } - | Edit::ResizeAnnotation { .. } - | Edit::StyleAnnotation { .. } - | Edit::FillAnnotation { .. } - | Edit::FontSizeAnnotation { .. } - | Edit::SetAnnotationInfo { .. } - | Edit::SetMeasurementScale { .. } => { - if p.annotate() { - Ok(()) - } else { - Err(EditError::NotPermitted("comments")) - } - } - Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } | Edit::XfaEvent { .. } => { - if p.fill_forms() { - Ok(()) - } else { - Err(EditError::NotPermitted("filling in form fields")) - } - } - Edit::Protect(_) | Edit::RemoveProtection => { - if unrestricted(p) { - Ok(()) - } else { - Err(EditError::NotPermitted("changing security")) - } - } - // Converting to PDF/A takes the encryption off (PDF/A forbids it), so on an encrypted - // document it is a change of security (#3921). - Edit::ConvertPdfA { .. } => { - if unrestricted(p) { - Ok(()) - } else { - Err(EditError::NotPermitted("removing the encryption, which PDF/A forbids")) - } - } - Edit::SetInfo { .. } - | Edit::AddHeaderFooter { .. } - | Edit::AddWatermark { .. } - | Edit::AddBackground { .. } - | Edit::RemoveMarks { .. } - | Edit::AddField { .. } - | Edit::ApplyRedactions { .. } - | Edit::AddText { .. } - | Edit::AddImage { .. } - | Edit::UpdateContent { .. } - | Edit::MoveObjects { .. } - | Edit::DeleteContent { .. } - | Edit::ReplaceImage { .. } - | Edit::PasteContent { .. } - | Edit::ClearRedactions - | Edit::RemoveHidden { .. } - | Edit::ImportData { .. } - | Edit::AddLink { .. } - | Edit::SetLink { .. } - | Edit::DeleteLink { .. } - | Edit::RemoveLinks { .. } - | Edit::AddLinks { .. } - | Edit::Sanitize - | Edit::XfaToStatic - | Edit::SetFieldProps { .. } - | Edit::DeleteField { .. } - | Edit::DuplicateField { .. } - | Edit::SetTabOrder { .. } - | Edit::MoveInTabOrder { .. } - | Edit::SetCalculationOrder { .. } - | Edit::SetInitialView(_) - | Edit::AddAttachment { .. } - | Edit::DeleteAttachment { .. } - | Edit::AutoTagText - | Edit::SetAltText { .. } - | Edit::AutoTagFormFields - | Edit::SetTagProperty { .. } - | Edit::MoveTag { .. } - | Edit::MarkDecorative { .. } - | Edit::MarkTagArtifact { .. } - | Edit::AddOcrText { .. } - | Edit::SetDocumentScript { .. } - | Edit::SetFieldScript { .. } - | Edit::RemoveStandardId { .. } - | Edit::EditTextLine { .. } - | Edit::EditTextBlock { .. } - | Edit::RegisterTextFont { .. } - | Edit::EditPageImage { .. } - | Edit::Flatten { .. } - | Edit::FlattenFillSign => { - if p.modify() { - Ok(()) - } else { - Err(EditError::NotPermitted("changes to the document")) - } - } - Edit::Batch { edits, .. } => edits.iter().try_for_each(|e| check_permission(e, p)), - } -} - -/// Dates and unique ids stamped onto what an edit creates. -struct EditCtx { - /// Field scripts run (`None`: JavaScript is off) and what they produced. - js: Option, - /// A laid-out XFA form's template (`None`: not an XFA form, or JavaScript is off) and what - /// its scripts produced. - xfa: Option>, - xfa_out: js::JsOutput, - /// The datasets stream this edit wrote (later writes in the edit replace it in place). - xfa_datasets: Option, - /// A form script ran too long and was abandoned: the document's scripts go off. - xfa_ran_away: bool, - /// The edit took the XFA out of the form: what went with it, for the document's warnings. - xfa_removed: Vec, - /// Pages in the document, for `xfa.layout.pageCount()`. - pages: usize, - date: Option, - /// Today in local time, for date tokens. - today: (i64, u32, u32), - seed: u64, - count: u64, - /// Fields the edit renamed to keep imported names separate (old → new), for the repair - /// log and the undo label. - renamed_fields: Vec<(String, String)>, - /// The overlay streams the edit's applied redactions added, which alone the renderer's check - /// leaves out. - redaction_overlays: Vec, -} - -impl EditCtx { - fn new(now: Option, salt: u64) -> Self { - let mut seed = 0x9E37_79B9_7F4A_7C15u64 ^ salt; - if let Some(t) = now { - seed ^= (t as u64).rotate_left(17); - } - #[cfg(not(target_arch = "wasm32"))] - if now.is_some() { - // Real clock: mix in sub-second time so ids from two sessions don't collide. - seed ^= std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos() as u64).unwrap_or(0) << 32; - } - Self { - js: None, - xfa: None, - xfa_out: Default::default(), - xfa_datasets: None, - xfa_ran_away: false, - xfa_removed: Vec::new(), - pages: 0, - date: now.map(pdfcraft_cos::pdf_date), - today: (1970, 1, 1), - seed, - count: 0, - renamed_fields: Vec::new(), - redaction_overlays: Vec::new(), - } - } - - /// 32 bytes of entropy for new encryption keys and salts. `RandomState` is seeded by the - /// operating system, so no extra dependency is needed. - fn entropy(&mut self) -> [u8; 32] { - use std::hash::{BuildHasher, Hasher}; - let mut out = [0u8; 32]; - for (i, chunk) in out.chunks_mut(8).enumerate() { - let mut h = std::collections::hash_map::RandomState::new().build_hasher(); - h.write_u64(self.seed ^ i as u64); - h.write_u64(self.count); - self.count += 1; - chunk.copy_from_slice(&h.finish().to_le_bytes()); - } - out - } - - /// A fresh `/NM`: a random-looking UUID (version 4 layout) from a splitmix64 stream. - fn meta(&mut self) -> pdfcraft_annot::Meta { - let mut next = || { - self.count += 1; - let mut z = self.seed.wrapping_add(self.count.wrapping_mul(0x9E37_79B9_7F4A_7C15)); - z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); - z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); - z ^ (z >> 31) - }; - let (a, b) = (next(), next()); - let id = format!( - "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}", - a >> 32, - (a >> 16) & 0xffff, - a & 0xfff, - 0x8000 | (b >> 48) & 0x3fff, - b & 0xffff_ffff_ffff - ); - pdfcraft_annot::Meta { date: self.date.clone(), id } - } -} - -/// Perform an edit on a working copy (the caller discards it on error). -fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Result<(), EditError> { - match edit { - 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, 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)?; - } - Edit::SetPageBox { pages, which, spec } => pdfcraft_organize::set_page_box(doc, pages, *which, *spec)?, - Edit::SetTransition { pages, spec } => pdfcraft_organize::set_transition(doc, pages, spec)?, - Edit::InsertPagesFrom { name, bytes, pages, at, field_conflicts } => { - let src = open_source(name, bytes)?; - let pages = match pages { - Some(p) => p.clone(), - None => (0..pdfcraft_organize::page_count(&src)?).collect(), - }; - // The file's bookmarks come along under one named after it, as in Combine Files. - let title = std::path::Path::new(name).file_stem().map_or_else(|| name.clone(), |s| s.to_string_lossy().into_owned()); - 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, 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)?, - Edit::MoveBookmark { from, to_parent, index } => { - pdfcraft_organize::move_bookmark(doc, from, to_parent, *index)?; - } - Edit::SetBookmarkPage { path, page } => pdfcraft_organize::set_bookmark_page(doc, path, *page)?, - Edit::SetBookmarkColor { path, color } => pdfcraft_organize::set_bookmark_color(doc, path, *color)?, - Edit::SetBookmarkStyle { path, bold, italic } => pdfcraft_organize::set_bookmark_style(doc, path, *bold, *italic)?, - Edit::BookmarksFromStructure => { - let entries: Vec<_> = pdfcraft_a11y::headings(doc) - .into_iter() - .map(|h| pdfcraft_organize::OutlineEntry { level: h.level, title: h.title, page: h.page, element: h.obj }) - .collect(); - pdfcraft_organize::add_bookmark_tree(doc, "Untitled", &entries)?; - } - Edit::NumberPages { from, to, style, prefix, first } => pdfcraft_organize::number_pages(doc, *from, *to, *style, prefix, *first)?, - Edit::AddMeasurement(m) => { - measure::add(doc, m, &cx.meta())?; - } - Edit::SetMeasurementScale { page, bbox, name, scale } => measure::set_scale(doc, *page, *bbox, name, scale)?, - Edit::AddAnnotation(a) => { - pdfcraft_annot::add_annotation(doc, a, &cx.meta())?; - } - Edit::AddCustomStamp { page, rect, name, file, author } => { - let src = mark_source(doc, file)?; - let (sw, sh) = (src.size.0.max(1.0), src.size.1.max(1.0)); - let natural = rect[2] == rect[0] && rect[3] == rect[1]; - let rotation = pdfcraft_organize::page_rotation(doc, *page)?; - // The picture as displayed: a quarter turn swaps its sides in user space. - let (dw, dh) = if matches!(rotation, 90 | 270) { (sh, sw) } else { (sw, sh) }; - let rect = if natural { - let k = (200.0 / sw.max(sh)).min(1.0); - let (w, h) = (dw * k, dh * k); - [rect[0] - w / 2.0, rect[1] - h / 2.0, rect[0] + w / 2.0, rect[1] + h / 2.0] - } else { - fit_picture(*rect, dw, dh) - }; - let shape = Shape::CustomStamp { rect, name: name.clone(), picture: src.xobject, image: src.image, size: (sw, sh) }; - let style = pdfcraft_annot::Style::default_for(&shape); - let new = NewAnnotation { page: *page, shape, style, contents: name.clone(), author: author.clone() }; - let index = pdfcraft_annot::add_annotation(doc, &new, &cx.meta())?; - pdfcraft_annot::orient_image_stamp(doc, *page, index, rotation)?; - } - Edit::DeleteAnnotation { page, index } => pdfcraft_annot::delete_annotation(doc, *page, *index)?, - Edit::SetAnnotationContents { page, index, text } => pdfcraft_annot::set_contents(doc, *page, *index, text, &cx.meta())?, - Edit::ReplyToAnnotation { page, index, text, author } => { - pdfcraft_annot::add_reply(doc, *page, *index, text, author, &cx.meta())?; - } - Edit::SetAnnotationStatus { page, index, state, author } => { - pdfcraft_annot::set_review_state(doc, *page, *index, *state, author, &cx.meta())?; - } - Edit::EraseInk { page, index, path, radius } => { - pdfcraft_annot::erase_ink(doc, *page, *index, path, *radius, &cx.meta())?; - } - Edit::AddAttachment { name, bytes, description, mime } => { - pdfcraft_organize::add_attachment(doc, name, bytes, description.as_deref(), mime.as_deref(), cx.date.as_deref())?; - } - Edit::DeleteAttachment { key } => pdfcraft_organize::delete_attachment(doc, key)?, - Edit::ReplaceText { page, quads, text, author, strike, caret } => { - // 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())?; - } - Edit::FillAnnotation { page, index, fill } => pdfcraft_annot::set_fill(doc, *page, *index, *fill, &cx.meta())?, - Edit::FontSizeAnnotation { page, index, size } => pdfcraft_annot::set_font_size(doc, *page, *index, *size, &cx.meta())?, - Edit::SetAnnotationInfo { page, index, author, subject, icon } => { - pdfcraft_annot::set_info(doc, *page, *index, author.as_deref(), subject.as_deref(), *icon, &cx.meta())?; - } - Edit::SetFieldValue { name, value } => { - let mut set = |doc: &mut pdfcraft_cos::Document, value: &FieldValue| match cx.js.as_mut() { - Some(js) => pdfcraft_forms::set_value_with(doc, name, value, js), - None => pdfcraft_forms::set_value(doc, name, value), - }; - match set(doc, value) { - Ok(()) => {} - // A date the field's format doesn't read may be typed by another of an XFA date - // field's patterns: it is kept in the first. Only a format refusal is tried - // again: it comes before any of the field's scripts run, so they run once. - Err(refused @ pdfcraft_forms::FormError::Format(_)) => match xfa::typed_date(doc, cx.xfa.as_deref(), name, value) { - Some(typed) => set(doc, &typed)?, - None => return Err(refused.into()), - }, - Err(refused) => return Err(refused.into()), - } - } - Edit::ApplyScriptChanges { changes } => match cx.js.as_mut() { - Some(js) => pdfcraft_forms::apply_script_changes(doc, changes, js)?, - None => pdfcraft_forms::apply_script_changes(doc, changes, &mut pdfcraft_forms::NoScripts)?, - }, - Edit::XfaEvent { som, activity } => { - let tpl = cx.xfa.clone().ok_or_else(|| EditError::Invalid("this is not a laid-out XFA form, or JavaScript is off".into()))?; - let mut run = xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false }; - let ran = xfa::on_event(doc, &tpl, som, activity, &mut run); - cx.xfa_datasets = run.datasets; - cx.xfa_ran_away |= run.ran_away; - ran.map_err(EditError::Invalid)?; - } - Edit::SetFieldImage { name, image } => { - let (img, _) = embed_image(doc, name, image)?; - let px = match &*doc.get(img) { - pdfcraft_cos::Object::Stream(s) => (s.dict.int(b"Width").unwrap_or(1) as u32, s.dict.int(b"Height").unwrap_or(1) as u32), - _ => (1, 1), - }; - // An XFA image field keeps the picture in the data too (as Designer saves it), and a - // laid-out form shows it from there: so only pictures the layout can show, and only - // for image fields (a laid-out one carries its SOM path; a static form's field name - // is its path). - let is_xfa = pdfcraft_xfa::read_packets(doc).ok().flatten().is_some(); - let image_field = is_xfa - && 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, 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()); - let path = match cx.xfa.as_deref() { - Some(tpl) => pdfcraft_xfa::data_path(tpl, &som), - None => pdfcraft_xfa::som_to_path(&som), - }; - let r = - pdfcraft_xfa::write_data_image(doc, &path, content_type, image, cx.xfa_datasets).map_err(|e| EditError::Write(e.to_string()))?; - if !r.written && r.warnings.is_empty() { - return Err(EditError::Write(format!("{name}: the picture could not be written into the form's data"))); - } - cx.xfa_datasets = r.stream.or(cx.xfa_datasets); - cx.xfa_out.errors.extend(r.warnings); - xfa_after_data_change(doc, cx, false)?; - } else { - pdfcraft_forms::set_button_icon(doc, name, img, px)?; - } - } - Edit::ResetForm { names } => { - pdfcraft_forms::reset(doc, names.as_deref())?; - } - Edit::AddField { page, rect, kind, name } => { - pdfcraft_forms::add_field(doc, *page, *rect, kind, name.as_deref())?; - } - Edit::SetFieldProps { name, props } => { - pdfcraft_forms::set_props(doc, name, props)?; - } - Edit::DuplicateField { name, pages } => { - if pdfcraft_forms::duplicate_field(doc, name, pages)? == 0 { - return Err(EditError::Form(pdfcraft_forms::FormError::Invalid(format!("{name} is already on those pages")))); - } - } - 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()))?; - } - Edit::SetAltText { figure, alt } => { - let r = pdfcraft_cos::ObjRef::new(*figure, doc.generation(*figure)); - a11y::set_alt(doc, r, alt.as_deref()).map_err(|e| EditError::Accessibility(e.to_string()))?; - } - Edit::AutoTagFormFields => { - a11y::tag_form_fields(doc).map_err(EditError::Accessibility)?; - } - Edit::SetTagProperty { object, property, value } => { - a11y::set_tag_property(doc, *object, *property, value.as_deref()).map_err(|e| EditError::Accessibility(e.to_string()))?; - } - Edit::MoveTag { object, anchor, placement } => { - a11y::move_tag(doc, *object, *anchor, *placement).map_err(|e| EditError::Accessibility(e.to_string()))?; - } - Edit::EditTextLine { page, line, text } => { - pdfcraft_edit::replace_line(doc, *page, *line, text)?; - } - Edit::SetFieldScript { name, event, script } => { - pdfcraft_forms::set_field_script(doc, name, event, script.as_deref())?; - match cx.js.as_mut() { - Some(js) => pdfcraft_forms::recalculate_with(doc, js)?, - None => pdfcraft_forms::recalculate(doc)?, - }; - } - 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))?; - } - Edit::EditPageImage { page, index, change } => { - let img = pdfcraft_edit::page_images(doc, *page)? - .into_iter() - .nth(*index) - .ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1))))?; - let c = match change { - ImageEdit::Move(to) => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::rect_to_rect(img.rect, *to)), - 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, - *horizontal, - !*horizontal, - )), - ImageEdit::Replace { name, bytes } => { - if img.is_form { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid( - "grouped Form artwork cannot be replaced with a bitmap".into(), - ))); - } - pdfcraft_edit::ImageChange::Replace(embed_image(doc, name, bytes)?.0) - } - ImageEdit::Crop(keep) => pdfcraft_edit::ImageChange::Crop(*keep), - ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete, - }; - pdfcraft_edit::change_image(doc, *page, *index, &c)?; - } - Edit::MoveObjects { page, objects, offset } => pdfcraft_edit::move_objects(doc, *page, objects, *offset)?, - Edit::EditTextBlock { page, block, text, style } => { - pdfcraft_edit::rewrite_block(doc, *page, *block, Some(text), style)?; - } - Edit::RegisterTextFont { page, bytes } => { - pdfcraft_edit::text::register_text_font(doc, *page, bytes)?; - } - Edit::MarkDecorative { figure } => { - let r = pdfcraft_cos::ObjRef::new(*figure, doc.generation(*figure)); - a11y::mark_decorative(doc, r).map_err(|e| EditError::Accessibility(e.to_string()))?; - } - Edit::MarkTagArtifact { object } => { - a11y::mark_tag_artifact(doc, *object).map_err(|e| EditError::Accessibility(e.to_string()))?; - } - Edit::AddHeaderFooter { pages, settings, replace } => { - let date = cx.today; - pdfcraft_edit::add_header_footer(doc, pages, settings, *replace, &pdfcraft_edit::Context { date })?; - } - Edit::AddWatermark { pages, settings, replace, file } => { - let mut s = settings.clone(); - if let Some(f) = file { - s.source = Some(mark_source(doc, f)?); - } - pdfcraft_edit::add_watermark(doc, pages, &s, *replace)? - } - Edit::AddBackground { pages, settings, replace, file } => { - let mut s = settings.clone(); - if let Some(f) = file { - s.source = Some(mark_source(doc, f)?); - } - pdfcraft_edit::add_background(doc, pages, &s, *replace)? - } - Edit::RemoveMarks { kind } => { - let n = pdfcraft_model::pages(doc).len(); - if pdfcraft_edit::remove_marks(doc, &(0..n).collect::>(), *kind)? == 0 { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("there is nothing to remove".into()))); - } - } - Edit::AddText { page, text } => { - pdfcraft_edit::add_content(doc, *page, &AddedContent::Text(text.clone()))?; - } - Edit::AddImage { page, rect, name, bytes } => { - let (image, natural) = embed_image(doc, name, bytes)?; - let rect = match rect { - Some(r) => *r, - None => { - let p = pdfcraft_model::pages(doc).swap_remove(*page); - let (pw, ph) = p.display_size(doc); - let k = ((pw * 0.8) / natural.0).min((ph * 0.8) / natural.1).min(1.0); - let (w, h) = (natural.0 * k, natural.1 * k); - [(pw - w) / 2.0, (ph - h) / 2.0, (pw + w) / 2.0, (ph + h) / 2.0] - } - }; - pdfcraft_edit::add_content(doc, *page, &AddedContent::Image(pdfcraft_edit::AddedImage::new(rect, image)))?; - } - Edit::UpdateContent { page, index, content } => pdfcraft_edit::update_content(doc, *page, *index, content)?, - Edit::DeleteContent { page, index } => pdfcraft_edit::delete_content(doc, *page, *index)?, - Edit::ReplaceImage { page, index, name, bytes } => { - let item = pdfcraft_edit::list_added(doc).into_iter().filter(|a| a.page == *page).nth(*index); - let Some(AddedContent::Image(old)) = item.map(|a| a.content) else { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("that item is not an image".into()))); - }; - let (image, _) = embed_image(doc, name, bytes)?; - pdfcraft_edit::update_content(doc, *page, *index, &AddedContent::Image(pdfcraft_edit::AddedImage { image, ..old }))?; - } - Edit::PasteContent { page, content, file } => { - let content = match content { - AddedContent::Text(_) => content.clone(), - AddedContent::Image(img) => { - let Some((name, bytes)) = file else { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("a pasted image needs its picture".into()))); - }; - // The same picture in this document (copied here, pasted here before, or an - // identical file): drawn again from the one image object rather than stored twice. - let image = if copy_image::is_picture(doc, img.image, bytes) { - img.image - } else { - match copy_image::added_picture(doc, bytes) { - Some(image) => image, - None => embed_image(doc, name, bytes)?.0, - } - }; - AddedContent::Image(pdfcraft_edit::AddedImage { image, ..img.clone() }) - } - }; - pdfcraft_edit::add_content(doc, *page, &content)?; - } - Edit::ApplyRedactions { pages } => { - cx.redaction_overlays = pdfcraft_redact::apply(doc, pages.as_deref())?.overlays; - } - Edit::ClearRedactions => { - pdfcraft_redact::clear_marks(doc, None)?; - } - Edit::AddLink { page, rect, action, style } => { - pdfcraft_annot::links::add(doc, *page, *rect, action, style)?; - } - Edit::SetLink { page, index, rect, action, style } => pdfcraft_annot::links::set(doc, *page, *index, *rect, action.as_ref(), style.as_ref())?, - Edit::DeleteLink { page, index } => pdfcraft_annot::links::delete(doc, *page, *index)?, - Edit::RemoveLinks { pages } => { - if pdfcraft_annot::links::remove_all(doc, pages.as_deref())? == 0 { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("there are no links to remove".into()))); - } - } - Edit::AddLinks { links, style } => { - if pdfcraft_annot::links::add_many(doc, links, style)? == 0 { - return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("no web addresses were found".into()))); - } - } - Edit::ImportData { bytes, .. } => { - // An XFA form takes XFA data files (XDP, datasets, or a data document) as its data; - // XFDF, FDF, Acrobat's XML and tables go the usual way. - let text = (pdfcraft_xfa::read_packets(doc).ok().flatten().is_some()).then(|| decode_data_file(bytes)).flatten(); - match text { - Some(Ok(text)) if is_xfa_data_file(&text) => { - let expect_root = cx.xfa.as_ref().and_then(|t| t.root.common.name.clone()); - let r = pdfcraft_xfa::import_data(doc, &text, expect_root.as_deref(), cx.xfa_datasets) - .map_err(|e| EditError::Write(e.to_string()))?; - cx.xfa_datasets = r.stream.or(cx.xfa_datasets); - cx.xfa_out.errors.extend(r.warnings); - cx.xfa_out.console.push(format!("Imported XFA data: {} node(s)", r.nodes)); - xfa_after_data_change(doc, cx, true)?; - } - Some(Err(why)) => return Err(EditError::Invalid(why)), - _ => { - pdfcraft_xfdf::import(doc, bytes)?; - } - } - } - Edit::RemoveHidden { which } => { - pdfcraft_redact::sanitize::remove_hidden(doc, which)?; - } - Edit::Sanitize => { - pdfcraft_redact::sanitize::sanitize(doc)?; - } - Edit::Flatten { comments, fields } => { - // An XFA form's fields baked into the pages: the XFA goes too, or Adobe's viewers - // would lay the form out again from its template over the baked pages. A dynamic - // form that was never laid out keeps its XFA: without it only placeholders remain. - let shape = (*fields).then(|| pdfcraft_xfa::xfa_shape(doc)).flatten(); - let n = pdfcraft_model::pages(doc).len(); - pdfcraft_edit::flatten(doc, &(0..n).collect::>(), *comments, *fields)?; - match shape { - Some(shape) if shape.strippable() => strip_xfa(doc, cx)?, - Some(_) => cx.xfa_removed.push("The XFA was kept: this dynamic form was not laid out, so its fields were not baked in".into()), - None => {} - } - } - Edit::XfaToStatic => { - let Some(shape) = pdfcraft_xfa::xfa_shape(doc) else { - return Err(EditError::Invalid("this document is not an XFA form".into())); - }; - if !shape.strippable() { - return Err(EditError::Invalid("this dynamic XFA form was not laid out, so there is no static form to keep".into())); - } - strip_xfa(doc, cx)?; - } - Edit::FlattenFillSign => { - let n = pdfcraft_model::pages(doc).len(); - pdfcraft_edit::flatten_fill_sign(doc, &(0..n).collect::>())?; - } - Edit::Protect(p) => { - p.validate()?; - let seed = cx.entropy(); - // Without a permissions password nothing is restricted, so the owner password is a - // random one nobody needs. - let random_owner: String = cx.entropy().iter().map(|b| format!("{b:02x}")).collect(); - let params = pdfcraft_cos::NewEncryption { - algorithm: p.algorithm, - user_password: p.open_password.as_deref().unwrap_or(""), - owner_password: p.permissions_password.as_deref().unwrap_or(&random_owner), - permissions: p.permission_bits(), - encrypt_metadata: p.encrypt_metadata, - seed, - }; - doc.set_encryption(¶ms).map_err(|e| EditError::Protection(e.to_string()))?; - } - Edit::RemoveProtection => { - if doc.output_handler().is_none() { - return Err(EditError::Protection("the document isn't protected".into())); - } - doc.remove_encryption(); - } - Edit::Batch { edits, .. } => { - for e in edits { - run_edit(doc, e, cx)?; - } - } - } - Ok(()) -} - -/// The comment list, read from the object graph (as `inspect` lists comments). -fn comment_list(doc: &pdfcraft_cos::Document) -> Vec { - pdfcraft_annot::summaries(doc) - .into_iter() - .map(|s| pdfcraft_render::Annotation { - page: s.page, - subtype: s.subtype, - author: s.author, - contents: s.contents, - modified: s.modified.map(|m| pdfcraft_render::pretty_date(&m)), - name: s.name, - in_reply_to: s.in_reply_to, - rect: s.rect, - color: s.color, - index: s.index, - state: s.state, - quads: s.quads, - locked: s.locked, - intent: s.intent, - fill_sign: s.fill_sign, - no_rotate: s.no_rotate, - points: s.points, - }) - .collect() -} - /// Seconds to add to UTC for local time (0 where unknown). fn local_utc_offset() -> i64 { #[cfg(not(target_arch = "wasm32"))] @@ -3099,18 +170,6 @@ fn parse_ymd(d: &str) -> Option<(i64, u32, u32)> { Some((d.get(0..4)?.parse().ok()?, d.get(4..6)?.parse().ok()?, d.get(6..8)?.parse().ok()?)) } -/// The passwords after `edit`, if it changes them. -fn keys_after(edit: &Edit) -> Option { - match edit { - Edit::Protect(p) => { - Some(Keys { render: p.open_password.clone(), reopen: p.permissions_password.clone().or_else(|| p.open_password.clone()) }) - } - Edit::RemoveProtection => Some(Keys::default()), - Edit::Batch { edits, .. } => edits.iter().rev().find_map(keys_after), - _ => None, - } -} - /// The recovered user password of an R2–R4 file as text for the renderer, which tries a password /// as its UTF-8 bytes and then in PDFDocEncoding: UTF-8 bytes (as some writers store passwords /// PDFDocEncoding can't hold, such as "şifre") stay UTF-8, and other bytes are read as @@ -3122,16 +181,24 @@ fn renderer_password(bytes: &[u8]) -> String { } } -/// The document as it may be written to a file (#3926): a copy without the values of text fields -/// with the Password flag, or `cos` itself when it holds none. ISO 32000-2 §12.7.5.3, Table 231 -/// (a note to the Password flag): "it is imperative that PDF processors never store the value of -/// the text field in the PDF file if this flag is set". The open document keeps them in memory. +/// The document as it may be written to a file (#3926, #3944): a copy without the values of text +/// fields with the Password flag (or XFA `` fields in the datasets), or `cos` +/// itself when it holds none. ISO 32000-2 §12.7.5.3, Table 231 (a note to the Password flag): +/// "it is imperative that PDF processors never store the value of the text field in the PDF file +/// if this flag is set". The open document keeps them in memory. fn writable(cos: &pdfcraft_cos::Document) -> Result, EditError> { - if !pdfcraft_forms::has_password_values(cos) { + let has_form_pw = pdfcraft_forms::has_password_values(cos); + let has_xfa_pw = pdfcraft_xfa::has_password_values(cos); + if !has_form_pw && !has_xfa_pw { return Ok(std::borrow::Cow::Borrowed(cos)); } let mut copy = cos.clone(); - pdfcraft_forms::clear_password_values(&mut copy).map_err(|e| EditError::Write(e.to_string()))?; + if has_form_pw { + pdfcraft_forms::clear_password_values(&mut copy).map_err(|e| EditError::Write(e.to_string()))?; + } + if has_xfa_pw { + pdfcraft_xfa::clear_password_values(&mut copy).map_err(|e| EditError::Write(e.to_string()))?; + } Ok(std::borrow::Cow::Owned(copy)) } @@ -3144,2353 +211,11 @@ pub fn guard(f: impl FnOnce() -> T) -> Result { }) } -/// Parse another PDF to copy pages from. -fn open_source(name: &str, bytes: &Arc>) -> Result { - open_source_with(name, bytes, None) -} - -/// [`open_source`], authenticating with `password` (user or owner) for an encrypted file. -fn open_source_with(name: &str, bytes: &Arc>, password: Option<&str>) -> Result { - source_document(bytes, password).map_err(|p| EditError::Source(format!("{name}: {p}"))) -} - -/// Why a file can't be a source for Combine Files (or Insert / Replace pages). -#[derive(Debug, Clone, PartialEq, thiserror::Error)] -pub enum SourceProblem { - #[error("it is password-protected")] - Password, - #[error("the password is wrong")] - WrongPassword, - #[error("its security settings don't allow copying pages")] - NotPermitted, - #[error("{0}")] - Unreadable(String), -} - -fn source_document(bytes: &Arc>, password: Option<&str>) -> Result { - match std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)) { - Ok(Ok(d)) if d.permissions().is_some_and(|p| !p.assemble()) => Err(SourceProblem::NotPermitted), - Ok(Ok(d)) => Ok(d), - Ok(Err(pdfcraft_cos::CosError::NeedsPassword)) => Err(SourceProblem::Password), - Ok(Err(pdfcraft_cos::CosError::WrongPassword)) => Err(SourceProblem::WrongPassword), - Ok(Err(e)) => Err(SourceProblem::Unreadable(e.to_string())), - Err(_) => Err(SourceProblem::Unreadable("the file could not be read".into())), - } -} - -/// Whether `bytes` can be combined, opened with `password` (user or owner) if given, and if not -/// why: what Combine would refuse it for, so the Combine files list can say so before Combine -/// is pressed. The owner (permissions) password lifts the restriction on copying pages. -pub fn combine_source_check(bytes: &Arc>, password: Option<&str>) -> Result<(), SourceProblem> { - source_document(bytes, password).map(|_| ()) -} - -/// The number of pages of `bytes`, opened with `password` (user or owner) if given, whatever -/// its permissions allow; `None` when it can't be read. -pub fn source_page_count(bytes: &Arc>, password: Option<&str>) -> Option { - let doc = std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)).ok()?.ok()?; - pdfcraft_organize::page_count(&doc).ok() -} - -/// The singular or the plural wording, by count. Both are written out: a language forms its plural -/// its own way, and `one` and `many` may differ in more than an "s". -fn plural(one: &'static str, many: &'static str, n: usize) -> Label { - Label::of(if n == 1 { one } else { many }) -} - -#[derive(Debug, thiserror::Error, PartialEq)] -pub enum EditError { - #[error("no such document")] - NoDocument, - #[error("this document can't be edited: {0}")] - ReadOnly(String), - #[error("the document's security settings don't allow {0}; open it with the owner password to make this change")] - NotPermitted(&'static str), - #[error("{0}")] - Organize(#[from] pdfcraft_organize::OrganizeError), - #[error("{0}")] - Bookmark(#[from] pdfcraft_organize::OutlineError), - #[error("{0}")] - Comment(#[from] pdfcraft_annot::AnnotError), - #[error(transparent)] - Measure(#[from] measure::MeasureError), - #[error("{0}")] - Protection(String), - #[error("the password is incorrect")] - WrongPassword, - #[error("{0}")] - Form(#[from] pdfcraft_forms::FormError), - #[error(transparent)] - Redact(#[from] pdfcraft_redact::RedactError), - #[error("{0}")] - Print(String), - #[error("{0}")] - Accessibility(String), - #[error(transparent)] - Data(#[from] pdfcraft_xfdf::DataError), - #[error("{0}")] - Edit(#[from] pdfcraft_edit::EditError), - #[error("{0}")] - Create(#[from] pdfcraft_create::CreateError), - #[error("the edited document could not be written: {0}")] - Write(String), - #[error("the edited document could not be reopened: {0}")] - Reopen(String), - #[error("couldn't use {0}")] - Source(String), - #[error("nothing to undo")] - NothingToUndo, - #[error("nothing to redo")] - NothingToRedo, - #[error("{0}")] - Sign(String), - #[error("{0}")] - Optimize(String), - #[error("cancelled")] - Cancelled, - #[error("{0} isn't possible in a signed document: it would rewrite the file and invalidate the signatures")] - SignedRewrite(String), - #[error("this document is signed: rewriting it would invalidate its signatures (save it incrementally instead)")] - Signed, - #[error("{0}")] - Invalid(String), -} - -impl From for EditError { - fn from(e: pdfcraft_sign::SignError) -> Self { - EditError::Sign(e.to_string()) - } -} - -#[derive(Default)] -pub struct Session { - docs: Vec, - next_id: u64, - /// Seconds since the Unix epoch, injected so saves are deterministic in tests. - clock: Option i64>, - /// Seconds to add to UTC for local time, read from whoever knows the zone (a browser, say). - /// Separate from `clock` on purpose: `clock` alone is the test shortcut that takes the - /// injected time AS local time and zeroes the zone, so injecting only it leaves either the - /// local dates or the UTC ones wrong by the zone's offset. - utc_offset: Option i64>, - /// Certificates trusted for signing (Acrobat: Trusted Certificates). - trust: Arc, - /// Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript, inverted (on by default). - js_off: bool, - /// Preferences ▸ Date format, when not the default (see [`dates`]). - date_format: Option, - /// Preferences ▸ Date format ▸ Language, when not following the interface language. - date_language: Option, -} - -/// Immutable session state needed while a document is prepared away from its owning thread. -#[derive(Clone)] -pub struct OpenContext { - now: Option, - js_off: bool, - trust: Arc, -} - -/// A fully prepared document that has not yet been registered in a session. -pub struct PreparedDocument { - document: Document, -} - -impl OpenContext { - /// Prepare a document without mutating the session that supplied this context. - pub fn prepare_open( - &self, - name: impl Into, - path: Option, - bytes: Arc>, - password: Option<&str>, - ) -> Result { - let name = name.into(); - guard(|| Session::prepare_open_unguarded(self, name, path, bytes, password)) - .unwrap_or_else(|m| Err(OpenError::Invalid(format!("reading it failed unexpectedly ({m})")))) - } -} - -/// Lay a dynamic XFA form out (pages and fields) and give its widgets appearances. -fn xfa_layout(doc: &mut pdfcraft_cos::Document) -> Result { - let mut report = pdfcraft_xfa::render_into(doc).map_err(|e| e.to_string())?; - xfa_redraw(doc)?; - // The form's own values: one its field's font can't draw is drawn with `?` and reported. - report.warnings.extend(undrawn_field_warnings(doc)); - Ok(report) -} - -/// Give the fields of a laid-out XFA form their appearances. A signed signature box keeps the -/// picture its signer's application drew. -pub(crate) fn xfa_redraw(doc: &mut pdfcraft_cos::Document) -> Result<(), String> { - for f in pdfcraft_forms::fields(doc) { - if f.kind == pdfcraft_forms::FieldKind::Signature && doc.get(f.obj).as_dict().is_some_and(|d| d.contains(b"V")) { - continue; - } - pdfcraft_forms::redraw_field(doc, &f.name).map_err(|e| format!("{}: {e}", f.name))?; - } - Ok(()) -} - -/// A warning for each field whose appearance PdfCraft drew with `?` for a character its font -/// can't draw (laying out an XFA form's own values). -fn undrawn_field_warnings(doc: &pdfcraft_cos::Document) -> Vec { - pdfcraft_forms::undrawn_fields(doc) - .into_iter() - .map(|(name, c)| { - format!("form field {name:?} holds \"{}\" (U+{:04X}), which its font can't draw: it shows ? in its place", c.escape_debug(), u32::from(c)) - }) - .collect() -} - -/// The form's fields as the XFA data layer wants them. -fn xfa_field_data(doc: &pdfcraft_cos::Document) -> Vec { - use pdfcraft_forms::FieldKind as K; - pdfcraft_forms::fields(doc) - .into_iter() - .map(|f| { - let data = match f.kind { - K::Text | K::Combo | K::List => pdfcraft_xfa::FieldData::Text(f.value.join("\n")), - K::CheckBox => pdfcraft_xfa::FieldData::Check(!f.value.is_empty()), - K::Radio => pdfcraft_xfa::FieldData::Radio(f.value.first().cloned()), - K::PushButton | K::Signature => pdfcraft_xfa::FieldData::None, - }; - pdfcraft_xfa::FieldDatum { obj: f.obj, name: f.name, data } - }) - .collect() -} - -/// Keep the XFA datasets packet in step with the fields after an edit. Returns what could not -/// be written. -/// `datasets`: the stream this edit already wrote, replaced in place rather than added again -/// (and set to the one written). -/// `tpl`: the form's parsed template when the document has one cached (else it is parsed). -fn xfa_sync_datasets( - doc: &mut pdfcraft_cos::Document, - tpl: Option<&pdfcraft_xfa::model::Template>, - datasets: &mut Option, -) -> Result, String> { - let data = xfa_field_data(doc); - let r = pdfcraft_xfa::write_datasets_with(doc, tpl, &data, *datasets).map_err(|e| e.to_string())?; - if r.stream.is_some() { - *datasets = r.stream; - } - Ok(r.warnings) -} - -/// What a document whose form script ran away is told. -const XFA_SCRIPTS_OFF: &str = - "A script of this form ran too long and was abandoned; the form's scripts are off for this document (reopen it to run them again)"; - -/// Most XFA warnings kept per document. -const MAX_XFA_WARNINGS: usize = 50; - -/// Add `new` to `list` (no repeats, at most [`MAX_XFA_WARNINGS`]). -fn note_warnings(list: &mut Vec, new: &[String]) { - for w in new { - if !list.contains(w) && list.len() < MAX_XFA_WARNINGS { - list.push(w.clone()); - } - } -} - -/// A data file's text: UTF-8 or UTF-16 (by a byte order mark, or how `<` is written), or -/// Latin-1 when its XML declaration says so. `None`: not text at all (FDF); `Err`: an -/// encoding this can't read. -fn decode_data_file(bytes: &[u8]) -> Option> { - if bytes.starts_with(b"%FDF") { - return None; - } - let declared = |text: &str| -> Option { - let head = text.get(..text.len().min(256))?; - let decl = head.strip_prefix('\u{feff}').unwrap_or(head).trim_start().strip_prefix("")?.0; - let enc = decl.split_once("encoding=")?.1; - let quote = enc.chars().next()?; - Some(enc.get(1..)?.split(quote).next()?.trim().to_ascii_lowercase()) - }; - if let Some((text, _)) = pdfcraft_xfa::packets::decode(bytes) { - match declared(&text).as_deref() { - None | Some("utf-8" | "utf8" | "utf-16" | "utf-16le" | "utf-16be" | "us-ascii" | "ascii") => return Some(Ok(text)), - Some("iso-8859-1" | "latin1" | "latin-1" | "windows-1252" | "cp1252") if !bytes.starts_with(&[0xEF, 0xBB, 0xBF]) => { - // Each byte is the character it names (the 0x80–0x9F block of Windows-1252 - // differs, and is rare in form data). - return Some(Ok(bytes.iter().map(|b| char::from(*b)).collect())); - } - Some(other) => return Some(Err(format!("the data file's encoding {other:?} is not supported (UTF-8, UTF-16 and ISO-8859-1 are)"))), - } - } - // Not valid UTF-8/UTF-16: Latin-1 by declaration, else refused. - let latin1: String = bytes.iter().map(|b| char::from(*b)).collect(); - match declared(&latin1).as_deref() { - Some("iso-8859-1" | "latin1" | "latin-1" | "windows-1252" | "cp1252") => Some(Ok(latin1)), - _ => Some(Err("the data file is not UTF-8, UTF-16 or ISO-8859-1 text".into())), - } -} - -/// Take the XFA out of `doc` and note what went with it: the packets, data no field showed, -/// buttons that did nothing but run scripts, and fields whose value differs from the form's -/// data (data PdfCraft could not read into them, now lost). -fn strip_xfa(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx) -> Result<(), EditError> { - let fields = pdfcraft_forms::fields(doc); - let data = xfa_field_data(doc); - let differ = pdfcraft_xfa::read_values(doc, &data) - .into_iter() - .filter(|(name, value)| { - let Some(f) = fields.iter().find(|f| f.name == *name) else { return false }; - match value { - pdfcraft_xfa::FieldData::Text(t) => f.value.join("\n") != *t && !t.trim().is_empty(), - pdfcraft_xfa::FieldData::Check(on) => f.value.is_empty() == *on, - pdfcraft_xfa::FieldData::Radio(sel) => f.value.first() != sel.as_ref(), - pdfcraft_xfa::FieldData::None => false, - } - }) - .count(); - let r = pdfcraft_xfa::strip(doc).map_err(|e| EditError::Write(e.to_string()))?; - cx.xfa_removed.extend(r.notes()); - if differ > 0 { - cx.xfa_removed.push(format!( - "{differ} field(s) held a value other than the form's XFA data (data PdfCraft could not read into them); the data's version is gone" - )); - } - Ok(()) -} - -/// Keep the document's XFA state in step with its file after an edit: a script may have laid -/// the form out again (rows added, subforms shown), an edit may have taken the XFA out -/// (flattened, or made static), or an undo put it back. -fn sync_xfa_state(doc: &mut Document) { - let Some(cos) = doc.editor.as_ref().map(|e| &e.cos) else { return }; - match pdfcraft_xfa::existing_layout(cos) { - Some(report) => { - if doc.xfa.is_none() { - // Back after an undo: the XFA's own warnings apply again, the strip's don't. - doc.xfa_warnings.retain(|w| !w.starts_with(XFA_REMOVED_PREFIX)); - if doc.xfa_template.is_none() && !doc.xfa_scripts_off { - doc.xfa_template = xfa::template(cos); - } - } - doc.xfa = Some(report); - } - None => { - doc.xfa = None; - doc.xfa_template = None; - // Only what the strip said stays (the XFA's own warnings no longer apply). - doc.xfa_warnings.retain(|w| w.starts_with(XFA_REMOVED_PREFIX)); - } - } -} - -/// How the warnings of a strip start, so they can be told from the XFA's own. -const XFA_REMOVED_PREFIX: &str = "XFA removed: "; - -/// Is this an XFA data file (an XDP, a datasets document, a data element or a data document), -/// rather than XFDF, Acrobat's `` XML or a table? By its root element. -fn is_xfa_data_file(text: &str) -> bool { - let text = text.get(..text.len().min(65_536)).unwrap_or(text); - let mut rest = text.trim_start_matches('\u{feff}').trim_start(); - // Past the XML declaration, comments and processing instructions. - let mut guard = 0; - while guard < 100 { - guard += 1; - if let Some(r) = rest.strip_prefix("").map_or("", |(_, r)| r).trim_start(); - } else if let Some(r) = rest.strip_prefix("").map_or("", |(_, r)| r).trim_start(); - } else if let Some(r) = rest.strip_prefix("').map_or("", |(_, r)| r).trim_start(); - } else { - break; - } - } - let Some(tag) = rest.strip_prefix('<') else { return false }; - let name = tag.split(|c: char| c.is_whitespace() || c == '>' || c == '/').next().unwrap_or(""); - let local = name.rsplit(':').next().unwrap_or(name); - !local.is_empty() && !matches!(local, "xfdf" | "fields" | "html" | "body") -} - -/// The content type a picture is saved under, from its bytes: the kinds a laid-out form shows. -fn image_content_type(bytes: &[u8]) -> Option<&'static str> { - match pdfcraft_xfa::image::kind(bytes)? { - 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"), - } -} - -/// After the XFA data changed outside the fields (an import, a picture): a laid-out form is -/// laid out again from the data (then, when `calculate`, its calculations run), and the -/// fields take the data's values. -fn xfa_after_data_change(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx, calculate: bool) -> Result<(), EditError> { - if let Some(tpl) = cx.xfa.clone() { - let mut run = xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false }; - let r = guard(|| xfa::on_data_replaced(doc, &tpl, calculate, &mut run)) - .unwrap_or_else(|m| Err(format!("laying the form out again failed unexpectedly ({m})"))); - cx.xfa_datasets = run.datasets; - cx.xfa_ran_away |= run.ran_away; - if let Err(e) = r { - cx.xfa_out.errors.push(e); - } - } - let taken = xfa_values_from_datasets(doc).map_err(EditError::Write)?; - if !taken.is_empty() { - cx.xfa_out.console.push(format!("{} field value(s) taken from the imported data", taken.len())); - } - // Not laid out again (a static form, or scripts off): fields the data no longer holds are - // emptied, so the data replaced is the data shown. - if cx.xfa.is_none() && calculate { - let data = xfa_field_data(doc); - let present: std::collections::HashSet = pdfcraft_xfa::read_values(doc, &data).into_iter().map(|(n, _)| n).collect(); - for f in pdfcraft_forms::fields(doc) { - if present.contains(&f.name) || f.value.is_empty() { - continue; - } - use pdfcraft_forms::FieldKind as K; - let empty = match f.kind { - K::Text => FieldValue::Text(String::new()), - K::CheckBox => FieldValue::Check(false), - K::Radio => FieldValue::Radio(None), - K::Combo | K::List => FieldValue::Choice(Vec::new()), - K::PushButton | K::Signature => continue, - }; - if let Err(e) = pdfcraft_forms::set_value(doc, &f.name, &empty) { - cx.xfa_out.errors.push(format!("{}: not emptied by the import: {e}", f.name)); - } - } - } - Ok(()) -} - -/// Give the fields the values the XFA datasets hold (a form filled in another viewer). Returns -/// the names of the fields that changed. -fn xfa_values_from_datasets(doc: &mut pdfcraft_cos::Document) -> Result, String> { - let data = xfa_field_data(doc); - let fields = pdfcraft_forms::fields(doc); - let mut changed = Vec::new(); - for (name, value) in pdfcraft_xfa::read_values(doc, &data) { - let Some(f) = fields.iter().find(|f| f.name == name) else { continue }; - let new = match value { - pdfcraft_xfa::FieldData::Text(t) if matches!(f.kind, pdfcraft_forms::FieldKind::Combo | pdfcraft_forms::FieldKind::List) => { - // Saved values, one per line for a multi-select list; shown text stands for - // its saved value (an older viewer may have written it). - let saved = |v: &str| f.options.iter().find(|(e, s)| e == v || s == v).map_or(v.to_string(), |(e, _)| e.clone()); - let picked: Vec = if f.has(pdfcraft_forms::flags::MULTI_SELECT) { - t.lines().map(str::trim).filter(|l| !l.is_empty()).map(saved).collect() - } else { - vec![saved(t.trim())] - }; - (f.value != picked).then_some(FieldValue::Choice(picked)) - } - pdfcraft_xfa::FieldData::Text(t) => (f.value.join("\n") != t).then_some(FieldValue::Text(t)), - pdfcraft_xfa::FieldData::Check(on) => (f.value.is_empty() == on).then_some(FieldValue::Check(on)), - pdfcraft_xfa::FieldData::Radio(sel) => (f.value.first() != sel.as_ref()).then_some(FieldValue::Radio(sel)), - pdfcraft_xfa::FieldData::None => None, - }; - if let Some(v) = new - && pdfcraft_forms::set_value(doc, &name, &v).is_ok() - { - changed.push(name); - } - } - Ok(changed) -} - /// Validate the signature fields of `cos` (written as `bytes`). 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 { - pub fn new() -> Self { - Self::default() - } - - /// Use a fixed clock (tests) instead of the system time. - pub fn with_clock(mut self, clock: fn() -> i64) -> Self { - self.clock = Some(clock); - self - } - - /// A real clock for a host that has neither a clock nor a time zone of its own (a - /// `wasm32-unknown-unknown` build: `now()` would be empty and `local_utc_offset()` is 0, so - /// Fill & Sign stamps 1 Jan 1970 and `/ModDate` is never written). `clock` is the UTC time in - /// seconds since the epoch; `utc_offset` is the local zone's seconds east of UTC, asked on every - /// use because the zone changes with the date (daylight saving). Unlike - /// [`Session::with_clock`], which is a test shortcut, nothing here is taken as local time. - pub fn with_wall_clock(mut self, clock: fn() -> i64, utc_offset: fn() -> i64) -> Self { - self.clock = Some(clock); - self.utc_offset = Some(utc_offset); - self - } - - /// Seconds to add to UTC for the local time of this session: the injected zone, else 0 under - /// the test clock (which is already local), else the system's. - fn local_offset(&self) -> i64 { - match (self.utc_offset, self.clock) { - (Some(zone), _) => zone(), - (None, Some(_)) => 0, - (None, None) => local_utc_offset(), - } - } - - /// Seconds since the Unix epoch from the session clock (0 when unknown). - pub fn now_secs(&self) -> i64 { - self.now().unwrap_or(0) - } - - fn now(&self) -> Option { - if let Some(c) = self.clock { - return Some(c()); - } - #[cfg(not(target_arch = "wasm32"))] - return std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_secs() as i64); - #[cfg(target_arch = "wasm32")] - return None; - } - - /// Today's date in local time: (year, month, day). With an injected clock (tests) the clock - /// is taken as local time. - /// A dynamic stamp's second line: "By Ada at 2:14 pm, Oct 02, 2026" (local time). - pub fn stamp_by_line(&self, author: &str) -> String { - let offset = self.local_offset(); - let d = self.now().map(|t| pdfcraft_cos::pdf_date(t + offset)).unwrap_or_default(); - let num = |a: usize, b: usize| d.get(a..b).and_then(|s| s.parse::().ok()).unwrap_or(0); - let (y, mo, day, hh, mm) = (num(2, 6), num(6, 8), num(8, 10), num(10, 12), num(12, 14)); - const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; - let h12 = if hh % 12 == 0 { 12 } else { hh % 12 }; - let who = if author.trim().is_empty() { String::new() } else { format!("By {} ", author.trim()) }; - format!("{who}at {h12}:{mm:02} {}, {} {day:02}, {y}", if hh < 12 { "am" } else { "pm" }, MONTHS[(mo.clamp(1, 12) - 1) as usize]) - } - - pub fn today(&self) -> (i64, u32, u32) { - let offset = self.local_offset(); - self.now().map(|t| pdfcraft_cos::pdf_date(t + offset)).as_deref().and_then(parse_ymd).unwrap_or((1970, 1, 1)) - } - - /// Open a document from bytes. Rendering starts lazily when pages are requested. - /// - /// `password` is tried as either the user or owner password when the file is encrypted. - /// Open saved revision `n` (1 = the oldest) of a document as a new, unsaved document named - /// " (revision n)". - pub fn open_revision(&mut self, id: DocId, n: usize) -> Result { - let doc = self.get(id).ok_or("no such document")?; - let ends = doc.revision_ends(); - let end = *n.checked_sub(1).and_then(|i| ends.get(i)).ok_or_else(|| format!("the document has {} revision(s)", ends.len()))?; - let name = format!("{} (revision {n}).pdf", doc.name.trim_end_matches(".pdf")); - let (bytes, password) = (Arc::new(doc.bytes[..end.min(doc.bytes.len())].to_vec()), doc.password.clone()); - self.open(name, None, bytes, password.as_deref()).map_err(|e| e.to_string()) - } - - /// Snapshot the session state that affects open-time scripts, dates and signature trust. - pub fn open_context(&self) -> OpenContext { - OpenContext { now: self.now(), js_off: self.js_off, trust: self.trust.clone() } - } - - /// Prepare a document without adding it to this session. - pub fn prepare_open( - &self, - name: impl Into, - path: Option, - bytes: Arc>, - password: Option<&str>, - ) -> Result { - self.open_context().prepare_open(name, path, bytes, password) - } - - /// Register a prepared document. This is the only open step that mutates the session. - pub fn commit_open(&mut self, prepared: PreparedDocument) -> DocId { - self.next_id += 1; - let id = DocId(self.next_id); - let mut document = prepared.document; - document.id = id; - self.docs.push(document); - id - } - - /// Synchronous convenience API used by the CLI and automation. - pub fn open(&mut self, name: impl Into, path: Option, bytes: Arc>, password: Option<&str>) -> Result { - let prepared = self.prepare_open(name, path, bytes, password)?; - Ok(self.commit_open(prepared)) - } - - fn prepare_open_unguarded( - context: &OpenContext, - name: String, - path: Option, - bytes: Arc>, - password: Option<&str>, - ) -> Result { - let cos = std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)); - // The renderer authenticates on its own. It cannot use the owner password of R2–R4 - // files, so give it the user password that owner authentication recovers. - let (info, render_password) = match inspect(bytes.clone(), password) { - Ok(info) => (info, password.map(str::to_owned)), - Err(OpenError::WrongPassword) => { - let user = match &cos { - Ok(Ok(d)) => recovered_render_password(d), - _ => None, - }; - let user = user.ok_or(OpenError::WrongPassword)?; - (inspect(bytes.clone(), Some(&user))?, Some(user)) - } - Err(e) => return Err(e), - }; - // A dynamic XFA form is a shell around an XML template; lay the template out into real - // pages and fields so the rest of the engine works on it. The original bytes stay: the - // laid-out form is one appended revision. - let opts = SaveOptions { mod_date: context.now.map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; - // Write `work` as one more revision and reopen it: what the document then is. - let rebase = |work: &pdfcraft_cos::Document| -> Result<(Arc>, DocInfo, pdfcraft_cos::Document), String> { - let new_bytes = write_incremental(work, &opts).map(Arc::new).map_err(|e| e.to_string())?; - let new_info = inspect(new_bytes.clone(), render_password.as_deref()).map_err(|e| e.to_string())?; - let new_cos = pdfcraft_cos::Document::open_with_password(new_bytes.clone(), password).map_err(|e| e.to_string())?; - Ok((new_bytes, new_info, new_cos)) - }; - let (bytes, info, cos, xfa) = match (info.xfa, cos) { - (Some(pdfcraft_render::Xfa::Dynamic), Ok(Ok(cos))) if pdfcraft_xfa::existing_layout(&cos).is_none() => { - let mut work = cos.clone(); - let laid_out = guard(|| xfa_layout(&mut work)) - .unwrap_or_else(|m| Err(format!("laying it out failed unexpectedly ({m})"))) - .and_then(|report| rebase(&work).map(|(b, i, c)| (b, i, c, report))); - match laid_out { - Ok((b, i, c, report)) => (b, i, Ok(Ok(c)), Some(report)), - Err(e) => { - let mut info = info; - info.warnings.push(format!("This dynamic XFA form could not be laid out: {e}")); - (bytes, info, Ok(Ok(cos)), None) - } - } - } - // A static XFA form, or a dynamic one laid out earlier: its datasets may hold values - // filled in by another viewer since; give the fields those values. - (Some(_), Ok(Ok(cos))) => { - let report = pdfcraft_xfa::existing_layout(&cos); - let mut work = cos.clone(); - let synced = - guard(|| xfa_values_from_datasets(&mut work)).unwrap_or_else(|m| Err(format!("reading its data failed unexpectedly ({m})"))); - match synced { - Ok(names) if names.is_empty() => (bytes, info, Ok(Ok(cos)), report), - Ok(names) => match rebase(&work) { - Ok((b, mut i, c)) => { - // The fields were rewritten from the XFA data: say so, and which. - let shown: Vec<&str> = names.iter().take(5).map(String::as_str).collect(); - let more = if names.len() > shown.len() { format!(" and {} more", names.len() - shown.len()) } else { String::new() }; - i.warnings.push(format!( - "{} form field value{} were taken from this form's XFA data (filled in by another viewer): {}{more}", - names.len(), - if names.len() == 1 { "" } else { "s" }, - shown.join(", ") - )); - (b, i, Ok(Ok(c)), report) - } - Err(e) => { - let mut info = info; - info.warnings.push(format!("The values in this form's XFA data could not be applied: {e}")); - (bytes, info, Ok(Ok(cos)), report) - } - }, - Err(e) => { - let mut info = info; - info.warnings.push(format!("The values in this form's XFA data could not be applied: {e}")); - (bytes, info, Ok(Ok(cos)), report) - } - } - } - (_, cos) => (bytes, info, cos, None), - }; - // A laid-out XFA form: its initialize and calculate scripts run now, as on opening in - // Acrobat; what they change is one more revision, still nothing to save. - let mut script_output = js::JsOutput::default(); - let mut ran_away = false; - let xfa_template = match (&xfa, &cos) { - (Some(_), Ok(Ok(c))) => xfa::template(c), - _ => None, - }; - let (bytes, info, cos) = match (&xfa, cos) { - (Some(_), Ok(Ok(cos))) if !context.js_off => match xfa_template.clone() { - Some(tpl) => { - let mut work = cos.clone(); - let pages = info.pages.len(); - let ran = guard(|| { - let mut run = xfa::XfaRun { page_count: pages, out: &mut script_output, datasets: None, ran_away: false }; - let changes = xfa::on_open(&mut work, &tpl, &mut run); - if run.ran_away { - ran_away = true; - } - changes - }) - .unwrap_or_else(|m| Err(format!("its scripts failed unexpectedly ({m})"))); - match ran { - Ok(changes) if work.is_modified() => match rebase(&work) { - Ok((b, mut i, c)) => { - // The file as saved differs from what opening shows: say so. - if !changes.is_empty() { - i.warnings.push(format!( - "This form's scripts changed it on opening ({}); saving keeps those changes", - changes.describe() - )); - } - (b, i, Ok(Ok(c))) - } - Err(e) => { - script_output.errors.push(format!("The form's scripts could not be applied: {e}")); - (bytes, info, Ok(Ok(cos))) - } - }, - Ok(_) => (bytes, info, Ok(Ok(cos))), - Err(e) => { - script_output.errors.push(format!("The form's scripts could not run: {e}")); - (bytes, info, Ok(Ok(cos))) - } - } - } - None => (bytes, info, Ok(Ok(cos))), - }, - (_, cos) => (bytes, info, cos), - }; - // The catalog leads to no page, but the reader found pages by scanning: gather the page - // objects into a page tree as one more revision, or page edits address none of them - // (#1656). Kept only when the reader then shows the same pages, and never on a signed - // document, where writing the repair could rewrite the file under its signatures. - let (bytes, info, cos) = match cos { - Ok(Ok(cos)) if !info.pages.is_empty() && pdfcraft_organize::page_count(&cos).unwrap_or(0) == 0 => { - let mut work = cos.clone(); - let signed = || pdfcraft_sign::pdf::list(&cos, &bytes, &context.trust).iter().any(|s| s.signed); - let rebuilt = match pdfcraft_organize::recover_page_tree(&mut work) { - Ok(n) if n > 0 && !signed() => rebase(&work).ok().filter(|(_, i, _)| same_pages(&i.pages, &info.pages)), - _ => None, - }; - match rebuilt { - Some((b, i, mut c)) => { - for line in cos.repair_log() { - c.note_repair(line.clone()); - } - c.note_repair(format!("the page tree is missing; its {} pages were found by scanning the file", i.pages.len())); - (b, i, Ok(Ok(c))) - } - None => (bytes, info, Ok(Ok(cos))), - } - } - cos => (bytes, info, cos), - }; - // XFA notes survive the document being re-read after edits. - let xfa_warnings: Vec = if info.xfa.is_some() { info.warnings.clone() } else { Vec::new() }; - let mut document = Self::build_document(&context.trust, name, path, bytes, info, cos, render_password, password, xfa); - // A script that ran away on open turns the form's scripts off for this document. - document.xfa_template = if ran_away { None } else { xfa_template }; - document.js_output.append(script_output); - if ran_away { - document.xfa_scripts_off = true; - document.js_output.errors.push(XFA_SCRIPTS_OFF.into()); - } - note_warnings(&mut document.xfa_warnings, &xfa_warnings); - Ok(PreparedDocument { document }) - } - - /// The last step of opening: build the document record and register it. - #[allow(clippy::too_many_arguments)] - fn build_document( - trust: &Arc, - name: String, - path: Option, - bytes: Arc>, - mut info: DocInfo, - cos: Result, Box>, - render_password: Option, - password: Option<&str>, - xfa: Option, - ) -> Document { - let config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() }; - let (mut editor, read_only_reason) = match cos { - Ok(Ok(cos)) => { - let keys = Keys { render: render_password.clone(), reopen: password.map(str::to_owned) }; - let saved = cos.bytes().clone(); - ( - Some(Editor { - 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, - ) - } - 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(); - // The inspection reads only `/Info`; a file stating its title or author only in XMP still - // shows them (#2373). - if let Some(e) = &editor { - for (field, value) in info_fields(&mut info, &e.cos) { - if field.is_none() { - *field = value; - } - } - } - let display = display_bytes(editor.as_mut(), &bytes); - let renderer = RenderPool::new(display.clone(), render_threads(), config.clone()); - let (form, adopted) = editor - .as_ref() - .map(|e| { - let mut fields = pdfcraft_forms::fields_with_adopted(&e.cos); - xfa::mark_script_buttons(&e.cos, &mut fields.items); - (fields.items, fields.adopted_page_fields) - }) - .unwrap_or_default(); - // Fields the file lists only as page widgets are adopted leniently; the file says so. - if adopted > 0 - && let Some(e) = &mut editor - { - let line = if adopted == 1 { - "the form's /Fields list omits 1 field; it was read from the page annotations".to_string() - } else { - format!("the form's /Fields list omits {adopted} fields; they were read from the page annotations") - }; - e.cos.note_repair(line); - } - Document { - id: DocId(0), - name, - path, - bytes, - display, - info, - renderer, - password: render_password, - dirty: false, - generation: 0, - snapshot_generation: 0, - read_only_reason, - form: Arc::new(form), - marker_scan: OnceLock::new(), - links: OnceLock::new(), - signatures: Arc::new(signatures), - trust: trust.clone(), - sig_cache, - editor, - config, - js_output: Default::default(), - xfa, - xfa_template: None, - xfa_scripts_off: false, - fonts_stale: false, - xfa_warnings: Vec::new(), - edit_notices: Vec::new(), - } - } - - fn doc_mut(&mut self, id: DocId) -> Result<&mut Document, EditError> { - self.docs.iter_mut().find(|d| d.id == id).ok_or(EditError::NoDocument) - } - - /// Apply an edit. On success the previous state is undoable and the view data is refreshed. - pub fn apply(&mut self, id: DocId, edit: Edit) -> Result<(), EditError> { - let now = self.now(); - let today = self.today(); - let js_off = self.js_off; - let doc = self.doc_mut(id)?; - 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(); - } - cx.pages = doc.info.pages.len(); - cx.today = today; - let reason = doc.read_only_reason.clone().unwrap_or_default(); - let signed = doc.is_signed(); - let editor = doc.editor.as_mut().ok_or(EditError::ReadOnly(reason))?; - if let Some(p) = editor.cos.permissions() { - check_permission(&edit, &p)?; - } - // Rewriting an OCR word (invisible text over a scan) also hides the scanned word: cover it - // in the paper's colour and draw the new text in the ink's. - let edit = match edit { - Edit::EditTextBlock { page, block, text, mut style } => { - // Only invisible text is sampled; the blocks are usually cached from opening it. - if style.cover.is_none() - && let Some(info) = doc.info.pages.get(page) - && let Some(b) = editor.text_blocks(page).ok().and_then(|b| b.get(block).filter(|b| b.invisible).cloned()) - && let Some((paper, ink)) = guard(|| scan_colours::sample(&editor.cos, info, page, &b)).ok().flatten() - { - style.cover = Some(paper); - style.color = style.color.or(Some(ink)); - } - Edit::EditTextBlock { page, block, text, style } - } - other => other, - }; - // Nothing to bake: leave undo history and the dirty flag alone. - if matches!(&edit, Edit::FlattenFillSign) && !pdfcraft_annot::has_visible_fill_sign(&editor.cos) { - return Ok(()); - } - let mut next = editor.cos.clone(); - if !js_off && uses_scripts(&edit) { - cx.js = Some(js::JsRunner::new(&next, &name)); - } - cx.renamed_fields.clear(); - cx.redaction_overlays.clear(); - // `next` is a copy: if the edit fails or crashes, the document is unchanged. - guard(|| run_edit(&mut next, &edit, &mut cx)) - .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?; - // Redaction removed what its own interpreter places under the marks. The marks were drawn - // over the page as the renderer shows it, so the renderer has the last word. - if let Edit::ApplyRedactions { pages } = &edit { - let marks: Vec = - pdfcraft_redact::marks(&editor.cos).into_iter().filter(|m| pages.as_ref().is_none_or(|p| p.contains(&m.page))).collect(); - guard(|| shown_under_redactions(&next, &marks, &cx.redaction_overlays, &doc.info.pages, doc.password.as_deref())) - .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?; - } - // Renamed fields are said, not slipped in: a repair-log note (with the caveat that - // scripts were left alone) and a count in the undo step's label. - let renamed_note = |renamed: &[(String, String)]| { - (!renamed.is_empty()).then(|| { - let shown: Vec = renamed.iter().take(5).map(|(old, new)| format!("{old} → {new}")).collect(); - let more = renamed.len().saturating_sub(shown.len()); - let more = if more > 0 { format!(", and {more} more") } else { String::new() }; - format!( - "inserted pages renamed {} form field{} to keep their names separate ({}{more}); scripts that refer to the old names were not rewritten", - renamed.len(), - if renamed.len() == 1 { "" } else { "s" }, - shown.join(", ") - ) - }) - }; - if let Some(note) = renamed_note(&cx.renamed_fields) { - next.note_repair(note); - } - // Only Insert from File renames fields; its label says so in a wording the interface - // translates (the count lives in the repair note). - let label = match (&edit, cx.renamed_fields.len()) { - (_, 0) => edit.label(), - (Edit::InsertPagesFrom { name, .. }, n) => Label::with( - if n == 1 { "Insert pages from {name} — 1 field renamed" } else { "Insert pages from {name} — fields renamed" }, - name.as_str(), - ), - _ => edit.label(), - }; - // An XFA form's scripts answer the change: exit and validate scripts of the field, then - // every calculation (and a reset recalculates). They read the data, so it is brought up - // to date first. - if let Some(tpl) = cx.xfa.clone() { - if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full | Scope::Content) { - let datasets = &mut cx.xfa_datasets; - let notes = guard(|| xfa_sync_datasets(&mut next, Some(&tpl), datasets)) - .unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})"))) - .map_err(EditError::Write)?; - cx.xfa_out.errors.extend(notes); - } - fn collect(e: &Edit, changed: &mut Vec, reset: &mut bool) { - match e { - Edit::SetFieldValue { name, .. } => changed.push(name.clone()), - Edit::ResetForm { .. } => *reset = true, - Edit::Batch { edits, .. } => edits.iter().for_each(|e| collect(e, changed, reset)), - _ => {} - } - } - let mut changed: Vec = Vec::new(); - let mut reset = false; - collect(&edit, &mut changed, &mut reset); - if !changed.is_empty() || reset { - let mut run = xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false }; - // Every changed field's scripts, then the calculations once (after a reset, - // only those). - let ran = guard(|| xfa::on_changes(&mut next, &tpl, &changed, &mut run)) - .unwrap_or_else(|m| Err(format!("the form's scripts failed unexpectedly ({m})"))); - cx.xfa_datasets = run.datasets; - cx.xfa_ran_away |= run.ran_away; - if let Err(e) = ran { - cx.xfa_out.errors.push(e); - } - } - } - // XFA forms keep their values in the datasets packet too, for Adobe's viewers. - let mut xfa_notes = Vec::new(); - if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full | Scope::Content) { - let (tpl, datasets) = (cx.xfa.as_deref(), &mut cx.xfa_datasets); - 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. - // So does any edit to a file rebuilt from object headers: it has no chain to append to. - let rewrites = |c: &pdfcraft_cos::Document| c.full_save_required() || c.encryption_changed(); - if signed && ((rewrites(&next) && !rewrites(&editor.cos)) || (next.is_modified() && next.revisions().is_empty())) { - return Err(EditError::SignedRewrite(edit.label().to_string())); - } - // What this edit drew with `?` (a value or comment text its font can't draw, stored as - // typed): reported, never silent (AGENTS.md §4). - let mut notices: Vec = if matches!(scope_of(&edit), Scope::Form | Scope::Comments | Scope::Full) { - guard(|| undrawn_notices(&editor.cos, &next)).unwrap_or_default().into_iter().map(EditNotice::Undrawn).collect() - } else { - Vec::new() - }; - // Pages whose own white backdrop Remove background couldn't turn back on: said, not silent. - if matches!(edit, Edit::RemoveMarks { kind: MarkKind::Background }) { - let pages = guard(|| pdfcraft_edit::backdrops_left_off(&next)).unwrap_or_default(); - if !pages.is_empty() { - notices.push(EditNotice::BackdropsLeftOff { pages }); - } - } - 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); - editor.undo.push((label, previous, previous_keys, scope)); - if editor.undo.len() > MAX_UNDO { - editor.undo.remove(0); - } - editor.redo.clear(); - Self::adopt_keys(doc); - let refreshed = guard(|| if scope == Scope::Content { Self::refresh_content(doc, Some(&edit)) } else { Self::refresh_scoped(doc, scope) }) - .unwrap_or_else(|m| Err(EditError::Reopen(m))); - if let Err(e) = refreshed { - // Roll back: the edit produced something we cannot display. - if let Some(ed) = doc.editor.as_mut() - && let Some((_, prev, keys, _)) = ed.undo.pop() - { - ed.cos = prev; - ed.keys = keys; - } - Self::adopt_keys(doc); - if !matches!(guard(|| Self::refresh(doc)), Ok(Ok(()))) { - Self::show_released(doc); - } - return Err(e); - } - doc.dirty = true; - doc.generation += 1; - doc.edit_notices = notices; - if !cx.xfa_removed.is_empty() { - let removed: Vec = cx.xfa_removed.iter().map(|n| format!("{XFA_REMOVED_PREFIX}{n}")).collect(); - doc.xfa_warnings.retain(|w| w.starts_with(XFA_REMOVED_PREFIX)); - note_warnings(&mut doc.xfa_warnings, &removed); - note_warnings(&mut doc.info.warnings, &removed); - } - note_warnings(&mut doc.xfa_warnings, &xfa_notes); - let notes = doc.xfa_warnings.clone(); - note_warnings(&mut doc.info.warnings, ¬es); - if let Some(js) = cx.js { - doc.js_output.append(js.output); - } - doc.js_output.append(cx.xfa_out); - if cx.xfa_ran_away { - doc.xfa_template = None; - doc.xfa_scripts_off = true; - doc.js_output.errors.push(XFA_SCRIPTS_OFF.into()); - } - Ok(()) - } - - pub fn undo(&mut self, id: DocId) -> Result { - let doc = self.doc_mut(id)?; - 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); - 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) - } - - pub fn redo(&mut self, id: DocId) -> Result { - let doc = self.doc_mut(id)?; - 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); - 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) - } - - /// Unsaved when the working graph is not the last opened or saved file. Undo back to that - /// 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.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). - fn adopt_keys(doc: &mut Document) { - if let Some(e) = doc.editor.as_ref() { - doc.password = e.keys.render.clone(); - doc.config.password = e.keys.render.as_deref().map(Arc::from); - } - } - - /// Rebuild the working bytes and the view data that `scope` may have changed. Metadata edits - /// keep the renderer; other scopes also replace it. - /// Comment and form edits skip the full re-inspection (seconds on very large files): the - /// comment list and field values are re-read from the object graph instead. - fn refresh_scoped(doc: &mut Document, scope: Scope) -> Result<(), EditError> { - if scope == Scope::Full || doc.info.encrypted != doc.editor.as_ref().is_some_and(|e| e.cos.output_handler().is_some()) { - return Self::refresh(doc); - } - if scope == Scope::Content { - return Self::refresh_content(doc, None); - } - if scope == Scope::Links { - return Self::refresh_links(doc); - } - // Metadata edits keep the renderer, which holds the working file anyway. - if scope != Scope::Metadata { - Self::release_working(doc); - } - let Some(editor) = doc.editor.as_mut() else { return Ok(()) }; - // Comment edits, and form edits (a script may change annotation properties), may change - // annotation dictionaries, invalidating cached appearances. - if matches!(scope, Scope::Comments | Scope::Form) { - editor.appearances.clear(); - } - let bytes = if editor.cos.is_modified() { - Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?) - } else { - editor.cos.bytes().clone() - }; - if scope == Scope::Metadata { - for (field, value) in info_fields(&mut doc.info, &editor.cos) { - *field = value; - } - doc.info.custom = pdfcraft_organize::custom_info(&editor.cos); - doc.info.file_size = bytes.len(); - #[cfg(test)] - check_refresh_panic(); - if !doc.signatures.is_empty() { - let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); - doc.signatures = Arc::new(signatures); - } - doc.bytes = bytes; - return Ok(()); - } - let mut form = pdfcraft_forms::fields(&editor.cos); - xfa::mark_script_buttons(&editor.cos, &mut form); - let form = Arc::new(form); - match scope { - Scope::Metadata => return Ok(()), - Scope::Comments => { - doc.info.annotations = comment_list(&editor.cos); - // Link indexes are positions in /Annots, so comment insertion/deletion can move them. - doc.links = OnceLock::new(); - } - Scope::Form => { - // PERF: keystroke path, index once. A linear find per field is O(F^2); - // forms reach 10k fields. - let mut by_name = std::collections::HashMap::with_capacity(form.len()); - for x in form.iter() { - // First match wins (do not simplify to collect: it keeps the last). - by_name.entry(x.name.as_str()).or_insert(x); - } - for f in &mut doc.info.fields { - if let Some(ff) = by_name.get(f.name.as_str()) { - f.value = match ff.kind { - pdfcraft_forms::FieldKind::CheckBox | pdfcraft_forms::FieldKind::Radio => { - Some(ff.value.first().cloned().unwrap_or_else(|| "Off".into())) - } - _ if ff.value.is_empty() => None, - _ => Some(ff.value.join(", ")), - }; - } - } - } - Scope::Links => return Self::refresh_links(doc), - // Handled above; kept as a full refresh so the match stays total. - Scope::Full => return Self::refresh(doc), - // Handled above (content-only refresh); the arm keeps the match total. - Scope::Content => return Self::refresh_content(doc, None), - } - doc.info.file_size = bytes.len(); - doc.form = form; - 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(()) - } - - /// Which derived lists a content-only edit can change: added content (`None`, from - /// undo/redo, re-reads both: always correct, rarely paid). - fn content_refresh_lists(edit: Option<&Edit>) -> (bool, bool) { - if let Some(Edit::Batch { edits, .. }) = edit { - // A batch of content edits (from a script, say) changes what its parts change. - return edits.iter().fold((false, false), |(added, marks), part| { - let (a, m) = Self::content_refresh_lists(Some(part)); - (added || a, marks || m) - }); - } - let touches_added = edit.is_none_or(|e| { - matches!( - e, - Edit::AddText { .. } - | Edit::AddImage { .. } - | Edit::UpdateContent { .. } - | Edit::DeleteContent { .. } - | Edit::ReplaceImage { .. } - | Edit::PasteContent { .. } - ) || matches!(e, Edit::MoveObjects { objects, .. } if objects.iter().any(|o| o.kind == ObjectKind::Added)) - }); - let touches_marks = edit.is_none_or(|e| { - matches!(e, Edit::AddHeaderFooter { .. } | Edit::AddWatermark { .. } | Edit::AddBackground { .. } | Edit::RemoveMarks { .. }) - }); - (touches_added, touches_marks) - } - - /// Rebuild working bytes, display bytes and renderer after a content-only edit: page - /// content streams, resources and added content/marks. The page tree, annotations, form - /// fields, links, outline and fonts are unchanged, so the full re-inspection (seconds on - /// very large files) is skipped. `edit` selects which derived lists are re-read (`None`, - /// from undo/redo, re-reads both: always correct, rarely paid). The cached display - /// appearances are kept: content edits leave every annotation dictionary alone. - fn refresh_content(doc: &mut Document, edit: Option<&Edit>) -> Result<(), EditError> { - Self::release_working(doc); - sync_xfa_state(doc); - let Some(editor) = doc.editor.as_mut() else { return Ok(()) }; - let bytes = if editor.cos.is_modified() { - Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?) - } else { - editor.cos.bytes().clone() - }; - // The edit must still leave a file the viewer can open; anything else rolls back above. - pdfcraft_render::check_pages(bytes.clone(), doc.password.as_deref(), doc.info.pages.len()).map_err(|e| EditError::Reopen(e.to_string()))?; - let (touches_added, touches_marks) = Self::content_refresh_lists(edit); - if touches_added || touches_marks { - doc.marker_scan = OnceLock::new(); - } - doc.info.file_size = bytes.len(); - 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; - Ok(()) - } - - /// Let go of the working file before the next one is written (gap 13). The working file is - /// the original plus one appended revision, so on a 1 GiB file keeping the old one (in the - /// working bytes, the display bytes and the renderer) while writing the new one held three - /// copies at once; this keeps two. Until the refresh finishes, the working and display bytes - /// are the original's (already held by the editor, so free) and the renderer draws nothing. - /// A failed edit rolls back and refreshes again, which replaces all three; where a refresh - /// still fails, [`Self::show_released`] draws the original rather than nothing. Save writes - /// from the editor, never from these. - fn release_working(doc: &mut Document) { - let Some(original) = doc.editor.as_ref().map(|e| e.cos.bytes().clone()) else { return }; - // Dropping the pool stops its workers; each lets go of the file when it exits. - 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), - /// so the page still shows a document. - fn show_released(doc: &mut Document) { - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - } - - /// Rebuild working bytes, inspection and renderer from the current edit state. - fn refresh(doc: &mut Document) -> Result<(), EditError> { - Self::refresh_with_indexes(doc, true, true) - } - - /// Reinspect a link edit while preserving the page-content marker scan cache. - fn refresh_links(doc: &mut Document) -> Result<(), EditError> { - Self::refresh_with_indexes(doc, false, true) - } - - fn refresh_with_indexes(doc: &mut Document, invalidate_markers: bool, invalidate_links: bool) -> Result<(), EditError> { - // A full refresh may follow structural or annotation changes: cached appearances - // may be stale, so they are rebuilt below. - if let Some(editor) = doc.editor.as_mut() { - editor.appearances.clear(); - } - Self::release_working(doc); - sync_xfa_state(doc); - let Some(editor) = doc.editor.as_mut() else { return Ok(()) }; - let bytes = if editor.cos.is_modified() { - Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?) - } else { - editor.cos.bytes().clone() - }; - let info = inspect(bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; - // Keep the user's layer choices where the layers still exist. - let mut info = info; - for l in &mut info.layers { - if let Some(old) = doc.info.layers.iter().find(|o| o.id == l.id) { - l.visible = old.visible; - } - } - note_warnings(&mut info.warnings, &doc.xfa_warnings); - // As in `build_document`: the inspection reads only `/Info`, so fields stated only in XMP - // are filled in again after every full refresh. - for (field, value) in info_fields(&mut info, &editor.cos) { - if field.is_none() { - *field = value; - } - } - doc.info = info; - doc.fonts_stale = false; - let mut form = pdfcraft_forms::fields(&editor.cos); - xfa::mark_script_buttons(&editor.cos, &mut form); - doc.form = Arc::new(form); - if invalidate_markers { - doc.marker_scan = OnceLock::new(); - } - if invalidate_links { - doc.links = OnceLock::new(); - } - 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(()) - } - - /// 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 && (!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() }; - // A restored snapshot can be based on an earlier revision. Append to the last saved - // file so that revision stays in the chain. A full rewrite (redaction, new encryption, - // a reconstructed file) still writes the current graph on its own. - let rewrite = editor.cos.full_save_required() || editor.cos.encryption_changed() || editor.cos.revisions().is_empty(); - let cos = writable(&editor.cos)?; - guard(|| { - if on_saved || rewrite { - write_incremental(&cos, &opts) - } else { - let mut base = pdfcraft_cos::Document::open_with_password(editor.saved.clone(), editor.saved_keys.reopen.as_deref())?; - base.overlay_changes_from(&cos)?; - write_incremental(&base, &opts) - } - }) - .map_err(EditError::Write)? - .map(Arc::new) - .map_err(|e| EditError::Write(e.to_string())) - } - - /// A compact, garbage-collected rewrite (Save As ▸ "Optimized" / Reduce File Size groundwork). - pub fn save_full_bytes(&self, id: DocId) -> Result>, EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - if doc.is_signed() { - return Err(EditError::Signed); - } - let editor = doc.editor.as_ref().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; - let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; - let cos = writable(&editor.cos)?; - guard(|| write_full(&cos, &opts)).map_err(EditError::Write)?.map(Arc::new).map_err(|e| EditError::Write(e.to_string())) - } - - /// Check that `bytes`, from [`Self::save_bytes`] or [`Self::save_full_bytes`], reopen as this - /// document before they are written (#1698): [`Self::mark_saved`] only finds out after the - /// file has been replaced. Same error as `mark_saved`. - pub fn check_save(&self, id: DocId, bytes: &Arc>) -> Result<(), EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - let Some(editor) = doc.editor.as_ref() else { return Ok(()) }; - pdfcraft_cos::Document::open_with_password(bytes.clone(), editor.keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; - pdfcraft_render::check_pages(bytes.clone(), doc.password.as_deref(), doc.info.pages.len()).map_err(|e| EditError::Reopen(e.to_string())) - } - - /// Record a successful save of `bytes` (to `path`, if any): rebase editing on the saved file - /// so the next save appends only newer edits. Undo history is kept. - pub fn mark_saved(&mut self, id: DocId, bytes: Arc>, path: Option) -> Result<(), EditError> { - let doc = self.doc_mut(id)?; - if let Some(editor) = doc.editor.as_mut() { - // An encrypted file needs the password it is protected with now (the owner - // password when known, so saving never downgrades this session's rights). - let keys = editor.keys.clone(); - // The saved file carries no Password-field values (#3943); the open document keeps them. - let typed = pdfcraft_forms::password_values(&editor.cos); - editor.cos = - pdfcraft_cos::Document::open_with_password(bytes.clone(), keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; - pdfcraft_forms::restore_password_values(&mut editor.cos, &typed).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()); - doc.path = Some(p); - } - doc.dirty = false; - doc.generation += 1; - Self::refresh(doc).inspect_err(|_| Self::show_released(doc)) - } - - /// Enter the permissions (owner) password of a document opened with its user password, - /// lifting its restrictions for this session (Acrobat asks for it before changing security). - /// Not an edit: nothing is marked unsaved and there is no undo step. Undo and redo states - /// are unlocked as well, and saving keeps the rights. Unencrypted documents and documents - /// opened by the owner need nothing. - pub fn unlock(&mut self, id: DocId, password: &str) -> Result<(), EditError> { - let doc = self.doc_mut(id)?; - let editor = doc.editor.as_mut().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; - if editor.cos.permissions().is_none_or(|p| p.owner) { - return Ok(()); - } - editor.cos.unlock(password).map_err(|e| match e { - pdfcraft_cos::CosError::WrongPassword => EditError::WrongPassword, - other => EditError::Protection(other.to_string()), - })?; - // Saving a state that keeps the security it was opened with reopens it with the same - // passwords, so it reopens as owner. - let reopen_as_owner = |cos: &pdfcraft_cos::Document, keys: &mut Keys| { - if !cos.encryption_changed() { - keys.reopen = Some(password.to_owned()); - } - }; - reopen_as_owner(&editor.cos, &mut editor.keys); - // Other states with the same restricted access are unlocked too; states opened with - // other passwords (protection changed and saved since) stay as they are. - for (_, cos, keys, _) in editor.undo.iter_mut().chain(editor.redo.iter_mut()) { - if cos.permissions().is_some_and(|p| !p.owner) && cos.unlock(password).is_ok() { - reopen_as_owner(cos, keys); - } - } - Ok(()) - } - - /// File ▸ Revert: back to the last saved version (or the file as opened). Undo history is - /// cleared, as in Acrobat. - pub fn revert(&mut self, id: DocId) -> Result<(), EditError> { - let doc = self.doc_mut(id)?; - let editor = doc.editor.as_mut().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; - let base = editor.saved.clone(); - // The saved file opens with the passwords it was saved with. The current snapshot may - // be an older revision (undo past that save), so its own base is not the saved file. - let mut candidates = vec![editor.saved_keys.clone(), editor.keys.clone()]; - candidates.extend(editor.undo.iter().rev().map(|s| s.2.clone())); - candidates.push(Keys::default()); - let (cos, keys) = candidates - .into_iter() - .find_map(|k| pdfcraft_cos::Document::open_with_password(base.clone(), k.reopen.as_deref()).ok().map(|c| (c, k))) - .ok_or_else(|| EditError::Reopen("the saved file can't be opened".into()))?; - editor.cos = cos; - editor.keys = keys; - editor.undo.clear(); - editor.redo.clear(); - Self::adopt_keys(doc); - Self::sync_dirty(doc); - doc.generation += 1; - Self::refresh(doc).inspect_err(|_| Self::show_released(doc)) - } - - /// The document's file changed on disk (a LaTeX or Typst build rewrote it, #431): open - /// `bytes`, the file as it is now, in its place. Everything opening does runs again (XFA - /// layout and scripts, signatures, repairs), but the document keeps its id, name and path, - /// so its tab and view stay with it. Undo history goes, as with Revert. The password it was - /// opened with is tried; on any failure the document is left as it was. - pub fn reload(&mut self, id: DocId, bytes: Arc>) -> Result<(), OpenError> { - let old = self.docs.iter().position(|d| d.id == id).ok_or_else(|| OpenError::Invalid("the document is not open".into()))?; - let doc = &self.docs[old]; - let (name, path, generation) = (doc.name.clone(), doc.path.clone(), doc.generation); - let password = doc.editor.as_ref().and_then(|e| e.keys.reopen.clone()).or_else(|| doc.password.clone()); - let new_id = self.open(name, path, bytes, password.as_deref())?; - 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; - doc.snapshot_generation = doc.generation; - // `open` pushed the new version last, so the old one is still at `old`. - *self.docs.get_mut(old).ok_or_else(|| OpenError::Invalid("the document is not open".into()))? = doc; - Ok(()) - } - - /// The page count of another PDF (Replace Pages, Insert Pages dialogs). - pub fn page_count_of(&self, name: &str, bytes: &Arc>) -> Result { - Ok(pdfcraft_organize::page_count(&open_source(name, bytes)?)?) - } - - /// A new blank document (Create ▸ Blank page). - pub fn create_blank(&self, width: f64, height: f64, pages: usize) -> Result>, EditError> { - self.write_new(&pdfcraft_create::blank(width, height, pages)?) - } - - /// A new document with one page per image (PNG, JPEG). - pub fn create_from_images(&self, images: &[(String, Vec)]) -> Result>, EditError> { - self.write_new(&pdfcraft_create::from_images(images)?) - } - - /// Create image pages at embedded resolution or a fixed dpi, without resampling. - pub fn create_from_images_with_resolution(&self, images: &[(String, Vec)], resolution: ImageResolution) -> Result>, EditError> { - self.write_new(&pdfcraft_create::from_images_with_resolution(images, resolution)?) - } - - /// A new document from plain text (US Letter, 11 pt Helvetica). Text Helvetica can't draw is - /// refused, naming the line and the character (never set as `?`). - pub fn create_from_text(&self, title: &str, text: &str) -> Result>, EditError> { - self.write_new(&pdfcraft_create::from_text(title, text, pdfcraft_create::LETTER, 11.0)?) - } - - /// [`Self::create_from_text`] for a report PdfCraft writes from documents (comment summaries, - /// compare reports), which can quote any language: characters Helvetica can't draw are written - /// as `[U+XXXX]`, and the report's first line says so, rather than the report being refused. - fn create_report(&self, title: &str, text: &str) -> Result>, EditError> { - let (doc, _escaped) = pdfcraft_create::from_text_escaped(title, text, pdfcraft_create::LETTER, 11.0)?; - self.write_new(&doc) - } - - /// Convert a file Create understands (an image or plain text) to PDF bytes; a PDF is checked - /// (it must open and allow copying pages) and returned as it is. - pub fn convert_to_pdf(&self, name: &str, bytes: &Arc>) -> Result<(SourceKind, Arc>), EditError> { - let Some(kind) = source_kind(name, bytes) else { - return Err(EditError::Source(format!("{name}: this file type can't be converted; use a PDF, an image or a .txt file"))); - }; - let title = name.rsplit_once('.').map_or(name, |(s, _)| s); - // Image decoders read untrusted bytes: a panic in one must not take the app down. - let created = guard(|| match kind { - SourceKind::Pdf => open_source(name, bytes).map(|_| bytes.clone()), - SourceKind::Image => self.create_from_images(&[(name.to_string(), bytes.to_vec())]), - SourceKind::Text => self.create_from_text(title, &decode_text(bytes)), - }) - .map_err(|_| EditError::Source(format!("{name}: the file could not be read")))?; - Ok((kind, created?)) - } - - /// A file for Combine as PDF bytes: an image or text file is converted; a PDF is returned as - /// it is, unchecked (Combine reads it itself, with its password), as is a file named .pdf - /// without a `%PDF-` header, which the reader may still open. - pub fn as_pdf(&self, name: &str, bytes: &Arc>) -> Result>, EditError> { - let pdf = match source_kind(name, bytes) { - Some(SourceKind::Pdf) => true, - Some(SourceKind::Image | SourceKind::Text) => false, - None => name.to_ascii_lowercase().ends_with(".pdf"), - }; - if pdf { Ok(bytes.clone()) } else { self.convert_to_pdf(name, bytes).map(|(_, pdf)| pdf) } - } - - /// Reduce File Size: Acrobat's defaults (images above 225 ppi to 150 ppi, JPEG medium - /// quality; thumbnails dropped), identical resources merged, unused objects dropped, objects - /// packed into compressed object streams. Returns the bytes and how many objects were - /// merged. The open document is not changed (Acrobat saves the reduced copy as a new file). - pub fn reduced_bytes(&self, id: DocId) -> Result<(Arc>, usize), EditError> { - let (bytes, report) = self.optimized_bytes(id, &optimize::Settings::default(), &[])?; - Ok((bytes, report.merged)) - } - - /// Optimize PDF ▸ Advanced optimization: `settings` for images and objects, plus Remove - /// Hidden Information's `discard` categories (user data). A full rewrite: signed documents - /// are refused. The open document is not changed. This is [`Self::optimize_job`] run in - /// place, without progress. - pub fn optimized_bytes(&self, id: DocId, settings: &optimize::Settings, discard: &[Hidden]) -> Result<(Arc>, OptimizeReport), EditError> { - self.optimize_job(id, settings, discard)?.run(|_| true) - } - - /// Export comments and/or form data: XFDF and FDF carry either or both; XML, CSV and text - /// are form data only. - pub fn export_data(&self, id: DocId, format: DataFormat, comments: bool, fields: bool) -> Result, EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - let 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()))? - } - }; - // An XFA form's data is its datasets, as Acrobat exports it: XML or an XDP. - let xfa = doc.info.xfa.is_some(); - if format == DataFormat::Xdp && !xfa { - return Err(EditError::Invalid("XDP holds XFA form data; this document is not an XFA form (use .xml, .csv or .txt)".into())); - } - guard(|| match format { - DataFormat::Xfdf => Ok(pdfcraft_xfdf::export_xfdf(&cos, comments, fields, &doc.name).into_bytes()), - 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()) - } - other => Ok(pdfcraft_xfdf::export_data(&cos, other).into_bytes()), - }) - .map_err(EditError::Write)? - .map_err(EditError::Write) - } - - /// The print-ready PDF for `settings` (sheets laid out for the paper; see `pdfcraft-print`). - pub fn print_pdf(&self, id: DocId, settings: &print::Settings) -> Result, EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - if !doc.allows_printing() { - return Err(EditError::NotPermitted("printing")); - } - // Table 22, bit 12 clear: only a low-level representation may be printed, so the pages - // always go as pictures, at no more than the low resolution. - let low_res = doc.permissions().is_some_and(|p| !p.print_high_quality()).then(|| print::Raster { - dpi: settings.raster.map_or(LOW_RESOLUTION_PRINT_DPI, |r| r.dpi.min(LOW_RESOLUTION_PRINT_DPI)), - grayscale: settings.raster.is_some_and(|r| r.grayscale), - }); - // Print as image: the pages used become pictures first, then the picture document is - // imposed in their place (its page k is the k-th distinct source page used). - if let Some(raster) = low_res.as_ref().or(settings.raster.as_ref()) { - let mut distinct: Vec = Vec::new(); - for &p in &settings.pages { - if !distinct.contains(&p) { - distinct.push(p); - } - } - let bytes = print_raster::rasterize(doc, &distinct, raster, settings.content).map_err(EditError::Print)?; - let pictures = pdfcraft_cos::Document::open(Arc::new(bytes)).map_err(|e| EditError::Print(e.to_string()))?; - let remapped = print::Settings { - pages: settings.pages.iter().map(|p| distinct.iter().position(|d| d == p).unwrap_or(0)).collect(), - content: print::Content::Document, - raster: None, - ..settings.clone() - }; - return pdfcraft_print::impose(&pictures, &remapped).map_err(|e| EditError::Print(e.to_string())); - } - 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())) - } - - /// For each page, whether its text mostly runs bottom-to-top ([`pdfcraft_print::text_reads_up`]); - /// only `pages` are read, the others are `false`. - pub fn print_text_up(&self, id: DocId, pages: &[usize]) -> Vec { - let Some(doc) = self.get(id) else { return Vec::new() }; - let cos = match doc.editor.as_ref() { - Some(e) => e.cos.clone(), - None => match pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()) { - Ok(c) => c, - Err(_) => return Vec::new(), - }, - }; - (0..doc.info.pages.len()).map(|i| pages.contains(&i) && pdfcraft_print::text_reads_up(&cos, i)).collect() - } - - /// Combine whole files, in order, into new PDF bytes (one bookmark per file). - pub fn combine(&self, sources: &[(String, Arc>)]) -> Result>, EditError> { - let docs = sources.iter().map(|(n, b)| open_source(n, b)).collect::, _>>()?; - let named: Vec<(&str, &pdfcraft_cos::Document)> = sources.iter().map(|(n, _)| n.as_str()).zip(docs.iter()).collect(); - let out = pdfcraft_organize::combine(&named)?; - self.write_new(&out) - } - - /// Combine Files with a page range per file ("1-3, 6"; `None` or empty for all pages). - pub fn combine_ranges(&self, sources: &[CombineSource]) -> Result>, EditError> { - self.combine_unlocked(sources, &[]) - } - - /// Combine sources one at a time, in order (Create ▸ Multiple files), to the same result as - /// [`Self::combine_ranges`]. Each item is a source's bookmark title, its PDF bytes (see - /// [`Self::convert_to_pdf`]) and its page range (`None` for all pages). Items are pulled one at a - /// time: a source is opened, its pages are copied into the result, and its bytes are released - /// before the next item is read. So the number of sources is not limited, and one source's - /// document is held at a time. An item's error stops the combine and is returned as it is. - /// `Ok(None)` when there are no sources. - pub fn combine_each(&self, sources: I) -> Result>>, E> - where - I: IntoIterator>, - E: From, - { - let mut combiner = pdfcraft_organize::Combiner::new(); - let mut added = false; - for source in sources { - let (title, bytes, range) = source?; - let doc = open_source(&title, &bytes)?; - let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty()); - let pages = match range { - Some(r) => { - let n = pdfcraft_organize::page_count(&doc).map_err(EditError::from)?; - let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false) - .map_err(|e| EditError::Print(format!("{title}: {e}")))?; - Some(p) - } - None => None, - }; - combiner.add(&title, &doc, pages.as_deref()).map_err(EditError::from)?; - added = true; - } - if !added { - return Ok(None); - } - let out = combiner.finish().map_err(EditError::from)?; - Ok(Some(self.write_new(&out)?)) - } - - /// [`Self::combine_ranges`] with the password each encrypted source is opened with, by - /// position (missing or `None`: no password). The result is not encrypted. - pub fn combine_unlocked(&self, sources: &[CombineSource], passwords: &[Option<&str>]) -> Result>, EditError> { - Ok(self.combine_unlocked_reporting(sources, passwords, pdfcraft_organize::FieldConflicts::Rename)?.bytes) - } - - /// The pre-flight over byte sources: what combining them, in order, would rename in each one - /// (whole files; page ranges are the combine's to apply). A source that cannot be opened - /// previews as nothing — its row already says why it cannot be combined. - pub fn combine_field_conflicts(&self, sources: &[CombineSource], passwords: &[Option<&str>]) -> Vec> { - let docs: Vec> = - sources.iter().enumerate().map(|(i, (n, b, _))| open_source_with(n, b, passwords.get(i).copied().flatten()).ok()).collect(); - let refs: Vec<&pdfcraft_cos::Document> = docs.iter().filter_map(Option::as_ref).collect(); - let mut opened = pdfcraft_organize::combine_field_conflicts(&refs).into_iter(); - // One preview per source, in step with it: a file that cannot be opened has none. - docs.iter().map(|d| if d.is_some() { opened.next().unwrap_or_default() } else { Default::default() }).collect() - } - - /// [`Self::combine_unlocked`] reporting what the conflict resolution renamed. - pub fn combine_unlocked_reporting( - &self, - sources: &[CombineSource], - passwords: &[Option<&str>], - field_conflicts: pdfcraft_organize::FieldConflicts, - ) -> Result { - let docs = sources - .iter() - .enumerate() - .map(|(i, (n, b, _))| open_source_with(n, b, passwords.get(i).copied().flatten())) - .collect::, _>>()?; - let mut pages = Vec::with_capacity(docs.len()); - for ((name, _, range), d) in sources.iter().zip(&docs) { - let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty()); - pages.push(match range { - Some(r) => { - let n = pdfcraft_organize::page_count(d)?; - let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false) - .map_err(|e| EditError::Print(format!("{name}: {e}")))?; - Some(p) - } - None => None, - }); - } - let named: Vec<(&str, &pdfcraft_cos::Document, Option<&[usize]>)> = - sources.iter().zip(docs.iter()).zip(&pages).map(|(((n, _, _), d), p)| (n.as_str(), d, p.as_deref())).collect(); - let out = pdfcraft_organize::combine_selected_with(&named, field_conflicts)?; - let bytes = self.write_new(&out.doc)?; - Ok(Combined { bytes, renamed_fields: out.renamed_fields }) - } - - /// [`Self::combine_unlocked`] where sources with the same `groups` key (by position) are one - /// file split into several runs placed apart: each file is opened and copied once, so it keeps - /// one bookmark, its links between its own pages, its fields and its attachments whole (see - /// `pdfcraft_organize::combine_grouped`). A group's later sources use its first source's bytes - /// and password; a source without a key is a file of its own. - pub fn combine_grouped(&self, sources: &[CombineSource], groups: &[u64], passwords: &[Option<&str>]) -> Result>, EditError> { - Ok(self.combine_grouped_reporting(sources, groups, passwords, pdfcraft_organize::FieldConflicts::Rename)?.bytes) - } - - /// [`Self::combine_grouped`] reporting what the conflict resolution renamed. - pub fn combine_grouped_reporting( - &self, - sources: &[CombineSource], - groups: &[u64], - passwords: &[Option<&str>], - field_conflicts: pdfcraft_organize::FieldConflicts, - ) -> Result { - let (mut keys, mut docs, mut file): (Vec>, Vec, Vec) = (Vec::new(), Vec::new(), Vec::new()); - for (i, (name, bytes, _)) in sources.iter().enumerate() { - let key = groups.get(i).copied(); - let known = key.and_then(|k| keys.iter().position(|g| *g == Some(k))); - let at = match known { - Some(at) => at, - None => { - docs.push(open_source_with(name, bytes, passwords.get(i).copied().flatten())?); - keys.push(key); - docs.len() - 1 - } - }; - file.push(at); - } - let mut pages = Vec::with_capacity(sources.len()); - for ((name, _, range), at) in sources.iter().zip(&file) { - let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty()); - let Some(d) = docs.get(*at) else { return Err(EditError::Write(format!("{name}: not opened"))) }; - pages.push(match range { - Some(r) => { - let n = pdfcraft_organize::page_count(d)?; - let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false) - .map_err(|e| EditError::Print(format!("{name}: {e}")))?; - Some(p) - } - None => None, - }); - } - let mut runs: Vec> = Vec::with_capacity(sources.len()); - for (((name, _, _), at), p) in sources.iter().zip(&file).zip(&pages) { - let Some(d) = docs.get(*at) else { return Err(EditError::Write(format!("{name}: not opened"))) }; - runs.push((*at, name.as_str(), d, p.as_deref())); - } - let out = pdfcraft_organize::combine_grouped_with(&runs, field_conflicts)?; - let bytes = self.write_new(&out.doc)?; - Ok(Combined { bytes, renamed_fields: out.renamed_fields }) - } - - /// New PDF bytes containing copies of `pages` of the document (Extract Pages). - pub fn extract(&self, id: DocId, pages: &[usize]) -> Result>, EditError> { - let src = self.cos(id)?; - if src.permissions().is_some_and(|p| !p.assemble()) { - return Err(EditError::NotPermitted("extracting pages")); - } - let out = pdfcraft_organize::extract_pages(src, pages)?; - self.write_new(&out) - } - - /// Split the document into several new PDFs. - pub fn split(&self, id: DocId, by: &pdfcraft_organize::SplitBy) -> Result, EditError> { - let src = self.cos(id)?; - if src.permissions().is_some_and(|p| !p.assemble()) { - return Err(EditError::NotPermitted("splitting the document")); - } - let n = pdfcraft_organize::page_count(src)?; - pdfcraft_organize::split_ranges(n, by) - .into_iter() - .map(|r| { - let doc = pdfcraft_organize::extract_pages(src, &r.clone().collect::>())?; - Ok((r.start + 1, r.end, self.write_new(&doc)?)) - }) - .collect() - } - - /// Split into parts of at most `max_bytes` each (Acrobat's "File size"): pages are taken in - /// order while their size (as single-page files, an over-estimate because shared fonts and - /// images count once per part) fits; a page larger than the limit is a part of its own. - pub fn split_by_size(&self, id: DocId, max_bytes: usize) -> Result, EditError> { - let src = self.cos(id)?; - if src.permissions().is_some_and(|p| !p.assemble()) { - return Err(EditError::NotPermitted("splitting the document")); - } - let n = pdfcraft_organize::page_count(src)?; - let mut cuts = Vec::new(); - let mut used = 0usize; - for p in 0..n { - let size = self.write_unchecked(&pdfcraft_organize::extract_pages(src, &[p])?)?.len(); - if used > 0 && used + size > max_bytes { - cuts.push(p); - used = 0; - } - used += size; - } - self.split(id, &pdfcraft_organize::SplitBy::Before(cuts)) - } - - /// Web addresses in the text of every page (Create links from URLs): (page, one rectangle - /// per line in user space, URI). Text that already has a link over it is skipped. - pub fn find_urls(&self, id: DocId) -> Vec<(usize, Vec<[f64; 4]>, String)> { - let Some(doc) = self.get(id) else { return Vec::new() }; - let config = RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() }; - let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config); - let mut out = Vec::new(); - for page in 0..doc.info.pages.len() { - let res = r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }); - let Some(text) = res.text else { continue }; - let found: std::cell::RefCell, String)>> = Default::default(); - let hits = text.find_with(|chars| { - let urls = pdfcraft_annot::links::find_urls(chars); - let ranges = urls.iter().map(|u| u.0.clone()).collect(); - *found.borrow_mut() = urls; - ranges - }); - for (glyphs, (_, uri)) in hits.into_iter().zip(found.into_inner()) { - let rects: Vec<[f64; 4]> = text - .line_rects(glyphs) - .into_iter() - .map(|v| { - let q = doc.info.pages[page].view_rect_to_quad(v); - let xs = [q[0], q[2], q[4], q[6]]; - let ys = [q[1], q[3], q[5], q[7]]; - [ - xs.iter().copied().fold(f64::MAX, f64::min), - ys.iter().copied().fold(f64::MAX, f64::min), - xs.iter().copied().fold(f64::MIN, f64::max), - ys.iter().copied().fold(f64::MIN, f64::max), - ] - }) - .filter(|r| { - !doc.links().iter().any(|l| l.page == page && l.rect[0] < r[2] && r[0] < l.rect[2] && l.rect[1] < r[3] && r[1] < l.rect[3]) - }) - .collect(); - if !rects.is_empty() { - out.push((page, rects, uri)); - } - } - } - out - } - - /// A page of the bookmark list in outline order: up to `limit` bookmarks from position `offset`. - /// The document's own structure is read for it, so every bookmark can be reached by paging. - pub fn bookmark_page(&self, id: DocId, offset: usize, limit: usize) -> Option { - let doc = self.get(id)?; - if let Some(editor) = doc.editor.as_ref() { - let page = pdfcraft_organize::bookmark_page(&editor.cos, offset, limit); - let rows = page - .bookmarks - .into_iter() - .map(|b| { - let view = b.view.map_or_else(Default::default, |(kind, numbers)| { - let numbers: Vec> = numbers.iter().map(|n| n.map(|n| n as f32)).collect(); - pdfcraft_render::DestView::from_operands(Some(&kind), &numbers) - }); - BookmarkRow { path: b.path, title: b.title, open: b.open, page: b.page, view, color: b.color, italic: b.italic, bold: b.bold } - }) - .collect(); - return Some(BookmarkPage { rows, next: page.next, truncated: false }); - } - let all = outline_rows(&doc.info.outline); - let end = offset.saturating_add(limit); - let rows = all.iter().skip(offset).take(limit).cloned().collect(); - Some(BookmarkPage { rows, next: (end < all.len()).then_some(end), truncated: doc.info.outline_more }) - } - - /// Top-level bookmarks as split points: (first page of each part, its bookmark's title). - pub fn bookmark_splits(&self, id: DocId) -> Vec<(usize, String)> { - let Some(doc) = self.get(id) else { return Vec::new() }; - let top: Vec<(Option, String)> = match doc.editor.as_ref() { - Some(editor) => pdfcraft_organize::top_level_bookmarks(&editor.cos).into_iter().map(|b| (b.page, b.title)).collect(), - None => doc.info.outline.iter().map(|o| (o.page, o.title.clone())).collect(), - }; - let mut out: Vec<(usize, String)> = top.into_iter().filter_map(|(page, title)| Some((page?, title))).collect(); - out.sort_by_key(|x| x.0); - out.dedup_by_key(|x| x.0); - out - } - - /// Open freshly created bytes (combine / extract) as a new, unsaved document. - pub fn open_new(&mut self, name: impl Into, bytes: Arc>) -> Result { - 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; - } - Ok(id) - } - - fn cos(&self, id: DocId) -> Result<&pdfcraft_cos::Document, EditError> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - doc.editor.as_ref().map(|e| &e.cos).ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default())) - } - - /// A new file (Combine, Extract, Split, Create) as bytes, checked like a save before anyone - /// writes it (#1698, #2166): it reopens, and shows the pages it was built from. - fn write_new(&self, doc: &pdfcraft_cos::Document) -> Result>, EditError> { - let bytes = self.write_unchecked(doc)?; - let expected = pdfcraft_organize::page_count(doc)?; - pdfcraft_cos::Document::open(bytes.clone()).map_err(|e| EditError::Reopen(e.to_string()))?; - pdfcraft_render::check_pages(bytes.clone(), None, expected).map_err(|e| EditError::Reopen(e.to_string()))?; - Ok(bytes) - } - - /// [`Self::write_new`] without the check, for bytes nobody writes: measuring a part's size. - /// Like a save, a new file never carries a typed Password-field value (#3926): Extract and - /// Split copy the fields of the pages they take. - fn write_unchecked(&self, doc: &pdfcraft_cos::Document) -> Result>, EditError> { - let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; - let doc = writable(doc)?; - 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 { - let mut out = Vec::new(); - for d in &mut self.docs { - if d.dirty && d.generation != d.snapshot_generation { - // The working bytes hold typed Password-field values (#3926): a recovery file is - // written from a copy without them, and none is written if that copy fails. - let bytes = match d.editor.as_ref().filter(|e| pdfcraft_forms::has_password_values(&e.cos)) { - Some(e) => match writable(&e.cos) - .and_then(|c| write_incremental(&c, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))) - { - Ok(b) => Arc::new(b), - Err(_) => continue, - }, - None => d.bytes.clone(), - }; - d.snapshot_generation = d.generation; - out.push(RecoverySnapshot { - doc: d.id, - name: d.name.clone(), - path: d.path.clone(), - bytes, - encrypted: d.info.encrypted || d.editor.as_ref().is_some_and(|e| e.cos.security().is_some()), - }); - } - } - out - } - - /// Mark a document opened from a recovery file: it has unsaved changes and belongs at - /// `path` (where Save writes), as when the session ended. - pub fn mark_recovered(&mut self, id: DocId, path: Option) { - 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 - } - } - - /// Show or hide a layer (optional content group) for viewing. Returns `true` if it changed; - /// callers must drop cached rasters and text for the document. - pub fn set_layer_visible(&mut self, id: DocId, layer: usize, visible: bool) -> bool { - let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; - let Some(l) = doc.info.layers.get_mut(layer) else { return false }; - if l.visible == visible { - return false; - } - l.visible = visible; - use_layer_choices(doc); - true - } - - /// Run a set-layer-visibility action (`SetOCGState`): apply `changes` in order, naming each - /// layer by its optional content group; groups that aren't layers are skipped. With - /// `preserve_rb`, a layer turned on turns off the other layers of its radio-button groups. - /// Returns `true` if any layer changed; callers must drop cached rasters and text for the - /// document, as for [`Self::set_layer_visible`]. - pub fn set_layer_state(&mut self, id: DocId, changes: &[(LayerOp, (u32, u16))], preserve_rb: bool) -> bool { - let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; - if !apply_layer_state(&mut doc.info.layers, &doc.info.layer_groups, changes, preserve_rb) { - return false; - } - use_layer_choices(doc); - 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 { - let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; - if doc.config.hide_comments == hide { - return false; - } - doc.config.hide_comments = hide; - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - doc.generation += 1; - true - } - - /// Set the document's reading colours (Acrobat's Replace Document Colors). Returns whether - /// anything changed; callers must drop cached rasters and text for the document. - pub fn set_reading_colors(&mut self, id: DocId, colors: ReadingColors) -> bool { - let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; - if doc.config.reading_colors == colors { - return false; - } - doc.config.reading_colors = colors; - doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); - doc.generation += 1; - true - } - - /// The document's reading-colours setting; `None` when no such document is open. - pub fn reading_colors(&self, id: DocId) -> Option { - 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> { - let doc = self.get(id).ok_or(EditError::NoDocument)?; - let text = comment_summary(&doc.name, &doc.info.annotations, sort); - self.create_report(&format!("Summary of Comments on {}", doc.name), &text) - } - - /// The certificates trusted for signing. - pub fn trusted_certificates(&self) -> &[pdfcraft_sign::Certificate] { - &self.trust.certs - } - - /// 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(); - 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()); - } - } - - /// The signing time as a PDF date in local time with its offset (`D:…+02'00'`). - pub fn signing_date(&self) -> String { - let offset = self.local_offset(); - let local = pdfcraft_cos::pdf_date(self.now().unwrap_or(0) + offset); - let stamp = local.trim_end_matches('Z'); - if offset == 0 { - return format!("{stamp}Z"); - } - let (sign, m) = if offset < 0 { ('-', -offset / 60) } else { ('+', offset / 60) }; - format!("{stamp}{sign}{:02}'{:02}'", m / 60, m % 60) - } - - /// Sign the document's current state with `id` (Use a certificate ▸ Digitally sign). Returns - /// the signed file; the caller saves it and then calls [`Session::mark_signed`]. An empty - /// `opts.date` takes the session clock. - pub fn sign(&self, doc: DocId, id: &pdfcraft_sign::DigitalId, mut opts: SignOptions) -> Result>, EditError> { - let d = self.get(doc).ok_or(EditError::NoDocument)?; - // Encrypted documents are signed under their permissions (the signer checks them). - let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; - if opts.date.is_empty() { - opts.date = self.signing_date(); - } - // A typed Password-field value is not signed into the file (#3926), as it isn't saved. - let cos = writable(&editor.cos)?; - Ok(Arc::new(pdfcraft_sign::sign(&cos, id, &opts)?)) - } - - /// [`Session::sign`], embedding an RFC 3161 signature timestamp (PAdES B-T) produced by - /// `tsa`. The transport lives with the caller; the engine never opens a socket. - pub fn sign_with_timestamp( - &self, - doc: DocId, - id: &pdfcraft_sign::DigitalId, - mut opts: SignOptions, - tsa: &dyn pdfcraft_sign::TimestampAuthority, - ) -> Result>, EditError> { - let d = self.get(doc).ok_or(EditError::NoDocument)?; - let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; - if opts.date.is_empty() { - opts.date = self.signing_date(); - } - let cos = writable(&editor.cos)?; - let signed = Arc::new(pdfcraft_sign::sign_with_timestamp(&cos, id, &opts, tsa)?); - // Checked like a save before anyone writes it (#2166). - self.check_save(doc, &signed)?; - Ok(signed) - } - - /// Append a standalone document timestamp (RFC 3161, `/ETSI.RFC3161`) covering the file's - /// current state. An empty `date` takes the session clock; the transport is the caller's. - pub fn timestamp_document(&self, doc: DocId, tsa: &dyn pdfcraft_sign::TimestampAuthority, date: String) -> Result>, EditError> { - let d = self.get(doc).ok_or(EditError::NoDocument)?; - let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; - let date = if date.is_empty() { self.signing_date() } else { date }; - let cos = writable(&editor.cos)?; - let stamped = Arc::new(pdfcraft_sign::timestamp_document(&cos, tsa, &date)?); - self.check_save(doc, &stamped)?; - Ok(stamped) - } - - /// Embed revocation evidence into the catalog's `/DSS` with `/VRI` entries per signature - /// (PAdES B-LT): an incremental update that never rewrites signed bytes. - pub fn embed_ltv(&self, doc: DocId, evidence: &pdfcraft_sign::dss::Evidence) -> Result>, EditError> { - let d = self.get(doc).ok_or(EditError::NoDocument)?; - let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; - let cos = writable(&editor.cos)?; - let embedded = Arc::new(pdfcraft_sign::dss::embed(&cos, evidence)?); - self.check_save(doc, &embedded)?; - Ok(embedded) - } - - /// Record that the signed file `bytes` was saved (to `path`): like [`Session::mark_saved`], - /// and the edit history before signing is dropped (signing can't be undone). - pub fn mark_signed(&mut self, id: DocId, bytes: Arc>, path: Option) -> Result<(), EditError> { - self.mark_saved(id, bytes, path)?; - let doc = self.doc_mut(id)?; - if let Some(e) = doc.editor.as_mut() { - e.undo.clear(); - e.redo.clear(); - } - Ok(()) - } - - pub fn close(&mut self, id: DocId) { - self.docs.retain(|d| d.id != id); - } - - pub fn get(&self, id: DocId) -> Option<&Document> { - self.docs.iter().find(|d| d.id == id) - } - - /// Read document `id`'s font list again if content edits since the last full inspection may - /// have changed it (Document Properties ▸ Fonts, `doc_info`). A no-op otherwise. - pub fn refresh_fonts(&mut self, id: DocId) { - let Some(doc) = self.docs.iter_mut().find(|d| d.id == id && d.fonts_stale) else { return }; - if let Ok(info) = pdfcraft_render::inspect(doc.bytes.clone(), doc.password.as_deref()) { - doc.info.fonts = info.fonts; - } - doc.fonts_stale = false; - } - - pub fn docs(&self) -> &[Document] { - &self.docs - } -} - -/// A picture for a watermark or background (Acrobat: Source ▸ File): an image, or `page` -/// (0-based) of a PDF. -#[derive(Clone, Debug, PartialEq)] -pub struct MarkFile { - pub name: String, - pub bytes: Arc>, - pub page: usize, -} - -/// Embed an image that is placed on a page, turned the way its EXIF Orientation says. -fn embed_image( - doc: &mut pdfcraft_cos::Document, - name: &str, - bytes: &[u8], -) -> Result<(pdfcraft_cos::ObjRef, (f64, f64)), pdfcraft_create::CreateError> { - pdfcraft_create::image_xobject(doc, name, bytes) -} - -/// The largest box of `w`:`h` shape inside `rect`, centred in it: a stamp's picture keeps its -/// shape in a box of any other shape. A box that is already that shape (to within rounding) is -/// kept exactly, so callers that size it themselves get their box back. An empty or non-finite -/// box, or a picture with no size, is returned as it is, for the annotation's own check to refuse. -fn fit_picture(rect: [f64; 4], w: f64, h: f64) -> [f64; 4] { - if !rect.iter().all(|v| v.is_finite()) { - return rect; - } - let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; - let (bw, bh) = (rect[2] - rect[0], rect[3] - rect[1]); - let usable = |v: f64| v.is_finite() && v > 0.0; - if !(usable(bw) && usable(bh) && usable(w) && usable(h)) { - return rect; - } - let k = (bw / w).min(bh / h); - let (fw, fh) = (w * k, h * k); - if (fw - bw).abs() <= bw * 1e-9 && (fh - bh).abs() <= bh * 1e-9 { - return rect; - } - let (cx, cy) = (rect[0] + bw / 2.0, rect[1] + bh / 2.0); - [cx - fw / 2.0, cy - fh / 2.0, cx + fw / 2.0, cy + fh / 2.0] -} - -/// Bring a mark's picture into `doc`: a PDF page as a form XObject, or an image. -fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Result { - let head = &f.bytes[..f.bytes.len().min(1024)]; - if head.windows(5).any(|w| w == b"%PDF-") { - let src = pdfcraft_cos::Document::open(f.bytes.clone()).map_err(|e| EditError::Source(format!("{}: {e}", f.name)))?; - let (xobject, size) = pdfcraft_organize::page_as_form(doc, &src, f.page)?; - Ok(pdfcraft_edit::MarkSource { xobject, size, image: false }) - } else { - let (xobject, size) = embed_image(doc, &f.name, &f.bytes)?; - Ok(pdfcraft_edit::MarkSource { xobject, size, image: true }) - } -} - /// What Optimize PDF did. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct OptimizeReport { @@ -5501,173 +226,5 @@ pub struct OptimizeReport { pub discarded: Vec<(Hidden, usize)>, } -/// Order of Summarize Comments. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum SummarySort { - #[default] - Page, - Author, - Date, - Type, -} - -impl SummarySort { - pub const ALL: [SummarySort; 4] = [SummarySort::Page, SummarySort::Author, SummarySort::Date, SummarySort::Type]; - pub fn name(self) -> &'static str { - match self { - SummarySort::Page => "Page", - SummarySort::Author => "Author", - SummarySort::Date => "Date", - SummarySort::Type => "Type", - } - } - pub fn parse(s: &str) -> Option { - Self::ALL.into_iter().find(|k| k.name().eq_ignore_ascii_case(s)) - } -} - -/// Acrobat's name for a comment type, as comment lists show it. -pub fn comment_type_name(subtype: &str) -> &str { - match subtype { - "Text" => "Sticky Note", - "FreeText" => "Text Box", - "Highlight" => "Highlight", - "Underline" => "Underline", - "StrikeOut" => "Strikethrough", - "Squiggly" => "Squiggly", - "Square" => "Rectangle", - "Circle" => "Oval", - "Line" => "Line", - "Ink" => "Pencil", - "Polygon" => "Polygon", - "PolyLine" => "Polygonal Line", - "Stamp" => "Stamp", - "Caret" => "Inserted Text", - "FileAttachment" => "File Attachment", - "Sound" => "Sound", - "Redact" => "Redaction", - other => other, - } -} - -/// [`comment_type_name`], telling callouts, clouds and typewriter text apart by `/IT`. -pub fn comment_kind(a: &pdfcraft_render::Annotation) -> &str { - match a.intent.as_deref() { - Some("FreeTextCallout") => "Callout", - Some("PolygonCloud") => "Cloud", - Some("FreeTextTypeWriter") => "Typewriter", - _ => comment_type_name(&a.subtype), - } -} - -/// The text of a comment summary: one block per comment (replies indented under it), with a -/// "Page N" heading when sorted by page. Checkmarks and status replies are left out, as are -/// pop-ups; the number is the comment's position on its page. -pub fn comment_summary(name: &str, all: &[pdfcraft_render::Annotation], sort: SummarySort) -> String { - use std::fmt::Write; - let top: Vec<&pdfcraft_render::Annotation> = all.iter().filter(|a| a.in_reply_to.is_none() && a.state.is_none()).collect(); - // Write a comment's replies, indenting each level. A reply to a reply keeps its `/IRT` on the - // direct parent, so following one level would leave the deeper replies out of the summary. - fn write_replies(out: &mut String, all: &[pdfcraft_render::Annotation], parent: &str, depth: usize) { - use std::fmt::Write; - if depth > 64 { - return; - } - for r in all.iter().filter(|r| r.state.is_none() && r.in_reply_to.as_deref() == Some(parent)) { - let indent = " ".repeat(depth + 1); - let _ = - writeln!(out, "{indent}Author: {} Subject: Reply Date: {}", r.author.as_deref().unwrap_or(""), r.modified.as_deref().unwrap_or("")); - for line in r.contents.as_deref().unwrap_or("").lines() { - let _ = writeln!(out, "{indent}{line}"); - } - if let Some(nm) = r.name.as_deref() { - write_replies(out, all, nm, depth + 1); - } - } - } - let mut numbered: Vec<(usize, &pdfcraft_render::Annotation)> = Vec::new(); - let mut last = usize::MAX; - let mut n = 0; - for a in &top { - if a.page != last { - last = a.page; - n = 0; - } - n += 1; - numbered.push((n, a)); - } - match sort { - SummarySort::Page => {} - SummarySort::Author => numbered.sort_by(|x, y| x.1.author.cmp(&y.1.author)), - SummarySort::Date => numbered.sort_by(|x, y| x.1.modified.cmp(&y.1.modified)), - SummarySort::Type => numbered.sort_by(|x, y| comment_kind(x.1).cmp(comment_kind(y.1))), - } - let mut out = format!("Summary of Comments on {name}\n\n"); - if numbered.is_empty() { - out.push_str("This document has no comments.\n"); - return out; - } - let mut heading = usize::MAX; - for (n, a) in numbered { - if sort == SummarySort::Page && a.page != heading { - heading = a.page; - let _ = writeln!(out, "Page: {}", a.page + 1); - } - let _ = write!(out, "Number: {n} Author: {} Subject: {}", a.author.as_deref().unwrap_or(""), comment_kind(a)); - if sort != SummarySort::Page { - let _ = write!(out, " Page: {}", a.page + 1); - } - let _ = writeln!(out, " Date: {}", a.modified.as_deref().unwrap_or("")); - if let Some(c) = a.contents.as_deref().filter(|c| !c.is_empty()) { - let _ = writeln!(out, "{c}"); - } - if let Some(nm) = a.name.as_deref() { - write_replies(&mut out, all, nm, 0); - } - out.push('\n'); - } - out -} - #[cfg(test)] mod tests; - -/// Page filters of Acrobat's Rotate Pages and page selection: even/odd page numbers and -/// orientation (as displayed). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum PageParity { - #[default] - Both, - Even, - Odd, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum PageOrientation { - #[default] - Both, - Landscape, - Portrait, -} - -/// The pages of `pages` (0-based) that pass the filters. -pub fn filter_pages(info: &pdfcraft_render::DocInfo, pages: &[usize], parity: PageParity, orientation: PageOrientation) -> Vec { - pages - .iter() - .copied() - .filter(|p| match parity { - PageParity::Both => true, - // Page numbers are 1-based: index 1 is page 2, an even page. - PageParity::Even => p % 2 == 1, - PageParity::Odd => p % 2 == 0, - }) - .filter(|p| { - let Some(pg) = info.pages.get(*p) else { return false }; - match orientation { - PageOrientation::Both => true, - PageOrientation::Landscape => pg.width > pg.height, - PageOrientation::Portrait => pg.width <= pg.height, - } - }) - .collect() -} diff --git a/crates/engine/src/open.rs b/crates/engine/src/open.rs new file mode 100644 index 0000000000..d8950d1d6d --- /dev/null +++ b/crates/engine/src/open.rs @@ -0,0 +1,355 @@ +//! Opening a document: preparing it away from the session, then registering it. + +use std::sync::{Arc, OnceLock}; + +use pdfcraft_cos::{SaveOptions, write_incremental}; +use pdfcraft_render::{DocInfo, OpenError, RenderConfig, RenderPool, inspect}; + +use crate::history::{Editor, Keys, TextBlocksCache}; +use crate::{ + AppearanceCache, DocId, Document, Session, TrustStore, XFA_SCRIPTS_OFF, XfaLayout, display_bytes, guard, info_fields, js, note_warnings, + recovered_render_password, render_threads, same_pages, signatures_of, xfa, xfa_layout, xfa_values_from_datasets, +}; + +/// Immutable session state needed while a document is prepared away from its owning thread. +#[derive(Clone)] +pub struct OpenContext { + now: Option, + js_off: bool, + trust: Arc, + xfa_time_scale: u32, +} + +/// A fully prepared document that has not yet been registered in a session. +pub struct PreparedDocument { + document: Document, +} + +impl OpenContext { + /// Prepare a document without mutating the session that supplied this context. + pub fn prepare_open( + &self, + name: impl Into, + path: Option, + bytes: Arc>, + password: Option<&str>, + ) -> Result { + let name = name.into(); + guard(|| Session::prepare_open_unguarded(self, name, path, bytes, password)) + .unwrap_or_else(|m| Err(OpenError::Invalid(format!("reading it failed unexpectedly ({m})")))) + } +} + +impl Session { + /// Snapshot the session state that affects open-time scripts, dates and signature trust. + pub fn open_context(&self) -> OpenContext { + OpenContext { now: self.now(), js_off: self.js_off, trust: self.trust.clone(), xfa_time_scale: self.xfa_time_scale.unwrap_or(1) } + } + + /// Prepare a document without adding it to this session. + pub fn prepare_open( + &self, + name: impl Into, + path: Option, + bytes: Arc>, + password: Option<&str>, + ) -> Result { + self.open_context().prepare_open(name, path, bytes, password) + } + + /// Register a prepared document. This is the only open step that mutates the session. + pub fn commit_open(&mut self, prepared: PreparedDocument) -> DocId { + self.next_id += 1; + let id = DocId(self.next_id); + let mut document = prepared.document; + document.id = id; + self.docs.push(document); + id + } + + /// Synchronous convenience API used by the CLI and automation. + pub fn open(&mut self, name: impl Into, path: Option, bytes: Arc>, password: Option<&str>) -> Result { + let prepared = self.prepare_open(name, path, bytes, password)?; + Ok(self.commit_open(prepared)) + } + + fn prepare_open_unguarded( + context: &OpenContext, + name: String, + path: Option, + bytes: Arc>, + password: Option<&str>, + ) -> Result { + let cos = std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)); + // The renderer authenticates on its own. It cannot use the owner password of R2–R4 + // files, so give it the user password that owner authentication recovers. + let (info, render_password) = match inspect(bytes.clone(), password) { + Ok(info) => (info, password.map(str::to_owned)), + Err(OpenError::WrongPassword) => { + let user = match &cos { + Ok(Ok(d)) => recovered_render_password(d), + _ => None, + }; + let user = user.ok_or(OpenError::WrongPassword)?; + (inspect(bytes.clone(), Some(&user))?, Some(user)) + } + Err(e) => return Err(e), + }; + // A dynamic XFA form is a shell around an XML template; lay the template out into real + // pages and fields so the rest of the engine works on it. The original bytes stay: the + // laid-out form is one appended revision. + let opts = SaveOptions { mod_date: context.now.map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; + // Write `work` as one more revision and reopen it: what the document then is. + let rebase = |work: &pdfcraft_cos::Document| -> Result<(Arc>, DocInfo, pdfcraft_cos::Document), String> { + let new_bytes = write_incremental(work, &opts).map(Arc::new).map_err(|e| e.to_string())?; + let new_info = inspect(new_bytes.clone(), render_password.as_deref()).map_err(|e| e.to_string())?; + let new_cos = pdfcraft_cos::Document::open_with_password(new_bytes.clone(), password).map_err(|e| e.to_string())?; + Ok((new_bytes, new_info, new_cos)) + }; + let (bytes, info, cos, xfa) = match (info.xfa, cos) { + (Some(pdfcraft_render::Xfa::Dynamic), Ok(Ok(cos))) if pdfcraft_xfa::existing_layout(&cos).is_none() => { + let mut work = cos.clone(); + let laid_out = guard(|| xfa_layout(&mut work)) + .unwrap_or_else(|m| Err(format!("laying it out failed unexpectedly ({m})"))) + .and_then(|report| rebase(&work).map(|(b, i, c)| (b, i, c, report))); + match laid_out { + Ok((b, i, c, report)) => (b, i, Ok(Ok(c)), Some(report)), + Err(e) => { + let mut info = info; + info.warnings.push(format!("This dynamic XFA form could not be laid out: {e}")); + (bytes, info, Ok(Ok(cos)), None) + } + } + } + // A static XFA form, or a dynamic one laid out earlier: its datasets may hold values + // filled in by another viewer since; give the fields those values. + (Some(_), Ok(Ok(cos))) => { + let report = pdfcraft_xfa::existing_layout(&cos); + let mut work = cos.clone(); + let synced = + guard(|| xfa_values_from_datasets(&mut work)).unwrap_or_else(|m| Err(format!("reading its data failed unexpectedly ({m})"))); + match synced { + Ok(names) if names.is_empty() => (bytes, info, Ok(Ok(cos)), report), + Ok(names) => match rebase(&work) { + Ok((b, mut i, c)) => { + // The fields were rewritten from the XFA data: say so, and which. + let shown: Vec<&str> = names.iter().take(5).map(String::as_str).collect(); + let more = if names.len() > shown.len() { format!(" and {} more", names.len() - shown.len()) } else { String::new() }; + i.warnings.push(format!( + "{} form field value{} were taken from this form's XFA data (filled in by another viewer): {}{more}", + names.len(), + if names.len() == 1 { "" } else { "s" }, + shown.join(", ") + )); + (b, i, Ok(Ok(c)), report) + } + Err(e) => { + let mut info = info; + info.warnings.push(format!("The values in this form's XFA data could not be applied: {e}")); + (bytes, info, Ok(Ok(cos)), report) + } + }, + Err(e) => { + let mut info = info; + info.warnings.push(format!("The values in this form's XFA data could not be applied: {e}")); + (bytes, info, Ok(Ok(cos)), report) + } + } + } + (_, cos) => (bytes, info, cos, None), + }; + // A laid-out XFA form: its initialize and calculate scripts run now, as on opening in + // Acrobat; what they change is one more revision, still nothing to save. + let mut script_output = js::JsOutput::default(); + let mut ran_away = false; + let xfa_template = match (&xfa, &cos) { + (Some(_), Ok(Ok(c))) => xfa::template(c), + _ => None, + }; + let (bytes, info, cos) = match (&xfa, cos) { + (Some(_), Ok(Ok(cos))) if !context.js_off => match xfa_template.clone() { + Some(tpl) => { + let mut work = cos.clone(); + let pages = info.pages.len(); + let ran = guard(|| { + let mut run = xfa::XfaRun { + page_count: pages, + out: &mut script_output, + datasets: None, + ran_away: false, + time_scale: context.xfa_time_scale, + }; + let changes = xfa::on_open(&mut work, &tpl, &mut run); + if run.ran_away { + ran_away = true; + } + changes + }) + .unwrap_or_else(|m| Err(format!("its scripts failed unexpectedly ({m})"))); + match ran { + Ok(changes) if work.is_modified() => match rebase(&work) { + Ok((b, mut i, c)) => { + // The file as saved differs from what opening shows: say so. + if !changes.is_empty() { + i.warnings.push(format!( + "This form's scripts changed it on opening ({}); saving keeps those changes", + changes.describe() + )); + } + (b, i, Ok(Ok(c))) + } + Err(e) => { + script_output.errors.push(format!("The form's scripts could not be applied: {e}")); + (bytes, info, Ok(Ok(cos))) + } + }, + Ok(_) => (bytes, info, Ok(Ok(cos))), + Err(e) => { + script_output.errors.push(format!("The form's scripts could not run: {e}")); + (bytes, info, Ok(Ok(cos))) + } + } + } + None => (bytes, info, Ok(Ok(cos))), + }, + (_, cos) => (bytes, info, cos), + }; + // The catalog leads to no page, but the reader found pages by scanning: gather the page + // objects into a page tree as one more revision, or page edits address none of them + // (#1656). Kept only when the reader then shows the same pages, and never on a signed + // document, where writing the repair could rewrite the file under its signatures. + let (bytes, info, cos) = match cos { + Ok(Ok(cos)) if !info.pages.is_empty() && pdfcraft_organize::page_count(&cos).unwrap_or(0) == 0 => { + let mut work = cos.clone(); + let signed = || pdfcraft_sign::pdf::list(&cos, &bytes, &context.trust).iter().any(|s| s.signed); + let rebuilt = match pdfcraft_organize::recover_page_tree(&mut work) { + Ok(n) if n > 0 && !signed() => rebase(&work).ok().filter(|(_, i, _)| same_pages(&i.pages, &info.pages)), + _ => None, + }; + match rebuilt { + Some((b, i, mut c)) => { + for line in cos.repair_log() { + c.note_repair(line.clone()); + } + c.note_repair(format!("the page tree is missing; its {} pages were found by scanning the file", i.pages.len())); + (b, i, Ok(Ok(c))) + } + None => (bytes, info, Ok(Ok(cos))), + } + } + cos => (bytes, info, cos), + }; + // XFA notes survive the document being re-read after edits. + let xfa_warnings: Vec = if info.xfa.is_some() { info.warnings.clone() } else { Vec::new() }; + let mut document = Self::build_document(&context.trust, name, path, bytes, info, cos, render_password, password, xfa); + // A script that ran away on open turns the form's scripts off for this document. + document.xfa_template = if ran_away { None } else { xfa_template }; + document.js_output.append(script_output); + if ran_away { + document.xfa_scripts_off = true; + document.js_output.errors.push(XFA_SCRIPTS_OFF.into()); + } + note_warnings(&mut document.xfa_warnings, &xfa_warnings); + Ok(PreparedDocument { document }) + } + + /// The last step of opening: build the document record and register it. + #[allow(clippy::too_many_arguments)] + fn build_document( + trust: &Arc, + name: String, + path: Option, + bytes: Arc>, + mut info: DocInfo, + cos: Result, Box>, + render_password: Option, + password: Option<&str>, + xfa: Option, + ) -> Document { + let config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() }; + let (mut editor, read_only_reason) = match cos { + Ok(Ok(cos)) => { + let keys = Keys { render: render_password.clone(), reopen: password.map(str::to_owned) }; + let saved = cos.bytes().clone(); + ( + Some(Editor { + 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, + ) + } + 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(); + // The inspection reads only `/Info`; a file stating its title or author only in XMP still + // shows them (#2373). + if let Some(e) = &editor { + for (field, value) in info_fields(&mut info, &e.cos) { + if field.is_none() { + *field = value; + } + } + } + let display = display_bytes(editor.as_mut(), &bytes); + let renderer = RenderPool::new(display.clone(), render_threads(), config.clone()); + let (form, adopted) = editor + .as_ref() + .map(|e| { + let mut fields = pdfcraft_forms::fields_with_adopted(&e.cos); + xfa::mark_script_buttons(&e.cos, &mut fields.items); + (fields.items, fields.adopted_page_fields) + }) + .unwrap_or_default(); + // Fields the file lists only as page widgets are adopted leniently; the file says so. + if adopted > 0 + && let Some(e) = &mut editor + { + let line = if adopted == 1 { + "the form's /Fields list omits 1 field; it was read from the page annotations".to_string() + } else { + format!("the form's /Fields list omits {adopted} fields; they were read from the page annotations") + }; + e.cos.note_repair(line); + } + Document { + id: DocId(0), + name, + path, + bytes, + display, + info, + renderer, + password: render_password, + dirty: false, + generation: 0, + snapshot_generation: 0, + read_only_reason, + form: Arc::new(form), + marker_scan: OnceLock::new(), + links: OnceLock::new(), + signatures: Arc::new(signatures), + trust: trust.clone(), + sig_cache, + editor, + config, + js_output: Default::default(), + xfa, + xfa_template: None, + xfa_scripts_off: false, + fonts_stale: false, + xfa_warnings: Vec::new(), + edit_notices: Vec::new(), + } + } +} diff --git a/crates/engine/src/optimizer.rs b/crates/engine/src/optimizer.rs index a3becf5147..42af737bab 100644 --- a/crates/engine/src/optimizer.rs +++ b/crates/engine/src/optimizer.rs @@ -87,8 +87,9 @@ impl OptimizeJob { fn work(self, progress: &mut dyn FnMut(OptimizeStage) -> bool) -> Result { let OptimizeJob { mut cos, settings, discard, opts } = self; - // Like every file written, without the values of Password-flag fields (#3926). + // Like every file written, without the values of Password-flag fields (#3926, #3944). pdfcraft_forms::clear_password_values(&mut cos).map_err(|e| EditError::Write(e.to_string()))?; + pdfcraft_xfa::clear_password_values(&mut cos).map_err(|e| EditError::Write(e.to_string()))?; let mut step = |stage| if progress(stage) { Ok(()) } else { Err(EditError::Cancelled) }; step(OptimizeStage::Discarding)?; let discarded = if discard.is_empty() { Vec::new() } else { pdfcraft_redact::sanitize::remove_hidden(&mut cos, &discard)? }; diff --git a/crates/engine/src/page_filter.rs b/crates/engine/src/page_filter.rs new file mode 100644 index 0000000000..aaedfbd65c --- /dev/null +++ b/crates/engine/src/page_filter.rs @@ -0,0 +1,41 @@ +//! Page filters: even or odd page numbers, and orientation as displayed. + +/// Page filters of Acrobat's Rotate Pages and page selection: even/odd page numbers and +/// orientation (as displayed). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum PageParity { + #[default] + Both, + Even, + Odd, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum PageOrientation { + #[default] + Both, + Landscape, + Portrait, +} + +/// The pages of `pages` (0-based) that pass the filters. +pub fn filter_pages(info: &pdfcraft_render::DocInfo, pages: &[usize], parity: PageParity, orientation: PageOrientation) -> Vec { + pages + .iter() + .copied() + .filter(|p| match parity { + PageParity::Both => true, + // Page numbers are 1-based: index 1 is page 2, an even page. + PageParity::Even => p % 2 == 1, + PageParity::Odd => p % 2 == 0, + }) + .filter(|p| { + let Some(pg) = info.pages.get(*p) else { return false }; + match orientation { + PageOrientation::Both => true, + PageOrientation::Landscape => pg.width > pg.height, + PageOrientation::Portrait => pg.width <= pg.height, + } + }) + .collect() +} diff --git a/crates/engine/src/pictures.rs b/crates/engine/src/pictures.rs new file mode 100644 index 0000000000..b29941720a --- /dev/null +++ b/crates/engine/src/pictures.rs @@ -0,0 +1,59 @@ +//! Pictures placed on pages: added images, and the pictures of watermarks and backgrounds. + +use std::sync::Arc; + +use crate::EditError; + +/// A picture for a watermark or background (Acrobat: Source ▸ File): an image, or `page` +/// (0-based) of a PDF. +#[derive(Clone, Debug, PartialEq)] +pub struct MarkFile { + pub name: String, + pub bytes: Arc>, + pub page: usize, +} + +/// Embed an image that is placed on a page, turned the way its EXIF Orientation says. +pub(crate) fn embed_image( + doc: &mut pdfcraft_cos::Document, + name: &str, + bytes: &[u8], +) -> Result<(pdfcraft_cos::ObjRef, (f64, f64)), pdfcraft_create::CreateError> { + pdfcraft_create::image_xobject(doc, name, bytes) +} + +/// The largest box of `w`:`h` shape inside `rect`, centred in it: a stamp's picture keeps its +/// shape in a box of any other shape. A box that is already that shape (to within rounding) is +/// kept exactly, so callers that size it themselves get their box back. An empty or non-finite +/// box, or a picture with no size, is returned as it is, for the annotation's own check to refuse. +pub(crate) fn fit_picture(rect: [f64; 4], w: f64, h: f64) -> [f64; 4] { + if !rect.iter().all(|v| v.is_finite()) { + return rect; + } + let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; + let (bw, bh) = (rect[2] - rect[0], rect[3] - rect[1]); + let usable = |v: f64| v.is_finite() && v > 0.0; + if !(usable(bw) && usable(bh) && usable(w) && usable(h)) { + return rect; + } + let k = (bw / w).min(bh / h); + let (fw, fh) = (w * k, h * k); + if (fw - bw).abs() <= bw * 1e-9 && (fh - bh).abs() <= bh * 1e-9 { + return rect; + } + let (cx, cy) = (rect[0] + bw / 2.0, rect[1] + bh / 2.0); + [cx - fw / 2.0, cy - fh / 2.0, cx + fw / 2.0, cy + fh / 2.0] +} + +/// Bring a mark's picture into `doc`: a PDF page as a form XObject, or an image. +pub(crate) fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Result { + let head = &f.bytes[..f.bytes.len().min(1024)]; + if head.windows(5).any(|w| w == b"%PDF-") { + let src = pdfcraft_cos::Document::open(f.bytes.clone()).map_err(|e| EditError::Source(format!("{}: {e}", f.name)))?; + let (xobject, size) = pdfcraft_organize::page_as_form(doc, &src, f.page)?; + Ok(pdfcraft_edit::MarkSource { xobject, size, image: false }) + } else { + let (xobject, size) = embed_image(doc, &f.name, &f.bytes)?; + Ok(pdfcraft_edit::MarkSource { xobject, size, image: true }) + } +} diff --git a/crates/engine/src/protection.rs b/crates/engine/src/protection.rs new file mode 100644 index 0000000000..e66f01538a --- /dev/null +++ b/crates/engine/src/protection.rs @@ -0,0 +1,158 @@ +//! Password protection to apply, and the security a document has. + +use crate::EditError; + +/// Document Properties ▸ Security. +#[derive(Clone, Debug, PartialEq)] +pub struct SecuritySummary { + /// "AES, 256-bit" etc. + pub method: String, + /// Opened with the owner password (no restrictions apply). + pub owner: bool, + /// For pending protection: the restrictions as they will apply to others. + pub permissions: pdfcraft_cos::Permissions, + /// Set in this session; written by the next save. + pub pending: bool, +} + +/// What printing a protected document allows (Acrobat: Printing allowed). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Printing { + None, + /// "Low Resolution (150 dpi)". + Low, + High, +} + +/// What changes a protected document allows (Acrobat: Changes allowed). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Changes { + None, + /// "Inserting, deleting, and rotating pages". + Pages, + /// "Filling in form fields and signing existing signature fields". + FillSign, + /// "Commenting, filling in form fields, and signing existing signature fields". + CommentFillSign, + /// "Any except extracting pages". + AnyExceptExtract, +} + +/// Password protection to apply (Protect Using Password and its Advanced options). +#[derive(Clone, PartialEq)] +pub struct Protection { + /// Required to open the document (the user password). + pub open_password: Option, + /// Required to change security and lift the restrictions below (the owner password). + pub permissions_password: Option, + pub printing: Printing, + pub changes: Changes, + /// "Enable copying of text, images, and other content". + pub copy: bool, + /// "Enable text access for screen reader devices". + pub accessibility: bool, + /// Compatibility level; AES-256 (Acrobat X and later) by default. + pub algorithm: pdfcraft_cos::Algorithm, + /// `false`: "Encrypt all document contents except metadata". + pub encrypt_metadata: bool, +} + +impl std::fmt::Debug for Protection { + // Never print passwords (edits end up in logs and error messages). + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Protection") + .field("open_password", &self.open_password.as_ref().map(|_| "…")) + .field("permissions_password", &self.permissions_password.as_ref().map(|_| "…")) + .field("printing", &self.printing) + .field("changes", &self.changes) + .field("copy", &self.copy) + .field("accessibility", &self.accessibility) + .field("algorithm", &self.algorithm) + .field("encrypt_metadata", &self.encrypt_metadata) + .finish() + } +} + +impl Default for Protection { + /// Acrobat's defaults: AES-256, printing and accessibility allowed, no changes, no copying. + fn default() -> Self { + Self { + open_password: None, + permissions_password: None, + printing: Printing::High, + changes: Changes::None, + copy: false, + accessibility: true, + algorithm: pdfcraft_cos::Algorithm::Aes256, + encrypt_metadata: true, + } + } +} + +impl Protection { + /// The `/P` permission bits (ISO 32000-2 Table 22), as Acrobat maps its choices. + pub fn permission_bits(&self) -> i32 { + let bit = |n: u32| 1i32 << (n - 1); + if self.permissions_password.is_none() { + return -1; // nothing restricted without a permissions password + } + let mut p = 0; + match self.printing { + Printing::None => {} + Printing::Low => p |= bit(3), + Printing::High => p |= bit(3) | bit(12), + } + p |= match self.changes { + Changes::None => 0, + Changes::Pages => bit(11), + Changes::FillSign => bit(9), + Changes::CommentFillSign => bit(6) | bit(9), + Changes::AnyExceptExtract => bit(4) | bit(6) | bit(9), + }; + if self.copy { + p |= bit(5) | bit(10); + } + if self.accessibility { + p |= bit(10); + } + p + } + + pub(crate) fn validate(&self) -> Result<(), EditError> { + let bad = |m: &str| Err(EditError::Protection(m.to_string())); + match (&self.open_password, &self.permissions_password) { + (None, None) => return bad("enter a password to open the document, a permissions password, or both"), + (Some(a), Some(b)) if a == b => return bad("the open password and the permissions password must be different"), + _ => {} + } + if [&self.open_password, &self.permissions_password].into_iter().flatten().any(String::is_empty) { + return bad("passwords can't be empty"); + } + if self.algorithm != pdfcraft_cos::Algorithm::Aes256 + && [&self.open_password, &self.permissions_password].into_iter().flatten().any(|p| p.chars().any(|c| !(' '..='~').contains(&c))) + { + return bad("this compatibility level supports only plain ASCII passwords; use AES-256 (Acrobat X and later)"); + } + // Encryption keys on a password as `effective_password` gives it (AES-256: SASLprep, + // which drops soft hyphens and folds compatibility forms; older levels: the first 32 + // bytes, padded). One that comes out as the empty password opens the file without asking, + // and an open password that comes out as the permissions password lifts the restrictions. + let effective = |p: &str| pdfcraft_cos::effective_password(self.algorithm, p); + let (open, permissions) = (self.open_password.as_deref().map(effective), self.permissions_password.as_deref().map(effective)); + let empty = effective(""); + if open.iter().chain(&permissions).any(|p| *p == empty) { + return bad("passwords can't be empty or made only of characters that passwords ignore, such as soft hyphens"); + } + if open.is_some() && open == permissions { + return bad("the open password and the permissions password must be different (PDF readers treat these two as the same password)"); + } + Ok(()) + } +} + +/// Opened as owner, or nothing is restricted (no permissions password was set): security may +/// be changed, as in Acrobat. +pub(crate) fn unrestricted(p: &pdfcraft_cos::Permissions) -> bool { + const ALL: i32 = 0b1111_0011_1100; // bits 3–6 and 9–12 + p.owner || p.bits & ALL == ALL +} diff --git a/crates/engine/src/reexports.rs b/crates/engine/src/reexports.rs new file mode 100644 index 0000000000..a5f1dba418 --- /dev/null +++ b/crates/engine/src/reexports.rs @@ -0,0 +1,53 @@ +//! What the engine re-exports from the other pdfcraft crates, so frontends need only this one. + +pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, Transition, TransitionSpec, mime_for_name, split_ranges}; + +pub use pdfcraft_organize::LabelStyle; +pub use pdfcraft_organize::Standard; +pub use pdfcraft_organize::check_custom_key; +pub use pdfcraft_organize::view::{InitialView, Layout as InitialLayout, Magnification, Navigation}; + +pub use pdfcraft_cos::Algorithm; +pub use pdfcraft_create::{CONVERTIBLE, CreateError, ImageResolution, SourceKind, decode_text, source_kind}; +pub use pdfcraft_edit::added::{ + CHAR_SPACING_LIMIT as ADDED_CHAR_SPACING_LIMIT, paste_place as added_paste_place, paste_rect as added_paste_rect, text_rect as added_text_rect, + text_width as added_text_width, +}; +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, 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::{Backdrop, BlockKey, BlockStyle, PageImage, RangeStyle, TextBlock, TextLine, block_overlapping, first_undrawable}; +pub use pdfcraft_measure as measure; +pub use pdfcraft_xfa::Report as XfaLayout; + +pub use pdfcraft_edit::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget}; + +pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, script_outline, undrawable_message}; + +/// Comment geometry helpers (text-box line breaking) for frontends. +pub use pdfcraft_annot::appearance as annot_text; +pub use pdfcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle}; +pub use pdfcraft_annot::{ + AppearanceCache, AttachIcon, FillMark, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, Props as CommentProps, + ReviewState, Rgb, Shape, StampGroup, StampKind, Style, rect_quad, +}; +pub use pdfcraft_forms::detect; +pub use pdfcraft_optimize as optimize; +pub use pdfcraft_preflight as pdfa; +pub use pdfcraft_print as print; +pub use pdfcraft_redact::codes::{CODE_SETS as REDACTION_CODE_SETS, CodeSet as RedactionCodeSet}; +pub use pdfcraft_redact::patterns::{ + MAX_WORDS as REDACT_MAX_WORDS, PATTERNS as REDACT_PATTERNS, Pattern as RedactPattern, find as find_pattern, word_list as redact_word_list, +}; +pub use pdfcraft_redact::sanitize::{HIDDEN, Hidden}; +pub use pdfcraft_sign as sign; +pub use pdfcraft_sign::{SignOptions, SignatureInfo, Status as SignatureStatus, TrustStore}; +pub use pdfcraft_xfdf::Format as DataFormat; diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs new file mode 100644 index 0000000000..ab4d0345a6 --- /dev/null +++ b/crates/engine/src/session.rs @@ -0,0 +1,1597 @@ +//! The session: the open documents and the operations on them. + +use std::sync::{Arc, OnceLock}; + +use pdfcraft_cos::{SaveOptions, write_full, write_incremental}; +use pdfcraft_render::{LayerOp, OpenError, ReadingColors, RenderConfig, RenderPool, inspect}; + +#[cfg(test)] +use crate::check_refresh_panic; +use crate::edit::{EditCtx, check_permission, run_edit}; +use crate::history::{Keys, MAX_UNDO, Scope, keys_after, scope_of, uses_scripts}; +use crate::{ + BookmarkPage, BookmarkRow, CombineSource, Combined, DataFormat, DocId, Document, Edit, EditError, EditNotice, Hidden, ImageResolution, + LOW_RESOLUTION_PRINT_DPI, Label, MarkKind, ObjectKind, OptimizeReport, RecoverySnapshot, SignOptions, SourceKind, SplitPart, SummarySort, + TrustStore, XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, apply_layer_state, comment_list, comment_summary, decode_text, display_bytes, guard, + info_fields, js, local_utc_offset, note_warnings, open_source, open_source_with, optimize, outline_rows, parse_ymd, print, print_raster, + render_threads, scan_colours, set_default_layer_state, shown_under_redactions, signatures_of, source_kind, sync_xfa_state, undrawn_notices, + use_layer_choices, writable, xfa, xfa_sync_datasets, +}; + +#[derive(Default)] +pub struct Session { + pub(crate) docs: Vec, + pub(crate) next_id: u64, + /// Seconds since the Unix epoch, injected so saves are deterministic in tests. + clock: Option i64>, + /// Seconds to add to UTC for local time, read from whoever knows the zone (a browser, say). + /// Separate from `clock` on purpose: `clock` alone is the test shortcut that takes the + /// injected time AS local time and zeroes the zone, so injecting only it leaves either the + /// local dates or the UTC ones wrong by the zone's offset. + utc_offset: Option i64>, + /// Certificates trusted for signing (Acrobat: Trusted Certificates). + pub(crate) trust: Arc, + /// Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript, inverted (on by default). + pub(crate) js_off: bool, + /// Preferences ▸ Date format, when not the default (see [`dates`]). + pub(crate) date_format: Option, + /// Preferences ▸ Date format ▸ Language, when not following the interface language. + pub(crate) date_language: Option, + /// What the XFA scripts' time limits are multiplied by (`None`: 1); see + /// [`Session::with_xfa_time_scale`]. + pub(crate) xfa_time_scale: Option, +} + +impl Session { + pub fn new() -> Self { + Self::default() + } + + /// Use a fixed clock (tests) instead of the system time. + pub fn with_clock(mut self, clock: fn() -> i64) -> Self { + self.clock = Some(clock); + self + } + + /// Multiply the time an XFA form's scripts may take by `factor` (tests): the 3 s the + /// scripts of an opening or a change share, the 5 s of a click's, the 10 s the script + /// engine has to start, and each script's own 1 s or 3 s. A test that checks what the + /// scripts do passes a large factor, so that a busy machine does not stop them. On native + /// builds, 0 stops every script before it starts; the wasm build has no wall clock, so + /// there the factor has no effect. + pub fn with_xfa_time_scale(mut self, factor: u32) -> Self { + self.xfa_time_scale = Some(factor); + self + } + + /// A real clock for a host that has neither a clock nor a time zone of its own (a + /// `wasm32-unknown-unknown` build: `now()` would be empty and `local_utc_offset()` is 0, so + /// Fill & Sign stamps 1 Jan 1970 and `/ModDate` is never written). `clock` is the UTC time in + /// seconds since the epoch; `utc_offset` is the local zone's seconds east of UTC, asked on every + /// use because the zone changes with the date (daylight saving). Unlike + /// [`Session::with_clock`], which is a test shortcut, nothing here is taken as local time. + pub fn with_wall_clock(mut self, clock: fn() -> i64, utc_offset: fn() -> i64) -> Self { + self.clock = Some(clock); + self.utc_offset = Some(utc_offset); + self + } + + /// Seconds to add to UTC for the local time of this session: the injected zone, else 0 under + /// the test clock (which is already local), else the system's. + fn local_offset(&self) -> i64 { + match (self.utc_offset, self.clock) { + (Some(zone), _) => zone(), + (None, Some(_)) => 0, + (None, None) => local_utc_offset(), + } + } + + /// Seconds since the Unix epoch from the session clock (0 when unknown). + pub fn now_secs(&self) -> i64 { + self.now().unwrap_or(0) + } + + pub(crate) fn now(&self) -> Option { + if let Some(c) = self.clock { + return Some(c()); + } + #[cfg(not(target_arch = "wasm32"))] + return std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_secs() as i64); + #[cfg(target_arch = "wasm32")] + return None; + } + + /// Today's date in local time: (year, month, day). With an injected clock (tests) the clock + /// is taken as local time. + /// A dynamic stamp's second line: "By Ada at 2:14 pm, Oct 02, 2026" (local time). + pub fn stamp_by_line(&self, author: &str) -> String { + let offset = self.local_offset(); + let d = self.now().map(|t| pdfcraft_cos::pdf_date(t + offset)).unwrap_or_default(); + let num = |a: usize, b: usize| d.get(a..b).and_then(|s| s.parse::().ok()).unwrap_or(0); + let (y, mo, day, hh, mm) = (num(2, 6), num(6, 8), num(8, 10), num(10, 12), num(12, 14)); + const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + let h12 = if hh % 12 == 0 { 12 } else { hh % 12 }; + let who = if author.trim().is_empty() { String::new() } else { format!("By {} ", author.trim()) }; + format!("{who}at {h12}:{mm:02} {}, {} {day:02}, {y}", if hh < 12 { "am" } else { "pm" }, MONTHS[(mo.clamp(1, 12) - 1) as usize]) + } + + pub fn today(&self) -> (i64, u32, u32) { + let offset = self.local_offset(); + self.now().map(|t| pdfcraft_cos::pdf_date(t + offset)).as_deref().and_then(parse_ymd).unwrap_or((1970, 1, 1)) + } + + /// Open a document from bytes. Rendering starts lazily when pages are requested. + /// + /// `password` is tried as either the user or owner password when the file is encrypted. + /// Open saved revision `n` (1 = the oldest) of a document as a new, unsaved document named + /// " (revision n)". + pub fn open_revision(&mut self, id: DocId, n: usize) -> Result { + let doc = self.get(id).ok_or("no such document")?; + let ends = doc.revision_ends(); + let end = *n.checked_sub(1).and_then(|i| ends.get(i)).ok_or_else(|| format!("the document has {} revision(s)", ends.len()))?; + let name = format!("{} (revision {n}).pdf", doc.name.trim_end_matches(".pdf")); + let (bytes, password) = (Arc::new(doc.bytes[..end.min(doc.bytes.len())].to_vec()), doc.password.clone()); + self.open(name, None, bytes, password.as_deref()).map_err(|e| e.to_string()) + } + + pub(crate) fn doc_mut(&mut self, id: DocId) -> Result<&mut Document, EditError> { + self.docs.iter_mut().find(|d| d.id == id).ok_or(EditError::NoDocument) + } + + /// Apply an edit. On success the previous state is undoable and the view data is refreshed. + pub fn apply(&mut self, id: DocId, edit: Edit) -> Result<(), EditError> { + let now = self.now(); + let today = self.today(); + let js_off = self.js_off; + let xfa_time_scale = self.xfa_time_scale.unwrap_or(1); + let doc = self.doc_mut(id)?; + 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(); + } + cx.xfa_time_scale = xfa_time_scale; + cx.pages = doc.info.pages.len(); + cx.today = today; + let reason = doc.read_only_reason.clone().unwrap_or_default(); + let signed = doc.is_signed(); + let editor = doc.editor.as_mut().ok_or(EditError::ReadOnly(reason))?; + if let Some(p) = editor.cos.permissions() { + check_permission(&edit, &p)?; + } + // Rewriting an OCR word (invisible text over a scan) also hides the scanned word: cover it + // in the paper's colour and draw the new text in the ink's. + let edit = match edit { + Edit::EditTextBlock { page, block, text, mut style } => { + // Only invisible text is sampled; the blocks are usually cached from opening it. + if style.cover.is_none() + && let Some(info) = doc.info.pages.get(page) + && let Some(b) = editor.text_blocks(page).ok().and_then(|b| b.get(block).filter(|b| b.invisible).cloned()) + && let Some((paper, ink)) = guard(|| scan_colours::sample(&editor.cos, info, page, &b)).ok().flatten() + { + style.cover = Some(paper); + style.color = style.color.or(Some(ink)); + } + Edit::EditTextBlock { page, block, text, style } + } + other => other, + }; + // Nothing to bake: leave undo history and the dirty flag alone. + if matches!(&edit, Edit::FlattenFillSign) && !pdfcraft_annot::has_visible_fill_sign(&editor.cos) { + return Ok(()); + } + let mut next = editor.cos.clone(); + if !js_off && uses_scripts(&edit) { + cx.js = Some(js::JsRunner::new(&next, &name)); + } + cx.renamed_fields.clear(); + cx.redaction_overlays.clear(); + // `next` is a copy: if the edit fails or crashes, the document is unchanged. + guard(|| run_edit(&mut next, &edit, &mut cx)) + .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?; + // Redaction removed what its own interpreter places under the marks. The marks were drawn + // over the page as the renderer shows it, so the renderer has the last word. + if let Edit::ApplyRedactions { pages } = &edit { + let marks: Vec = + pdfcraft_redact::marks(&editor.cos).into_iter().filter(|m| pages.as_ref().is_none_or(|p| p.contains(&m.page))).collect(); + guard(|| shown_under_redactions(&next, &marks, &cx.redaction_overlays, &doc.info.pages, doc.password.as_deref())) + .unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?; + } + // Renamed fields are said, not slipped in: a repair-log note (with the caveat that + // scripts were left alone) and a count in the undo step's label. + let renamed_note = |renamed: &[(String, String)]| { + (!renamed.is_empty()).then(|| { + let shown: Vec = renamed.iter().take(5).map(|(old, new)| format!("{old} → {new}")).collect(); + let more = renamed.len().saturating_sub(shown.len()); + let more = if more > 0 { format!(", and {more} more") } else { String::new() }; + format!( + "inserted pages renamed {} form field{} to keep their names separate ({}{more}); scripts that refer to the old names were not rewritten", + renamed.len(), + if renamed.len() == 1 { "" } else { "s" }, + shown.join(", ") + ) + }) + }; + if let Some(note) = renamed_note(&cx.renamed_fields) { + next.note_repair(note); + } + // Only Insert from File renames fields; its label says so in a wording the interface + // translates (the count lives in the repair note). + let label = match (&edit, cx.renamed_fields.len()) { + (_, 0) => edit.label(), + (Edit::InsertPagesFrom { name, .. }, n) => Label::with( + if n == 1 { "Insert pages from {name} — 1 field renamed" } else { "Insert pages from {name} — fields renamed" }, + name.as_str(), + ), + _ => edit.label(), + }; + // An XFA form's scripts answer the change: exit and validate scripts of the field, then + // every calculation (and a reset recalculates). They read the data, so it is brought up + // to date first. + if let Some(tpl) = cx.xfa.clone() { + if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full | Scope::Content) { + let datasets = &mut cx.xfa_datasets; + let notes = guard(|| xfa_sync_datasets(&mut next, Some(&tpl), datasets)) + .unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})"))) + .map_err(EditError::Write)?; + cx.xfa_out.errors.extend(notes); + } + fn collect(e: &Edit, changed: &mut Vec, reset: &mut bool) { + match e { + Edit::SetFieldValue { name, .. } => changed.push(name.clone()), + Edit::ResetForm { .. } => *reset = true, + Edit::Batch { edits, .. } => edits.iter().for_each(|e| collect(e, changed, reset)), + _ => {} + } + } + let mut changed: Vec = Vec::new(); + let mut reset = false; + collect(&edit, &mut changed, &mut reset); + if !changed.is_empty() || reset { + let mut run = xfa::XfaRun { + page_count: cx.pages, + out: &mut cx.xfa_out, + datasets: cx.xfa_datasets, + ran_away: false, + time_scale: cx.xfa_time_scale, + }; + // Every changed field's scripts, then the calculations once (after a reset, + // only those). + let ran = guard(|| xfa::on_changes(&mut next, &tpl, &changed, &mut run)) + .unwrap_or_else(|m| Err(format!("the form's scripts failed unexpectedly ({m})"))); + cx.xfa_datasets = run.datasets; + cx.xfa_ran_away |= run.ran_away; + if let Err(e) = ran { + cx.xfa_out.errors.push(e); + } + } + } + // XFA forms keep their values in the datasets packet too, for Adobe's viewers. + let mut xfa_notes = Vec::new(); + if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full | Scope::Content) { + let (tpl, datasets) = (cx.xfa.as_deref(), &mut cx.xfa_datasets); + 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. + // So does any edit to a file rebuilt from object headers: it has no chain to append to. + let rewrites = |c: &pdfcraft_cos::Document| c.full_save_required() || c.encryption_changed(); + if signed && ((rewrites(&next) && !rewrites(&editor.cos)) || (next.is_modified() && next.revisions().is_empty())) { + return Err(EditError::SignedRewrite(edit.label().to_string())); + } + // What this edit drew with `?` (a value or comment text its font can't draw, stored as + // typed): reported, never silent (AGENTS.md §4). + let mut notices: Vec = if matches!(scope_of(&edit), Scope::Form | Scope::Comments | Scope::Full) { + guard(|| undrawn_notices(&editor.cos, &next)).unwrap_or_default().into_iter().map(EditNotice::Undrawn).collect() + } else { + Vec::new() + }; + // Pages whose own white backdrop Remove background couldn't turn back on: said, not silent. + if matches!(edit, Edit::RemoveMarks { kind: MarkKind::Background }) { + let pages = guard(|| pdfcraft_edit::backdrops_left_off(&next)).unwrap_or_default(); + if !pages.is_empty() { + notices.push(EditNotice::BackdropsLeftOff { pages }); + } + } + 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); + editor.undo.push((label, previous, previous_keys, scope)); + if editor.undo.len() > MAX_UNDO { + editor.undo.remove(0); + } + editor.redo.clear(); + Self::adopt_keys(doc); + let refreshed = guard(|| if scope == Scope::Content { Self::refresh_content(doc, Some(&edit)) } else { Self::refresh_scoped(doc, scope) }) + .unwrap_or_else(|m| Err(EditError::Reopen(m))); + if let Err(e) = refreshed { + // Roll back: the edit produced something we cannot display. + if let Some(ed) = doc.editor.as_mut() + && let Some((_, prev, keys, _)) = ed.undo.pop() + { + ed.cos = prev; + ed.keys = keys; + } + Self::adopt_keys(doc); + if !matches!(guard(|| Self::refresh(doc)), Ok(Ok(()))) { + Self::show_released(doc); + } + return Err(e); + } + doc.dirty = true; + doc.generation += 1; + doc.edit_notices = notices; + if !cx.xfa_removed.is_empty() { + let removed: Vec = cx.xfa_removed.iter().map(|n| format!("{XFA_REMOVED_PREFIX}{n}")).collect(); + doc.xfa_warnings.retain(|w| w.starts_with(XFA_REMOVED_PREFIX)); + note_warnings(&mut doc.xfa_warnings, &removed); + note_warnings(&mut doc.info.warnings, &removed); + } + note_warnings(&mut doc.xfa_warnings, &xfa_notes); + let notes = doc.xfa_warnings.clone(); + note_warnings(&mut doc.info.warnings, ¬es); + if let Some(js) = cx.js { + doc.js_output.append(js.output); + } + doc.js_output.append(cx.xfa_out); + if cx.xfa_ran_away { + doc.xfa_template = None; + doc.xfa_scripts_off = true; + doc.js_output.errors.push(XFA_SCRIPTS_OFF.into()); + } + Ok(()) + } + + pub fn undo(&mut self, id: DocId) -> Result { + let doc = self.doc_mut(id)?; + 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); + 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) + } + + pub fn redo(&mut self, id: DocId) -> Result { + let doc = self.doc_mut(id)?; + 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); + 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) + } + + /// Unsaved when the working graph is not the last opened or saved file. Undo back to that + /// 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.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). + fn adopt_keys(doc: &mut Document) { + if let Some(e) = doc.editor.as_ref() { + doc.password = e.keys.render.clone(); + doc.config.password = e.keys.render.as_deref().map(Arc::from); + } + } + + /// Rebuild the working bytes and the view data that `scope` may have changed. Metadata edits + /// keep the renderer; other scopes also replace it. + /// Comment and form edits skip the full re-inspection (seconds on very large files): the + /// comment list and field values are re-read from the object graph instead. + fn refresh_scoped(doc: &mut Document, scope: Scope) -> Result<(), EditError> { + if scope == Scope::Full || doc.info.encrypted != doc.editor.as_ref().is_some_and(|e| e.cos.output_handler().is_some()) { + return Self::refresh(doc); + } + if scope == Scope::Content { + return Self::refresh_content(doc, None); + } + if scope == Scope::Links { + return Self::refresh_links(doc); + } + // Metadata edits keep the renderer, which holds the working file anyway. + if scope != Scope::Metadata { + Self::release_working(doc); + } + let Some(editor) = doc.editor.as_mut() else { return Ok(()) }; + // Comment edits, and form edits (a script may change annotation properties), may change + // annotation dictionaries, invalidating cached appearances. + if matches!(scope, Scope::Comments | Scope::Form) { + editor.appearances.clear(); + } + let bytes = if editor.cos.is_modified() { + Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?) + } else { + editor.cos.bytes().clone() + }; + if scope == Scope::Metadata { + for (field, value) in info_fields(&mut doc.info, &editor.cos) { + *field = value; + } + doc.info.custom = pdfcraft_organize::custom_info(&editor.cos); + doc.info.file_size = bytes.len(); + #[cfg(test)] + check_refresh_panic(); + if !doc.signatures.is_empty() { + let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache); + doc.signatures = Arc::new(signatures); + } + doc.bytes = bytes; + return Ok(()); + } + let mut form = pdfcraft_forms::fields(&editor.cos); + xfa::mark_script_buttons(&editor.cos, &mut form); + let form = Arc::new(form); + match scope { + Scope::Metadata => return Ok(()), + Scope::Comments => { + doc.info.annotations = comment_list(&editor.cos); + // Link indexes are positions in /Annots, so comment insertion/deletion can move them. + doc.links = OnceLock::new(); + } + Scope::Form => { + // PERF: keystroke path, index once. A linear find per field is O(F^2); + // forms reach 10k fields. + let mut by_name = std::collections::HashMap::with_capacity(form.len()); + for x in form.iter() { + // First match wins (do not simplify to collect: it keeps the last). + by_name.entry(x.name.as_str()).or_insert(x); + } + for f in &mut doc.info.fields { + if let Some(ff) = by_name.get(f.name.as_str()) { + f.value = match ff.kind { + pdfcraft_forms::FieldKind::CheckBox | pdfcraft_forms::FieldKind::Radio => { + Some(ff.value.first().cloned().unwrap_or_else(|| "Off".into())) + } + _ if ff.value.is_empty() => None, + _ => Some(ff.value.join(", ")), + }; + } + } + } + Scope::Links => return Self::refresh_links(doc), + // Handled above; kept as a full refresh so the match stays total. + Scope::Full => return Self::refresh(doc), + // Handled above (content-only refresh); the arm keeps the match total. + Scope::Content => return Self::refresh_content(doc, None), + } + doc.info.file_size = bytes.len(); + doc.form = form; + 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(()) + } + + /// Which derived lists a content-only edit can change: added content (`None`, from + /// undo/redo, re-reads both: always correct, rarely paid). + pub(crate) fn content_refresh_lists(edit: Option<&Edit>) -> (bool, bool) { + if let Some(Edit::Batch { edits, .. }) = edit { + // A batch of content edits (from a script, say) changes what its parts change. + return edits.iter().fold((false, false), |(added, marks), part| { + let (a, m) = Self::content_refresh_lists(Some(part)); + (added || a, marks || m) + }); + } + let touches_added = edit.is_none_or(|e| { + matches!( + e, + Edit::AddText { .. } + | Edit::AddImage { .. } + | Edit::UpdateContent { .. } + | Edit::DeleteContent { .. } + | Edit::ReplaceImage { .. } + | Edit::PasteContent { .. } + ) || matches!(e, Edit::MoveObjects { objects, .. } if objects.iter().any(|o| o.kind == ObjectKind::Added)) + }); + let touches_marks = edit.is_none_or(|e| { + matches!(e, Edit::AddHeaderFooter { .. } | Edit::AddWatermark { .. } | Edit::AddBackground { .. } | Edit::RemoveMarks { .. }) + }); + (touches_added, touches_marks) + } + + /// Rebuild working bytes, display bytes and renderer after a content-only edit: page + /// content streams, resources and added content/marks. The page tree, annotations, form + /// fields, links, outline and fonts are unchanged, so the full re-inspection (seconds on + /// very large files) is skipped. `edit` selects which derived lists are re-read (`None`, + /// from undo/redo, re-reads both: always correct, rarely paid). The cached display + /// appearances are kept: content edits leave every annotation dictionary alone. + fn refresh_content(doc: &mut Document, edit: Option<&Edit>) -> Result<(), EditError> { + Self::release_working(doc); + sync_xfa_state(doc); + let Some(editor) = doc.editor.as_mut() else { return Ok(()) }; + let bytes = if editor.cos.is_modified() { + Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?) + } else { + editor.cos.bytes().clone() + }; + // The edit must still leave a file the viewer can open; anything else rolls back above. + pdfcraft_render::check_pages(bytes.clone(), doc.password.as_deref(), doc.info.pages.len()).map_err(|e| EditError::Reopen(e.to_string()))?; + let (touches_added, touches_marks) = Self::content_refresh_lists(edit); + if touches_added || touches_marks { + doc.marker_scan = OnceLock::new(); + } + doc.info.file_size = bytes.len(); + 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; + Ok(()) + } + + /// Let go of the working file before the next one is written (gap 13). The working file is + /// the original plus one appended revision, so on a 1 GiB file keeping the old one (in the + /// working bytes, the display bytes and the renderer) while writing the new one held three + /// copies at once; this keeps two. Until the refresh finishes, the working and display bytes + /// are the original's (already held by the editor, so free) and the renderer draws nothing. + /// A failed edit rolls back and refreshes again, which replaces all three; where a refresh + /// still fails, [`Self::show_released`] draws the original rather than nothing. Save writes + /// from the editor, never from these. + pub(crate) fn release_working(doc: &mut Document) { + let Some(original) = doc.editor.as_ref().map(|e| e.cos.bytes().clone()) else { return }; + // Dropping the pool stops its workers; each lets go of the file when it exits. + 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), + /// so the page still shows a document. + fn show_released(doc: &mut Document) { + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + } + + /// Rebuild working bytes, inspection and renderer from the current edit state. + pub(crate) fn refresh(doc: &mut Document) -> Result<(), EditError> { + Self::refresh_with_indexes(doc, true, true) + } + + /// Reinspect a link edit while preserving the page-content marker scan cache. + fn refresh_links(doc: &mut Document) -> Result<(), EditError> { + Self::refresh_with_indexes(doc, false, true) + } + + fn refresh_with_indexes(doc: &mut Document, invalidate_markers: bool, invalidate_links: bool) -> Result<(), EditError> { + // A full refresh may follow structural or annotation changes: cached appearances + // may be stale, so they are rebuilt below. + if let Some(editor) = doc.editor.as_mut() { + editor.appearances.clear(); + } + Self::release_working(doc); + sync_xfa_state(doc); + let Some(editor) = doc.editor.as_mut() else { return Ok(()) }; + let bytes = if editor.cos.is_modified() { + Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?) + } else { + editor.cos.bytes().clone() + }; + let info = inspect(bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; + // Keep the user's layer choices where the layers still exist. + let mut info = info; + for l in &mut info.layers { + if let Some(old) = doc.info.layers.iter().find(|o| o.id == l.id) { + l.visible = old.visible; + } + } + note_warnings(&mut info.warnings, &doc.xfa_warnings); + // As in `build_document`: the inspection reads only `/Info`, so fields stated only in XMP + // are filled in again after every full refresh. + for (field, value) in info_fields(&mut info, &editor.cos) { + if field.is_none() { + *field = value; + } + } + doc.info = info; + doc.fonts_stale = false; + let mut form = pdfcraft_forms::fields(&editor.cos); + xfa::mark_script_buttons(&editor.cos, &mut form); + doc.form = Arc::new(form); + if invalidate_markers { + doc.marker_scan = OnceLock::new(); + } + if invalidate_links { + doc.links = OnceLock::new(); + } + 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(()) + } + + /// 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 && (!editor.cos.reconstructed() || doc.is_signed()) { + return Ok(editor.saved.clone()); + } + // What the user removed again before saving (a cleared redaction mark) stays out of the file. + let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), drop_unlinked: true, ..SaveOptions::default() }; + // A restored snapshot can be based on an earlier revision. Append to the last saved + // file so that revision stays in the chain. A full rewrite (redaction, new encryption, + // a reconstructed file) still writes the current graph on its own. + let rewrite = editor.cos.full_save_required() || editor.cos.encryption_changed() || editor.cos.revisions().is_empty(); + let cos = writable(&editor.cos)?; + guard(|| { + if on_saved || rewrite { + write_incremental(&cos, &opts) + } else { + let mut base = pdfcraft_cos::Document::open_with_password(editor.saved.clone(), editor.saved_keys.reopen.as_deref())?; + base.overlay_changes_from(&cos)?; + write_incremental(&base, &opts) + } + }) + .map_err(EditError::Write)? + .map(Arc::new) + .map_err(|e| EditError::Write(e.to_string())) + } + + /// A compact, garbage-collected rewrite (Save As ▸ "Optimized" / Reduce File Size groundwork). + pub fn save_full_bytes(&self, id: DocId) -> Result>, EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + if doc.is_signed() { + return Err(EditError::Signed); + } + let editor = doc.editor.as_ref().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; + let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; + let cos = writable(&editor.cos)?; + guard(|| write_full(&cos, &opts)).map_err(EditError::Write)?.map(Arc::new).map_err(|e| EditError::Write(e.to_string())) + } + + /// Check that `bytes`, from [`Self::save_bytes`] or [`Self::save_full_bytes`], reopen as this + /// document before they are written (#1698): [`Self::mark_saved`] only finds out after the + /// file has been replaced. Same error as `mark_saved`. + pub fn check_save(&self, id: DocId, bytes: &Arc>) -> Result<(), EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + let Some(editor) = doc.editor.as_ref() else { return Ok(()) }; + pdfcraft_cos::Document::open_with_password(bytes.clone(), editor.keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; + pdfcraft_render::check_pages(bytes.clone(), doc.password.as_deref(), doc.info.pages.len()).map_err(|e| EditError::Reopen(e.to_string())) + } + + /// Record a successful save of `bytes` (to `path`, if any): rebase editing on the saved file + /// so the next save appends only newer edits. Undo history is kept. + pub fn mark_saved(&mut self, id: DocId, bytes: Arc>, path: Option) -> Result<(), EditError> { + let doc = self.doc_mut(id)?; + if let Some(editor) = doc.editor.as_mut() { + // An encrypted file needs the password it is protected with now (the owner + // password when known, so saving never downgrades this session's rights). + let keys = editor.keys.clone(); + // The saved file carries no Password-field values (#3943, #3944); the open document keeps them. + let typed = pdfcraft_forms::password_values(&editor.cos); + editor.cos = + pdfcraft_cos::Document::open_with_password(bytes.clone(), keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?; + pdfcraft_forms::restore_password_values(&mut editor.cos, &typed).map_err(|e| EditError::Reopen(e.to_string()))?; + if !typed.is_empty() && doc.info.xfa.is_some() { + let mut datasets = None; + let _ = xfa_sync_datasets(&mut editor.cos, doc.xfa_template.as_deref(), &mut datasets); + } + 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()); + doc.path = Some(p); + } + doc.dirty = false; + doc.generation += 1; + Self::refresh(doc).inspect_err(|_| Self::show_released(doc)) + } + + /// Enter the permissions (owner) password of a document opened with its user password, + /// lifting its restrictions for this session (Acrobat asks for it before changing security). + /// Not an edit: nothing is marked unsaved and there is no undo step. Undo and redo states + /// are unlocked as well, and saving keeps the rights. Unencrypted documents and documents + /// opened by the owner need nothing. + pub fn unlock(&mut self, id: DocId, password: &str) -> Result<(), EditError> { + let doc = self.doc_mut(id)?; + let editor = doc.editor.as_mut().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; + if editor.cos.permissions().is_none_or(|p| p.owner) { + return Ok(()); + } + editor.cos.unlock(password).map_err(|e| match e { + pdfcraft_cos::CosError::WrongPassword => EditError::WrongPassword, + other => EditError::Protection(other.to_string()), + })?; + // Saving a state that keeps the security it was opened with reopens it with the same + // passwords, so it reopens as owner. + let reopen_as_owner = |cos: &pdfcraft_cos::Document, keys: &mut Keys| { + if !cos.encryption_changed() { + keys.reopen = Some(password.to_owned()); + } + }; + reopen_as_owner(&editor.cos, &mut editor.keys); + // Other states with the same restricted access are unlocked too; states opened with + // other passwords (protection changed and saved since) stay as they are. + for (_, cos, keys, _) in editor.undo.iter_mut().chain(editor.redo.iter_mut()) { + if cos.permissions().is_some_and(|p| !p.owner) && cos.unlock(password).is_ok() { + reopen_as_owner(cos, keys); + } + } + Ok(()) + } + + /// File ▸ Revert: back to the last saved version (or the file as opened). Undo history is + /// cleared, as in Acrobat. + pub fn revert(&mut self, id: DocId) -> Result<(), EditError> { + let doc = self.doc_mut(id)?; + let editor = doc.editor.as_mut().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?; + let base = editor.saved.clone(); + // The saved file opens with the passwords it was saved with. The current snapshot may + // be an older revision (undo past that save), so its own base is not the saved file. + let mut candidates = vec![editor.saved_keys.clone(), editor.keys.clone()]; + candidates.extend(editor.undo.iter().rev().map(|s| s.2.clone())); + candidates.push(Keys::default()); + let (cos, keys) = candidates + .into_iter() + .find_map(|k| pdfcraft_cos::Document::open_with_password(base.clone(), k.reopen.as_deref()).ok().map(|c| (c, k))) + .ok_or_else(|| EditError::Reopen("the saved file can't be opened".into()))?; + editor.cos = cos; + editor.keys = keys; + editor.undo.clear(); + editor.redo.clear(); + Self::adopt_keys(doc); + Self::sync_dirty(doc); + doc.generation += 1; + Self::refresh(doc).inspect_err(|_| Self::show_released(doc)) + } + + /// The document's file changed on disk (a LaTeX or Typst build rewrote it, #431): open + /// `bytes`, the file as it is now, in its place. Everything opening does runs again (XFA + /// layout and scripts, signatures, repairs), but the document keeps its id, name and path, + /// so its tab and view stay with it. Undo history goes, as with Revert. The password it was + /// opened with is tried; on any failure the document is left as it was. + pub fn reload(&mut self, id: DocId, bytes: Arc>) -> Result<(), OpenError> { + let old = self.docs.iter().position(|d| d.id == id).ok_or_else(|| OpenError::Invalid("the document is not open".into()))?; + let doc = &self.docs[old]; + let (name, path, generation) = (doc.name.clone(), doc.path.clone(), doc.generation); + let password = doc.editor.as_ref().and_then(|e| e.keys.reopen.clone()).or_else(|| doc.password.clone()); + let new_id = self.open(name, path, bytes, password.as_deref())?; + 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; + doc.snapshot_generation = doc.generation; + // `open` pushed the new version last, so the old one is still at `old`. + *self.docs.get_mut(old).ok_or_else(|| OpenError::Invalid("the document is not open".into()))? = doc; + Ok(()) + } + + /// The page count of another PDF (Replace Pages, Insert Pages dialogs). + pub fn page_count_of(&self, name: &str, bytes: &Arc>) -> Result { + Ok(pdfcraft_organize::page_count(&open_source(name, bytes)?)?) + } + + /// A new blank document (Create ▸ Blank page). + pub fn create_blank(&self, width: f64, height: f64, pages: usize) -> Result>, EditError> { + self.write_new(&pdfcraft_create::blank(width, height, pages)?) + } + + /// A new document with one page per image (PNG, JPEG). + pub fn create_from_images(&self, images: &[(String, Vec)]) -> Result>, EditError> { + self.write_new(&pdfcraft_create::from_images(images)?) + } + + /// Create image pages at embedded resolution or a fixed dpi, without resampling. + pub fn create_from_images_with_resolution(&self, images: &[(String, Vec)], resolution: ImageResolution) -> Result>, EditError> { + self.write_new(&pdfcraft_create::from_images_with_resolution(images, resolution)?) + } + + /// A new document from plain text (US Letter, 11 pt Helvetica). Text Helvetica can't draw is + /// refused, naming the line and the character (never set as `?`). + pub fn create_from_text(&self, title: &str, text: &str) -> Result>, EditError> { + self.write_new(&pdfcraft_create::from_text(title, text, pdfcraft_create::LETTER, 11.0)?) + } + + /// [`Self::create_from_text`] for a report PdfCraft writes from documents (comment summaries, + /// compare reports), which can quote any language: characters Helvetica can't draw are written + /// as `[U+XXXX]`, and the report's first line says so, rather than the report being refused. + pub(crate) fn create_report(&self, title: &str, text: &str) -> Result>, EditError> { + let (doc, _escaped) = pdfcraft_create::from_text_escaped(title, text, pdfcraft_create::LETTER, 11.0)?; + self.write_new(&doc) + } + + /// Convert a file Create understands (an image or plain text) to PDF bytes; a PDF is checked + /// (it must open and allow copying pages) and returned as it is. + pub fn convert_to_pdf(&self, name: &str, bytes: &Arc>) -> Result<(SourceKind, Arc>), EditError> { + let Some(kind) = source_kind(name, bytes) else { + return Err(EditError::Source(format!("{name}: this file type can't be converted; use a PDF, an image or a .txt file"))); + }; + let title = name.rsplit_once('.').map_or(name, |(s, _)| s); + // Image decoders read untrusted bytes: a panic in one must not take the app down. + let created = guard(|| match kind { + SourceKind::Pdf => open_source(name, bytes).map(|_| bytes.clone()), + SourceKind::Image => self.create_from_images(&[(name.to_string(), bytes.to_vec())]), + SourceKind::Text => self.create_from_text(title, &decode_text(bytes)), + }) + .map_err(|_| EditError::Source(format!("{name}: the file could not be read")))?; + Ok((kind, created?)) + } + + /// A file for Combine as PDF bytes: an image or text file is converted; a PDF is returned as + /// it is, unchecked (Combine reads it itself, with its password), as is a file named .pdf + /// without a `%PDF-` header, which the reader may still open. + pub fn as_pdf(&self, name: &str, bytes: &Arc>) -> Result>, EditError> { + let pdf = match source_kind(name, bytes) { + Some(SourceKind::Pdf) => true, + Some(SourceKind::Image | SourceKind::Text) => false, + None => name.to_ascii_lowercase().ends_with(".pdf"), + }; + if pdf { Ok(bytes.clone()) } else { self.convert_to_pdf(name, bytes).map(|(_, pdf)| pdf) } + } + + /// Reduce File Size: Acrobat's defaults (images above 225 ppi to 150 ppi, JPEG medium + /// quality; thumbnails dropped), identical resources merged, unused objects dropped, objects + /// packed into compressed object streams. Returns the bytes and how many objects were + /// merged. The open document is not changed (Acrobat saves the reduced copy as a new file). + pub fn reduced_bytes(&self, id: DocId) -> Result<(Arc>, usize), EditError> { + let (bytes, report) = self.optimized_bytes(id, &optimize::Settings::default(), &[])?; + Ok((bytes, report.merged)) + } + + /// Optimize PDF ▸ Advanced optimization: `settings` for images and objects, plus Remove + /// Hidden Information's `discard` categories (user data). A full rewrite: signed documents + /// are refused. The open document is not changed. This is [`Self::optimize_job`] run in + /// place, without progress. + pub fn optimized_bytes(&self, id: DocId, settings: &optimize::Settings, discard: &[Hidden]) -> Result<(Arc>, OptimizeReport), EditError> { + self.optimize_job(id, settings, discard)?.run(|_| true) + } + + /// Export comments and/or form data: XFDF and FDF carry either or both; XML, CSV and text + /// are form data only. + pub fn export_data(&self, id: DocId, format: DataFormat, comments: bool, fields: bool) -> Result, EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + let 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()))? + } + }; + // An XFA form's data is its datasets, as Acrobat exports it: XML or an XDP. + let xfa = doc.info.xfa.is_some(); + if format == DataFormat::Xdp && !xfa { + return Err(EditError::Invalid("XDP holds XFA form data; this document is not an XFA form (use .xml, .csv or .txt)".into())); + } + guard(|| match format { + DataFormat::Xfdf => Ok(pdfcraft_xfdf::export_xfdf(&cos, comments, fields, &doc.name).into_bytes()), + 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()) + } + other => Ok(pdfcraft_xfdf::export_data(&cos, other).into_bytes()), + }) + .map_err(EditError::Write)? + .map_err(EditError::Write) + } + + /// The print-ready PDF for `settings` (sheets laid out for the paper; see `pdfcraft-print`). + pub fn print_pdf(&self, id: DocId, settings: &print::Settings) -> Result, EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + if !doc.allows_printing() { + return Err(EditError::NotPermitted("printing")); + } + // Table 22, bit 12 clear: only a low-level representation may be printed, so the pages + // always go as pictures, at no more than the low resolution. + let low_res = doc.permissions().is_some_and(|p| !p.print_high_quality()).then(|| print::Raster { + dpi: settings.raster.map_or(LOW_RESOLUTION_PRINT_DPI, |r| r.dpi.min(LOW_RESOLUTION_PRINT_DPI)), + grayscale: settings.raster.is_some_and(|r| r.grayscale), + }); + // Print as image: the pages used become pictures first, then the picture document is + // imposed in their place (its page k is the k-th distinct source page used). + if let Some(raster) = low_res.as_ref().or(settings.raster.as_ref()) { + let mut distinct: Vec = Vec::new(); + for &p in &settings.pages { + if !distinct.contains(&p) { + distinct.push(p); + } + } + let bytes = print_raster::rasterize(doc, &distinct, raster, settings.content).map_err(EditError::Print)?; + let pictures = pdfcraft_cos::Document::open(Arc::new(bytes)).map_err(|e| EditError::Print(e.to_string()))?; + let remapped = print::Settings { + pages: settings.pages.iter().map(|p| distinct.iter().position(|d| d == p).unwrap_or(0)).collect(), + content: print::Content::Document, + raster: None, + ..settings.clone() + }; + return pdfcraft_print::impose(&pictures, &remapped).map_err(|e| EditError::Print(e.to_string())); + } + 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())) + } + + /// For each page, whether its text mostly runs bottom-to-top ([`pdfcraft_print::text_reads_up`]); + /// only `pages` are read, the others are `false`. + pub fn print_text_up(&self, id: DocId, pages: &[usize]) -> Vec { + let Some(doc) = self.get(id) else { return Vec::new() }; + let cos = match doc.editor.as_ref() { + Some(e) => e.cos.clone(), + None => match pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()) { + Ok(c) => c, + Err(_) => return Vec::new(), + }, + }; + (0..doc.info.pages.len()).map(|i| pages.contains(&i) && pdfcraft_print::text_reads_up(&cos, i)).collect() + } + + /// Combine whole files, in order, into new PDF bytes (one bookmark per file). + pub fn combine(&self, sources: &[(String, Arc>)]) -> Result>, EditError> { + let docs = sources.iter().map(|(n, b)| open_source(n, b)).collect::, _>>()?; + let named: Vec<(&str, &pdfcraft_cos::Document)> = sources.iter().map(|(n, _)| n.as_str()).zip(docs.iter()).collect(); + let out = pdfcraft_organize::combine(&named)?; + self.write_new(&out) + } + + /// Combine Files with a page range per file ("1-3, 6"; `None` or empty for all pages). + pub fn combine_ranges(&self, sources: &[CombineSource]) -> Result>, EditError> { + self.combine_unlocked(sources, &[]) + } + + /// Combine sources one at a time, in order (Create ▸ Multiple files), to the same result as + /// [`Self::combine_ranges`]. Each item is a source's bookmark title, its PDF bytes (see + /// [`Self::convert_to_pdf`]) and its page range (`None` for all pages). Items are pulled one at a + /// time: a source is opened, its pages are copied into the result, and its bytes are released + /// before the next item is read. So the number of sources is not limited, and one source's + /// document is held at a time. An item's error stops the combine and is returned as it is. + /// `Ok(None)` when there are no sources. + pub fn combine_each(&self, sources: I) -> Result>>, E> + where + I: IntoIterator>, + E: From, + { + let mut combiner = pdfcraft_organize::Combiner::new(); + let mut added = false; + for source in sources { + let (title, bytes, range) = source?; + let doc = open_source(&title, &bytes)?; + let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty()); + let pages = match range { + Some(r) => { + let n = pdfcraft_organize::page_count(&doc).map_err(EditError::from)?; + let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false) + .map_err(|e| EditError::Print(format!("{title}: {e}")))?; + Some(p) + } + None => None, + }; + combiner.add(&title, &doc, pages.as_deref()).map_err(EditError::from)?; + added = true; + } + if !added { + return Ok(None); + } + let out = combiner.finish().map_err(EditError::from)?; + Ok(Some(self.write_new(&out)?)) + } + + /// [`Self::combine_ranges`] with the password each encrypted source is opened with, by + /// position (missing or `None`: no password). The result is not encrypted. + pub fn combine_unlocked(&self, sources: &[CombineSource], passwords: &[Option<&str>]) -> Result>, EditError> { + Ok(self.combine_unlocked_reporting(sources, passwords, pdfcraft_organize::FieldConflicts::Rename)?.bytes) + } + + /// The pre-flight over byte sources: what combining them, in order, would rename in each one + /// (whole files; page ranges are the combine's to apply). A source that cannot be opened + /// previews as nothing — its row already says why it cannot be combined. + pub fn combine_field_conflicts(&self, sources: &[CombineSource], passwords: &[Option<&str>]) -> Vec> { + let docs: Vec> = + sources.iter().enumerate().map(|(i, (n, b, _))| open_source_with(n, b, passwords.get(i).copied().flatten()).ok()).collect(); + let refs: Vec<&pdfcraft_cos::Document> = docs.iter().filter_map(Option::as_ref).collect(); + let mut opened = pdfcraft_organize::combine_field_conflicts(&refs).into_iter(); + // One preview per source, in step with it: a file that cannot be opened has none. + docs.iter().map(|d| if d.is_some() { opened.next().unwrap_or_default() } else { Default::default() }).collect() + } + + /// [`Self::combine_unlocked`] reporting what the conflict resolution renamed. + pub fn combine_unlocked_reporting( + &self, + sources: &[CombineSource], + passwords: &[Option<&str>], + field_conflicts: pdfcraft_organize::FieldConflicts, + ) -> Result { + let docs = sources + .iter() + .enumerate() + .map(|(i, (n, b, _))| open_source_with(n, b, passwords.get(i).copied().flatten())) + .collect::, _>>()?; + let mut pages = Vec::with_capacity(docs.len()); + for ((name, _, range), d) in sources.iter().zip(&docs) { + let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty()); + pages.push(match range { + Some(r) => { + let n = pdfcraft_organize::page_count(d)?; + let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false) + .map_err(|e| EditError::Print(format!("{name}: {e}")))?; + Some(p) + } + None => None, + }); + } + let named: Vec<(&str, &pdfcraft_cos::Document, Option<&[usize]>)> = + sources.iter().zip(docs.iter()).zip(&pages).map(|(((n, _, _), d), p)| (n.as_str(), d, p.as_deref())).collect(); + let out = pdfcraft_organize::combine_selected_with(&named, field_conflicts)?; + let bytes = self.write_new(&out.doc)?; + Ok(Combined { bytes, renamed_fields: out.renamed_fields }) + } + + /// [`Self::combine_unlocked`] where sources with the same `groups` key (by position) are one + /// file split into several runs placed apart: each file is opened and copied once, so it keeps + /// one bookmark, its links between its own pages, its fields and its attachments whole (see + /// `pdfcraft_organize::combine_grouped`). A group's later sources use its first source's bytes + /// and password; a source without a key is a file of its own. + pub fn combine_grouped(&self, sources: &[CombineSource], groups: &[u64], passwords: &[Option<&str>]) -> Result>, EditError> { + Ok(self.combine_grouped_reporting(sources, groups, passwords, pdfcraft_organize::FieldConflicts::Rename)?.bytes) + } + + /// [`Self::combine_grouped`] reporting what the conflict resolution renamed. + pub fn combine_grouped_reporting( + &self, + sources: &[CombineSource], + groups: &[u64], + passwords: &[Option<&str>], + field_conflicts: pdfcraft_organize::FieldConflicts, + ) -> Result { + let (mut keys, mut docs, mut file): (Vec>, Vec, Vec) = (Vec::new(), Vec::new(), Vec::new()); + for (i, (name, bytes, _)) in sources.iter().enumerate() { + let key = groups.get(i).copied(); + let known = key.and_then(|k| keys.iter().position(|g| *g == Some(k))); + let at = match known { + Some(at) => at, + None => { + docs.push(open_source_with(name, bytes, passwords.get(i).copied().flatten())?); + keys.push(key); + docs.len() - 1 + } + }; + file.push(at); + } + let mut pages = Vec::with_capacity(sources.len()); + for ((name, _, range), at) in sources.iter().zip(&file) { + let range = range.as_deref().map(str::trim).filter(|r| !r.is_empty()); + let Some(d) = docs.get(*at) else { return Err(EditError::Write(format!("{name}: not opened"))) }; + pages.push(match range { + Some(r) => { + let n = pdfcraft_organize::page_count(d)?; + let p = pdfcraft_print::select_pages(n, Some(r), &[], pdfcraft_print::Subset::All, false) + .map_err(|e| EditError::Print(format!("{name}: {e}")))?; + Some(p) + } + None => None, + }); + } + let mut runs: Vec> = Vec::with_capacity(sources.len()); + for (((name, _, _), at), p) in sources.iter().zip(&file).zip(&pages) { + let Some(d) = docs.get(*at) else { return Err(EditError::Write(format!("{name}: not opened"))) }; + runs.push((*at, name.as_str(), d, p.as_deref())); + } + let out = pdfcraft_organize::combine_grouped_with(&runs, field_conflicts)?; + let bytes = self.write_new(&out.doc)?; + Ok(Combined { bytes, renamed_fields: out.renamed_fields }) + } + + /// New PDF bytes containing copies of `pages` of the document (Extract Pages). + pub fn extract(&self, id: DocId, pages: &[usize]) -> Result>, EditError> { + let src = self.cos(id)?; + if src.permissions().is_some_and(|p| !p.assemble()) { + return Err(EditError::NotPermitted("extracting pages")); + } + let out = pdfcraft_organize::extract_pages(src, pages)?; + self.write_new(&out) + } + + /// Split the document into several new PDFs. + pub fn split(&self, id: DocId, by: &pdfcraft_organize::SplitBy) -> Result, EditError> { + let src = self.cos(id)?; + if src.permissions().is_some_and(|p| !p.assemble()) { + return Err(EditError::NotPermitted("splitting the document")); + } + let n = pdfcraft_organize::page_count(src)?; + pdfcraft_organize::split_ranges(n, by) + .into_iter() + .map(|r| { + let doc = pdfcraft_organize::extract_pages(src, &r.clone().collect::>())?; + Ok((r.start + 1, r.end, self.write_new(&doc)?)) + }) + .collect() + } + + /// Split into parts of at most `max_bytes` each (Acrobat's "File size"): pages are taken in + /// order while their size (as single-page files, an over-estimate because shared fonts and + /// images count once per part) fits; a page larger than the limit is a part of its own. + pub fn split_by_size(&self, id: DocId, max_bytes: usize) -> Result, EditError> { + let src = self.cos(id)?; + if src.permissions().is_some_and(|p| !p.assemble()) { + return Err(EditError::NotPermitted("splitting the document")); + } + let n = pdfcraft_organize::page_count(src)?; + let mut cuts = Vec::new(); + let mut used = 0usize; + for p in 0..n { + let size = self.write_unchecked(&pdfcraft_organize::extract_pages(src, &[p])?)?.len(); + if used > 0 && used + size > max_bytes { + cuts.push(p); + used = 0; + } + used += size; + } + self.split(id, &pdfcraft_organize::SplitBy::Before(cuts)) + } + + /// Web addresses in the text of every page (Create links from URLs): (page, one rectangle + /// per line in user space, URI). Text that already has a link over it is skipped. + pub fn find_urls(&self, id: DocId) -> Vec<(usize, Vec<[f64; 4]>, String)> { + let Some(doc) = self.get(id) else { return Vec::new() }; + let config = RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() }; + let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config); + let mut out = Vec::new(); + for page in 0..doc.info.pages.len() { + let res = r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }); + let Some(text) = res.text else { continue }; + let found: std::cell::RefCell, String)>> = Default::default(); + let hits = text.find_with(|chars| { + let urls = pdfcraft_annot::links::find_urls(chars); + let ranges = urls.iter().map(|u| u.0.clone()).collect(); + *found.borrow_mut() = urls; + ranges + }); + for (glyphs, (_, uri)) in hits.into_iter().zip(found.into_inner()) { + let rects: Vec<[f64; 4]> = text + .line_rects(glyphs) + .into_iter() + .map(|v| { + let q = doc.info.pages[page].view_rect_to_quad(v); + let xs = [q[0], q[2], q[4], q[6]]; + let ys = [q[1], q[3], q[5], q[7]]; + [ + xs.iter().copied().fold(f64::MAX, f64::min), + ys.iter().copied().fold(f64::MAX, f64::min), + xs.iter().copied().fold(f64::MIN, f64::max), + ys.iter().copied().fold(f64::MIN, f64::max), + ] + }) + .filter(|r| { + !doc.links().iter().any(|l| l.page == page && l.rect[0] < r[2] && r[0] < l.rect[2] && l.rect[1] < r[3] && r[1] < l.rect[3]) + }) + .collect(); + if !rects.is_empty() { + out.push((page, rects, uri)); + } + } + } + out + } + + /// A page of the bookmark list in outline order: up to `limit` bookmarks from position `offset`. + /// The document's own structure is read for it, so every bookmark can be reached by paging. + pub fn bookmark_page(&self, id: DocId, offset: usize, limit: usize) -> Option { + let doc = self.get(id)?; + if let Some(editor) = doc.editor.as_ref() { + let page = pdfcraft_organize::bookmark_page(&editor.cos, offset, limit); + let rows = page + .bookmarks + .into_iter() + .map(|b| { + let view = b.view.map_or_else(Default::default, |(kind, numbers)| { + let numbers: Vec> = numbers.iter().map(|n| n.map(|n| n as f32)).collect(); + pdfcraft_render::DestView::from_operands(Some(&kind), &numbers) + }); + BookmarkRow { path: b.path, title: b.title, open: b.open, page: b.page, view, color: b.color, italic: b.italic, bold: b.bold } + }) + .collect(); + return Some(BookmarkPage { rows, next: page.next, truncated: false }); + } + let all = outline_rows(&doc.info.outline); + let end = offset.saturating_add(limit); + let rows = all.iter().skip(offset).take(limit).cloned().collect(); + Some(BookmarkPage { rows, next: (end < all.len()).then_some(end), truncated: doc.info.outline_more }) + } + + /// Top-level bookmarks as split points: (first page of each part, its bookmark's title). + pub fn bookmark_splits(&self, id: DocId) -> Vec<(usize, String)> { + let Some(doc) = self.get(id) else { return Vec::new() }; + let top: Vec<(Option, String)> = match doc.editor.as_ref() { + Some(editor) => pdfcraft_organize::top_level_bookmarks(&editor.cos).into_iter().map(|b| (b.page, b.title)).collect(), + None => doc.info.outline.iter().map(|o| (o.page, o.title.clone())).collect(), + }; + let mut out: Vec<(usize, String)> = top.into_iter().filter_map(|(page, title)| Some((page?, title))).collect(); + out.sort_by_key(|x| x.0); + out.dedup_by_key(|x| x.0); + out + } + + /// Open freshly created bytes (combine / extract) as a new, unsaved document. + pub fn open_new(&mut self, name: impl Into, bytes: Arc>) -> Result { + 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; + } + Ok(id) + } + + fn cos(&self, id: DocId) -> Result<&pdfcraft_cos::Document, EditError> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + doc.editor.as_ref().map(|e| &e.cos).ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default())) + } + + /// A new file (Combine, Extract, Split, Create) as bytes, checked like a save before anyone + /// writes it (#1698, #2166): it reopens, and shows the pages it was built from. + fn write_new(&self, doc: &pdfcraft_cos::Document) -> Result>, EditError> { + let bytes = self.write_unchecked(doc)?; + let expected = pdfcraft_organize::page_count(doc)?; + pdfcraft_cos::Document::open(bytes.clone()).map_err(|e| EditError::Reopen(e.to_string()))?; + pdfcraft_render::check_pages(bytes.clone(), None, expected).map_err(|e| EditError::Reopen(e.to_string()))?; + Ok(bytes) + } + + /// [`Self::write_new`] without the check, for bytes nobody writes: measuring a part's size. + /// Like a save, a new file never carries a typed Password-field value (#3926): Extract and + /// Split copy the fields of the pages they take. + fn write_unchecked(&self, doc: &pdfcraft_cos::Document) -> Result>, EditError> { + let opts = SaveOptions { mod_date: self.now().map(pdfcraft_cos::pdf_date), ..SaveOptions::default() }; + let doc = writable(doc)?; + 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 { + let mut out = Vec::new(); + for d in &mut self.docs { + if d.dirty && d.generation != d.snapshot_generation { + // The working bytes hold typed Password-field values (#3926, #3944): a recovery file + // is written from a copy without them, and none is written if that copy fails. + let bytes = + match d.editor.as_ref().filter(|e| pdfcraft_forms::has_password_values(&e.cos) || pdfcraft_xfa::has_password_values(&e.cos)) { + Some(e) => match writable(&e.cos) + .and_then(|c| write_incremental(&c, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))) + { + Ok(b) => Arc::new(b), + Err(_) => continue, + }, + None => d.bytes.clone(), + }; + d.snapshot_generation = d.generation; + out.push(RecoverySnapshot { + doc: d.id, + name: d.name.clone(), + path: d.path.clone(), + bytes, + encrypted: d.info.encrypted || d.editor.as_ref().is_some_and(|e| e.cos.security().is_some()), + }); + } + } + out + } + + /// Mark a document opened from a recovery file: it has unsaved changes and belongs at + /// `path` (where Save writes), as when the session ended. + pub fn mark_recovered(&mut self, id: DocId, path: Option) { + 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 + } + } + + /// Show or hide a layer (optional content group) for viewing. Returns `true` if it changed; + /// callers must drop cached rasters and text for the document. + pub fn set_layer_visible(&mut self, id: DocId, layer: usize, visible: bool) -> bool { + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; + let Some(l) = doc.info.layers.get_mut(layer) else { return false }; + if l.visible == visible { + return false; + } + l.visible = visible; + use_layer_choices(doc); + true + } + + /// Run a set-layer-visibility action (`SetOCGState`): apply `changes` in order, naming each + /// layer by its optional content group; groups that aren't layers are skipped. With + /// `preserve_rb`, a layer turned on turns off the other layers of its radio-button groups. + /// Returns `true` if any layer changed; callers must drop cached rasters and text for the + /// document, as for [`Self::set_layer_visible`]. + pub fn set_layer_state(&mut self, id: DocId, changes: &[(LayerOp, (u32, u16))], preserve_rb: bool) -> bool { + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; + if !apply_layer_state(&mut doc.info.layers, &doc.info.layer_groups, changes, preserve_rb) { + return false; + } + use_layer_choices(doc); + 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 { + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; + if doc.config.hide_comments == hide { + return false; + } + doc.config.hide_comments = hide; + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + doc.generation += 1; + true + } + + /// Set the document's reading colours (Acrobat's Replace Document Colors). Returns whether + /// anything changed; callers must drop cached rasters and text for the document. + pub fn set_reading_colors(&mut self, id: DocId, colors: ReadingColors) -> bool { + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; + if doc.config.reading_colors == colors { + return false; + } + doc.config.reading_colors = colors; + doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone()); + doc.generation += 1; + true + } + + /// The document's reading-colours setting; `None` when no such document is open. + pub fn reading_colors(&self, id: DocId) -> Option { + 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> { + let doc = self.get(id).ok_or(EditError::NoDocument)?; + let text = comment_summary(&doc.name, &doc.info.annotations, sort); + self.create_report(&format!("Summary of Comments on {}", doc.name), &text) + } + + /// The certificates trusted for signing. + pub fn trusted_certificates(&self) -> &[pdfcraft_sign::Certificate] { + &self.trust.certs + } + + /// 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(); + 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()); + } + } + + /// The signing time as a PDF date in local time with its offset (`D:…+02'00'`). + pub fn signing_date(&self) -> String { + let offset = self.local_offset(); + let local = pdfcraft_cos::pdf_date(self.now().unwrap_or(0) + offset); + let stamp = local.trim_end_matches('Z'); + if offset == 0 { + return format!("{stamp}Z"); + } + let (sign, m) = if offset < 0 { ('-', -offset / 60) } else { ('+', offset / 60) }; + format!("{stamp}{sign}{:02}'{:02}'", m / 60, m % 60) + } + + /// Sign the document's current state with `id` (Use a certificate ▸ Digitally sign). Returns + /// the signed file; the caller saves it and then calls [`Session::mark_signed`]. An empty + /// `opts.date` takes the session clock. + pub fn sign(&self, doc: DocId, id: &pdfcraft_sign::DigitalId, mut opts: SignOptions) -> Result>, EditError> { + let d = self.get(doc).ok_or(EditError::NoDocument)?; + // Encrypted documents are signed under their permissions (the signer checks them). + let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; + if opts.date.is_empty() { + opts.date = self.signing_date(); + } + // A typed Password-field value is not signed into the file (#3926), as it isn't saved. + let cos = writable(&editor.cos)?; + Ok(Arc::new(pdfcraft_sign::sign(&cos, id, &opts)?)) + } + + /// [`Session::sign`], embedding an RFC 3161 signature timestamp (PAdES B-T) produced by + /// `tsa`. The transport lives with the caller; the engine never opens a socket. + pub fn sign_with_timestamp( + &self, + doc: DocId, + id: &pdfcraft_sign::DigitalId, + mut opts: SignOptions, + tsa: &dyn pdfcraft_sign::TimestampAuthority, + ) -> Result>, EditError> { + let d = self.get(doc).ok_or(EditError::NoDocument)?; + let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; + if opts.date.is_empty() { + opts.date = self.signing_date(); + } + let cos = writable(&editor.cos)?; + let signed = Arc::new(pdfcraft_sign::sign_with_timestamp(&cos, id, &opts, tsa)?); + // Checked like a save before anyone writes it (#2166). + self.check_save(doc, &signed)?; + Ok(signed) + } + + /// Append a standalone document timestamp (RFC 3161, `/ETSI.RFC3161`) covering the file's + /// current state. An empty `date` takes the session clock; the transport is the caller's. + pub fn timestamp_document(&self, doc: DocId, tsa: &dyn pdfcraft_sign::TimestampAuthority, date: String) -> Result>, EditError> { + let d = self.get(doc).ok_or(EditError::NoDocument)?; + let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; + let date = if date.is_empty() { self.signing_date() } else { date }; + let cos = writable(&editor.cos)?; + let stamped = Arc::new(pdfcraft_sign::timestamp_document(&cos, tsa, &date)?); + self.check_save(doc, &stamped)?; + Ok(stamped) + } + + /// Embed revocation evidence into the catalog's `/DSS` with `/VRI` entries per signature + /// (PAdES B-LT): an incremental update that never rewrites signed bytes. + pub fn embed_ltv(&self, doc: DocId, evidence: &pdfcraft_sign::dss::Evidence) -> Result>, EditError> { + let d = self.get(doc).ok_or(EditError::NoDocument)?; + let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?; + let cos = writable(&editor.cos)?; + let embedded = Arc::new(pdfcraft_sign::dss::embed(&cos, evidence)?); + self.check_save(doc, &embedded)?; + Ok(embedded) + } + + /// Record that the signed file `bytes` was saved (to `path`): like [`Session::mark_saved`], + /// and the edit history before signing is dropped (signing can't be undone). + pub fn mark_signed(&mut self, id: DocId, bytes: Arc>, path: Option) -> Result<(), EditError> { + self.mark_saved(id, bytes, path)?; + let doc = self.doc_mut(id)?; + if let Some(e) = doc.editor.as_mut() { + e.undo.clear(); + e.redo.clear(); + } + Ok(()) + } + + pub fn close(&mut self, id: DocId) { + self.docs.retain(|d| d.id != id); + } + + pub fn get(&self, id: DocId) -> Option<&Document> { + self.docs.iter().find(|d| d.id == id) + } + + /// Read document `id`'s font list again if content edits since the last full inspection may + /// have changed it (Document Properties ▸ Fonts, `doc_info`). A no-op otherwise. + pub fn refresh_fonts(&mut self, id: DocId) { + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id && d.fonts_stale) else { return }; + if let Ok(info) = pdfcraft_render::inspect(doc.bytes.clone(), doc.password.as_deref()) { + doc.info.fonts = info.fonts; + } + doc.fonts_stale = false; + } + + pub fn docs(&self) -> &[Document] { + &self.docs + } +} diff --git a/crates/engine/src/sources.rs b/crates/engine/src/sources.rs new file mode 100644 index 0000000000..3c8083162e --- /dev/null +++ b/crates/engine/src/sources.rs @@ -0,0 +1,51 @@ +//! Other files to combine with or copy pages from: opening them, and why one can't be used. + +use std::sync::Arc; + +use crate::{EditError, SourceProblem}; + +/// A file to combine: its name (the bookmark title), bytes, and page range (`None`: all). +pub type CombineSource = (String, Arc>, Option); + +/// What a combine produced: the new PDF bytes, and the fields renamed to keep colliding names +/// separate (old fully-qualified name → new one), in order across the sources. +#[derive(Debug)] +pub struct Combined { + pub bytes: Arc>, + pub renamed_fields: Vec<(String, String)>, +} + +/// Parse another PDF to copy pages from. +pub(crate) fn open_source(name: &str, bytes: &Arc>) -> Result { + open_source_with(name, bytes, None) +} + +/// [`open_source`], authenticating with `password` (user or owner) for an encrypted file. +pub(crate) fn open_source_with(name: &str, bytes: &Arc>, password: Option<&str>) -> Result { + source_document(bytes, password).map_err(|p| EditError::Source(format!("{name}: {p}"))) +} + +fn source_document(bytes: &Arc>, password: Option<&str>) -> Result { + match std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)) { + Ok(Ok(d)) if d.permissions().is_some_and(|p| !p.assemble()) => Err(SourceProblem::NotPermitted), + Ok(Ok(d)) => Ok(d), + Ok(Err(pdfcraft_cos::CosError::NeedsPassword)) => Err(SourceProblem::Password), + Ok(Err(pdfcraft_cos::CosError::WrongPassword)) => Err(SourceProblem::WrongPassword), + Ok(Err(e)) => Err(SourceProblem::Unreadable(e.to_string())), + Err(_) => Err(SourceProblem::Unreadable("the file could not be read".into())), + } +} + +/// Whether `bytes` can be combined, opened with `password` (user or owner) if given, and if not +/// why: what Combine would refuse it for, so the Combine files list can say so before Combine +/// is pressed. The owner (permissions) password lifts the restriction on copying pages. +pub fn combine_source_check(bytes: &Arc>, password: Option<&str>) -> Result<(), SourceProblem> { + source_document(bytes, password).map(|_| ()) +} + +/// The number of pages of `bytes`, opened with `password` (user or owner) if given, whatever +/// its permissions allow; `None` when it can't be read. +pub fn source_page_count(bytes: &Arc>, password: Option<&str>) -> Option { + let doc = std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)).ok()?.ok()?; + pdfcraft_organize::page_count(&doc).ok() +} diff --git a/crates/engine/src/test_hooks.rs b/crates/engine/src/test_hooks.rs new file mode 100644 index 0000000000..d6e504599e --- /dev/null +++ b/crates/engine/src/test_hooks.rs @@ -0,0 +1,42 @@ +//! Test-only hooks: injected refresh panics and counts of the secondary page scans. + +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) }; +} + +pub(crate) fn panic_on_refresh(count: usize) { + REFRESH_PANICS.with(|n| n.set(count)); +} + +pub(crate) fn check_refresh_panic() { + REFRESH_PANICS.with(|n| { + let rem = n.get(); + if rem > 0 { + n.set(rem - 1); + panic!("injected refresh panic"); + } + }); +} + +pub(crate) fn reset_secondary_scan_counts() { + SECONDARY_SCAN_COUNTS.with(|counts| counts.set((0, 0))); +} + +pub(crate) fn secondary_scan_counts() -> (usize, usize) { + SECONDARY_SCAN_COUNTS.with(std::cell::Cell::get) +} + +pub(crate) fn record_marker_scan() { + SECONDARY_SCAN_COUNTS.with(|counts| { + let (markers, links) = counts.get(); + counts.set((markers + 1, links)); + }); +} + +pub(crate) fn record_link_scan() { + SECONDARY_SCAN_COUNTS.with(|counts| { + let (markers, links) = counts.get(); + counts.set((markers, links + 1)); + }); +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a7d984ee87..980eea0fec 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1,6 +1,9 @@ use std::sync::Arc; +use super::document::is_placeholder_title; +use super::edit::check_permission; use super::*; +use pdfcraft_render::{Layer, LayerOp, OpenError, ReadingColors}; /// A minimal `n`-page document; page `i` shows "Page i+1". pub(crate) fn fixture(n: usize) -> Vec { @@ -2284,6 +2287,82 @@ fn a_signed_file_carries_no_password_field_value() { assert!(!appears_in_pdf(&signed, "*******"), "nor the typed length"); } +/// #3944: a value typed into an XFA `` field stays in the open document (both the +/// widget and the live XFA datasets, even across multiple edits and across Save + `mark_saved`), +/// but is written to no file — neither on the widget nor in the XFA datasets stream. +#[test] +fn xfa_password_field_values_are_not_saved_in_datasets_or_widgets() { + let xfa_pdf = pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::fields_template("")); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("xfa.pdf", None, Arc::new(xfa_pdf), None).unwrap(); + let pin_name = s.get(id).unwrap().form.iter().find(|f| f.name.contains("pin")).unwrap().name.clone(); + + // Edit the password twice so an earlier edit's datasets stream could not linger in the overlay. + s.apply(id, Edit::SetFieldValue { name: pin_name.clone(), value: FieldValue::Text("secret1".into()) }).unwrap(); + s.apply(id, Edit::SetFieldValue { name: pin_name.clone(), value: FieldValue::Text("hunter2".into()) }).unwrap(); + + let field = s.get(id).unwrap().form.iter().find(|f| f.name == pin_name).cloned().unwrap(); + assert!(field.has(pdfcraft_forms::flags::PASSWORD)); + assert_eq!(field.value, vec!["hunter2".to_string()], "the open document keeps the value"); + assert!(appears_in_pdf(&s.get(id).unwrap().bytes, "hunter2"), "the working bytes hold the live datasets"); + assert!(!appears_in_pdf(&s.get(id).unwrap().bytes, "secret1"), "the first edit's datasets stream was replaced in place"); + + let saved = s.save_bytes(id).unwrap(); + for leak in ["hunter2", "secret1", "*******"] { + assert!(!appears_in_pdf(&saved, leak), "Save leaked {leak}"); + assert!(!appears_in_pdf(&s.save_full_bytes(id).unwrap(), leak), "save_full_bytes leaked {leak}"); + assert!(!appears_in_pdf(&s.reduced_bytes(id).unwrap().0, leak), "reduced_bytes leaked {leak}"); + } + let snaps = s.autosave_snapshots(); + assert_eq!(snaps.len(), 1); + for leak in ["hunter2", "secret1", "*******"] { + assert!(!appears_in_pdf(&snaps[0].bytes, leak), "autosave snapshot leaked {leak}"); + } + + // Reopening the saved file in a new session leaves the password field empty (it does not + // repopulate from the XFA datasets on open). + let mut t = Session::new(); + let reopened = t.open("saved.pdf", None, saved.clone(), None).unwrap(); + assert!( + t.get(reopened).unwrap().form.iter().find(|f| f.name == pin_name).unwrap().value.iter().all(|v| v.is_empty()), + "reopened XFA form must have an empty password field" + ); + + // Recording the save keeps the typed password in the open document (and its live datasets), + // while a subsequent save still omits it. + s.mark_saved(id, saved, Some("saved.pdf".into())).unwrap(); + assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == pin_name).unwrap().value, ["hunter2"]); + assert!(!s.get(id).unwrap().dirty); + for leak in ["hunter2", "secret1", "*******"] { + assert!(!appears_in_pdf(&s.save_bytes(id).unwrap(), leak), "save after mark_saved leaked {leak}"); + } + + // Undo past `mark_saved` and edit again: the datasets stream in the restored overlay is + // overwritten in place rather than left behind. + s.undo(id).unwrap(); + s.apply(id, Edit::SetFieldValue { name: pin_name.clone(), value: FieldValue::Text("secret3".into()) }).unwrap(); + let saved_after_undo = s.save_bytes(id).unwrap(); + for leak in ["hunter2", "secret1", "secret3", "*******"] { + assert!(!appears_in_pdf(&saved_after_undo, leak), "save after undo leaked {leak}"); + } + + // A relayout (`rerender`) after typing a password frees the replaced generated widget and its + // masked appearance stream so neither remains in the overlay when saving. + let relayout_xml = pdfcraft_xfa::fixtures::fields_template("").replace( + r#""#, + r#""#, + ); + let id2 = s.open("relayout.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&relayout_xml)), None).unwrap(); + let pin2 = s.get(id2).unwrap().form.iter().find(|f| f.name.contains("pin")).unwrap().name.clone(); + let grow2 = s.get(id2).unwrap().form.iter().find(|f| f.name.contains("grow")).unwrap().name.clone(); + s.apply(id2, Edit::SetFieldValue { name: pin2, value: FieldValue::Text("s3cr3t!".into()) }).unwrap(); + s.apply(id2, Edit::SetFieldValue { name: grow2, value: FieldValue::Text("line1\nline2\nline3\nline4\nline5".into()) }).unwrap(); + let saved_relayout = s.save_bytes(id2).unwrap(); + for leak in ["s3cr3t!", "*******"] { + assert!(!appears_in_pdf(&saved_relayout, leak), "save after relayout leaked {leak}"); + } +} + /// A value its field's font can't draw is kept, and the edit that drew it reports it in /// `edit_notices` (the interface shows it, tools return it): not a later unrelated edit, not a /// failed one, and not a value that can be drawn. The form (an inline AcroForm here) asks other @@ -2654,6 +2733,84 @@ fn redaction_marks_apply_for_good_and_undo() { assert!(matches!(s.apply(id, Edit::ApplyRedactions { pages: None }), Err(EditError::Redact(_)))); } +/// What [`mark_with_reply`] writes that must not reach a file once the mark is removed. +const MARK_TRACES: [&str; 5] = ["/Redact", "MARKED OVERLAY", "Mark Author", "REPLY TO THE MARK", "Replier"]; + +/// A redaction mark on page 1 (the first annotation there) and a reply to it. +fn mark_with_reply(s: &mut Session, id: DocId) { + let shape = Shape::Redact { + quads: vec![pdfcraft_annot::rect_quad([80.0, 140.0, 100.0, 180.0])], + overlay: "MARKED OVERLAY".into(), + look: Default::default(), + }; + let mark = Edit::AddAnnotation(NewAnnotation { + page: 0, + style: Style::default_for(&shape), + shape, + contents: String::new(), + author: "Mark Author".into(), + }); + s.apply(id, mark).unwrap(); + let reply = Edit::ReplyToAnnotation { page: 0, index: 0, text: "REPLY TO THE MARK".into(), author: "Replier".into() }; + s.apply(id, reply).unwrap(); +} + +/// Marks removed before they are applied, all at once or one by one, are not saved, nor are +/// replies to them: the file would otherwise record where they were, their overlay text and who +/// made them, and the page is left as it was. +#[test] +fn removed_redaction_marks_are_not_saved() { + for clear in [true, false] { + let (mut s, id) = session_with(1); + mark_with_reply(&mut s, id); + let removal = if clear { Edit::ClearRedactions } else { Edit::DeleteAnnotation { page: 0, index: 0 } }; + s.apply(id, removal).unwrap(); + assert_eq!(s.get(id).unwrap().redaction_marks(), 0); + let saved = s.save_bytes(id).unwrap(); + for trace in MARK_TRACES { + assert!(!saved.windows(trace.len()).any(|w| w == trace.as_bytes()), "clear: {clear}, {trace:?}"); + } + let mut reopened = Session::new(); + let back = reopened.open("saved.pdf", None, saved.clone(), None).unwrap(); + assert_eq!(page_texts(&reopened, back), ["Page 1"]); + let page = pdfcraft_model::pages(&reopened.get(back).unwrap().editor.as_ref().unwrap().cos).swap_remove(0); + assert!(!page.dict.contains(b"Annots"), "clear: {clear}"); + + // Undo past that save brings the mark back, under the number the saved file left free. + s.mark_saved(id, saved, None).unwrap(); + s.undo(id).unwrap(); + let again = s.save_bytes(id).unwrap(); + let mut reopened = Session::new(); + let back = reopened.open("again.pdf", None, again.clone(), None).unwrap(); + let d = reopened.get(back).unwrap(); + assert!(d.info.warnings.is_empty() && d.editor.as_ref().unwrap().cos.repair_log().is_empty(), "{:?}", d.info.warnings); + assert_eq!(d.redaction_marks(), 1, "clear: {clear}"); + let marks = pdfcraft_redact::marks(&d.editor.as_ref().unwrap().cos); + assert_eq!(marks.first().map(|m| m.overlay.as_str()), Some("MARKED OVERLAY")); + } +} + +/// Removed marks are not in a signed file either, nor in one with revocation evidence embedded: +/// no later save could take them out of those. +#[test] +fn removed_redaction_marks_are_not_signed() { + 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(); + mark_with_reply(&mut s, id); + s.apply(id, Edit::ClearRedactions).unwrap(); + let signed = s.sign(id, &digital_id, SignOptions { rect: None, ..SignOptions::default() }).unwrap(); + let evidence = sign::dss::Evidence { certs: vec![digital_id.certificate.raw.clone()], ocsps: Vec::new(), crls: Vec::new() }; + let ltv = s.embed_ltv(id, &evidence).unwrap(); + for (what, out) in [("signed", signed), ("LTV", ltv)] { + for trace in MARK_TRACES { + assert!(!out.windows(trace.len()).any(|w| w == trace.as_bytes()), "{what}: {trace:?}"); + } + } +} + #[test] fn oversized_redaction_overlay_preserves_the_document_and_undo() { let (mut s, id) = session_with(1); @@ -3549,8 +3706,12 @@ fn added_images_rotate_flip_and_crop_as_drawn() { assert!(blue(colour_at(&s, 70, 150)) && red(colour_at(&s, 130, 150)), "flipped"); img.flip_h = false; img.crop = [0.0, 0.0, 0.5, 0.0]; - s.apply(id, Edit::UpdateContent { page: 0, index: 0, content: AddedContent::Image(img) }).unwrap(); + s.apply(id, Edit::UpdateContent { page: 0, index: 0, content: AddedContent::Image(img.clone()) }).unwrap(); assert!(red(colour_at(&s, 130, 150)), "the right half cropped away: red fills the box"); + // More than half from one side: only the blue right part is left. + img.crop = [0.7, 0.0, 0.0, 0.0]; + s.apply(id, Edit::UpdateContent { page: 0, index: 0, content: AddedContent::Image(img) }).unwrap(); + assert!(blue(colour_at(&s, 53, 150)) && blue(colour_at(&s, 130, 150)), "70 % trimmed from the left: blue fills the box to its left edge"); } #[test] @@ -3773,6 +3934,78 @@ fn comments_lock_take_checkmarks_hide_and_summarize() { assert_eq!(comment_summary("x", &[], SummarySort::Page), "Summary of Comments on x\n\nThis document has no comments.\n"); } +/// Replies thread by their `/IRT` reference, not `/NM`: PyMuPDF names the first comment on every +/// page `fitz-A0`, which used to file every reply under page 1's comment. An `/IRT` to the +/// annotation itself, or to a link (never listed as a comment), leaves it a comment of its own. Both readers (inspect on open, and the +/// comment list the engine refreshes after an edit) must agree. +#[test] +fn replies_thread_by_reference_when_names_repeat_across_pages() { + let pdf = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 8 0 R 9 0 R 10 0 R] >> endobj +4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [6 0 R 7 0 R] >> endobj +5 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (fitz-A0) /T (Alpha) /Contents (COVER) >> endobj +6 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (fitz-A0) /T (Beta) /Contents (SECOND) >> endobj +7 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /NM (fitz-A1) /IRT 6 0 R /T (Gamma) /Contents (ANSWER) >> endobj +8 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /NM (fitz-A1) /IRT 8 0 R /T (Delta) /Contents (SELF) >> endobj +9 0 obj << /Type /Annot /Subtype /Link /Rect [200 20 260 40] /Border [0 0 0] /Dest [3 0 R /Fit] >> endobj +10 0 obj << /Type /Annot /Subtype /Text /Rect [20 60 40 80] /NM (fitz-A2) /IRT 9 0 R /T (Echo) /Contents (LINKED) >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("fitz.pdf", None, Arc::new(pdf.to_vec()), None).expect("opens"); + let check = |s: &Session, replies: usize| { + let all = &s.get(id).unwrap().info.annotations; + let answer = all.iter().find(|a| a.contents.as_deref() == Some("ANSWER")).unwrap(); + assert_eq!(answer.parent, Some((1, 0)), "{answer:?}"); + let looped = all.iter().find(|a| a.contents.as_deref() == Some("SELF")).unwrap(); + assert!(!looped.is_reply(), "{looped:?}"); + let linked = all.iter().find(|a| a.contents.as_deref() == Some("LINKED")).unwrap(); + assert!(!linked.is_reply(), "{linked:?}"); + let text = comment_summary("fitz.pdf", all, SummarySort::Page); + let (cover, second) = text.split_once("SECOND").unwrap(); + assert!(!cover.contains("ANSWER"), "page 1's comment has no replies: {text}"); + assert!(cover.contains("Number: 2 Author: Delta"), "the self-reply is a comment of its own: {text}"); + assert!(cover.contains("Number: 3 Author: Echo"), "so is the reply to a link: {text}"); + assert_eq!(second.matches(" Author:").count(), replies, "{text}"); + }; + check(&s, 1); + s.apply(id, Edit::ReplyToAnnotation { page: 1, index: 0, text: "LATER".into(), author: "Delta".into() }).unwrap(); + check(&s, 2); +} + +/// A comment listed through a reference chain (`/Annots [5 0 R]`, `5 0 obj 6 0 R`) keeps its +/// replies after an edit, whether their `/IRT` names the comment or the chain, as on open. +#[test] +fn replies_thread_through_a_reference_chain_after_an_edit() { + let pdf = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 7 0 R 8 0 R 9 0 R] >> endobj +5 0 obj 6 0 R endobj +6 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (one) /Contents (CHAINED) >> endobj +7 0 obj << /Type /Annot /Subtype /Text /Rect [20 120 40 130] /IRT 6 0 R /Contents (TO THE COMMENT) >> endobj +8 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 110] /IRT 5 0 R /Contents (THROUGH THE CHAIN) >> endobj +9 0 obj << /Type /Annot /Subtype /Text /Rect [20 60 40 80] /NM (two) /Contents (PLAIN) >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("chain.pdf", None, Arc::new(pdf.to_vec()), None).expect("opens"); + let check = |s: &Session| { + let all = &s.get(id).unwrap().info.annotations; + let comment = all.iter().find(|a| a.contents.as_deref() == Some("CHAINED")).expect("listed"); + assert_eq!((comment.page, comment.index, comment.is_reply()), (0, 0, false), "{all:?}"); + for reply in ["TO THE COMMENT", "THROUGH THE CHAIN"] { + let a = all.iter().find(|a| a.contents.as_deref() == Some(reply)).expect("listed"); + assert_eq!((a.parent, a.in_reply_to.as_deref()), (Some((0, 0)), Some("one")), "{reply}: {all:?}"); + } + }; + check(&s); + s.apply(id, Edit::ReplyToAnnotation { page: 0, index: 3, text: "LATER".into(), author: "Delta".into() }).unwrap(); + check(&s); +} + /// #820: the generated summary includes replies to replies, indented one level deeper. #[test] fn comment_summary_includes_replies_to_replies() { @@ -5407,6 +5640,74 @@ 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 static XFA form whose values are only in its datasets takes every one on opening, as +/// filling the fields one at a time would leave it: values, appearances and the note; a +/// read-only field keeps its own value. +#[test] +fn a_static_xfa_form_takes_many_data_values_as_one_by_one() { + let n = 60; + let original = Arc::new(pdfcraft_xfa::fixtures::static_shell_many(n)); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("many.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.dirty && doc.bytes.starts_with(&original[..]), "the values are an appended revision"); + // The read-only first field keeps its own (empty) value, and is not named. + let note = doc.info.warnings.iter().find(|w| w.contains("taken from this form's XFA data")).expect("a warning"); + assert!(note.starts_with("59 form field values") && note.contains("form1[0].page1[0].f1[0]") && note.ends_with("and 54 more"), "{note}"); + assert!(!note.contains("f0[0]"), "{note}"); + // The same values filled one at a time. + let mut one_by_one = pdfcraft_cos::Document::open(original).unwrap(); + for i in 0..n { + let r = pdfcraft_forms::set_value(&mut one_by_one, &format!("form1[0].page1[0].f{i}[0]"), &FieldValue::Text(format!("v{i}"))); + assert_eq!(r.is_err(), i == 0, "field {i}: {r:?}"); + } + let opened = &doc.editor.as_ref().unwrap().cos; + let shown = |d: &pdfcraft_cos::Document| -> Vec<(String, Vec, String)> { + pdfcraft_forms::fields(d) + .into_iter() + .map(|f| { + let w = f.widgets.first().map(|w| w.obj).unwrap(); + let ap = d.get(w).as_dict().and_then(|w| w.get(b"AP").cloned()).map(|a| d.resolve(&a)); + let n = ap.and_then(|ap| ap.as_dict().and_then(|a| a.get(b"N").cloned())).map(|n| d.resolve(&n)); + let look = match n.as_deref() { + Some(pdfcraft_cos::Object::Stream(st)) => String::from_utf8_lossy(&st.decoded().unwrap()).into_owned(), + _ => String::new(), + }; + (f.name, f.value, look) + }) + .collect() + }; + let got = shown(opened); + assert_eq!(got.len(), n); + assert_eq!(got, shown(&one_by_one)); + assert!(got[0].1.is_empty()); + assert!(got.iter().enumerate().skip(1).all(|(i, (_, v, ap))| *v == [format!("v{i}")] && ap.contains(&format!("(v{i})")))); +} + +/// Opening a static XFA form whose datasets fill thousands of fields took minutes: each value +/// read the whole form again (twice), so the time grew with the square of the fields. Eight +/// times the fields now takes at most about eight times as long (it took about fifty). +#[test] +fn opening_a_static_xfa_form_grows_linearly_with_its_data_values() { + let time = |n: usize| { + let bytes = Arc::new(pdfcraft_xfa::fixtures::static_shell_many(n)); + (0..3) + .map(|_| { + let mut s = Session::new(); + let start = std::time::Instant::now(); + s.open("many.pdf", None, bytes.clone(), None).expect("opens"); + start.elapsed() + }) + .min() + .unwrap_or_default() + }; + let small = time(100); + let large = time(800); + let ratio = large.as_secs_f64() / small.as_secs_f64().max(1e-6); + assert!(ratio < 16.0, "100 fields: {small:?}, 800 fields: {large:?} ({ratio:.1}x)"); +} + /// 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. @@ -5490,6 +5791,8 @@ fn xfa_data_no_field_binds_to_survives_an_edit_and_save() { assert!(doc.xfa_warnings.iter().any(|w| w.contains("structured content"))); } +/// An XFA form's choice lists, signature and picture fields open as their kinds of field, take +/// values that scripts read, and are saved into the datasets. #[test] fn xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save() { let data = "
JapanEnglish\nSpanish
"; @@ -5509,7 +5812,8 @@ fn xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save() { assert_eq!(langs.value, vec!["English".to_string(), "Spanish".to_string()]); assert!(field(&s, "other").has(pdfcraft_forms::flags::EDIT)); assert!(field(&s, "pin").has(pdfcraft_forms::flags::PASSWORD)); - assert_eq!(field(&s, "sign").kind, K::Signature); + // The signature field is named by its SOM path, as another viewer finds a signature. + assert_eq!(field(&s, "form[0].page1[0].sign[0]").kind, K::Signature); // Scripts read a choice list's saved value. assert_eq!(field(&s, "summary").value, vec!["Country: JP".to_string()], "{:?}", s.take_js_output(id)); // Choosing another item recalculates; the list takes several values; a typed value is kept @@ -5667,7 +5971,8 @@ fn xfa_data_import_on_a_static_form_replaces_every_value_and_pictures_reach_its_ #[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())); - let mut s = Session::new().with_clock(|| 1_700_000_000); + // Raised time limits: a busy machine does not stop the calculations this checks. + let mut s = Session::new().with_clock(|| 1_700_000_000).with_xfa_time_scale(100); let id = s.open("rows.pdf", None, bytes, None).expect("opens"); let names = |s: &Session| s.get(id).unwrap().form.iter().map(|f| f.name.clone()).collect::>(); 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(); @@ -5696,16 +6001,19 @@ 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. +/// Hundreds of calculations, in JavaScript and FormCalc, run on opening in one engine, and a +/// change recalculates them in one more. The time limits are raised so that a busy machine +/// does not stop them; the next test checks that the limits stop them. #[test] -fn hundreds_of_xfa_calculations_run_in_one_engine_within_the_open_budget() { +fn hundreds_of_xfa_calculations_run_in_one_engine() { let (sections, rows) = (10, 20); let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::many_calculations_template(sections, rows))); - let mut s = Session::new().with_clock(|| 1_700_000_000); + let mut s = Session::new().with_clock(|| 1_700_000_000).with_xfa_time_scale(100); + let engines = pdfcraft_js::xfa_session::engines_started(); let started = std::time::Instant::now(); let id = s.open("many.pdf", None, bytes, None).expect("opens"); let elapsed = started.elapsed(); + assert_eq!(pdfcraft_js::xfa_session::engines_started() - engines, 1, "the scripts of the opening share one engine"); let out = s.take_js_output(id); assert!(!out.errors.iter().any(|e| e.contains("were skipped") || e.contains("stopped")), "{:?}", out.errors); let form = &s.get(id).unwrap().form; @@ -5718,9 +6026,10 @@ fn hundreds_of_xfa_calculations_run_in_one_engine_within_the_open_budget() { let expect = (rows * (rows + 1)).to_string(); assert!(sums.iter().all(|f| f.value == vec![expect.clone()]), "{:?}", sums.iter().map(|f| f.value.clone()).collect::>()); eprintln!("{} calculate scripts on open in {elapsed:?}", sections * rows + sections); - // A change recalculates them all again, well within the event budget. + // A change recalculates them all again, in one more engine. let started = std::time::Instant::now(); s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("100".into()) }).unwrap(); + assert_eq!(pdfcraft_js::xfa_session::engines_started() - engines, 2, "the scripts of the change share one engine"); let out = s.take_js_output(id); assert!(!out.errors.iter().any(|e| e.contains("were skipped")), "{:?}", out.errors); assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().value, vec!["200".to_string()]); @@ -5728,6 +6037,28 @@ fn hundreds_of_xfa_calculations_run_in_one_engine_within_the_open_budget() { eprintln!("recalculated after a change in {:?}", started.elapsed()); } +/// The time the scripts of an opening share stops the calculations it has no time for, and +/// says how many were skipped; a change's scripts are stopped the same way. +#[test] +fn the_xfa_open_budget_stops_the_calculations_it_has_no_time_for() { + let (sections, rows) = (10, 20); + let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::many_calculations_template(sections, rows))); + // A time scale of 0 leaves no time at all: every script is skipped before it starts. + let mut s = Session::new().with_clock(|| 1_700_000_000).with_xfa_time_scale(0); + let engines = pdfcraft_js::xfa_session::engines_started(); + let id = s.open("many.pdf", None, bytes, None).expect("opens"); + let skipped = sections * rows + sections; + let stopped = format!("The form's scripts were stopped: the scripts took more than 0 seconds; {skipped} more were skipped"); + assert_eq!(s.take_js_output(id).errors, vec![stopped.clone()]); + let totals = |s: &Session| s.get(id).unwrap().form.iter().filter(|f| f.name.starts_with("total")).map(|f| f.value.clone()).collect::>(); + assert_eq!(totals(&s).len(), sections * rows); + assert!(totals(&s).iter().all(Vec::is_empty), "no total was calculated: {:?}", totals(&s)); + s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("100".into()) }).unwrap(); + assert!(s.take_js_output(id).errors.iter().any(|e| e.starts_with("The form's scripts were stopped: the scripts took more than 0 seconds"))); + assert!(totals(&s).iter().all(Vec::is_empty), "no total was calculated: {:?}", totals(&s)); + assert_eq!(pdfcraft_js::xfa_session::engines_started(), engines, "no script ran, so no engine started"); +} + /// 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. @@ -8377,3 +8708,70 @@ fn pdf_with_annots(annots: &[&[u8]]) -> Vec { bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); bytes } + +/// ISO 32000-2 Table 224 (`/NeedAppearances`) and Table 192 (`/MK /CA`): a form that leaves its +/// appearances to the viewer shows its check boxes and radio buttons drawn from `/MK`, over the +/// filled squares the file carries: a checked box its check mark (ZapfDingbats `4`), an unchecked +/// one its empty border, a radio button its dot (`l`), on screen and printed as an image. Without +/// `/NeedAppearances` the file's own squares show. Saving writes the file as it was. +#[test] +fn need_appearances_buttons_are_displayed_from_their_captions() { + 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 4 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 8 0 R] >> endobj\n\ + 4 0 obj << /Fields [6 0 R 7 0 R 8 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\ + 5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\ + 6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (on) /V /Yes /AS /Yes /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [20 100 60 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ + 7 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (off) /V /Off /AS /Off /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [100 100 140 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ + 8 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 32768 /T (radio) /V /B /AS /B /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (l) >> /Rect [180 100 220 140] /F 4 /P 3 0 R /AP << /N << /B 9 0 R /Off 9 0 R >> >> >> endobj\n\ + 9 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length 18 >> stream\n0 g 4 4 32 32 re f\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF" + ) + .into_bytes() + }; + let render = |bytes: Arc>| { + let mut r = pdfcraft_render::PageRenderer::new(bytes, pdfcraft_render::RenderConfig::default()); + r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }) + }; + // Dark pixels of the page (drawn at scale 1, 400 x 200) in the user-space box x0..x1, y0..y1. + let ink = |page: &pdfcraft_render::RenderedPage, (x0, y0, x1, y1): (u32, u32, u32, u32)| { + let (px, _) = page.rgba.as_chunks::<4>(); + (200 - y1..200 - y0) + .flat_map(|y| (x0..x1).map(move |x| (x, y))) + .filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(|p| p[0] < 128 && p[1] < 128 && p[2] < 128)) + .count() + }; + // In a widget whose lower-left corner is (x, y): its whole inside, the top-left of its inside + // (where a check mark leaves the box empty), and the lower-left corner (outside a dot). + let inside = |x: u32, y: u32| (x + 6, y + 6, x + 34, y + 34); + let top_left = |x: u32, y: u32| (x + 6, y + 26, x + 14, y + 34); + let corner = |x: u32, y: u32| (x + 5, y + 5, x + 9, y + 9); + let mut s = Session::new(); + let id = s.open("form.pdf", None, Arc::new(pdf(true)), None).unwrap(); + let shown = render(s.get(id).unwrap().display.clone()); + let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false }; + let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap(); + let printed = render(Arc::new(pictures)); + for (page, how) in [(&shown, "shown"), (&printed, "printed")] { + let (check, check_corner) = (ink(page, inside(20, 100)), ink(page, top_left(20, 100))); + assert!(check > 100 && check < 600, "{how}: a check mark, not the 28 x 28 square: {check}"); + assert_eq!(check_corner, 0, "{how}: the check mark leaves the top left empty"); + assert_eq!(ink(page, inside(100, 100)), 0, "{how}: the unchecked box is empty"); + assert!(ink(page, (100, 100, 140, 102)) > 30, "{how}: and keeps its border"); + let dot = ink(page, (188, 108, 212, 132)); + assert!(dot > 300 && ink(page, corner(180, 100)) == 0, "{how}: the radio button's dot: {dot}"); + } + let saved = s.save_bytes(id).unwrap(); + assert!(saved.windows(18).any(|w| w == b"0 g 4 4 32 32 re f"), "the file keeps its own appearances"); + + // Without /NeedAppearances the file's filled squares are shown. + let id = s.open("plain.pdf", None, Arc::new(pdf(false)), None).unwrap(); + let shown = render(s.get(id).unwrap().display.clone()); + for x in [20, 100, 180] { + assert!(ink(&shown, inside(x, 100)) > 700, "the file's square at {x}"); + } +} diff --git a/crates/engine/src/text_preview.rs b/crates/engine/src/text_preview.rs index d533faf7e0..290231cf73 100644 --- a/crates/engine/src/text_preview.rs +++ b/crates/engine/src/text_preview.rs @@ -89,4 +89,17 @@ impl Document { Ok(TextEditPreview { page, bytes, config: self.config.clone() }) })? } + + /// An added item alone, on a transparent page: what follows the pointer while it is dragged, + /// so the page under its first place doesn't come along. Read-only, like + /// [`Self::added_content_preview`]. + pub fn added_content_alone(&self, page: usize, index: usize) -> Result { + guard(|| { + let editor = self.editor.as_ref().ok_or("the document can't be read")?; + let mut cos = editor.cos.clone(); + pdfcraft_edit::isolate_content(&mut cos, page, index).map_err(|e| e.to_string())?; + let bytes = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).map_err(|e| e.to_string())?); + Ok(TextEditPreview { page, bytes, config: RenderConfig { transparent: true, ..self.config.clone() } }) + })? + } } diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index 2f98efdb04..dd011d47fc 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -128,6 +128,10 @@ pub(crate) struct XfaRun<'a> { pub datasets: Option, /// A script ran past its time limit and was abandoned: the form's scripts should stay off. pub ran_away: bool, + /// What every time limit of the scripts is multiplied by (the event's budget, the engine's + /// start and each script's own): 1, unless the session was given another + /// ([`crate::Session::with_xfa_time_scale`]). + pub time_scale: u32, } /// The live form node `n`, with its descendants, as the script engine's snapshot of it. @@ -277,6 +281,7 @@ impl<'a, 'b> Runner<'a, 'b> { fn new(doc: &'a mut pdfcraft_cos::Document, tpl: &'a Template, run: &'a mut XfaRun<'b>, budget_ms: u64) -> Self { let data = pdfcraft_xfa::data_of(doc); let ov = pdfcraft_xfa::overrides(doc); + let budget_ms = budget_ms.saturating_mul(u64::from(run.time_scale)); Runner { doc, tpl, @@ -311,7 +316,8 @@ impl<'a, 'b> Runner<'a, 'b> { /// The script engine for this event. fn session(&mut self) -> &mut XfaSession { let doc_info = XfaDoc { file_name: String::new(), page: 0, page_count: self.run.page_count }; - self.session.get_or_insert_with(|| XfaSession::new(doc_info)) + let scale = self.run.time_scale; + self.session.get_or_insert_with(|| XfaSession::new(doc_info).with_time_scale(scale)) } /// Give the object at `som` the value `text` in the data and in the live form (refreshed @@ -661,7 +667,7 @@ impl<'a, 'b> Runner<'a, 'b> { event, root, limits: limits_for(&ev.activity), - timeout: timeout_for(&ev.activity), + timeout: timeout_for(&ev.activity).saturating_mul(self.run.time_scale), }; let o = self.session().run(script); if o.abandoned { @@ -893,7 +899,7 @@ pub(crate) fn on_event(doc: &mut pdfcraft_cos::Document, tpl: &Template, som: &s r.run(&ev, "")?; } // The calculations that follow get a budget of their own. - r.clock = Clock::start(OPEN_BUDGET_MS); + r.clock = Clock::start(OPEN_BUDGET_MS.saturating_mul(u64::from(r.run.time_scale))); r.calculates()?; r.finish().map(|_| ()) } diff --git a/crates/engine/src/xfa_form.rs b/crates/engine/src/xfa_form.rs new file mode 100644 index 0000000000..5ceaba1cd4 --- /dev/null +++ b/crates/engine/src/xfa_form.rs @@ -0,0 +1,297 @@ +//! A document's XFA form: laying it out, keeping its datasets and fields in step, and taking it out. + +use crate::edit::EditCtx; +use crate::{Document, EditError, FieldValue, XfaLayout, guard, xfa}; + +/// Lay a dynamic XFA form out (pages and fields) and give its widgets appearances. +pub(crate) fn xfa_layout(doc: &mut pdfcraft_cos::Document) -> Result { + let mut report = pdfcraft_xfa::render_into(doc).map_err(|e| e.to_string())?; + xfa_redraw(doc)?; + // The form's own values: one its field's font can't draw is drawn with `?` and reported. + report.warnings.extend(undrawn_field_warnings(doc)); + Ok(report) +} + +/// Give the fields of a laid-out XFA form their appearances. A signed signature box keeps the +/// picture its signer's application drew. +pub(crate) fn xfa_redraw(doc: &mut pdfcraft_cos::Document) -> Result<(), String> { + for f in pdfcraft_forms::fields(doc) { + if f.kind == pdfcraft_forms::FieldKind::Signature && doc.get(f.obj).as_dict().is_some_and(|d| d.contains(b"V")) { + continue; + } + pdfcraft_forms::redraw_field(doc, &f.name).map_err(|e| format!("{}: {e}", f.name))?; + } + Ok(()) +} + +/// A warning for each field whose appearance PdfCraft drew with `?` for a character its font +/// can't draw (laying out an XFA form's own values). +pub(crate) fn undrawn_field_warnings(doc: &pdfcraft_cos::Document) -> Vec { + pdfcraft_forms::undrawn_fields(doc) + .into_iter() + .map(|(name, c)| { + format!("form field {name:?} holds \"{}\" (U+{:04X}), which its font can't draw: it shows ? in its place", c.escape_debug(), u32::from(c)) + }) + .collect() +} + +/// The form's fields as the XFA data layer wants them. +fn xfa_field_data(doc: &pdfcraft_cos::Document) -> Vec { + use pdfcraft_forms::FieldKind as K; + pdfcraft_forms::fields(doc) + .into_iter() + .map(|f| { + let data = match f.kind { + K::Text | K::Combo | K::List => pdfcraft_xfa::FieldData::Text(f.value.join("\n")), + K::CheckBox => pdfcraft_xfa::FieldData::Check(!f.value.is_empty()), + K::Radio => pdfcraft_xfa::FieldData::Radio(f.value.first().cloned()), + K::PushButton | K::Signature => pdfcraft_xfa::FieldData::None, + }; + pdfcraft_xfa::FieldDatum { obj: f.obj, name: f.name, data } + }) + .collect() +} + +/// Keep the XFA datasets packet in step with the fields after an edit. Returns what could not +/// be written. +/// `datasets`: the stream this edit already wrote, replaced in place rather than added again +/// (and set to the one written). +/// `tpl`: the form's parsed template when the document has one cached (else it is parsed). +pub(crate) fn xfa_sync_datasets( + doc: &mut pdfcraft_cos::Document, + tpl: Option<&pdfcraft_xfa::model::Template>, + datasets: &mut Option, +) -> Result, String> { + let data = xfa_field_data(doc); + let r = pdfcraft_xfa::write_datasets_with(doc, tpl, &data, *datasets).map_err(|e| e.to_string())?; + if r.stream.is_some() { + *datasets = r.stream; + } + Ok(r.warnings) +} + +/// What a document whose form script ran away is told. +pub(crate) const XFA_SCRIPTS_OFF: &str = + "A script of this form ran too long and was abandoned; the form's scripts are off for this document (reopen it to run them again)"; + +/// Most XFA warnings kept per document. +const MAX_XFA_WARNINGS: usize = 50; + +/// Add `new` to `list` (no repeats, at most [`MAX_XFA_WARNINGS`]). +pub(crate) fn note_warnings(list: &mut Vec, new: &[String]) { + for w in new { + if !list.contains(w) && list.len() < MAX_XFA_WARNINGS { + list.push(w.clone()); + } + } +} + +/// A data file's text: UTF-8 or UTF-16 (by a byte order mark, or how `<` is written), or +/// Latin-1 when its XML declaration says so. `None`: not text at all (FDF); `Err`: an +/// encoding this can't read. +pub(crate) fn decode_data_file(bytes: &[u8]) -> Option> { + if bytes.starts_with(b"%FDF") { + return None; + } + let declared = |text: &str| -> Option { + let head = text.get(..text.len().min(256))?; + let decl = head.strip_prefix('\u{feff}').unwrap_or(head).trim_start().strip_prefix("")?.0; + let enc = decl.split_once("encoding=")?.1; + let quote = enc.chars().next()?; + Some(enc.get(1..)?.split(quote).next()?.trim().to_ascii_lowercase()) + }; + if let Some((text, _)) = pdfcraft_xfa::packets::decode(bytes) { + match declared(&text).as_deref() { + None | Some("utf-8" | "utf8" | "utf-16" | "utf-16le" | "utf-16be" | "us-ascii" | "ascii") => return Some(Ok(text)), + Some("iso-8859-1" | "latin1" | "latin-1" | "windows-1252" | "cp1252") if !bytes.starts_with(&[0xEF, 0xBB, 0xBF]) => { + // Each byte is the character it names (the 0x80–0x9F block of Windows-1252 + // differs, and is rare in form data). + return Some(Ok(bytes.iter().map(|b| char::from(*b)).collect())); + } + Some(other) => return Some(Err(format!("the data file's encoding {other:?} is not supported (UTF-8, UTF-16 and ISO-8859-1 are)"))), + } + } + // Not valid UTF-8/UTF-16: Latin-1 by declaration, else refused. + let latin1: String = bytes.iter().map(|b| char::from(*b)).collect(); + match declared(&latin1).as_deref() { + Some("iso-8859-1" | "latin1" | "latin-1" | "windows-1252" | "cp1252") => Some(Ok(latin1)), + _ => Some(Err("the data file is not UTF-8, UTF-16 or ISO-8859-1 text".into())), + } +} + +/// Take the XFA out of `doc` and note what went with it: the packets, data no field showed, +/// buttons that did nothing but run scripts, and fields whose value differs from the form's +/// data (data PdfCraft could not read into them, now lost). +pub(crate) fn strip_xfa(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx) -> Result<(), EditError> { + let fields = pdfcraft_forms::fields(doc); + let data = xfa_field_data(doc); + let differ = pdfcraft_xfa::read_values(doc, &data) + .into_iter() + .filter(|(name, value)| { + let Some(f) = fields.iter().find(|f| f.name == *name) else { return false }; + match value { + pdfcraft_xfa::FieldData::Text(t) => f.value.join("\n") != *t && !t.trim().is_empty(), + pdfcraft_xfa::FieldData::Check(on) => f.value.is_empty() == *on, + pdfcraft_xfa::FieldData::Radio(sel) => f.value.first() != sel.as_ref(), + pdfcraft_xfa::FieldData::None => false, + } + }) + .count(); + let r = pdfcraft_xfa::strip(doc).map_err(|e| EditError::Write(e.to_string()))?; + cx.xfa_removed.extend(r.notes()); + if differ > 0 { + cx.xfa_removed.push(format!( + "{differ} field(s) held a value other than the form's XFA data (data PdfCraft could not read into them); the data's version is gone" + )); + } + Ok(()) +} + +/// Keep the document's XFA state in step with its file after an edit: a script may have laid +/// the form out again (rows added, subforms shown), an edit may have taken the XFA out +/// (flattened, or made static), or an undo put it back. +pub(crate) fn sync_xfa_state(doc: &mut Document) { + let Some(cos) = doc.editor.as_ref().map(|e| &e.cos) else { return }; + match pdfcraft_xfa::existing_layout(cos) { + Some(report) => { + if doc.xfa.is_none() { + // Back after an undo: the XFA's own warnings apply again, the strip's don't. + doc.xfa_warnings.retain(|w| !w.starts_with(XFA_REMOVED_PREFIX)); + if doc.xfa_template.is_none() && !doc.xfa_scripts_off { + doc.xfa_template = xfa::template(cos); + } + } + doc.xfa = Some(report); + } + None => { + doc.xfa = None; + doc.xfa_template = None; + // Only what the strip said stays (the XFA's own warnings no longer apply). + doc.xfa_warnings.retain(|w| w.starts_with(XFA_REMOVED_PREFIX)); + } + } +} + +/// How the warnings of a strip start, so they can be told from the XFA's own. +pub(crate) const XFA_REMOVED_PREFIX: &str = "XFA removed: "; + +/// Is this an XFA data file (an XDP, a datasets document, a data element or a data document), +/// rather than XFDF, Acrobat's `` XML or a table? By its root element. +pub(crate) fn is_xfa_data_file(text: &str) -> bool { + let text = text.get(..text.len().min(65_536)).unwrap_or(text); + let mut rest = text.trim_start_matches('\u{feff}').trim_start(); + // Past the XML declaration, comments and processing instructions. + let mut guard = 0; + while guard < 100 { + guard += 1; + if let Some(r) = rest.strip_prefix("").map_or("", |(_, r)| r).trim_start(); + } else if let Some(r) = rest.strip_prefix("").map_or("", |(_, r)| r).trim_start(); + } else if let Some(r) = rest.strip_prefix("').map_or("", |(_, r)| r).trim_start(); + } else { + break; + } + } + let Some(tag) = rest.strip_prefix('<') else { return false }; + let name = tag.split(|c: char| c.is_whitespace() || c == '>' || c == '/').next().unwrap_or(""); + let local = name.rsplit(':').next().unwrap_or(name); + !local.is_empty() && !matches!(local, "xfdf" | "fields" | "html" | "body") +} + +/// The content type a picture is saved under, from its bytes: the kinds a laid-out form shows. +pub(crate) fn image_content_type(bytes: &[u8]) -> Option<&'static str> { + match pdfcraft_xfa::image::kind(bytes)? { + 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"), + } +} + +/// After the XFA data changed outside the fields (an import, a picture): a laid-out form is +/// laid out again from the data (then, when `calculate`, its calculations run), and the +/// fields take the data's values. +pub(crate) fn xfa_after_data_change(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx, calculate: bool) -> Result<(), EditError> { + if let Some(tpl) = cx.xfa.clone() { + let mut run = + xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false, time_scale: cx.xfa_time_scale }; + let r = guard(|| xfa::on_data_replaced(doc, &tpl, calculate, &mut run)) + .unwrap_or_else(|m| Err(format!("laying the form out again failed unexpectedly ({m})"))); + cx.xfa_datasets = run.datasets; + cx.xfa_ran_away |= run.ran_away; + if let Err(e) = r { + cx.xfa_out.errors.push(e); + } + } + let taken = xfa_values_from_datasets(doc).map_err(EditError::Write)?; + if !taken.is_empty() { + cx.xfa_out.console.push(format!("{} field value(s) taken from the imported data", taken.len())); + } + // Not laid out again (a static form, or scripts off): fields the data no longer holds are + // emptied, so the data replaced is the data shown. + if cx.xfa.is_none() && calculate { + let data = xfa_field_data(doc); + let present: std::collections::HashSet = pdfcraft_xfa::read_values(doc, &data).into_iter().map(|(n, _)| n).collect(); + for f in pdfcraft_forms::fields(doc) { + if present.contains(&f.name) || f.value.is_empty() { + continue; + } + use pdfcraft_forms::FieldKind as K; + let empty = match f.kind { + K::Text => FieldValue::Text(String::new()), + K::CheckBox => FieldValue::Check(false), + K::Radio => FieldValue::Radio(None), + K::Combo | K::List => FieldValue::Choice(Vec::new()), + K::PushButton | K::Signature => continue, + }; + if let Err(e) = pdfcraft_forms::set_value(doc, &f.name, &empty) { + cx.xfa_out.errors.push(format!("{}: not emptied by the import: {e}", f.name)); + } + } + } + Ok(()) +} + +/// Give the fields the values the XFA datasets hold (a form filled in another viewer). Returns +/// the names of the fields that changed. +pub(crate) fn xfa_values_from_datasets(doc: &mut pdfcraft_cos::Document) -> Result, String> { + let data = xfa_field_data(doc); + let fields = pdfcraft_forms::fields(doc); + // The first field of each name, as a lookup by name would find it. + let mut by_name = std::collections::HashMap::new(); + for f in &fields { + by_name.entry(f.name.as_str()).or_insert(f); + } + let mut updates = Vec::new(); + for (name, value) in pdfcraft_xfa::read_values(doc, &data) { + let Some(f) = by_name.get(name.as_str()) else { continue }; + let new = match value { + pdfcraft_xfa::FieldData::Text(t) if matches!(f.kind, pdfcraft_forms::FieldKind::Combo | pdfcraft_forms::FieldKind::List) => { + // Saved values, one per line for a multi-select list; shown text stands for + // its saved value (an older viewer may have written it). + let saved = |v: &str| f.options.iter().find(|(e, s)| e == v || s == v).map_or(v.to_string(), |(e, _)| e.clone()); + let picked: Vec = if f.has(pdfcraft_forms::flags::MULTI_SELECT) { + t.lines().map(str::trim).filter(|l| !l.is_empty()).map(saved).collect() + } else { + vec![saved(t.trim())] + }; + (f.value != picked).then_some(FieldValue::Choice(picked)) + } + pdfcraft_xfa::FieldData::Text(t) => (f.value.join("\n") != t).then_some(FieldValue::Text(t)), + pdfcraft_xfa::FieldData::Check(on) => (f.value.is_empty() == on).then_some(FieldValue::Check(on)), + pdfcraft_xfa::FieldData::Radio(sel) => (f.value.first() != sel.as_ref()).then_some(FieldValue::Radio(sel)), + pdfcraft_xfa::FieldData::None => None, + }; + if let Some(v) = new { + updates.push((name, v)); + } + } + // All at once: one by one, each value would read the whole form again, which took minutes + // on a form with thousands of fields. + let Ok(results) = pdfcraft_forms::set_values(doc, &updates) else { return Ok(Vec::new()) }; + Ok(updates.into_iter().zip(results).filter(|(_, r)| r.is_ok()).map(|((name, _), _)| name).collect()) +} diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 1303254d66..a9c43fb423 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -2,7 +2,9 @@ //! //! The engine reduces each page to [`Page`]: paragraphs (text, box, size, bold/italic) and //! images (encoded bytes and box), in reading order. The writers turn that into a flowing -//! document: headings are the paragraphs set larger than the body text. Word keeps pages with +//! document: headings are the paragraphs set larger than the body text, and a block that +//! continues another on its line (an italic word, a link) is a run of that block's paragraph. +//! Word keeps pages with //! rotated text, or text overlapped by artwork or watermark-sized text, as positioned, editable //! shapes to avoid reflowing overlays; other pages flow with images inline. @@ -30,6 +32,10 @@ pub struct Block { pub color: [f64; 3], /// Counterclockwise baseline angle in user space (degrees). pub rotation: f64, + /// Whether the source had a word space just before (`[0]`) and just after (`[1]`) `text`, + /// which `text` leaves out. A block that continues this one on its line is a run of the same + /// paragraph, and these say whether a space separates them (#4158). + pub space_around: [bool; 2], } /// An image of a page. @@ -81,7 +87,8 @@ pub struct Table { /// What goes into the output, in order. enum Item<'a> { - Para(&'a Block, u8), + /// A paragraph's runs (at least one) and its heading level. + Para(Vec>, u8), /// Owned: tables are detected per page inside [`items`], so they cannot borrow. Table(Table), Img(&'a Image), @@ -149,6 +156,12 @@ const CONTINUATION_PROBE: usize = 64; /// [`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 { + continued_from(blocks).into_iter().map(|from| from.is_some()).collect() +} + +/// Per block, the block it continues on the same line (see [`continuations`]): of those it could +/// continue, the one that ends nearest to where it starts. +fn continued_from(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 @@ -162,11 +175,88 @@ fn continuations(blocks: &[Block]) -> Vec { .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])) + .filter_map(|(_, ai)| blocks.get(*ai).filter(|_| *ai != bi).map(|a| (*ai, a))) + .filter(|(_, 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])) + .min_by(|(_, a), (_, c)| (a.rect[2] - b.rect[0]).abs().total_cmp(&(c.rect[2] - b.rect[0]).abs())) + .map(|(ai, _)| ai) }) .collect() } + +/// A gap between two runs wider than this (as a fraction of the font size) is a word space the +/// text left out. It is the gap the reading-block grouping uses for the same purpose. +const WORD_GAP: f64 = 0.12; + +/// One run of a paragraph: a block, and the text it contributes (its own, plus the word space +/// before the next run when the source had one there or the page shows a gap, but the text has +/// none). +pub(crate) struct Run<'a> { + pub(crate) block: &'a Block, + pub(crate) text: String, +} + +/// A page's paragraphs, each a list of runs from left to right along its line. A block that +/// continues another on the same line ([`continued_from`]: an italic case name, a link, a +/// superscript) is a run in its own style inside that block's paragraph, not a paragraph of its +/// own (#4158). Blocks marked in `skip` (table cells, a slide title) are left out and join +/// nothing. Paragraphs come in the order of their first runs in `blocks`. +pub(crate) fn paragraphs<'a>(blocks: &'a [Block], skip: &[bool]) -> Vec>> { + let skipped = |i: usize| skip.get(i).copied().unwrap_or(false); + let from = continued_from(blocks); + // Left to right. A block joins only one that starts strictly further left (in `total_cmp`'s + // order, NaN included), so that one is visited first and has its paragraph already, and the + // links cannot form a cycle. + let mut order: Vec<(f64, usize)> = blocks.iter().enumerate().filter(|(i, _)| !skipped(*i)).map(|(i, b)| (b.rect[0], i)).collect(); + order.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1))); + // Each block's paragraph, named by its first block, and each paragraph's blocks in order. + let mut head: Vec = (0..blocks.len()).collect(); + let mut members: Vec> = vec![Vec::new(); blocks.len()]; + for &(x0, bi) in &order { + let Some(b) = blocks.get(bi) else { continue }; + let joined = from + .get(bi) + .copied() + .flatten() + .filter(|ai| !skipped(*ai)) + .filter(|ai| blocks.get(*ai).is_some_and(|a| a.rect[0].total_cmp(&x0).is_lt() && (a.rotation - b.rotation).abs() <= 0.1)) + .and_then(|ai| head.get(ai).copied()); + let h = joined.unwrap_or(bi); + if let Some(slot) = head.get_mut(bi) { + *slot = h; + } + if let Some(m) = members.get_mut(h) { + m.push(bi); + } + } + members + .into_iter() + .filter(|m| !m.is_empty()) + .map(|m| { + let mut runs: Vec> = Vec::with_capacity(m.len()); + for b in m.into_iter().filter_map(|bi| blocks.get(bi)) { + if let Some(prev) = runs.last_mut() + && !prev.text.ends_with(char::is_whitespace) + && !b.text.starts_with(char::is_whitespace) + && (prev.block.space_around[1] || b.space_around[0] || b.rect[0] - prev.block.rect[2] > b.size * WORD_GAP) + { + prev.text.push(' '); + } + runs.push(Run { block: b, text: b.text.clone() }); + } + runs + }) + .collect() +} + +/// A paragraph's heading level: the lowest of its runs', so body text that runs on after a bold +/// lead-in on the same line keeps the paragraph body text. A marker set smaller than the first run +/// (a footnote number, "st" in "1st") does not count, so a heading keeps its footnote marker. +fn para_level(runs: &[Run], body: f64) -> u8 { + let Some(first) = runs.first() else { return 0 }; + let marker = |r: &Run| r.block.size < first.block.size * 0.9 && r.block.text.trim().chars().count() <= 3; + runs.iter().filter(|r| !marker(r)).map(|r| level(r.block, body)).min().unwrap_or(0) +} + /// 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() @@ -374,12 +464,14 @@ fn items(pages: &[Page]) -> Vec> { } let (tables, consumed) = tables(&p.blocks); // Blocks and images by their top edge, top to bottom. - let mut parts: Vec<(f64, f64, Item)> = p - .blocks - .iter() - .enumerate() - .filter(|(bi, _)| !consumed.get(*bi).copied().unwrap_or(false)) - .map(|(_, b)| (b.rect[3], b.rect[0], Item::Para(b, level(b, body)))) + // A paragraph sits where its first run does: its other runs follow along the same line. + let mut parts: Vec<(f64, f64, Item)> = paragraphs(&p.blocks, &consumed) + .into_iter() + .filter_map(|runs| { + let first = runs.first()?.block; + let lvl = para_level(&runs, body); + Some((first.rect[3], first.rect[0], Item::Para(runs, lvl))) + }) .collect(); parts.extend(tables.into_iter().map(|t| (t.rect[3], t.rect[0], Item::Table(t)))); parts.extend(p.images.iter().map(|im| (im.rect[3], im.rect[0], Item::Img(im)))); @@ -434,15 +526,26 @@ table{{border-collapse:collapse;margin:1em 0}}td,th{{border:1px solid #999;paddi ); for it in items(pages) { match it { - Item::Para(b, lvl) => { - let mut t = esc(&b.text); - if b.italic { - t = format!("{t}"); + Item::Para(runs, lvl) => { + let Some(first) = runs.first().map(|r| r.block) else { continue }; + let mut t = String::new(); + for r in &runs { + let mut run = esc(&r.text); + if r.block.italic { + run = format!("{run}"); + } + if r.block.bold && lvl == 0 { + run = format!("{run}"); + } + // The paragraph has its first run's colour; a run in another colour says so. + let colour = rgb8(r.block.color); + if colour != rgb8(first.color) { + let [red, green, blue] = colour; + run = format!("{run}"); + } + t.push_str(&run); } - if b.bold && lvl == 0 { - t = format!("{t}"); - } - let style = hex(b.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")); + let style = hex(first.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")); match lvl { 0 => s.push_str(&format!("{t}

\n")), l => s.push_str(&format!("{t}\n")), @@ -711,7 +814,6 @@ pub fn docx(pages: &[Page], title: &str) -> Vec { let mut media: Vec<(String, &Image)> = Vec::new(); // Word needs a paragraph between adjacent tables and after the last one in the body. let mut after_table = false; - let run = |b: &Block| run_xml(&b.text, b.size, b.bold, b.italic, b.color); let mut shape_id = 0usize; for (page_index, page) in pages.iter().enumerate() { let (pw, _) = word_page_size(page); @@ -722,13 +824,18 @@ pub fn docx(pages: &[Page], title: &str) -> Vec { } else { for it in items(std::slice::from_ref(page)) { match &it { - Item::Para(b, lvl) => { + Item::Para(runs, lvl) => { let style = match lvl { 1 => "", 2 => "", _ => "", }; - body.push_str(&format!("{style}{}", run(b))); + body.push_str(""); + body.push_str(style); + for r in runs { + body.push_str(&run_xml(&r.text, r.block.size, r.block.bold, r.block.italic, r.block.color)); + } + body.push_str(""); } Item::Table(t) => { if after_table { @@ -873,7 +980,7 @@ pub fn rtf(pages: &[Page]) -> String { }; for it in &items { match it { - Item::Para(b, _) => note(b.color), + Item::Para(runs, _) => runs.iter().for_each(|r| note(r.block.color)), Item::Table(t) => t.rows.iter().flatten().for_each(|c| note(c.color)), Item::Img(_) | Item::PageBreak => {} } @@ -894,16 +1001,32 @@ pub fn rtf(pages: &[Page]) -> String { } for it in items { match it { - Item::Para(b, _) => { - let mut fmt = format!("\\fs{}", (b.size * 2.0).round() as i64); - if b.bold { - fmt.push_str("\\b"); + Item::Para(runs, _) => { + // The first run's formatting is the paragraph's; each later run is a group that + // turns on or off what differs from it. + let fmt = |b: &Block, later: bool| { + let mut fmt = format!("\\fs{}", (b.size * 2.0).round() as i64); + if b.bold { + fmt.push_str("\\b"); + } else if later { + fmt.push_str("\\b0"); + } + if b.italic { + fmt.push_str("\\i"); + } else if later { + fmt.push_str("\\i0"); + } + let colour = cf(b.color); + fmt.push_str(if colour.is_empty() && later { "\\cf0" } else { &colour }); + fmt + }; + let mut runs = runs.iter(); + let Some(first) = runs.next() else { continue }; + s.push_str(&format!("{{\\pard{} {}", fmt(first.block, false), rtf_text(&first.text))); + for r in runs { + s.push_str(&format!("{{{} {}}}", fmt(r.block, true), rtf_text(&r.text))); } - if b.italic { - fmt.push_str("\\i"); - } - fmt.push_str(&cf(b.color)); - s.push_str(&format!("{{\\pard{fmt} {}\\par}}\n", rtf_text(&b.text))); + s.push_str("\\par}\n"); } Item::Table(t) => { let mut edges = t.cols.clone(); @@ -957,6 +1080,7 @@ mod tests { italic: false, color: [(i % 256) as f64 / 255.0, (i / 256 % 256) as f64 / 255.0, 0.5], rotation: 0.0, + space_around: [false; 2], }) .collect(); Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None } @@ -995,6 +1119,7 @@ mod tests { italic: false, color: [0.0; 3], rotation: 0.0, + space_around: [false; 2], }; Page { width: 612.0, @@ -1098,6 +1223,7 @@ mod tests { italic: false, color: [0.8; 3], rotation: 0.0, + space_around: [false; 2], }); p.images = page().images; let xml = part(&docx(&[p], "Invoice"), "word/document.xml"); @@ -1184,6 +1310,7 @@ mod tests { italic: false, color: [0.0; 3], rotation: 0.0, + space_around: [false; 2], }); let xml = part(&docx(&[p.clone()], "Receipt\u{1}"), "word/document.xml"); assert!(xml.contains(">Total 4.50\tGBP"), "{xml}"); @@ -1237,6 +1364,7 @@ mod tests { italic: true, color: [0.0; 3], rotation: 0.0, + space_around: [false; 2], }); let r = rtf(&[p]); assert!(r.starts_with("{\\rtf1") && r.ends_with('}')); @@ -1254,6 +1382,7 @@ mod tests { italic: false, color: [0.0; 3], rotation: 0.0, + space_around: [false; 2], }; Page { width: 612.0, @@ -1277,6 +1406,7 @@ mod tests { italic: false, color: [0.0; 3], rotation: 0.0, + space_around: [false; 2], }, ], images: Vec::new(), @@ -1329,7 +1459,16 @@ mod tests { .map(|i| { let (row, col) = (i / 2, i / 2 + i % 2); let (x, y) = (10.0 + col as f64 * 20.0, 10_000.0 - row as f64 * 14.0); - Block { text: "x".into(), rect: [x, y, x + 8.0, y + 12.0], size: 11.0, bold: false, italic: false, color: [0.0; 3], rotation: 0.0 } + Block { + text: "x".into(), + rect: [x, y, x + 8.0, y + 12.0], + size: 11.0, + bold: false, + italic: false, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + } }) .collect(); let started = std::time::Instant::now(); @@ -1356,6 +1495,7 @@ mod tests { italic: false, color: [0.0; 3], rotation: 0.0, + space_around: [false; 2], } }) }) @@ -1370,7 +1510,16 @@ mod tests { } fn block(t: &str, x0: f64, y0: f64, x1: f64, y1: f64) -> Block { - Block { text: t.into(), rect: [x0, y0, x1, y1], size: 12.0, bold: false, italic: false, color: [0.0; 3], rotation: 0.0 } + Block { + text: t.into(), + rect: [x0, y0, x1, y1], + size: 12.0, + bold: false, + italic: false, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + } } /// #2806: a sentence whose link is set in another colour or font runs on as separate blocks @@ -1400,6 +1549,8 @@ mod tests { }; let h = html(std::slice::from_ref(&p), "Regulations"); assert!(!h.contains(""), "running text became a table: {h}"); + // The link is a run of its sentence, in its own colour (#4158). + assert!(h.contains("

6.3. 2. All content shown at http"), "{h}"); for b in &p.blocks { assert!(h.contains(&b.text), "{} was lost: {h}", b.text); } @@ -1487,6 +1638,7 @@ mod tests { italic: false, color: [0.0; 3], rotation: 0.0, + space_around: [false; 2], }; let p = Page { width: 612.0, @@ -1541,4 +1693,158 @@ mod tests { let rels = part(&zip, "word/_rels/document.xml.rels"); assert_eq!(rels.matches("relationships/image\"").count(), MAX_MEDIA); } + + /// The flowing paragraphs of a Word document (table cells left out), each as its runs' text + /// and whether the run is italic. + fn word_paragraphs(xml: &str) -> Vec> { + let body = xml.split("").map(|s| s.split_once("").map_or(s, |(_, after)| after)).collect::(); + body.split("") + .skip(1) + .map(|p| { + let p = p.split_once("").map_or(p, |(inside, _)| inside); + p.split("") + .skip(1) + .filter_map(|r| { + let text = r.split_once("")?.1.split_once("")?.0; + Some((text.to_string(), r.contains(""))) + }) + .collect::>() + }) + .filter(|runs| !runs.is_empty()) + .collect() + } + + fn runs(r: &[(&str, bool)]) -> Vec<(String, bool)> { + r.iter().map(|(t, i)| (t.to_string(), *i)).collect() + } + + /// "Before *Chevron* after." on one line: the roman text, the italic case name and the roman + /// text after it, the way reading blocks split a sentence at its style changes (#4158). The + /// italic face stands a little taller than the roman one around it. + fn italic_sentence() -> Vec { + vec![ + block("Before", 72.0, 700.0, 110.0, 712.0), + Block { italic: true, ..block("Chevron", 113.0, 700.0, 158.0, 712.6) }, + block("after.", 161.0, 700.0, 190.0, 712.0), + ] + } + + /// #4158: a run in another style inside a sentence stays in that sentence's paragraph, as a + /// run in its own style, in every Office format. + #[test] + fn an_italic_word_stays_in_its_sentence() { + let mut blocks = italic_sentence(); + blocks.push(block("The next line is a paragraph of its own.", 72.0, 684.0, 300.0, 696.0)); + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; + let xml = part(&docx(std::slice::from_ref(&p), "Opinion"), "word/document.xml"); + assert_eq!( + word_paragraphs(&xml), + vec![runs(&[("Before ", false), ("Chevron ", true), ("after.", false)]), runs(&[("The next line is a paragraph of its own.", false)])], + "{xml}" + ); + let h = html(std::slice::from_ref(&p), "Opinion"); + assert!(h.contains("

Before Chevron after.

"), "{h}"); + let r = rtf(std::slice::from_ref(&p)); + assert!(r.contains("{\\pard\\fs24 Before {\\fs24\\b0\\i\\cf0 Chevron }{\\fs24\\b0\\i0\\cf0 after.}\\par}"), "{r}"); + // The longest line is the slide's title; the sentence is one paragraph of the body. + let pxml = part(&pptx(std::slice::from_ref(&p), "Opinion"), "ppt/slides/slide1.xml"); + assert!( + pxml.contains("Before Chevron after."), + "{pxml}" + ); + } + + /// #4158: the runs keep their order along the line whatever order the blocks come in, and the + /// paragraph keeps its place on the page, though the italic run stands taller than the first. + #[test] + fn a_sentence_keeps_its_reading_order() { + let mut blocks = vec![block("Then the next line.", 72.0, 684.0, 200.0, 696.0)]; + blocks.extend(italic_sentence().into_iter().rev()); + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; + let xml = part(&docx(std::slice::from_ref(&p), "Opinion"), "word/document.xml"); + assert_eq!( + word_paragraphs(&xml), + vec![runs(&[("Before ", false), ("Chevron ", true), ("after.", false)]), runs(&[("Then the next line.", false)])], + "{xml}" + ); + } + + /// The text of a block leaves out the spaces at its ends, and the box of a run often takes in + /// its trailing space, so runs can touch. The source's own space still separates them; runs + /// that touch with no space between them (a superscript) stay joined. + #[test] + fn runs_keep_the_spaces_the_source_had_between_them() { + let blocks = vec![ + Block { space_around: [false, true], ..block("Before", 72.0, 700.0, 113.0, 712.0) }, + Block { italic: true, ..block("Chevron", 113.0, 700.0, 158.0, 712.0) }, + Block { space_around: [true, false], ..block("after.", 158.0, 700.0, 190.0, 712.0) }, + block("E = mc", 72.0, 660.0, 110.0, 672.0), + Block { size: 8.0, ..block("2", 110.0, 665.0, 115.0, 673.0) }, + ]; + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; + let xml = part(&docx(std::slice::from_ref(&p), "Spaces"), "word/document.xml"); + assert_eq!( + word_paragraphs(&xml), + vec![runs(&[("Before ", false), ("Chevron ", true), ("after.", false)]), runs(&[("E = mc", false), ("2", false)])], + "{xml}" + ); + } + /// Text turned another way (a label up the margin) is not a run of the line it touches. + #[test] + fn turned_text_is_not_a_run_of_the_line_beside_it() { + let blocks = vec![block("Before", 72.0, 700.0, 110.0, 712.0), Block { rotation: 90.0, ..block("SIDE", 111.0, 690.0, 123.0, 720.0) }]; + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; + let h = html(std::slice::from_ref(&p), "Turned"); + assert!(h.contains("

Before

") && h.contains("

SIDE

"), "{h}"); + } + /// A block that starts where the line above ends is the next line, not a run of it. + #[test] + fn a_block_on_the_next_line_starts_a_paragraph() { + let blocks = vec![ + block("The first line ends here", 72.0, 700.0, 190.0, 712.0), + Block { italic: true, ..block("and the next starts there.", 190.5, 690.0, 320.0, 702.0) }, + ]; + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; + let xml = part(&docx(std::slice::from_ref(&p), "Lines"), "word/document.xml"); + assert_eq!(word_paragraphs(&xml), vec![runs(&[("The first line ends here", false)]), runs(&[("and the next starts there.", true)])], "{xml}"); + } + + /// A superscript beside a table cell stays in the table: table detection consumes it before + /// paragraphs are formed, and a sentence after the table still keeps its italic run. + #[test] + fn table_cells_are_not_runs_of_a_paragraph() { + 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")].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)); + } + blocks.extend(italic_sentence().into_iter().map(|b| Block { rect: [b.rect[0], b.rect[1] - 200.0, b.rect[2], b.rect[3] - 200.0], ..b })); + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; + let xml = part(&docx(std::slice::from_ref(&p), "Prizes"), "word/document.xml"); + assert_eq!(xml.matches("").count(), 1, "{xml}"); + for cell in ["st", "nd", "rd", "30,000"] { + assert_eq!(xml.matches(&format!("preserve\">{cell}")).count(), 1, "{cell}: {xml}"); + } + // The cells are all in the table: the only flowing paragraph is the sentence. + assert_eq!(word_paragraphs(&xml), vec![runs(&[("Before ", false), ("Chevron ", true), ("after.", false)])], "{xml}"); + } + + /// A bold lead-in followed by body text on its line is body text, not a heading; a heading + /// with a smaller footnote marker after it stays a heading. + #[test] + fn a_paragraph_is_a_heading_only_when_its_text_is() { + let blocks = vec![ + Block { size: 24.0, ..block("Annual Report", 72.0, 740.0, 250.0, 764.0) }, + Block { size: 14.0, ..block("1", 251.0, 752.0, 258.0, 766.0) }, + Block { bold: true, ..block("Summary", 72.0, 700.0, 125.0, 712.0) }, + block("the year closed with more members than it opened with, and fewer debts.", 128.0, 700.0, 540.0, 712.0), + ]; + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; + let xml = part(&docx(std::slice::from_ref(&p), "Report"), "word/document.xml"); + assert_eq!(xml.matches("").count(), 1, "{xml}"); + assert!(!xml.contains("Heading2"), "the lead-in made its paragraph a heading: {xml}"); + assert_eq!(word_paragraphs(&xml).len(), 2, "{xml}"); + } } diff --git a/crates/export/src/pptx.rs b/crates/export/src/pptx.rs index 886a4bdbde..2edc0e25c9 100644 --- a/crates/export/src/pptx.rs +++ b/crates/export/src/pptx.rs @@ -1,11 +1,12 @@ //! PowerPoint presentation (.pptx, Office Open XML PresentationML): slides per page, //! largest text as slide title, remaining paragraphs as body text, images and tables between them. -use crate::{Block, Image, Page, Table, Zip, esc, hex}; +use crate::{Image, Page, Run, Table, Zip, esc, hex}; /// Items on a slide in reading order. enum SlideBodyItem<'a> { - Para(&'a Block), + /// A paragraph's runs (at least one). + Para(Vec>), Table(Table), Img(&'a Image), } @@ -136,11 +137,11 @@ pub fn pptx(pages: &[Page], title: &str) -> Vec { .map(|(i, _)| i); let mut items: Vec<(f64, f64, SlideBodyItem)> = Vec::new(); - for (bi, b) in p.blocks.iter().enumerate() { - if Some(bi) == title_idx || consumed.get(bi).copied().unwrap_or(false) { - continue; - } - items.push((b.rect[3], b.rect[0], SlideBodyItem::Para(b))); + // The title is a shape of its own, so it joins no paragraph. + let skip: Vec = (0..p.blocks.len()).map(|bi| Some(bi) == title_idx || consumed.get(bi).copied().unwrap_or(false)).collect(); + for runs in crate::paragraphs(&p.blocks, &skip) { + let Some(first) = runs.first().map(|r| r.block) else { continue }; + items.push((first.rect[3], first.rect[0], SlideBodyItem::Para(runs))); } for t in tables { items.push((t.rect[3], t.rect[0], SlideBodyItem::Table(t))); @@ -173,11 +174,16 @@ pub fn pptx(pages: &[Page], title: &str) -> Vec { SlideBodyItem::Para(_) => { let mut paras_xml = String::new(); let mut est_h_pt = 0.0; - while let Some((_, _, SlideBodyItem::Para(b))) = items.get(i) { - let run = run_drawingml(&b.text, b.size, b.bold, b.italic, b.color); - paras_xml.push_str(&format!("{run}")); - let lines = (b.text.len() as f64 / 60.0).ceil().max(1.0); - est_h_pt += (b.size.max(10.0) * 1.3) * lines + 6.0; + while let Some((_, _, SlideBodyItem::Para(runs))) = items.get(i) { + paras_xml.push_str(""); + for r in runs { + paras_xml.push_str(&run_drawingml(&r.text, r.block.size, r.block.bold, r.block.italic, r.block.color)); + } + paras_xml.push_str(""); + let len = runs.iter().map(|r| r.text.len()).fold(0usize, usize::saturating_add); + let size = runs.iter().map(|r| r.block.size).fold(10.0, f64::max); + let lines = (len as f64 / 60.0).ceil().max(1.0); + est_h_pt += (size * 1.3) * lines + 6.0; i += 1; } let box_h = height_emu(est_h_pt); @@ -354,6 +360,7 @@ xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" type=\"blank\" #[cfg(test)] mod tests { use super::*; + use crate::Block; use crate::tests::part; fn sample_page() -> Page { @@ -369,6 +376,7 @@ mod tests { italic: false, color: [0.1, 0.2, 0.3], rotation: 0.0, + space_around: [false; 2], }, Block { text: "Revenue grew 25% year over year across all regions.".into(), @@ -378,6 +386,7 @@ mod tests { italic: false, color: [0.0; 3], rotation: 0.0, + space_around: [false; 2], }, Block { text: "Operational expenses remained flat.".into(), @@ -387,6 +396,7 @@ mod tests { italic: true, color: [0.0; 3], rotation: 0.0, + space_around: [false; 2], }, ], images: vec![Image { @@ -510,6 +520,7 @@ mod tests { italic: false, color: [0.0; 3], rotation: 0.0, + space_around: [false; 2], }; let table_page = Page { width: 612.0, @@ -611,6 +622,7 @@ mod tests { italic: false, color: [0.0; 3], rotation: 0.0, + space_around: [false; 2], }; let mut blocks = vec![huge("Title", f64::MAX, 500.0)]; for k in 0..40 { @@ -682,6 +694,7 @@ mod tests { italic: false, color: [0.0; 3], rotation: 0.0, + space_around: [false; 2], }; let p = Page { width: 720.0, @@ -696,6 +709,7 @@ mod tests { italic: false, color: [0.0; 3], rotation: 0.0, + space_around: [false; 2], }, // Table where one cell has a very large number (e.g. 36 pt callout in the table) cell("Metric", 72.0, 400.0, 100.0, 12.0), diff --git a/crates/fonts/build.rs b/crates/fonts/build.rs index bc87a16634..980c195709 100644 --- a/crates/fonts/build.rs +++ b/crates/fonts/build.rs @@ -2,7 +2,9 @@ //! 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`, `Thai` or `Deva` face), to keep the web build within hosting -//! limits (Cloudflare Pages: 25 MiB per file). +//! limits (Cloudflare Pages: 25 MiB per file). Native builds also read the optional +//! `fonts/simplified-chinese-manifest.txt` (LXGW WenKai Lite); it is never embedded on wasm. +//! `CRAFT_FONTS_REQUIRED` makes both the checkout and that native manifest required. use std::fmt::Write as _; use std::path::PathBuf; @@ -10,18 +12,21 @@ use std::path::PathBuf; #[path = "src/cjk_scripts.rs"] mod cjk_scripts; +/// Build-script entry point: embed craft-fonts from `CRAFT_FONTS_DIR`, make a missing or invalid input fatal +/// when `CRAFT_FONTS_REQUIRED` is set, and write `craft_fonts.rs` to `OUT_DIR`. fn main() { println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR"); println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED"); let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n"); + let required = std::env::var_os("CRAFT_FONTS_REQUIRED").is_some(); if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from) { - match craft_fonts(&dir) { + match craft_fonts(&dir, required) { Ok(entries) => src.push_str(&entries), - Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => { - println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display()); - } + Err(e) if required => println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display()), Err(e) => println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {e}", dir.display()), } + } else if required { + println!("cargo::error=CRAFT_FONTS_REQUIRED is set but CRAFT_FONTS_DIR is missing"); } src.push_str("];\n"); let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs"); @@ -30,26 +35,66 @@ fn main() { } } -/// One `CraftFont { .. }` initialiser per manifest line. -fn craft_fonts(dir: &std::path::Path) -> Result { - let manifest = dir.join("fonts/manifest.txt"); - println!("cargo::rerun-if-changed={}", manifest.display()); - let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?; +/// The main manifest, plus the native-only Simplified Chinese manifest. The latter is optional +/// for ordinary builds and required when `CRAFT_FONTS_REQUIRED` is set, so a required build can +/// never pass the Chinese UI tests only by skipping them. +fn craft_fonts(dir: &std::path::Path, required: bool) -> Result { let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32"); + let mut out = manifest(dir, &dir.join("fonts/manifest.txt"), wasm, false)?; + if !wasm { + let chinese = dir.join("fonts/simplified-chinese-manifest.txt"); + println!("cargo::rerun-if-changed={}", chinese.display()); + match chinese.try_exists() { + Ok(false) if required => { + return Err(format!("{}: CRAFT_FONTS_REQUIRED is set but the simplified-Chinese font manifest is missing", chinese.display())); + } + Ok(false) => {} + Ok(true) => match manifest(dir, &chinese, false, required) { + Ok(entries) => out.push_str(&entries), + // A broken optional addition must never discard the main Japanese/symbol/RTL faces. + Err(e) if required => return Err(e), + Err(e) => println!("cargo::warning=skipping optional Chinese fonts: {e}"), + }, + Err(e) if required => return Err(format!("{}: {e}", chinese.display())), + Err(e) => println!("cargo::warning=skipping optional Chinese fonts: {}: {e}", chinese.display()), + } + } + Ok(out) +} + +/// One `CraftFont { .. }` initialiser per manifest line. +/// +/// `strict_cap` is set for the required native Simplified Chinese manifest: a face that does not +/// fit its cap must fail the build rather than be silently left out, so a required build cannot +/// pass without the Chinese face it promises. +fn manifest(dir: &std::path::Path, manifest: &std::path::Path, wasm: bool, strict_cap: bool) -> Result { + println!("cargo::rerun-if-changed={}", manifest.display()); + let text = std::fs::read_to_string(manifest).map_err(|e| format!("{}: {e}", manifest.display()))?; let mut out = String::new(); + // A required native Chinese manifest must add the Hans face itself, not merely some other + // CJK face that happens to fit: otherwise CRAFT_FONTS_REQUIRED would pass while the promised + // Simplified Chinese fallback is still absent. + let mut has_hans = false; for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) { let f: Vec<&str> = line.split(" | ").map(str::trim).collect(); let [family, style, file, scripts, ..] = f.as_slice() else { return Err(format!("malformed manifest line: {line}")); }; - // Whole CJK faces aren't embedded for size; interface CJK comes from cut faces once the app - // moves to craft-fonts-build (storytold/craft-libs#213). + // Keep the upstream general CJK size cap. The separately pinned native Chinese UI + // face has its own bound; web builds retain the smaller general limit. A required + // Chinese face over its cap fails the build instead of being silently omitted. let cjk = scripts.split(',').any(cjk_scripts::is_cjk_script); - if cjk - && !cjk_scripts::WHOLE_CJK_FAMILIES.contains(family) - && std::fs::metadata(dir.join(file)).is_ok_and(|m| m.len() > cjk_scripts::MAX_OTHER_CJK_BYTES) - { - continue; + if cjk && let Ok(metadata) = std::fs::metadata(dir.join(file)) { + match cjk_scripts::cjk_size_decision(family, metadata.len(), wasm, strict_cap) { + cjk_scripts::CjkSizeDecision::Embed => {} + cjk_scripts::CjkSizeDecision::Skip => continue, + cjk_scripts::CjkSizeDecision::Reject => { + let cap = cjk_scripts::cjk_cap_bytes(family, wasm); + return Err(format!( + "{file}: the {family} face is larger than the {cap}-byte native Chinese UI cap; CRAFT_FONTS_REQUIRED cannot embed it" + )); + } + } } // Arabic interface faces are small, so the web build keeps them too. let arabic = scripts.split(',').any(|s| s.trim() == "Arab"); @@ -68,6 +113,7 @@ fn craft_fonts(dir: &std::path::Path) -> Result { if wasm && !web_face { continue; } + has_hans |= cjk_scripts::has_script_tag(scripts, "Hans"); let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?; println!("cargo::rerun-if-changed={}", path.display()); let scripts: Vec = scripts.split(',').map(|s| format!("{:?}", s.trim())).collect(); @@ -78,5 +124,36 @@ fn craft_fonts(dir: &std::path::Path) -> Result { path.display().to_string(), ); } + if strict_cap && !has_hans { + return Err(format!( + "{}: CRAFT_FONTS_REQUIRED is set but the manifest emits no Hans face (a Simplified Chinese face is required)", + manifest.display() + )); + } Ok(out) } + +#[cfg(test)] +mod manifest_tests { + use super::*; + + /// Regression test: a required native-font manifest must declare at least one Hans face, while optional + /// input accepts another script and a missing asset still fails. + #[test] + fn required_native_manifest_rejects_non_hans_faces_after_parsing() { + let root = std::env::temp_dir().join(format!("pdfcraft-required-hans-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("face.ttf"), b"synthetic manifest fixture").unwrap(); + let path = root.join("manifest.txt"); + for scripts in ["Hant,Latn", "Hans-HK,Latn", "Latn"] { + std::fs::write(&path, format!("Fixture | Regular | face.ttf | {scripts}\n")).unwrap(); + assert!(manifest(&root, &path, false, true).unwrap_err().contains("no Hans face"), "{scripts}"); + assert!(manifest(&root, &path, false, false).unwrap().contains("CraftFont {"), "optional input stays usable"); + } + std::fs::write(&path, "Fixture | Regular | face.ttf | Latn, Hans\n").unwrap(); + assert!(manifest(&root, &path, false, true).unwrap().contains("CraftFont {")); + std::fs::remove_file(root.join("face.ttf")).unwrap(); + assert!(manifest(&root, &path, false, true).is_err(), "a missing Hans asset cannot satisfy required input"); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/crates/fonts/src/cjk_scripts.rs b/crates/fonts/src/cjk_scripts.rs index 16a863c683..1058c6e61b 100644 --- a/crates/fonts/src/cjk_scripts.rs +++ b/crates/fonts/src/cjk_scripts.rs @@ -8,6 +8,11 @@ pub const WHOLE_CJK_FAMILIES: [&str; 3] = ["Shippori Mincho", "BIZ UDPGothic", " /// Any other CJK face over this size is left out. pub const MAX_OTHER_CJK_BYTES: u64 = 4 << 20; +/// Reviewed native-only UI fallback from the separate Chinese manifest. +/// It is never included in the browser payload. Other CJK families keep the 4 MiB cap. +pub const NATIVE_UI_CJK_FAMILIES: [&str; 1] = ["LXGW WenKai Lite"]; +pub const MAX_NATIVE_UI_CJK_BYTES: u64 = 16 << 20; + /// ISO 15924 codes of the Han, Japanese and Korean scripts, Hangul on its own included. const CJK_SCRIPTS: [&str; 6] = ["Hani", "Hans", "Hant", "Jpan", "Kore", "Hang"]; @@ -17,3 +22,76 @@ pub fn is_cjk_script(tag: &str) -> bool { let code = tag.trim().split('-').next().unwrap_or_default(); CJK_SCRIPTS.contains(&code) } + +/// Whether a manifest's comma-separated script list contains `script` exactly, ignoring spaces. +/// The interface and document lookups use exact tags (`CraftFont::covers`), so a regional tag +/// such as `Hans-HK` is not a `Hans` entry. +pub fn has_script_tag(scripts: &str, script: &str) -> bool { + scripts.split(',').any(|tag| tag.trim() == script) +} + +/// What `build.rs` does with one CJK face from a manifest. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CjkSizeDecision { + /// The face fits its cap (or is a Japanese family embedded whole). + Embed, + /// An optional face over its cap: leave it out and keep the rest. + Skip, + /// A required face over its cap: fail the build rather than silently omit it. + Reject, +} + +/// The size cap that applies to `family` in this build. +pub fn cjk_cap_bytes(family: &str, wasm: bool) -> u64 { + if !wasm && NATIVE_UI_CJK_FAMILIES.contains(&family) { MAX_NATIVE_UI_CJK_BYTES } else { MAX_OTHER_CJK_BYTES } +} + +/// Decide whether a CJK face is embedded, skipped, or rejected. +/// +/// The Japanese document/UI families are embedded whole. Every other CJK family uses its cap; +/// `required` marks the native Simplified Chinese manifest of a `CRAFT_FONTS_REQUIRED` build, +/// where an over-cap face must fail instead of quietly disappearing. +pub fn cjk_size_decision(family: &str, bytes: u64, wasm: bool, required: bool) -> CjkSizeDecision { + if WHOLE_CJK_FAMILIES.contains(&family) { + return CjkSizeDecision::Embed; + } + if bytes <= cjk_cap_bytes(family, wasm) { + CjkSizeDecision::Embed + } else if required { + CjkSizeDecision::Reject + } else { + CjkSizeDecision::Skip + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Check that a required Chinese manifest needs an exact `Hans` script tag and does not accept + /// Traditional or region-suffixed tags. + #[test] + fn a_required_chinese_manifest_needs_an_exact_hans_entry() { + assert!(has_script_tag("Hans,Latn", "Hans")); + assert!(has_script_tag(" Latn , Hans ", "Hans")); + assert!(!has_script_tag("Hant,Latn", "Hans"), "Traditional Chinese is not the required Simplified face"); + assert!(!has_script_tag("Hans-HK,Latn", "Hans"), "the UI looks for the exact Hans tag"); + assert!(!has_script_tag("Hani,Jpan,Kore", "Hans")); + } + + /// Check that a native Chinese face over its cap is rejected when required, skipped when optional, and + /// uses the web cap on the web. + #[test] + fn a_required_native_chinese_face_over_its_cap_is_rejected_not_skipped() { + let at_cap = MAX_NATIVE_UI_CJK_BYTES; + assert_eq!(cjk_size_decision("LXGW WenKai Lite", at_cap, false, true), CjkSizeDecision::Embed); + assert_eq!(cjk_size_decision("LXGW WenKai Lite", at_cap + 1, false, true), CjkSizeDecision::Reject); + assert_eq!(cjk_size_decision("LXGW WenKai Lite", at_cap + 1, false, false), CjkSizeDecision::Skip); + // The web build keeps the smaller general cap even for the native Chinese family. + assert_eq!(cjk_size_decision("LXGW WenKai Lite", MAX_OTHER_CJK_BYTES + 1, true, true), CjkSizeDecision::Reject); + // Japanese document/UI families are embedded whole. + assert_eq!(cjk_size_decision("BIZ UDPGothic", u64::MAX, false, true), CjkSizeDecision::Embed); + assert_eq!(cjk_cap_bytes("LXGW WenKai Lite", false), MAX_NATIVE_UI_CJK_BYTES); + assert_eq!(cjk_cap_bytes("LXGW WenKai Lite", true), MAX_OTHER_CJK_BYTES); + } +} diff --git a/crates/fonts/src/craft.rs b/crates/fonts/src/craft.rs index e778a42ad7..dff96762aa 100644 --- a/crates/fonts/src/craft.rs +++ b/crates/fonts/src/craft.rs @@ -315,12 +315,13 @@ mod tests { } /// `build.rs` embeds the Japanese CJK faces whole and any other CJK face only up to - /// `MAX_OTHER_CJK_BYTES`, so a checkout with a large Chinese or Korean face (craft-fonts has the + /// `MAX_OTHER_CJK_BYTES` (the explicitly pinned native-only LXGW UI fallback has a separate + /// 16 MiB cap), so a checkout with a large Chinese or Korean face (craft-fonts has the /// 16.4 MB Noto Sans CJK SC) leaves it out. The four Japanese faces of the pinned craft-fonts /// are 24.1 MB. #[test] fn cjk_faces_follow_the_size_rule() { - use crate::cjk_scripts::{MAX_OTHER_CJK_BYTES, WHOLE_CJK_FAMILIES, is_cjk_script}; + use crate::cjk_scripts::{MAX_NATIVE_UI_CJK_BYTES, MAX_OTHER_CJK_BYTES, NATIVE_UI_CJK_FAMILIES, WHOLE_CJK_FAMILIES, is_cjk_script}; // A tag can carry a region after the code: craft-fonts tags Noto Sans HK UI `Hant-HK`. assert!(["Hant-HK", "Hans", "Jpan", "Hang"].iter().all(|s| is_cjk_script(s))); assert!(!["Latn", "Arab", "Hebr"].iter().any(|s| is_cjk_script(s))); @@ -328,7 +329,10 @@ mod tests { let (whole, other): (Vec<_>, Vec<_>) = cjk.partition(|f| WHOLE_CJK_FAMILIES.contains(&f.family)); for face in &other { let len = face.bytes.len(); - assert!(u64::try_from(len).is_ok_and(|n| n <= MAX_OTHER_CJK_BYTES), "{} is embedded at {len} bytes", face.name()); + let native_ui = NATIVE_UI_CJK_FAMILIES.contains(&face.family); + assert!(!cfg!(target_arch = "wasm32") || !native_ui, "native UI face in web payload"); + let cap = if native_ui { MAX_NATIVE_UI_CJK_BYTES } else { MAX_OTHER_CJK_BYTES }; + assert!(u64::try_from(len).is_ok_and(|n| n <= cap), "{} is embedded at {len} bytes", face.name()); } let bytes: usize = whole.iter().map(|f| f.bytes.len()).sum(); assert!(bytes < 25_000_000, "{bytes} bytes of Japanese faces"); diff --git a/crates/fonts/tests/build_input.rs b/crates/fonts/tests/build_input.rs new file mode 100644 index 0000000000..832cc2c16c --- /dev/null +++ b/crates/fonts/tests/build_input.rs @@ -0,0 +1,4 @@ +//! Required manifest checks execute the same parser as the production build script. +#[allow(dead_code)] +#[path = "../build.rs"] +mod build_script; diff --git a/crates/forms/README.md b/crates/forms/README.md index 4aec223ee9..93b65bd14e 100644 --- a/crates/forms/README.md +++ b/crates/forms/README.md @@ -14,6 +14,8 @@ set_value(&mut doc, "name", &FieldValue::Text("Ada".into()))?; set_value(&mut doc, "agree", &FieldValue::Check(true))?; set_value(&mut doc, "size", &FieldValue::Radio(Some("L".into())))?; set_value(&mut doc, "toppings", &FieldValue::Choice(vec!["Ham".into()]))?; +// Many values: as set_value one by one, but reading the form once and recalculating once. +let results: Vec> = set_values(&mut doc, &[("name".into(), FieldValue::Text("Ada".into()))])?; reset(&mut doc, None)?; // Clear form (back to /DV) // Prepare a form @@ -58,7 +60,9 @@ let cells: Vec = grid.cells_for(names)?; // "Table1_1", "Table 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 with no appearance for their state are drawn from `/MK` (border, background, and when on the `/MK /CA` ZapfDingbats - symbol in the `/DA` colour), and push buttons with none from `/MK`, `/BS` and their caption. + symbol in the `/DA` colour), and with `/NeedAppearances` so is every one that has an `/MK`, + over the appearance the file carries; push buttons with none are drawn from `/MK`, `/BS` and + their caption. - 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 diff --git a/crates/forms/src/af.rs b/crates/forms/src/af.rs index 7b706e9ae9..3119481cff 100644 --- a/crates/forms/src/af.rs +++ b/crates/forms/src/af.rs @@ -844,7 +844,10 @@ fn group(int: &str, sep: char) -> String { fn number_text(v: f64, decimals: u8, sep: u8) -> String { // Round half away from zero, as JavaScript's toFixed does for these values. let p = 10f64.powi(i32::from(decimals)); - let s = format!("{:.*}", decimals as usize, (v.abs() * p).round() / p); + let scaled = v.abs() * p; + // Past 2^53 every value is already whole, and scaling could overflow to infinity. + let r = if scaled.is_finite() { scaled.round() / p } else { v.abs() }; + let s = format!("{:.*}", decimals as usize, r); let (int, frac) = s.split_once('.').map_or((s.as_str(), ""), |(a, b)| (a, b)); let (g, d) = match sep { 1 => (None, '.'), @@ -2060,4 +2063,12 @@ mod undelimited_tests { assert_eq!(parse_date("99999999999999999999", "yyyymmdd"), None); assert_eq!(parse_date("2026 99999999999", "yyyy"), None, "an unreadable number is left over, not ignored"); } + + #[test] + fn huge_finite_numbers_do_not_print_inf() { + let n = format_value(&Format::Number { decimals: 2, sep: 1, neg: 0, currency: String::new(), prepend: true }, "1e307"); + assert!(n.len() > 300 && n.ends_with(".00") && !n.contains("inf"), "got {n}"); + let p = format_value(&Format::Percent { decimals: 2, sep: 1 }, "1e306"); + assert!(!p.contains("inf"), "got {p}"); + } } diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index da25c211c0..c1bb5630a6 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -480,7 +480,7 @@ fn frame(doc: &Document, wd: &Dict, w: f64, h: f64) -> (String, f64) { /// Whether `wd` is a check box or radio button PdfCraft laid out for a round XFA check button /// (`/PCXfaRound true`, written by the XFA layout). -fn is_round(doc: &Document, wd: &Dict) -> bool { +pub(crate) fn is_round(doc: &Document, wd: &Dict) -> bool { matches!(wd.get(b"PCXfaRound").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) } @@ -1029,9 +1029,10 @@ fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64, color: } /// How a check box or radio button widget `w` of field `f` looks in state on (`on`) or Off when -/// the file has no appearance for that state: its `/MK` background and border ([`frame`]), and -/// when on, its caption `/MK /CA` (ISO 32000-2 Table 192: the normal caption, which check boxes -/// and radio buttons may use too) as a ZapfDingbats character, as in the check box example of +/// the file has no appearance for that state, or leaves its appearances to the viewer +/// (`/NeedAppearances`): its `/MK` background and border ([`frame`]), and when on, its caption +/// `/MK /CA` (ISO 32000-2 Table 192: the normal caption, which check boxes and radio buttons may +/// use too) as a ZapfDingbats character, as in the check box example of /// 12.7.5.2.3: `4` (a check mark) by default, `l` (a dot) for a radio button. The character is /// drawn in the `/DA` colour, at the `/DA` size or, for an automatic size (`0 Tf`), filling most /// of the box. ZapfDingbats is one of the standard 14 fonts, so no font program is needed. diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index 873ef3d716..215bd095fc 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -963,6 +963,7 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel rotation: 0, caption: None, list: None, + round: true, }; let ap = appearance::check_box_states(doc, &w, FieldKind::Radio, export, "/ZaDb 0 Tf 0 g"); doc.update_dict(widget, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?; diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 75c3dbee00..39cebd8fdd 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -118,6 +118,9 @@ pub struct Widget { pub caption: Option, /// List boxes: where the options are drawn, so a click can pick the one under the pointer. pub list: Option, + /// Drawn round: a radio button, or a check box laid out for a round XFA check button + /// (`/PCXfaRound true`). + pub round: bool, } /// A terminal form field. @@ -570,7 +573,21 @@ pub fn fields_with_adopted(doc: &Document) -> Fields { Fields { items, adopted_page_fields } } +#[cfg(test)] +thread_local! { + /// Whole-form readings ([`enumerate`]) on this thread. + static FORM_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// How many times this thread has read the whole form. +#[cfg(test)] +fn form_reads() -> usize { + FORM_READS.with(std::cell::Cell::get) +} + fn enumerate(doc: &Document) -> (Vec, usize) { + #[cfg(test)] + FORM_READS.with(|n| n.set(n.get() + 1)); let af = acroform(doc); let mut page_of = std::collections::HashMap::new(); let mut annot_index = std::collections::HashMap::new(); @@ -882,6 +899,7 @@ fn walk( }) .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)), + round: kind == FieldKind::Radio || (kind == FieldKind::CheckBox && appearance::is_round(doc, wd)), }) }) .collect(); @@ -931,23 +949,93 @@ pub fn set_value_with(doc: &mut Document, name: &str, value: &FieldValue, script if all.is_empty() { return Err(FormError::NoForm); } - let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; + let named: Vec = all.iter().enumerate().filter(|(_, f)| f.name == name).map(|(i, _)| i).collect(); + fill(doc, &all, &named, name, value, scripts)?; + recalculate_with(doc, scripts)?; + Ok(()) +} + +/// Fill several fields, as [`set_value`] fills each in turn, but reading the form once and +/// recalculating once, after the last value. Filling fields one by one reads the whole form twice +/// per field, which takes minutes on a form with thousands of fields. +/// +/// Returns each value's result, in order. The outer `Err` is the recalculation failing, after +/// which, as with [`set_value`], no value counts as filled (though each was written). +pub fn set_values(doc: &mut Document, values: &[(String, FieldValue)]) -> Result>, FormError> { + let mut scripts = NoScripts; + let mut form = NamedFields::read(doc); + // The field objects this batch wrote since the form was last read. + let mut written = std::collections::HashSet::new(); + let mut results = Vec::with_capacity(values.len()); + let mut filled = false; + for (name, value) in values { + if form.all.is_empty() { + results.push(Err(FormError::NoForm)); + continue; + } + // A field written earlier in the batch (named twice, or a twin's name) is read again, + // so this write starts from what the earlier one left, as it would one by one. + if form.objs(name).any(|o| written.contains(&o)) { + form = NamedFields::read(doc); + written.clear(); + } + let named = form.named(name); + let result = fill(doc, &form.all, named, name, value, &mut scripts); + written.extend(form.objs(name)); + filled |= result.is_ok(); + results.push(result); + } + if filled { + recalculate_with(doc, &mut scripts)?; + } + Ok(results) +} + +/// The form's fields, with the positions of each fully qualified name in them (in field order). +struct NamedFields { + all: Vec, + by_name: std::collections::HashMap>, +} + +impl NamedFields { + fn read(doc: &Document) -> Self { + let all = fields(doc); + let mut by_name: std::collections::HashMap> = std::collections::HashMap::new(); + for (i, f) in all.iter().enumerate() { + by_name.entry(f.name.clone()).or_default().push(i); + } + NamedFields { all, by_name } + } + + fn named(&self, name: &str) -> &[usize] { + self.by_name.get(name).map_or(&[], Vec::as_slice) + } + + /// The field objects named `name`. + fn objs<'a>(&'a self, name: &str) -> impl Iterator + 'a { + self.named(name).iter().filter_map(|&i| self.all.get(i)).map(|f| f.obj) + } +} + +/// Write `value` into the field `name`, without recalculating: `named` are the positions in +/// `all` (the form as read before this write) of the fields with that name, in field order. +fn fill(doc: &mut Document, all: &[Field], named: &[usize], name: &str, value: &FieldValue, scripts: &mut dyn Scripts) -> Result<(), FormError> { + let f = named.first().and_then(|&i| all.get(i)).ok_or_else(|| FormError::NoSuchField(name.into()))?; if f.read_only() { return Err(FormError::ReadOnly(name.into())); } write_value(doc, f, value, scripts)?; - author::form_fonts_as_objects(doc, &all)?; + author::form_fonts_as_objects(doc, all)?; // Fields with the same fully qualified name are one field (§12.7.3.1). Writers that repeat a // name as separate field dictionaries, instead of widgets under one parent, get the value // the first one took after its scripts, without running them again. - let twins: Vec<&Field> = all.iter().filter(|t| t.name == name && t.obj != f.obj && t.kind == f.kind).collect(); + let twins: Vec<&Field> = named.iter().filter_map(|&i| all.get(i)).filter(|t| t.obj != f.obj && t.kind == f.kind).collect(); if !twins.is_empty() { let taken = fields(doc).into_iter().find(|x| x.obj == f.obj).map(|x| x.value).unwrap_or_default(); for t in twins { share_value(doc, f.obj, t, &taken, scripts)?; } } - recalculate_with(doc, scripts)?; Ok(()) } @@ -1253,6 +1341,7 @@ fn redraw(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Sc [one] if matches!(f.kind, FieldKind::Text | FieldKind::Combo) => scripting::formatted(doc, f, one, scripts), _ => None, }; + let in_place = f.kind == FieldKind::Text && f.has(flags::PASSWORD); let mut undrawn = false; for w in &f.widgets { let (stream, missing) = match &shown { @@ -1260,7 +1349,23 @@ fn redraw(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Sc None => appearance::field_appearance_drawn(doc, f, w, values, true), }; undrawn |= missing.is_some(); - let ap = doc.add(Object::Stream(stream)); + let existing_ap = in_place + .then(|| { + doc.get(w.obj) + .as_dict() + .and_then(|d| d.get(b"AP").map(|a| doc.resolve(a))) + .and_then(|a| a.as_dict().and_then(|a| a.get(b"N").cloned())) + .and_then(|n| n.as_ref()) + .filter(|r| doc.is_edited(r.num) && matches!(&*doc.get(*r), Object::Stream(_))) + }) + .flatten(); + let ap = match existing_ap { + Some(r) => { + doc.set(r, Object::Stream(stream)); + r + } + None => doc.add(Object::Stream(stream)), + }; let mut fresh = Dict::new(); fresh.set(b"N".to_vec(), Object::Ref(ap)); let apd = merged_appearance(doc, w.obj, &fresh); @@ -1315,7 +1420,7 @@ fn password_fields(doc: &Document) -> Vec { /// again only makes the answer cautious. It reads only `/Ff`, `/Kids` and `/Parent`, so a save /// or recovery snapshot of a form without such a field doesn't build every [`Field`]. Like /// [`fields`], it looks at the form's field tree and at the widgets on the pages, which may be -/// missing from `/Fields`. Each dictionary is visited once and at most 32 levels deep. +/// missing from `/Fields`. Each dictionary is visited once and at most 64 levels deep. fn has_password_flag(doc: &Document) -> bool { let sets_it = |r: ObjRef| doc.get(r).as_dict().and_then(|d| field_flags(doc, d)).is_some_and(|ff| ff & flags::PASSWORD != 0); let top = acroform(doc).and_then(|af| af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned())).unwrap_or_default(); @@ -1323,7 +1428,7 @@ fn has_password_flag(doc: &Document) -> bool { let mut seen = std::collections::HashSet::new(); while let Some((o, depth)) = stack.pop() { let Object::Ref(r) = o else { continue }; - if depth > 32 || !seen.insert(r) { + if depth > 64 || !seen.insert(r) { continue; } if sets_it(r) { @@ -1363,11 +1468,11 @@ pub fn restore_password_values(doc: &mut Document, values: &[(String, String)]) Ok(()) } -/// `r` and its field ancestors (`/Parent`), nearest first, at most 32 deep and without repeats. +/// `r` and its field ancestors (`/Parent`), nearest first, at most 64 deep and without repeats. fn field_chain(doc: &Document, r: ObjRef) -> Vec { let mut chain = vec![r]; let mut cur = r; - while chain.len() < 32 { + while chain.len() < 64 { let Some(Object::Ref(parent)) = doc.get(cur).as_dict().and_then(|d| d.get(b"Parent").cloned()) else { break }; if chain.contains(&parent) { break; @@ -1448,8 +1553,9 @@ pub fn need_appearances(doc: &Document) -> bool { /// writer left the appearances to the viewer), and otherwise each widget without a normal /// appearance (`/AP /N`), as typing the value would draw it (#3470). Check boxes and radio buttons /// with no normal appearance for their state (`/AS`) are drawn from their `/MK` background, border -/// and caption, and push buttons with none from their `/MK` and `/BS` (Table 192). `doc` is not -/// changed: saving writes the file as it was. +/// and caption, and with `/NeedAppearances` every one that has an `/MK` is, over its own +/// appearance; push buttons with none are drawn from their `/MK` and `/BS` (Table 192). `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()) } @@ -1467,19 +1573,25 @@ pub fn with_field_appearances_cached(doc: &Document, cache: &mut FieldAppearance .and_then(|d| d.get(b"AP")) .and_then(|ap| doc.resolve(ap).as_dict().and_then(|ap| ap.get(b"N").map(|n| doc.resolve(n)))); // A check box or radio button shows the appearance of its state (12.7.5.2.3): `/AS`, - // else a check box's value, else Off. An Off state may go without one (it shows - // nothing); an on state needs one. + // else the value when it names this widget's on state (any check box value when the + // widget names none), else Off. An Off state may go without one (it shows nothing); + // an on state needs one. let state = match f.kind { FieldKind::CheckBox | FieldKind::Radio => { - let value = f.value.first().filter(|v| f.kind == FieldKind::CheckBox && !v.is_empty() && v.as_str() != "Off"); + let mine = |v: &&String| w.on_state.as_ref().map_or(f.kind == FieldKind::CheckBox, |on| on == *v); + let value = f.value.first().filter(|v| !v.is_empty() && v.as_str() != "Off").filter(mine); Some(w.state.clone().or_else(|| value.cloned()).unwrap_or_else(|| "Off".into())) } _ => None, }; + // With /NeedAppearances a check box or radio button that describes its look in /MK is + // drawn from it (Table 192), its own appearance or not; one without /MK keeps its own. + let described = || widget.as_dict().and_then(|d| d.get(b"MK")).is_some_and(|mk| doc.resolve(mk).as_dict().is_some()); let missing = match f.kind { FieldKind::Text | FieldKind::Combo | FieldKind::List => normal.is_none() || all, FieldKind::PushButton => normal.is_none(), FieldKind::CheckBox | FieldKind::Radio => match (normal.as_deref(), state.as_deref()) { + _ if all && described() => true, (None, _) => true, (Some(Object::Dict(states)), Some(s)) => s != "Off" && !states.contains(&name_bytes(s)), _ => false, diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 54bc137abd..61dab131e0 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -4088,6 +4088,65 @@ fn a_password_field_missing_from_the_field_list_is_still_cleared() { } } +/// A password field nested beyond depth 32 (e.g. depth 35) is recognized by `has_password_flag` +/// and `has_password_values`, matching the field tree walk limit of 64. +#[test] +fn a_password_field_nested_beyond_depth_32_is_recognized() { + let target_depth = 35; + let leaf_obj_num = 5 + target_depth; + let mut 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 >>".into(), + "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), + ]; + for d in 0..target_depth { + let obj_num = 5 + d; + let next_obj_num = obj_num + 1; + let parent_entry = if d == 0 { String::new() } else { format!(" /Parent {} 0 R", obj_num - 1) }; + objs.push(format!("<< /T (node{d}) /Kids [{next_obj_num} 0 R]{parent_entry} >>")); + } + objs.push(format!( + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (pass) /Ff 8192 /V (secret35) /Parent {} 0 R /Rect [50 600 150 620] /P 3 0 R >>", + leaf_obj_num - 1 + )); + let mut doc = document(&objs); + assert!(has_password_flag(&doc)); + assert!(has_password_values(&doc)); + assert_eq!(clear_password_values(&mut doc).unwrap(), 1); + assert!(!has_password_values(&doc)); +} + +/// A password field whose widget is reachable through a page's Annots and whose inherited Password +/// flag is deeper than 32 levels is recognized by `has_password_flag`. +#[test] +fn a_page_widget_inheriting_password_flag_beyond_depth_32_is_recognized() { + let target_depth = 35; + let leaf_obj_num = 5 + target_depth; + let mut 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(), + format!("<< /Type /Page /Parent 2 0 R /Annots [{leaf_obj_num} 0 R] >>"), + "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), + ]; + for d in 0..target_depth { + let obj_num = 5 + d; + let next_obj_num = obj_num + 1; + let parent_entry = if d == 0 { String::new() } else { format!(" /Parent {} 0 R", obj_num - 1) }; + let ff_entry = if d == 0 { " /Ff 8192" } else { "" }; + objs.push(format!("<< /T (node{d}){ff_entry} /Kids [{next_obj_num} 0 R]{parent_entry} >>")); + } + objs.push(format!( + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (pass) /V (secret_chain) /Parent {} 0 R /Rect [50 600 150 620] /P 3 0 R >>", + leaf_obj_num - 1 + )); + let mut doc = document(&objs); + assert!(has_password_flag(&doc)); + assert!(has_password_values(&doc)); + assert_eq!(clear_password_values(&mut doc).unwrap(), 1); + assert!(!has_password_values(&doc)); +} + /// #4018: a value that doesn't fit its box at the field's font size is reported (it is drawn cut /// at the box). An auto-sized field shrinks to fit, and a short or empty value fits. #[test] @@ -4300,3 +4359,221 @@ fn comb_fields_show_their_cells() { let plain = saved_look(&doc, "d"); assert!(!plain.contains(" m ") && plain.contains("(A) Tj"), "{plain}"); } + +/// Check boxes and radio buttons with an `/MK` look and appearances of their own, in a form that +/// sets `/NeedAppearances` (`need`) or not. +fn mk_buttons(need: bool) -> Document { + let flag = if need { "/NeedAppearances true " } else { "" }; + let widget = |n: &str, rest: &str| format!("<< /Type /Annot /Subtype /Widget /FT /Btn /T ({n}) /F 4 /P 3 0 R {rest} >>"); + let states = |on: &str| format!("/AP << /N << /{on} 15 0 R /Off 16 0 R >> >>"); + 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 11 0 R 12 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 11 0 R 12 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(), + widget("check", &format!("/V /Yes /AS /Yes /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [50 700 74 724] {}", states("Yes"))), // 6 + widget("cross", &format!("/V /Yes /AS /Yes /DA (/ZaDb 0 Tf 1 0 0 rg) /MK << /CA (8) >> /Rect [100 700 124 724] {}", states("Yes"))), + widget("diamond", &format!("/V /Yes /AS /Yes /MK << /CA (u) /BG [1 1 0] >> /Rect [150 700 174 724] {}", states("Yes"))), + widget("square", &format!("/V /Yes /AS /Yes /MK << /CA (n) >> /Rect [200 700 224 724] {}", states("Yes"))), + widget("star", &format!("/V /Yes /AS /Yes /MK << /CA (H) >> /Rect [250 700 274 724] {}", states("Yes"))), // 10 + widget("off", &format!("/V /Off /AS /Off /MK << /BC [0 0 0] /CA (4) >> /Rect [300 700 324 724] {}", states("Yes"))), + widget("radio", &format!("/Ff 32768 /V /B /MK << /BC [0 0 1] /CA (l) >> /Rect [350 700 374 724] {}", states("B"))), + widget("plain", &format!("/V /Yes /AS /Yes /Rect [400 700 424 724] {}", states("Yes"))), + widget( + "odd", + &format!( + "/V /Yes /AS /Yes /DA (/ZaDb nan Tf 1 x rg) /MK << /CA 42 /BC [(no) /Name] /BG 7 >> /BS << /W -3 >> /Rect [450 700 474 724] {}", + states("Yes") + ), + ), + "<< /Length 24 >>\nstream\n0 g 4 4 16 16 re f\nendstream".into(), // 15: a filled square + "<< /Length 4 >>\nstream\nq\nQ\nendstream".into(), + ]) +} + +/// ISO 32000-2 Table 224 (`/NeedAppearances`) and Table 192 (`/MK /CA`): a form that leaves its +/// appearances to the viewer shows each check box and radio button with an `/MK` drawn from it, +/// over the appearance the file carries: the caption's ZapfDingbats symbol in the `/DA` colour +/// when on, only the border when off. A widget with no `/MK` keeps its own; the file is untouched. +#[test] +fn need_appearances_draws_check_boxes_and_radio_buttons_from_their_captions() { + let doc = mk_buttons(true); + let copy = with_field_appearances(&doc).expect("buttons to draw"); + let all = fields(©); + for (name, state, symbol, colour) in [ + ("check", "Yes", "(4) Tj", "0 g"), + ("cross", "Yes", "(8) Tj", "1 0 0 rg"), + ("diamond", "Yes", "(u) Tj", "0 g"), + ("square", "Yes", "(n) Tj", "0 g"), + ("star", "Yes", "(H) Tj", "0 g"), + ("radio", "B", "(l) Tj", "0 g"), + ] { + let shown = state_ap(©, &field(&all, name).widgets[0], state); + assert!(shown.contains(symbol) && shown.contains(&format!("{colour}\n")), "{name}: {shown}"); + assert!(!shown.contains("4 4 16 16 re f"), "{name} is not the file's filled square: {shown}"); + } + let check = state_ap(©, &field(&all, "check").widgets[0], "Yes"); + assert!(check.contains("0 0 0 RG\n1 w\n0.5 0.5 23 23 re S"), "the /BC border: {check}"); + assert!(state_ap(©, &field(&all, "diamond").widgets[0], "Yes").contains("1 1 0 rg\n0 0 24 24 re f"), "the /BG background"); + assert!(state_ap(©, &field(&all, "radio").widgets[0], "B").contains("0 0 1 RG"), "the radio's own border"); + let off = state_ap(©, &field(&all, "off").widgets[0], "Off"); + assert!(off.contains("re S") && !off.contains("Tj"), "off: only the border: {off}"); + // Nonsense in /MK, /DA and /BS: the default check, no border, and no panic. + let odd = state_ap(©, &field(&all, "odd").widgets[0], "Yes"); + assert!(odd.contains("(4) Tj") && !odd.contains("re S"), "{odd}"); + let own = |d: &Document, name: &str| d.get(field(&fields(d), name).widgets[0].obj).as_dict().and_then(|w| w.get(b"AP").cloned()); + assert_eq!(own(©, "plain"), own(&doc, "plain"), "a box without /MK keeps its own look"); + assert_eq!(state_ap(&doc, &field(&fields(&doc), "check").widgets[0], "Yes").trim_end(), "0 g 4 4 16 16 re f", "the file is untouched"); +} + +/// Without `/NeedAppearances` the file's own check box and radio button appearances are shown +/// as they are, `/MK` or not: no copy is needed. +#[test] +fn without_need_appearances_buttons_keep_their_own_appearances() { + assert!(with_field_appearances(&mk_buttons(false)).is_none()); +} + +/// A form for filling many values at once: two summed fields and their calculated total, a +/// name two field dictionaries share, a check box, a radio group, a list and a read-only field. +fn batch_form() -> Document { + let tx = |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 8 0 R 9 0 R 10 0 R 12 0 R 13 0 R 14 0 R 17 0 R] >>".into(), + "<< /Fields [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 14 0 R 17 0 R] /CO [7 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 15 0 R >> >> >>" + .into(), + tx("a", 700, "/V (1)"), + tx("b", 660, ""), + tx("total", 620, r#"/AA << /C << /S /JavaScript /JS (AFSimple_Calculate\("SUM", new Array\("a", "b"\)\);) >> >>"#), + tx("dup", 580, ""), + tx("dup", 540, ""), + "<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /V /Off /AS /Off /Rect [300 700 315 715] /P 3 0 R >>".into(), + "<< /FT /Btn /T (size) /Ff 49152 /Kids [12 0 R 13 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /Parent 11 0 R /AS /Off /Rect [300 650 315 665] /P 3 0 R /AP << /N << /S 16 0 R /Off 16 0 R >> >> >>" + .into(), + "<< /Type /Annot /Subtype /Widget /Parent 11 0 R /AS /Off /Rect [330 650 345 665] /P 3 0 R /AP << /N << /L 16 0 R /Off 16 0 R >> >> >>" + .into(), + "<< /Type /Annot /Subtype /Widget /FT /Ch /T (country) /Ff 131072 /Opt [[(ca) (Canada)] [(fr) (France)]] /Rect [300 600 450 620] /P 3 0 R >>" + .into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + "<< /Length 0 >>\nstream\n\nendstream".into(), + tx("locked", 500, "/Ff 1 /V (kept)"), + ]; + document(&objs) +} + +/// What a filled form shows: every field as read, and each widget's drawn appearance. +fn filled(doc: &Document) -> (Vec, Vec) { + let all = fields(doc); + let looks = all + .iter() + .flat_map(|f| &f.widgets) + .map(|w| { + let wd = doc.get(w.obj).as_dict().cloned().unwrap_or_default(); + let n = wd.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().and_then(|a| a.get(b"N").cloned())); + match n.map(|n| doc.resolve(&n)) { + Some(n) => match &*n { + Object::Stream(s) => String::from_utf8_lossy(&s.decoded().unwrap_or_default()).into_owned(), + other => format!("{other:?}").chars().filter(|c| !c.is_ascii_digit()).collect(), + }, + None => String::new(), + } + }) + .collect(); + (all, looks) +} + +/// Filling values in one batch leaves the form as filling them one by one does: the values, +/// the twin of a shared name, the calculated total and every widget's appearance; a name given +/// twice ends with its last value, and is checked against the value it took earlier in the batch; +/// and each value fails as it would on its own. +#[test] +fn set_values_fills_as_set_value_does_one_by_one() { + let values: Vec<(String, FieldValue)> = vec![ + ("a".into(), FieldValue::Text("2".into())), + ("b".into(), FieldValue::Text("3".into())), + ("dup".into(), FieldValue::Text("shared".into())), + ("agree".into(), FieldValue::Check(true)), + ("size".into(), FieldValue::Radio(Some("L".into()))), + ("country".into(), FieldValue::Choice(vec!["fr".into()])), + ("nowhere".into(), FieldValue::Text("x".into())), + ("locked".into(), FieldValue::Text("changed".into())), + ("size".into(), FieldValue::Radio(Some("XL".into()))), + ("dup".into(), FieldValue::Text("again".into())), + ("a".into(), FieldValue::Text("10".into())), + // Refused only because the batch already selected L: the group keeps one selected. + ("size".into(), FieldValue::Radio(None)), + ]; + let mut one_by_one = batch_form(); + let expected: Vec> = values.iter().map(|(n, v)| set_value(&mut one_by_one, n, v)).collect(); + let mut batch = batch_form(); + let results = set_values(&mut batch, &values).expect("recalculates"); + assert_eq!(results, expected); + assert_eq!( + results.iter().filter(|r| r.is_err()).count(), + 4, + "the unknown name, the read-only field, the unknown option and clearing the group fail: {results:?}" + ); + let (all, looks) = filled(&batch); + assert_eq!((all.clone(), looks), filled(&one_by_one)); + assert_eq!(field(&all, "total").value, ["13"], "recalculated once, after the last value"); + assert!(all.iter().filter(|f| f.name == "dup").all(|f| f.value == ["again"]), "both dup fields take the last value"); + // And as saved. + assert_eq!(filled(&reopen(&batch)), filled(&reopen(&one_by_one))); +} + +/// A form without fields refuses each value, and an empty batch changes nothing. +#[test] +fn set_values_without_a_form_or_values() { + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + ]); + let r = set_values(&mut doc, &[("a".into(), FieldValue::Text("1".into()))]).unwrap(); + assert_eq!(r, vec![Err(FormError::NoForm)]); + let mut form = batch_form(); + assert_eq!(set_values(&mut form, &[]).unwrap(), Vec::new()); + assert!(!form.is_modified()); + // Nothing filled, nothing recalculated: the total stays as the file has it. + let r = set_values(&mut form, &[("nowhere".into(), FieldValue::Text("1".into()))]).unwrap(); + assert_eq!(r, vec![Err(FormError::NoSuchField("nowhere".into()))]); + assert!(field(&fields(&form), "total").value.is_empty() && !form.is_modified()); +} + +/// Filling many fields reads the form a fixed number of times, not once or twice per field +/// (which took minutes to open a form with thousands of fields whose XFA data was filled in +/// elsewhere). +#[test] +fn set_values_reads_the_form_once_not_per_value() { + let n: u32 = 60; + let mut 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()]; + let kids: Vec = (0..n).map(|i| format!("{} 0 R", i + 5)).collect(); + objs.push(format!("<< /Type /Page /Parent 2 0 R /Annots [{}] >>", kids.join(" "))); + objs.push(format!("<< /Fields [{}] /DA (/Helv 10 Tf 0 g) >>", kids.join(" "))); + for i in 0..n { + objs.push(format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (f{i}) /Rect [50 {y} 250 {top}] /P 3 0 R >>", y = 10 * i, top = 10 * i + 10)); + } + let mut doc = document(&objs); + let values: Vec<(String, FieldValue)> = (0..n).map(|i| (format!("f{i}"), FieldValue::Text(format!("v{i}")))).collect(); + let before = form_reads(); + let results = set_values(&mut doc, &values).unwrap(); + let reads = form_reads() - before; + assert!(results.iter().all(Result::is_ok)); + // One reading to fill, one to recalculate. + assert!(reads <= 2, "{reads} readings of the form for {n} values"); + assert!(fields(&doc).iter().all(|f| f.value == [format!("v{}", &f.name[1..])])); + // A name filled again is read again, once: f0 and f1 again cost one more reading. + let again: Vec<(String, FieldValue)> = ["f0", "f1", "f0", "f1"].iter().map(|n| (n.to_string(), FieldValue::Text("w".into()))).collect(); + let before = form_reads(); + assert!(set_values(&mut doc, &again).unwrap().iter().all(Result::is_ok)); + let reads = form_reads() - before; + assert_eq!(reads, 3, "one to fill, one when f0 comes again, one to recalculate"); +} diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs index 7b0d0e88a7..39ecdbde5a 100644 --- a/crates/js/src/formcalc.rs +++ b/crates/js/src/formcalc.rs @@ -2115,7 +2115,8 @@ impl Interp<'_> { let a = self.arg_num(args, 0)?; let places = if args.len() > 1 { self.arg_num(args, 1)?.clamp(0.0, 12.0) as i32 } else { 0 }; let k = 10f64.powi(places); - Val::Num((a * k).round() / k) + let scaled = a * k; + if scaled.is_finite() { Val::Num(scaled.round() / k) } else { Val::Num(a) } } "count" => { // Non-null values and objects: an empty field doesn't count, a subform does. @@ -2278,8 +2279,11 @@ impl Interp<'_> { let mut i = 0; while i < b.len() { if b[i] == b'%' - && let Some(h) = - b.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok()) + && let Some(h) = b + .get(i + 1..i + 3) + .filter(|h| h.iter().all(u8::is_ascii_hexdigit)) + .and_then(|h| std::str::from_utf8(h).ok()) + .and_then(|h| u8::from_str_radix(h, 16).ok()) { out.push(h); i += 3; diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 3d3c4ab524..9e96dcd2e3 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -1009,9 +1009,17 @@ mod formcalc { assert_eq!(calc(r#"Format("9.99", 2.344)"#), "2.34"); } + #[test] + fn round_keeps_huge_values_finite() { + assert_eq!(calc("Round(1e300, 12) == 1e300"), "1"); + assert_eq!(calc("Round(1.2345, 2)"), "1.23"); + } + #[test] fn built_in_functions_cover_the_common_set() { assert_eq!(calc("Abs(-2.5)"), "2.5"); + assert_eq!(calc(r#"Decode("a%2Fb", "url")"#), "a/b"); + assert_eq!(calc(r#"Decode("a%+fb", "url")"#), "a%+fb"); assert_eq!(calc("Ceil(2.1)"), "3"); assert_eq!(calc("Floor(-2.1)"), "-3"); assert_eq!(calc("Mod(7, 3)"), "1"); diff --git a/crates/js/src/xfa_session.rs b/crates/js/src/xfa_session.rs index 91447454dd..28ce53994d 100644 --- a/crates/js/src/xfa_session.rs +++ b/crates/js/src/xfa_session.rs @@ -18,6 +18,18 @@ use crate::xfa::{XfaDoc, XfaEvent, XfaNode, XfaOutcome}; #[cfg(test)] pub(crate) static START_DELAY_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +std::thread_local! { + static ENGINES_STARTED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// How many script engine threads the sessions made on this thread have started, for +/// diagnostics and tests: an event's scripts share one, so opening a form with hundreds of +/// calculations starts one. Per thread, so a caller counting its own events is not disturbed +/// by forms on other threads. Always 0 on wasm, where the scripts run on the caller's thread. +pub fn engines_started() -> u64 { + ENGINES_STARTED.with(std::cell::Cell::get) +} + /// What one script run needs. #[derive(Clone, Debug)] pub struct Script { @@ -58,7 +70,7 @@ fn run_script(s: Script, doc: &XfaDoc, started: &dyn Fn()) -> XfaOutcome { #[cfg(not(target_arch = "wasm32"))] mod native { use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel}; - use std::time::Instant; + use std::time::{Duration, Instant}; use super::{Script, run_script}; use crate::xfa::{ENGINE_START_LIMIT, XfaDoc, XfaOutcome}; @@ -83,11 +95,20 @@ mod native { worker: Option, /// A script ran past its limit: its thread is left to it and nothing more runs. dead: Option, + /// How long the engine has to be ready for a script ([`ENGINE_START_LIMIT`] by default). + start_limit: Duration, } impl XfaSession { pub fn new(doc: XfaDoc) -> XfaSession { - XfaSession { doc, worker: None, dead: None } + XfaSession { doc, worker: None, dead: None, start_limit: ENGINE_START_LIMIT } + } + + /// Multiply the time the engine has to be ready for a script by `factor` (a script's + /// own time is [`Script::timeout`]). + pub fn with_time_scale(mut self, factor: u32) -> XfaSession { + self.start_limit = ENGINE_START_LIMIT.saturating_mul(factor); + self } /// Why nothing more runs, once a script was abandoned. @@ -115,11 +136,12 @@ mod native { } }); spawned.map_err(|e| format!("the script engine could not start: {e}"))?; - match from.recv_timeout(ENGINE_START_LIMIT) { + super::ENGINES_STARTED.with(|n| n.set(n.get().saturating_add(1))); + match from.recv_timeout(self.start_limit) { Ok(Reply::Ready) => {} Ok(_) => return Err("the script engine answered out of turn".into()), Err(RecvTimeoutError::Timeout) => { - return Err(format!("the script engine took longer than {:.0} s to start", ENGINE_START_LIMIT.as_secs_f64())); + return Err(format!("the script engine took longer than {:.0} s to start", self.start_limit.as_secs_f64())); } Err(RecvTimeoutError::Disconnected) => return Err("the script engine stopped with an internal error".into()), } @@ -129,8 +151,9 @@ mod native { } /// Run one script, waiting at most its `timeout` from when the engine is ready for it - /// (the engine itself gets [`ENGINE_START_LIMIT`] to be ready); past that it is - /// abandoned on its thread and the session is dead. + /// (the engine itself gets [`ENGINE_START_LIMIT`] to be ready, unless + /// [`XfaSession::with_time_scale`] changed it); past that it is abandoned on its thread + /// and the session is dead. pub fn run(&mut self, script: Script) -> XfaOutcome { let abandoned = |why: String| XfaOutcome { error: Some(why), abandoned: true, ..Default::default() }; let failed = |why: String| XfaOutcome { error: Some(why), ..Default::default() }; @@ -138,6 +161,7 @@ mod native { return abandoned(why.clone()); } let timeout = script.timeout; + let start_limit = self.start_limit; let worker = match self.worker() { Ok(w) => w, Err(why) => return failed(why), @@ -148,7 +172,7 @@ mod native { return failed("the script engine stopped with an internal error".into()); } // The engine's start, then the script's own time. - let (mut running, mut limit) = (false, ENGINE_START_LIMIT); + let (mut running, mut limit) = (false, start_limit); let mut deadline = Instant::now().checked_add(limit); loop { let left = deadline.map_or(limit, |d| d.saturating_duration_since(Instant::now())); @@ -167,7 +191,7 @@ mod native { let why = if running { format!("the script ran longer than {:.1} s and was abandoned", timeout.as_secs_f64()) } else { - format!("the script engine took longer than {:.0} s to start", ENGINE_START_LIMIT.as_secs_f64()) + format!("the script engine took longer than {:.0} s to start", start_limit.as_secs_f64()) }; self.dead = Some(why.clone()); // Dropping the sender ends the thread's loop once the script is done. @@ -199,6 +223,11 @@ mod native { XfaSession { doc } } + /// No engine to start on wasm: nothing to scale. + pub fn with_time_scale(self, _factor: u32) -> XfaSession { + self + } + pub fn dead(&self) -> Option<&str> { None } diff --git a/crates/organize/src/outline.rs b/crates/organize/src/outline.rs index cc1bbb0884..2bb4122d2f 100644 --- a/crates/organize/src/outline.rs +++ b/crates/organize/src/outline.rs @@ -265,6 +265,15 @@ fn explicit_dest(doc: &Document, targets: &mut Targets, dest: &Object, depth: u8 } } +/// The explicit destination array `dest` stands for: itself, a dictionary whose `/D` is one, or a +/// named destination (a name or string, ISO 32000-2 §12.3.2.4) looked up in the `/Names /Dests` +/// tree, then the catalog `/Dests`. `None` when a name names nothing. +pub(crate) fn resolve_dest(doc: &Document, dest: &Object) -> Option> { + // Names only: the page map is not needed to look one up. + let mut targets = Targets { pages: HashMap::new(), names: None }; + explicit_dest(doc, &mut targets, dest, 0) +} + /// The page index an explicit destination array names. fn page_of_explicit(targets: &Targets, dest: &[Object]) -> Option { match dest.first()? { diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 1ba8861cbe..1fd60d744a 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -2460,10 +2460,89 @@ fn an_unchanged_opening_destination_keeps_its_position_and_name() { assert_eq!(crate::initial_view(&doc), crate::InitialView { hide_toolbar: true, ..v.clone() }); // A changed magnification writes a destination of its own. let mut doc = doc; - crate::set_initial_view(&mut doc, &crate::InitialView { magnification: Magnification::FitPage, ..v }).unwrap(); + crate::set_initial_view(&mut doc, &crate::InitialView { magnification: Magnification::FitWidth, ..v }).unwrap(); let catalog = doc.get(root).as_dict().cloned().unwrap(); assert_ne!(catalog.get(b"OpenAction"), Some(&action)); - assert_eq!(crate::initial_view(&doc).magnification, Magnification::FitPage); + assert_eq!(crate::initial_view(&doc).magnification, Magnification::FitWidth); + } +} + +/// A document whose `/OpenAction` is `action`, with the named destination `there` (page 3 at +/// 200%) in the catalog `/Dests` and `here` (page 1, fit) in a `/Names /Dests` tree. +fn doc_opening_at(action: Object) -> Document { + let page1 = Object::Ref(pdfcraft_cos::ObjRef::new(4, 0)); + let page3 = Object::Ref(pdfcraft_cos::ObjRef::new(6, 0)); + let mut dests = pdfcraft_cos::Dict::new(); + dests.set(b"there".to_vec(), Object::Array(vec![page3, Object::name("XYZ"), Object::Null, Object::Null, Object::Int(2)])); + let mut tree = pdfcraft_cos::Dict::new(); + tree.set(b"Names".to_vec(), Object::Array(vec![Object::String(PdfString::text("here")), Object::Array(vec![page1, Object::name("Fit")])])); + let mut names = pdfcraft_cos::Dict::new(); + names.set(b"Dests".to_vec(), Object::Dict(tree)); + let mut doc = open(fixture()); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| { + c.set(b"OpenAction".to_vec(), action); + c.set(b"Dests".to_vec(), Object::Dict(dests)); + c.set(b"Names".to_vec(), Object::Dict(names)); + }) + .unwrap(); + doc +} + +fn open_action(doc: &Document) -> Option { + doc.get(doc.root().unwrap()).as_dict().and_then(|c| c.get(b"OpenAction").cloned()) +} + +/// ISO 32000-2 §12.3.2.4: a named destination stands for the explicit one it names. Choosing +/// page 1 in Document Properties on a document that opens at a named destination elsewhere +/// writes a destination to page 1; it used to read the name as page 1 and change nothing. +#[test] +fn choosing_page_one_replaces_a_named_opening_destination() { + use crate::view::Magnification; + let page1 = Object::Ref(pdfcraft_cos::ObjRef::new(4, 0)); + let mut goto = pdfcraft_cos::Dict::new(); + goto.set(b"S".to_vec(), Object::name("GoTo")); + goto.set(b"D".to_vec(), Object::name("there")); + for action in [Object::Dict(goto), Object::name("there"), Object::String(PdfString::text("there"))] { + let mut doc = doc_opening_at(action.clone()); + // The dialog shows where the document really opens. + let v = crate::initial_view(&doc); + assert_eq!((v.page, v.magnification), (2, Magnification::Percent(200.0)), "{action:?}"); + // Page 1 at the same magnification: an explicit destination to page 1. + crate::set_initial_view(&mut doc, &crate::InitialView { page: 0, ..v.clone() }).unwrap(); + let written = Object::Array(vec![page1.clone(), Object::name("XYZ"), Object::Null, Object::Null, Object::Real(2.0)]); + assert_eq!(open_action(&doc), Some(written), "{action:?}"); + let reopened = save_and_reopen(&doc); + assert_eq!(crate::initial_view(&reopened), crate::InitialView { page: 0, ..v.clone() }, "{action:?}"); + // Page 1 at the default magnification: no opening action, so the first page opens. + let mut doc = doc_opening_at(action.clone()); + crate::set_initial_view(&mut doc, &crate::InitialView { page: 0, magnification: Magnification::Default, ..v }).unwrap(); + assert_eq!(open_action(&doc), None, "{action:?}"); + assert_eq!(crate::initial_view(&save_and_reopen(&doc)).page, 0, "{action:?}"); + } +} + +/// A named opening destination that already goes to the chosen page and magnification is kept, +/// and so is one that names nothing (it reads as page 1) until another page is chosen. +#[test] +fn a_named_opening_destination_is_kept_while_it_still_holds() { + use crate::view::Magnification; + let page2 = Object::Ref(pdfcraft_cos::ObjRef::new(5, 0)); + for (action, magnification) in + [(Object::String(PdfString::text("here")), Magnification::FitPage), (Object::name("nowhere"), Magnification::Default)] + { + let mut doc = doc_opening_at(action.clone()); + let v = crate::initial_view(&doc); + assert_eq!((v.page, v.magnification), (0, magnification), "{action:?}"); + crate::set_initial_view(&mut doc, &crate::InitialView { hide_toolbar: true, ..v.clone() }).unwrap(); + assert_eq!(open_action(&doc), Some(action.clone()), "{action:?}"); + crate::set_initial_view(&mut doc, &crate::InitialView { page: 1, ..v.clone() }).unwrap(); + let kind = if magnification == Magnification::FitPage { "Fit" } else { "XYZ" }; + assert_eq!( + open_action(&doc).as_ref().and_then(Object::as_array).map(|a| (a.first().cloned(), a.get(1).cloned())), + Some((Some(page2.clone()), Some(Object::name(kind)))), + "{action:?}" + ); } } diff --git a/crates/organize/src/view.rs b/crates/organize/src/view.rs index b51df1aa55..899293b980 100644 --- a/crates/organize/src/view.rs +++ b/crates/organize/src/view.rs @@ -118,10 +118,12 @@ pub fn initial_view(doc: &Document) -> InitialView { }; } } - // The opening destination: an explicit array, or a GoTo action's. + // The opening destination: an explicit array, a GoTo action's `/D`, or a named destination (a + // name or string, §12.3.2.4) as the explicit one it names, so that the page and magnification + // read here are where the document opens. A name that names nothing reads as page 1. let dest = c.get(b"OpenAction").map(|o| doc.resolve(o)).and_then(|o| match &*o { - Object::Array(a) => Some(a.clone()), - Object::Dict(d) if d.name(b"S") == Some(b"GoTo") => d.get(b"D").map(|x| doc.resolve(x)).and_then(|x| x.as_array().cloned()), + Object::Array(_) | Object::Name(_) | Object::String(_) => crate::outline::resolve_dest(doc, &o), + Object::Dict(d) if d.name(b"S") == Some(b"GoTo") => d.get(b"D").and_then(|x| crate::outline::resolve_dest(doc, x)), _ => None, }); if let Some(d) = dest { @@ -195,9 +197,12 @@ pub fn set_initial_view(doc: &mut Document, v: &InitialView) -> Result<(), Organ } else { vp.remove(b"Direction"); } + // A destination, explicit or named (§12.3.2.4), or a GoTo action to one. let open_action_is_dest = match c.get(b"OpenAction").map(|o| doc.resolve(o)) { None => true, - Some(o) => matches!(&*o, Object::Array(_)) || o.as_dict().is_some_and(|d| d.name(b"S") == Some(b"GoTo")), + Some(o) => { + matches!(&*o, Object::Array(_) | Object::Name(_) | Object::String(_)) || o.as_dict().is_some_and(|d| d.name(b"S") == Some(b"GoTo")) + } }; let dest = match v.magnification { Magnification::Default if v.page == 0 => None, diff --git a/crates/platform/README.md b/crates/platform/README.md index ea7af7a079..fea358e508 100644 --- a/crates/platform/README.md +++ b/crates/platform/README.md @@ -8,4 +8,4 @@ Synthetic tests pin both name forms, the 128-byte cut, the attempt limit and ref 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. +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, the Windows hidden and system attributes (the staging file is created with them, so a hidden file is never published visible and a plain one never hidden, #4593) 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 c0e8f990bc..092aa65239 100644 --- a/crates/platform/src/staging.rs +++ b/crates/platform/src/staging.rs @@ -34,7 +34,8 @@ pub fn create_staging( /// [`create_staging`], giving the new file the mode and group of the file `keep` describes on /// Unix. It is created readable and writable by its owner only and then given them, 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). The owner is the saver's. +/// handle opened then would keep reading what is written later). The owner is the saver's. On +/// Windows it is created with that file's [`KEPT_ATTRIBUTES`]. fn create_staging_as( dir: &Path, name: &str, @@ -63,8 +64,15 @@ fn create_staging_as( use std::os::unix::fs::OpenOptionsExt as _; options.mode(0o600); } + // Given as the file is created, so a hidden target's staging file is never visible. + #[cfg(windows)] + if let Some(keep) = keep { + use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _}; + options.attributes(keep.file_attributes() & KEPT_ATTRIBUTES); + } match options.open(&tmp) { Ok(file) => { + #[cfg(unix)] 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. @@ -78,9 +86,18 @@ fn create_staging_as( Err(e) => return Err(e), } } + #[cfg(not(any(unix, windows)))] + let _ = keep; Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "every temporary file name tried is taken")) } +/// The Windows attributes a replaced file passes on to its staging file (#4593): +/// `FILE_ATTRIBUTE_HIDDEN` and `FILE_ATTRIBUTE_SYSTEM`, the two that say who should see the +/// file. A read-only file is refused before anything is staged, and the archive bit is the +/// system's to set on a changed file; the other attributes describe how the volume stores it. +#[cfg(windows)] +const KEPT_ATTRIBUTES: u32 = 0x2 | 0x4; + /// The permissions a staging file gets to replace the file `keep` describes. The mode's group bits /// are meant for that file's group, so the staging file joins it first. Where it can't (the saver /// isn't a member), group and others get nothing: someone the group bits shut out would @@ -96,11 +113,6 @@ fn kept_permissions(file: &std::fs::File, keep: &std::fs::Metadata) -> std::fs:: mode } -#[cfg(not(unix))] -fn kept_permissions(_: &std::fs::File, keep: &std::fs::Metadata) -> std::fs::Permissions { - keep.permissions() -} - /// Unpredictable staging-name suffixes, so a name can't be planted in advance. Each is the /// standard library's keyed hash of a counter (`RandomState::hash_one`, currently SipHash-1-3), /// keyed from the operating system's random source. That is all a staging name needs, but it is @@ -119,7 +131,8 @@ pub fn staging_suffixes() -> impl Iterator { /// 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. On Unix a file /// that is replaced keeps its permissions and group, 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 +/// usual mode for a new file. On Windows a replaced file stays hidden or system if it was, and +/// plain if it was plain. 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). @@ -146,10 +159,14 @@ pub fn write_atomically_with( } let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new(".")); let name = path.file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("save"); - // A file that is replaced keeps its permissions (#1790) and group. Unix only: Windows is as before. + // A file that is replaced keeps its permissions (#1790) and group on Unix, and its hidden and + // system attributes on Windows (#4593). A save replaces a symbolic link, not the file it + // points to, so on Windows the attributes are the link's own. #[cfg(unix)] let keep = std::fs::metadata(path).ok(); - #[cfg(not(unix))] + #[cfg(windows)] + let keep = std::fs::symlink_metadata(path).ok(); + #[cfg(not(any(unix, windows)))] let keep = None; // A saved file is the same document: it keeps its creation date (#2658). #[cfg(any(windows, target_os = "macos"))] @@ -661,4 +678,69 @@ mod tests { assert!(std::fs::metadata(&fresh).unwrap().created().unwrap() > then); let _ = std::fs::remove_dir_all(&dir); } + + /// #4593: a save replaces the file, so a hidden or system file stays hidden or system, and + /// its staging file already is while it is written; a plain file stays plain, though the + /// staging name starts with a dot; a new file gets no attribute from anywhere. + #[cfg(windows)] + #[test] + fn a_replaced_file_keeps_its_hidden_and_system_attributes() { + use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _}; + const HIDDEN: u32 = 0x2; + const SYSTEM: u32 = 0x4; + let dir = test_dir("attributes"); + let kept = |p: &Path| std::fs::metadata(p).unwrap().file_attributes() & (HIDDEN | SYSTEM); + for (i, keep) in [0, HIDDEN, SYSTEM, HIDDEN | SYSTEM].into_iter().enumerate() { + let target = dir.join(format!("doc{i}.pdf")); + let mut fixture = std::fs::OpenOptions::new().write(true).create_new(true).attributes(keep).open(&target).unwrap(); + std::io::Write::write_all(&mut fixture, b"OLD").unwrap(); + drop(fixture); + assert_eq!(kept(&target), keep, "the fixture has attributes {keep:#x}"); + for form in [StagingName::TagThenSuffix, StagingName::SuffixThenTag] { + let mut staged = u32::MAX; + write_atomically_with(&target, b"NEW", form, [i as u64], |f| { + staged = f.metadata()?.file_attributes() & (HIDDEN | SYSTEM); + Ok(()) + }) + .unwrap(); + assert_eq!(staged, keep, "the staging file has the target's attributes before it is flushed"); + assert_eq!(kept(&target), keep, "the saved file keeps attributes {keep:#x}"); + assert_eq!(std::fs::read(&target).unwrap(), b"NEW"); + } + } + let fresh = dir.join("new.pdf"); + write_atomically(&fresh, b"NEW", StagingName::TagThenSuffix).unwrap(); + assert_eq!(kept(&fresh), 0, "a new file is neither hidden nor system"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A save replaces a symbolic link, not the file it points to, so the saved file takes the + /// link's own attributes: a plain link to a hidden file saves as a plain file, and the file + /// it pointed to is left as it was. + #[cfg(windows)] + #[test] + fn a_replaced_link_keeps_its_own_attributes_not_its_targets() { + use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _}; + const HIDDEN: u32 = 0x2; + let dir = test_dir("link-attributes"); + let referent = dir.join("referent.pdf"); + let mut fixture = std::fs::OpenOptions::new().write(true).create_new(true).attributes(HIDDEN).open(&referent).unwrap(); + std::io::Write::write_all(&mut fixture, b"OLD").unwrap(); + drop(fixture); + let link = dir.join("link.pdf"); + if let Err(e) = std::os::windows::fs::symlink_file(&referent, &link) { + eprintln!("skipped: creating a symbolic link needs Developer Mode or administrator rights ({e})"); + let _ = std::fs::remove_dir_all(&dir); + return; + } + assert_eq!(std::fs::symlink_metadata(&link).unwrap().file_attributes() & HIDDEN, 0, "the link itself is visible"); + write_atomically(&link, b"NEW", StagingName::TagThenSuffix).unwrap(); + let saved = std::fs::symlink_metadata(&link).unwrap(); + assert!(!saved.file_type().is_symlink(), "the save replaced the link"); + assert_eq!(saved.file_attributes() & HIDDEN, 0, "the link was visible, so the saved file is"); + assert_eq!(std::fs::read(&link).unwrap(), b"NEW"); + assert_eq!(std::fs::read(&referent).unwrap(), b"OLD", "the file the link pointed to is untouched"); + assert_ne!(std::fs::metadata(&referent).unwrap().file_attributes() & HIDDEN, 0, "and still hidden"); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index bc0454c999..e6432d8aec 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -283,8 +283,8 @@ fn spooler_arguments_and_printer_list() { assert_eq!( parse_lpstat(out), [ - spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None }, - spool::Printer { name: "Label_Writer".into(), default: false, two_sided: None } + spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None, description: String::new() }, + spool::Printer { name: "Label_Writer".into(), default: false, two_sided: None, description: String::new() } ] ); assert!(parse_lpstat("lpstat: No destinations added.\nno system default destination\n").is_empty()); @@ -327,18 +327,21 @@ fn driverless_printers_join_the_list_without_a_queue() { assert_eq!( spool::printers_parsed(queues, Some("Basement_Color\nOffice_Laser\n")), [ - spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None }, - spool::Printer { name: "Basement_Color".into(), default: false, two_sided: None }, + spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None, description: String::new() }, + spool::Printer { name: "Basement_Color".into(), default: false, two_sided: None, description: String::new() }, ], "queues first, then the driverless destination; the queue listed twice is not doubled" ); // An old spooler without `-e` (CUPS < 1.7) keeps its queues; only the driverless names are lost. - assert_eq!(spool::printers_parsed(queues, None), [spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None }]); + assert_eq!( + spool::printers_parsed(queues, None), + [spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None, description: String::new() }] + ); // The default can itself be driverless (`lpoptions -d` names a destination no queue exists // for): it keeps its marker, so the dialog preselects the user's default, not the first queue. assert_eq!( spool::printers_parsed("system default destination: Basement_Color\n", Some("Basement_Color\n")), - [spool::Printer { name: "Basement_Color".into(), default: true, two_sided: None }], + [spool::Printer { name: "Basement_Color".into(), default: true, two_sided: None, description: String::new() }], "a default destination without a queue is still the default" ); assert!(spool::parse_lpstat_e("\n \n").is_empty(), "no destinations is empty, not [\"\"]"); @@ -414,17 +417,17 @@ fn windows_printers_carry_one_default_and_keep_their_names() { // Windows marks the system default with a `*` no queue name may contain; names hold spaces, // parentheses and non-ASCII letters, and the lines end with CRLF. let printers = parse_windows_printers("*Microsoft Print to PDF\r\nHP LaserJet 400 (Copy 2)\r\nКопир Canon iR\r\n\r\n*XPS via USB\r\n"); - assert_eq!(printers[0], Printer { name: "Microsoft Print to PDF".into(), default: true, two_sided: None }); - assert_eq!(printers[1], Printer { name: "HP LaserJet 400 (Copy 2)".into(), default: false, two_sided: None }); - assert_eq!(printers[2], Printer { name: "Копир Canon iR".into(), default: false, two_sided: None }); + assert_eq!(printers[0], Printer { name: "Microsoft Print to PDF".into(), default: true, two_sided: None, description: String::new() }); + assert_eq!(printers[1], Printer { name: "HP LaserJet 400 (Copy 2)".into(), default: false, two_sided: None, description: String::new() }); + assert_eq!(printers[2], Printer { name: "Копир Canon iR".into(), default: false, two_sided: None, description: String::new() }); // A spooler marking two defaults: the first still wins, so the dialog has one to preselect. - assert_eq!(printers[3], Printer { name: "XPS via USB".into(), default: false, two_sided: None }); + assert_eq!(printers[3], Printer { name: "XPS via USB".into(), default: false, two_sided: None, description: String::new() }); assert_eq!(parse_windows_printers("\r\n"), Vec::new()); // After a tab, whether the driver reports two-sided printing; a tab inside a name stays. let sided = parse_windows_printers("*Office PCL\t2\r\nOffice PS\t1\r\nTab\tName\t2\r\n"); - assert_eq!(sided[0], Printer { name: "Office PCL".into(), default: true, two_sided: Some(true) }); - assert_eq!(sided[1], Printer { name: "Office PS".into(), default: false, two_sided: Some(false) }); - assert_eq!(sided[2], Printer { name: "Tab\tName".into(), default: false, two_sided: Some(true) }); + assert_eq!(sided[0], Printer { name: "Office PCL".into(), default: true, two_sided: Some(true), description: String::new() }); + assert_eq!(sided[1], Printer { name: "Office PS".into(), default: false, two_sided: Some(false), description: String::new() }); + assert_eq!(sided[2], Printer { name: "Tab\tName".into(), default: false, two_sided: Some(true), description: String::new() }); } /// A PPD shaped like a Fiery's: installable options, multi-line PostScript in the choices, Latin-1 diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index ffd59a26f6..bd9da5215f 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -884,37 +884,14 @@ fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], } let b = bbox.unwrap_or_default(); changed = true; - if scope.covered(b) { - scope.report.paths_removed += 1; - if clips { - // Keep the clipping, drop the painting. - ops.extend(body); - ops.push(Op::new("n", vec![])); - } - } else if clips { - // A clip-and-paint path can't be wrapped in q/Q without losing its clip: - // paint it clipped, then set its clip with a no-op path. - scope.report.paths_clipped += 1; - if let Some(c) = clip_out(scope.rects, &gs.ctm, b) { - let plain: Vec = body.iter().filter(|p| !p.is("W") && !p.is("W*")).cloned().collect(); - ops.push(Op::new("q", vec![])); - ops.extend(c); - ops.extend(plain); - ops.push(op); - ops.push(Op::new("Q", vec![])); - } + scope.report.paths_removed += 1; + if scope.covered(b) && clips { + // Keep the clipping, drop the painting. ops.extend(body); ops.push(Op::new("n", vec![])); - } else { - scope.report.paths_clipped += 1; - if let Some(c) = clip_out(scope.rects, &gs.ctm, b) { - ops.push(Op::new("q", vec![])); - ops.extend(c); - ops.extend(body); - ops.push(op); - ops.push(Op::new("Q", vec![])); - } } + // Partial vector redaction must not retain underlying drawing commands + // behind clipping (F-008). Refuse partial clip retention and remove the path. continue; } if !path.is_empty() { diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 29f23d3c96..bdf1db873e 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -699,27 +699,24 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result) -> Result { - let all = marks(doc); - let doomed: Vec = all.iter().filter(|m| pages.is_none_or(|p| p.contains(&m.page))).map(|m| m.obj).collect(); - if doomed.is_empty() { + let targets: HashSet = marks(doc).iter().filter(|m| pages.is_none_or(|p| p.contains(&m.page))).map(|m| m.obj).collect(); + if targets.is_empty() { return Err(RedactError::NothingToApply); } + let listed: Vec = pdfcraft_model::pages(doc).iter().flat_map(|p| annots_of(doc, &p.dict)).collect(); + let doomed = pdfcraft_annot::with_replies_and_popups(doc, &listed, targets.iter().copied()); for p in pdfcraft_model::pages(doc) { let annots = annots_of(doc, &p.dict); - let kept: Vec = annots - .iter() - .filter(|a| { - let Some(r) = a.as_ref() else { return true }; - let parent = doc.get(r).as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref); - !doomed.contains(&r) && !parent.is_some_and(|p| doomed.contains(&p)) - }) - .cloned() - .collect(); + let kept: Vec = annots.iter().filter(|a| !pdfcraft_annot::goes_with(a, &doomed)).cloned().collect(); + // As deleting a comment does: a page whose marks were all it had loses `/Annots` again, + // and an indirect array stays the one object the page points to. if kept.len() != annots.len() { - doc.update_dict(p.obj, |d| d.set(b"Annots".to_vec(), Object::Array(kept)))?; + pdfcraft_annot::set_annots(doc, p.obj, kept)?; } } - Ok(doomed.len()) + Ok(targets.len()) } diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 8625f99c25..202ece31cb 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -474,7 +474,16 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { + owned(Hidden::Attachments) } Hidden::Comments => annots.iter().filter(|a| is_comment(a.name(b"Subtype").unwrap_or(b""))).count(), - Hidden::FormFields => pdfcraft_forms::fields(doc).len(), + Hidden::FormFields => { + let n = pdfcraft_forms::fields(doc).len(); + if n > 0 { + n + } else if cat.contains(b"AcroForm") { + 1 + } else { + 0 + } + } Hidden::HiddenText => content_pass(&mut doc2, true, false, false).map(|(n, _)| n).unwrap_or(0), Hidden::HiddenLayers => { let off = hidden_layers(doc).len(); @@ -510,7 +519,8 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result 0; + let has_acroform = doc.get(root).as_dict().is_some_and(|d| d.contains(b"AcroForm")); + let fields = which.contains(&Hidden::FormFields) && (count(Hidden::FormFields) > 0 || has_acroform); if fields { // Fields without appearances get one first, so their values stay visible. for f in pdfcraft_forms::fields(doc) { @@ -631,9 +641,12 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result { + cat.remove(b"AcroForm"); + } _ => {} } - let n = count(h); + let n = if h == Hidden::FormFields && fields { count(h).max(1) } else { count(h) }; if n > 0 { done.push((h, n)); } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 9ffb07e6f8..c072d19db7 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -281,14 +281,40 @@ fn vectors_are_removed_or_clipped_and_inline_images_go() { let mut doc = one_page(src, "", vec![]); mark(&mut doc, 0, &[[5.0, 5.0, 35.0, 35.0], [45.0, 45.0, 65.0, 65.0]], ""); let r = apply(&mut doc, None).unwrap(); - assert_eq!((r.paths_removed, r.paths_clipped, r.images_removed), (1, 1, 1), "{r:?}"); + assert_eq!((r.paths_removed, r.paths_clipped, r.images_removed), (2, 0, 1), "{r:?}"); let c = content(&doc, 0); assert!(!c.contains("10 10 20 20 re"), "{c}"); - assert!(c.contains("W*\nn\n0 0 300 300 re\nf\nQ"), "the page-size rect is clipped: {c}"); + assert!(!c.contains("300 300 re"), "the intersecting rect is removed: {c}"); assert!(!c.contains("BI"), "{c}"); assert!(c.contains("150 150 m"), "the line elsewhere stays"); } +#[test] +fn partially_covered_vector_paths_do_not_leak_drawing_commands() { + // A vector path with detailed drawing commands (e.g. outlined text or confidential geometry) + // that is only partially covered by a redaction mark must be completely removed, not retained + // behind a clipping path (F-008). + let src = b"0 g 100 100 m 150 150 l 200 100 l h f 10 10 m 20 20 l W b 300 300 m 350 350 l S"; + let mut doc = one_page(src, "", vec![]); + // Redaction mark covers only part of the triangle (100..200, 100..150): + // mark is [120, 120, 160, 160], partially intersecting the triangle. + // Also partially intersect the clip-and-stroke path [10..20, 10..20] with [15, 15, 25, 25]. + mark(&mut doc, 0, &[[120.0, 120.0, 160.0, 160.0], [15.0, 15.0, 25.0, 25.0]], ""); + let r = apply(&mut doc, None).unwrap(); + assert_eq!(r.paths_removed, 2, "both partially intersected paths removed"); + assert_eq!(r.paths_clipped, 0, "no paths retained via clipping"); + + let c = content(&doc, 0); + // Neither the drawing operators nor the coordinates should leak in the content stream. + assert!(!c.contains("100 100 m"), "underlying geometry must not leak: {c}"); + assert!(!c.contains("150 150 l"), "underlying geometry must not leak: {c}"); + assert!(!c.contains("200 100 l"), "underlying geometry must not leak: {c}"); + assert!(!c.contains("10 10 m"), "partial clip geometry must not leak: {c}"); + assert!(!c.contains("20 20 l"), "partial clip geometry must not leak: {c}"); + // Unrelated non-intersecting path remains untouched. + assert!(c.contains("300 300 m\n350 350 l\nS"), "unaffected vector path must be kept: {c}"); +} + /// A chain of nested forms deeper than the pass can walk was removed from the page, taking /// whatever else it drew with it even when nothing in it was marked. The limit now reaches as /// deep as the renderer and Edit text read (50), so anything a reader can see can be walked; @@ -1145,6 +1171,41 @@ fn nothing_to_apply_and_clearing_marks() { assert!(content(&doc, 0).contains("(AB) Tj")); } +/// Clearing marks puts a page's annotation list back as it was: a page that had none has no +/// `/Annots` again, replies to the marks included, and one listed through an indirect array keeps +/// pointing at that array. +#[test] +fn clearing_marks_leaves_the_annotation_list_as_it_was() { + let mut doc = one_page(b"BT /F1 10 Tf 10 100 Td (AB) Tj ET", "", vec![]); + let page = pdfcraft_model::pages(&doc)[0].obj; + let before = doc.get(page); + mark(&mut doc, 0, &[[0.0, 0.0, 50.0, 50.0]], ""); + pdfcraft_annot::add_reply(&mut doc, 0, 0, "a reply", "Tester", &Meta::default()).unwrap(); + // And a reply written inline, as a file can. + let mut inline = Dict::new(); + inline.set(b"Subtype".to_vec(), Object::name("Text")); + inline.set(b"IRT".to_vec(), Object::Ref(marks(&doc)[0].obj)); + let mut listed = annots_of(&doc, doc.get(page).as_dict().unwrap()); + listed.push(Object::Dict(inline)); + doc.update_dict(page, |d| d.set(b"Annots".to_vec(), Object::Array(listed))).unwrap(); + assert_eq!(clear_marks(&mut doc, None), Ok(1), "one mark"); + assert_eq!(doc.get(page), before, "the page has no /Annots again: both replies went with their mark"); + + 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] /Annots 4 0 R >>".to_vec(), + b"[5 0 R]".to_vec(), + b"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] >>".to_vec(), + ]); + let (page, list) = (ObjRef::new(3, 0), ObjRef::new(4, 0)); + let before = (doc.get(page), doc.get(list)); + mark(&mut doc, 0, &[[0.0, 0.0, 50.0, 50.0]], ""); + assert_eq!(doc.get(list).as_array().map(|a| a.len()), Some(2), "the mark is listed in the shared array"); + clear_marks(&mut doc, None).unwrap(); + assert_eq!((doc.get(page), doc.get(list)), before, "the page points at its array, which lists the link alone"); +} + #[test] fn unreadable_content_fails_closed() { let mut doc = one_page(b"", "", vec![]); @@ -2230,6 +2291,74 @@ fn unselected_pages_keep_inherited_forms() { assert_eq!(under(&mut saved, 1, &[[0.0, 190.0, 300.0, 220.0]]), 6); } +#[test] +fn nested_resource_less_forms_keep_live_resources_after_redacting_another_page() { + for own_resources in [false, true] { + for still_drawn in [false, true] { + let original = b"BT /F1 10 Tf 10 200 Td (SECRET) Tj ET"; + let mut extra = vec![ + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", original), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", if still_drawn { b"/Fm Do".as_slice() } else { b"q Q".as_slice() }), + ]; + if own_resources { + extra.push(stream( + "/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> /XObject << /Fm 7 0 R /Relay 8 0 R >> >>", + b"/Relay Do", + )); + } + let resources = + if own_resources { "/XObject << /Fm 7 0 R /Relay 8 0 R /Wrapper 9 0 R >>" } else { "/XObject << /Fm 7 0 R /Relay 8 0 R >>" }; + let mut doc = one_page(b"/Fm Do", resources, extra); + let mut page = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().clone(); + let inherited = page.remove(b"Resources").unwrap(); + doc.set(ObjRef::new(3, 0), Object::Dict(page.clone())); + let second_content = + doc.add(Object::Stream(Stream::from_raw(Dict::new(), if own_resources { b"/Wrapper Do".to_vec() } else { b"/Relay Do".to_vec() }))); + page.set(b"Contents".to_vec(), Object::Ref(second_content)); + let second = doc.add(Object::Dict(page)); + doc.update_dict(ObjRef::new(2, 0), |d| { + d.set(b"Resources".to_vec(), inherited); + 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(); + + let region = [[0.0, 190.0, 300.0, 220.0]]; + let surviving = if still_drawn { 6 } else { 0 }; + assert_eq!(under(&mut doc, 0, ®ion), 6, "the marked occurrence is real text"); + assert_eq!(under(&mut doc, 1, ®ion), surviving, "the relay's original nested content is visible"); + mark(&mut doc, 0, ®ion, ""); + let report = apply(&mut doc, Some(&[0])).unwrap(); + assert_eq!((report.marks, report.glyphs), (1, 6)); + assert_eq!(under(&mut doc, 0, ®ion), 0); + assert_eq!(under(&mut doc, 1, ®ion), surviving, "redacting another page must preserve live nested content"); + + // A full saved document must retain both the reachable resource and its content, + // without relying on an old incremental revision's otherwise-unreachable objects. + let mut saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + assert_eq!(under(&mut saved, 0, ®ion), 0); + assert_eq!(under(&mut saved, 1, ®ion), surviving); + let page = pdfcraft_model::pages(&saved).swap_remove(1); + let res = saved.resolve(page.dict.get(b"Resources").unwrap()); + let xo = saved.resolve(res.as_dict().unwrap().get(b"XObject").unwrap()); + let mut holder = xo.as_dict().unwrap().clone(); + if own_resources { + let wrapper = saved.resolve(holder.get(b"Wrapper").unwrap()); + let Object::Stream(wrapper) = &*wrapper else { panic!("a real wrapper form") }; + let res = saved.resolve(wrapper.dict.get(b"Resources").unwrap()); + let xo = saved.resolve(res.as_dict().unwrap().get(b"XObject").unwrap()); + holder = xo.as_dict().unwrap().clone(); + } + assert_eq!(holder.contains(b"Fm"), still_drawn, "keep only resources a surviving nested form actually uses"); + if still_drawn { + let form = saved.resolve(holder.get(b"Fm").unwrap()); + let Object::Stream(form) = &*form else { panic!("the preserved original form") }; + assert_eq!(form.decoded().unwrap(), original); + } + } + } +} + /// A page tree of `n` pages that all draw form /Fm and inherit a direct `/Resources` from the root. fn inheriting_pages(n: usize) -> (Document, Vec) { let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"BT /F1 10 Tf 10 200 Td (SECRET) Tj ET"); @@ -2673,6 +2802,27 @@ fn removing_form_fields_takes_hidden_widgets_and_their_values_out_of_the_file() assert!(annots_of(&doc, &pdfcraft_model::pages(&doc).swap_remove(0).dict).is_empty()); } +/// F-021: removing form fields on a document with an AcroForm dictionary (even one with no +/// enumerable terminal fields) removes the AcroForm dictionary, processes widgets, and requires full save. +#[test] +fn removing_form_fields_removes_acroform_even_without_terminal_fields() { + use crate::sanitize::{Hidden, remove_hidden, scan}; + let mut doc = + one_page(b"BT /F1 10 Tf 10 100 Td (Visible) Tj ET", "", vec![b"<< /Type /Annot /Subtype /Widget /Rect [10 200 150 220] >>".to_vec()]); + doc.update_dict(doc.root().unwrap(), |d| d.set(b"AcroForm".to_vec(), fields_dict(&[]))).unwrap(); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Annots".to_vec(), ref_array(&[7]))).unwrap(); + let counts = scan(&doc); + assert_eq!(counts.iter().find(|c| c.0 == Hidden::FormFields).map(|c| c.1), Some(1)); + assert!(doc.get(doc.root().unwrap()).as_dict().is_some_and(|d| d.contains(b"AcroForm"))); + assert!(!doc.full_save_required()); + + let done = remove_hidden(&mut doc, &[Hidden::FormFields]).unwrap(); + assert_eq!(done, [(Hidden::FormFields, 1)]); + assert!(!doc.get(doc.root().unwrap()).as_dict().is_some_and(|d| d.contains(b"AcroForm"))); + assert!(annots_of(&doc, &pdfcraft_model::pages(&doc).swap_remove(0).dict).is_empty()); + assert!(doc.full_save_required()); +} + /// Removing hidden text cleans the structure tree as redaction does: the elements describing the /// removed text lose their descriptions, and a reference into a rewritten Form points at the copy. #[test] diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 827e678567..71b672e547 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -6,6 +6,7 @@ //! are empty and `warnings` says why. use std::cell::OnceCell; +use std::collections::hash_map::Entry; use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::Arc; @@ -424,9 +425,17 @@ pub struct Annotation { pub author: Option, pub contents: Option, pub modified: Option, - /// `/NM` of this annotation, and of the one it replies to (`/IRT`), for threading. + /// `/NM` of this annotation, and of the one it replies to (`/IRT`). pub name: Option, pub in_reply_to: Option, + /// The page and `/Annots` position of the comment `/IRT` points at, found by object + /// reference. Threads follow this rather than `/NM`, which need not be unique: PyMuPDF + /// names the first comment on every page `fitz-A0`. `None` when `/IRT` leads to no listed + /// comment ([`link_replies`]). + pub parent: Option<(usize, usize)>, + /// `/RT /Group`: grouped with the annotation `/IRT` points at (Replace Text's caret with its + /// strikeout) rather than a reply to it. + pub grouped: bool, /// Rect in PDF user space: [x0, y0, x1, y1]. pub rect: [f32; 4], pub color: Option<[f32; 3]>, @@ -458,6 +467,23 @@ impl Annotation { matches!(self.state.as_deref(), Some("Marked" | "Unmarked")) } + /// A reply (or status reply) to another comment rather than a comment of its own. + pub fn is_reply(&self) -> bool { + self.parent.is_some() + } + + /// A comment of its own: not a reply, and not a status or checkmark. One whose comment can't + /// be found (deleted from another page, or never listed) has nothing to show, so it is listed + /// nowhere: not as a card, on the page or in a count. + pub fn is_comment(&self) -> bool { + !self.is_reply() && self.state.is_none() + } + + /// Whether this annotation's `/IRT` points at `other`. + pub fn replies_to(&self, other: &Annotation) -> bool { + self.parent == Some((other.page, other.index)) + } + /// Where the comment is drawn on a page turned by `rotation` (`PageInfo::rotation`), in user /// space: `rect`, or for a NoRotate comment `rect` turned upright about its upper-left corner /// (see [`Self::no_rotate`]). Hit-testing, selection and anything placed beside the comment @@ -511,6 +537,55 @@ fn rect_from_upright(shown: [f64; 4], rotation: u16) -> [f64; 4] { [x0, y1 - h, x0 + w, y1] } +/// Settle the reply links of a document's annotations: a reply keeps its `parent` only when +/// following the links reaches a comment of its own within 64 steps. An `/IRT` to an unlisted +/// object, a pop-up, the annotation itself or a loop leaves it a comment of its own, so it stays +/// in view instead of hanging off nothing. Both comment readers (inspect, and the engine's list +/// after an edit) run this. +/// +/// Only comments can be parents: both readers leave links, form widgets, pop-ups and other +/// non-markup annotations out of `annotations` (`pdfcraft_annot::is_comment_subtype`), so an +/// `/IRT` to one of those finds no parent here. Pop-ups are skipped again in case a caller's list +/// has them. +pub fn link_replies(annotations: &mut [Annotation]) { + let at: HashMap<(usize, usize), usize> = + annotations.iter().enumerate().filter(|(_, a)| a.subtype != "Popup").map(|(i, a)| ((a.page, a.index), i)).collect(); + let links: Vec> = + annotations.iter().enumerate().map(|(i, a)| a.parent.and_then(|p| at.get(&p).copied()).filter(|&j| j != i)).collect(); + for (i, a) in annotations.iter_mut().enumerate() { + let mut up = links.get(i).copied().flatten(); + let mut settled = false; + for _ in 0..64 { + let Some(j) = up else { break }; + up = links.get(j).copied().flatten(); + if up.is_none() { + settled = true; + break; + } + } + if !settled { + a.parent = None; + } + } +} + +/// The comment each reply's thread starts from, by the reply's page and `/Annots` position: a +/// reply to a reply files under that comment too. Expects links [`link_replies`] settled. +pub fn thread_tops(annotations: &[Annotation]) -> HashMap<(usize, usize), (usize, usize)> { + let parents: HashMap<(usize, usize), (usize, usize)> = annotations.iter().filter_map(|a| Some(((a.page, a.index), a.parent?))).collect(); + parents + .iter() + .map(|(&reply, &first)| { + let mut top = first; + for _ in 0..64 { + let Some(&up) = parents.get(&top) else { break }; + top = up; + } + (reply, top) + }) + .collect() +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum FieldKind { Text, @@ -1332,13 +1407,12 @@ impl<'a> Inspector<'a> { pages.sort_unstable_by_key(|&(_, page)| page); // An annotation, or a whole `/Annots` array, that several entries or pages list is on the // first of them (§12.5.2 allows one page per annotation). Both are told apart by the object - // a reference resolves to, since an indirect object may itself hold a reference. + // a reference resolves to, since an indirect object may itself hold a reference. Where each + // annotation is listed is collected before any is read, so a reply's `/IRT` finds its + // parent by reference even when the parent comes later. let mut seen_arrays = HashSet::new(); - let mut seen_annotations = HashSet::new(); - let mut kept = 0usize; - // Once the comment budget is spent, comments are no longer built, but links and attachments - // are still listed. - let mut comments_full = false; + let mut slots: HashMap = HashMap::new(); + let mut entries = Vec::new(); for (id, page) in pages { let Ok(page_dict) = self.doc.get_dictionary(id) else { continue }; if let Ok(annots) = page_dict.get(b"Annots") @@ -1349,107 +1423,116 @@ impl<'a> Inspector<'a> { } let Ok(Object::Array(annots)) = page_dict.get(b"Annots").map(|o| self.resolve(o)) else { continue }; for (index, a) in annots.iter().enumerate() { - if let Some(r) = self.resolved_id(a) - && !seen_annotations.insert(r) - { - continue; + if let Some(r) = self.resolved_id(a) { + let Entry::Vacant(slot) = slots.entry(r) else { continue }; + slot.insert((page, index)); } - let Some(d) = self.dict(a) else { continue }; - let Some(subtype) = self.name(d, b"Subtype") else { continue }; - if subtype == "FileAttachment" - && let Ok(fs) = d.get(b"FS") - { - let fallback = |budget: &mut usize| self.budgeted_text(d, b"Contents", budget); - info.attachments.push(self.attachment(fs, AttachmentSource::Annotation { page, index }, text_budget, fallback)); - } - if subtype == "Link" { - if let Some(link) = self.link(page, index, d, text_budget) { - if matches!(&link.target, LinkTarget::Other(s) if s == "JavaScript") { - info.has_javascript = true; - } - info.links.push(link); - } - continue; - } - // Not comments: form widgets, pop-ups and non-markup annotations such as the - // Screen annotation that drives a LaTeX `animate` player (keep in step with - // `pdfcraft_annot::is_comment_subtype`). - if matches!(subtype.as_str(), "Widget" | "Popup" | "Screen" | "Movie" | "RichMedia" | "3D" | "PrinterMark" | "TrapNet" | "Watermark") - { - continue; - } - if comments_full { - continue; - } - let rect = rect4(self.resolve(d.get(b"Rect").unwrap_or(&Object::Null))); - let mut color = match d.get(b"C").map(|o| self.resolve(o)) { - Ok(Object::Array(c)) if c.len() == 3 => { - let f = |i: usize| c[i].as_float().unwrap_or(0.0); - Some([f(0), f(1), f(2)]) - } - _ => None, - }; - // A text box's /C is its background; its text colour is in /DA. - if subtype == "FreeText" - && let Some(da) = self.text(d, b"DA") - { - let t: Vec<&str> = da.split_whitespace().collect(); - if let Some(i) = t.iter().position(|x| *x == "rg") - && i >= 3 - { - let f = |k: usize| t[k].parse::().unwrap_or(0.0); - color = Some([f(i - 3), f(i - 2), f(i - 1)]); - } - } - let quads = match d.get(b"QuadPoints").map(|o| self.resolve(o)) { - Ok(Object::Array(q)) => { - let v: Vec = q.iter().map(|o| o.as_float().unwrap_or(0.0)).collect(); - v.as_chunks::<8>().0.to_vec() - } - _ => Vec::new(), - }; - 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(), - }; - let annotation = Annotation { - page, - subtype, - author: self.text(d, b"T"), - // Acrobat ends lines with CR, which the interface would draw as nothing (keep - // in step with `pdfcraft_annot::summaries`). - contents: self.text(d, b"Contents").map(|s| s.replace("\r\n", "\n").replace('\r', "\n")), - modified: self.text(d, b"M").map(|m| pretty_date(&m)), - name: self.text(d, b"NM"), - in_reply_to, - rect, - color, - index, - state: self.text(d, b"State"), - quads, - 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, - no_rotate: d.get(b"F").is_ok_and(|f| draw_flags(self.resolve(f)) & 16 != 0), - points, - }; - kept = kept.saturating_add(retained(&annotation)); - if kept > MAX_ANNOTATION_BYTES { - comments_full = true; - continue; - } - info.annotations.push(annotation); + entries.push((page, index, a)); } } + let mut kept = 0usize; + // Once the comment budget is spent, comments are no longer built, but links and attachments + // are still listed. + let mut comments_full = false; + for (page, index, a) in entries { + let Some(d) = self.dict(a) else { continue }; + let Some(subtype) = self.name(d, b"Subtype") else { continue }; + if subtype == "FileAttachment" + && let Ok(fs) = d.get(b"FS") + { + let fallback = |budget: &mut usize| self.budgeted_text(d, b"Contents", budget); + info.attachments.push(self.attachment(fs, AttachmentSource::Annotation { page, index }, text_budget, fallback)); + } + if subtype == "Link" { + if let Some(link) = self.link(page, index, d, text_budget) { + if matches!(&link.target, LinkTarget::Other(s) if s == "JavaScript") { + info.has_javascript = true; + } + info.links.push(link); + } + continue; + } + // Not comments: form widgets, pop-ups and non-markup annotations such as the + // Screen annotation that drives a LaTeX `animate` player (keep in step with + // `pdfcraft_annot::is_comment_subtype`). + if matches!(subtype.as_str(), "Widget" | "Popup" | "Screen" | "Movie" | "RichMedia" | "3D" | "PrinterMark" | "TrapNet" | "Watermark") { + continue; + } + if comments_full { + continue; + } + let rect = rect4(self.resolve(d.get(b"Rect").unwrap_or(&Object::Null))); + let mut color = match d.get(b"C").map(|o| self.resolve(o)) { + Ok(Object::Array(c)) if c.len() == 3 => { + let f = |i: usize| c[i].as_float().unwrap_or(0.0); + Some([f(0), f(1), f(2)]) + } + _ => None, + }; + // A text box's /C is its background; its text colour is in /DA. + if subtype == "FreeText" + && let Some(da) = self.text(d, b"DA") + { + let t: Vec<&str> = da.split_whitespace().collect(); + if let Some(i) = t.iter().position(|x| *x == "rg") + && i >= 3 + { + let f = |k: usize| t[k].parse::().unwrap_or(0.0); + color = Some([f(i - 3), f(i - 2), f(i - 1)]); + } + } + let quads = match d.get(b"QuadPoints").map(|o| self.resolve(o)) { + Ok(Object::Array(q)) => { + let v: Vec = q.iter().map(|o| o.as_float().unwrap_or(0.0)).collect(); + v.as_chunks::<8>().0.to_vec() + } + _ => Vec::new(), + }; + let in_reply_to = d.get(b"IRT").ok().and_then(|o| self.dict(o)).and_then(|p| self.text(p, b"NM")); + let parent = d.get(b"IRT").ok().and_then(|o| self.resolved_id(o)).and_then(|r| slots.get(&r).copied()); + 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(), + }; + let annotation = Annotation { + page, + subtype, + author: self.text(d, b"T"), + // Acrobat ends lines with CR, which the interface would draw as nothing (keep + // in step with `pdfcraft_annot::summaries`). + contents: self.text(d, b"Contents").map(|s| s.replace("\r\n", "\n").replace('\r', "\n")), + modified: self.text(d, b"M").map(|m| pretty_date(&m)), + name: self.text(d, b"NM"), + in_reply_to, + parent, + grouped: self.name(d, b"RT").as_deref() == Some("Group"), + rect, + color, + index, + state: self.text(d, b"State"), + quads, + 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, + no_rotate: d.get(b"F").is_ok_and(|f| draw_flags(self.resolve(f)) & 16 != 0), + points, + }; + kept = kept.saturating_add(retained(&annotation)); + if kept > MAX_ANNOTATION_BYTES { + comments_full = true; + continue; + } + info.annotations.push(annotation); + } info.annotations.sort_by(|a, b| a.page.cmp(&b.page).then(b.rect[3].total_cmp(&a.rect[3]))); + link_replies(&mut info.annotations); } fn link(&self, page: usize, index: usize, d: &Dictionary, text_budget: &mut usize) -> Option { @@ -2426,6 +2509,8 @@ mod tests { modified: None, name: None, in_reply_to: None, + parent: None, + grouped: false, rect: [40.0, 50.0, 70.0, 70.0], color: None, index: 0, @@ -2999,6 +3084,39 @@ trailer << /Root 1 0 R >> } } + /// A reply finds its comment where the listing keeps it: on the first page of a page-shared + /// `/Annots` array, whether its `/IRT` names the comment or an object holding a reference to it. + #[test] + fn a_reply_finds_its_comment_through_a_reference_chain_and_a_shared_array() { + let pdf = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 7 0 R >> endobj\n\ + 4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots 7 0 R >> endobj\n\ + 5 0 obj 6 0 R endobj\n\ + 6 0 obj << /Type /Annot /Subtype /Text /Rect [0 0 1 1] /Contents (comment) >> endobj\n\ + 7 0 obj [5 0 R 8 0 R 9 0 R] endobj\n\ + 8 0 obj << /Type /Annot /Subtype /Text /Rect [0 0 1 1] /Contents (to the comment) /IRT 6 0 R >> endobj\n\ + 9 0 obj << /Type /Annot /Subtype /Text /Rect [0 0 1 1] /Contents (through the chain) /IRT 5 0 R >> endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF"; + let mut doc = Document::load_mem(pdf.as_bytes()).expect("loads"); + let mut saved = Vec::new(); + doc.save_to(&mut saved).expect("saves"); + let lazy = LazyStructure::new(Arc::new(saved), None).expect("opens"); + let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }; + for reader in [&doc as &dyn Structure, &lazy] { + let mut info = DocInfo { pages: vec![page.clone(), page.clone()], ..Default::default() }; + Inspector::new(reader).fill(&mut info); + let notes = &info.annotations; + assert_eq!(notes.len(), 3, "{notes:?}"); + let comment = notes.iter().find(|a| a.contents.as_deref() == Some("comment")).expect("listed"); + assert_eq!((comment.page, comment.index, comment.is_reply()), (0, 0, false)); + for reply in ["to the comment", "through the chain"] { + let a = notes.iter().find(|a| a.contents.as_deref() == Some(reply)).expect("listed"); + assert_eq!((a.page, a.parent), (0, Some((0, 0))), "{reply}"); + } + } + } + /// A conversion rejected as too long, or trimmed short or to nothing, still spends the raw /// length, so a string many entries share is converted only a few times. #[test] diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index cc1e4af2e3..954823ef09 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -18,7 +18,7 @@ pub mod transition; pub use inspect::{ Annotation, Attachment, AttachmentSource, DestView, DocInfo, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, OutlineItem, PageInfo, - PageTransition, RemoteDestination, TransitionStyle, Xfa, attachment_data, check_pages, inspect, pretty_date, + PageTransition, RemoteDestination, TransitionStyle, Xfa, attachment_data, check_pages, inspect, link_replies, pretty_date, thread_tops, }; pub use pixels::Pixels; pub use raster::{ diff --git a/crates/render/tests/it/luminosity_group_space.rs b/crates/render/tests/it/luminosity_group_space.rs new file mode 100644 index 0000000000..d4f54c1a77 --- /dev/null +++ b/crates/render/tests/it/luminosity_group_space.rs @@ -0,0 +1,132 @@ +//! A luminosity soft mask takes its values from the gray of its group's colour (ISO 32000-2 +//! §11.5.3). In a `/DeviceCMYK` group that gray is `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` +//! (§10.4.2.3), so 100% K masks fully, whatever profile CMYK is displayed through. + +use std::sync::Arc; + +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind}; + +/// The page's grey backdrop, `0.5 g`, as the renderer writes it. +const BACKDROP: f64 = 128.0; + +fn pdf(objects: &[String]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, body) in objects.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + out +} + +fn stream(dict: &str, content: &str) -> String { + format!("<< {dict} /Length {} >>\nstream\n{content}\nendstream", content.len()) +} + +fn render(bytes: Vec) -> (Vec, u32) { + let mut renderer = PageRenderer::new(Arc::new(bytes), RenderConfig::default()); + let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + assert!(page.error.is_none(), "{:?}", page.error); + (page.rgba.to_vec(), page.width) +} + +fn pixel(rgba: &[u8], width: u32, x: u32, y: u32) -> [u8; 3] { + let offset = usize::try_from((y * width + x) * 4).unwrap(); + [rgba[offset], rgba[offset + 1], rgba[offset + 2]] +} + +/// Red filled over the grey backdrop under a luminosity mask whose group, in `space`, paints its +/// left half with `paint`; its right half is the backdrop `/BC` (`bc`, when given). Returns the +/// mask values read back from the left and the right half. +fn mask_values(space: &str, paint: &str, bc: Option<&str>) -> (f64, f64) { + let bc = bc.map(|bc| format!("/BC {bc}")).unwrap_or_default(); + let objects = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << /ExtGState << /M 5 0 R >> >> >>".to_string(), + stream("", "0.5 g 0 0 100 100 re f /M gs 1 0 0 rg 0 0 100 100 re f"), + format!("<< /Type /ExtGState /SMask << /Type /Mask /S /Luminosity /G 6 0 R {bc} >> >>"), + stream( + &format!("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /CS {space} >>"), + &format!("{paint} 0 0 50 100 re f"), + ), + ]; + let (rgba, width) = render(pdf(&objects)); + let value = |x| { + let [r, g, b] = pixel(&rgba, width, x, 50); + // Red over grey: red rises from the backdrop to 255 and green and blue fall to 0 as the + // mask goes from 0 to 1. + let from_red = (f64::from(r) - BACKDROP) / (255.0 - BACKDROP); + let from_green = (BACKDROP - f64::from(g)) / BACKDROP; + let from_blue = (BACKDROP - f64::from(b)) / BACKDROP; + assert!( + (from_red - from_green).abs() < 0.03 && (from_green - from_blue).abs() < 0.03, + "{space} {paint}: pixel {:?} is not red over the backdrop", + [r, g, b] + ); + from_green + }; + (value(25), value(75)) +} + +fn assert_mask(space: &str, paint: &str, bc: Option<&str>, left: f64, right: f64) { + let (got_left, got_right) = mask_values(space, paint, bc); + assert!((got_left - left).abs() < 0.02, "{space} group, `{paint}`: mask {got_left:.3}, expected {left}"); + assert!((got_right - right).abs() < 0.02, "{space} group, /BC {bc:?}: mask {got_right:.3}, expected {right}"); +} + +#[test] +fn black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully() { + assert_mask("/DeviceCMYK", "0 0 0 1 k", None, 0.0, 0.0); + assert_mask("/DeviceCMYK", "0 0 0 0 k", None, 1.0, 0.0); + assert_mask("/DeviceCMYK", "0 0 0 0.5 k", None, 0.5, 0.0); +} + +#[test] +fn each_process_ink_in_a_device_cmyk_group_masks_by_its_weight() { + assert_mask("/DeviceCMYK", "1 0 0 0 k", None, 0.7, 0.0); + assert_mask("/DeviceCMYK", "0 1 0 0 k", None, 0.41, 0.0); + assert_mask("/DeviceCMYK", "0 0 1 0 k", None, 0.89, 0.0); + assert_mask("/DeviceCMYK", "1 1 1 1 k", None, 0.0, 0.0); + // The stroking colour too, and a colour selected with `cs`/`sc`. + assert_mask("/DeviceCMYK", "0 0 0 1 K 0 0 0 1 k 50 w 25 0 m 25 100 l S", None, 0.0, 0.0); + assert_mask("/DeviceCMYK", "/DeviceCMYK cs 0 0 0 0.5 sc", None, 0.5, 0.0); +} + +#[test] +fn a_device_cmyk_backdrop_colour_masks_by_its_gray() { + assert_mask("/DeviceCMYK", "0 0 0 1 k", Some("[0 0 0 0]"), 0.0, 1.0); + assert_mask("/DeviceCMYK", "0 0 0 0 k", Some("[0 0 0 1]"), 1.0, 0.0); + assert_mask("/DeviceCMYK", "0 0 0 1 k", Some("[1 0 0 0]"), 0.0, 0.7); +} + +#[test] +fn gray_and_rgb_groups_mask_as_before() { + assert_mask("/DeviceGray", "0 g", None, 0.0, 0.0); + assert_mask("/DeviceGray", "1 g", Some("[1]"), 1.0, 1.0); + assert_mask("/DeviceGray", "0.5 g", None, 0.5, 0.0); + assert_mask("/DeviceRGB", "0 0 0 rg", None, 0.0, 0.0); + assert_mask("/DeviceRGB", "1 1 1 rg", Some("[1 1 1]"), 1.0, 1.0); + assert_mask("/DeviceRGB", "0.5 0.5 0.5 rg", None, 0.5, 0.0); +} + +#[test] +fn device_cmyk_on_the_page_keeps_its_display_colour() { + // Only a luminosity mask in a DeviceCMYK group reads CMYK as its gray. On the page, CMYK is + // displayed through the CMYK profile as before, where 100% K is a dark grey. + let objects = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R >>".to_string(), + stream("", "0 0 0 1 k 0 0 100 100 re f"), + ]; + let (rgba, width) = render(pdf(&objects)); + let [r, g, b] = pixel(&rgba, width, 50, 50); + assert!(r > 10 && g > 10 && b > 10 && r < 80, "100% K on the page: {:?}", [r, g, b]); +} diff --git a/crates/render/tests/it/main.rs b/crates/render/tests/it/main.rs index ca9adf016e..7c396b6f50 100644 --- a/crates/render/tests/it/main.rs +++ b/crates/render/tests/it/main.rs @@ -3,6 +3,7 @@ mod cid_fallback_query; mod inline_image_ei; mod jbig2_invariants; +mod luminosity_group_space; mod named_form_properties; mod search; mod separation_all; diff --git a/crates/sign/Cargo.toml b/crates/sign/Cargo.toml index 4f1f0d4094..3c0f207a98 100644 --- a/crates/sign/Cargo.toml +++ b/crates/sign/Cargo.toml @@ -18,7 +18,7 @@ pdfcraft-model = { workspace = true } thiserror.workspace = true # ASN.1 UTCTime/GeneralizedTime conversion goes through chrono (no hand-rolled calendar code). chrono = { workspace = true } -base64 = "0.22" +base64 = "0.23" sha1 = "0.11" sha2 = "0.11" p256 = { version = "0.14", default-features = false, features = ["ecdsa", "std"] } diff --git a/crates/sign/src/dss.rs b/crates/sign/src/dss.rs index e095ec7491..0d82c797a7 100644 --- a/crates/sign/src/dss.rs +++ b/crates/sign/src/dss.rs @@ -93,7 +93,9 @@ pub fn embed(doc: &Document, evidence: &Evidence) -> Result, SignError> } let dss_ref = doc.add(Object::Dict(dss)); doc.update_dict(root, |c| c.set(b"DSS".to_vec(), Object::Ref(dss_ref)))?; - Ok(pdfcraft_cos::write_incremental(&doc, &SaveOptions::default())?) + // What the user removed again before this (a cleared redaction mark) stays out of the file, + // as in a save; the store and everything in it are reachable from the catalog. + Ok(pdfcraft_cos::write_incremental(&doc, &SaveOptions { drop_unlinked: true, ..SaveOptions::default() })?) } /// Store one DER blob as a `/Type /Embed` stream. diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index b522a855e5..279feee096 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -2202,8 +2202,11 @@ fn sign_inner( let mut apd = Dict::new(); apd.set(b"N".to_vec(), Object::Ref(ap_ref)); doc.update_dict(widget, |w| w.set(b"AP".to_vec(), Object::Dict(apd)))?; - // Write with the placeholders, then patch them. - let save = SaveOptions { mod_date: Some(opts.date.clone()), object_streams: false, ..SaveOptions::default() }; + // Write with the placeholders, then patch them. What the user removed again before signing + // (a cleared redaction mark) stays out of the file, as in a save; what signing added is + // reachable (the signature through the field's `/V`), and the placeholders are found in the + // written bytes. + let save = SaveOptions { mod_date: Some(opts.date.clone()), object_streams: false, drop_unlinked: true, ..SaveOptions::default() }; let mut out = pdfcraft_cos::write_incremental(&doc, &save)?; let (br_at, gap) = locate(&out, reserve)?; let (start, end) = gap; @@ -2252,6 +2255,10 @@ pub fn timestamp_document(doc: &Document, tsa: &dyn crate::timestamp::TimestampA )); } let mut doc = doc.clone(); + // What the user removed again before this (a cleared redaction mark) stays out of the file, + // as in a save. Nothing refers to the timestamp either, so the leftovers go first and the + // save keeps everything. + doc.free_unlinked_additions(); let mut v = Dict::new(); v.set(b"Type".to_vec(), Object::name("DocTimeStamp")); v.set(b"Filter".to_vec(), Object::name("Adobe.PPKLite")); diff --git a/crates/sign/tests/it/pdf.rs b/crates/sign/tests/it/pdf.rs index d71e616ce7..7234e782ed 100644 --- a/crates/sign/tests/it/pdf.rs +++ b/crates/sign/tests/it/pdf.rs @@ -2,7 +2,7 @@ use std::sync::Arc; -use pdfcraft_cos::{Document, Object, PdfString, SaveOptions, write_incremental}; +use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, SaveOptions, write_incremental}; use pdfcraft_sign::{ Certificate, Detail, DigitalId, Modification, Name, SignError, SignOptions, Status, Time, TimestampAuthority, TrustStore, WORDINGS, der, in_english, kind, pkcs12, signatures, @@ -815,6 +815,71 @@ fn sign_then_ltv_then_timestamp_makes_a_b_lta_file() { assert_eq!(stamp.status, Status::Valid, "{:?}", stamp.details); } +/// A redaction mark and a reply to it, added to the page and removed again before signing (what +/// clearing a mark leaves behind), are in none of the signing outputs: a signature, a timestamped +/// one, embedded revocation evidence or a document timestamp. Nothing can take them out of a +/// signed revision afterwards. Each output still validates. +#[test] +fn what_was_removed_before_signing_is_not_signed() { + use pdfcraft_sign::dss::{self, Evidence}; + let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap(); + let tsa = TestTsa { id: tsa_id(), time: Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 } }; + let anchor = id.chain.first().cloned().unwrap_or_else(|| id.certificate.clone()); + let trust = TrustStore { certs: vec![anchor, tsa.id.certificate.clone()] }; + let text = |s: &str| Object::String(PdfString::literal(s.as_bytes().to_vec())); + let dict = |entries: &[(&str, Object)]| { + let mut d = Dict::new(); + entries.iter().for_each(|(k, v)| d.set(k.as_bytes().to_vec(), v.clone())); + Object::Dict(d) + }; + let cleared = |bytes: &[u8]| { + let mut doc = open(bytes); + let page = ObjRef::new(3, 0); + let annots = doc.get(page).as_dict().and_then(|d| d.get(b"Annots").cloned()).unwrap(); + let mark = doc.add(dict(&[ + ("Type", Object::name("Annot")), + ("Subtype", Object::name("Redact")), + ("Rect", Object::Array([80, 140, 100, 180].map(Object::Int).to_vec())), + ("OverlayText", text("MARKED OVERLAY")), + ("T", text("Mark Author")), + ("P", Object::Ref(page)), + ])); + let reply = doc.add(dict(&[ + ("Type", Object::name("Annot")), + ("Subtype", Object::name("Text")), + ("IRT", Object::Ref(mark)), + ("Contents", text("REPLY TO THE MARK")), + ("T", text("Replier")), + ])); + let mut with = annots.as_array().cloned().unwrap(); + with.extend([Object::Ref(mark), Object::Ref(reply)]); + doc.update_dict(page, |d| d.set(b"Annots".to_vec(), Object::Array(with))).unwrap(); + doc.update_dict(page, |d| d.set(b"Annots".to_vec(), annots.clone())).unwrap(); + doc + }; + let unsigned = fixture(); + let signed = pdfcraft_sign::sign(&open(&unsigned), &id, &opts()).unwrap(); + let evidence = Evidence { certs: vec![id.certificate.raw.clone()], ocsps: Vec::new(), crls: Vec::new() }; + let outputs = [ + ("sign", &unsigned, pdfcraft_sign::sign(&cleared(&unsigned), &id, &opts()).unwrap()), + ("sign_with_timestamp", &unsigned, pdfcraft_sign::sign_with_timestamp(&cleared(&unsigned), &id, &opts(), &tsa).unwrap()), + ("dss::embed", &signed, dss::embed(&cleared(&signed), &evidence).unwrap()), + ("timestamp_document", &unsigned, pdfcraft_sign::timestamp_document(&cleared(&unsigned), &tsa, "D:20261006120000Z").unwrap()), + ]; + for (what, input, out) in outputs { + assert!(out.starts_with(input), "{what}: an incremental update"); + let holds = |s: &str| out.windows(s.len()).any(|w| w == s.as_bytes()); + for trace in ["/Redact", "MARKED OVERLAY", "Mark Author", "REPLY TO THE MARK", "Replier"] { + assert!(!holds(trace), "{what} wrote {trace:?}"); + } + let doc = open(&out); + assert!(doc.repair_log().is_empty(), "{what}: {:?}", doc.repair_log()); + let all: Vec<_> = signatures(&doc, &out, &trust).into_iter().filter(|s| s.signed).collect(); + assert_eq!(all.iter().filter(|s| s.doc_timestamp).count(), usize::from(what == "timestamp_document"), "{what}"); + assert!(!all.is_empty() && all.iter().all(|s| s.status == Status::Valid), "{what}: {all:?}"); + } +} + #[test] fn an_embedded_verified_revocation_invalidates_the_signature() { use pdfcraft_sign::dss::{self, Evidence}; diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 39eaff6076..804b5ea22a 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -11,7 +11,11 @@ workspace = true [dependencies] craft-ui = { workspace = true } -# macOS: a window close quits through `terminate:` (the Touch Bar crash, #1136); elsewhere a no-op. +# The menus: native on macOS, an egui bar elsewhere, from one definition (menus.rs). +craft-menu = { workspace = true } +craft-keymap = { workspace = true } +# Quit from the menus asks the window to close, so the unsaved-changes check runs; on macOS a window +# close also quits through `terminate:` (the Touch Bar crash, #1136). craft-quit = { workspace = true, features = ["egui"] } pdfcraft-fonts = { workspace = true } pdfcraft-engine = { workspace = true } @@ -19,28 +23,32 @@ pdfcraft-cos = { workspace = true } pdfcraft-render = { workspace = true } pdfcraft-model = { workspace = true } pdfcraft-platform = { workspace = true } -egui = { workspace = true, features = ["bytemuck"] } +egui = { workspace = true, features = ["bytemuck", "default_fonts", "monochrome_emoji_fonts"] } egui_extras = { workspace = true } -eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "persistence", "links"] } +eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "persistence", "links", "clipboard"] } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } # UTC/calendar display (combine modified-time stamp, the form date picker) goes through chrono # (no hand-rolled calendar code); std-only, so it stays wasm-safe. chrono = { workspace = true } +# Monotonic deadlines for work that runs in `logic` with no UI pass (native and web). +web-time = { workspace = true } # Hands rendered rasters to egui as its pixels, without copying; already in the tree through egui and wgpu. bytemuck = { version = "1", features = ["extern_crate_alloc"] } rfd = { workspace = true } png = "0.18" -base64 = "0.22" -# Right-to-left file names in the chrome (egui shapes but does not reorder); already in the tree. +base64 = "0.23" +# Unicode bidi paragraph segmentation for right-to-left comment wrapping; egui 0.37's epaint owns +# display reordering. Already in the tree through egui’s epaint. unicode-bidi = "0.3" # Checks that an installed fallback font parses and has the glyphs, and reads the vertical metrics # that put fallback faces on the interface's baseline (#1676); the same skrifa egui uses. skrifa = "0.44" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] +craft-update = { workspace = true } # Control-channel tokens. getrandom = "0.4" # Create ▸ Clipboard (images; already in the tree through egui-winit). @@ -79,8 +87,12 @@ js-sys = "0.3" web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "HtmlIFrameElement", "HtmlImageElement", "HtmlCollection", "Node", "EventTarget", "Navigator"] } [dev-dependencies] +craft-update = { workspace = true } pdfcraft-xfa = { workspace = true } egui_kittest = { workspace = true, features = ["eframe", "wgpu"] } +# Tests draw PdfCraft text without a system provider: the bundled and monochrome emoji faces must +# be enabled on the test graph itself (a dependency's dev-features do not propagate). +egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] } image = { version = "0.25", default-features = false, features = ["png"] } # Glyph-coverage test of the interface fonts; the same skrifa pdfcraft-fonts already uses. skrifa = "0.44" diff --git a/crates/ui-egui/src/actions_ui.rs b/crates/ui-egui/src/actions_ui.rs index 89cf2569e8..ddd8a0736c 100644 --- a/crates/ui-egui/src/actions_ui.rs +++ b/crates/ui-egui/src/actions_ui.rs @@ -301,16 +301,18 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool close } +/// Draw the custom-action editor: name, description and ordered steps; Save stays disabled until the name is +/// unique and non-empty and at least one step exists. fn edit_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { let Some((original, a)) = app.wizard.editing.as_mut() else { return false }; ui.label(egui::RichText::new(if original.is_some() { tl!("Edit Action") } else { tl!("New Action") }).font(theme::semibold(18.0))); ui.add_space(8.0); egui::Grid::new("action-edit").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { - ui.label(tl!("Action name:")); - ui.add(crate::widgets::line(&mut a.name).desired_width(320.0).id_salt("action-name")); + let name_label = ui.label(tl!("Action name:")); + ui.add(crate::widgets::line(&mut a.name).desired_width(320.0).id_salt("action-name")).labelled_by(name_label.id); ui.end_row(); - ui.label(tl!("Description:")); - ui.add(crate::widgets::line(&mut a.description).desired_width(320.0).id_salt("action-description")); + let description_label = ui.label(tl!("Description:")); + ui.add(crate::widgets::line(&mut a.description).desired_width(320.0).id_salt("action-description")).labelled_by(description_label.id); ui.end_row(); }); ui.add_space(6.0); @@ -324,9 +326,9 @@ fn edit_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { for (i, s) in a.steps.iter_mut().enumerate() { ui.push_id(i, |ui| { ui.horizontal(|ui| { - ui.label(format!("{}. {}", i + 1, tl!(s.label()))); + let step_label = ui.label(format!("{}. {}", i + 1, tl!(s.label()))); if let Some(arg) = s.arg_mut() { - ui.add(crate::widgets::line(arg).desired_width(220.0)); + ui.add(crate::widgets::line(arg).desired_width(220.0)).labelled_by(step_label.id); } ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if ui.button(tl!("Remove")).clicked() { diff --git a/crates/ui-egui/src/all_tools_ui.rs b/crates/ui-egui/src/all_tools_ui.rs new file mode 100644 index 0000000000..9b09c887d1 --- /dev/null +++ b/crates/ui-egui/src/all_tools_ui.rs @@ -0,0 +1,197 @@ +//! The expanded All tools view: every tool, filed under Acrobat-style categories, with a search +//! field, filling the workspace instead of the sidebar. The sidebar's expand button opens it; +//! its collapse button, Esc or choosing a tool closes it again. + +use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, vec2}; +use pdfcraft_engine::catalog::{self, Availability, TOOL_CATEGORIES, ToolGroup}; + +use crate::theme::{self, Tokens}; +use crate::{PdfCraftApp, icons, widgets}; + +const TILE: egui::Vec2 = vec2(148.0, 118.0); +/// Item names a tile lists when the search found the tool by them. +const MATCHES_SHOWN: usize = 2; + +/// Whether `query` (already trimmed and lower-cased) is in `label` or its translation. +/// Translations and queries stay in logical order. For right-to-left labels, also accept +/// multiword queries in a different word order, as in the command palette. +fn label_hit(label: &str, translated: &str, query: &str, rtl: bool) -> bool { + let translated = translated.to_lowercase(); + label.to_lowercase().contains(query) + || translated.contains(query) + || (rtl && query.contains(char::is_whitespace) && query.split_whitespace().all(|word| translated.contains(word))) +} + +/// Whether `group` matches `query` (already trimmed and lower-cased), in English or the UI +/// language. `None` is no match; otherwise the items that matched, empty when the tool's own +/// name did. An entry with a menu (Watermark: Add, Update, Remove) also matches by its commands' +/// names, so "remove watermark" finds it. +fn matches(query: &str, group: &ToolGroup) -> Option> { + let rtl = crate::i18n::current().rtl(); + let hit = |label: &str| label_hit(label, tl!(label), query, rtl); + if query.is_empty() || hit(group.label) { + return Some(Vec::new()); + } + let action_hit = |id: &&str| { + pdfcraft_engine::commands::command(id).is_some_and(|spec| label_hit(spec.label, &crate::i18n::menu_label(spec.id, spec.label), query, rtl)) + }; + let items: Vec<_> = + group.sections.iter().flat_map(|s| s.items.iter()).filter(|i| hit(i.label) || i.actions.iter().any(action_hit)).map(|i| i.label).collect(); + (!items.is_empty()).then_some(items) +} + +/// Draw the view over the workspace: the title with Collapse, the search field, and each +/// category's tiles that match the search. Choosing a tile opens that tool's panel. +pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + // A dialog, prompt or the command palette over the view owns Escape. + if !app.modal_open() && !app.palette_open && ui.input(|i| i.key_pressed(egui::Key::Escape)) { + app.all_tools_full = false; + return; + } + let mut chosen = None; + egui::Frame::NONE.fill(t.panel).show(ui, |ui| { + ui.set_min_size(ui.available_size()); + egui::Frame::NONE.inner_margin(egui::Margin { left: 36, right: 28, top: 20, bottom: 12 }).show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!("All tools")).font(theme::semibold(22.0)).color(t.text)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if icons::button(ui, "minimize-2", 28.0, false, tl!("Collapse all tools")).clicked() { + app.all_tools_full = false; + } + }); + }); + ui.add_space(10.0); + let field = ui.add( + widgets::line(&mut app.all_tools_query).hint_text(tl!("Search tools and commands…")).desired_width(ui.available_width().min(520.0)), + ); + // Typing goes straight to the search, as in Acrobat's tools view. + if !ui.memory(|m| m.focused().is_some()) { + field.request_focus(); + } + }); + ui.separator(); + let query = app.all_tools_query.trim().to_lowercase(); + egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { + egui::Frame::NONE.inner_margin(egui::Margin { left: 36, right: 28, top: 8, bottom: 28 }).show(ui, |ui| { + ui.set_width(ui.available_width()); + let mut any = false; + for category in TOOL_CATEGORIES { + let found: Vec<_> = + category.groups.iter().filter_map(|id| catalog::group(id)).filter_map(|g| matches(&query, g).map(|m| (g, m))).collect(); + if found.is_empty() { + continue; + } + any = true; + ui.add_space(12.0); + ui.label(egui::RichText::new(tl!(category.label)).font(theme::semibold(16.0)).color(t.text)); + ui.add_space(10.0); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(14.0, 14.0); + for (g, items) in found { + if tile(ui, &t, g, &items).clicked() { + chosen = Some(g.id); + } + } + }); + ui.add_space(8.0); + } + if !any { + ui.add_space(24.0); + ui.label(egui::RichText::new(tl!("No tools match your search.")).color(t.text_muted)); + } + }); + }); + }); + if let Some(id) = chosen { + app.left_open = true; + app.select_tool(id); + } +} + +/// One tool's tile: its icon and name, then the `items` the search found it by or, when there are +/// none, its availability (a planned tool is greyed out with its milestone). A name or note cut +/// short shows in full on hover. +fn tile(ui: &mut egui::Ui, t: &Tokens, g: &ToolGroup, items: &[&'static str]) -> egui::Response { + let (rect, resp) = ui.allocate_exact_size(TILE, Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!(g.label))); + let press = theme::Press::track(ui, &resp); + crate::home::card_lift(ui, t, rect, press); + ui.painter().rect(rect, CornerRadius::same(10), press.fill(t.card, t.hover, t.pressed), Stroke::new(1.0, t.divider), egui::StrokeKind::Inside); + let body = rect.translate(press.offset()); + let ready = g.availability == Availability::Ready; + let hue = Color32::from_rgb(g.hue[0], g.hue[1], g.hue[2]); + icons::paint(ui, Rect::from_center_size(body.center_top() + vec2(0.0, 34.0), vec2(32.0, 32.0)), g.icon, 30.0, hue); + let mut job = + egui::text::LayoutJob::simple(tl!(g.label).to_owned(), theme::medium(13.0), if ready { t.text } else { t.text_muted }, body.width() - 16.0); + job.halign = egui::Align::Center; + job.wrap.max_rows = 2; + let title = ui.fonts_mut(|f| f.layout_job(job)); + let mut elided = title.elided; + let title_h = title.size().y; + ui.painter().galley(body.center_top() + vec2(0.0, 60.0), title, t.text); + // What found the tool, or else whether it works today. + let note = if items.is_empty() { + match g.availability { + Availability::Ready => String::new(), + Availability::Planned(m) => format!("{} · {m}", tl!("Planned")), + Availability::Provider => g.badge.map(|b| tl!(b).to_owned()).unwrap_or_default(), + } + } else { + items.iter().take(MATCHES_SHOWN).map(|l| tl!(l)).collect::>().join(" · ") + }; + if !note.is_empty() { + let color = if items.is_empty() { t.text_faint } else { t.accent_text }; + let mut job = egui::text::LayoutJob::simple_singleline(note.clone(), theme::regular(11.0), color); + job.halign = egui::Align::Center; + job.wrap = egui::text::TextWrapping { max_width: body.width() - 16.0, max_rows: 1, break_anywhere: true, ..Default::default() }; + let galley = ui.fonts_mut(|f| f.layout_job(job)); + elided |= galley.elided; + ui.painter().galley(body.center_top() + vec2(0.0, 64.0 + title_h), galley, color); + } + if let Some(b) = g.badge.filter(|_| g.availability == Availability::Ready) { + let font = theme::semibold(9.5); + let w = widgets::text_width(ui, tl!(b), &font); + let r = Rect::from_min_size(body.right_top() + vec2(-w - 18.0, 8.0), vec2(w + 10.0, 16.0)); + ui.painter().rect_filled(r, CornerRadius::same(4), t.badge_new); + ui.painter().text(r.center(), Align2::CENTER_CENTER, tl!(b), font, Color32::WHITE); + } + let resp = if elided { resp.on_hover_text(format!("{}\n{note}", tl!(g.label))) } else { resp }; + theme::hand(resp) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn group(id: &str) -> &'static ToolGroup { + catalog::group(id).expect("catalogue group") + } + + #[test] + fn search_matches_tool_names_and_their_items() { + assert_eq!(matches("", group("organize")), Some(Vec::new()), "an empty query shows everything"); + assert_eq!(matches("organize", group("organize")), Some(Vec::new()), "the tool's own name"); + let rotate = matches("rotate", group("organize")).expect("Organize pages lists Rotate"); + assert!(!rotate.is_empty() && rotate.iter().all(|l| l.to_lowercase().contains("rotate")), "{rotate:?}"); + assert_eq!(matches("no such tool anywhere", group("organize")), None); + } + + #[test] + fn search_finds_an_entry_by_its_menus_commands() { + let found = matches("remove watermark", group("edit")).expect("Watermark's menu has Remove watermark"); + assert!(found.contains(&"Watermark"), "{found:?}"); + } + + #[test] + fn right_to_left_labels_match_a_query_typed_in_logical_order() { + let arabic = crate::i18n::Lang::from_code("ar").expect("Arabic is registered"); + let translated = crate::i18n::tr(arabic, "Organize pages"); + assert_eq!(translated, "تنظيم الصفحات", "the catalogue keeps logical text"); + assert!(label_hit("Organize pages", translated, "تنظيم الصفحات", true)); + assert!(label_hit("Organize pages", translated, "الصفحات", true), "one word"); + assert!(label_hit("Organize pages", translated, "الصفحات تنظيم", true), "RTL word-order fallback"); + assert!(!label_hit("Organize pages", translated, "تنظيم الملفات", true), "every word must be there"); + assert!(!label_hit("Organize pages", translated, "الصفحات تنظيم", false), "left-to-right labels stay exact"); + } +} diff --git a/crates/ui-egui/src/appearance.rs b/crates/ui-egui/src/appearance.rs index 536d8e4d97..1393c9d937 100644 --- a/crates/ui-egui/src/appearance.rs +++ b/crates/ui-egui/src/appearance.rs @@ -118,8 +118,8 @@ fn card(ui: &mut egui::Ui, t: &Tokens, title: &str, active: bool, themes: &[(The pub fn preferences_section(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { ui.label(RichText::new(tl_ctx!("theme", "Appearance")).font(theme::semibold(13.0))); ui.horizontal(|ui| { - ui.label(tl!("Appearance mode")); - egui::ComboBox::from_id_salt("appearance-mode").selected_text(tl!(mode_label(app.theme_preference))).show_ui(ui, |ui| { + let label = ui.label(tl!("Appearance mode")); + let combo = egui::ComboBox::from_id_salt("appearance-mode").selected_text(tl!(mode_label(app.theme_preference))).show_ui(ui, |ui| { for preference in [ThemePreference::System, ThemePreference::Light, ThemePreference::Dark] { if ui.selectable_label(app.theme_preference == preference, tl!(mode_label(preference))).clicked() { app.execute(mode_command(preference)); @@ -127,6 +127,8 @@ pub fn preferences_section(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) } } }); + // The visible label names the combo box for assistive technology and tests. + combo.response.labelled_by(label.id); }); ui.add_space(4.0); let light_active = app.theme == ThemeKind::Light; diff --git a/crates/ui-egui/src/bidi.rs b/crates/ui-egui/src/bidi.rs index 50cae547e3..c5809c601f 100644 --- a/crates/ui-egui/src/bidi.rs +++ b/crates/ui-egui/src/bidi.rs @@ -1,18 +1,18 @@ -//! Visual order for single-line interface text that may hold right-to-left script (file names, -//! document titles). +//! Wrapping right-to-left text into logical lines, and test-only display-order helpers. //! -//! egui shapes each font-face run with the script's own direction, so the letters of an Arabic -//! word join and run right to left, but it places the runs themselves left to right in logical -//! order: `واحد اثنين.pdf` would show its two words swapped. [`visual`] puts the runs in display -//! order and leaves the letters of each word alone for the shaper. -//! -//! [`display_rtl`] does the same for the labels of a right-to-left interface language (the Arabic -//! catalog), whose base direction is right to left. +//! egui 0.37's epaint shapes text with the Unicode bidirectional algorithm (UAX #9), so +//! production code passes it logical (typing-order) text: [`wrap`] breaks a comment into logical +//! lines and lets epaint reorder each one. [`visual`], [`visual_auto`] and [`display_rtl`] are +//! kept for the tests that prove the old preordering is gone; they must not be used for painting +//! with egui 0.37, where they would reverse mixed-direction text a second time. +#[cfg(test)] use std::borrow::Cow; use std::ops::Range; -use unicode_bidi::{BidiInfo, Level, ParagraphInfo}; +use unicode_bidi::BidiInfo; +#[cfg(test)] +use unicode_bidi::{Level, ParagraphInfo}; /// `text` rearranged for an egui call that lays out one line. Text without right-to-left /// characters comes back borrowed and unchanged. @@ -20,6 +20,7 @@ use unicode_bidi::{BidiInfo, Level, ParagraphInfo}; /// The base direction is left to right, as the interface is: a file name keeps its extension /// at the end, the way the window title shows it. Use it for painting only; accessibility /// labels and anything stored keep the logical text. +#[cfg(test)] pub fn visual(text: &str) -> Cow<'_, str> { if !text.chars().any(is_rtl_script) { return Cow::Borrowed(text); @@ -30,6 +31,7 @@ pub fn visual(text: &str) -> Cow<'_, str> { } /// `text` in display order for the paragraph direction `base`, appended to `out`. +#[cfg(test)] fn push_visual(out: &mut String, text: &str, base: Level) { let info = BidiInfo::new(text, Some(base)); for para in &info.paragraphs { @@ -39,6 +41,7 @@ fn push_visual(out: &mut String, text: &str, base: Level) { /// The `line` of paragraph `para` (a non-empty byte range of `text`) in display order, as egui /// needs it, appended to `out`. +#[cfg(test)] fn push_line(out: &mut String, text: &str, info: &BidiInfo<'_>, para: &ParagraphInfo, line: Range) { let (levels, runs) = info.visual_runs(para, line); // First the true display order, every character left to right as it appears on screen. @@ -62,6 +65,7 @@ fn push_line(out: &mut String, text: &str, info: &BidiInfo<'_>, para: &Paragraph /// each such stretch goes back to logical order. What lies between them (spaces, punctuation, /// Latin) is drawn by the Latin face left to right and stays as it is. The stretches include /// Persian and Arabic-Indic digits: Arabic script to the shaper, though they read left to right. +#[cfg(test)] fn push_for_shaper(out: &mut String, shown: &str) { let mut stretch: Vec = Vec::new(); for c in shown.chars() { @@ -78,7 +82,7 @@ fn push_for_shaper(out: &mut String, shown: &str) { /// One line of wrapped text (see [`wrap`]). #[derive(Clone, Debug, PartialEq, Eq)] pub struct Line { - /// The line in display order, for one egui call that lays out one line. + /// The line in logical (reading) order. epaint shapes and reorders it for display. pub text: String, /// The paragraph reads right to left: the line belongs against the right edge. pub rtl: bool, @@ -89,14 +93,44 @@ pub fn has_rtl(text: &str) -> bool { text.chars().any(is_rtl_script) } +/// The paragraph direction for translated interface text: explicitly right to left for Arabic, +/// Kurdish and Persian, and `TextDirection::Auto` otherwise. A label whose first strong character is +/// Latin or a `{placeholder}` must not fall back to a left-to-right paragraph in an RTL locale. +pub fn ui_direction() -> egui::epaint::text::TextDirection { + if crate::i18n::current().rtl() { egui::epaint::text::TextDirection::RightToLeft } else { egui::epaint::text::TextDirection::Auto } +} + +/// Lay out one line of translated interface text with the locale's explicit base direction. +pub fn layout_ui(ui: &egui::Ui, text: impl Into, font: egui::FontId, color: egui::Color32) -> std::sync::Arc { + layout_one_line(ui, text, font, color, ui_direction()) +} + +/// Lay out one line of file or path text left to right, so an Arabic file name keeps `.pdf` at +/// its visual end exactly as a Latin one does. The text stays logical for accessibility. +pub fn layout_ltr(ui: &egui::Ui, text: impl Into, font: egui::FontId, color: egui::Color32) -> std::sync::Arc { + layout_one_line(ui, text, font, color, egui::epaint::text::TextDirection::LeftToRight) +} + +fn layout_one_line( + ui: &egui::Ui, + text: impl Into, + font: egui::FontId, + color: egui::Color32, + direction: egui::epaint::text::TextDirection, +) -> std::sync::Arc { + let mut job = egui::text::LayoutJob::simple_singleline(text.into(), font, color); + job.direction = direction; + ui.painter().layout_job(job) +} + /// Multi-line text with right-to-left script (a comment in Persian, Arabic, Hebrew), broken -/// into lines that `fits` accepts (it measures a line in logical order) and each put in display -/// order by the Unicode bidirectional algorithm (UAX #9). +/// into logical-order lines that `fits` accepts (it measures a line in logical order). /// -/// Every paragraph takes its direction from its first strong character, as `dir="auto"` does, -/// so a Persian comment reads right to left with its Latin words and numbers left to right -/// inside it. Lines break between words, in reading order, and a word wider than a line -/// between characters. Use it for painting; copying and accessibility keep the logical text. +/// The lines keep reading order and each line's paragraph direction ([`Line::rtl`]): egui 0.37's +/// epaint already shapes every line with the Unicode bidirectional algorithm (UAX #9), so +/// preordering here would reorder mixed Persian/Latin text a second time. Lines break between +/// words, in reading order, and a word wider than a line between characters. Copying and +/// accessibility use the same logical text. pub fn wrap(text: &str, mut fits: impl FnMut(&str) -> bool) -> Vec { let info = BidiInfo::new(text, None); let mut lines = Vec::new(); @@ -109,12 +143,7 @@ pub fn wrap(text: &str, mut fits: impl FnMut(&str) -> bool) -> Vec { let content = para.range.start..para.range.start.saturating_add(whole.trim_end_matches(is_paragraph_separator).len()); let rtl = para.level.is_rtl(); for line in break_lines(text, content, &mut fits) { - let mut out = String::new(); - // unicode-bidi indexes the first level of a line, so an empty one would panic. - if !line.is_empty() { - push_line(&mut out, text, &info, para, line); - } - lines.push(Line { text: out, rtl }); + lines.push(Line { text: text.get(line).unwrap_or_default().to_owned(), rtl }); } } lines @@ -122,12 +151,18 @@ pub fn wrap(text: &str, mut fits: impl FnMut(&str) -> bool) -> Vec { /// One line of `text` in display order, its direction from its first strong character (an /// author's name). Text without right-to-left characters comes back borrowed and unchanged. +/// +/// Only the bidi unit tests use this now: interface labels keep logical text and let the egui +/// fork reorder, so no production call site preorders a label. +#[cfg(test)] pub fn visual_auto(text: &str) -> Cow<'_, str> { if !has_rtl(text) { return Cow::Borrowed(text); } - let lines: Vec = wrap(text, |_| true).into_iter().map(|l| l.text).collect(); - Cow::Owned(lines.join("\n")) + let base = BidiInfo::new(text, None).paragraphs.first().map_or(Level::ltr(), |para| para.level); + let mut out = String::with_capacity(text.len()); + push_visual(&mut out, text, base); + Cow::Owned(out) } /// The byte ranges of the lines `range` of `text` breaks into: as many words as `fits` accepts @@ -209,7 +244,9 @@ fn is_paragraph_separator(c: char) -> bool { /// - A short caption's closing colon stays on the right too, next to the widget it captions. /// /// Text without right-to-left characters comes back unchanged. Like [`visual`], this is for -/// painting one line; a label egui wraps breaks at the wrong end. +/// painting one line; a label egui wraps breaks at the wrong end. Kept for the tests that prove +/// the old preordering is gone; production catalogs stay logical. +#[cfg(test)] pub fn display_rtl(text: &str) -> String { if !text.chars().any(is_rtl_script) { return text.to_owned(); @@ -225,13 +262,17 @@ pub fn display_rtl(text: &str) -> String { } /// The most words a caption may have for its closing colon to stay on the right. +#[cfg(test)] const CAPTION_WORDS: usize = 3; /// First of the private-use characters that stand for placeholders while a line is reordered. /// They are strong left-to-right, like the names they replace. +#[cfg(test)] const PLACEHOLDER_BASE: u32 = 0xE000; /// Placeholders protected per line; a line with more keeps the rest as plain text. +#[cfg(test)] const MAX_PLACEHOLDERS: usize = 64; +#[cfg(test)] fn push_rtl_line(out: &mut String, line: &str) { let edge = |c: char| c.is_whitespace() || c == '·'; let core = line.trim_start_matches(edge); @@ -266,6 +307,7 @@ fn push_rtl_line(out: &mut String, line: &str) { /// `text` with each `{name}` placeholder and each keyboard shortcut (`⇧⌘+`) replaced by one /// stand-in character, and what they replaced, in order. A shortcut's symbols have no direction /// of their own, so left alone they would be drawn in reverse. +#[cfg(test)] fn mask_placeholders(text: &str) -> (String, Vec<&str>) { let mut masked = String::with_capacity(text.len()); let mut names: Vec<&str> = Vec::new(); @@ -286,6 +328,7 @@ fn mask_placeholders(text: &str) -> (String, Vec<&str>) { } /// The byte range of the first placeholder or keyboard shortcut in `text`. +#[cfg(test)] fn next_atom(text: &str) -> Option<(usize, usize)> { let placeholder = text.find('{').and_then(|open| { let len = text.get(open..)?.find('}')?; @@ -308,6 +351,7 @@ fn next_atom(text: &str) -> Option<(usize, usize)> { } /// The modifier-key symbols of keyboard shortcuts: ⌘ ⇧ ⌥ ⌃. +#[cfg(test)] fn is_modifier_key(c: char) -> bool { matches!(c, '\u{2318}' | '\u{21E7}' | '\u{2325}' | '\u{2303}') } @@ -320,15 +364,18 @@ fn is_rtl_script(c: char) -> bool { /// The zero-width (non-)joiners inside a word (Persian `می‌روم`): egui shapes them with the /// letter before them. +#[cfg(test)] fn is_joiner(c: char) -> bool { matches!(c, '\u{200C}' | '\u{200D}') } /// Direction marks, embeddings, overrides and isolates: they steer the reordering and are not drawn. +#[cfg(test)] fn is_bidi_control(c: char) -> bool { matches!(c, '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}') } +#[cfg(test)] fn mirrored(c: char) -> char { match c { '(' => ')', @@ -474,50 +521,57 @@ mod tests { Line { text: text.to_owned(), rtl: false } } + /// Check that a Persian paragraph, including words joined by a zero-width non-joiner, reaches epaint as + /// one logical right-to-left run. #[test] - fn persian_paragraph_reads_right_to_left() { - // "I go to school": the words come out in reverse for egui's left-to-right placement. - assert_eq!(wrap_at("من به مدرسه می‌روم", 80), [rtl("می‌روم مدرسه به من")]); + fn persian_paragraph_keeps_logical_order_for_epaint() { + // The line stays in reading order; epaint shapes and reorders it (see `on_screen`). + assert_eq!(wrap_at("من به مدرسه می‌روم", 80), [rtl("من به مدرسه می‌روم")]); // The zero-width non-joiner keeps a word whole, so the shaper gets it as one run. assert_eq!(wrap_at("کتاب‌ها", 80), [rtl("کتاب‌ها")]); } + /// Check that Arabic text wraps into lines in logical reading order rather than visual order. #[test] fn arabic_lines_break_in_reading_order() { - // The first words go on the first line, each line reordered on its own. - assert_eq!(wrap_at("واحد اثنين ثلاثة أربعة", 11), [rtl("اثنين واحد"), rtl("أربعة ثلاثة")]); - assert_eq!(wrap_at("واحد اثنين ثلاثة أربعة", 80), [rtl("أربعة ثلاثة اثنين واحد")]); + // The first words go on the first line, in logical order for epaint to reorder. + assert_eq!(wrap_at("واحد اثنين ثلاثة أربعة", 11), [rtl("واحد اثنين"), rtl("ثلاثة أربعة")]); + assert_eq!(wrap_at("واحد اثنين ثلاثة أربعة", 80), [rtl("واحد اثنين ثلاثة أربعة")]); } + /// Check that each paragraph takes its base direction from its first strong character, not from leading + /// numbers or neutral text. #[test] fn base_direction_comes_from_the_first_strong_character() { // Latin first: a left-to-right paragraph with an Arabic phrase inside it. - assert_eq!(wrap_at("See واحد اثنين here", 80), [ltr("See اثنين واحد here")]); - // Persian first: right to left, the Latin words and the number left to right inside it. - assert_eq!(wrap_at("فایل PDF نسخه 2 است", 80), [rtl("است 2 نسخه PDF فایل")]); - assert_eq!(wrap_at("سلام Hello World", 80), [rtl("Hello World سلام")]); + assert_eq!(wrap_at("See واحد اثنين here", 80), [ltr("See واحد اثنين here")]); + // Persian first: right to left, with the Latin words and number in reading order. + assert_eq!(wrap_at("فایل PDF نسخه 2 است", 80), [rtl("فایل PDF نسخه 2 است")]); + assert_eq!(wrap_at("سلام Hello World", 80), [rtl("سلام Hello World")]); // Leading neutrals and numbers don't decide it. - assert_eq!(wrap_at("12. واحد اثنين", 80), [rtl("اثنين واحد .12")]); + assert_eq!(wrap_at("12. واحد اثنين", 80), [rtl("12. واحد اثنين")]); // Each paragraph finds its own direction. - assert_eq!(wrap_at("واحد اثنين\nOK then\n\nשלום עולם", 80), [rtl("اثنين واحد"), ltr("OK then"), ltr(""), rtl("עולם שלום")]); + assert_eq!(wrap_at("واحد اثنين\nOK then\n\nשלום עולם", 80), [rtl("واحد اثنين"), ltr("OK then"), ltr(""), rtl("שלום עולם")]); } + /// Check that right-to-left punctuation and brackets stay in logical order for epaint to mirror when + /// displaying. #[test] fn rtl_punctuation_and_brackets() { - // The sentence's full stop ends up on the left, brackets flip. - assert_eq!(wrap_at("سلام دنیا.", 80), [rtl(".دنیا سلام")]); - assert_eq!(wrap_at("واحد (اثنين) ثلاثة", 80), [rtl("ثلاثة (اثنين) واحد")]); - assert_eq!(wrap_at("آیا آماده است؟", 80), [rtl("است؟ آماده آیا")]); + // Logical text: epaint mirrors punctuation and brackets for display. + assert_eq!(wrap_at("سلام دنیا.", 80), [rtl("سلام دنیا.")]); + assert_eq!(wrap_at("واحد (اثنين) ثلاثة", 80), [rtl("واحد (اثنين) ثلاثة")]); + assert_eq!(wrap_at("آیا آماده است؟", 80), [rtl("آیا آماده است؟")]); } + /// Check that Persian and Arabic-Indic digits stay in logical order inside a right-to-left paragraph. #[test] fn persian_digits_go_to_the_shaper_reversed() { - // Persian and Arabic-Indic digits are Arabic script, which egui's shaper lays out right to - // left; they read left to right, so they are handed over reversed. - assert_eq!(wrap_at("سال ۱۴۰۲ شمسی", 80), [rtl("شمسی ۲۰۴۱ سال")]); - assert_eq!(wrap_at("صفحه ١٢", 80), [rtl("٢١ صفحه")]); - // Glued to a word, number and word swap places within one shaped run. - assert_eq!(wrap_at("ب۱۲", 80), [rtl("ب۲۱")]); + // Persian and Arabic-Indic digits stay in logical order; epaint's shaper preserves their + // left-to-right reading inside the right-to-left paragraph. + assert_eq!(wrap_at("سال ۱۴۰۲ شمسی", 80), [rtl("سال ۱۴۰۲ شمسی")]); + assert_eq!(wrap_at("صفحه ١٢", 80), [rtl("صفحه ١٢")]); + assert_eq!(wrap_at("ب۱۲", 80), [rtl("ب۱۲")]); } #[test] @@ -546,8 +600,30 @@ mod tests { let output = ctx.run_ui(egui::RawInput::default(), |ui| { for line in wrap(text, |_| true) { let galley = ui.painter().layout_no_wrap(line.text, egui::FontId::proportional(13.0), egui::Color32::WHITE); - let glyphs = galley.rows.iter().flat_map(|row| row.glyphs.iter()); - shown.push(glyphs.filter(|g| g.advance_width > 0.0).map(|g| g.chr).collect()); + let atlas = ui.fonts_mut(|fonts| fonts.image()); + let mut visual = String::new(); + for row in &galley.rows { + // 0.37 keeps glyphs in logical order for selection. Read their actual + // positions to test visual order instead of assuming vector order. + let mut glyphs: Vec<_> = row.glyphs.iter().filter(|g| g.advance_width > 0.0).collect(); + glyphs.sort_by(|a, b| a.pos.x.total_cmp(&b.pos.x)); + visual.extend(glyphs.into_iter().map(|g| match g.chr { + // `chr` remains logical even when the shaper mirrors the painted + // bracket. Read the bitmap's middle-row ink to check what is visible. + '(' | ')' => { + let [x0, y0] = g.uv_rect.min.map(usize::from); + let [x1, y1] = g.uv_rect.max.map(usize::from); + let y = usize::midpoint(y0, y1); + let ink: Vec = (x0..x1).map(|x| f32::from(atlas[(x, y)].a())).collect(); + let total: f32 = ink.iter().sum(); + assert!(total > 0.0 && !ink.is_empty(), "a bracket must have visible ink"); + let center = ink.iter().enumerate().map(|(i, alpha)| i as f32 * alpha).sum::() / total; + if center < (ink.len() - 1) as f32 / 2.0 { '(' } else { ')' } + } + _ => g.chr, + })); + } + shown.push(visual); } }); output.drop_without_applying_deltas(); @@ -573,6 +649,64 @@ mod tests { assert_eq!(on_screen("واحد (اثنين)."), [format!(".({}) {}", rev("اثنين"), rev("واحد"))]); } + /// A file name keeps its extension at the visual end even when the first strong word is + /// Arabic: with an explicit `LeftToRight` base, `.pdf` is right of every Arabic glyph. + #[test] + fn file_names_are_laid_out_left_to_right() { + if pdfcraft_fonts::ui_arabic_fonts().is_empty() { + return; + } + let ctx = egui::Context::default(); + ctx.set_fonts(crate::theme::font_definitions()); + let mut positions: Vec<(char, f32)> = Vec::new(); + ctx.run_ui(egui::RawInput::default(), |ui| { + let galley = layout_ltr(ui, "واحد اثنين.pdf", egui::FontId::proportional(13.0), egui::Color32::WHITE); + positions = galley.rows.iter().flat_map(|row| row.glyphs.iter().map(|g| (g.chr, g.pos.x))).collect(); + }) + .drop_without_applying_deltas(); + let rightmost_arabic = positions.iter().filter(|(c, _)| is_rtl_script(*c)).map(|(_, x)| *x).fold(f32::MIN, f32::max); + let leftmost_extension = positions.iter().filter(|(c, _)| matches!(*c, '.' | 'p' | 'd' | 'f')).map(|(_, x)| *x).fold(f32::MAX, f32::min); + assert!(rightmost_arabic < leftmost_extension, "the extension must stay to the right: {positions:?}"); + let last = positions.iter().filter(|(c, _)| !c.is_whitespace()).max_by(|a, b| a.1.total_cmp(&b.1)); + assert_eq!(last.map(|(c, _)| *c), Some('f'), "the painted line ends in the extension"); + } + + /// Arabic/Kurdish labels get an explicit right-to-left paragraph, even when their first + /// strong character is Latin. With English the same text stays left to right. + #[test] + fn arabic_ui_labels_use_an_explicit_paragraph_direction() { + if pdfcraft_fonts::ui_arabic_fonts().is_empty() { + return; + } + let previous = crate::i18n::current(); + let ctx = egui::Context::default(); + ctx.set_fonts(crate::theme::font_definitions()); + let read = |lang: &str| { + crate::i18n::set_current(crate::i18n::Lang::from_code(lang).expect("registered language")); + let mut got = None; + ctx.run_ui(egui::RawInput::default(), |ui| { + let galley = layout_ui(ui, "PDF واحد", egui::FontId::proportional(13.0), egui::Color32::WHITE); + let positions: Vec<_> = galley.rows.iter().flat_map(|row| row.glyphs.iter().map(|g| (g.chr, g.pos.x))).collect(); + got = Some((galley.job.direction, positions)); + }) + .drop_without_applying_deltas(); + got.expect("one layout") + }; + let (direction, positions) = read("ar"); + crate::i18n::set_current(previous); + assert_eq!(direction, egui::epaint::text::TextDirection::RightToLeft); + let p = positions.iter().find(|(c, _)| *c == 'P').map(|(_, x)| *x).expect("a Latin P"); + let rightmost_arabic = positions.iter().filter(|(c, _)| is_rtl_script(*c)).map(|(_, x)| *x).fold(f32::MIN, f32::max); + assert!(p > rightmost_arabic, "in an RTL paragraph the first Latin run is at the right: {positions:?}"); + + let (direction, positions) = read("en"); + crate::i18n::set_current(previous); + assert_eq!(direction, egui::epaint::text::TextDirection::Auto); + let p = positions.iter().find(|(c, _)| *c == 'P').map(|(_, x)| *x).expect("a Latin P"); + let leftmost_arabic = positions.iter().filter(|(c, _)| is_rtl_script(*c)).map(|(_, x)| *x).fold(f32::MAX, f32::min); + assert!(p < leftmost_arabic, "with English the Latin run stays at the left: {positions:?}"); + } + #[test] fn single_line_with_its_own_direction() { assert!(matches!(visual_auto("Ada Lovelace"), Cow::Borrowed("Ada Lovelace"))); diff --git a/crates/ui-egui/src/bulk_fields.rs b/crates/ui-egui/src/bulk_fields.rs index 3101724cff..fe401f0574 100644 --- a/crates/ui-egui/src/bulk_fields.rs +++ b/crates/ui-egui/src/bulk_fields.rs @@ -283,7 +283,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut Draft, t: &theme::Tokens) -> (bool egui::Grid::new("bulk-field-properties").num_columns(3).spacing([16.0, 10.0]).show(ui, |ui| match d.tab { Tab::General => { row(ui, "Tooltip:", &mut d.tooltip, |ui, v| { - ui.add(egui::TextEdit::singleline(v).desired_width(280.0)); + ui.add(egui::TextEdit::singleline(v).desired_width(280.0)).accessible_name(tl!("Tooltip")); }); bool_row(ui, "Read Only", &mut d.read_only); bool_row(ui, "Required", &mut d.required); diff --git a/crates/ui-egui/src/calc_order_ui.rs b/crates/ui-egui/src/calc_order_ui.rs index 894fe17929..4cbfa28177 100644 --- a/crates/ui-egui/src/calc_order_ui.rs +++ b/crates/ui-egui/src/calc_order_ui.rs @@ -494,7 +494,7 @@ fn explained(issues: &[CalculationIssue]) -> String { /// The Calculation order page; Back returns to Prepare a form. pub(crate) fn panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { - let (back, close) = crate::panels::panel_header(ui, t, "Calculation order", Some(tl!("Back to Prepare a form"))); + let (back, close) = crate::panels::panel_header(ui, t, "Calculation order", Some(("chevron-left", tl!("Back to Prepare a form")))); if back { app.left = LeftPanel::Tool("form"); } @@ -566,7 +566,7 @@ pub(crate) fn panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { let is_selected = selected.as_deref() == Some(f.name.as_str()); resp.widget_info(|| { let label = if row.value.is_empty() { format!("{}. {}", k + 1, f.name) } else { format!("{}. {}: {}", k + 1, f.name, row.value) }; - egui::WidgetInfo::selected(egui::WidgetType::Button, true, is_selected, label) + egui::WidgetInfo::selected(egui::Role::Button, true, is_selected, label) }); if reveal.as_deref() == Some(f.name.as_str()) { resp.scroll_to_me(Some(egui::Align::Center)); @@ -631,8 +631,9 @@ pub(crate) fn panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { [(up, "chevron-up", true, tl!("Earlier in calculation order")), (down, "chevron-down", false, tl!("Later in calculation order"))] { let to = if earlier { k.checked_sub(1) } else { Some(k + 1).filter(|&to| to < count) }; - let b = ui.interact(r, ui.id().with(("calc-order", k, earlier)), if to.is_some() { Sense::click() } else { Sense::hover() }); - b.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, to.is_some(), format!("{tip}: {}", f.name))); + let b = + ui.interact(r, ui.scope_id().with(("calc-order", k, earlier)), if to.is_some() { Sense::click() } else { Sense::hover() }); + b.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, to.is_some(), format!("{tip}: {}", f.name))); if visible { let bpress = theme::Press::track(ui, &b); bpress.wash(ui, r, 4, false); @@ -724,10 +725,9 @@ pub(crate) fn panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { /// The calculation order on page `page`: the links of the field in focus, with the fields it has /// nothing to do with faded, and the steps of the focus and its linked fields; with nothing in /// focus, the steps of the fields whose place is wrong. A form with many calculations stays -/// readable either way. Returns the field under the pointer. +/// readable either way. Returns the field under the pages' `pointer`. #[allow(clippy::too_many_arguments)] pub(crate) fn paint_page( - ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, page: usize, @@ -736,6 +736,7 @@ pub(crate) fn paint_page( view: &CalcOrderView, generation: u64, selected: Option<&str>, + pointer: Option, ) -> Option { let listed = view.current(generation)?; // Each field's first widget on this page. @@ -749,7 +750,6 @@ pub(crate) fn paint_page( if on_page.is_empty() { return None; } - let pointer = ui.input(|i| i.pointer.hover_pos()); let hovered = pointer.and_then(|p| on_page.iter().rev().find(|(_, r)| r.contains(p))).map(|(n, _)| (*n).to_owned()); let at: HashMap<&str, Rect> = on_page.iter().copied().collect(); let zoom = xf.zoom().clamp(0.5, 3.0); diff --git a/crates/ui-egui/src/camera_ui.rs b/crates/ui-egui/src/camera_ui.rs index 720ac7c6a7..d470378070 100644 --- a/crates/ui-egui/src/camera_ui.rs +++ b/crates/ui-egui/src/camera_ui.rs @@ -5,6 +5,7 @@ use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Shape, Stroke, vec2}; use crate::canvas::{DocView, PageXform, ViewAction}; +use crate::page_pointer::PagePointer; use crate::theme::Tokens; pub use pdfcraft_engine::camera_clean::Look; @@ -58,9 +59,8 @@ fn screen(xf: &PageXform, c: [f32; 2]) -> Pos2 { } /// Corner dragging on one page. A press on a page without the quadrilateral moves it there. -pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, page: usize, view: &mut DocView, allowed: bool) { - let pointer = ui.input(|i| i.pointer.hover_pos()); - let origin = ui.input(|i| i.pointer.press_origin()); +pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, page: usize, view: &mut DocView, allowed: bool) { + let (pointer, origin) = (ptr.pos, ptr.origin); if !allowed { if pointer.is_some_and(|p| xf.rect.contains(p)) { ui.ctx().set_cursor_icon(egui::CursorIcon::NotAllowed); @@ -141,7 +141,7 @@ pub(crate) fn paint(painter: &egui::Painter, xf: &PageXform, page: usize, view: /// way, choose its look (original, colour, greyscale, black and white) and clean-up level, /// Crop pages, Organize pages, or Done. The instructions are a notice (`ocr.enhance_camera`). pub(crate) fn bar(ctx: &egui::Context, view: &mut DocView, area: Rect, t: &Tokens, allowed: bool) { - egui::Area::new(egui::Id::new("camera-bar")) + egui::Area::new(egui::Id::unique("camera-bar")) .order(egui::Order::Middle) .pivot(Align2::RIGHT_TOP) .fixed_pos(area.right_top() + vec2(-18.0, 12.0)) @@ -183,7 +183,7 @@ pub(crate) fn bar(ctx: &egui::Context, view: &mut DocView, area: Rect, t: &Token let icon = |ui: &mut egui::Ui, name: &str, tip: &str| { let r = ui.add_enabled(allowed, egui::Button::image(crate::icons::image(name, 18.0, t.text)).frame(false)); // Named for screen readers (and tests), not only on hover. - r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, allowed, tip)); + r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, allowed, tip)); r.on_hover_text(tip).clicked() }; if icon(ui, "rotate-ccw", tl!("Rotate counterclockwise")) { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a0e80e0aab..4f00aa6bec 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -12,6 +12,7 @@ use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Stroke, TextureHand use pdfcraft_engine::{DocId, Edit}; use pdfcraft_render::{DestView, DocInfo, LayerOp, LinkTarget, PRINT_TAG, PageText, RenderPool, RenderRequest, RequestKind, Tile, device_pixels}; +use crate::page_pointer::PagePointer; use crate::theme::{self, Tokens}; use crate::{PdfCraftApp, QuickTool, RightPanel, comments, icons, widgets}; @@ -233,6 +234,18 @@ pub struct Find { /// (page, glyph range) in document order. pub matches: Vec<(usize, Range)>, pub current: Option, + /// The page a search started from while its match is picked automatically: a text layer + /// that arrives out of order can still supply an earlier match, and the automatic choice + /// follows this page. Cleared when the user picks a match or moves the view themselves. + pub auto_from: Option, + /// The page the automatic choice last put the view on (the search's starting page until it + /// chose anything). Compared with the viewed page, it tells an automatic scroll from one + /// the reader made themselves; `None` with `auto_from` once the choice is the user's. + pub auto_page: Option, + /// The reader ended automatic following (by navigating, scrolling, or picking a match). + /// This is distinct from a search that never followed: a late match must be listed without + /// being revealed by scrolling, even when the reader moved before the first match arrived. + pub follow_ended: bool, pub focus: bool, pub case_query: String, /// Find options (Acrobat's: case-sensitive, whole words only). @@ -242,6 +255,51 @@ pub struct Find { pub in_panel: bool, } +/// What a text layer arriving after a search began may do with the automatic choice. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Follow { + /// The reader has not moved the view since the automatic choice: it may follow `from`. + Active(usize), + /// The reader navigated, scrolled or picked a match, so no automatic choice may reveal. + Ended, + /// No automatic choice is being followed (the search has not started, or the reader chose). + Never, +} + +impl Find { + /// The first match at or after `page`, or the first match when none follows (the search + /// wraps round the document). + fn first_at_or_after(&self, page: usize) -> Option { + self.matches.iter().position(|(p, _)| *p >= page).or_else(|| (!self.matches.is_empty()).then_some(0)) + } + + /// How a text layer arriving now relates to the automatic choice. Once the reader has moved + /// the view (page box, Pages panel, outline, view history or any scrolling), automatic + /// following ends: a later layer must not pull them back to an anchor-derived match. + /// `Ended` is deliberately distinct from `Never`, so a first match arriving after that move + /// is not revealed by a flash meant only for a search that is still following. + fn follow_page(&mut self, viewed: usize) -> Follow { + if self.follow_ended { + return Follow::Ended; + } + match (self.auto_from, self.auto_page) { + (Some(from), Some(page)) if page == viewed => Follow::Active(from), + (Some(_), _) | (_, Some(_)) => { + self.stop_following(); + Follow::Ended + } + (None, None) => Follow::Never, + } + } + + /// Ends automatic following: the selected match is now the user's choice to keep. + fn stop_following(&mut self) { + self.auto_from = None; + self.auto_page = None; + self.follow_ended = true; + } +} + /// A text selection on one page. #[derive(Clone, Copy, Debug, PartialEq)] struct Selection { @@ -729,6 +787,30 @@ impl DocView { self.auto_scroll.panning() } + /// Abandon only pointer previews: clearing egui's held button must not commit an app-owned + /// grab through a tool's `!primary_down` fallback. Keep selections, text drafts and edits + /// that were already completed before cancellation. + pub(crate) fn cancel_pointer_gestures(&mut self) { + self.marquee = None; + self.marquee_done = None; + self.comments.gesture = None; + self.links.drag = None; + self.sign.drag = None; + self.redact_drag = None; + self.crop_drag = None; + self.block_drag = None; + self.org_drag = None; + self.panel_drag = None; + self.selection_drag = false; + self.auto_scroll.cancel(); + self.prepare.cancel_pointer_gesture(); + self.content.cancel_pointer_gesture(); + self.objects.cancel_pointer_gesture(); + if let Some(image) = &mut self.image_selection { + image.cancel_pointer_gesture(); + } + } + /// Pages that could not be rendered, with the reason (for automation; 0-based pages). pub fn page_errors(&self) -> Vec<(usize, &str)> { let mut v: Vec<(usize, &str)> = self.errors.iter().map(|(p, e)| (*p, e.as_str())).collect(); @@ -1034,6 +1116,10 @@ impl DocView { self.last_queue.clear(); self.selection = None; self.all_text = false; + // A content change is not reader navigation: keep the automatic anchor so the + // re-extracted layers still follow it, and keep `follow_ended` so a reader who already + // took over stays in control. `document_changed` clamps `current`; if the view no longer + // sits where the automatic choice left it, `follow_page` ends it. if let Some(f) = self.find.as_mut() { f.matches.clear(); f.current = None; @@ -1224,6 +1310,16 @@ impl DocView { f.case_query = f.query.clone(); f.matches.clear(); f.current = None; + // Refining the query keeps the page the reader started searching from, even when an + // automatic choice has already scrolled them to a match. Only a view the reader moved + // themselves re-anchors the search on the page now shown. + let from = match (f.auto_from, f.auto_page) { + (Some(from), Some(page)) if !f.follow_ended && page == self.current => from, + _ => self.current, + }; + f.auto_from = Some(from); + f.auto_page = Some(self.current); + f.follow_ended = false; if f.case_query.trim().is_empty() { return; } @@ -1235,12 +1331,12 @@ impl DocView { } } f.matches.sort_by_key(|(p, r)| (*p, r.start)); - if !f.matches.is_empty() { - // First match at or after the page being viewed. - let cur = self.current; - let i = f.matches.iter().position(|(p, _)| *p >= cur).unwrap_or(0); + // First match at or after the page the search started from. + if let Some(i) = f.first_at_or_after(from) + && let Some(&(p, _)) = f.matches.get(i) + { f.current = Some(i); - let (p, _) = f.matches[i]; + f.auto_page = Some(p); self.flash_match(p); } } @@ -1250,21 +1346,33 @@ impl DocView { if f.case_query.trim().is_empty() { return; } - let current_key = f.current.and_then(|c| f.matches.get(c).cloned()); + let previous = f.current.and_then(|c| f.matches.get(c).cloned()); + // An automatic choice follows the page it was anchored on, so a page whose text + // arrives late can still supply an earlier match. Once the reader has moved the view, + // this arrival must not pull them back; the choice then becomes theirs to keep. + let follow = f.follow_page(self.current); f.matches.retain(|(p, _)| *p != page); f.matches.extend(text.find_opts(&f.case_query, f.case_sensitive, f.whole_words).into_iter().map(|r| (page, r))); f.matches.sort_by_key(|(p, r)| (*p, r.start)); - f.current = current_key.as_ref().and_then(|k| f.matches.iter().position(|m| m == k)); - if f.current.is_none() && !f.matches.is_empty() { - // First match at or after the page being viewed. - let cur = self.current; - let i = f.matches.iter().position(|(p, _)| *p >= cur).unwrap_or(0); - f.current = Some(i); - let (p, _) = f.matches[i]; - if current_key.is_none() { + if let Follow::Active(from) = follow { + f.current = f.first_at_or_after(from); + let selected = f.current.and_then(|i| f.matches.get(i).cloned()); + // Follow a changed automatic choice; the view is still where the last one left it. + if selected != previous + && let Some((p, _)) = selected + { + f.auto_page = Some(p); self.flash_match(p); } + return; } + // A match the user picked keeps its identity, exactly as before automatic following. + // When it is gone (or none was picked), the first match at or after the viewed page is + // selected for the panel, but the view stays put: `Ended` and `Never` have no automatic + // choice to reveal, and a reader who moved before the first layer arrived must not be + // pulled back by that late match. + let preserved = previous.as_ref().and_then(|k| f.matches.iter().position(|m| m == k)); + f.current = preserved.or_else(|| f.first_at_or_after(self.current)); } /// Go to match `i` (the Search panel's results). @@ -1272,6 +1380,7 @@ impl DocView { let Some(f) = self.find.as_mut() else { return }; let Some(&(p, _)) = f.matches.get(i) else { return }; f.current = Some(i); + f.stop_following(); self.go_to_page(p); self.goto_point = None; self.flash_match(p); @@ -1287,6 +1396,7 @@ impl DocView { let c = f.current.unwrap_or(0); let next = if forward { (c + 1) % n } else { (c + n - 1) % n }; f.current = Some(next); + f.stop_following(); let p = f.matches[next].0; // Only explicit search navigation joins history; background refresh uses flash_match alone. self.go_to_page(p); @@ -1340,8 +1450,18 @@ impl DocView { self.wheel_boost_y.reset(); } + /// Ends automatic search following after the reader navigated or scrolled the view. A + /// programmatic search flash never comes through here; it sets `goto` and `current` directly + /// so that its own scroll cannot be mistaken for the reader's. + fn end_find_following(&mut self) { + if let Some(f) = self.find.as_mut() { + f.stop_following(); + } + } + pub fn go_to_page(&mut self, page: usize) { let page = page.min(self.page_count.saturating_sub(1)); + self.end_find_following(); if page != self.current { // The view history (Previous view / Next view). if self.back.last() != Some(&self.current) { @@ -1470,7 +1590,7 @@ impl DocView { /// Page-editor focus and drafts belong to a document, even while another tab is active. pub(crate) fn page_input_id(&self) -> egui::Id { - egui::Id::new(("page-input", self.id.0)) + egui::Id::unique(("page-input", self.id.0)) } /// The idle page box and unchanged-input lookup share the same label fallback. @@ -1503,6 +1623,11 @@ impl DocView { /// View ▸ Page navigation ▸ Previous view (`false`) / Next view (`true`). pub fn view_history(&mut self, forward: bool) -> bool { + let has_target = if forward { !self.forward.is_empty() } else { !self.back.is_empty() }; + if !has_target { + return false; + } + self.end_find_following(); let (from, to) = if forward { (&mut self.forward, &mut self.back) } else { (&mut self.back, &mut self.forward) }; let Some(page) = from.pop() else { return false }; to.push(self.current); @@ -2126,12 +2251,17 @@ impl DocView { log::warn!("page {} text: {}", r.request.page + 1, r.error.unwrap_or_default()); self.text_failed.insert(r.request.page); if let Some(f) = self.find.as_mut() { - let current_key = f.current.and_then(|c| f.matches.get(c).cloned()); + let previous = f.current.and_then(|c| f.matches.get(c).cloned()); + let follow = f.follow_page(self.current); f.matches.retain(|(p, _)| *p != r.request.page); - f.current = current_key.and_then(|k| f.matches.iter().position(|m| *m == k)); - if f.current.is_none() && !f.matches.is_empty() { - let cur = self.current; - f.current = Some(f.matches.iter().position(|(p, _)| *p >= cur).unwrap_or(0)); + match follow { + // An automatic choice keeps following its anchor; a failure never + // flashes, so the view stays exactly where it was. + Follow::Active(from) => f.current = f.first_at_or_after(from), + Follow::Ended | Follow::Never => { + let preserved = previous.as_ref().and_then(|k| f.matches.iter().position(|m| m == k)); + f.current = preserved.or_else(|| f.first_at_or_after(self.current)); + } } } } @@ -2373,7 +2503,7 @@ impl DocView { /// The hover popup for an annotation. Replies and Fill & Sign marks (typed text, checks, signatures) /// show none: they are page content being edited, not comments. fn annotation_hover_text(a: &pdfcraft_render::Annotation) -> Option { - if a.in_reply_to.is_some() || a.fill_sign { + if a.is_reply() || a.fill_sign { return None; } let who = a.author.clone().unwrap_or_else(|| a.subtype.clone()); @@ -2700,15 +2830,14 @@ const SUPPRESS_SECS: f64 = 0.25; /// Short rolling extension while egui still has an unprocessed wheel tail. const SUPPRESS_TAIL_SECS: f64 = 0.05; -// The viewer moves between docking and the central panel as modes change; keep its scroll -// identity tied to the document instead of either parent UI. +// Keep the viewer's scroll identity tied to the document instead of its parent UI. fn page_scroll( ui: &mut egui::Ui, scroll: egui::ScrollArea, id: egui::Id, draw: impl FnOnce(&mut egui::Ui, egui::Rect) -> R, ) -> egui::scroll_area::ScrollAreaOutput { - ui.scope_builder(egui::UiBuilder::new().id(id), |ui| scroll.show_viewport(ui, draw)).inner + ui.scope_builder(egui::UiBuilder::new().scope_id(id), |ui| scroll.show_viewport(ui, draw)).inner } /// Draws the document tab at `index`: its notice bars, then its pages (or the Organize grid) in a @@ -2717,6 +2846,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); // Keyboard scrolling applies to this frame's page view only, never later. let key_scroll = std::mem::take(&mut app.views[index].key_scroll); + if key_scroll != 0.0 { + app.views[index].end_find_following(); + } // The Search panel closed: its search moves to the find bar. let search_open = app.right == Some(crate::RightPanel::Search); if let Some(f) = app.views[index].find.as_mut() @@ -2791,7 +2923,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { None => {} } // No dialog, close prompt or palette over the page: only then does page input count. - let unobstructed = !app.modal_open() && !app.palette_open; + let modal = app.modal_open(); + let unobstructed = !modal && !app.palette_open; let organize_zoom = app.organize_zoom; let view = &mut app.views[index]; if view.organize { @@ -2835,11 +2968,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // Full screen: a click on the document goes forward a page and a shift-click back, as in // Acrobat, unless the click did something else (a link, a word selected). Decided below, // once the pages have had the click. - let present_click = view.presentation.is_some() - && navigation_enabled - && ui.rect_contains_pointer(navigation_rect) - && ui.input(|i| i.pointer.primary_clicked()) - && app.quick_tool == QuickTool::Select; + let present_click = + view.presentation.is_some() && navigation_enabled && presentation_click(ui, navigation_rect) && app.quick_tool == QuickTool::Select; // Shift as held now, or as the click's own event carried it (a synthetic click). let present_back = present_click && ui.input(|i| { @@ -2850,7 +2980,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { }); if zoom_delta.is_finite() && zoom_delta > 0.0 && zoom_delta != 1.0 && navigation_enabled && ui.rect_contains_pointer(navigation_rect) { let z = view.zoom * zoom_delta; - match ui.input(|i| i.pointer.hover_pos()) { + match zoom_anchor(ui) { Some(p) => view.zoom_at(z, p), None => view.set_zoom(z), } @@ -2866,7 +2996,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // Drawing a comment: the pointer in a band along any edge (or just past it) scrolls the view // that way, so a point beyond what is shown can be reached without letting go of the tool. let edge_delta = if unobstructed && matches!(view.comments.gesture, Some(comments::Gesture::Draw { .. })) { - let (pointer, dt) = ui.input(|i| (if i.pointer.any_down() { i.pointer.latest_pos() } else { i.pointer.hover_pos() }, i.stable_dt)); + let (pointer, dt) = drawing_edge_pointer(ui); crate::autoscroll::edge_scroll(pointer, avail, dt) } else { Vec2::ZERO @@ -2915,7 +3045,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // even while the page can't turn. ui.input(|i| { for e in &i.events { - if let egui::Event::MouseWheel { unit, delta, phase, modifiers } = e { + if let egui::Event::MouseWheel { unit, delta, phase, modifiers, .. } = e { // Zooming (⌘ or Ctrl) and sideways scrolling (Shift) never turn pages. let dy = if modifiers.command || modifiers.ctrl || modifiers.shift { 0.0 } else { delta.y }; if fits { @@ -2966,8 +3096,14 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { line_base.y = 0.0; boost_extra.y = 0.0; } - if now < view.precise_scroll_until { - if ui.input(|i| i.is_scrolling()) { + // egui smooths a Point event that moved at least 8 points, keeping part of it for later + // passes. Suppression must outlive that whole action: once it expired after a fixed 0.25 s, + // the last few eased points leaked through the ScrollArea after a precise 1:1 scroll (held + // point input measured 33 points for a 30-point event). Keep hiding the eased copy until + // egui says the action is over, then forget the suppression. + let scrolling = ui.input(|i| i.is_scrolling()); + if now < view.precise_scroll_until || (view.precise_scroll_until != 0.0 && scrolling) { + if scrolling { // Keep hiding egui's unprocessed copy until it has drained. The short rolling grace // avoids a delayed duplicate without prolonging suppression after the tail is gone. view.precise_scroll_until = view.precise_scroll_until.max(now + SUPPRESS_TAIL_SECS); @@ -2989,10 +3125,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } } else { precise_scroll = Vec2::ZERO; - if line_base != Vec2::ZERO || has_page { - // Selection handling below uses zero to distinguish an inactive window. - view.precise_scroll_until = 0.0; - } + // Selection handling below uses zero to distinguish an inactive window. Clearing a past + // deadline also keeps a later line/page action from being mistaken for the old tail. + view.precise_scroll_until = 0.0; } if has_page && paging_owns_vertical { // Page input belongs to egui on the free axis; the pager still owns vertical motion. @@ -3006,7 +3141,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let may_glide = !paging_owns_vertical && !middle_gesture && navigation_enabled && unobstructed && animates; let over_page = ui.rect_contains_pointer(avail); ui.input(|i| view.fling.read(&i.events, now, may_glide && over_page)); - let interrupted = ui.input(|i| i.pointer.any_pressed() || i.events.iter().any(|e| matches!(e, egui::Event::Key { pressed: true, .. }))); + let interrupted = glide_interrupted(ui); if !may_glide || interrupted || key_scroll != 0.0 || auto_delta != Vec2::ZERO || zoom_delta != 1.0 { view.fling.stop(); } @@ -3014,6 +3149,14 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { if view.fling.gliding() { ui.ctx().request_repaint(); } + // Any reader-owned motion ends automatic search following, even when it stays on the same + // page: the wheel, keyboard, middle-button auto-scroll, a drawing's edge scroll, or the + // momentum left by a touchpad swipe. The automatic flash uses `goto`, not these deltas, so + // its programmatic scroll is never mistaken for a reader action. + let wheel_scrolled = precise_scroll != Vec2::ZERO || line_base != Vec2::ZERO || boost_extra != Vec2::ZERO || has_page; + if wheel_scrolled || auto_delta != Vec2::ZERO || edge_delta != Vec2::ZERO || glide != Vec2::ZERO { + view.end_find_following(); + } // The wheel may have turned the page above: draw the row it turned to. Measured from the old // row, the jump to the new page scrolled both out of view, a blank frame on every turn forward. let row = shown_row(view); @@ -3063,6 +3206,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } else { ui.input(|i| i.pointer.delta()) }; + if moved != Vec2::ZERO { + view.end_find_following(); + } // Within the content, as egui keeps it: past an end the page would show there for a // frame and spring back. `max` is never NaN or below zero, so the clamp can't panic. let max = vec2((content_w - avail.width()).max(0.0), (content_h - avail.height()).max(0.0)); @@ -3164,15 +3310,6 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let mut open_signature = false; let mut hover_text: Option<(Pos2, String)> = None; let mut badge_hover: Option<(Pos2, String)> = None; - // A dialog, save prompt or palette covers the page. Hover tips are drawn above those - // windows, so a comment under the pointer would otherwise float over the dialog. - let page_covered = app.dialog.is_some() - || app.close_request.is_some() - || app.palette_open - || app.password_prompt.is_some() - || app.pending_link.is_some() - || app.pending_remote_link.is_some() - || app.updates.open; let mut clicked_link: Option = None; let mut remote_open: Option<(pdfcraft_engine::DocId, String, pdfcraft_render::RemoteDestination)> = None; let mut canvas_action: Option = None; @@ -3214,7 +3351,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } } let selection_wheel = view.selection_drag && navigation_enabled && unobstructed && !single_paging && ui.rect_contains_pointer(avail); - let out = page_scroll(ui, scroll, egui::Id::new(("page-view-scope", view.id)), |ui, viewport| { + let out = page_scroll(ui, scroll, egui::Id::unique(("page-view-scope", view.id)), |ui, viewport| { // egui's drag responses accept every pointer button. Keep a wheel gesture from also // selecting text, drawing a mark, or panning with the Hand tool. if middle_gesture { @@ -3261,11 +3398,14 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let mut best_overlap = -1.0f32; let mut current_overlap = -1.0f32; let mut current_height = f32::INFINITY; - let pointer = ui.input(|i| i.pointer.hover_pos()); + // The pointer as the pages see it: nothing over them (a dialog from the frame it opens), + // or a press they hold. The page modules read it rather than egui's raw pointer state. + let ptr = PagePointer::read(ui, &resp, modal); + let pointer = ptr.pos; // A press on a page makes it the current one (as in Acrobat), so a page command with // no selection (Rotate, Delete) acts on the page the reader pointed at, not on a page // gone to earlier that still shows whole beside it. - let pressed = unobstructed && ui.input(|i| i.pointer.primary_pressed()) && ui.rect_contains_pointer(avail); + let pressed = unobstructed && ptr.pressed().is_some(); let mut pressed_page = None; if resp.secondary_clicked() { // Set again below when the right-click lands on a link. @@ -3420,15 +3560,14 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } view.text_background.paint(painter, &xf, i, view.line_editor.as_ref(), view.content.draft.as_ref()); if editing_content { - let page_tex = view.pages.get(&i).map(|p| p.tex.id()); - view.move_background.paint(ui, painter, &xf, i, info, &view.content, page_tex); + view.move_background.paint(painter, &xf, i, info, &view.content, pointer); } if view.show_page_gaps { painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(0.5, t.border.gamma_multiply(0.8)), egui::StrokeKind::Outside); } // Comments: tools, selection, moving and resizing come before text selection. - let pcx = comments::PageCx { page: i, xf: &xf, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; + let pcx = comments::PageCx { page: i, xf: &xf, ptr: &ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; if let QuickTool::Measure(measure_tool) = tool { crate::measure_ui::page_input(ui, &resp, doc, view, &pcx, measure_tool, units); } @@ -3437,7 +3576,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // every viewing mode); then comments; then text selection. if let QuickTool::Stamp(kind) = tool && allowed - && let Some(p) = ui.input(|inp| inp.pointer.hover_pos()).filter(|p| xf.rect.contains(*p)) + && let Some(p) = ptr.within(xf.rect) { ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); if resp.clicked() { @@ -3461,7 +3600,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } if let Some(cs) = custom_stamp.as_ref() && allowed - && let Some(p) = ui.input(|inp| inp.pointer.hover_pos()).filter(|p| xf.rect.contains(*p)) + && let Some(p) = ptr.within(xf.rect) { ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); if resp.clicked() { @@ -3482,12 +3621,13 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // Over a mark already placed, the Fill & Sign tool picks it up (comments, below). if let QuickTool::Fill(ft) = tool && allowed - && !comments::fill_grabs(ui, &pcx, view) + && !comments::fill_grabs(&pcx, view) { match crate::fill_sign::page_input( ui, &resp, &xf, + &ptr, i, info, ft, @@ -3511,15 +3651,15 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } } if matches!(tool, QuickTool::SignArea { .. }) { - crate::sign_ui::page_input(ui, &resp, &xf, i, info, view); + crate::sign_ui::page_input(ui, &resp, &xf, &ptr, i, info, view); } if matches!(tool, QuickTool::MarqueeZoom | QuickTool::Snapshot) { - crate::zoom_snap::page_input(ui, &resp, &xf, i, view); + crate::zoom_snap::page_input(ui, &resp, &xf, &ptr, i, view); } if tool == QuickTool::EnhanceCamera { - crate::camera_ui::page_input(ui, &resp, &xf, i, view, can_modify); + crate::camera_ui::page_input(ui, &resp, &xf, &ptr, i, view, can_modify); } - if tool == QuickTool::Crop && crate::crop::page_input(ui, &resp, &xf, i, info, view, can_crop) { + if tool == QuickTool::Crop && crate::crop::page_input(ui, &resp, &xf, &ptr, i, info, view, can_crop) { view.current = i; open_boxes = true; } @@ -3531,7 +3671,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { }; // While a table is being set up, nothing else is drawn or moved. let allowed = can_modify && table_preview.is_none() && open_table.is_none(); - let o = crate::prepare::page_input(ui, &resp, &xf, i, info, &form, field_tool, allowed, view); + let o = crate::prepare::page_input(ui, &resp, &xf, &ptr, i, info, &form, field_tool, allowed, view); field_props |= o.properties; field_placed |= o.placed; open_table = open_table.or(o.table); @@ -3539,10 +3679,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } else { use crate::forms_ui::FieldPointer; let field = if tool == QuickTool::Select - && !(editing_content && can_modify && crate::content_ui::select_grabs(ui, &xf, i, info, &added, view)) - && !comments::select_grabs(ui, &pcx, view) + && !(editing_content && can_modify && crate::content_ui::select_grabs(&xf, &ptr, i, info, &added, view)) + && !comments::select_grabs(&pcx, view) { - crate::forms_ui::page_input(ui, &resp, &xf, i, info, &form, can_fill, view) + crate::forms_ui::page_input(ui, &resp, &xf, &ptr, i, info, &form, can_fill, view) } else { FieldPointer::Away }; @@ -3556,11 +3696,11 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let (vx, vy) = xf.screen_to_view(p); text.as_ref().is_some_and(|t| t.glyphs.iter().any(|g| vx >= g.rect[0] && vx <= g.rect[2] && vy >= g.rect[1] && vy <= g.rect[3])) }; - crate::redact_ui::page_input(ui, &resp, &xf, i, info, over_text, view) + crate::redact_ui::page_input(ui, &resp, &xf, &ptr, i, info, over_text, view) }; - let image_owns_pointer = tool == QuickTool::EditText && crate::edit_text_ui::image_owns_pointer(ui, &xf, i, info, view); + let image_owns_pointer = tool == QuickTool::EditText && crate::edit_text_ui::image_owns_pointer(ui, &xf, &ptr, i, info, view); let on_objects = - !image_owns_pointer && tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(ui, &resp, &xf, i, doc, view); + !image_owns_pointer && tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(ui, &resp, &xf, &ptr, i, doc, view); let lines = if matches!(tool, QuickTool::AddText | QuickTool::EditText) && can_modify { let generation = doc.edit_generation(); match view.edit_lines.get(&i) { @@ -3592,6 +3732,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { ui, &resp, &xf, + &ptr, i, info, &added, @@ -3620,11 +3761,13 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { }; // Editable text takes precedence over background/overlapping artwork. Both // inventories are painted every frame, even when text owns the pointer. - let text_used = crate::edit_text_ui::page_input(ui, &resp, &xf, i, doc, &lines, view, !others_own_pointer && !image_owns_pointer); + let text_used = + crate::edit_text_ui::page_input(ui, &resp, &xf, &ptr, i, doc, &lines, view, !others_own_pointer && !image_owns_pointer); let image_used = crate::edit_text_ui::image_input( ui, &resp, &xf, + &ptr, i, info, &images, @@ -3634,7 +3777,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { ); !others_own_pointer && (text_used || image_used) }; - let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(ui, &resp, &xf, i, info, &doc_links, view); + let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(ui, &resp, &xf, &ptr, i, info, &doc_links, view); if tool == QuickTool::Select && resp.secondary_clicked() && can_modify @@ -3712,23 +3855,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } else if over_text && cursor_free { ui.ctx().set_cursor_icon(egui::CursorIcon::Text); } - let press_here = ui - .input(|inp| { - inp.pointer.press_origin().or_else(|| { - // A press and release can arrive in one pass; egui has already - // cleared press_origin, but the press still starts multi-click selection. - inp.events.iter().find_map(|event| match event { - egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, .. } - if pos.is_finite() => - { - Some(*pos) - } - _ => None, - }) - }) - }) - .is_some_and(|o| r.contains(o)); - let pressed = ui.input(|inp| inp.pointer.primary_pressed()); + // The pages' press, kept when its release came in the same frame (#2100): it + // still starts multi-click selection. + let press_here = ptr.origin.is_some_and(|o| r.contains(o)); + let pressed = ptr.pressed().is_some(); if pressed && press_here && !over_link { let now = ui.input(|inp| inp.time); // The same limits egui uses to count clicks on release. @@ -3753,7 +3883,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // re-drag over text whose bar Copy closed would keep the bar hidden. view.actions_dismissed = None; view.selection_drag = true; - let origin = ui.input(|inp| inp.pointer.press_origin()).unwrap_or(p); + let origin = ptr.origin.unwrap_or(p); let (ox, oy) = xf.screen_to_view(origin); if view.clicks < 2 { view.selection = if column { @@ -3843,11 +3973,11 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { crate::redact_ui::after_text(&resp, i, info, view); } if area_tool { - crate::redact_ui::paint(ui, painter, i, view); + crate::redact_ui::paint(painter, i, view, pointer); } comments::paint_page(ui, painter, &pcx, view); if editing_content { - crate::content_ui::paint_page(ui, painter, &xf, i, info, &added, view); + crate::content_ui::paint_page(painter, &xf, i, info, &added, view, pointer); } if tool == QuickTool::EditText { crate::object_ui::paint(painter, &xf, i, info, &view.objects); @@ -3856,38 +3986,39 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { crate::camera_ui::paint(painter, &xf, i, view); } if tool == QuickTool::Link { - crate::link_ui::paint(ui, painter, &xf, i, info, &doc_links, view); + crate::link_ui::paint(painter, &xf, i, info, &doc_links, view, pointer); } if preparing { - crate::prepare::paint_page(ui, painter, &xf, i, info, &form, view); + crate::prepare::paint_page(ui, painter, &xf, i, info, &form, view, pointer); if let Some(preview) = &table_preview { crate::field_table_ui::paint_preview(painter, &xf, i, preview); } if calc_order { let selected = view.prepare.selected.as_ref().map(|(name, _)| name.as_str()); - if let Some(name) = crate::calc_order_ui::paint_page(ui, painter, &xf, i, info, &form, &view.calc_order, generation, selected) { + if let Some(name) = + crate::calc_order_ui::paint_page(painter, &xf, i, info, &form, &view.calc_order, generation, selected, pointer) + { calc_hover = Some(name); } } } else { - crate::forms_ui::paint_page(ui, painter, &xf, i, info, &form, view); + crate::forms_ui::paint_page(painter, &xf, i, info, &form, view, pointer); } // Form-field highlight (Acrobat's "Highlight existing fields"); required fields get a - // red border. Radio buttons are round, and so is theirs (as in Acrobat). Only what can - // be filled is marked: a read-only widget (its field's, or a radio button's own) is - // left as it is drawn. + // red border. Radio buttons and round XFA check boxes are round, and so is theirs (as + // in Acrobat). Only what can be filled is marked: a read-only widget (its field's, or a + // radio button's own) is left as it is drawn. if view.highlight_fields { for f in form.iter() { let required = f.has(pdfcraft_engine::field_flags::REQUIRED); - let round = f.kind == pdfcraft_engine::FormFieldKind::Radio; for w in f.widgets.iter().filter(|w| w.page == Some(i) && !w.hidden && !w.read_only) { let r = w.rect; let sr = xf.user_rect(info, i, [r[0] as f32, r[1] as f32, r[2] as f32, r[3] as f32]); let tint = Color32::from_rgba_unmultiplied(0x6E, 0x8E, 0xF5, 48); let (width, color) = if required { (2.0, Color32::from_rgb(0xE3, 0x22, 0x22)) } else { (1.0, Color32::from_rgb(0x6E, 0x8E, 0xF5)) }; - if round { + if w.round { let radius = sr.width().min(sr.height()) / 2.0; painter.circle_filled(sr.center(), radius, tint); painter.circle_stroke(sr.center(), radius - width / 2.0, Stroke::new(width, color)); @@ -3898,9 +4029,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } } } - // Fill & Sign keeps its placement cursor clear of link/comment hover feedback. - if !page_covered - && !matches!(tool, QuickTool::Fill(_)) + // Fill & Sign keeps its placement cursor clear of link/comment hover feedback. Tips + // draw above the palette, so none shows while it is open, even beside it. + if !matches!(tool, QuickTool::Fill(_)) + && unobstructed && let Some(p) = pointer { for l in info.links.iter().filter(|l| l.page == i) { @@ -4022,10 +4154,11 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { && navigation_enabled && unobstructed && !single_paging - && let Some(p) = ui.input(|i| i.pointer.latest_pos()) + && let Some(p) = ptr.pos { let dy = crate::autoscroll::edge_delta(p.y, avail, ui.input(|i| i.stable_dt)); if dy != 0.0 { + view.end_find_following(); ui.scroll_with_delta_animation(vec2(0.0, dy), egui::style::ScrollAnimation::none()); // Keep scrolling while the pointer rests there and sends no events. ui.ctx().request_repaint(); @@ -4129,7 +4262,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { && let Some((page, at)) = view.comments.context_at { // The page's content is parsed once per right-click, not on every frame the menu is open. - let key = egui::Id::new("copy-image-under-menu"); + let key = egui::Id::unique("copy-image-under-menu"); let cached = ui.data(|d| d.get_temp::<(usize, [f64; 2], Option)>(key)).filter(|c| c.0 == page && c.1 == at); let hit = match cached { Some(c) => c.2, @@ -4160,7 +4293,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // below the window's top). Within a point, as for wheel paging, so layout rounding // can't hide an edge. let max_y = (out.content_size.y - out.inner_rect.height()).max(0.0); - (page, out.state.offset.y <= MARGIN - view.page_gap() + 1.0, out.state.offset.y >= max_y - 1.0) + (page, out.state.clamped_offset().y <= MARGIN - view.page_gap() + 1.0, out.state.clamped_offset().y >= max_y - 1.0) }); view.viewport_screen = out.inner_rect; @@ -4175,7 +4308,20 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // backdrop/older tiles instead of queueing a storm of interprets (one per frame) that // the workers cannot cancel mid-operator. Fresh tiles go out once the pan settles; // pages with nothing on screen yet still queue. - let scroll_offset = out.state.offset; + let scroll_offset = out.state.clamped_offset(); + // The document ScrollArea's own scroll surfaces move the offset without the wheel deltas + // above: its two scroll-bar tracks/handles and, for touch or the Hand tool, its drag + // background. The output gives us the area id, and egui's pinned ScrollArea registers those + // widgets under `id.with(0)` (horizontal), `id.with(1)` (vertical) and `id.with("area")`. + // Ask for their exact responses instead of treating any held drag as scrolling: a text + // selection, a panel splitter or another widget's drag must not excuse the programmatic jump + // a search flash makes, while a track click (which moves the offset with no pointer motion) + // must still count as the reader scrolling. + let scroll_surface = |id: egui::Id| ui.ctx().read_response(id).is_some_and(|r| r.is_pointer_button_down_on() || r.dragged() || r.clicked()); + let pointer_scrolling = scroll_surface(out.id.with(0usize)) || scroll_surface(out.id.with(1usize)) || scroll_surface(out.id.with("area")); + if pointer_scrolling && (scroll_offset - view.last_scroll).length() > 2.0 { + view.end_find_following(); + } let now_settle = ui.input(|i| i.time); if (scroll_offset - view.last_scroll).length() > 2.0 { view.pan_hold_until = now_settle + 0.25; @@ -4307,7 +4453,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { view.frame_visible = visible_now.iter().copied().collect(); if let Some((pos, text)) = hover_text { - egui::Area::new(egui::Id::new("canvas-hover")).order(egui::Order::Tooltip).fixed_pos(pos + vec2(14.0, 16.0)).show(ui.ctx(), |ui| { + // Click-through, as a tooltip is: the pages lose the pointer under anything that isn't. + let tip = egui::Area::new(egui::Id::unique("canvas-hover")).order(egui::Order::Tooltip).interactable(false); + tip.fixed_pos(pos + vec2(14.0, 16.0)).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_max_width(320.0); ui.label(text); @@ -4580,26 +4728,29 @@ fn selection_actions( let x = rect.left().clamp(viewport.left() + 8.0, (viewport.right() - 520.0).max(viewport.left() + 8.0)); let y = if rect.bottom() + 48.0 < viewport.bottom() { rect.bottom() + 6.0 } else { (rect.top() - 44.0).max(viewport.top() + 6.0) }; let mut chosen = None; - egui::Area::new(egui::Id::new(("text-selection-actions", view.id.0))).order(egui::Order::Foreground).fixed_pos(pos2(x, y)).show(ui.ctx(), |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { - ui.horizontal(|ui| { - for (label, action) in [ - (tl!("Add comment"), SelectionAction::Note), - (tl!("Highlight"), SelectionAction::Markup(comments::CommentTool::Highlight)), - (tl!("Underline"), SelectionAction::Markup(comments::CommentTool::Underline)), - (tl!("Strikethrough"), SelectionAction::Markup(comments::CommentTool::StrikeOut)), - (tl!("Replace text"), SelectionAction::Replace), - ] { - if ui.add_enabled(can_comment, egui::Button::new(label)).clicked() { - chosen = Some(action); + egui::Area::new(egui::Id::unique(("text-selection-actions", view.id.0))).order(egui::Order::Foreground).fixed_pos(pos2(x, y)).show( + ui.ctx(), + |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.horizontal(|ui| { + for (label, action) in [ + (tl!("Add comment"), SelectionAction::Note), + (tl!("Highlight"), SelectionAction::Markup(comments::CommentTool::Highlight)), + (tl!("Underline"), SelectionAction::Markup(comments::CommentTool::Underline)), + (tl!("Strikethrough"), SelectionAction::Markup(comments::CommentTool::StrikeOut)), + (tl!("Replace text"), SelectionAction::Replace), + ] { + if ui.add_enabled(can_comment, egui::Button::new(label)).clicked() { + chosen = Some(action); + } } - } - if ui.add_enabled(can_copy, egui::Button::new(tl!("Copy"))).clicked() { - chosen = Some(SelectionAction::Copy); - } + if ui.add_enabled(can_copy, egui::Button::new(tl!("Copy"))).clicked() { + chosen = Some(SelectionAction::Copy); + } + }); }); - }); - }); + }, + ); let Some(action) = chosen else { return false }; if let SelectionAction::Copy = action { // Copying closes the bar but keeps the words selected, for ⌘C or a second action. @@ -4729,6 +4880,31 @@ fn set_layers(app: &mut PdfCraftApp, index: usize, changes: &[(LayerOp, (u32, u1 } } +/// Where a pinch or ⌘-scroll zooms the view about: the pointer, wherever it is over the view. Not +/// page input: it is read before the pages' widget exists, once `rect_contains_pointer` has found +/// the view under the pointer. +fn zoom_anchor(ui: &egui::Ui) -> Option { + ui.input(|i| i.pointer.hover_pos()) +} + +/// The pointer and frame time that scroll the view while a comment is drawn. Not page input: it is +/// read before the pages' widget exists, and the pointer may be past the view's edge. +fn drawing_edge_pointer(ui: &egui::Ui) -> (Option, f32) { + ui.input(|i| (i.pointer.latest_pos(), i.stable_dt)) +} + +/// A press anywhere, or a key, stops the touchpad's glide (#531). +fn glide_interrupted(ui: &egui::Ui) -> bool { + ui.input(|i| i.pointer.any_pressed() || i.events.iter().any(|e| matches!(e, egui::Event::Key { pressed: true, .. }))) +} + +/// Full screen's click on the document, which turns the page: a primary click this frame with the +/// view on top under it in `area`. Not page input: it is read before the pages' widget exists, and +/// the turn waits until the pages have had the click. +fn presentation_click(ui: &egui::Ui, area: Rect) -> bool { + ui.rect_contains_pointer(area) && ui.input(|i| i.pointer.primary_clicked()) +} + /// Whether `p` lies in the bottom-left quadrant of `area`, where the page size shows (#1855). fn in_size_hint_zone(area: Rect, p: Pos2) -> bool { p.x >= area.min.x && p.x < area.center().x && p.y > area.center().y && p.y <= area.max.y @@ -4743,7 +4919,7 @@ fn page_size_hint(view: &DocView, info: &DocInfo, units: crate::units::Unit, are return; } let text = units.size(f64::from(page.width), f64::from(page.height)); - egui::Area::new(egui::Id::new("page-size-hint")) + egui::Area::new(egui::Id::unique("page-size-hint")) .order(egui::Order::Foreground) .interactable(false) .pivot(egui::Align2::LEFT_BOTTOM) @@ -4770,7 +4946,7 @@ fn find_bar(view: &mut DocView, pages: usize, area: Rect, ui: &mut egui::Ui, t: let mut close = false; let mut step: Option = None; let searched = view.texts.len() + view.text_failed.len(); - egui::Area::new(egui::Id::new("find-bar")) + egui::Area::new(egui::Id::unique("find-bar")) .order(egui::Order::Middle) .pivot(Align2::RIGHT_TOP) .fixed_pos(area.right_top() + vec2(-18.0, 12.0)) @@ -4785,11 +4961,11 @@ fn find_bar(view: &mut DocView, pages: usize, area: Rect, ui: &mut egui::Ui, t: ui.horizontal(|ui| { ui.add(icons::image("search", 16.0, t.text_muted)); let edit = egui::TextEdit::singleline(&mut find.query) - .id(egui::Id::new("find-input")) + .id(egui::Id::unique("find-input")) .hint_text(tl!("Find text")) .desired_width(220.0) .frame(egui::Frame::NONE); - let r = ui.add(edit); + let r = ui.add(edit).accessible_name(tl!("Find text")); if find.focus { r.request_focus(); find.focus = false; @@ -5100,7 +5276,7 @@ fn notice_bar(ui: &mut egui::Ui, t: &Tokens, icon: &str, icon_color: Color32, te fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let pos = area.left_top() + vec2(14.0, 14.0); - egui::Area::new(egui::Id::new("quick-bar")).order(egui::Order::Middle).fixed_pos(pos).show(ui.ctx(), |ui| { + egui::Area::new(egui::Id::unique("quick-bar")).order(egui::Order::Middle).fixed_pos(pos).show(ui.ctx(), |ui| { egui::Frame::NONE .fill(t.card) .stroke(Stroke::new(1.0, t.border)) @@ -5162,7 +5338,7 @@ fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) { } let click = theme::hand(click); let info = tl!(tool.label()).to_string(); - click.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, info.clone())); + click.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, info.clone())); if click.clicked() { app.execute(tool.command()); ui.close(); @@ -5241,7 +5417,7 @@ fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) { } let click = theme::hand(click); let info = tl!(tool.label()).to_string(); - click.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, on, info.clone())); + click.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, info.clone())); if click.clicked() { app.execute(tool.command()); ui.close(); @@ -5291,7 +5467,7 @@ fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) { .info .annotations .iter() - .any(|a| a.page == page && a.index == annotation && a.in_reply_to.is_none() && comments::made_by(a, tool)) + .any(|a| a.page == page && a.index == annotation && !a.is_reply() && comments::made_by(a, tool)) }) { app.apply_edit(Edit::StyleAnnotation { @@ -5375,7 +5551,7 @@ fn organize_toolbar(view: &mut DocView, info: &DocInfo, editable: bool, dirty: b let percent = egui::Button::new(egui::RichText::new(format!("{:.0}%", zoom * 100.0)).font(theme::medium(12.0)).color(t.text_muted)) .frame(false); let percent = ui.add_sized([44.0, 30.0], percent); - percent.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Reset page size"))); + percent.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Reset page size"))); if percent.on_hover_text(tl!("Reset page size")).clicked() { view.grid_zoom_request = Some(1.0); } @@ -5438,8 +5614,8 @@ fn copy_modifier(ctx: &egui::Context, m: &egui::Modifiers) -> bool { /// A "+" on a gap of the page grid: insert files there. Returns whether it was clicked. fn gap_button(ui: &mut egui::Ui, gap: usize, at: Pos2, height: f32, label: String, t: &Tokens) -> bool { - let resp = ui.interact(Rect::from_center_size(at, Vec2::splat(22.0)), ui.id().with(("org-gap", gap)), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label.clone())); + let resp = ui.interact(Rect::from_center_size(at, Vec2::splat(22.0)), ui.scope_id().with(("org-gap", gap)), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label.clone())); let hot = resp.hovered(); if hot { ui.painter().line_segment([pos2(at.x, at.y - height / 2.0), pos2(at.x, at.y + height / 2.0)], Stroke::new(3.0, t.accent)); @@ -5503,7 +5679,7 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool let Some(p) = info.pages.get(i) else { break }; let c = Rect::from_min_size(pos2(row_rect.left() + left + col as f32 * cell.x, row_rect.top()), cell); view.need_thumbnail(i, c.intersects(ui.clip_rect())); - let resp = ui.interact(c, ui.id().with(("org", i)), if editable { Sense::click_and_drag() } else { Sense::click() }); + let resp = ui.interact(c, ui.scope_id().with(("org", i)), if editable { Sense::click_and_drag() } else { Sense::click() }); cells.push((i, c)); if let Some((_, above)) = anchor.filter(|(page, _)| *page == i) { ui.scroll_to_rect_animation(c.translate(vec2(0.0, -above)), Some(egui::Align::Min), egui::style::ScrollAnimation::none()); @@ -5522,7 +5698,7 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool drop = true; } let page_label = crate::i18n::fmt(tl!("Page {label}"), &[("label", &p.label)]); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, view.selected.contains(&i), page_label.clone())); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, view.selected.contains(&i), page_label.clone())); let pr = grid_page_rect(c, vec2(p.width, p.height)); let size = pr.size(); let selected = view.selected.contains(&i) || (view.selected.is_empty() && i == view.current); @@ -5575,7 +5751,7 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool let check_rect = Rect::from_min_size(c.min + vec2(8.0, 8.0), Vec2::splat(20.0)); let check = ui.push_id(("org-check", i), |ui| ui.put(check_rect, widgets::checkbox_without_text(&mut checked))).inner; let label = crate::i18n::fmt(tl!("Select page {label}"), &[("label", &p.label)]); - check.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, checked, label.clone())); + check.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, checked, label.clone())); if check.on_hover_text(label).changed() { view.click_page(i, egui::Modifiers::COMMAND, false); // The document shows the page picked here when the grid closes, as after a @@ -5877,6 +6053,8 @@ mod tests { modified: None, name: None, in_reply_to: None, + parent: None, + grouped: false, rect: [0.0; 4], color: None, index: 0, @@ -6135,14 +6313,445 @@ trailer << /Root 1 0 R >> assert_eq!(v.goto, Some((3, 0.5))); assert!(v.back.is_empty()); assert_eq!(v.forward, vec![4]); - v.current = 0; v.find.as_mut().unwrap().current = None; - v.refresh_find_page(3); - assert_eq!(v.current, 3); - assert!(v.back.is_empty()); + v.texts.insert(1, Arc::new(find_test_text("match"))); + v.refresh_find_page(1); + assert_eq!(v.current, 1, "an automatic late match still follows its anchor"); + assert!(v.back.is_empty(), "automatic search updates do not record history"); assert_eq!(v.forward, vec![4]); } + /// One glyph per character on one line, for the search-order tests below. + fn find_test_text(s: &str) -> PageText { + PageText { + glyphs: s + .chars() + .map(|c| pdfcraft_render::TextGlyph { text: c.into(), rect: [0.0, 0.0, 10.0, 10.0], quad: [[0.0, 0.0]; 4], direction: [1.0, 0.0] }) + .collect(), + line_of: s.chars().map(|_| 0).collect(), + space_before: s.chars().map(|_| false).collect(), + } + } + + /// A text-layer result for `page`, for the search tests below. + fn find_text_result(page: usize, text: &str) -> pdfcraft_render::RenderedPage { + pdfcraft_render::RenderedPage { + request: RenderRequest { page, kind: RequestKind::Text, tag: TEXT_TAG, ..Default::default() }, + width: 0, + height: 0, + rgba: Default::default(), + error: None, + text: Some(Arc::new(find_test_text(text))), + warnings: Vec::new(), + millis: 0, + } + } + + /// Text layers arrive in whatever order the workers finish, not in page order. A search + /// started on page 1 must settle on page 1's match even when page 2's text arrives first, + /// instead of keeping the later match picked only because page 1 had not been read yet. + #[test] + fn automatic_search_match_follows_the_viewed_page_when_text_arrives_out_of_order() { + let mut v = view(2, PageLayout::Single, true); + v.current = 0; + v.find = Some(Find { query: "sample".into(), ..Default::default() }); + v.rerun_find(); + assert_eq!(v.find.as_ref().unwrap().current, None, "no text yet, so no match"); + + let ctx = egui::Context::default(); + let request = |page| RenderRequest { page, kind: RequestKind::Text, tag: TEXT_TAG, ..Default::default() }; + let result = |page, text: &str| pdfcraft_render::RenderedPage { + request: request(page), + width: 0, + height: 0, + rgba: Default::default(), + error: None, + text: Some(Arc::new(find_test_text(text))), + warnings: Vec::new(), + millis: 0, + }; + v.last_queue = vec![request(1), request(0)]; + // Page 2's text finishes first, as it can when the workers race. + v.receive_result(&ctx, result(1, "Second page sample text")); + assert_eq!(v.find.as_ref().unwrap().current, Some(0), "the only known match is picked while page 1 is pending"); + + v.receive_result(&ctx, result(0, "Panel sample draft notes")); + let f = v.find.as_ref().unwrap(); + assert_eq!(f.matches.iter().map(|(p, _)| *p).collect::>(), vec![0, 1]); + assert_eq!(f.current, Some(0), "page 1's match takes over once its text arrives"); + assert_eq!(v.current, 0, "the view follows the first match at or after the page being viewed"); + } + + /// A match the user picked (a click in the Search panel, or ⌘G) is not moved by a text + /// layer that arrives later: only automatic choices follow the viewed page. + #[test] + fn a_user_picked_search_match_stays_put_when_earlier_text_arrives() { + let mut v = view(2, PageLayout::Single, true); + v.current = 0; + v.find = Some(Find { query: "sample".into(), ..Default::default() }); + v.rerun_find(); + v.texts.insert(1, Arc::new(find_test_text("Second page sample text"))); + v.refresh_find_page(1); + v.go_to_match(0); + assert_eq!(v.find.as_ref().unwrap().auto_from, None, "an explicit choice is not automatic"); + + v.texts.insert(0, Arc::new(find_test_text("Panel sample draft notes"))); + v.refresh_find_page(0); + let f = v.find.as_ref().unwrap(); + assert_eq!(f.matches.iter().map(|(p, _)| *p).collect::>(), vec![0, 1]); + assert_eq!(f.current, Some(1), "the picked match keeps its identity after page 1 is inserted"); + assert_eq!(v.current, 1, "the user's page does not change under them"); + } + + /// The automatic choice may move the view only while the reader has left it where that + /// choice put it. Navigating by the page box/Pages panel/outline or by view history is the + /// reader's decision: a text layer arriving afterwards must not pull them back to an + /// anchor-derived match. (The harness covers same-page wheel and pointer scrolling.) + #[test] + fn a_late_automatic_match_does_not_override_the_readers_navigation() { + enum Nav { + Page, + History, + } + for nav in [Nav::Page, Nav::History] { + let mut v = view(3, PageLayout::Single, true); + v.current = 0; + v.texts.insert(2, Arc::new(find_test_text("Second page sample"))); + v.find = Some(Find { query: "sample".into(), ..Default::default() }); + v.rerun_find(); + assert_eq!((v.current, v.find.as_ref().unwrap().auto_page), (2, Some(2)), "the automatic match was shown"); + match nav { + // The page box, the Pages panel and outline destinations all end here. + Nav::Page => v.go_to_page(1), + // View ▸ Page navigation ▸ Previous view writes `current` directly. + Nav::History => { + v.back = vec![1]; + assert!(v.view_history(false)); + } + } + assert_eq!(v.current, 1); + v.texts.insert(0, Arc::new(find_test_text("First page sample"))); + v.refresh_find_page(0); + let f = v.find.as_ref().unwrap(); + assert_eq!(f.auto_from, None, "the reader's navigation ends automatic following"); + assert_eq!(f.auto_page, None); + assert_eq!(v.current, 1, "a late match cannot pull the reader back"); + } + } + + /// Typing on while the results arrive must not re-anchor the search on a page the automatic + /// flash picked. The anchor stays the page the reader was on when they started searching. + #[test] + fn refining_the_query_keeps_the_page_the_search_started_from() { + let mut v = view(3, PageLayout::Single, true); + v.current = 0; + v.texts.insert(2, Arc::new(find_test_text("Second page sample text"))); + v.find = Some(Find { query: "sample".into(), ..Default::default() }); + v.rerun_find(); + assert_eq!((v.current, v.find.as_ref().unwrap().auto_from), (2, Some(0)), "the first query's match scrolled the view"); + + // Page 1 has a match for the refined query, and so has page 3. Anchored on the page the + // automatic flash reached, the search would pick page 3; the starting page picks page 1. + v.texts.insert(0, Arc::new(find_test_text("Panel draft text"))); + v.find.as_mut().unwrap().query = "text".into(); + v.rerun_find(); + let f = v.find.as_ref().unwrap(); + assert_eq!(f.auto_from, Some(0), "refinement keeps the page the reader searched from"); + assert_eq!(f.matches.first().map(|(p, _)| *p), Some(0)); + assert_eq!(f.current, Some(0)); + assert_eq!(v.current, 0); + } + + /// ⌘G / Ctrl+G goes through `find_step`, an explicit choice like a Search-panel click: a + /// text layer arriving later must not undo it. + #[test] + fn keyboard_find_step_is_explicit_and_a_late_text_layer_cannot_undo_it() { + let mut v = view(3, PageLayout::Single, true); + v.current = 0; + v.texts.insert(1, Arc::new(find_test_text("first sample"))); + v.texts.insert(2, Arc::new(find_test_text("second sample"))); + v.find = Some(Find { query: "sample".into(), ..Default::default() }); + v.rerun_find(); + assert_eq!((v.current, v.find.as_ref().unwrap().auto_from), (1, Some(0))); + + let ctx = egui::Context::default(); + ctx.input_mut(|i| { + i.events.push(egui::Event::Key { + key: egui::Key::G, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::COMMAND, + }); + }); + shortcuts(&mut v, &ctx, Default::default()); + + assert_eq!(v.current, 2, "the keyboard step chose the next match"); + assert_eq!(v.find.as_ref().unwrap().auto_from, None, "the keyboard step is the user's choice"); + assert_eq!(v.back, vec![1], "an explicit search jump records its origin"); + + v.texts.insert(0, Arc::new(find_test_text("intro sample"))); + v.refresh_find_page(0); + let f = v.find.as_ref().unwrap(); + assert_eq!(v.current, 2, "the chosen match keeps the view"); + assert_eq!(f.current.and_then(|i| f.matches.get(i)).map(|(p, _)| *p), Some(2)); + } + + /// A page whose text extraction failed keeps the automatic anchor: the failure selects the + /// next match without moving the view, and a later text layer can still follow the anchor. + #[test] + fn a_failed_text_layer_keeps_the_automatic_anchor_without_jitter() { + let mut v = view(3, PageLayout::Single, true); + v.current = 0; + v.texts.insert(1, Arc::new(find_test_text("first sample"))); + v.texts.insert(2, Arc::new(find_test_text("second sample"))); + v.find = Some(Find { query: "sample".into(), ..Default::default() }); + v.rerun_find(); + assert_eq!((v.current, v.find.as_ref().unwrap().auto_page), (1, Some(1))); + + let goto = v.goto; + let ctx = egui::Context::default(); + let request = RenderRequest { page: 1, kind: RequestKind::Text, ..Default::default() }; + v.last_queue = vec![request]; + v.receive_result( + &ctx, + pdfcraft_render::RenderedPage { + request, + width: 0, + height: 0, + rgba: Default::default(), + error: Some("extraction failed".into()), + text: None, + warnings: Vec::new(), + millis: 0, + }, + ); + let f = v.find.as_ref().unwrap(); + assert_eq!(f.matches.iter().map(|(p, _)| *p).collect::>(), vec![2]); + assert_eq!(f.current, Some(0), "the remaining match is selected"); + assert_eq!((f.auto_from, f.auto_page), (Some(0), Some(1)), "a failure does not end automatic following"); + assert_eq!(v.current, 1, "a failure never moves the view"); + assert_eq!(v.goto, goto, "a failure never flashes the match"); + + // The search still follows its original page when that page's text arrives. + v.last_queue = vec![RenderRequest { page: 0, kind: RequestKind::Text, tag: TEXT_TAG, ..Default::default() }]; + v.receive_result(&ctx, find_text_result(0, "intro sample")); + assert_eq!(v.current, 0, "the anchor's earlier match takes over"); + let f = v.find.as_ref().unwrap(); + assert_eq!(f.current.and_then(|i| f.matches.get(i)).map(|(p, _)| *p), Some(0)); + } + + /// A reader who navigated before a page's text failed is not pulled back by a later + /// matching layer: the failure path must preserve the same `Ended` state as a successful + /// one, and the result list may select the late match without revealing it. + #[test] + fn a_failed_text_layer_after_navigation_does_not_reveal_a_late_match() { + let mut v = view(3, PageLayout::Single, true); + v.current = 0; + v.find = Some(Find { query: "sample".into(), ..Default::default() }); + v.rerun_find(); + v.go_to_page(2); + let moved = v.goto; + assert!(v.find.as_ref().unwrap().follow_ended, "the reader navigated before any match arrived"); + + let ctx = egui::Context::default(); + let failed = RenderRequest { page: 1, kind: RequestKind::Text, tag: TEXT_TAG, ..Default::default() }; + v.last_queue = vec![failed]; + v.receive_result( + &ctx, + pdfcraft_render::RenderedPage { + request: failed, + width: 0, + height: 0, + rgba: Default::default(), + error: Some("extraction failed".into()), + text: None, + warnings: Vec::new(), + millis: 0, + }, + ); + let f = v.find.as_ref().unwrap(); + assert!(f.follow_ended, "a failure after navigation does not restart following"); + assert_eq!((f.auto_from, f.auto_page), (None, None)); + assert_eq!(v.current, 2, "a failure never moves the view"); + assert_eq!(v.goto, moved); + + let arrived = RenderRequest { page: 0, kind: RequestKind::Text, tag: TEXT_TAG, ..Default::default() }; + v.last_queue = vec![arrived]; + v.receive_result(&ctx, find_text_result(0, "intro sample")); + let f = v.find.as_ref().unwrap(); + assert!(f.follow_ended); + assert_eq!((f.auto_from, f.auto_page), (None, None)); + assert_eq!(v.current, 2, "the late matching layer does not pull the reader back"); + assert_eq!(v.goto, moved, "no flash was issued"); + assert_eq!(f.current.and_then(|i| f.matches.get(i)).map(|(p, _)| *p), Some(0), "the result list still selects the late match"); + } + + /// The reader can navigate before any text layer has supplied a match. That navigation + /// still ends automatic following, so the first late match is listed but not revealed by + /// a flash that would pull the reader back. + #[test] + fn navigation_before_the_first_match_is_not_overridden_by_a_late_text_layer() { + let mut v = view(3, PageLayout::Single, true); + v.current = 0; + v.find = Some(Find { query: "sample".into(), ..Default::default() }); + v.rerun_find(); + let f = v.find.as_ref().unwrap(); + assert_eq!(f.current, None, "no text layer has arrived yet"); + assert_eq!((f.auto_from, f.auto_page), (Some(0), Some(0))); + + v.go_to_page(2); + assert_eq!(v.goto, Some((2, 0.0)), "the reader moved to page 3"); + assert!(v.find.as_ref().unwrap().follow_ended, "moving the view ends automatic following"); + + let ctx = egui::Context::default(); + let request = RenderRequest { page: 1, kind: RequestKind::Text, tag: TEXT_TAG, ..Default::default() }; + v.last_queue = vec![request]; + v.receive_result(&ctx, find_text_result(1, "late sample")); + let f = v.find.as_ref().unwrap(); + assert_eq!(v.current, 2, "the late first match does not pull the reader back"); + assert_eq!(v.goto, Some((2, 0.0)), "no flash was issued"); + assert_eq!((f.auto_from, f.auto_page), (None, None)); + assert_eq!(f.current.and_then(|i| f.matches.get(i)).map(|(p, _)| *p), Some(1), "the result list still selects the match"); + } + + /// A page-box jump to the page already shown is still the reader's navigation: automatic + /// following must end, so a late earlier match is listed without moving the view. (The + /// harness covers wheel, scrollbar and pointer scrolling through the production paint path.) + #[test] + fn a_same_page_page_box_jump_ends_automatic_following() { + let mut v = view(3, PageLayout::Single, true); + v.current = 0; + v.texts.insert(1, Arc::new(find_test_text("first sample"))); + v.find = Some(Find { query: "sample".into(), ..Default::default() }); + v.rerun_find(); + assert_eq!((v.current, v.find.as_ref().unwrap().auto_page), (1, Some(1)), "the automatic choice is on page 2"); + + // Choosing the page already shown still ends following; it stays on page 2 at its top. + v.go_to_page(1); + let shown = v.goto; + assert_eq!(shown, Some((1, 0.0))); + + v.texts.insert(0, Arc::new(find_test_text("intro sample"))); + v.refresh_find_page(0); + let f = v.find.as_ref().unwrap(); + assert!(f.follow_ended); + assert_eq!((f.auto_from, f.auto_page), (None, None)); + assert_eq!(v.current, 1, "the late earlier match does not move the reader within the page"); + assert_eq!(v.goto, shown, "no flash was issued"); + assert_eq!(f.current.and_then(|i| f.matches.get(i)).map(|(p, _)| *p), Some(1), "the page-2 match stays selected"); + } + + /// A document-wide invalidation (a layer toggle, an edit, undo/redo) clears results for + /// re-extraction but is not the reader navigating. Automatic following keeps its anchor so + /// the re-extracted layers still reveal the automatic choice; a reader who already took + /// over stays in control. If `document_changed` clamps the view away from that anchor, the + /// next layer ends following instead of revealing a match from the stale page. + #[test] + fn content_invalidation_keeps_the_following_state() { + let mut v = view(3, PageLayout::Single, true); + v.current = 0; + v.texts.insert(1, Arc::new(find_test_text("first sample"))); + v.find = Some(Find { query: "sample".into(), ..Default::default() }); + v.rerun_find(); + assert_eq!((v.current, v.find.as_ref().unwrap().auto_page), (1, Some(1)), "the automatic choice is on page 2"); + + v.invalidate_content(); + let f = v.find.as_ref().unwrap(); + assert!(f.matches.is_empty() && f.current.is_none(), "results wait for the re-extracted text"); + assert!(!f.follow_ended, "a content change is not reader navigation"); + assert_eq!((f.auto_from, f.auto_page), (Some(0), Some(1))); + + // Re-extracted text still follows the page the search started from. + let ctx = egui::Context::default(); + let request = RenderRequest { page: 0, kind: RequestKind::Text, tag: TEXT_TAG, ..Default::default() }; + v.last_queue = vec![request]; + v.receive_result(&ctx, find_text_result(0, "intro sample")); + assert_eq!(v.current, 0, "the re-extracted match is revealed by the still-active search"); + let f = v.find.as_ref().unwrap(); + assert!(!f.follow_ended); + assert_eq!((f.auto_from, f.auto_page), (Some(0), Some(0))); + + // A reader who navigated first keeps the view they chose across the invalidation. + v.go_to_page(2); + let moved = v.goto; + assert!(v.find.as_ref().unwrap().follow_ended); + v.invalidate_content(); + let f = v.find.as_ref().unwrap(); + assert!(f.follow_ended); + assert_eq!((f.auto_from, f.auto_page), (None, None)); + v.last_queue = vec![request]; + v.receive_result(&ctx, find_text_result(0, "late sample")); + assert_eq!(v.current, 2, "no delayed reveal after the reader navigated"); + assert_eq!(v.goto, moved, "no flash was issued"); + let f = v.find.as_ref().unwrap(); + assert_eq!(f.current.and_then(|i| f.matches.get(i)).map(|(p, _)| *p), Some(0), "the result list still selects the late match"); + } + + /// A document edit can shrink the page count. `document_changed` clamps `current` but leaves + /// the automatic anchor's page index alone; a re-extracted layer must then end following + /// rather than reveal a match by scrolling from a page that no longer exists. + #[test] + fn a_page_count_shrink_ends_following_before_a_late_reveal() { + let mut v = view(5, PageLayout::Single, true); + v.current = 0; + v.texts.insert(4, Arc::new(find_test_text("last sample"))); + v.find = Some(Find { query: "sample".into(), ..Default::default() }); + v.rerun_find(); + assert_eq!((v.current, v.find.as_ref().unwrap().auto_page), (4, Some(4)), "the automatic choice is on the last page"); + + let info = pdfcraft_render::DocInfo { + pages: (0..2) + .map(|_| pdfcraft_render::PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 }) + .collect(), + ..Default::default() + }; + v.document_changed(&info); + assert_eq!(v.current, 1, "the view clamps to the last remaining page"); + let f = v.find.as_ref().unwrap(); + assert!(!f.follow_ended, "the content change itself keeps the automatic state"); + assert_eq!((f.auto_from, f.auto_page), (Some(0), Some(4)), "the anchor still names the removed page"); + let goto = v.goto; + + let ctx = egui::Context::default(); + let request = RenderRequest { page: 0, kind: RequestKind::Text, tag: TEXT_TAG, ..Default::default() }; + v.last_queue = vec![request]; + v.receive_result(&ctx, find_text_result(0, "intro sample")); + let f = v.find.as_ref().unwrap(); + assert!(f.follow_ended, "a layer for the remaining pages ends the stale follow"); + assert_eq!((f.auto_from, f.auto_page), (None, None)); + assert_eq!(v.current, 1, "the late layer cannot pull the view back"); + assert_eq!(v.goto, goto, "no flash was issued"); + assert_eq!(f.current.and_then(|i| f.matches.get(i)).map(|(p, _)| *p), Some(0), "the result list still selects the match"); + } + + /// Typing refinements while text layers keep arriving must keep the page the search began + /// on, not re-anchor on whichever page an earlier automatic flash happened to show. + #[test] + fn rapid_query_edits_interleaved_with_text_results_keep_the_start_anchor() { + let mut v = view(3, PageLayout::Single, true); + v.current = 0; + v.find = Some(Find { query: "sam".into(), ..Default::default() }); + v.rerun_find(); + assert!(v.find.as_ref().unwrap().current.is_none()); + + let edits = [("samp", 2, "Page three sample", 2), ("sampl", 1, "Page two sample", 1), ("sample", 0, "Page one sample", 0)]; + for (query, page, text, shown) in edits { + v.find.as_mut().unwrap().query = query.into(); + v.rerun_find(); + let f = v.find.as_ref().unwrap(); + assert_eq!(f.auto_from, Some(0), "refinement keeps the page the reader started from"); + assert!(!f.follow_ended); + v.texts.insert(page, Arc::new(find_test_text(text))); + v.refresh_find_page(page); + let f = v.find.as_ref().unwrap(); + assert_eq!(v.current, shown, "the late page follows the automatic anchor {shown}"); + assert_eq!((f.auto_from, f.auto_page), (Some(0), Some(shown))); + assert!(!f.follow_ended, "the automatic flash itself does not end following"); + } + assert!(v.back.is_empty(), "automatic search updates do not record history"); + } + #[test] fn text_layers_evict_far_pages_but_keep_visible_and_matches() { let info = thumbnail_info(100); @@ -6964,6 +7573,7 @@ trailer << /Root 1 0 R >> delta: vec2(0.0, -100_000.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); // Well past egui's longest scroll animation (about 0.3 s). h.run_steps(40); diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 2723d16c23..b81ac2d58c 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -1,6 +1,7 @@ //! Window chrome: tab strip (with the integrated macOS title bar), mode bar, right rail. -use egui::{Align, Align2, Color32, CornerRadius, Layout, Rect, Sense, Stroke, vec2}; +use craft_ui::panes::{Edge, Rail, RailItem, RailRequest}; +use egui::{Align, Color32, CornerRadius, Layout, Rect, Sense, Stroke, vec2}; use crate::canvas::{DocView, Fit, PageLayout}; use crate::theme::{self, ThemePreference, Tokens}; @@ -26,6 +27,8 @@ const TAB_STRIP: f32 = 38.0; const TAB_HEIGHT: f32 = 30.0; const STRIP_BUTTON: f32 = 28.0; +/// Draw the window's tab strip: home and open controls, shrinkable and scrollable document tabs with drag +/// reordering, and platform window controls. pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); // macOS: the strip is the 28 pt title bar, so the traffic lights are centred in it; tabs and @@ -38,7 +41,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { .frame(egui::Frame::NONE.fill(t.titlebar).inner_margin(egui::Margin { left, right: 10, top: 0, bottom: 0 })) .show(ui, |ui| { let full = ui.max_rect(); - let drag = ui.interact(full, ui.id().with("titledrag"), Sense::click_and_drag()); + let drag = ui.interact(full, ui.scope_id().with("titledrag"), Sense::click_and_drag()); if drag.drag_started() { window_controls::desktop_grab(ui.ctx(), egui::ViewportCommand::StartDrag); } @@ -92,7 +95,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { } let cap = tab_cap(&natural, tabs_width, gap, TAB_MIN_WIDTH); // Where the tabs were scrolled to last frame, and how far they can go. - let scroll_id = ui.id().with("tab_scroll"); + let scroll_id = ui.scope_id().with("tab_scroll"); let (offset, max_offset) = ui.data(|d| d.get_temp::<(f32, f32)>(scroll_id)).unwrap_or((0.0, 0.0)); let mut scroll_to = None; if overflow && arrow(ui, "chevron-left", offset > 0.5, tl!("Scroll tabs left")) { @@ -102,7 +105,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { // the strip jumping to it. let state = (app.active_ids().map(|(_, id)| id), app.views.len(), app.combine_showing()); let changed = ui.data_mut(|data| { - let id = ui.id().with("active_tab"); + let id = ui.scope_id().with("active_tab"); let changed = data.get_temp::<(Option, usize, bool)>(id) != Some(state); data.insert_temp(id, state); changed @@ -131,7 +134,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let file = (name != doc.name).then_some(doc.name.as_str()); let response = tab( ui, - ui.id().with(("document-tab", app.views[i].id)), + ui.scope_id().with(("document-tab", app.views[i].id)), &t, "file-text", &name, @@ -213,7 +216,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let mut close = None; let response = tab( ui, - ui.id().with("combine-tab"), + ui.scope_id().with("combine-tab"), &t, "files", tl!("Combine files"), @@ -239,7 +242,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { }); }); let max = (out.content_size.x - out.inner_rect.width()).max(0.0); - ui.data_mut(|d| d.insert_temp(scroll_id, (out.state.offset.x, max))); + ui.data_mut(|d| d.insert_temp(scroll_id, (out.state.clamped_offset().x, max))); // A tab dragged to either edge of a strip that scrolls brings the hidden tabs in. if let Some(x) = drag_x && max > 0.0 @@ -252,7 +255,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { 0.0 }; if step != 0.0 { - ui.data_mut(|d| d.insert_temp(scroll_id.with("to"), (out.state.offset.x + step).clamp(0.0, max))); + ui.data_mut(|d| d.insert_temp(scroll_id.with("to"), (out.state.clamped_offset().x + step).clamp(0.0, max))); ui.ctx().request_repaint(); } } @@ -308,25 +311,6 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { }); } -/// Both theme entry points use the same choices and command path. -fn theme_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) { - for (preference, command, label) in [ - (ThemePreference::System, "view.theme.system", "Use system setting"), - (ThemePreference::Light, "view.theme.light", "Light gray"), - (ThemePreference::Dark, "view.theme.dark", "Dark gray"), - ] { - if ui.add(widgets::radio(app.theme_preference == preference, tl!(label))).clicked() { - app.execute(command); - ui.close(); - } - } - ui.separator(); - if ui.button(tl!("Appearance settings…")).clicked() { - app.dialog = Some(Dialog::Preferences); - ui.close(); - } -} - /// The right-click menu on the title bar's theme button. fn appearance_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) { if ui.button(tl!("Appearance settings…")).clicked() { @@ -383,7 +367,7 @@ fn tab_name(name: &str) -> String { /// The width a tab takes when nothing has to shrink. fn tab_natural_width(ui: &egui::Ui, t: &Tokens, name: &str) -> f32 { - let label = crate::bidi::visual(&tab_name(name)).into_owned(); + let label = tab_name(name); ui.fonts_mut(|f| f.layout_no_wrap(label, theme::regular(13.0), t.text).size().x) + TAB_CHROME } @@ -411,6 +395,8 @@ fn tab_cap(natural: &[f32], budget: f32, gap: f32, min: f32) -> Option { None } +/// Draw one tab with its icon, elided label, close button and unsaved dot; records a close request and +/// returns the tab response for drag handling. #[allow(clippy::too_many_arguments)] fn tab( ui: &mut egui::Ui, @@ -432,13 +418,13 @@ fn tab( // A shrunk tab shows as much of its name as fits (the tooltip has all of it). if let Some(cap) = cap { let room = (cap - TAB_CHROME).max(0.0); - label = ui.fonts_mut(|f| { - crate::panels::ellipsized_prefix(&label, |s| f.layout_no_wrap(crate::bidi::visual(s).into_owned(), font.clone(), t.text).size().x <= room) - }); + label = crate::panels::ellipsized_prefix(&label, |s| crate::bidi::layout_ltr(ui, s.to_owned(), font.clone(), t.text).size().x <= room); } - // Painted text only: the accessibility name below keeps the logical order. - let label = crate::bidi::visual(&label).into_owned(); - let text_w = ui.fonts_mut(|f| f.layout_no_wrap(label.clone(), font.clone(), t.text).size().x); + // Logical text, laid out explicitly left to right: the fork shapes and reorders it once, + // and an Arabic file name keeps its extension at the visual end. AccessKit names stay logical. + let label_color = if active { t.text } else { t.text_muted }; + let label_galley = crate::bidi::layout_ltr(ui, label.clone(), font.clone(), label_color); + let text_w = label_galley.size().x; let width = cap.map_or(text_w + TAB_CHROME, |cap| (text_w + TAB_CHROME).min(cap)); // Full strip height so the active tab sits on the mode bar instead of floating in the title bar. let height = ui.available_height().max(height); @@ -462,7 +448,7 @@ fn tab( 15.0, if active { t.accent } else { t.text_muted }, ); - ui.painter().text(rect.min + vec2(28.0, rect.height() / 2.0), Align2::LEFT_CENTER, label, font, if active { t.text } else { t.text_muted }); + ui.painter().galley(rect.min + vec2(28.0, (rect.height() - label_galley.size().y) / 2.0), label_galley.clone(), label_color); }); let x_rect = Rect::from_center_size(rect.right_center() - vec2(16.0, 0.0), vec2(20.0, 20.0)); let x = ui.interact(x_rect, id.with("close"), Sense::click()); @@ -478,14 +464,10 @@ fn tab( *close = Some(index); } let shown = match file { - Some(file) => std::borrow::Cow::Owned(format!("{}\n{}", crate::bidi::visual(name), crate::bidi::visual(file))), - None => crate::bidi::visual(name), + Some(file) => format!("{name}\n{file}"), + None => name.to_string(), }; - theme::hand(resp).on_hover_text(if dirty { - crate::i18n::fmt(tl!("{name} — unsaved changes"), &[("name", shown.as_ref())]) - } else { - shown.into_owned() - }) + theme::hand(resp).on_hover_text(if dirty { crate::i18n::fmt(tl!("{name} — unsaved changes"), &[("name", &shown)]) } else { shown }) } pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) { @@ -496,10 +478,13 @@ pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) { .show(ui, |ui| { ui.horizontal_centered(|ui| { ui.spacing_mut().item_spacing.x = 2.0; - main_menu(app, ui); - ui.add_space(6.0); - ui.painter().vline(ui.cursor().left(), ui.max_rect().y_range().shrink(12.0), Stroke::new(1.0, t.divider)); - ui.add_space(10.0); + // The menus, unless the native macOS menu bar has them. + if app.native_menu.is_none() { + app.menu_bar_ui(ui); + ui.add_space(6.0); + ui.painter().vline(ui.cursor().left(), ui.max_rect().y_range().shrink(12.0), Stroke::new(1.0, t.divider)); + ui.add_space(10.0); + } // Acrobat's mode bar has no Read tab: reading is what the viewer does with no tool // in use. Read mode (the panes hidden) stays a View command and a default workspace. for (mode, label) in [(Mode::AllTools, "All tools"), (Mode::Edit, "Edit"), (Mode::Convert, "Convert"), (Mode::Sign, "E-Sign")] { @@ -539,103 +524,8 @@ pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) { }); } -/// Keep menus usable when their contents are taller than the window. -pub(crate) fn scroll_menu(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui)) { - let height = (ui.ctx().content_rect().height() - 24.0).max(40.0); - egui::ScrollArea::vertical().max_height(height).auto_shrink([true, true]).show(ui, add); -} - -fn main_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) { - let t = Tokens::get(ui.ctx()); - let resp = widgets::ghost_button(ui, "panel-left", tl!("Menu")); - egui::Popup::menu(&resp).show(|ui| { - scroll_menu(ui, |ui| { - ui.set_min_width(230.0); - ui.menu_button(tl!("File"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "File"))); - ui.menu_button(tl!("Edit"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "Edit"))); - ui.menu_button(tl!("Pages"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "Pages"))); - ui.menu_button(tl!("View"), |ui| { - scroll_menu(ui, |ui| { - if let Some(i) = app.active { - let v = &mut app.views[i]; - let ctx = ui.ctx().clone(); - let key = |s| crate::commands::shortcut_label(&ctx, s); - use crate::commands::{ACTUAL_SIZE, FIT_WIDTH, NEXT_VIEW, PAGE_LEVEL, PREV_VIEW, ROTATE_CCW, ROTATE_CW, ZOOM_IN, ZOOM_OUT}; - ui.label(egui::RichText::new(tl!("Zoom")).color(t.text_faint).small()); - if widgets::menu_item(ui, tl!("Actual size"), &key(ACTUAL_SIZE)).clicked() { - v.set_zoom(1.0); - } - if widgets::menu_item(ui, tl!("Zoom to page level"), &key(PAGE_LEVEL)).clicked() { - v.fit = Fit::Page; - } - if widgets::menu_item(ui, tl!("Fit to width"), &key(FIT_WIDTH)).clicked() { - v.fit = Fit::Width; - } - if widgets::menu_item(ui, tl!("Fit to height"), "").clicked() { - v.fit = Fit::Height; - v.goto = Some((v.current, 0.0)); - } - if widgets::menu_item(ui, tl!("Fit visible"), &crate::commands::command_shortcut_label(&ctx, "view.fit_visible")).clicked() { - ui.close(); - app.execute("view.fit_visible"); - return; - } - if widgets::menu_item(ui, tl!("Zoom in"), &key(ZOOM_IN)).clicked() { - v.zoom_step(true); - } - if widgets::menu_item(ui, tl!("Zoom out"), &key(ZOOM_OUT)).clicked() { - v.zoom_step(false); - } - if widgets::menu_item(ui, tl!("Rotate view clockwise"), &key(ROTATE_CW)).clicked() { - v.rotate_view(true); - } - if widgets::menu_item(ui, tl!("Rotate view counterclockwise"), &key(ROTATE_CCW)).clicked() { - v.rotate_view(false); - } - ui.separator(); - ui.label(egui::RichText::new(tl!("Page navigation")).color(t.text_faint).small()); - if ui.add_enabled(!v.back.is_empty(), egui::Button::new(tl!("Previous view")).shortcut_text(key(PREV_VIEW))).clicked() { - v.view_history(false); - } - if ui.add_enabled(!v.forward.is_empty(), egui::Button::new(tl!("Next view")).shortcut_text(key(NEXT_VIEW))).clicked() { - v.view_history(true); - } - ui.separator(); - if let Some(id) = page_display_menu(ui, &app.views[i]) { - app.execute(id); - } - ui.separator(); - } - crate::commands::registry_menu(app, ui, "View"); - ui.menu_button(tl!("Display theme"), |ui| theme_menu(app, ui)); - ui.menu_button(tl!("Side panels"), |ui| { - scroll_menu(ui, |ui| { - for (p, label) in [ - (RightPanel::Comments, tl!("Comments")), - (RightPanel::Bookmarks, tl!("Bookmarks")), - (RightPanel::Pages, tl!("Pages")), - (RightPanel::Fields, tl!("Fields")), - (RightPanel::Layers, tl!("Layers")), - (RightPanel::Attachments, tl!("Attachments")), - (RightPanel::Signatures, tl!("Signatures")), - (RightPanel::Accessibility, tl!("Accessibility Checker")), - (RightPanel::Tags, tl!("Tags")), - (RightPanel::Search, tl!("Search")), - (RightPanel::Compare, tl!("Compare")), - ] { - if ui.add(widgets::radio(app.right == Some(p), tl!(label))).clicked() { - app.choose_right_panel(Some(p)); - } - } - }) - }); - }) - }); - ui.menu_button(tl!("Help"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "Help"))); - }) - }); -} - +/// Draw the right tool rail with page navigation, history, zoom, rotation and page-display controls for the +/// active view. pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let Some((index, id)) = app.active_ids() else { return }; @@ -651,166 +541,165 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) { ); let page_count = doc.info.pages.len(); let labels: Vec = doc.info.pages.iter().map(|p| p.label.clone()).collect(); - egui::Panel::right("rail") - .resizable(false) - .exact_size(48.0) + let item = |panel: RightPanel, icon: &str, label: &str, marker: Option<&str>| { + let item = RailItem::new(panel, icons::source(icon), tl!(label)).marker(marker.is_some()); + match marker { + Some(marker) => item.marker_label(tl!(marker)), + None => item, + } + }; + let has = |has: bool, label: &'static str| has.then_some(label); + let mut items = vec![ + item(RightPanel::Comments, "message-square-text", "Comments", has(has_comments, "Has comments")), + item(RightPanel::Bookmarks, "bookmark", "Bookmarks", has(has_outline, "Has bookmarks")), + item(RightPanel::Pages, "files", "Page thumbnails", None), + item(RightPanel::Fields, "text-cursor-input", "Form fields", has(has_fields, "Has form fields")), + item(RightPanel::Layers, "layers", "Layers", has(has_layers, "Has layers")), + item(RightPanel::Attachments, "paperclip", "Attachments", has(has_files, "Has attachments")), + item(RightPanel::Signatures, "signature", "Signatures", has(has_signatures, "Signed")), + ]; + if has_check { + items.push(item(RightPanel::Accessibility, "accessibility", "Accessibility Checker", None)); + } + items.push(item(RightPanel::Tags, "tag", "Tags", None)); + let view = &mut app.views[index]; + let mut page_display = None; + let rail = Rail::new(egui::Id::unique("rail")) + .edge(Edge::End) .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(6, 8)).stroke(Stroke::new(1.0, t.divider))) - .show(ui, |ui| { - // Page navigation cluster at the bottom (as in Acrobat's rail). It is a panel of its own so - // that in a short window the buttons above scroll instead of running into it (#3282). - let view = &mut app.views[index]; - let mut page_display = None; - egui::Panel::bottom("rail-nav").frame(egui::Frame::NONE).show_separator_line(false).resizable(false).show(ui, |ui| { - ui.with_layout(Layout::bottom_up(Align::Center), |ui| { - ui.spacing_mut().item_spacing.y = 2.0; - let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom out ({key})"), crate::commands::ZOOM_OUT); - if icons::button(ui, "zoom-out", 32.0, false, &tip).clicked() { - view.zoom_step(false); + .show(ui, &items, app.right.as_ref(), |ui| { + // Page navigation cluster at the bottom, a panel of its own so that in a short + // window the buttons above scroll instead of running into it (#3282). + ui.with_layout(Layout::bottom_up(Align::Center), |ui| { + ui.spacing_mut().item_spacing.y = 2.0; + let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom out ({key})"), crate::commands::ZOOM_OUT); + if icons::button(ui, "zoom-out", 32.0, false, &tip).clicked() { + view.zoom_step(false); + } + let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom in ({key})"), crate::commands::ZOOM_IN); + if icons::button(ui, "zoom-in", 32.0, false, &tip).clicked() { + view.zoom_step(true); + } + // Between rotate and zoom, as in Acrobat. Its command runs once `view` is free. + let tip = crate::i18n::fmt(tl!("Page display: {layout}"), &[("layout", tl!(view.layout.label()))]); + let resp = icons::button(ui, view.layout.icon(), 32.0, false, &tip); + page_display = egui::Popup::menu(&resp) + .show(|ui| { + ui.set_min_width(220.0); + rail_view_menu(ui, view) + }) + .and_then(|r| r.inner); + let tip = crate::commands::key_tip(ui.ctx(), tl!("Rotate view clockwise ({key})"), crate::commands::ROTATE_CW); + if icons::button(ui, "rotate-cw", 32.0, false, &tip).clicked() { + view.rotate_view(true); + } + // Not `columns-2` while fitting the page: that is the two-page view's icon. + let fit_icon = if view.fit == Fit::Width { "maximize" } else { "maximize-2" }; + if icons::button(ui, fit_icon, 32.0, false, tl!("Toggle fit page / fit width")).clicked() { + view.fit = if view.fit == Fit::Width { Fit::Page } else { Fit::Width }; + view.goto = Some((view.current, 0.0)); + } + let id = egui::Id::unique("zoom-input"); + let draft = id.with("draft"); + let mut typed = ui.data(|d| d.get_temp::(draft)).unwrap_or_else(|| format!("{:.0}%", view.zoom * 100.0)); + let edit = egui::TextEdit::singleline(&mut typed) + .id(id) + .desired_width(40.0) + .align(egui::Align2::CENTER_TOP) + .font(theme::medium(11.0)) + .text_color(t.text_muted) + .frame(egui::Frame::NONE) + .margin(vec2(2.0, 2.0)); + let r = ui.add(edit).on_hover_text(tl!("Zoom — type a percentage and press Enter")); + ui.ctx().accesskit_node_builder(id, |node| node.set_label(tl!("Zoom"))); + if r.gained_focus() { + let mut state = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default(); + let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(typed.chars().count())); + state.cursor.set_char_range(Some(all)); + state.store(ui.ctx(), id); + } + if r.has_focus() { + ui.data_mut(|d| d.insert_temp(draft, typed.clone())); + } else { + ui.data_mut(|d| d.remove::(draft)); + if r.lost_focus() + && ui.input(|i| i.key_pressed(egui::Key::Enter)) + && let Some(zoom) = DocView::parse_zoom_percent(&typed) + { + view.set_zoom(zoom); } - let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom in ({key})"), crate::commands::ZOOM_IN); - if icons::button(ui, "zoom-in", 32.0, false, &tip).clicked() { - view.zoom_step(true); + } + ui.add_space(6.0); + if icons::button(ui, "chevron-down", 30.0, false, tl!("Next page")).clicked() { + view.step_page(true); + } + if icons::button(ui, "chevron-up", 30.0, false, tl!("Previous page")).clicked() { + view.step_page(false); + } + // History is distinct from stepping to the adjacent page, and belongs to this tab. + for (forward, icon, label, shortcut, enabled) in [ + (true, "chevron-right", tl!("Next view"), crate::commands::NEXT_VIEW, !view.forward.is_empty()), + (false, "chevron-left", tl!("Previous view"), crate::commands::PREV_VIEW, !view.back.is_empty()), + ] { + let tip = format!("{label} ({})", crate::commands::shortcut_label(ui.ctx(), shortcut)); + if ui.add_enabled_ui(enabled, |ui| icons::button(ui, icon, 30.0, false, &tip)).inner.clicked() { + view.view_history(forward); } - // Between rotate and zoom, as in Acrobat. Its command runs once `view` is free. - let tip = crate::i18n::fmt(tl!("Page display: {layout}"), &[("layout", tl!(view.layout.label()))]); - let resp = icons::button(ui, view.layout.icon(), 32.0, false, &tip); - page_display = egui::Popup::menu(&resp) - .show(|ui| { - ui.set_min_width(220.0); - rail_view_menu(ui, view) - }) - .and_then(|r| r.inner); - let tip = crate::commands::key_tip(ui.ctx(), tl!("Rotate view clockwise ({key})"), crate::commands::ROTATE_CW); - if icons::button(ui, "rotate-cw", 32.0, false, &tip).clicked() { - view.rotate_view(true); + } + let font = theme::regular(11.0); + let mut position = page_position(view.current, page_count, labels.get(view.current).map_or("", String::as_str)); + if widgets::text_width(ui, &position, &font) > ui.available_width() { + position = page_count.to_string(); + } + // Out of range (an empty document), like `page_position`: "Page 0 of 0", never past the end. + let shown = if view.current < page_count { view.current + 1 } else { page_count }; + let of = crate::i18n::fmt(tl!("Page {p} of {n}"), &[("p", &shown.to_string()), ("n", &page_count.to_string())]); + ui.label(egui::RichText::new(position).font(font).color(t.text_muted)).on_hover_text(of); + let page_input_id = view.page_input_id(); + // Consume replacement selection once, including when the editor has lost focus. + let reselect = std::mem::take(&mut view.page_input_reselect); + // Navigation updates the idle display, never the text being edited. + if !ui.memory(|m| m.has_focus(page_input_id)) { + view.page_input = view.current_page_label(labels.get(view.current).map(String::as_str)); + } else if reselect { + // The replacement changed the label while egui retained this editor's cursor. + let mut state = egui::TextEdit::load_state(ui.ctx(), page_input_id).unwrap_or_default(); + let end = egui::text::CCursor::new(view.page_input.chars().count()); + state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), end))); + state.store(ui.ctx(), page_input_id); + } + let edit = egui::TextEdit::singleline(&mut view.page_input) + .id(page_input_id) + .desired_width(34.0) + .align(egui::Align2::CENTER_TOP) + .font(theme::medium(12.0)) + .margin(vec2(2.0, 4.0)); + let r = egui::Frame::NONE + .fill(t.field) + .stroke(Stroke::new(1.0, t.border)) + .corner_radius(CornerRadius::same(5)) + .show(ui, |ui| ui.add(edit)) + .inner + .on_hover_text(tl!("Current page — type a page number or label (such as iv) and press Enter")) + .accessible_name(tl!("Current page")); + if r.lost_focus() { + if ui.input(|i| i.key_pressed(egui::Key::Enter)) { + // A page label first (logical page numbers, as Acrobat), then a number. + let typed = view.page_input.clone(); + view.go_to_typed(&typed, &labels); } - // Not `columns-2` while fitting the page: that is the two-page view's icon. - let fit_icon = if view.fit == Fit::Width { "maximize" } else { "maximize-2" }; - if icons::button(ui, fit_icon, 32.0, false, tl!("Toggle fit page / fit width")).clicked() { - view.fit = if view.fit == Fit::Width { Fit::Page } else { Fit::Width }; - view.goto = Some((view.current, 0.0)); - } - let id = egui::Id::new("zoom-input"); - let draft = id.with("draft"); - let mut typed = ui.data(|d| d.get_temp::(draft)).unwrap_or_else(|| format!("{:.0}%", view.zoom * 100.0)); - let edit = egui::TextEdit::singleline(&mut typed) - .id(id) - .desired_width(40.0) - .horizontal_align(Align::Center) - .font(theme::medium(11.0)) - .text_color(t.text_muted) - .frame(egui::Frame::NONE) - .margin(vec2(2.0, 2.0)); - let r = ui.add(edit).on_hover_text(tl!("Zoom — type a percentage and press Enter")); - ui.ctx().accesskit_node_builder(id, |node| node.set_label(tl!("Zoom"))); - if r.gained_focus() { - let mut state = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default(); - let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(typed.chars().count())); - state.cursor.set_char_range(Some(all)); - state.store(ui.ctx(), id); - } - if r.has_focus() { - ui.data_mut(|d| d.insert_temp(draft, typed.clone())); - } else { - ui.data_mut(|d| d.remove::(draft)); - if r.lost_focus() - && ui.input(|i| i.key_pressed(egui::Key::Enter)) - && let Some(zoom) = DocView::parse_zoom_percent(&typed) - { - view.set_zoom(zoom); - } - } - ui.add_space(6.0); - if icons::button(ui, "chevron-down", 30.0, false, tl!("Next page")).clicked() { - view.step_page(true); - } - if icons::button(ui, "chevron-up", 30.0, false, tl!("Previous page")).clicked() { - view.step_page(false); - } - // History is distinct from stepping to the adjacent page, and belongs to this tab. - for (forward, icon, label, shortcut, enabled) in [ - (true, "chevron-right", tl!("Next view"), crate::commands::NEXT_VIEW, !view.forward.is_empty()), - (false, "chevron-left", tl!("Previous view"), crate::commands::PREV_VIEW, !view.back.is_empty()), - ] { - let tip = format!("{label} ({})", crate::commands::shortcut_label(ui.ctx(), shortcut)); - if ui.add_enabled_ui(enabled, |ui| icons::button(ui, icon, 30.0, false, &tip)).inner.clicked() { - view.view_history(forward); - } - } - let font = theme::regular(11.0); - let mut position = page_position(view.current, page_count, labels.get(view.current).map_or("", String::as_str)); - if widgets::text_width(ui, &position, &font) > ui.available_width() { - position = page_count.to_string(); - } - // Out of range (an empty document), like `page_position`: "Page 0 of 0", never past the end. - let shown = if view.current < page_count { view.current + 1 } else { page_count }; - let of = crate::i18n::fmt(tl!("Page {p} of {n}"), &[("p", &shown.to_string()), ("n", &page_count.to_string())]); - ui.label(egui::RichText::new(position).font(font).color(t.text_muted)).on_hover_text(of); - let page_input_id = view.page_input_id(); - // Consume replacement selection once, including when the editor has lost focus. - let reselect = std::mem::take(&mut view.page_input_reselect); - // Navigation updates the idle display, never the text being edited. - if !ui.memory(|m| m.has_focus(page_input_id)) { - view.page_input = view.current_page_label(labels.get(view.current).map(String::as_str)); - } else if reselect { - // The replacement changed the label while egui retained this editor's cursor. - let mut state = egui::TextEdit::load_state(ui.ctx(), page_input_id).unwrap_or_default(); - let end = egui::text::CCursor::new(view.page_input.chars().count()); - state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), end))); - state.store(ui.ctx(), page_input_id); - } - let edit = egui::TextEdit::singleline(&mut view.page_input) - .id(page_input_id) - .desired_width(34.0) - .horizontal_align(Align::Center) - .font(theme::medium(12.0)) - .margin(vec2(2.0, 4.0)); - let r = egui::Frame::NONE - .fill(t.field) - .stroke(Stroke::new(1.0, t.border)) - .corner_radius(CornerRadius::same(5)) - .show(ui, |ui| ui.add(edit)) - .inner - .on_hover_text(tl!("Current page — type a page number or label (such as iv) and press Enter")); - if r.lost_focus() { - if ui.input(|i| i.key_pressed(egui::Key::Enter)) { - // A page label first (logical page numbers, as Acrobat), then a number. - let typed = view.page_input.clone(); - view.go_to_typed(&typed, &labels); - } - view.page_input = view.current_page_label(labels.get(view.current).map(String::as_str)); - } - }); - }); - egui::ScrollArea::vertical().id_salt("rail-panels").show(ui, |ui| { - ui.spacing_mut().item_spacing.y = 4.0; - let mut rail_button = |ui: &mut egui::Ui, panel: RightPanel, icon: &str, tip: &str, has: bool| { - let selected = app.right == Some(panel) && (app.mode == Mode::Read || app.docking.exposed(crate::docking::Panel::Inspector)); - let r = icons::button(ui, icon, 34.0, selected, tl!(tip)); - if has && !selected { - let c = r.rect.right_top() + vec2(-8.0, 8.0); - ui.painter().circle_filled(c, 3.0, t.accent); - } - if r.clicked() { - app.choose_right_panel(if selected { None } else { Some(panel) }); - } - }; - rail_button(ui, RightPanel::Comments, "message-square-text", "Comments", has_comments); - rail_button(ui, RightPanel::Bookmarks, "bookmark", "Bookmarks", has_outline); - rail_button(ui, RightPanel::Pages, "files", "Page thumbnails", false); - rail_button(ui, RightPanel::Fields, "text-cursor-input", "Form fields", has_fields); - rail_button(ui, RightPanel::Layers, "layers", "Layers", has_layers); - rail_button(ui, RightPanel::Attachments, "paperclip", "Attachments", has_files); - rail_button(ui, RightPanel::Signatures, "signature", "Signatures", has_signatures); - if has_check { - rail_button(ui, RightPanel::Accessibility, "accessibility", "Accessibility Checker", false); + view.page_input = view.current_page_label(labels.get(view.current).map(String::as_str)); } - rail_button(ui, RightPanel::Tags, "tag", "Tags", false); }); - if let Some(id) = page_display { - app.execute(id); - } }); + match rail.request { + Some(RailRequest::Open(panel)) => app.choose_right_panel(Some(panel)), + Some(RailRequest::Close(_)) => app.choose_right_panel(None), + _ => {} + } + if let Some(id) = page_display { + app.execute(id); + } } /// What the rail shows under the page box: the page count, or, when the current page's label is @@ -820,7 +709,7 @@ fn page_position(current: usize, count: usize, label: &str) -> String { if label.is_empty() || label == number || current >= count { count.to_string() } else { format!("{number}/{count}") } } -/// View ▸ Page display and the rail's Page display menu: the layouts and the cover page. +/// The rail's Page display menu: the layouts and the cover page (also View ▸ Page display). /// Returns the command a click asks for, for the caller to run. fn page_display_menu(ui: &mut egui::Ui, view: &DocView) -> Option<&'static str> { let t = Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/src/combine_grid.rs b/crates/ui-egui/src/combine_grid.rs index ee3b03f698..f66ec0b843 100644 --- a/crates/ui-egui/src/combine_grid.rs +++ b/crates/ui-egui/src/combine_grid.rs @@ -614,7 +614,14 @@ pub(crate) fn grid( let over_grid = ui.rect_contains_pointer(viewport); let mut width = viewport.width(); let mut focused_card = None; - let output = egui::ScrollArea::vertical().id_salt("combine-grid").auto_shrink([false, false]).show_viewport(ui, |ui, clip| { + // A zoom in the previous frame asks for a new scroll position. 0.37 keeps `State`'s offset + // private, so the request is applied through the builder and consumed here. + let pending_offset = ctx.data_mut(|d| d.remove_temp::(pending_offset_id())); + let mut grid = egui::ScrollArea::vertical().id_salt("combine-grid").auto_shrink([false, false]); + if let Some(offset) = pending_offset { + grid = grid.vertical_scroll_offset(offset.y); + } + grid.show_viewport(ui, |ui, clip| { // Dragging near the top or bottom edge scrolls. if drag.is_some() && let Some(p) = pointer @@ -792,12 +799,10 @@ pub(crate) fn grid( let cols = columns_in(width, cell); let content = TOP + places.len().div_ceil(cols) as f32 * cell.y; let y = (TOP + (i / cols) as f32 * cell.y - above).min(content - viewport.height()).max(0.0); - // Set as the scroll position itself, before the next frame draws anything: no + // Applied by the next frame's scroll area, before anything is drawn there: no // jump, and no scroll still animating (to a card revealed by the keys, say) can - // carry it elsewhere. - let mut state = egui::scroll_area::State::default(); - state.offset = vec2(0.0, y); - state.store(&ctx, output.id); + // carry it elsewhere. The request is consumed once. + ctx.data_mut(|d| d.insert_temp(pending_offset_id(), vec2(0.0, y))); } ctx.request_repaint(); } @@ -806,6 +811,11 @@ pub(crate) fn grid( action } +/// The scroll position a zoom asked the next frame's grid to start at. +fn pending_offset_id() -> egui::Id { + egui::Id::unique("pdfcraft-combine-grid-offset") +} + /// One card's place and what it shows. struct Card { /// The file, by its place in the list. @@ -826,8 +836,8 @@ struct Card { impl Card { fn id(&self, ui: &egui::Ui, f: &CombineFile) -> egui::Id { match self.page { - None => ui.id().with(("combine-card", f.id)), - Some((nth, ..)) => ui.id().with(("combine-page", f.id, nth)), + None => ui.scope_id().with(("combine-card", f.id)), + Some((nth, ..)) => ui.scope_id().with(("combine-page", f.id, nth)), } } @@ -845,7 +855,7 @@ impl Card { }; // As the organize grid's pages: a check box that is on when selected; and, for screen // readers, selected as items in a list are. - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, self.selected, &label)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, self.selected, &label)); let pages = match self.page { // For screen readers: which page of how many. Some((_, p, _)) => crate::i18n::fmt(tl!("Page {p} of {n}"), &[("p", &p.saturating_add(1).to_string()), ("n", &f.pages.to_string())]), @@ -969,9 +979,11 @@ impl Card { } None => (theme::medium(12.5), t.text, name_y), }; - let fits = |s: &str| painter.layout_no_wrap(crate::bidi::visual(s).into_owned(), font.clone(), colour).size().x <= max_w; - let shown = crate::bidi::visual(&fit_name(&f.name, fits)).into_owned(); - painter.text(pos2(c.center().x, y), Align2::CENTER_CENTER, shown, font, colour); + let fits = |s: &str| crate::bidi::layout_ltr(ui, s.to_owned(), font.clone(), colour).size().x <= max_w; + let shown = fit_name(&f.name, fits); + // A file name is file/path chrome: always left to right, so an Arabic name ends in `.pdf`. + let name_galley = crate::bidi::layout_ltr(ui, shown, font, colour); + painter.galley(pos2(c.center().x - name_galley.size().x / 2.0, y - name_galley.size().y / 2.0), name_galley, colour); let note = if self.page.is_none() && !f.range.trim().is_empty() { match &self.check { Ok(k) => Some((crate::i18n::fmt(tl!("Pages: {k} of {n}"), &[("k", &k.to_string()), ("n", &f.pages.to_string())]), t.text_muted)), @@ -1143,10 +1155,10 @@ pub(crate) fn preview(app: &mut PdfCraftApp, ctx: &egui::Context, t: &Tokens) { // No larger than the GPU takes, or the render would be refused. let longest = ctx.input(|i| i.max_texture_side as f32).min(PREVIEW_SIDE); let thumbs = &mut app.combine_thumbs; - let modal = egui::Modal::new(egui::Id::new("combine-preview")).show(ctx, |ui| { + let modal = egui::Modal::new(egui::Id::unique("combine-preview")).show(ctx, |ui| { ui.set_width(width); ui.horizontal(|ui| { - ui.label(egui::RichText::new(crate::bidi::visual(&f.name)).font(theme::semibold(15.0))); + ui.label(egui::RichText::new(f.name.clone()).font(theme::semibold(15.0))); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if icons::button(ui, "x", 30.0, false, tl!("Close")).clicked() { close = true; diff --git a/crates/ui-egui/src/combine_ui.rs b/crates/ui-egui/src/combine_ui.rs index 62d9163154..e7f7f26a19 100644 --- a/crates/ui-egui/src/combine_ui.rs +++ b/crates/ui-egui/src/combine_ui.rs @@ -742,7 +742,7 @@ pub(crate) fn page(app: &mut PdfCraftApp, ui: &mut egui::Ui) { ui.close(); } for (i, name) in &open_docs { - if ui.button(crate::bidi::visual(name).as_ref()).clicked() { + if ui.button(name.as_str()).clicked() { app.add_open_documents(&[*i]); ui.close(); } @@ -921,7 +921,7 @@ fn skip_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) { left_out.push((f.name.as_str(), f.problem.as_deref().unwrap_or_default())); } let mut choice: Option = None; - let modal = egui::Modal::new(egui::Id::new("combine-skip")).show(ctx, |ui| { + let modal = egui::Modal::new(egui::Id::unique("combine-skip")).show(ctx, |ui| { ui.set_width(460.0); ui.horizontal(|ui| { ui.add(icons::image("triangle-alert", 22.0, ERROR)); @@ -932,7 +932,7 @@ fn skip_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) { ui.add_space(6.0); for (name, why) in left_out.iter().take(SKIP_PROMPT_NAMES) { ui.horizontal_wrapped(|ui| { - ui.label(egui::RichText::new(format!("• {}", crate::bidi::visual(name))).strong()); + ui.label(egui::RichText::new(format!("• {name}")).strong()); ui.label(egui::RichText::new(*why).color(t.text_muted)); }); } @@ -978,7 +978,7 @@ fn unlock_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) { }; let batch = files.len() > 1; let (mut submit, mut cancel) = (false, false); - let area = egui::Area::new(egui::Id::new("combine-unlock")).order(egui::Order::Foreground).fixed_pos(prompt.at).show(ctx, |ui| { + let area = egui::Area::new(egui::Id::unique("combine-unlock")).order(egui::Order::Foreground).fixed_pos(prompt.at).show(ctx, |ui| { egui::Frame::popup(ui.style()).inner_margin(egui::Margin::same(14)).show(ui, |ui| { ui.set_width(340.0); ui.horizontal(|ui| { @@ -1189,7 +1189,7 @@ fn zoom_controls(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, edge: f32 let percent = egui::Button::new(egui::RichText::new(format!("{:.0}%", zoom * 100.0)).font(theme::medium(12.0)).color(t.text_muted)).frame(false); let percent = ui.add_sized([44.0, 30.0], percent); - percent.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Reset page size"))); + percent.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Reset page size"))); if percent.on_hover_text(tl!("Reset page size")).clicked() { app.combine_tab.zoom_request = Some(1.0); } @@ -1347,7 +1347,7 @@ fn heading(ui: &mut egui::Ui, t: &Tokens, key: SortKey, sort: Option<(SortKey, b None => "", }; let a11y = if state.is_empty() { text.to_owned() } else { format!("{text}, {state}") }; - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &a11y)); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &a11y)); let being_dragged = egui::DragAndDrop::payload::(ui.ctx()).is_some_and(|p| p.0 == key); if being_dragged || response.is_pointer_button_down_on() { ui.painter().rect_filled(rect, CornerRadius::same(4), t.pressed); @@ -1395,6 +1395,8 @@ fn heading(ui: &mut egui::Ui, t: &Tokens, key: SortKey, sort: Option<(SortKey, b event } +/// Draw the Combine files table body: reorder handles, editable page ranges with validation, file metadata +/// and per-row warnings. #[allow(clippy::too_many_arguments)] fn table_body( app: &mut PdfCraftApp, @@ -1410,7 +1412,7 @@ fn table_body( let order = app.combine_columns.order; // The heading being dragged follows the pointer. if let (Some(dragged), Some(pos)) = (egui::DragAndDrop::payload::(ui.ctx()), ui.ctx().pointer_latest_pos()) { - let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("combine-heading-drag"))); + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("combine-heading-drag"))); let galley = painter.layout_no_wrap(dragged.0.label().to_owned(), theme::medium(12.5), t.text); let r = Rect::from_min_size(pos + vec2(10.0, 8.0), galley.size() + vec2(16.0, 8.0)); painter.rect(r, CornerRadius::same(4), t.card, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); @@ -1454,7 +1456,7 @@ fn table_body( row.set_selected(rows.selected.get(i).copied().unwrap_or(false)); // Drag handle. row.col(|ui| { - let id = ui.id().with(("combine-drag", f.id)); + let id = ui.scope_id().with(("combine-drag", f.id)); ui.dnd_drag_source(id, i, |ui| { let (rect, resp) = ui.allocate_exact_size(vec2(12.0, 18.0), Sense::hover()); grip(ui, rect, if resp.hovered() || rows.dragging { t.text_muted } else { t.text_faint }); @@ -1467,7 +1469,8 @@ fn table_body( row.col(|ui| match kind { SortKey::Name => { ui.add(icons::image("file-text", 16.0, t.icon)); - ui.add(egui::Label::new(crate::bidi::visual(&f.name).as_ref()).truncate().selectable(false)); + // A file name is file/path chrome: explicitly LTR, extension last. + crate::widgets::label_ltr_truncated(ui, f.name.clone(), theme::regular(13.0), t.text); } // The range field and how many pages it takes. SortKey::Pages => { @@ -1581,7 +1584,7 @@ fn table_body( let r = response.rect; let y = if from > i { r.top() } else { r.bottom() }; if from != i { - response.ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("combine-drop-line"))).hline( + response.ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("combine-drop-line"))).hline( r.x_range(), y, Stroke::new(2.0, t.accent), diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 78f50b49d6..bed373f6d6 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -10,7 +10,6 @@ use pdfcraft_engine::commands::{self, COMMANDS, CommandSpec, Shortcut}; use crate::{ Dialog, Mode, PdfCraftApp, PropsTab, RightPanel, SaveTarget, theme::{ThemeKind, ThemePreference}, - widgets, }; /// Keys the document view handles itself (`canvas::shortcuts`), for the View menu, tooltips and @@ -157,10 +156,12 @@ impl PdfCraftApp { let targets = active.map(|i| self.views[i].target_pages()).unwrap_or_default(); match id { "ui.dock.reset" => { - if let Err(error) = crate::docking::command(self, true, &serde_json::Value::Null) { + if let Err(error) = crate::side_panels::command(self, true, &serde_json::Value::Null) { self.notify(error); } } + "view.side_panel" => self.toggle_side_panel(), + "view.tools_pane" => self.left_open = !self.left_open, "file.open" => self.open_dialog(), "file.open_recent" => match self.recent.first().map(|r| r.path.clone()) { // The palette runs commands without a submenu: open the most recent file. @@ -723,11 +724,6 @@ impl PdfCraftApp { // Buttons also own keyboard focus for Tab/Enter activation, but they must not // suppress document shortcuts. Text editors retain their own undo/redo handling. let typing = ctx.text_edit_focused(); - // A form field's editor is open on the page: its text isn't in the document until - // committed. (Not egui's keyboard focus: an Escape in this frame has already cleared that, - // while the field has yet to see the Escape and discard its draft.) - let active = self.active_ids(); - let form_typing = active.and_then(|(i, _)| self.views.get(i)).is_some_and(|v| v.forms.focus.is_some()); let mut specs: Vec<&CommandSpec> = COMMANDS.iter().filter(|c| c.shortcut.is_some()).collect(); specs.sort_by_key(|c| std::cmp::Reverse(c.shortcut.map(|s| s.modifier_count()).unwrap_or(0))); for spec in specs { @@ -751,23 +747,29 @@ impl PdfCraftApp { m |= Modifiers::CTRL; } if ctx.input_mut(|i| i.consume_shortcut(&KeyboardShortcut::new(m, key))) { - if form_typing { - // A form field has the keyboard: let it take this frame's typing (and - // Escape) first, so ⌘S saves what's on screen (#166). Runs next frame, for - // this document only. - self.deferred_commands.push((spec.id, active.map(|(_, id)| id))); - ctx.request_repaint(); - } else if spec.id == "file.close" && active.is_none() && crate::commands::mac_shortcuts(ctx) && !cfg!(target_arch = "wasm32") { - // macOS ⌘W closes the last window when no document tabs remain (#869). - // Route through the normal viewport-close path (and its quit guard), - // not the disabled document-only `file.close` command. - ctx.send_viewport_cmd(egui::ViewportCommand::Close); - } else { - self.execute(spec.id); - } + self.dispatch_command(ctx, spec.id); } } } + + /// Run a registry command from its shortcut or a menu, which share this path. + pub(crate) fn dispatch_command(&mut self, ctx: &egui::Context, id: &'static str) { + let active = self.active_ids(); + let form_typing = active.and_then(|(i, _)| self.views.get(i)).is_some_and(|v| v.forms.focus.is_some()); + if form_typing { + // A form field has the keyboard: let it take this frame's typing (and Escape) first, + // so ⌘S saves what's on screen (#166). Runs next frame, for this document only. + self.deferred_commands.push((id, active.map(|(_, id)| id))); + ctx.request_repaint(); + } else if id == "file.close" && active.is_none() && crate::commands::mac_shortcuts(ctx) && !cfg!(target_arch = "wasm32") { + // macOS ⌘W closes the last window when no document tabs remain (#869). Route through + // the normal viewport-close path (and its quit guard), not the disabled document-only + // `file.close` command. + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } else { + self.execute(id); + } + } } /// The tab after `current` of `count` (before it when `forward` is false), wrapping around. From @@ -788,57 +790,6 @@ fn tab_after(current: Option, count: usize, forward: bool) -> Option = None; - let mut clear = false; - ui.menu_button(label, |ui| { - crate::chrome::scroll_menu(ui, |ui| { - for r in &app.recent { - if ui.button(&r.name).on_hover_text(&r.path).clicked() { - open = Some(r.path.clone()); - ui.close(); - } - } - ui.separator(); - if ui.button(tl!("Clear Recent Files")).clicked() { - clear = true; - ui.close(); - } - }) - }); - if let Some(p) = open { - app.open_recent(&p); - ui.close(); - } - if clear { - app.execute("file.clear_recent"); - ui.close(); - } - continue; - } - let shortcut = spec.shortcut.or((spec.id == "edit.copy").then_some(COPY)).map(|s| shortcut_label(ui.ctx(), s)).unwrap_or_default(); - let enabled = app.command_enabled(spec); - let resp = ui.add_enabled(enabled, egui::Button::new(label).shortcut_text(shortcut)); - if resp.clicked() { - app.execute(spec.id); - ui.close(); - } - } - let _ = widgets::menu_item; -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/comment_props.rs b/crates/ui-egui/src/comment_props.rs index f66885d361..38fd2c4117 100644 --- a/crates/ui-egui/src/comment_props.rs +++ b/crates/ui-egui/src/comment_props.rs @@ -98,7 +98,7 @@ impl PdfCraftApp { pub fn make_props_default(&mut self, page: usize, index: usize, p: &CommentProps) { let Some((_, id)) = self.active_ids() else { return }; let Some(doc) = self.session.get(id) else { return }; - let Some(a) = doc.info.annotations.iter().find(|a| a.page == page && a.index == index && a.in_reply_to.is_none()) else { return }; + let Some(a) = doc.info.annotations.iter().find(|a| a.page == page && a.index == index && !a.is_reply()) else { return }; let Some(tool) = crate::comments::tool_for_default(a, p.endings.as_deref()) else { return }; let mut style = self.comment_prefs.style(tool); if let Some(c) = p.color { @@ -167,12 +167,11 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo .and_then(|(_, id)| app.session.get(id)) .zip(app.comment_props.as_ref()) .map(|(doc, d)| { - let me = doc.info.annotations.iter().find(|a| a.page == d.page && a.index == d.index && a.in_reply_to.is_none()); - let nm = me.and_then(|a| a.name.clone()); + let me = doc.info.annotations.iter().find(|a| a.page == d.page && a.index == d.index && !a.is_reply()); doc.info .annotations .iter() - .filter(|r| r.state.is_some() && nm.is_some() && r.in_reply_to == nm) + .filter(|r| r.state.is_some() && me.is_some_and(|a| r.replies_to(a))) .map(|r| (r.state.clone().unwrap_or_default(), r.author.clone().unwrap_or_default(), r.modified.clone().unwrap_or_default())) .collect() }) @@ -180,7 +179,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo let default_tool = app.comment_props.as_ref().and_then(|d| { let (_, id) = app.active_ids()?; let doc = app.session.get(id)?; - let a = doc.info.annotations.iter().find(|a| a.page == d.page && a.index == d.index && a.in_reply_to.is_none())?; + let a = doc.info.annotations.iter().find(|a| a.page == d.page && a.index == d.index && !a.is_reply())?; crate::comments::tool_for_default(a, d.edited.endings.as_deref()) }); let Some(d) = app.comment_props.as_mut() else { return (false, true) }; diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index b5c016750a..bf9b5fe72c 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -17,6 +17,7 @@ use pdfcraft_engine::{Edit, LineEnding, Markup, NewAnnotation, NoteIcon, ReviewS use pdfcraft_render::{Annotation, DocInfo, PageInfo, PageText}; use crate::canvas::{DocView, PageXform}; +use crate::page_pointer::PagePointer; use crate::theme::{self, Tokens}; use crate::{QuickTool, icons}; @@ -594,6 +595,8 @@ pub enum SortBy { pub(crate) struct PageCx<'a> { pub page: usize, pub xf: &'a PageXform, + /// The pointer as the pages see it this frame. + pub ptr: &'a PagePointer, pub info: &'a DocInfo, pub tool: QuickTool, pub prefs: &'a CommentPrefs, @@ -630,7 +633,7 @@ impl PageCx<'_> { /// Top-level comments on this page, in paint order. fn comments(&self) -> impl Iterator { - self.info.annotations.iter().filter(move |a| !self.hidden && a.page == self.page && a.in_reply_to.is_none() && a.subtype != "Popup") + self.info.annotations.iter().filter(move |a| !self.hidden && a.page == self.page && a.is_comment() && a.subtype != "Popup") } /// Where a comment is on screen: one rectangle, or one per marked line for text markup. @@ -808,13 +811,12 @@ pub(crate) fn resized(r: Rect, (hx, hy): (i8, i8), d: egui::Vec2, aspect_ratio: /// Comment input on one page, before text selection runs. Returns `true` when the pointer /// gesture belongs to commenting (text selection must ignore it). pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, view: &mut DocView) -> bool { - let pointer = ui.input(|i| i.pointer.hover_pos()); - let origin = ui.input(|i| i.pointer.press_origin()); + let (pointer, origin) = (cx.ptr.pos, cx.ptr.origin); let page_rect = cx.xf.rect; let pressed_here = origin.is_some_and(|o| page_rect.contains(o)); let over_page = pointer.is_some_and(|p| page_rect.contains(p)); - let fill_grab = fill_grabs(ui, cx, view); - let draw_grab = draw_grabs(ui, cx, view); + let fill_grab = fill_grabs(cx, view); + let draw_grab = draw_grabs(cx, view); let caret_text = if cx.tool == QuickTool::Comment(CommentTool::Caret) { view.page_text(cx.page) } else { None }; let cv = &mut view.comments; if cv.gesture.is_some() && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { @@ -1083,20 +1085,20 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, /// moves it and the selected one's handles resize it. Elsewhere the tool places its mark. While /// the button is down the press decides; otherwise the pointer (egui clears the press origin on /// release, so a click is located by the pointer). -pub(crate) fn fill_grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView) -> bool { - matches!(cx.tool, QuickTool::Fill(_)) && grabs(ui, cx, view, |a| a.fill_sign) +pub(crate) fn fill_grabs(cx: &PageCx<'_>, view: &DocView) -> bool { + matches!(cx.tool, QuickTool::Fill(_)) && grabs(cx, view, |a| a.fill_sign) } /// With the Select tool, a comment that would move (not text markup) is picked up before a form /// field under it, so a signature placed over a field can still be dragged off it. -pub(crate) fn select_grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView) -> bool { - cx.tool == QuickTool::Select && grabs(ui, cx, view, |a| !is_markup(&a.subtype)) +pub(crate) fn select_grabs(cx: &PageCx<'_>, view: &DocView) -> bool { + cx.tool == QuickTool::Select && grabs(cx, view, |a| !is_markup(&a.subtype)) } /// With a shape tool (rectangle, oval, line, polygon…), the selected comment's resize handles /// are picked up as with the Select tool, so the shape just drawn can be resized straight away /// (#2338). Only the handles: a press anywhere else, the shape itself included, still draws. -pub(crate) fn draw_grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView) -> bool { +pub(crate) fn draw_grabs(cx: &PageCx<'_>, view: &DocView) -> bool { let QuickTool::Comment(tool) = cx.tool else { return false }; if !(tool.draws() || tool.clicks_points()) || tool == CommentTool::Eraser || cx.hidden { return false; @@ -1109,12 +1111,12 @@ pub(crate) fn draw_grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView) -> bool if cv.gesture.is_some() { return false; } - press_point(ui, cx).is_some_and(|p| on_handle(cx, view, p, |_| true)) + press_point(cx).is_some_and(|p| on_handle(cx, view, p, |_| true)) } /// Whether the press (or, with no button down, the pointer) is on a `pick` comment or on the /// selected one's resize handles. -fn grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView, pick: impl Fn(&Annotation) -> bool) -> bool { +fn grabs(cx: &PageCx<'_>, view: &DocView, pick: impl Fn(&Annotation) -> bool) -> bool { if cx.hidden { return false; } @@ -1122,15 +1124,14 @@ fn grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView, pick: impl Fn(&Annotati { return true; } - let Some(p) = press_point(ui, cx) else { return false }; + let Some(p) = press_point(cx) else { return false }; on_handle(cx, view, p, &pick) || cx.hit(p).is_some_and(pick) } /// Where a gesture on this page is decided: the press while the button is down, otherwise the /// pointer (egui clears the press origin on release). `None` off the page. -fn press_point(ui: &egui::Ui, cx: &PageCx<'_>) -> Option { - let (pointer, origin, down) = ui.input(|i| (i.pointer.hover_pos(), i.pointer.press_origin(), i.pointer.any_down())); - (if down { origin } else { pointer }).filter(|p| cx.xf.rect.contains(*p)) +fn press_point(cx: &PageCx<'_>) -> Option { + (if cx.ptr.any_down { cx.ptr.origin } else { cx.ptr.pos }).filter(|p| cx.xf.rect.contains(*p)) } /// Whether `p` is on a resize handle of the selected comment, when it is a resizable `pick`. @@ -1320,7 +1321,7 @@ pub(crate) fn page_after_text(resp: &egui::Response, cx: &PageCx<'_>, view: &mut /// Paint comment selection, hover and gesture previews on a page. pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView) { let cv = &view.comments; - let pointer = ui.input(|i| i.pointer.hover_pos()); + let pointer = cx.ptr.pos; // A redaction mark shows its fill while the pointer is over it, as Acrobat's does, so the // marked area reads before anything is applied. if cv.gesture.is_none() @@ -1471,7 +1472,7 @@ fn paint_draw(painter: &egui::Painter, cx: &PageCx<'_>, tool: &CommentTool, poin CommentTool::Polygon | CommentTool::PolyLine | CommentTool::Cloud => { // The vertices so far, and a rubber band to the pointer. let mut line = pts.clone(); - if let Some(p) = painter.ctx().input(|i| i.pointer.hover_pos()) { + if let Some(p) = cx.ptr.pos { line.push(p); } painter.add(egui::Shape::line(line, stroke)); @@ -1582,7 +1583,17 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, } return None; }; - let cx = PageCx { page, xf: &xf, info, tool: QuickTool::Select, prefs, allowed: true, hidden: false, redact_mark: ([0.89, 0.13, 0.13], 1.0) }; + let cx = PageCx { + page, + xf: &xf, + ptr: &PagePointer::default(), + info, + tool: QuickTool::Select, + prefs, + allowed: true, + hidden: false, + redact_mark: ([0.89, 0.13, 0.13], 1.0), + }; let anchor = cx.to_screen(c.at); let viewport = view.viewport_rect(); let t = Tokens::get(ctx); @@ -1610,12 +1621,12 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, anchor = anchor.clamp(viewport.min, viewport.max); } let pos = pos2(anchor.x + 12.0, anchor.y); - let area = egui::Area::new(egui::Id::new(("comment-composer", view.id.0))).order(egui::Order::Foreground).constrain_to(viewport); + let area = egui::Area::new(egui::Id::unique(("comment-composer", view.id.0))).order(egui::Order::Foreground).constrain_to(viewport); area.fixed_pos(pos).show(ctx, |ui| { egui::Frame::popup(ui.style()).inner_margin(egui::Margin::same(12)).corner_radius(CornerRadius::same(8)).show(ui, |ui| { ui.set_width(260.0); ui.horizontal(|ui| { - ui.label(egui::RichText::new(crate::bidi::visual_auto(&prefs.author)).font(theme::semibold(13.0))); + ui.label(egui::RichText::new(prefs.author.clone()).font(theme::semibold(13.0))); ui.label(egui::RichText::new(tl!(title)).font(theme::regular(11.5)).color(t.text_faint)); }); ui.add_space(6.0); @@ -1625,8 +1636,10 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ComposerKind::Replace => tl!("Replacement text"), _ => tl!("Add a comment"), }; - let edit = - ui.add(egui::TextEdit::multiline(&mut c.text).hint_text(hint).desired_rows(3).desired_width(f32::INFINITY).id_salt("composer-text")); + let edit = ui + .add(egui::TextEdit::multiline(&mut c.text).hint_text(hint).desired_rows(3).desired_width(f32::INFINITY).id_salt("composer-text")) + // 0.37 kittest rejects an unnamed text input; the hint is its logical name. + .accessible_name(hint); if c.focus { edit.request_focus(); c.focus = false; @@ -1870,8 +1883,7 @@ pub(crate) fn nudge_keys(ctx: &egui::Context, view: &mut DocView, info: &DocInfo return; } let Some((page, index)) = cv.selected else { return }; - let movable = - info.annotations.iter().any(|a| a.page == page && a.index == index && a.in_reply_to.is_none() && !is_markup(&a.subtype) && !a.locked); + let movable = info.annotations.iter().any(|a| a.page == page && a.index == index && !a.is_reply() && !is_markup(&a.subtype) && !a.locked); let (Some(xf), Some(p)) = (view.page_xform(page), info.pages.get(page)) else { return }; if !movable { return; @@ -1934,7 +1946,7 @@ pub(crate) fn context_menu( can_modify: bool, ) -> Option { let mut action = None; - let selected = view.comments.selected.and_then(|(p, i)| info.annotations.iter().find(|a| a.page == p && a.index == i && a.in_reply_to.is_none())); + let selected = view.comments.selected.and_then(|(p, i)| info.annotations.iter().find(|a| a.page == p && a.index == i && !a.is_reply())); match selected { Some(a) => { let (page, index) = (a.page, a.index); @@ -1979,7 +1991,7 @@ pub(crate) fn context_menu( } }); }); - let thread: Vec<&pdfcraft_render::Annotation> = info.annotations.iter().filter(|r| a.name.is_some() && r.in_reply_to == a.name).collect(); + let thread: Vec<&pdfcraft_render::Annotation> = info.annotations.iter().filter(|r| r.replies_to(a)).collect(); let marked = crate::comments_panel::is_marked(&thread); if ui.add_enabled(allowed, egui::Button::new(tl!(if marked { "Remove checkmark" } else { "Mark with checkmark" }))).clicked() { action = Some(CanvasAction::Edit(Box::new(Edit::MarkAnnotation { page, index, marked: !marked, author: prefs.author.clone() }))); @@ -2046,8 +2058,9 @@ pub enum CanvasAction { /// How many custom colours the "Recent colours" row keeps. const RECENT_CUSTOM: usize = 5; +/// Return the stable egui id for the recent custom-colour palette. fn recent_custom_id() -> egui::Id { - egui::Id::new("pdfcraft-recent-custom-colours") + egui::Id::unique("pdfcraft-recent-custom-colours") } /// Custom colours picked with "Other colour…" this session, newest first (#1193). @@ -2081,7 +2094,7 @@ fn rgb_of(c: Color32) -> Rgb { /// One round swatch; `on` rings it as the current colour. fn swatch(ui: &mut egui::Ui, c: Rgb, on: bool, tooltip: &str) -> egui::Response { let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tooltip)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tooltip)); ui.painter().circle_filled(r.center(), 9.0, color32(c)); ui.painter().circle_stroke(r.center(), 9.0, Stroke::new(1.0, Color32::from_black_alpha(40))); if on || resp.hovered() { @@ -2103,7 +2116,7 @@ fn other_colour_swatch(ui: &mut egui::Ui, open: bool) -> egui::Response { ui.painter().circle_stroke(c, 11.0, Stroke::new(1.5, SELECT_BLUE)); } let label = tl!("Other colour…"); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, open, label)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, open, label)); resp.on_hover_text(label) } @@ -2192,7 +2205,7 @@ pub(crate) fn quick_bar_controls(ui: &mut egui::Ui, tool: CommentTool, prefs: &m let (r, resp) = ui.allocate_exact_size(vec2(32.0, 32.0), Sense::click()); ui.painter().circle_filled(r.center(), 9.0, color32(style.color)); ui.painter().circle_stroke(r.center(), 9.0, Stroke::new(1.0, Color32::from_black_alpha(50))); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Colour"))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Colour"))); let resp = resp.on_hover_text(tl!("Colour")); egui::Popup::menu(&resp).align(egui::RectAlign::RIGHT_START).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { if let Some(c) = swatch_grid(ui, Some(style.color)) { @@ -2256,7 +2269,7 @@ fn fill_button(ui: &mut egui::Ui, fill: Option) -> egui::Response { Some(_) => tl!("Fill colour"), None => tl!("Fill colour: none"), }; - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label)); resp.on_hover_text(label) } @@ -2305,6 +2318,8 @@ mod tests { modified: None, name: None, in_reply_to: None, + parent: None, + grouped: false, rect: [40.0, 20.0, 70.0, 40.0], color: None, index: 0, @@ -2326,6 +2341,7 @@ mod tests { let cx = PageCx { page: 0, xf: &xf, + ptr: &PagePointer::default(), info: &info, tool: QuickTool::Select, prefs: &prefs, @@ -2543,6 +2559,8 @@ mod tests { modified: None, name: None, in_reply_to: None, + parent: None, + grouped: false, rect: [10.0, 10.0, 50.0, 50.0], color: None, index: 0, @@ -2572,7 +2590,7 @@ mod tests { h.run(); h.get_by_label("Page").click_secondary(); h.run(); - h.get_by_label("Colour ⏵").click(); + h.get_by_label("Colour").click(); h.run(); h.get_by_label("Other colour…").click(); h.run(); diff --git a/crates/ui-egui/src/comments_panel.rs b/crates/ui-egui/src/comments_panel.rs index 6c13d78d33..54ca9e5152 100644 --- a/crates/ui-egui/src/comments_panel.rs +++ b/crates/ui-egui/src/comments_panel.rs @@ -1,8 +1,9 @@ //! The Comments panel (Acrobat's right-hand comment list; 11-visual-spec §3, audit `a15-*`). //! -//! Header "Comments N" with search; an "Add a comment" box that posts a sticky note on the -//! current page; cards grouped by page. Selecting a card selects the comment on the page and -//! opens its reply box and "…" menu (Edit, Set status, Delete). Status replies show as a badge. +//! Header "Comments N" (comments, as Acrobat counts them) with "N replies" under it and search; +//! an "Add a comment" box that posts a sticky note on the current page; cards grouped by page. +//! Selecting a card selects the comment on the page and opens its reply box and "…" menu (Edit, +//! Set status, Delete). Status replies show as a badge. use std::collections::HashMap; @@ -87,9 +88,16 @@ pub fn is_marked(thread: &[&Annotation]) -> bool { thread.iter().rev().find(|r| r.is_mark()).is_some_and(|r| r.state.as_deref() == Some("Marked")) } -/// The number shown in the header: comments and their replies (not status changes). +/// The number shown in the header: comments, not their replies or status changes (as Acrobat +/// counts them). pub fn count(info: &DocInfo) -> usize { - info.annotations.iter().filter(|a| a.state.is_none()).count() + info.annotations.iter().filter(|a| a.is_comment()).count() +} + +/// The replies under the header's count, at any depth: not status changes, checkmarks or a +/// Replace Text caret (grouped with its strikeout, not a reply to it). +pub fn reply_count(info: &DocInfo) -> usize { + info.annotations.iter().filter(|a| a.is_reply() && a.state.is_none() && !a.grouped).count() } fn matches(a: &Annotation, q: &str) -> bool { @@ -116,7 +124,9 @@ pub(crate) fn show( let mut edit = None; // Search (the header's magnifier toggles it). if let Some(q) = view.comments.search.as_mut() { - let r = ui.add(crate::widgets::line(q).hint_text(tl!("Search comments")).desired_width(f32::INFINITY).id_salt("comment-search")); + let r = ui + .add(crate::widgets::line(q).hint_text(tl!("Search comments")).desired_width(f32::INFINITY).id_salt("comment-search")) + .accessible_name(tl!("Search comments")); if view.comments.search_focus { r.request_focus(); view.comments.search_focus = false; @@ -125,9 +135,14 @@ pub(crate) fn show( } // "Add a comment": a sticky note on the current page, near its top-right corner. if allowed { - let r = ui.add( - crate::widgets::line(&mut view.comments.add_box).hint_text(tl!("Add a comment")).desired_width(f32::INFINITY).id_salt("comment-add-box"), - ); + let r = ui + .add( + crate::widgets::line(&mut view.comments.add_box) + .hint_text(tl!("Add a comment")) + .desired_width(f32::INFINITY) + .id_salt("comment-add-box"), + ) + .accessible_name(tl!("Add a comment")); if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && !view.comments.add_box.trim().is_empty() { let page = view.current.min(info.pages.len().saturating_sub(1)); if let Some(p) = info.pages.get(page) { @@ -157,25 +172,32 @@ pub(crate) fn show( return edit; } let query = view.comments.search.clone().filter(|q| !q.trim().is_empty()); - let mut replies: HashMap<&str, Vec<&Annotation>> = HashMap::new(); + let mut replies: HashMap<(usize, usize), Vec<&Annotation>> = HashMap::new(); let mut page_counts: HashMap = HashMap::new(); - // Keep global name matching and source reply order. Page badges count roots before filtering. + // Replies find their parent by reference (`/NM` need not be unique) and keep source order. + // A reply at any depth joins the thread of the comment it starts from, as comment_list lists + // it. A status or checkmark stays with the annotation it was set on (its `/IRT` names it, + // ISO 32000-2 §12.5.6.2): one set on a reply, at any depth, is that reply's and not the + // comment's, so the card's badge and checkmark leave it out (comment_list reports it on the + // reply). Page badges count roots before filtering. + let tops = pdfcraft_render::thread_tops(&info.annotations); for a in &info.annotations { - if let Some(parent) = a.in_reply_to.as_deref() { - replies.entry(parent).or_default().push(a); - } else { + if let Some(parent) = a.parent { + let key = if a.state.is_none() { tops.get(&(a.page, a.index)).copied().unwrap_or(parent) } else { parent }; + replies.entry(key).or_default().push(a); + } else if a.is_comment() { let count = page_counts.entry(a.page).or_default(); *count = count.saturating_add(1); } } - let replies_of = |a: &Annotation| -> &[&Annotation] { a.name.as_deref().and_then(|name| replies.get(name)).map(Vec::as_slice).unwrap_or(&[]) }; + let replies_of = |a: &Annotation| -> &[&Annotation] { replies.get(&(a.page, a.index)).map(Vec::as_slice).unwrap_or(&[]) }; let cv = &view.comments; let status_of = |a: &Annotation| replies_of(a).iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.clone()).unwrap_or_else(|| "None".into()); let mut roots: Vec<&Annotation> = info .annotations .iter() - .filter(|a| a.in_reply_to.is_none()) + .filter(|a| a.is_comment()) .filter(|a| query.as_deref().is_none_or(|q| matches(a, q) || replies_of(a).iter().any(|r| matches(r, q)))) .filter(|a| !cv.hidden_types.iter().any(|t| t == kind_label(a))) .filter(|a| !cv.hidden_authors.contains(&a.author.clone().unwrap_or_default())) @@ -223,9 +245,7 @@ pub(crate) fn show( if g.is_some() && g != group { ui.add_space(6.0); let title = g.clone().unwrap_or_default(); - // Catalog labels are already in display order; an author's name is put in it here. - let shown = if sort == SortBy::Author && a.author.is_some() { crate::bidi::visual_auto(&title) } else { tl!(&title).into() }; - ui.label(egui::RichText::new(shown).font(theme::semibold(12.5)).color(t.text_muted)); + ui.label(egui::RichText::new(tl!(&title)).font(theme::semibold(12.5)).color(t.text_muted)); group = g; } } else if a.page != page { @@ -272,6 +292,8 @@ pub(crate) fn show( edit } +/// Draw one comment thread card, including author, body or inline editor, replies, status and per-comment +/// actions; returns an edit to apply. #[allow(clippy::too_many_arguments)] fn card( ui: &mut egui::Ui, @@ -304,9 +326,11 @@ fn card( .show(ui, |ui| { ui.set_width(ui.available_width()); let mut buttons_left = f32::INFINITY; + let mut above = egui::Rect::NOTHING; let head = ui.horizontal(|ui| { - avatar(ui, t, Some(subtype_icon(&a.subtype)), None, color); - let author = a.author.as_deref().map_or_else(|| tl!("Unknown author").into(), crate::bidi::visual_auto); + above = avatar(ui, t, Some(subtype_icon(&a.subtype)), None, color); + // The catalogue and author names stay logical: egui lays out RTL text itself. + let author = a.author.as_deref().map_or_else(|| tl!("Unknown author"), |name| name); ui.label(egui::RichText::new(author).font(theme::semibold(12.5)).color(t.text)); if let Some(m) = &a.modified { ui.add(egui::Label::new(egui::RichText::new(m).font(theme::regular(11.0)).color(t.text_faint)).truncate()); @@ -376,15 +400,17 @@ fn card( }); } }); - // Replies, joined to the parent by a thread line. + // Replies, each joined to the avatar above it (the comment's or the previous reply's) + // by a thread line that runs the whole gap. for r in &replies { ui.add_space(8.0); - let resp = ui.horizontal(|ui| { - avatar(ui, t, None, Some(&initials(r.author.as_deref().unwrap_or("?"))), t.text_faint); + let mut me = egui::Rect::NOTHING; + ui.horizontal(|ui| { + me = avatar(ui, t, None, Some(&initials(r.author.as_deref().unwrap_or("?"))), t.text_faint); ui.vertical(|ui| { ui.spacing_mut().item_spacing.y = 2.0; ui.horizontal(|ui| { - let author = r.author.as_deref().map_or_else(|| tl!("Reply").into(), crate::bidi::visual_auto); + let author = r.author.as_deref().map_or_else(|| tl!("Reply"), |name| name); ui.label(egui::RichText::new(author).font(theme::semibold(12.0))); if let Some(m) = &r.modified { ui.add(egui::Label::new(egui::RichText::new(m).font(theme::regular(11.0)).color(t.text_faint)).truncate()); @@ -393,8 +419,12 @@ fn card( widgets::wrapped_text(ui, r.contents.as_deref().unwrap_or(""), theme::regular(12.5), t.text, false); }); }); - let x = resp.response.rect.left() + 12.0; - ui.painter().vline(x, (resp.response.rect.top() - 14.0)..=(resp.response.rect.top() - 2.0), Stroke::new(1.5, t.border)); + // The circles are 12 px in radius; leave a 1 px gap at each end. + let (top, bottom) = (above.center().y + 13.0, me.center().y - 13.0); + if above.is_positive() && top < bottom { + ui.painter().vline(me.center().x, top..=bottom, Stroke::new(1.5, t.border)); + } + above = me; } if selected && allowed { ui.add_space(8.0); @@ -439,7 +469,7 @@ fn card( // clickable everywhere. let hit = if selected { frame.inner } else { rect }; let click = ui - .interact(hit, ui.id().with(("card", key)), Sense::click()) + .interact(hit, ui.scope_id().with(("card", key)), Sense::click()) .on_hover_cursor(egui::CursorIcon::PointingHand) .on_hover_text(crate::i18n::fmt(tl!("{kind} — click to show it on the page"), &[("kind", tl!(kind_label(a)))])); if click.clicked() { @@ -450,7 +480,7 @@ fn card( if click.double_clicked() && allowed { view.comments.open_editor(a.page, a.index, a.contents.clone().unwrap_or_default()); } - let menu_id = ui.id().with(("card-menu", key)); + let menu_id = ui.scope_id().with(("card-menu", key)); if open_menu { egui::Popup::open_id(ui.ctx(), menu_id); } @@ -501,8 +531,8 @@ fn card( edit } -/// A 26 pt round badge: a type glyph (comments) or initials (replies). -fn avatar(ui: &mut egui::Ui, t: &Tokens, icon: Option<&str>, text: Option<&str>, color: Color32) { +/// A 26 pt round badge: a type glyph (comments) or initials (replies). Returns where it was drawn. +fn avatar(ui: &mut egui::Ui, t: &Tokens, icon: Option<&str>, text: Option<&str>, color: Color32) -> egui::Rect { let (r, _) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::hover()); let bg = if t.dark() { Color32::from_gray(0x55) } else { Color32::from_gray(0xD8) }; ui.painter().circle_filled(r.center(), 12.0, bg); @@ -512,6 +542,7 @@ fn avatar(ui: &mut egui::Ui, t: &Tokens, icon: Option<&str>, text: Option<&str>, if let Some(s) = text { ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, s, theme::semibold(10.5), t.text); } + r } /// Darken very light annotation colours (e.g. note yellow) so their glyph stays legible. @@ -530,15 +561,13 @@ fn legible(c: Color32, t: &Tokens) -> Color32 { pub(crate) fn header_controls(ui: &mut egui::Ui, info: &DocInfo, view: &mut DocView, hidden: bool) -> Option<&'static str> { let mut command = None; let cv = &mut view.comments; - let mut types: Vec = info.annotations.iter().filter(|a| a.in_reply_to.is_none()).map(|a| kind_label(a).to_string()).collect(); + let mut types: Vec = info.annotations.iter().filter(|a| a.is_comment()).map(|a| kind_label(a).to_string()).collect(); types.sort(); types.dedup(); - let mut authors: Vec = - info.annotations.iter().filter(|a| a.in_reply_to.is_none()).map(|a| a.author.clone().unwrap_or_default()).collect(); + let mut authors: Vec = info.annotations.iter().filter(|a| a.is_comment()).map(|a| a.author.clone().unwrap_or_default()).collect(); authors.sort(); authors.dedup(); - let mut colors: Vec<(String, Option<[f32; 3]>)> = - info.annotations.iter().filter(|a| a.in_reply_to.is_none()).map(|a| (color_key(a), a.color)).collect(); + let mut colors: Vec<(String, Option<[f32; 3]>)> = info.annotations.iter().filter(|a| a.is_comment()).map(|a| (color_key(a), a.color)).collect(); colors.sort_by(|a, b| a.0.cmp(&b.0)); colors.dedup_by(|a, b| a.0 == b.0); let active = !(cv.hidden_types.is_empty() @@ -615,8 +644,7 @@ pub(crate) fn header_controls(ui: &mut egui::Ui, info: &DocInfo, view: &mut DocV ui.separator(); ui.label(egui::RichText::new(tl!("Author")).small()); for a in &authors { - let name = crate::bidi::visual_auto(a); - toggle(ui, &mut cv.hidden_authors, a, if a.is_empty() { tl!("Unknown author") } else { &name }); + toggle(ui, &mut cv.hidden_authors, a, if a.is_empty() { tl!("Unknown author") } else { a.as_str() }); } ui.separator(); ui.label(egui::RichText::new(tl!("Status")).small()); diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 2ac624d3fc..0da3b235c8 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -8,6 +8,7 @@ use pdfcraft_engine::{Added, AddedContent, AddedText, Edit}; use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; +use crate::page_pointer::PagePointer; use crate::theme::Tokens; use crate::widgets; @@ -90,6 +91,11 @@ pub struct ContentView { } impl ContentView { + /// Forget an in-progress pointer grab for content editing. + pub(crate) fn cancel_pointer_gesture(&mut self) { + self.grab = None; + } + pub(crate) fn finish_draft(&mut self, added: &[Added]) -> Option { self.draft.take().and_then(|d| finish(self, d, added)) } @@ -117,6 +123,10 @@ pub(crate) struct MoveBackground { key: Option<(u64, usize, usize)>, renderer: Option, texture: Option, + /// The item alone on a transparent page: the pixels that follow the pointer. The page's own + /// raster has whatever the item covers in its box too, which must stay where it is (#4464). + item_renderer: Option, + item: Option, tag: Option, /// The item's display-space box when the gesture started. from: Option<[f64; 4]>, @@ -129,7 +139,12 @@ pub(crate) struct MoveBackground { impl MoveBackground { pub fn pending(&self) -> bool { - self.renderer.is_some() && self.texture.is_none() + self.renderer.is_some() && self.item_renderer.is_some() && (self.texture.is_none() || self.item.is_none()) + } + + /// Both rasters are in: the page without the item, and the item alone. + fn ready(&self) -> bool { + self.texture.is_some() && self.item.is_some() } pub fn prepare(&mut self, ctx: &egui::Context, doc: &pdfcraft_engine::Document, cv: &ContentView, scale: f32) { @@ -144,6 +159,7 @@ impl MoveBackground { *self = Self { key, ..Default::default() }; if let Some((_, page, index)) = key { self.renderer = doc.added_content_preview(page, index).ok().map(|p| p.renderer()); + self.item_renderer = doc.added_content_alone(page, index).ok().map(|p| p.renderer()); self.from = on_page(doc.added(), page).into_iter().find(|(i, _)| *i == index).map(|(_, a)| a.content.rect()); self.picture = picture(page, index); } @@ -151,39 +167,60 @@ impl MoveBackground { if self.settling.is_some() { return; } - let (Some((_, page, _)), Some(renderer)) = (self.key, self.renderer.as_ref()) else { return }; + let (Some((_, page, _)), Some(renderer), Some(item_renderer)) = (self.key, self.renderer.as_ref(), self.item_renderer.as_ref()) else { + return; + }; let Some(p) = doc.info.pages.get(page) else { return }; let max_side = ctx.input(|i| i.max_texture_side) as f32; let scale = scale.min(crate::canvas::BASE_SIDE.min(max_side) / p.width.max(p.height).max(1.0)); let tag = (scale * 1000.0) as u64; if self.tag != Some(tag) { - renderer.set_queue(vec![pdfcraft_render::RenderRequest { page, scale, tag, ..Default::default() }]); + for r in [renderer, item_renderer] { + r.set_queue(vec![pdfcraft_render::RenderRequest { page, scale, tag, ..Default::default() }]); + } self.tag = Some(tag); } - if let Some(out) = renderer.try_recv() { + if self.receive(ctx, tag) && self.pending() { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } + } + + /// Takes in the rasters rendered at `tag`. `false` when either failed: the preview is then + /// dropped whole, including rasters in from an earlier scale. + fn receive(&mut self, ctx: &egui::Context, tag: u64) -> bool { + let (Some(renderer), Some(item_renderer)) = (self.renderer.as_ref(), self.item_renderer.as_ref()) else { + return false; + }; + let mut failed = false; + for (r, slot, name) in + [(renderer, &mut self.texture, "added-content-move-background"), (item_renderer, &mut self.item, "added-content-move-item")] + { + let Some(out) = r.try_recv() else { continue }; if out.error.is_some() { - // Without a background the item stays put while dragged, as before. - self.renderer = None; - return; - } - if out.request.tag == tag { - self.texture = Some(ctx.load_texture( - "added-content-move-background", + failed = true; + } else if out.request.tag == tag { + *slot = Some(ctx.load_texture( + name, crate::canvas::texture_image([out.width as usize, out.height as usize], out.rgba), egui::TextureOptions::LINEAR, )); } } - if self.pending() { - ctx.request_repaint_after(std::time::Duration::from_millis(16)); + if failed { + // Without both rasters the item stays put while dragged, as before. + self.renderer = None; + self.item_renderer = None; + self.texture = None; + self.item = None; } + !failed } /// The move was applied: keep the preview, now at the new generation, until the page is /// rendered again. Any other edit ends it. pub fn committed(&mut self, edit: &Edit, generation: u64) { match edit { - Edit::UpdateContent { page, index, content } if self.texture.is_some() && self.key.is_some_and(|(_, p, i)| (p, i) == (*page, *index)) => { + Edit::UpdateContent { page, index, content } if self.ready() && self.key.is_some_and(|(_, p, i)| (p, i) == (*page, *index)) => { self.key = Some((generation, *page, *index)); self.settling = Some(content.rect()); } @@ -197,26 +234,17 @@ impl MoveBackground { } } - /// The page without the moving item, then the item's pixels taken from `page_tex` (the page - /// as rendered, item included where it was) at the dragged or applied position. - #[allow(clippy::too_many_arguments)] - pub fn paint( - &self, - ui: &egui::Ui, - painter: &egui::Painter, - xf: &PageXform, - page: usize, - info: &DocInfo, - cv: &ContentView, - page_tex: Option, - ) { - let (Some((_, kp, _)), Some(background), Some(page_tex), Some(from)) = (self.key, &self.texture, page_tex, self.from) else { return }; + /// The page without the moving item, then the item's own pixels (from its raster alone, so + /// nothing it covered comes along) at the dragged (to the pages' `pointer`) or applied + /// position. + pub fn paint(&self, painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, cv: &ContentView, pointer: Option) { + let (Some((_, kp, _)), Some(background), Some(item), Some(from)) = (self.key, &self.texture, &self.item, self.from) else { return }; if kp != page { return; } let r = screen_rect(xf, info, page, from); let to = if let Some((_, grab, start)) = cv.grab { - let Some(p) = ui.input(|i| i.pointer.hover_pos()) else { return }; + let Some(p) = pointer else { return }; dragged(r, grab, p - start, self.picture) } else if let Some(to) = self.settling { let to = screen_rect(xf, info, page, to); @@ -229,7 +257,7 @@ impl MoveBackground { xf.paint_image(&painter, background.id(), 0.0, 0.0, 1.0, 1.0); // A pixel around the box, so its edges come along. let (r, to) = (r.expand(1.0), to.expand(1.0)); - let mut mesh = egui::Mesh::with_texture(page_tex); + let mut mesh = egui::Mesh::with_texture(item.id()); for (a, b) in [ (r.left_top(), to.left_top()), (r.right_top(), to.right_top()), @@ -302,14 +330,12 @@ fn dragged(r: Rect, grab: Grab, d: egui::Vec2, keep_aspect: bool) -> Rect { } /// Added text and images keep pointer ownership over a form field while editing content. -pub(crate) fn select_grabs(ui: &egui::Ui, xf: &PageXform, page: usize, info: &DocInfo, added: &[Added], view: &DocView) -> bool { +pub(crate) fn select_grabs(xf: &PageXform, ptr: &PagePointer, page: usize, info: &DocInfo, added: &[Added], view: &DocView) -> bool { let cv = &view.content; if cv.grab.is_some_and(|(grab_page, _, _)| grab_page == page) { return true; } - let Some(p) = ui.input(|i| i.pointer.hover_pos().or(i.pointer.interact_pos())).filter(|p| xf.rect.contains(*p)) else { - return false; - }; + let Some(p) = ptr.within(xf.rect) else { return false }; on_page(added, page).into_iter().any(|(index, item)| { let rect = screen_rect(xf, info, page, item.content.rect()); rect.expand(2.0).contains(p) @@ -324,6 +350,7 @@ pub(crate) fn page_input( ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, + ptr: &PagePointer, page: usize, info: &DocInfo, added: &[Added], @@ -333,13 +360,13 @@ pub(crate) fn page_input( view: &mut DocView, ) -> bool { let mut consumed = false; - let pointer = ui.input(|i| i.pointer.hover_pos().or(i.pointer.interact_pos())); + let pointer = ptr.pos; let cv = &mut view.content; if let Some((gp, grab, start)) = cv.grab && gp == page { consumed = true; - if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) { + if resp.drag_stopped() || !ptr.primary_down { cv.grab = None; let end = pointer.unwrap_or(start); if let Some((sp, si)) = cv.selected @@ -408,7 +435,7 @@ pub(crate) fn page_input( ui.ctx().set_cursor_icon(egui::CursorIcon::Move); } if resp.drag_started() { - let origin = ui.input(|i| i.pointer.press_origin()).unwrap_or(p); + let origin = ptr.origin.unwrap_or(p); if let Some((_, left, top)) = selected_rect.and_then(|r| handles(r).into_iter().find(|(c, ..)| c.distance(origin) <= 6.0)) { cv.grab = Some((page, Grab::Corner(left, top), origin)); consumed = true; @@ -450,10 +477,18 @@ pub(crate) fn page_input( consumed || hit.is_some() && resp.is_pointer_button_down_on() } -/// Outlines of added items (hover and selection with handles) and the box being dragged. -pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, added: &[Added], view: &DocView) { +/// Outlines of added items (hover and selection with handles) and the box being dragged, the +/// pages' `pointer` over them. +pub(crate) fn paint_page( + painter: &egui::Painter, + xf: &PageXform, + page: usize, + info: &DocInfo, + added: &[Added], + view: &DocView, + pointer: Option, +) { let cv = &view.content; - let pointer = ui.input(|i| i.pointer.hover_pos()); for (i, a) in on_page(added, page) { if cv.draft.as_ref().is_some_and(|d| d.page == page && d.index == Some(i)) { continue; @@ -555,7 +590,7 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad // Shown at once, like the paragraph editor: a fade-in shows the page's copy through it. Over // the page, so only where the document shows: text scrolled under the mode bar or a side // panel must leave them their pointer (#2755). - egui::Area::new(egui::Id::new(("added-text", view.id.0))) + egui::Area::new(egui::Id::unique(("added-text", view.id.0))) .fade_in(false) .order(egui::Order::Foreground) .fixed_pos(at) @@ -589,7 +624,10 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad .font(font.clone()) .desired_width(width) .desired_rows(1) - .horizontal_align(halign) + .align(egui::Align2::new(halign, egui::Align::TOP)) + // Keep the page editor's original inset; the theme's larger button padding + // otherwise shifts narrow right-aligned boxes away from their text edge. + .margin(egui::Margin::symmetric(4, 2)) .background_color(if background_ready { Color32::TRANSPARENT } else { Color32::WHITE }) .text_color(Color32::from_rgb(cr, cg, cb)) .hint_text(tl!("Type text")) @@ -597,7 +635,7 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad if letter_spacing != 0.0 { edit = edit.layouter(&mut spaced); } - let resp = ui.add(edit); + let resp = ui.add(edit).accessible_name(tl!("Added text")); if t.focus { resp.request_focus(); t.focus = false; @@ -935,19 +973,28 @@ pub(crate) fn image_panel(ui: &mut egui::Ui, t: &Tokens, img: &pdfcraft_engine:: } }); ui.label(egui::RichText::new(tl!("Crop (% trimmed from each side)")).small().color(t.text_muted)); - let mut crop = img.crop.map(|v| (v * 100.0).round()); + let most = (1.0 - pdfcraft_engine::AddedImage::MIN_CROP_KEEP) * 100.0; + // Unrounded: a side the user doesn't touch keeps its stored value, also one read from a file. + let shown = img.crop.map(|v| v * 100.0); + let mut crop = shown; let mut changed = false; ui.horizontal(|ui| { for (k, label) in ["L", "B", "R", "T"].into_iter().enumerate() { ui.label(label); - // Applied when the drag ends, so a drag is one undo step. - let r = ui.add(egui::DragValue::new(&mut crop[k]).range(0.0..=45.0).speed(0.5).suffix("%")); + // Up to what the opposite side leaves. Applied when the drag ends: one undo step. + let room = (most - crop.get((k + 2) % 4).copied().unwrap_or(0.0)).max(0.0); + let Some(side) = crop.get_mut(k) else { continue }; + let r = ui.add(egui::DragValue::new(side).range(0.0..=room).clamp_existing_to_range(false).speed(0.5).suffix("%")); changed |= r.drag_stopped() || (r.changed() && !r.dragged()); } }); if changed { let mut i = img.clone(); - i.crop = crop.map(|v| v / 100.0); + for (stored, (now, was)) in i.crop.iter_mut().zip(crop.iter().zip(&shown)) { + if now != was { + *stored = now / 100.0; + } + } if i.crop != img.crop { out = Some(ImageAction::Update(AddedContent::Image(i))); } @@ -1015,6 +1062,35 @@ mod tests { assert!(i.flip_v && !i.flip_h, "Flip vertical mirrors top and bottom"); } + /// #4464: both rasters were in at one scale and a render at a new scale failed. The rasters + /// from the old scale must go too, or the item keeps following the pointer although the + /// preview gave up. + #[test] + fn failed_rerender_drops_the_rasters_from_the_earlier_scale() { + let ctx = egui::Context::default(); + let texture = |name| Some(ctx.load_texture(name, egui::ColorImage::filled([1, 1], Color32::WHITE), egui::TextureOptions::LINEAR)); + // Bytes no parser accepts: every render answers with an error. + let pool = || { + let pool = pdfcraft_render::RenderPool::new_inline(std::sync::Arc::new(b"not a pdf".to_vec()), Default::default()); + pool.set_queue(vec![pdfcraft_render::RenderRequest { page: 0, scale: 2.0, tag: 2000, ..Default::default() }]); + pool + }; + let mut mb = MoveBackground { + key: Some((0, 0, 0)), + renderer: Some(pool()), + texture: texture("background"), + item_renderer: Some(pool()), + item: texture("item"), + tag: Some(2000), + from: Some([0.0, 0.0, 10.0, 10.0]), + ..Default::default() + }; + assert!(mb.ready(), "both rasters from the earlier scale are in"); + assert!(!mb.receive(&ctx, 2000), "the new scale's renders fail"); + assert!(mb.texture.is_none() && mb.item.is_none(), "no raster is left to draw the moving item from"); + assert!(!mb.ready() && !mb.pending()); + } + /// #4245: the page counts the spacing after a line's last character too, so a line that fits /// only without it wraps in the saved text and must wrap in the editor. #[test] diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 47c5b200a3..db6e2445db 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -53,21 +53,14 @@ use serde_json::{Value, json}; pub type Reply = Result; /// Seconds to wait for frames (input to be handled, a screenshot to be taken) before reporting -/// that the window is not being drawn. While a window is hidden, minimized or fully covered, -/// eframe keeps calling `logic` but runs no egui pass, so input and screenshots wait. +/// that the window is not being drawn. `App::logic` also runs while a window is hidden, +/// minimized or fully covered, where egui's own input clock does not advance; every deadline +/// here uses a monotonic `web_time::Instant` so hidden logic cannot stall or restart it. const NOT_DRAWN_TIMEOUT: f64 = 5.0; -/// Wall-clock seconds (egui's own clock stops while the window is not drawn). -fn now_secs() -> f64 { - #[cfg(not(target_arch = "wasm32"))] - { - static START: std::sync::OnceLock = std::sync::OnceLock::new(); - START.get_or_init(std::time::Instant::now).elapsed().as_secs_f64() - } - #[cfg(target_arch = "wasm32")] - { - js_sys::Date::now() / 1000.0 - } +/// Return the deadline used to reject control requests that wait for a drawn window. +fn timeout() -> std::time::Duration { + std::time::Duration::from_secs_f64(NOT_DRAWN_TIMEOUT) } fn not_drawn(what: &str) -> String { @@ -76,8 +69,23 @@ fn not_drawn(what: &str) -> String { ) } +/// Return the actionable error text for a control request whose window was not drawn in time. +fn still_starting() -> String { + format!("the app was still starting after {NOT_DRAWN_TIMEOUT:.0} s; the control request was not applied, retry when the window is drawn") +} + +/// The app finished starting, but only after this request's one admission deadline had already +/// passed. Report that instead of claiming the app is still starting. +fn startup_deadline_expired() -> String { + format!( + "the app finished starting after this control request's {NOT_DRAWN_TIMEOUT:.0} s deadline; the request was not applied, retry now that the window is drawn" + ) +} + /// One request from a client, answered on `reply`. pub struct ControlRequest { + /// Admission time on the sending thread, including time waiting for the next app tick. + pub since: web_time::Instant, pub method: String, pub params: Value, pub reply: Sender, @@ -94,7 +102,7 @@ impl ControlClient { /// Queue a request; the reply arrives on the returned receiver once the app has handled it. pub fn send(&self, method: &str, params: Value) -> Receiver { let (reply, rx) = channel(); - let req = ControlRequest { method: method.into(), params, reply }; + let req = ControlRequest { since: web_time::Instant::now(), method: method.into(), params, reply }; if let Err(e) = self.tx.send(req) { let _ = e.0.reply.send(Err("the app has closed".into())); } @@ -109,12 +117,99 @@ struct Shared { nodes: HashMap, root: Option, focus: Option, - /// Event batches to inject, one batch per frame. - inject: VecDeque>, - /// Real egui passes so far (counted in `output_hook`, which `logic`-only calls never reach). + /// Queued synthetic-input requests, each with one event batch per UI pass. + inject: VecDeque, + /// Accepted (not discarded) egui passes, counted in `output_hook`. passes: u64, + /// The document/focus generation the app last admitted input for. + epoch: u64, + /// Input requests whose queued events became stale before a UI pass consumed them. + dropped: Vec, + next_batch: u64, + /// A batch admitted before its deadline may finish this pass. Expiry runs before the next + /// input hook (or in hidden logic), never after egui has already calculated its interaction. + in_pass: bool, + expired: Vec, + cleanup: Vec, + cancel_pointer: bool, + cancel_gestures: bool, } +/// One queued synthetic-input request: its batches are injected one per UI pass. +struct RequestBatches { + request: u64, + /// The document/dialog/focus generation the request was admitted for. + epoch: u64, + /// The first batch was injected, so a focus/dialog change does not reject its own remaining + /// batches. The admission deadline still cancels a partially delivered gesture. + started: bool, + since: web_time::Instant, + /// Inverse presses still held by this request. Pointer entries are ownership markers for + /// cancellation only: replaying one on timeout would click or drop the abandoned gesture. + release: Vec, + batches: VecDeque>, +} + +/// Track only presses that reached egui and have not yet been released. A pointer release is +/// retained as an ownership marker, never as a timeout event. +fn note_pressed(release: &mut Vec, events: &[egui::Event]) { + for event in events { + match event { + egui::Event::Key { key, pressed: true, .. } + if !release.iter().any(|event| matches!(event, egui::Event::Key { key: seen, pressed: false, .. } if seen == key)) => + { + release.push(egui::Event::Key { key: *key, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::NONE }); + } + egui::Event::PointerButton { pos, button, pressed: true, .. } + if !release + .iter() + .any(|event| matches!(event, egui::Event::PointerButton { button: seen, pressed: false, .. } if seen == button)) => + { + release.push(egui::Event::PointerButton { pos: *pos, button: *button, pressed: false, modifiers: egui::Modifiers::NONE }); + } + egui::Event::ModifiersChanged(modifiers) + if *modifiers != egui::Modifiers::NONE && !release.contains(&egui::Event::ModifiersChanged(egui::Modifiers::NONE)) => + { + release.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + } + egui::Event::Key { key, pressed: false, .. } => { + release.retain(|event| !matches!(event, egui::Event::Key { key: seen, .. } if seen == key)); + } + egui::Event::PointerButton { button, pressed: false, .. } => { + release.retain(|event| !matches!(event, egui::Event::PointerButton { button: seen, .. } if seen == button)); + } + egui::Event::ModifiersChanged(modifiers) if *modifiers == egui::Modifiers::NONE => { + release.retain(|event| !matches!(event, egui::Event::ModifiersChanged(_))); + } + _ => {} + } + } +} + +/// Cancel expired requests before egui computes interaction. Hidden logic calls this too, so +/// replies do not wait for a visible frame. Never complete an abandoned pointer gesture. +fn expire_input(shared: &mut Shared, now: web_time::Instant) { + shared.inject.retain_mut(|request| { + if now.duration_since(request.since) <= timeout() { + return true; + } + shared.expired.push(request.request); + for event in request.release.drain(..) { + if matches!(event, egui::Event::PointerButton { .. }) { + shared.cancel_pointer = true; + shared.cancel_gestures = true; + } else { + shared.cleanup.push(event); + } + } + false + }); +} + +/// Synthetic input is bounded like screenshots: a local authenticated client must not be able to +/// queue unbounded batches (one frame each) and hold the control channel's memory for ever. +const MAX_QUEUED_BATCHES: usize = 512; + /// The egui plugin half of the control channel. pub struct ControlPlugin { shared: Arc>, @@ -125,19 +220,71 @@ impl egui::Plugin for ControlPlugin { "pdfcraft-control" } + /// Inject admitted control batches into the current pass, dropping stale requests, applying cancellations + /// and scheduling repaints. fn input_hook(&mut self, ctx: &egui::Context, input: &mut egui::RawInput) { let Ok(mut s) = self.shared.lock() else { return }; - if let Some(batch) = s.inject.pop_front() { - input.events.extend(batch); + expire_input(&mut s, web_time::Instant::now()); + if std::mem::take(&mut s.cancel_pointer) { + // input_hook precedes Context::begin_pass. stop_dragging sets the OLD snapshot's + // drag_stopped; begin_pass replaces that snapshot without a release/click/drop. + ctx.input_mut(|i| i.pointer = Default::default()); + ctx.stop_dragging(); + egui::DragAndDrop::clear_payload(ctx); + } + input.events.append(&mut s.cleanup); + s.in_pass = true; + let epoch = s.epoch; + let Some(front) = s.inject.front_mut() else { return }; + if !front.started { + if front.epoch != epoch { + // The document, dialog or focus changed after this request was admitted, before + // any of its input landed. Drop only this request; later requests are unrelated. + let request = front.request; + s.inject.pop_front(); + s.dropped.push(request); + if !s.inject.is_empty() { + ctx.request_repaint(); + } + return; + } + front.started = true; + } + // A non-expired request's own earlier batch may have changed focus or opened a dialog. + // Let it finish normally; expiry above cancels held pointer state instead of completing + // an abandoned click/drop. + let events = front.batches.pop_front(); + if let Some(events) = &events { + // Remember the inverse of what this request presses as it is applied, so a timeout + // after this batch can still release exactly those inputs. + note_pressed(&mut front.release, events); + } + let done = front.batches.is_empty(); + if done { + s.inject.pop_front(); + } + if let Some(events) = events { + input.events.extend(events); } if !s.inject.is_empty() { ctx.request_repaint(); } } - fn output_hook(&mut self, _ctx: &egui::Context, output: &mut egui::FullOutput) { + /// Record presented passes and the accesskit tree and focus after a pass, so control replies observe only + /// drawn frames. + fn output_hook(&mut self, ctx: &egui::Context, output: &mut egui::FullOutput) { let Ok(mut s) = self.shared.lock() else { return }; - s.passes += 1; + s.in_pass = false; + // A discarded pass is layout work, not a frame the agent can observe. Only count passes + // whose output is actually presented, so a request cannot be answered early. The last + // pass of a frame is presented even when its own discard request was refused because + // `max_passes` is reached; egui still reports that request in the output. + let max_passes = ctx.options(|o| o.max_passes.get()); + let final_pass = output.platform_output.num_completed_passes >= max_passes; + if output.platform_output.request_discard_reasons.is_empty() || final_pass { + s.passes += 1; + } let Some(update) = &output.platform_output.accesskit_update else { return }; s.nodes = update.nodes.iter().cloned().collect(); s.root = update.tree.as_ref().map(|t| t.root); @@ -145,30 +292,271 @@ impl egui::Plugin for ControlPlugin { } } +/// Screenshot admission limits: bounded requests, per-image bytes and aggregate retained bytes. +const MAX_PENDING_SCREENSHOTS: usize = 4; +const MAX_IMAGE_BYTES: usize = 128 * 1024 * 1024; +const MAX_TOTAL_BYTES: usize = 256 * 1024 * 1024; +/// One image's encode working copies are bounded too: the RGBA buffer fed to the PNG encoder, +/// the encoded PNG, and the base64 string in the reply. They are made one capture at a time, +/// after the retained image budget above has been checked. Peak encoding memory is therefore at +/// most the retained aggregate plus one crop, one PNG and one base64 string; it is not hidden +/// behind the retained-image limit. +const MAX_ENCODED_BYTES: usize = MAX_IMAGE_BYTES; +const MAX_BASE64_BYTES: usize = 192 * 1024 * 1024; + +/// Replace one pending capture's reservation with `bytes`, keeping the other reservations. +/// +/// The drain retries captures one at a time: using a single total taken before the loop would +/// let several retries each see room that no longer exists together, and the aggregate would +/// grow past [`MAX_TOTAL_BYTES`]. +fn retried_reservation(total: usize, old: usize, bytes: usize) -> Option { + let without_this = total.saturating_sub(old); + let new_total = without_this.saturating_add(bytes); + (bytes <= MAX_IMAGE_BYTES && new_total <= MAX_TOTAL_BYTES).then_some(new_total) +} + +/// The window content a screenshot request was admitted and issued for. +/// +/// Only the viewport's local size and scale matter: the window's position on the monitor does not +/// change a single captured pixel, and keyboard focus is not part of the captured content. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Geometry { + size: egui::Vec2, + pixels_per_point: f32, + /// Active document and open dialog/prompt identity, never the focused widget. + epoch: u64, +} + +impl Geometry { + /// Capture the current viewport size and effective pixels-per-point for the request epoch. + fn current(ctx: &egui::Context, epoch: u64) -> Self { + // Viewport info is refreshed by `run_logic`; the last UI pass's `viewport_rect` can be + // stale while the window is hidden. Fall back to it only when the host cannot say. Only + // its size is kept: `inner_rect` is in monitor space and its origin may move. + let (rect, native) = ctx.input(|i| { + let viewport = i.viewport(); + (viewport.inner_rect.unwrap_or_else(|| i.viewport_rect()), viewport.native_pixels_per_point) + }); + let native = native.or_else(|| ctx.native_pixels_per_point()).unwrap_or(1.0); + Self { size: rect.size(), pixels_per_point: native * ctx.zoom_factor(), epoch } + } + + /// Checked bytes an image of this geometry needs, before the capture is issued. + fn image_bytes(self) -> Option { + let width = (self.size.x * self.pixels_per_point).ceil(); + let height = (self.size.y * self.pixels_per_point).ceil(); + if !(width.is_finite() && height.is_finite()) || width <= 0.0 || height <= 0.0 { + return None; + } + checked_image_bytes(width as usize, height as usize) + } + + /// Whether `region` is a non-empty rectangle inside this viewport, in logical points. + fn contains_region(self, region: egui::Rect) -> bool { + region.min.x.is_finite() + && region.min.y.is_finite() + && region.max.x.is_finite() + && region.max.y.is_finite() + && region.min.x >= 0.0 + && region.min.y >= 0.0 + && region.max.x > region.min.x + && region.max.y > region.min.y + && region.max.x <= self.size.x + && region.max.y <= self.size.y + } +} + +/// Return the byte size of an RGBA image of the given dimensions, or `None` when the multiplication +/// overflows. +fn checked_image_bytes(width: usize, height: usize) -> Option { + width.checked_mul(height)?.checked_mul(core::mem::size_of::()) +} + enum Pending { - /// Answer after `frames` more frames (the injected input has been handled by then). + /// Answer after `frames` more accepted frames (the injected input has been handled by then). Frames { - /// Answer once this many egui passes have run (the injected input has been handled). + /// Answer once this many accepted UI passes have run. until_pass: u64, - since: f64, + /// The queued batches this request depends on; a stale one fails the request. + batches: Vec, + since: web_time::Instant, reply: Sender, value: Value, }, Screenshot { - tag: u64, + slot: craft_ui::capture::CaptureSlot, + /// The one-shot callback, kept until the settled pass that issues it. + callback: Option, region: Option, reply: Sender, - /// When it was requested (egui time, seconds). - since: f64, + since: web_time::Instant, + geometry: Geometry, + /// Bytes reserved in the aggregate budget for this request. + reserved: usize, + attempts: u8, }, } +/// A request that arrived while the app could not yet run a real UI pass (fonts not ready). +struct DeferredRequest { + request: ControlRequest, + /// When this request reached the control queue. One admission deadline runs from here through + /// startup, input injection, pending frames and screenshot issuance: dispatching it later must + /// not start a fresh 5 s in each phase, and a request that used up the deadline while the app + /// was starting is never applied against a later UI state. + since: web_time::Instant, +} + +/// A screenshot pending in the `finish_pending` pass, with ownership of its reply. +struct PendingCapture { + slot: craft_ui::capture::CaptureSlot, + callback: Option, + region: Option, + reply: Sender, + since: web_time::Instant, + geometry: Geometry, + reserved: usize, + attempts: u8, +} + +impl PendingCapture { + /// Convert a screenshot request into the pending form that later frames finish. + fn into_pending(self) -> Pending { + Pending::Screenshot { + slot: self.slot, + callback: self.callback, + region: self.region, + reply: self.reply, + since: self.since, + geometry: self.geometry, + reserved: self.reserved, + attempts: self.attempts, + } + } +} + +/// The state one `finish_pending` pass needs while resolving pending replies. +struct PendingFinisher { + now: web_time::Instant, + passes: u64, + current: Geometry, + in_pass: bool, + dropped: Vec, + expired: Vec, + reserved_total: usize, +} + +impl PendingFinisher { + /// Answer or keep one queued-input request, preserving its one arrival deadline. + fn resolve_frames(&self, until_pass: u64, batches: Vec, since: web_time::Instant, reply: Sender, value: Value) -> Option { + if batches.iter().any(|id| self.expired.contains(id)) { + let _ = reply.send(Err(not_drawn("handling the input"))); + return None; + } + if batches.iter().any(|id| self.dropped.contains(id)) { + let _ = reply.send(Err("the document, dialog or focus changed before the input could be applied; nothing was done".into())); + return None; + } + if self.passes >= until_pass { + let _ = reply.send(Ok(value)); + return None; + } + if !self.in_pass && self.now.duration_since(since) > timeout() { + let _ = reply.send(Err(not_drawn("handling the input"))); + return None; + } + Some(Pending::Frames { until_pass, batches, since, reply, value }) + } + + /// Give one reservation back to the running aggregate budget. + fn release_reservation(&mut self, reserved: usize) { + self.reserved_total = self.reserved_total.saturating_sub(reserved); + } + + /// Resolve one screenshot request, keeping it only while its capture is still pending. + fn finish_capture(&mut self, ctx: &egui::Context, capture: PendingCapture) -> Option { + // Completion cannot restart or bypass the admission deadline, even when the callback + // arrived before this delayed tick or a resize would retry it. + if self.now.duration_since(capture.since) > timeout() { + capture.slot.cancel(); + self.release_reservation(capture.reserved); + let _ = capture.reply.send(Err(not_drawn("the screenshot"))); + return None; + } + match capture.slot.take() { + Some(Ok(image)) => self.finish_captured_image(ctx, capture, image), + Some(Err(error)) => { + self.release_reservation(capture.reserved); + let _ = capture.reply.send(Err(format!("the screenshot could not be captured: {error}"))); + None + } + None => Some(capture.into_pending()), + } + } + + /// Finish a screenshot whose callback produced an image. + fn finish_captured_image(&mut self, ctx: &egui::Context, capture: PendingCapture, image: Arc) -> Option { + if self.current.epoch != capture.geometry.epoch { + // The document/dialog changed: never save a capture of the previous context, and do + // not retry it into the new one. + self.release_reservation(capture.reserved); + let _ = capture.reply.send(Err("the document or dialog changed while the screenshot was being captured; it was not saved".into())); + return None; + } + if self.current.size != capture.geometry.size || self.current.pixels_per_point != capture.geometry.pixels_per_point { + // A late completion after a resize/DPI change is never encoded: retry once with a + // fresh slot, else report a defined error. + return self.retry_captured_image(ctx, capture); + } + self.release_reservation(capture.reserved); + let _ = capture.reply.send(screenshot_png(&image, capture.region, capture.geometry)); + None + } + + /// Retry one resize/DPI-stale capture with the current geometry. + fn retry_captured_image(&mut self, ctx: &egui::Context, capture: PendingCapture) -> Option { + if capture.attempts >= 1 { + self.release_reservation(capture.reserved); + let _ = capture.reply.send(Err("the window geometry changed while the screenshot was being captured; it was not saved".into())); + return None; + } + if capture.region.as_ref().is_some_and(|region| !self.current.contains_region(*region)) { + self.release_reservation(capture.reserved); + let _ = capture.reply.send(Err("the region must lie inside the window".into())); + return None; + } + let Some(bytes) = self.current.image_bytes() else { + self.release_reservation(capture.reserved); + let _ = capture.reply.send(Err("the window has no capturable size".into())); + return None; + }; + let Some(new_total) = retried_reservation(self.reserved_total, capture.reserved, bytes) else { + self.release_reservation(capture.reserved); + let _ = capture.reply.send(Err("the screenshot no longer fits the screenshot budget".into())); + return None; + }; + self.reserved_total = new_total; + let (slot, callback) = craft_ui::capture::CaptureSlot::new(ctx, egui::ViewportId::ROOT, MAX_IMAGE_BYTES); + Some(Pending::Screenshot { + slot, + callback: Some(callback), + region: capture.region, + reply: capture.reply, + since: capture.since, + geometry: self.current, + reserved: bytes, + attempts: capture.attempts + 1, + }) + } +} + /// The app half: the request queue and requests waiting for later frames. pub struct Control { rx: Receiver, shared: Arc>, pending: Vec, - next_tag: u64, + /// Requests admitted while the app cannot yet run a real UI pass (fonts not ready). + deferred: VecDeque, } /// Install the control channel on `ctx`: returns the app half and a client. @@ -177,117 +565,363 @@ pub fn attach(ctx: &egui::Context) -> (Control, ControlClient) { ctx.add_plugin(ControlPlugin { shared: shared.clone() }); ctx.enable_accesskit(); let (tx, rx) = channel(); - (Control { rx, shared, pending: Vec::new(), next_tag: 1 }, ControlClient { tx, ctx: ctx.clone() }) + (Control { rx, shared, pending: Vec::new(), deferred: VecDeque::new() }, ControlClient { tx, ctx: ctx.clone() }) } /// What a request needs from the app (implemented by `PdfCraftApp`). pub(crate) trait Host { + /// Return the UI-state snapshot reported by `ui.state`. fn state(&self) -> Value; + /// Run one registered command by id and return its reply. fn command(&mut self, id: &str) -> Reply; - fn dock(&mut self, reset: bool, params: &Value) -> Reply; + /// Apply or reset the side panel layout from a control request. + fn panel(&mut self, reset: bool, params: &Value) -> Reply; + /// List the commands available to the control channel. fn commands(&self) -> Value; + /// Set one application option from a control request. fn set(&mut self, key: &str, value: &str) -> Reply; + /// Open a document path requested through the control channel. fn open(&mut self, path: &str) -> Reply; + /// Request application exit; returns whether it closed without asking for confirmation. fn quit(&mut self, ctx: &egui::Context) -> bool; + + /// A generation that changes when the input target (document, dialog, focus) does. + /// + /// Synthetic input admitted for one generation must not be replayed into another. + fn input_epoch(&self) -> u64 { + 0 + } + + /// A generation for the captured content (document and dialog identity only). + /// + /// Screenshots do not depend on keyboard focus, so a field gaining focus must not discard a + /// pending capture. Hosts that do not distinguish the two reuse the input generation. + fn capture_epoch(&self) -> u64 { + self.input_epoch() + } + + /// Whether the app can apply a real UI pass yet (fonts installed, no early return). + fn accepts_input(&self) -> bool { + true + } + + /// Drop transient app-owned grabs before widgets observe the canceled pointer as up. + fn cancel_pointer_gestures(&mut self) {} } impl Control { - /// Handle new requests and finish waiting ones. Call at the start of every frame. + /// Handle new requests and finish waiting ones. Call at the start of every frame (`logic`). pub(crate) fn tick(&mut self, ctx: &egui::Context, host: &mut impl Host) { - self.finish_pending(ctx); - while let Ok(req) = self.rx.try_recv() { - match self.handle(ctx, host, &req.method, &req.params) { - Handled::Now(r) => { - let _ = req.reply.send(r); + self.set_epoch(host.input_epoch()); + self.finish_pending(ctx, host); + let ready = host.accepts_input(); + if !ready { + // Expiry applies while the app is still starting too: a request that waited through + // the startup gate must fail at the same deadline instead of running whenever fonts + // arrive. + let now = web_time::Instant::now(); + while self.deferred.front().is_some_and(|deferred| now.duration_since(deferred.since) > timeout()) { + if let Some(deferred) = self.deferred.pop_front() { + let _ = deferred.request.reply.send(Err(still_starting())); } - Handled::AfterFrames(frames, value) => { - let until_pass = self.passes() + u64::from(frames); - self.pending.push(Pending::Frames { until_pass, since: now_secs(), reply: req.reply, value }); - } - Handled::Screenshot(region) => { - let tag = self.next_tag; - self.next_tag += 1; - ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(tag))); - self.pending.push(Pending::Screenshot { tag, region, reply: req.reply, since: now_secs() }); + } + } else { + // Deferred requests reached the queue before anything still in the channel, so they + // are dispatched first: one client's requests keep their arrival order. Each carries + // its own admission timestamp, so startup time counts against its deadline. + while let Some(deferred) = self.deferred.pop_front() { + if web_time::Instant::now().duration_since(deferred.since) > timeout() { + let _ = deferred.request.reply.send(Err(startup_deadline_expired())); + } else { + self.dispatch(ctx, host, deferred.request, deferred.since); } } } - if !self.pending.is_empty() { + while let Ok(req) = self.rx.try_recv() { + // One admission timestamp per request: queued input, pending frames and an admitted + // screenshot all measure their deadline from the sending thread, including time + // waiting in this channel, never from a later phase. + let since = req.since; + if web_time::Instant::now().duration_since(since) > timeout() { + let _ = req.reply.send(Err(if ready { startup_deadline_expired() } else { still_starting() })); + continue; + } + if !ready { + if self.deferred.len() < 32 { + self.deferred.push_back(DeferredRequest { request: req, since }); + } else { + let _ = req.reply.send(Err("the app is still starting; too many control requests queued".into())); + } + continue; + } + self.dispatch(ctx, host, req, since); + } + if !self.pending.is_empty() || !self.deferred.is_empty() { ctx.request_repaint(); + // A hidden or occluded window runs `logic` without a UI pass. Ask for one so queued + // input and screenshots can be applied; the screenshot command itself also paints. + // + // The shared fork's web host does not implement `RequestPaintWhileHidden` yet: a + // hidden browser tab still times out rather than falsely succeeding. Recorded as a + // pending fork prerequisite, not qualified here. + #[cfg(not(target_arch = "wasm32"))] + if ctx.input(|i| i.viewport().visible()) != Some(true) { + ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden); + } } } - fn finish_pending(&mut self, ctx: &egui::Context) { - let shots: Vec<(u64, Arc)> = ctx.input(|i| { - i.raw - .events - .iter() - .filter_map(|e| match e { - egui::Event::Screenshot { user_data, image, .. } => { - user_data.data.as_ref().and_then(|d| d.downcast_ref::()).map(|tag| (*tag, image.clone())) + /// Refresh the input-target generation before a pass consumes queued synthetic input. + /// + /// `raw_input_hook` runs before the plugin's `input_hook`, so a dialog opened by the + /// previous pass already invalidates a request that has not started yet, instead of a pass + /// later. Only that request is rejected; requests already started finish their batches. + pub(crate) fn set_epoch(&self, epoch: u64) { + if let Ok(mut s) = self.shared.lock() { + s.epoch = epoch; + } + } + + /// Issue admitted captures after a settled UI pass. Call at the end of `App::ui`. + pub(crate) fn finish_frame(&mut self, ctx: &egui::Context, host: &mut impl Host) { + if !host.accepts_input() || ctx.will_discard() { + return; + } + let current = Geometry::current(ctx, host.capture_epoch()); + let mut reserved_total = self.reserved_bytes(); + let mut kept = Vec::new(); + for pending in self.pending.drain(..) { + let mut entry = pending; + if let Pending::Screenshot { slot, callback, reply, region, geometry, reserved, attempts, since } = &mut entry + && callback.is_some() + { + if web_time::Instant::now().duration_since(*since) > timeout() { + slot.cancel(); + reserved_total = reserved_total.saturating_sub(*reserved); + let _ = reply.send(Err(not_drawn("the screenshot"))); + continue; + } + if geometry.epoch != current.epoch { + slot.cancel(); + reserved_total = reserved_total.saturating_sub(*reserved); + let _ = reply.send(Err("the document or dialog changed before the screenshot could be issued; it was not saved".into())); + continue; + } + if *geometry != current { + // Resize, DPI or zoom between admission and issuance: retry once with the + // real geometry rather than cropping with stale numbers. + if *attempts >= 1 { + slot.cancel(); + reserved_total = reserved_total.saturating_sub(*reserved); + let _ = reply.send(Err("the window geometry changed while the screenshot was being prepared; it was not saved".into())); + continue; } - _ => None, - }) - .collect() - }); - let ppp = ctx.pixels_per_point(); - let now = now_secs(); - let passes = self.passes(); + if region.as_ref().is_some_and(|region| !current.contains_region(*region)) { + slot.cancel(); + reserved_total = reserved_total.saturating_sub(*reserved); + let _ = reply.send(Err("the region must lie inside the window".into())); + continue; + } + let Some(bytes) = current.image_bytes() else { + slot.cancel(); + reserved_total = reserved_total.saturating_sub(*reserved); + let _ = reply.send(Err("the window has no capturable size".into())); + continue; + }; + let Some(new_total) = retried_reservation(reserved_total, *reserved, bytes) else { + slot.cancel(); + reserved_total = reserved_total.saturating_sub(*reserved); + let _ = reply.send(Err("the screenshot no longer fits the screenshot budget".into())); + continue; + }; + reserved_total = new_total; + slot.cancel(); + let (new_slot, new_callback) = craft_ui::capture::CaptureSlot::new(ctx, egui::ViewportId::ROOT, MAX_IMAGE_BYTES); + *slot = new_slot; + *callback = Some(new_callback); + *geometry = current; + *reserved = bytes; + *attempts += 1; + } + if let Some(callback) = callback.take() { + // The screenshot command itself requests a paint, including while hidden. + ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(callback)); + } + } + kept.push(entry); + } + self.pending = kept; + if !self.pending.is_empty() { + #[cfg(not(target_arch = "wasm32"))] + if ctx.input(|i| i.viewport().visible()) != Some(true) { + ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden); + } + } + } + + /// `since` is the request's arrival time: the one admission deadline it was admitted under. + fn dispatch(&mut self, ctx: &egui::Context, host: &mut impl Host, req: ControlRequest, since: web_time::Instant) { + match self.handle(ctx, host, &req.method, &req.params, since) { + Handled::Now(r) => { + let _ = req.reply.send(r); + } + Handled::AfterFrames(frames, value) => { + let until_pass = self.passes() + u64::from(frames); + self.pending.push(Pending::Frames { until_pass, batches: Vec::new(), since, reply: req.reply, value }); + } + Handled::AfterInput { batches, frames, value } => { + let until_pass = self.passes() + u64::from(frames) + 1; + self.pending.push(Pending::Frames { until_pass, batches, since, reply: req.reply, value }); + } + Handled::Screenshot(region) => self.admit_screenshot(ctx, host, region, req.reply, since), + } + } + + /// Return the total screenshot bytes reserved by pending captures without overflowing. + fn reserved_bytes(&self) -> usize { + self.pending + .iter() + .filter_map(|p| if let Pending::Screenshot { reserved, .. } = p { Some(*reserved) } else { None }) + .fold(0usize, usize::saturating_add) + } + + /// Count the screenshot requests still waiting for a capture. + fn pending_screenshots(&self) -> usize { + self.pending.iter().filter(|p| matches!(p, Pending::Screenshot { .. })).count() + } + + /// Admit one screenshot request within the count, per-image and aggregate limits. + fn admit_screenshot( + &mut self, + ctx: &egui::Context, + host: &mut impl Host, + region: Option, + reply: Sender, + since: web_time::Instant, + ) { + if self.pending_screenshots() >= MAX_PENDING_SCREENSHOTS { + let _ = reply.send(Err(format!("too many screenshots are already pending (limit {MAX_PENDING_SCREENSHOTS})"))); + return; + } + let geometry = Geometry::current(ctx, host.capture_epoch()); + let Some(bytes) = geometry.image_bytes() else { + let _ = reply.send(Err("the window has no capturable size".into())); + return; + }; + if let Some(region) = region + && !geometry.contains_region(region) + { + let _ = reply.send(Err("the region must lie inside the window".into())); + return; + } + if bytes > MAX_IMAGE_BYTES { + let _ = reply.send(Err(format!("the screenshot would use {bytes} bytes, over the {MAX_IMAGE_BYTES}-byte image limit"))); + return; + } + if self.reserved_bytes().saturating_add(bytes) > MAX_TOTAL_BYTES { + let _ = reply.send(Err(format!("the screenshot would exceed the {MAX_TOTAL_BYTES}-byte aggregate screenshot budget"))); + return; + } + let (slot, callback) = craft_ui::capture::CaptureSlot::new(ctx, egui::ViewportId::ROOT, MAX_IMAGE_BYTES); + self.pending.push(Pending::Screenshot { slot, callback: Some(callback), region, reply, since, geometry, reserved: bytes, attempts: 0 }); + } + + /// Resolve completed frame and screenshot requests while retaining those that still need a pass or a + /// retry. + fn finish_pending(&mut self, ctx: &egui::Context, host: &mut impl Host) { + let now = web_time::Instant::now(); + let (dropped, expired, in_pass) = self.take_finished_shared(ctx, host, now); + let mut finisher = PendingFinisher { + now, + passes: self.passes(), + current: Geometry::current(ctx, host.capture_epoch()), + in_pass, + dropped, + expired, + reserved_total: self.reserved_bytes(), + }; let mut keep = Vec::new(); - for p in self.pending.drain(..) { - match p { - Pending::Frames { until_pass, reply, value, .. } if passes >= until_pass => { - let _ = reply.send(Ok(value)); + for pending in std::mem::take(&mut self.pending) { + let kept = match pending { + Pending::Frames { until_pass, batches, since, reply, value } => finisher.resolve_frames(until_pass, batches, since, reply, value), + Pending::Screenshot { slot, callback, region, reply, since, geometry, reserved, attempts } => { + finisher.finish_capture(ctx, PendingCapture { slot, callback, region, reply, since, geometry, reserved, attempts }) } - Pending::Frames { since, reply, .. } if now - since > NOT_DRAWN_TIMEOUT => { - // The agent is told it failed, so it must not happen later either. - if let Ok(mut sh) = self.shared.lock() { - sh.inject.clear(); - } - let _ = reply.send(Err(not_drawn("handling the input"))); - } - p @ Pending::Frames { .. } => keep.push(p), - Pending::Screenshot { tag, region, reply, since } => match shots.iter().find(|(t, _)| *t == tag) { - Some((_, image)) => { - let _ = reply.send(screenshot_png(image, region, ppp)); - } - // The platform skips painting windows that are hidden, minimized or fully - // covered, so no screenshot ever arrives: say so instead of hanging. - None if now - since > NOT_DRAWN_TIMEOUT => { - let _ = reply.send(Err(not_drawn("the screenshot"))); - } - None => keep.push(Pending::Screenshot { tag, region, reply, since }), - }, + }; + if let Some(pending) = kept { + keep.push(pending); } } self.pending = keep; } + /// Expire queued input and collect the batches a pending reply must react to. + fn take_finished_shared(&self, ctx: &egui::Context, host: &mut impl Host, now: web_time::Instant) -> (Vec, Vec, bool) { + let (dropped, expired, in_pass) = self + .shared + .lock() + .map(|mut s| { + if !s.in_pass { + expire_input(&mut s, now); + } + if std::mem::take(&mut s.cancel_gestures) { + host.cancel_pointer_gestures(); + } + (std::mem::take(&mut s.dropped), std::mem::take(&mut s.expired), s.in_pass) + }) + .unwrap_or_default(); + if !expired.is_empty() { + ctx.request_repaint(); // Run the queued key/modifier cleanup and pointer cancellation. + } + (dropped, expired, in_pass) + } + fn passes(&self) -> u64 { self.shared.lock().map(|s| s.passes).unwrap_or(0) } - fn inject(&self, batches: Vec>) -> u32 { - let n = batches.len() as u32; - if let Ok(mut s) = self.shared.lock() { - s.inject.extend(batches); + /// Queue one request's event batches (one per UI pass) and return its request id. + /// + /// The request is stamped once, at admission: all of its batches share the input-target + /// generation and the caller's single arrival deadline (`since`), so a deferred startup wait + /// is not restarted here. A poisoned mutex or a full queue is an error, never a silently empty + /// queue that would let the caller answer `Ok` for input that never happened. + fn inject(&self, batches: Vec>, since: web_time::Instant) -> Result, String> { + let mut s = self.shared.lock().map_err(|_| "control state poisoned".to_string())?; + let queued: usize = s.inject.iter().map(|request| request.batches.len()).sum(); + if queued.saturating_add(batches.len()) > MAX_QUEUED_BATCHES { + return Err(format!("too many synthetic input batches are already queued (limit {MAX_QUEUED_BATCHES})")); } - // The last batch is handled during the frame it is injected into; answer one frame later. - n + 1 + s.next_batch += 1; + let id = s.next_batch; + let epoch = s.epoch; + s.inject.push_back(RequestBatches { request: id, epoch, started: false, since, release: Vec::new(), batches: batches.into() }); + Ok(vec![id]) } - fn handle(&mut self, ctx: &egui::Context, host: &mut impl Host, method: &str, p: &Value) -> Handled { + /// Queue synthetic input batches and reply only after the requested number of frames has been presented. + fn after_input(&self, batches: Vec>, value: Value, since: web_time::Instant) -> Result { + let frames = batches.len() as u32; + let batches = self.inject(batches, since)?; + Ok(Handled::AfterInput { batches, frames, value }) + } + + /// Validate and dispatch one control method, rejecting unknown methods and malformed parameters with + /// actionable errors. + fn handle(&mut self, ctx: &egui::Context, host: &mut impl Host, method: &str, p: &Value, since: web_time::Instant) -> Handled { let str_param = |k: &str| p.get(k).and_then(Value::as_str).ok_or_else(|| format!("{method}: missing string parameter {k}")); let r: Result = (|| match method { "ui.state" => { let mut state = host.state(); - let size = ctx.input(|i| i.viewport_rect().size()); - state["window"] = json!({ "width": size.x, "height": size.y, "pixels_per_point": ctx.pixels_per_point() }); + // Viewport info is fresh in `logic`; the last UI pass's rectangle can be stale. + let geometry = Geometry::current(ctx, host.capture_epoch()); + let size = geometry.size; + state["window"] = json!({ "width": size.x, "height": size.y, "pixels_per_point": geometry.pixels_per_point }); Ok(Handled::Now(Ok(state))) } "ui.commands" => Ok(Handled::Now(Ok(host.commands()))), - "ui.dock" | "ui.dock.reset" => Ok(Handled::Now(host.dock(method == "ui.dock.reset", p))), - "ui.command" if str_param("id")? == "ui.dock" => Ok(Handled::Now(host.dock(false, p.get("params").unwrap_or(p)))), + "ui.panel" | "ui.dock.reset" => Ok(Handled::Now(host.panel(method == "ui.dock.reset", p))), + "ui.command" if str_param("id")? == "ui.panel" => Ok(Handled::Now(host.panel(false, p.get("params").unwrap_or(p)))), "ui.command" => Ok(Handled::Now(host.command(str_param("id")?))), "ui.set" => { let value = match p.get("value") { @@ -299,8 +933,8 @@ impl Control { } "ui.open" => Ok(Handled::Now(host.open(str_param("path")?))), "ui.inspect" => Ok(Handled::Now(Ok(self.inspect(p)))), - "ui.click" => self.click(p), - "ui.focus" => self.focus(p), + "ui.click" => self.click(p, since), + "ui.focus" => self.focus(p, since), "ui.resize" => { only_params(p, method, &["width", "height"])?; let dimension = |key: &str| -> Result { @@ -326,7 +960,7 @@ impl Control { let quitting = host.quit(ctx); Ok(Handled::Now(Ok(json!({ "quitting": quitting, "needs_confirmation": !quitting })))) } - "ui.drag" => self.drag(p), + "ui.drag" => self.drag(p, since), "ui.move" => { let point = |key: &str| -> Result { p.get(key) @@ -336,19 +970,18 @@ impl Control { .ok_or_else(|| format!("ui.move: {key} must be a finite coordinate in points")) }; let pos = egui::pos2(point("x")?, point("y")?); - Ok(Handled::AfterFrames(self.inject(vec![vec![egui::Event::PointerMoved(pos)]]), json!({ "moved": [pos.x, pos.y] }))) + self.after_input(vec![vec![egui::Event::PointerMoved(pos)]], json!({ "moved": [pos.x, pos.y] }), since) } "ui.type" => { let text = str_param("text")?.to_string(); - Ok(Handled::AfterFrames(self.inject(vec![vec![egui::Event::Text(text)]]), json!({ "typed": true }))) + self.after_input(vec![vec![egui::Event::Text(text)]], json!({ "typed": true }), since) } "ui.key" => { let name = str_param("key")?; let key = egui::Key::from_name(name) .ok_or_else(|| format!("ui.key: unknown key {name:?} (egui key names: A, Enter, Escape, ArrowDown, F5, …)"))?; let modifiers = modifiers(p.get("modifiers"))?; - let ev = |pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }; - Ok(Handled::AfterFrames(self.inject(vec![vec![ev(true)], vec![ev(false)]]), json!({ "key": name }))) + self.after_input(key_batches(key, modifiers), json!({ "key": name }), since) } "ui.screenshot" => { let region = match p.get("region") { @@ -397,7 +1030,7 @@ impl Control { } /// Press at `from`, move to `to` in `steps` frames, release (drawing, selecting text, moving). - fn drag(&mut self, p: &Value) -> Result { + fn drag(&mut self, p: &Value, since: web_time::Instant) -> Result { let point = |k: &str| -> Result { match p.get(k).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_f64).collect::>()).as_deref() { Some([x, y]) => Some(egui::pos2(*x as f32, *y as f32)) @@ -430,11 +1063,11 @@ impl Control { if modifiers != egui::Modifiers::NONE { frames.push(vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE)]); } - let n = self.inject(frames); - Ok(Handled::AfterFrames(n, json!({ "dragged": [[from.x, from.y], [to.x, to.y]] }))) + self.after_input(frames, json!({ "dragged": [[from.x, from.y], [to.x, to.y]] }), since) } - fn click(&mut self, p: &Value) -> Result { + /// Click at a point, or on an accesskit widget by id or unique enabled label. + fn click(&mut self, p: &Value, since: web_time::Instant) -> Result { if let (Some(x), Some(y)) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { let point = |key: &str, n: f64| { Some(n as f32).filter(|n| n.is_finite()).ok_or_else(|| format!("ui.click: {key} must be a finite coordinate in points")) @@ -443,8 +1076,8 @@ impl Control { let which = pointer_button(p, "ui.click")?; let button = |pressed| egui::Event::PointerButton { pos, button: which, pressed, modifiers: egui::Modifiers::NONE }; // Keep hover/layout warmup, then make an instantaneous click even on slow frames. - let frames = self.inject(vec![vec![egui::Event::PointerMoved(pos)], vec![button(true), button(false)]]); - return Ok(Handled::AfterFrames(frames, json!({ "clicked": [x, y] }))); + let frames = vec![vec![egui::Event::PointerMoved(pos)], vec![button(true), button(false)]]; + return self.after_input(frames, json!({ "clicked": [x, y] }), since); } let s = self.shared.lock().map_err(|_| "control state poisoned")?; let id = match (p.get("id"), p.get("label").and_then(Value::as_str)) { @@ -480,11 +1113,12 @@ impl Control { let label = node.label().map(str::to_owned); let action = accesskit::ActionRequest { action: Action::Click, target_tree: accesskit::TreeId::ROOT, target_node: id, data: None }; drop(s); - let frames = self.inject(vec![vec![egui::Event::AccessKitActionRequest(action)]]); - Ok(Handled::AfterFrames(frames, json!({ "clicked": id.0.to_string(), "label": label }))) + let frames = vec![vec![egui::Event::AccessKitActionRequest(action)]]; + self.after_input(frames, json!({ "clicked": id.0.to_string(), "label": label }), since) } - fn focus(&mut self, p: &Value) -> Result { + /// Focus an accesskit widget by id or unique enabled label. + fn focus(&mut self, p: &Value, since: web_time::Instant) -> Result { only_params(p, "ui.focus", &["id", "label"])?; let s = self.shared.lock().map_err(|_| "control state poisoned")?; let id = match (p.get("id"), p.get("label")) { @@ -512,14 +1146,20 @@ impl Control { } let action = accesskit::ActionRequest { action: Action::Focus, target_tree: accesskit::TreeId::ROOT, target_node: id, data: None }; drop(s); - let frames = self.inject(vec![vec![egui::Event::AccessKitActionRequest(action)]]); - Ok(Handled::AfterFrames(frames, json!({ "focus_requested": id.0.to_string() }))) + let frames = vec![vec![egui::Event::AccessKitActionRequest(action)]]; + self.after_input(frames, json!({ "focus_requested": id.0.to_string() }), since) } } enum Handled { Now(Reply), + /// Answer after this many accepted UI passes, without injecting input. AfterFrames(u32, Value), + AfterInput { + batches: Vec, + frames: u32, + value: Value, + }, Screenshot(Option), } @@ -584,6 +1224,23 @@ fn pointer_button(p: &Value, method: &str) -> Result Vec> { + let key_event = |pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }; + let mut press = vec![key_event(true)]; + if modifiers != egui::Modifiers::NONE { + press.insert(0, egui::Event::ModifiersChanged(modifiers)); + } + let mut release = vec![key_event(false)]; + if modifiers != egui::Modifiers::NONE { + release.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + } + vec![press, release] +} + fn modifiers(v: Option<&Value>) -> Result { let mut m = egui::Modifiers::NONE; for name in v.and_then(Value::as_array).into_iter().flatten() { @@ -616,19 +1273,47 @@ fn rect_param(v: &Value) -> Result { } /// Encode (a region of) a screenshot as PNG; the reply carries base64 data and the size. -fn screenshot_png(image: &egui::ColorImage, region: Option, ppp: f32) -> Reply { +/// +/// The image scale is derived from the returned pixels and the logical viewport the capture was +/// admitted for, never from the current `Context`: egui-wgpu's surface fit may have shrunk the +/// surface (and a resize/DPI change has already been rejected before this call). +fn screenshot_png(image: &egui::ColorImage, region: Option, geometry: Geometry) -> Reply { let [w, h] = image.size; + let view = geometry.size; + if w == 0 || h == 0 { + return Err("the screenshot has no pixels".into()); + } + if !(view.x.is_finite() && view.y.is_finite()) || view.x <= 0.0 || view.y <= 0.0 { + return Err("the window has no capturable size".into()); + } + if region.is_some_and(|region| !geometry.contains_region(region)) { + return Err("the region must lie inside the window".into()); + } + let scale_x = w as f32 / view.x; + let scale_y = h as f32 / view.y; + // A fitted surface shrinks both sides by one factor; a different x/y scale means the window + // changed shape and this image cannot be cropped honestly. + if (scale_x - scale_y).abs() > 0.01 * scale_x.max(1.0) { + return Err("the screenshot's aspect ratio does not match the window".into()); + } let (x0, y0, x1, y1) = match region { Some(r) => { - let px = |v: f32, max: usize| ((v * ppp).round().max(0.0) as usize).min(max); - (px(r.min.x, w), px(r.min.y, h), px(r.max.x, w), px(r.max.y, h)) + let px = |v: f32, scale: f32, max: usize| ((v * scale).round().max(0.0) as usize).min(max); + (px(r.min.x, scale_x, w), px(r.min.y, scale_y, h), px(r.max.x, scale_x, w), px(r.max.y, scale_y, h)) } None => (0, 0, w, h), }; if x1 <= x0 || y1 <= y0 { return Err("the region is outside the window".into()); } - let mut rgba = Vec::with_capacity((x1 - x0) * (y1 - y0) * 4); + let crop_bytes = (x1 - x0) + .checked_mul(y1 - y0) + .and_then(|pixels| pixels.checked_mul(core::mem::size_of::())) + .ok_or_else(|| "the screenshot region is too large".to_string())?; + if crop_bytes > MAX_ENCODED_BYTES { + return Err(format!("the screenshot region would need {crop_bytes} bytes to encode, over the {MAX_ENCODED_BYTES}-byte encode limit")); + } + let mut rgba = Vec::with_capacity(crop_bytes); for y in y0..y1 { for px in &image.pixels[y * w + x0..y * w + x1] { rgba.extend_from_slice(&px.to_srgba_unmultiplied()); @@ -642,13 +1327,71 @@ fn screenshot_png(image: &egui::ColorImage, region: Option, ppp: f32 let mut writer = enc.write_header().map_err(|e| e.to_string())?; writer.write_image_data(&rgba).map_err(|e| e.to_string())?; writer.finish().map_err(|e| e.to_string())?; + if out.len() > MAX_ENCODED_BYTES { + return Err(format!("the encoded screenshot is {} bytes, over the {MAX_ENCODED_BYTES}-byte encode limit", out.len())); + } + let encoded = out.len().div_ceil(3).checked_mul(4).ok_or_else(|| "the encoded screenshot is too large".to_string())?; + if encoded > MAX_BASE64_BYTES { + return Err(format!("the screenshot's base64 reply would be {encoded} bytes, over the {MAX_BASE64_BYTES}-byte limit")); + } use base64::Engine as _; - Ok(json!({ "png_base64": base64::engine::general_purpose::STANDARD.encode(out), "width": cw, "height": ch, "pixels_per_point": ppp })) + Ok(json!({ + "png_base64": base64::engine::general_purpose::STANDARD.encode(out), + "width": cw, + "height": ch, + "pixels_per_point": scale_x, + })) } impl Host for crate::PdfCraftApp { - fn dock(&mut self, reset: bool, params: &Value) -> Reply { - crate::docking::command(self, reset, params) + /// Cancel any in-progress pointer gesture in every open view. + fn cancel_pointer_gestures(&mut self) { + for view in &mut self.views { + view.cancel_pointer_gestures(); + } + } + + /// The input target: active document, open dialog/prompt, palette and find state. + /// + /// Synthetic input admitted for one generation is never replayed into another document or + /// dialog. Only these identities are hashed, never text or document content. + fn input_epoch(&self) -> u64 { + use std::hash::{Hash as _, Hasher as _}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + self.capture_epoch().hash(&mut hasher); + // The focused widget is part of the input target: keys typed for one field must not land + // in another after focus moves. Only the id value is hashed, never text. + let focused = self.ctx.as_ref().and_then(|ctx| ctx.memory(|m| m.focused())).map(|id| id.value()); + focused.hash(&mut hasher); + hasher.finish() + } + + /// The captured content: active document and open dialog/prompt, never keyboard focus. + fn capture_epoch(&self) -> u64 { + use std::hash::{Hash as _, Hasher as _}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + let doc = self.active.and_then(|i| self.views.get(i)).map(|v| v.id.0); + doc.hash(&mut hasher); + format!( + "{:?}/{:?}/{:?}/{:?}/{:?}", + self.dialog, + self.password_prompt.is_some(), + self.palette_open, + self.close_request.is_some(), + self.active.and_then(|i| self.views.get(i)).and_then(|v| v.find.as_ref().map(|_| true)), + ) + .hash(&mut hasher); + hasher.finish() + } + + /// Control requests wait for the first UI pass that installs fonts; a font-gated early + /// return cannot consume injected input or issue a screenshot. + fn accepts_input(&self) -> bool { + self.fonts_ready + } + + fn panel(&mut self, reset: bool, params: &Value) -> Reply { + crate::side_panels::command(self, reset, params) } fn quit(&mut self, ctx: &egui::Context) -> bool { @@ -672,7 +1415,7 @@ impl Host for crate::PdfCraftApp { #[cfg(target_arch = "wasm32")] let pending_opens: Vec = Vec::new(); json!({ - "docking": self.docking, + "side_panels": crate::side_panels::state(self), "documents": docs, "pending_opens": pending_opens, "active": active.map(|v| json!({ @@ -735,7 +1478,7 @@ impl Host for crate::PdfCraftApp { "default_mode": self.default_mode, "comment_tool_pinned": self.comment_prefs.pinned, "left_panel": if self.left_open { json!(format!("{:?}", self.left)) } else { Value::Null }, - "tools_side": if self.docking.tools_on_right() { "right" } else { "left" }, + "tools_side": if self.side_panels.tools_on_right() { "right" } else { "left" }, "right_panel": self.right.map(|r| format!("{r:?}")), "dialog": self.dialog.map(|d| format!("{d:?}")), "palette_open": self.palette_open, @@ -763,7 +1506,7 @@ impl Host for crate::PdfCraftApp { .iter() .map(|c| json!({ "id": c.id, "label": c.label, "menu": c.menu, "enabled": self.command_enabled(c), "shortcut": c.shortcut.map(|s| s.label(cfg!(target_os = "macos"))) })) .collect(); - list.push(json!({"id": "ui.dock", "label": "Arrange Panel", "enabled": true, "params": "action, or operation/panel; move: target/zone/before; float/moveFloating: rect; resizeSplit: path/size; setStackOpen: open; resizeStack: height"})); + list.push(json!({"id": "ui.panel", "label": "Side panel", "enabled": true, "params": "panel: tools or side; open: boolean; width: number (clamped to the panel's limits)"})); json!({ "commands": list }) } @@ -1401,6 +2144,1009 @@ mod transport_tests { } } +#[cfg(test)] +mod lifecycle_tests { + use super::*; + use egui::Plugin as _; + use egui_kittest::Harness; + + /// A request is answered after accepted frames only: a discarded layout pass is not a frame + /// the agent can see, so it must not advance the count. + #[test] + fn discarded_passes_are_not_counted_as_accepted_frames() { + let shared = Arc::new(Mutex::new(Shared::default())); + let mut plugin = ControlPlugin { shared: shared.clone() }; + let ctx = egui::Context::default(); + let mut accepted = egui::FullOutput::default(); + plugin.output_hook(&ctx, &mut accepted); + let mut discarded = egui::FullOutput::default(); + discarded.platform_output.request_discard_reasons.push(egui::RepaintCause::new_reason("control test discard")); + plugin.output_hook(&ctx, &mut discarded); + assert_eq!(shared.lock().unwrap().passes, 1, "only the presented pass counts"); + } + + /// A frame that requests discard on every pass and hits `max_passes` still presents its + /// last pass: that presented pass must count, or `Pending::Frames` waits for a frame that + /// never arrives. This runs the real multi-pass loop rather than a hand-built FullOutput. + #[test] + fn a_final_capped_pass_is_counted_as_presented() { + let shared = Arc::new(Mutex::new(Shared::default())); + let ctx = egui::Context::default(); + ctx.add_plugin(ControlPlugin { shared: shared.clone() }); + ctx.options_mut(|o| o.max_passes = 2.try_into().expect("two is non-zero")); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| ui.ctx().request_discard("control test must discard")); + output.textures_delta.clear(); + assert_eq!(output.platform_output.num_completed_passes, 2, "the cap was reached"); + assert!(!output.platform_output.request_discard_reasons.is_empty(), "the last pass still requested a discard"); + assert_eq!(shared.lock().unwrap().passes, 1, "the presented final pass counts once"); + } + + /// `max_passes` above two still counts a frame that settles after earlier discarded passes: + /// egui clears the previous pass's discard reasons at the start of each pass, so the merged + /// output of a presented final pass is not mistaken for a discarded one. + #[test] + fn a_frame_that_settles_before_a_higher_pass_cap_is_counted_once() { + let shared = Arc::new(Mutex::new(Shared::default())); + let ctx = egui::Context::default(); + ctx.add_plugin(ControlPlugin { shared: shared.clone() }); + ctx.options_mut(|o| o.max_passes = 3.try_into().expect("three is non-zero")); + let mut pass = 0; + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + pass += 1; + if pass < 3 { + ui.ctx().request_discard("control test must discard the first two passes"); + } + }); + output.textures_delta.clear(); + assert_eq!(output.platform_output.num_completed_passes, 3, "the third pass settled at the higher cap"); + assert!(output.platform_output.request_discard_reasons.is_empty(), "earlier discard reasons do not survive the settling pass"); + assert_eq!(shared.lock().unwrap().passes, 1, "the one presented frame counts once"); + } + + /// `ui.key` delivers held modifiers through `ModifiersChanged`, because egui does not derive + /// `InputState.modifiers` from a key event's own field. Shift+Tab and ⌘Enter handlers read it. + #[test] + fn key_commands_deliver_modifier_state() { + let held = egui::Modifiers { command: true, ctrl: true, ..egui::Modifiers::NONE }; + let batches = key_batches(egui::Key::Enter, held); + let key = |pressed| egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed, repeat: false, modifiers: held }; + assert_eq!( + batches, + vec![vec![egui::Event::ModifiersChanged(held), key(true)], vec![key(false), egui::Event::ModifiersChanged(egui::Modifiers::NONE)]] + ); + let ctx = egui::Context::default(); + let mut press_seen = None; + ctx.run_ui(egui::RawInput { events: batches[0].clone(), ..Default::default() }, |ui| press_seen = Some(ui.input(|i| i.modifiers))) + .textures_delta + .clear(); + let mut release_seen = None; + ctx.run_ui(egui::RawInput { events: batches[1].clone(), ..Default::default() }, |ui| release_seen = Some(ui.input(|i| i.modifiers))) + .textures_delta + .clear(); + assert_eq!(press_seen, Some(held), "the press sees the modifiers it was sent with"); + assert_eq!(release_seen, Some(egui::Modifiers::NONE), "the modifier release follows the key-up"); + } + + /// A batch admitted for one document/focus generation must not apply after that generation + /// changes; the request is reported as stale instead of replaying the input. + #[test] + fn a_stale_synthetic_batch_is_dropped_and_reported() { + let shared = Arc::new(Mutex::new(Shared::default())); + let mut plugin = ControlPlugin { shared: shared.clone() }; + { + let mut s = shared.lock().unwrap(); + s.epoch = 2; + s.inject.push_back(RequestBatches { + request: 41, + epoch: 1, + started: false, + since: web_time::Instant::now(), + release: Vec::new(), + batches: VecDeque::from([vec![egui::Event::PointerMoved(egui::pos2(1.0, 1.0))]]), + }); + } + let mut raw = egui::RawInput::default(); + plugin.input_hook(&egui::Context::default(), &mut raw); + assert!(raw.events.is_empty(), "stale input must not reach the new document"); + let s = shared.lock().unwrap(); + assert_eq!(s.dropped, vec![41]); + assert!(s.inject.is_empty()); + } + + /// A stale request is dropped alone. A request admitted later for the current generation + /// behind it still runs: one agent's outdated input must not fail another's. + #[test] + fn a_stale_request_does_not_drop_a_later_current_one() { + let shared = Arc::new(Mutex::new(Shared::default())); + let mut plugin = ControlPlugin { shared: shared.clone() }; + { + let mut s = shared.lock().unwrap(); + s.epoch = 2; + s.inject.push_back(RequestBatches { + request: 41, + epoch: 1, + started: false, + since: web_time::Instant::now(), + release: Vec::new(), + batches: VecDeque::from([vec![egui::Event::PointerMoved(egui::pos2(1.0, 1.0))]]), + }); + s.inject.push_back(RequestBatches { + request: 42, + epoch: 2, + started: false, + since: web_time::Instant::now(), + release: Vec::new(), + batches: VecDeque::from([vec![egui::Event::PointerMoved(egui::pos2(2.0, 2.0))]]), + }); + } + let ctx = egui::Context::default(); + let mut first = egui::RawInput::default(); + plugin.input_hook(&ctx, &mut first); + assert!(first.events.is_empty(), "the stale request is not replayed"); + let mut second = egui::RawInput::default(); + plugin.input_hook(&ctx, &mut second); + assert_eq!(second.events, [egui::Event::PointerMoved(egui::pos2(2.0, 2.0))], "the current request still applies"); + let s = shared.lock().unwrap(); + assert_eq!(s.dropped, vec![41]); + assert!(s.inject.is_empty()); + } + + /// Once a request has started, the focus/dialog change caused by its own first batch must not + /// drop its release: a key press without its release leaves `keys_down` stuck. + #[test] + fn a_started_request_finishes_its_batches_after_focus_moves() { + let shared = Arc::new(Mutex::new(Shared::default())); + let mut plugin = ControlPlugin { shared: shared.clone() }; + { + let mut s = shared.lock().unwrap(); + s.epoch = 7; + s.inject.push_back(RequestBatches { + request: 9, + epoch: 7, + started: false, + since: web_time::Instant::now(), + release: Vec::new(), + batches: VecDeque::from([ + vec![egui::Event::Key { + key: egui::Key::Tab, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }], + vec![egui::Event::Key { + key: egui::Key::Tab, + physical_key: None, + pressed: false, + repeat: false, + modifiers: egui::Modifiers::NONE, + }], + ]), + }); + } + let ctx = egui::Context::default(); + let mut press = egui::RawInput::default(); + plugin.input_hook(&ctx, &mut press); + assert!(matches!(press.events.as_slice(), [egui::Event::Key { pressed: true, .. }])); + // The press moved focus: the next pass sees another input-target generation. + shared.lock().unwrap().epoch = 8; + let mut release = egui::RawInput::default(); + plugin.input_hook(&ctx, &mut release); + assert!(matches!(release.events.as_slice(), [egui::Event::Key { pressed: false, .. }]), "the release still arrives"); + let s = shared.lock().unwrap(); + assert!(s.dropped.is_empty(), "a started request is not reported as stale"); + assert!(s.inject.is_empty()); + } + + /// A host for timeout tests: the control layer must not need a real app. + struct NoopHost; + + impl Host for NoopHost { + fn state(&self) -> Value { + json!({}) + } + fn command(&mut self, _id: &str) -> Reply { + Err("no commands in this test".into()) + } + fn panel(&mut self, _reset: bool, _params: &Value) -> Reply { + Err("no side panels in this test".into()) + } + fn commands(&self) -> Value { + json!([]) + } + fn set(&mut self, _key: &str, _value: &str) -> Reply { + Err("no settings in this test".into()) + } + fn open(&mut self, _path: &str) -> Reply { + Err("no files in this test".into()) + } + fn quit(&mut self, _ctx: &egui::Context) -> bool { + false + } + } + + /// A host that starts unready (fonts not installed) and counts dispatched state reads. + /// `state` takes `&self`, so the count sits in a `Cell`. + struct ReadinessHost { + ready: bool, + states: std::cell::Cell, + /// Methods the host was asked to run, in dispatch order. + calls: std::cell::RefCell>, + } + + impl ReadinessHost { + fn new(ready: bool) -> Self { + Self { ready, states: std::cell::Cell::new(0), calls: std::cell::RefCell::new(Vec::new()) } + } + } + + impl Host for ReadinessHost { + fn state(&self) -> Value { + self.states.set(self.states.get() + 1); + self.calls.borrow_mut().push("ui.state"); + json!({ "ready": self.ready }) + } + fn command(&mut self, _id: &str) -> Reply { + Err("no commands in this test".into()) + } + fn panel(&mut self, _reset: bool, _params: &Value) -> Reply { + Err("no side panels in this test".into()) + } + fn commands(&self) -> Value { + self.calls.borrow_mut().push("ui.commands"); + json!([]) + } + fn set(&mut self, _key: &str, _value: &str) -> Reply { + Err("no settings in this test".into()) + } + fn open(&mut self, _path: &str) -> Reply { + Err("no files in this test".into()) + } + fn quit(&mut self, _ctx: &egui::Context) -> bool { + false + } + fn accepts_input(&self) -> bool { + self.ready + } + } + + /// The admission timestamp of the one pending request, whichever kind it is. + fn pending_since(control: &Control) -> Option { + let first = control.pending.first()?; + match first { + Pending::Frames { since, .. } | Pending::Screenshot { since, .. } => Some(*since), + } + } + + #[test] + fn a_request_expired_in_the_channel_is_never_dispatched_or_deferred() { + for ready in [false, true] { + let ctx = egui::Context::default(); + let (mut control, client) = attach(&ctx); + let mut host = ReadinessHost::new(ready); + let (reply, waiting) = channel(); + let since = web_time::Instant::now().checked_sub(std::time::Duration::from_secs(10)).unwrap(); + client.tx.send(ControlRequest { since, method: "ui.state".into(), params: json!({}), reply }).unwrap(); + control.tick(&ctx, &mut host); + let error = waiting.try_recv().expect("expired channel request replies on the first tick").expect_err("expired request cannot dispatch"); + assert!(error.contains(if ready { "finished starting" } else { "still starting" }), "{error}"); + assert_eq!(host.states.get(), 0); + assert!(control.deferred.is_empty() && control.pending.is_empty()); + } + } + + /// A request held during startup keeps its arrival time. Once the app accepts input, one + /// older than the control deadline fails instead of being dispatched into a later UI. + #[test] + fn a_deferred_request_expires_instead_of_dispatching_after_the_deadline() { + let ctx = egui::Context::default(); + let (mut control, client) = attach(&ctx); + let mut host = ReadinessHost::new(false); + let waiting = client.send("ui.state", json!({})); + control.tick(&ctx, &mut host); + assert_eq!(control.deferred.len(), 1, "unready host defers the request"); + assert!(waiting.try_recv().is_err(), "it is not answered while starting"); + let past = web_time::Instant::now().checked_sub(std::time::Duration::from_secs(10)).unwrap(); + for deferred in &mut control.deferred { + deferred.since = past; + } + // It expires even while the app is still not ready, so the client is not left waiting. + control.tick(&ctx, &mut host); + let error = waiting.try_recv().expect("expired deferred request receives a reply").expect_err("it must not dispatch"); + assert!(error.contains("still starting"), "{error}"); + assert!(control.deferred.is_empty()); + // And readiness arriving later cannot revive it. + host.ready = true; + control.tick(&ctx, &mut host); + assert_eq!(host.states.get(), 0, "an expired request is never dispatched"); + } + + /// A request deferred only briefly still runs once fonts are ready. + #[test] + fn a_fresh_deferred_request_still_dispatches_when_the_app_becomes_ready() { + let ctx = egui::Context::default(); + let (mut control, client) = attach(&ctx); + let mut host = ReadinessHost::new(false); + let waiting = client.send("ui.state", json!({})); + control.tick(&ctx, &mut host); + assert!(waiting.try_recv().is_err()); + host.ready = true; + control.tick(&ctx, &mut host); + let reply = waiting.try_recv().expect("fresh deferred request replies").expect("it dispatches"); + assert_eq!(reply["ready"], true); + assert_eq!(host.states.get(), 1, "the fresh deferred request is dispatched once"); + } + + /// A request deferred during startup keeps its one arrival timestamp. Dispatching it into + /// synthetic input, a deferred frame reply or a screenshot reservation must not start a fresh + /// 5 s in that phase: every later phase measures the same deadline from arrival. + #[test] + fn a_startup_wait_counts_against_one_admission_deadline_in_every_phase() { + let params = [("ui.type", json!({ "text": "x" })), ("ui.resize", json!({ "width": 800, "height": 600 })), ("ui.screenshot", json!({}))]; + for (method, params) in params { + // `ui.resize` needs a native window; the other phases are checked on every target. + if method == "ui.resize" && cfg!(target_arch = "wasm32") { + continue; + } + let ctx = egui::Context::default(); + // A real window size, so an admitted screenshot has capturable geometry. + ctx.run_ui( + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(640.0, 480.0))), ..Default::default() }, + |_| {}, + ) + .drop_without_applying_deltas(); + let (mut control, client) = attach(&ctx); + let mut host = ReadinessHost::new(false); + let waiting = client.send(method, params); + control.tick(&ctx, &mut host); + assert_eq!(control.deferred.len(), 1, "{method}: deferred while starting"); + // Three of the five seconds were spent starting: the request is still inside its + // deadline, but only just. + let arrived = web_time::Instant::now().checked_sub(std::time::Duration::from_secs(3)).unwrap(); + for deferred in &mut control.deferred { + deferred.since = arrived; + } + host.ready = true; + control.tick(&ctx, &mut host); + assert!(control.deferred.is_empty(), "{method}: dispatched once the app accepts input"); + assert!(waiting.try_recv().is_err(), "{method}: a preserved deadline does not answer early"); + assert_eq!(pending_since(&control), Some(arrived), "{method}: the pending reply keeps the arrival deadline"); + if method == "ui.type" { + let shared = control.shared.lock().unwrap(); + assert_eq!(shared.inject.front().map(|request| request.since), Some(arrived), "{method}: queued input keeps the arrival deadline"); + } + // Move that same shared timestamp past its own deadline (five seconds after arrival). + // Expiry measures the one admission instant, so nothing waits for a fresh five. + let past = arrived.checked_sub(timeout()).expect("a representable instant"); + for pending in &mut control.pending { + match pending { + Pending::Frames { since, .. } | Pending::Screenshot { since, .. } => *since = past, + } + } + if let Ok(mut shared) = control.shared.lock() { + for request in &mut shared.inject { + request.since = past; + } + } + control.finish_pending(&ctx, &mut host); + let error = waiting.try_recv().expect("the expired request replies").expect_err("it must not succeed"); + assert!(error.contains("not being drawn"), "{method}: {error}"); + assert!(control.pending.is_empty(), "{method}: the expired request is not retried"); + assert_eq!(control.reserved_bytes(), 0, "{method}: any reservation is released"); + } + } + + /// One client's requests keep their arrival order: a request deferred during startup runs + /// before a newer request that was still waiting in the channel when the app became ready. + #[test] + fn a_deferred_request_runs_before_a_newer_queued_one() { + let ctx = egui::Context::default(); + let (mut control, client) = attach(&ctx); + let mut host = ReadinessHost::new(false); + let older = client.send("ui.state", json!({})); + control.tick(&ctx, &mut host); + assert_eq!(control.deferred.len(), 1, "the older request is deferred while starting"); + let newer = client.send("ui.commands", json!({})); + host.ready = true; + control.tick(&ctx, &mut host); + assert_eq!(*host.calls.borrow(), ["ui.state", "ui.commands"], "the older deferred request is dispatched first"); + assert_eq!(older.try_recv().expect("the older request replies").expect("it dispatches")["ready"], true); + assert_eq!(newer.try_recv().expect("the newer request replies").expect("it dispatches"), json!([])); + } + + /// A request whose startup wait used up the deadline is refused once the app is ready, and the + /// message says the app finished starting instead of claiming it is still starting. + #[test] + fn a_request_that_outlasted_startup_reports_that_the_app_finished_starting() { + let ctx = egui::Context::default(); + let (mut control, client) = attach(&ctx); + let mut host = ReadinessHost::new(false); + let waiting = client.send("ui.state", json!({})); + control.tick(&ctx, &mut host); + assert_eq!(control.deferred.len(), 1); + let past = web_time::Instant::now().checked_sub(std::time::Duration::from_secs(10)).unwrap(); + for deferred in &mut control.deferred { + deferred.since = past; + } + host.ready = true; + control.tick(&ctx, &mut host); + let error = waiting.try_recv().expect("the expired request replies").expect_err("it is never dispatched"); + assert!(error.contains("finished starting"), "{error}"); + assert!(!error.contains("was still starting"), "the app is ready now: {error}"); + assert_eq!(host.states.get(), 0, "an expired request is never dispatched"); + assert!(control.deferred.is_empty() && control.pending.is_empty()); + } + + /// Keep key/modifier cleanup, but a canceled pointer must never produce a release event. + #[test] + fn a_timed_out_started_request_cancels_pointer_and_releases_keys() { + let ctx = egui::Context::default(); + let (mut control, _client) = attach(&ctx); + let held = egui::Modifiers { command: true, ctrl: true, ..egui::Modifiers::NONE }; + let pos = egui::pos2(24.0, 32.0); + let key = |pressed| egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed, repeat: false, modifiers: held }; + let ids = control + .inject( + vec![ + vec![ + egui::Event::ModifiersChanged(held), + key(true), + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: held }, + ], + vec![egui::Event::PointerMoved(egui::pos2(40.0, 48.0))], + vec![key(false), egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: held }], + ], + web_time::Instant::now(), + ) + .unwrap(); + let (reply, rx) = channel(); + control.pending.push(Pending::Frames { until_pass: u64::MAX, batches: ids, since: web_time::Instant::now(), reply, value: json!({}) }); + ctx.run_ui(egui::RawInput::default(), |_| {}).drop_without_applying_deltas(); + assert!(ctx.input(|i| i.keys_down.contains(&egui::Key::Enter) && i.pointer.primary_down())); + assert_eq!(ctx.input(|i| i.modifiers), held); + age_input(&mut control); + let next = egui::pos2(80.0, 90.0); + control.inject(vec![vec![egui::Event::PointerMoved(next)]], web_time::Instant::now()).unwrap(); + control.finish_pending(&ctx, &mut NoopHost); + assert!(rx.try_recv().unwrap().unwrap_err().contains("not being drawn")); + assert!(control.pending.is_empty()); + ctx.run_ui(egui::RawInput::default(), |ui| { + assert!(!ui.input(|i| i.keys_down.contains(&egui::Key::Enter))); + assert_eq!(ui.input(|i| i.modifiers), egui::Modifiers::NONE); + assert_eq!(ui.input(|i| i.pointer.hover_pos()), Some(next), "another live request survives"); + assert!(!ui.input(|i| i.pointer.any_down() || i.pointer.any_released())); + assert!(ui.ctx().dragged_id().is_none() && ui.ctx().drag_stopped_id().is_none()); + }) + .drop_without_applying_deltas(); + assert!(control.shared.lock().unwrap().inject.is_empty()); + assert!(rx.try_recv().is_err(), "only one timeout reply"); + } + + #[test] + fn a_deadline_crossing_inside_an_admitted_pass_cancels_before_the_next_interaction() { + let ctx = egui::Context::default(); + let (mut control, _client) = attach(&ctx); + let pos = egui::pos2(24.0, 32.0); + let ids = control + .inject( + vec![ + vec![ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }, + ], + vec![egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }], + ], + web_time::Instant::now(), + ) + .unwrap(); + let (reply, rx) = channel(); + control.pending.push(Pending::Frames { until_pass: u64::MAX, batches: ids, since: web_time::Instant::now(), reply, value: json!({}) }); + ctx.run_ui(egui::RawInput::default(), |ui| { + assert!(ui.input(|i| i.pointer.primary_down()), "the press was admitted before expiry"); + age_input(&mut control); + control.finish_pending(ui.ctx(), &mut NoopHost); + assert!(rx.try_recv().is_err(), "do not cancel an already-calculated interaction mid-pass"); + }) + .drop_without_applying_deltas(); + ctx.run_ui(egui::RawInput::default(), |ui| { + control.finish_pending(ui.ctx(), &mut NoopHost); + assert!(!ui.input(|i| i.pointer.any_down() || i.pointer.any_released())); + assert!(ui.ctx().drag_stopped_id().is_none()); + }) + .drop_without_applying_deltas(); + assert!(rx.try_recv().unwrap().unwrap_err().contains("not being drawn")); + assert!(control.shared.lock().unwrap().inject.is_empty()); + } + + /// Deterministic elapsed-time input: age the same admission in both halves, without sleeping + /// or fabricating any egui pointer/widget state. + fn age_input(control: &mut Control) { + let past = web_time::Instant::now().checked_sub(std::time::Duration::from_secs(10)).unwrap(); + for request in &mut control.shared.lock().unwrap().inject { + request.since = past; + } + for pending in &mut control.pending { + if let Pending::Frames { since, .. } = pending { + *since = past; + } + } + } + + /// The public gestures this test drives through real UI passes. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + enum Gesture { + AtomicClick, + HeldClick, + Marquee, + } + + fn gesture_request(gesture: Gesture, from: egui::Pos2, to: egui::Pos2) -> (&'static str, Value) { + match gesture { + Gesture::AtomicClick => ("ui.click", json!({"x": from.x, "y": from.y})), + Gesture::HeldClick => ("ui.drag", json!({"from": [from.x, from.y], "to": [from.x, from.y], "steps": 6})), + Gesture::Marquee => ("ui.drag", json!({"from": [from.x, from.y], "to": [to.x, to.y], "steps": 6})), + } + } + + fn marquee_harness() -> (Harness<'static, crate::PdfCraftApp>, ControlClient) { + let client_slot: Arc>> = Arc::new(Mutex::new(None)); + let slot = client_slot.clone(); + let h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).with_step_dt(0.01).build_eframe(move |cc| { + let mut app = crate::PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("synthetic-form.pdf", None, include_bytes!("../tests/data/form.pdf").to_vec()).unwrap(); + app.set_option("quick", "marquee-zoom").unwrap(); + *slot.lock().unwrap() = Some(app.attach_control(&cc.egui_ctx)); + app + }); + let client = client_slot.lock().unwrap().take().unwrap(); + (h, client) + } + + fn marquee_step(h: &mut Harness<'static, crate::PdfCraftApp>) { + let ctx = h.ctx.clone(); + let mut raw = std::mem::take(h.input_mut()); + h.state_mut().prepare_raw_input(&ctx, &mut raw); + *h.input_mut() = raw; + h.step(); + } + + /// The actual laid-out page rect and a short point gesture inside it. + fn gesture_target(h: &Harness<'static, crate::PdfCraftApp>) -> (egui::Pos2, egui::Pos2) { + let page = h.state().views[0].page_screen_rect(0).expect("the actual PDF page was laid out").intersect(h.state().views[0].viewport_rect()); + assert!(page.width() > 100.0 && page.height() > 100.0, "a visible hit target: {page:?}"); + let from = page.center(); + let to = from + egui::vec2(40.0, 50.0); + assert!(page.contains(to)); + (from, to) + } + + /// Step until the public request has really reached egui and can still be interrupted. + fn await_gesture_admission(h: &mut Harness<'static, crate::PdfCraftApp>, gesture: Gesture) -> bool { + let mut admitted = false; + for _ in 0..8 { + marquee_step(h); + admitted = if gesture == Gesture::AtomicClick { + let shared = h.state().control.as_ref().unwrap().shared.lock().unwrap(); + shared.inject.front().is_some_and(|request| request.started && request.batches.len() == 1) + } else { + h.ctx.input(|i| i.pointer.primary_down()) && (gesture != Gesture::Marquee || h.state().views[0].marquee.is_some()) + }; + if admitted { + break; + } + } + admitted + } + + /// The request is queued exactly once, with the expected terminal batch not yet applied. + fn assert_interruptible_admission(h: &Harness<'static, crate::PdfCraftApp>, gesture: Gesture, from: egui::Pos2) { + { + let shared = h.state().control.as_ref().unwrap().shared.lock().unwrap(); + assert_eq!(shared.inject.len(), 1, "only the actual public request is queued"); + let pending = shared.inject.front().unwrap(); + assert!(pending.started); + let terminal = pending.batches.back().expect("the terminal batch is still pending"); + if gesture == Gesture::AtomicClick { + assert_eq!(pending.batches.len(), 1); + assert_eq!(terminal.len(), 2); + assert!( + matches!(terminal[0], egui::Event::PointerButton { pressed: true, button: egui::PointerButton::Primary, pos, .. } if pos == from) + ); + assert!( + matches!(terminal[1], egui::Event::PointerButton { pressed: false, button: egui::PointerButton::Primary, pos, .. } if pos == from) + ); + assert!(!h.ctx.input(|i| i.pointer.primary_down()), "atomic click has only hovered so far"); + } else { + assert!(h.ctx.input(|i| i.pointer.primary_down()), "a real pass consumed the press"); + assert_eq!(terminal.len(), 1); + assert!(matches!(terminal[0], egui::Event::PointerButton { pressed: false, button: egui::PointerButton::Primary, .. })); + } + } + assert!(!h.ctx.input(|i| i.pointer.any_released()), "the terminal release has not run"); + assert!(h.ctx.drag_stopped_id().is_none()); + if gesture == Gesture::Marquee { + assert!(h.ctx.dragged_id().is_some() && h.state().views[0].marquee.is_some(), "the app has a real drag preview"); + } else { + assert!(h.state().views[0].marquee.is_none() && h.state().views[0].marquee_done.is_none()); + } + } + + /// Expire the admission both through hidden logic and through the next input hook, then + /// assert that cancellation left no gesture, zoom or edit behind. + fn assert_timed_out_gesture(h: &mut Harness<'static, crate::PdfCraftApp>, reply: &Receiver, hidden_logic: bool, zoom: f32, bytes: &[u8]) { + age_input(h.state_mut().control.as_mut().unwrap()); + if hidden_logic { + let ctx = h.ctx.clone(); + let app = h.state_mut(); + let mut control = app.control.take().unwrap(); + control.tick(&ctx, app); + app.control = Some(control); + assert!(reply.try_recv().unwrap().unwrap_err().contains("not being drawn")); + } + marquee_step(h); + if !hidden_logic { + assert!(reply.try_recv().unwrap().unwrap_err().contains("not being drawn")); + } + for _ in 0..2 { + marquee_step(h); + } + assert!(!h.ctx.input(|i| i.pointer.any_down() || i.pointer.any_released())); + assert!(h.ctx.dragged_id().is_none() && h.ctx.drag_stopped_id().is_none()); + assert!(h.state().views[0].marquee.is_none() && h.state().views[0].marquee_done.is_none()); + assert_eq!(h.state().views[0].zoom, zoom, "canceling must not turn a drag into a zoom click"); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().bytes.as_slice(), bytes, "no abandoned edit"); + assert!(h.state().control.as_ref().unwrap().shared.lock().unwrap().inject.is_empty()); + assert!(h.state().control.as_ref().unwrap().pending.is_empty()); + assert!(reply.try_recv().is_err(), "the cancellation replies once"); + } + + /// Complete one public gesture and check its real event trace and one-step zoom. + fn complete_positive_gesture( + h: &mut Harness<'static, crate::PdfCraftApp>, + client: &ControlClient, + gesture: Gesture, + positive: Gesture, + bytes: &[u8], + ) { + let (from, to) = gesture_target(h); + let zoom = h.state().views[0].zoom; + let (method, params) = gesture_request(positive, from, to); + let reply = client.send(method, params); + let mut value = None; + let mut buttons = Vec::new(); + let mut positions = Vec::new(); + for _ in 0..20 { + marquee_step(h); + h.ctx.input(|i| { + for event in &i.events { + match event { + egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed, pos, .. } => { + buttons.push((*pressed, i.time)); + positions.push(*pos); + } + egui::Event::PointerMoved(pos) => positions.push(*pos), + _ => {} + } + } + }); + if let Ok(result) = reply.try_recv() { + value = Some(result.unwrap()); + break; + } + } + assert!(value.is_some(), "a fresh completed request succeeds: {positive:?}, after {gesture:?}"); + assert_eq!(buttons.iter().map(|(pressed, _)| *pressed).collect::>(), [true, false]); + assert!(!h.ctx.input(|i| i.pointer.any_down())); + assert!(h.ctx.dragged_id().is_none()); + if positive == Gesture::Marquee { + assert!(h.state().views[0].zoom > zoom, "the normal marquee rectangle zooms"); + } else { + let options = h.ctx.options(|o| o.input_options); + assert!(buttons[1].1 - buttons[0].1 < options.max_click_duration, "real press/release remain a short click"); + assert!(positions.iter().all(|pos| pos.distance(from) < options.max_click_dist), "real input remains stationary"); + assert!((h.state().views[0].zoom - zoom * 1.5).abs() < 0.001, "a normal click zooms one step"); + } + assert!(h.state().views[0].marquee.is_none() && h.state().views[0].marquee_done.is_none()); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().bytes.as_slice(), bytes, "completed zooms do not edit the document"); + assert!(reply.try_recv().is_err(), "successful request replies once"); + } + + /// Real page hit-testing, real control batches and the production marquee completion path. + /// Exercise both hidden-logic expiry and expiry first observed by the next input hook. + #[test] + fn a_timed_out_marquee_never_zooms_and_completed_clicks_and_drags_still_work() { + for hidden_logic in [false, true] { + for gesture in [Gesture::AtomicClick, Gesture::HeldClick, Gesture::Marquee] { + let (mut h, client) = marquee_harness(); + for _ in 0..5 { + marquee_step(&mut h); + } + let (from, to) = gesture_target(&h); + let zoom = h.state().views[0].zoom; + let bytes = h.state().session.get(h.state().views[0].id).unwrap().bytes.clone(); + let (method, params) = gesture_request(gesture, from, to); + let reply = client.send(method, params); + assert!(await_gesture_admission(&mut h, gesture), "a real interruptible admission: {gesture:?}, hidden={hidden_logic}"); + assert_interruptible_admission(&h, gesture, from); + assert!(reply.try_recv().is_err(), "the terminal batch has not run"); + assert_eq!(h.state().views[0].zoom, zoom); + assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().bytes, bytes); + assert_timed_out_gesture(&mut h, &reply, hidden_logic, zoom, &bytes); + + // Complete the same public gesture, then an atomic click after held input. + // Real event traces prove the stationary drag is a valid short point click. + let positives = [gesture, Gesture::AtomicClick]; + let count = if gesture == Gesture::AtomicClick { 1 } else { 2 }; + for positive in positives.into_iter().take(count) { + complete_positive_gesture(&mut h, &client, gesture, positive, &bytes); + } + } + } + } + + /// A screenshot that times out cancels its slot and gives its reservation back, with a + /// defined error instead of a silent success. + #[test] + fn a_timed_out_screenshot_releases_its_slot_and_budget() { + struct FakeHost; + impl Host for FakeHost { + fn state(&self) -> Value { + json!({}) + } + fn command(&mut self, _id: &str) -> Reply { + Err("no commands in this test".into()) + } + fn panel(&mut self, _reset: bool, _params: &Value) -> Reply { + Err("no side panels in this test".into()) + } + fn commands(&self) -> Value { + json!([]) + } + fn set(&mut self, _key: &str, _value: &str) -> Reply { + Err("no settings in this test".into()) + } + fn open(&mut self, _path: &str) -> Reply { + Err("no files in this test".into()) + } + fn quit(&mut self, _ctx: &egui::Context) -> bool { + false + } + } + let ctx = egui::Context::default(); + let (mut control, _client) = attach(&ctx); + let (slot, callback) = craft_ui::capture::CaptureSlot::new(&ctx, egui::ViewportId::ROOT, MAX_IMAGE_BYTES); + let (reply, rx) = channel(); + control.pending.push(Pending::Screenshot { + slot, + callback: Some(callback), + region: None, + reply, + since: web_time::Instant::now().checked_sub(std::time::Duration::from_secs(10)).unwrap_or_else(web_time::Instant::now), + geometry: Geometry { size: egui::vec2(800.0, 600.0), pixels_per_point: 1.0, epoch: 0 }, + reserved: 800 * 600 * 4, + attempts: 0, + }); + control.finish_pending(&ctx, &mut FakeHost); + let error = rx.try_recv().expect("the request is answered").expect_err("a timeout is an error"); + assert!(error.contains("not being drawn"), "{error}"); + assert_eq!(control.reserved_bytes(), 0, "the reservation is released"); + assert!(control.pending.is_empty(), "the cancelled capture is not retried"); + } + + /// A real successful callback must not bypass expiry, including when resize would otherwise + /// retry it. A non-expired completion still produces a real encoded PNG reply. + #[test] + fn completed_screenshots_obey_the_admission_deadline_before_success_or_retry() { + for (expired, resized) in [(false, false), (true, false), (true, true)] { + let ctx = egui::Context::default(); + ctx.run_ui( + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(64.0, 32.0))), ..Default::default() }, + |_| {}, + ) + .drop_without_applying_deltas(); + let (mut control, _client) = attach(&ctx); + let geometry = Geometry::current(&ctx, 0); + let (slot, callback) = craft_ui::capture::CaptureSlot::new(&ctx, egui::ViewportId::ROOT, MAX_IMAGE_BYTES); + let image = Arc::new(egui::ColorImage::filled([64, 32], egui::Color32::RED)); + let weak_image = Arc::downgrade(&image); + callback.complete(image); + assert!(weak_image.upgrade().is_some(), "the completed slot really retains pixels"); + let (reply, rx) = channel(); + control.pending.push(Pending::Screenshot { + slot, + callback: None, + region: None, + reply, + since: if expired { + web_time::Instant::now().checked_sub(std::time::Duration::from_secs(10)).unwrap() + } else { + web_time::Instant::now() + }, + geometry, + reserved: geometry.image_bytes().unwrap(), + attempts: 0, + }); + if resized { + ctx.run_ui( + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(80.0, 40.0))), ..Default::default() }, + |_| {}, + ) + .drop_without_applying_deltas(); + assert_ne!(Geometry::current(&ctx, 0).size, geometry.size); + } + control.finish_pending(&ctx, &mut NoopHost); + let result = rx.try_recv().expect("one settled reply"); + if expired { + assert!(result.unwrap_err().contains("not being drawn"), "expiry takes priority over success or resize"); + } else { + let image = result.expect("the live completed capture still succeeds"); + assert_eq!(image["width"], 64); + assert_eq!(image["height"], 32); + use base64::Engine as _; + let png = base64::engine::general_purpose::STANDARD.decode(image["png_base64"].as_str().expect("actual PNG reply")).unwrap(); + assert!(png.starts_with(b"\x89PNG\r\n\x1a\n")); + } + assert!(control.pending.is_empty(), "an expired capture is never retried"); + assert_eq!(control.reserved_bytes(), 0); + assert!(weak_image.upgrade().is_none(), "the slot releases its pixels"); + assert!(rx.try_recv().is_err(), "exactly one reply"); + } + } + + #[test] + fn expired_unissued_screenshots_are_not_issued_and_late_callbacks_are_harmless() { + let ctx = egui::Context::default(); + let (mut control, _client) = attach(&ctx); + let (slot, callback) = craft_ui::capture::CaptureSlot::new(&ctx, egui::ViewportId::ROOT, MAX_IMAGE_BYTES); + let late_callback = callback.clone(); + let geometry = Geometry { size: egui::vec2(64.0, 32.0), pixels_per_point: 1.0, epoch: 0 }; + let (reply, rx) = channel(); + control.pending.push(Pending::Screenshot { + slot, + callback: Some(callback), + region: None, + reply, + since: web_time::Instant::now().checked_sub(std::time::Duration::from_secs(10)).unwrap(), + geometry, + reserved: 64 * 32 * 4, + attempts: 0, + }); + let output = ctx.run_ui(egui::RawInput::default(), |ui| control.finish_frame(ui.ctx(), &mut NoopHost)); + assert!( + output + .viewport_output + .values() + .all(|viewport| !viewport.commands.iter().any(|command| matches!(command, egui::ViewportCommand::Screenshot(_)))) + ); + output.drop_without_applying_deltas(); + assert!(rx.try_recv().unwrap().unwrap_err().contains("not being drawn")); + assert!(control.pending.is_empty()); + assert_eq!(control.reserved_bytes(), 0); + let image = Arc::new(egui::ColorImage::filled([64, 32], egui::Color32::RED)); + let weak_image = Arc::downgrade(&image); + late_callback.complete(image); + assert!(weak_image.upgrade().is_none(), "a late completion cannot resurrect a canceled request"); + control.finish_pending(&ctx, &mut NoopHost); + assert!(rx.try_recv().is_err(), "no second reply"); + } + + /// One queued batch is consumed by exactly one UI pass. + #[test] + fn one_batch_is_consumed_by_one_pass() { + let shared = Arc::new(Mutex::new(Shared::default())); + let mut plugin = ControlPlugin { shared: shared.clone() }; + { + let mut s = shared.lock().unwrap(); + s.epoch = 3; + s.inject.push_back(RequestBatches { + request: 1, + epoch: 3, + started: false, + since: web_time::Instant::now(), + release: Vec::new(), + batches: VecDeque::from([vec![egui::Event::PointerMoved(egui::pos2(2.0, 3.0))]]), + }); + s.inject.push_back(RequestBatches { + request: 2, + epoch: 3, + started: false, + since: web_time::Instant::now(), + release: Vec::new(), + batches: VecDeque::from([vec![egui::Event::PointerMoved(egui::pos2(4.0, 5.0))]]), + }); + } + let mut first = egui::RawInput::default(); + plugin.input_hook(&egui::Context::default(), &mut first); + assert_eq!(first.events.len(), 1); + let mut second = egui::RawInput::default(); + plugin.input_hook(&egui::Context::default(), &mut second); + assert_eq!(second.events.len(), 1); + assert_eq!(second.events[0], egui::Event::PointerMoved(egui::pos2(4.0, 5.0))); + let mut third = egui::RawInput::default(); + plugin.input_hook(&egui::Context::default(), &mut third); + assert!(third.events.is_empty(), "a consumed batch is never repeated"); + } +} + +#[cfg(test)] +mod screenshot_geometry_tests { + use super::*; + + #[test] + fn image_bytes_are_checked_against_overflow_and_limits() { + assert_eq!(checked_image_bytes(2, 3), Some(24)); + assert_eq!(checked_image_bytes(usize::MAX, 2), None); + let big = Geometry { size: egui::vec2(10_000.0, 10_000.0), pixels_per_point: 8.0, epoch: 0 }; + assert!(big.image_bytes().is_some_and(|bytes| bytes > MAX_IMAGE_BYTES)); + } + + /// The crop is derived from the returned pixels and the admitted logical rect, not from a + /// current `pixels_per_point`: a fitted surface may return a smaller image. + #[test] + fn region_crop_uses_the_capture_geometry() { + let image = egui::ColorImage::filled([200, 100], egui::Color32::RED); + let geometry = Geometry { size: egui::vec2(100.0, 50.0), pixels_per_point: 2.0, epoch: 7 }; + let reply = + screenshot_png(&image, Some(egui::Rect::from_min_max(egui::pos2(10.0, 5.0), egui::pos2(60.0, 30.0))), geometry).expect("a valid crop"); + assert_eq!(reply["width"], 100); + assert_eq!(reply["height"], 50); + assert_eq!(reply["pixels_per_point"], 2.0); + } + + /// A retry replaces one reservation in a running aggregate. Three 50 MiB captures that each + /// retry to 100 MiB must not all be admitted against a stale 150 MiB snapshot. + #[test] + fn retries_keep_one_running_aggregate_budget() { + const MIB: usize = 1024 * 1024; + let first = retried_reservation(3 * 50 * MIB, 50 * MIB, 100 * MIB).expect("the first retry fits"); + let second = retried_reservation(first, 50 * MIB, 100 * MIB).expect("the second retry fits"); + assert_eq!(second, 250 * MIB); + assert_eq!(retried_reservation(second, 50 * MIB, 100 * MIB), None, "the third would pass 256 MiB"); + assert_eq!(retried_reservation(0, 0, MAX_IMAGE_BYTES + 1), None, "a single image over the per-image limit never fits"); + } + + #[test] + fn an_image_with_another_aspect_ratio_is_rejected() { + let image = egui::ColorImage::filled([200, 100], egui::Color32::RED); + let geometry = Geometry { size: egui::vec2(100.0, 60.0), pixels_per_point: 2.0, epoch: 0 }; + assert!(screenshot_png(&image, None, geometry).is_err()); + } + + #[test] + fn an_outside_region_is_rejected_not_panicking() { + let image = egui::ColorImage::filled([20, 10], egui::Color32::RED); + let geometry = Geometry { size: egui::vec2(10.0, 5.0), pixels_per_point: 2.0, epoch: 0 }; + assert!(screenshot_png(&image, Some(egui::Rect::from_min_max(egui::pos2(11.0, 6.0), egui::pos2(15.0, 9.0))), geometry).is_err()); + } + + /// A region that a shrink now cuts off is refused, not silently clamped to a smaller crop. + #[test] + fn a_region_cut_by_a_shrink_is_rejected_instead_of_cropped() { + let image = egui::ColorImage::filled([20, 10], egui::Color32::RED); + let shrunk = Geometry { size: egui::vec2(10.0, 5.0), pixels_per_point: 2.0, epoch: 0 }; + let region = egui::Rect::from_min_max(egui::pos2(8.0, 4.0), egui::pos2(15.0, 9.0)); + assert!(!shrunk.contains_region(region)); + let error = screenshot_png(&image, Some(region), shrunk).expect_err("a cut region must not become a smaller image"); + assert!(error.contains("inside the window"), "{error}"); + } + + /// Screenshot identity is the captured content, not the window's monitor-space origin or the + /// focused widget: only the capture epoch and the viewport-local size/scale matter. + #[test] + fn capture_geometry_compares_size_scale_and_capture_epoch_only() { + let admitted = Geometry { size: egui::vec2(800.0, 600.0), pixels_per_point: 2.0, epoch: 5 }; + assert_eq!(admitted, Geometry { size: egui::vec2(800.0, 600.0), pixels_per_point: 2.0, epoch: 5 }); + assert_ne!(admitted, Geometry { size: egui::vec2(801.0, 600.0), pixels_per_point: 2.0, epoch: 5 }); + assert_ne!(admitted, Geometry { size: egui::vec2(800.0, 600.0), pixels_per_point: 1.5, epoch: 5 }); + assert_ne!(admitted, Geometry { size: egui::vec2(800.0, 600.0), pixels_per_point: 2.0, epoch: 6 }); + assert!(admitted.contains_region(egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(800.0, 600.0)))); + assert!(!admitted.contains_region(egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(801.0, 600.0)))); + assert!(!admitted.contains_region(egui::Rect::from_min_max(egui::pos2(-1.0, 0.0), egui::pos2(10.0, 10.0)))); + } +} + #[cfg(test)] mod pointer_tests { use super::*; @@ -1436,6 +3182,8 @@ mod pointer_tests { let slow_dt = (max_duration + 0.25) as f32; assert!(f64::from(slow_dt) > max_duration); for discard in [false, true] { + // This probe draws its own UI with the harness's fonts, not PdfCraftApp::ui. + // Exercise the readiness gate explicitly before allowing this host's input. let state = Probe { control: None, client: None, @@ -1484,6 +3232,26 @@ mod pointer_tests { ); h.run_steps(3); let c = h.state().client.as_ref().unwrap().clone(); + let probe_pos = h.get_by_label("Click probe").rect().center(); + let waiting = c.send("ui.click", json!({"x":probe_pos.x,"y":probe_pos.y})); + h.run_steps(3); + assert!(waiting.try_recv().is_err(), "unready host must defer control input"); + assert_eq!(h.state().primary, 0); + assert!(h.state().events.is_empty()); + h.state_mut().host.fonts_ready = true; + let mut ready_reply = None; + for _ in 0..8 { + h.step(); + if let Ok(reply) = waiting.try_recv() { + ready_reply = Some(reply); + break; + } + } + assert!(ready_reply.expect("ready host completes deferred click").is_ok()); + assert_eq!(h.state().primary, 1); + h.state_mut().primary = 0; + h.state_mut().events.clear(); + h.state_mut().discarded = false; assert_eq!(h.ctx.options(|o| o.input_options.max_click_duration), max_duration); let pos = h.get_by_label("Click probe").rect().center(); assert_eq!((h.state().primary, h.state().secondary, h.state().disabled), (0, 0, 0)); diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index 59535f35e6..4fe58a6714 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -232,7 +232,7 @@ impl Default for View { /// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table. pub fn contributors_ui(ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let id = egui::Id::new("credits_view"); + let id = egui::Id::unique("credits_view"); let mut v = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_default(); ui.horizontal_wrapped(|ui| { ui.label(crate::i18n::t("Show")); diff --git a/crates/ui-egui/src/crop.rs b/crates/ui-egui/src/crop.rs index 1344a30f33..1f1e1aa131 100644 --- a/crates/ui-egui/src/crop.rs +++ b/crates/ui-egui/src/crop.rs @@ -6,6 +6,7 @@ use pdfcraft_engine::{BoxSpec, Edit, PageBox}; use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; +use crate::page_pointer::PagePointer; /// A crop rectangle being dragged: (page, start on screen). pub type CropDrag = Option<(usize, Pos2)>; @@ -17,17 +18,18 @@ fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] { } /// Crop gestures on one page. Returns `Some(true)` to open Set Page Boxes for this page. +#[allow(clippy::too_many_arguments)] pub(crate) fn page_input( ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, + ptr: &PagePointer, page: usize, info: &DocInfo, view: &mut DocView, allowed: bool, ) -> bool { - let pointer = ui.input(|i| i.pointer.hover_pos()); - let origin = ui.input(|i| i.pointer.press_origin()); + let (pointer, origin) = (ptr.pos, ptr.origin); if pointer.is_some_and(|p| xf.rect.contains(p)) { ui.ctx().set_cursor_icon(if allowed { egui::CursorIcon::Crosshair } else { egui::CursorIcon::NotAllowed }); } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 086dd2346c..b3437246a0 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -10,11 +10,18 @@ use crate::{CloseRequest, Dialog, PdfCraftApp, PropsTab, panels::human_size, wid const INFO_KEYS: [&str; 4] = ["Title", "Author", "Subject", "Keywords"]; pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { + // The page sets hover cursors from the raw pointer position (form fields, links, comments) + // before any dialog is drawn, and nothing under a modal's backdrop takes clicks. Start from + // the arrow; the modal's own widgets set theirs as they are drawn. + if app.modal_open() || ctx.memory(|m| m.top_modal_layer().is_some()) { + ctx.set_cursor_icon(egui::CursorIcon::Default); + } dialogs(app, ctx); // Last, so it sits above Document Properties when Security's Unlock… asked for it. unlock(app, ctx); } +/// Draw the active modal dialog and apply the action the user chose. fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { password(app, ctx); save_prompt(app, ctx); @@ -43,6 +50,10 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { if let (Dialog::Properties(PropsTab::Fonts), Some((_, id))) = (dialog, app.active_ids()) { app.session.refresh_fonts(id); } + // Properties ▸ Space Audit walks the whole file, so it is measured once per edit, not per frame. + if dialog == Dialog::Properties(PropsTab::SpaceAudit) { + app.refresh_space_audit(); + } // Seed the editable Description fields from the document when the dialog opens. if let (Dialog::Properties(_), Some((_, id))) = (dialog, app.active_ids()) && app.props_draft.as_ref().is_none_or(|(d, _)| *d != id) @@ -118,6 +129,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { (PropsTab::Fonts, tl!("Fonts")), (PropsTab::Custom, tl!("Custom")), (PropsTab::Advanced, tl!("Advanced")), + (PropsTab::SpaceAudit, tl!("Space Audit")), ] { if widgets::mode_tab(ui, label, tab == tb).clicked() { next = Dialog::Properties(tb); @@ -129,27 +141,54 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { let Some((_, id)) = app.active_ids() else { return }; let Some(doc) = app.session.get(id) else { return }; let i = &doc.info; + // Description shows plain values among text fields; they start where the fields' text does. + let editable_info = tab == PropsTab::Description && doc.allows_modification() && app.props_draft.is_some(); + let inset = if editable_info { widgets::FIELD_TEXT_INSET } else { 0.0 }; let row = |ui: &mut egui::Ui, k: &str, v: String| { // Row labels are UI chrome; values are document data and stay as they are. ui.label(egui::RichText::new(tl!(k)).color(t.text_muted)); - ui.scope(|ui| { - ui.set_max_width(420.0); + ui.horizontal(|ui| { + ui.add_space(inset); ui.add( egui::Label::new(if v.is_empty() { egui::RichText::new("—").color(t.text_faint) } else { egui::RichText::new(v) }).wrap(), ); }); ui.end_row(); }; + // A file name or path is read left to right even in an RTL UI: the extension + // stays at its visual end, as in the tab strip. + let path_row = |ui: &mut egui::Ui, k: &str, v: String| { + ui.label(egui::RichText::new(tl!(k)).color(t.text_muted)); + ui.scope(|ui| { + ui.set_max_width(420.0); + // Align path text with editable fields while keeping its LTR text order. + ui.add_space(inset); + if v.is_empty() { + ui.label(egui::RichText::new("—").color(t.text_faint)); + } else { + crate::widgets::label_ltr(ui, v, crate::theme::regular(13.0), t.text); + } + }); + ui.end_row(); + }; egui::ScrollArea::vertical().max_height(460.0).auto_shrink([false, true]).show(ui, |ui| { + if tab == PropsTab::SpaceAudit { + ui.add_space(4.0); + crate::optimize_ui::audit_table(ui, &app.space_audit, &t); + return; + } egui::Grid::new("props").num_columns(2).spacing([18.0, 8.0]).min_col_width(140.0).show(ui, |ui| match tab { PropsTab::Description => { - row(ui, "File", crate::bidi::visual(&doc.name).into_owned()); + path_row(ui, "File", doc.name.clone()); match app.props_draft.as_mut() { - Some((_, draft)) if doc.allows_modification() => { + Some((_, draft)) if editable_info => { for (k, v) in INFO_KEYS.iter().zip(draft.iter_mut()) { let l = ui.label(egui::RichText::new(tl!(k)).color(t.text_muted)); - ui.add(widgets::line(v).desired_width(420.0).background_color(t.field).id_salt(("info", *k))) - .labelled_by(l.id); + // Subject and Keywords are often a sentence or a list: they wrap and grow + // instead of hiding most of the value past the field's edge. + let field = + if matches!(*k, "Subject" | "Keywords") { widgets::block(v).desired_rows(1) } else { widgets::line(v) }; + ui.add(field.desired_width(f32::INFINITY).background_color(t.field).id_salt(("info", *k))).labelled_by(l.id); ui.end_row(); } } @@ -170,7 +209,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { let Some((_, v)) = app.view_draft.as_mut() else { return }; ui.label(egui::RichText::new(tl!("Layout and Magnification")).font(theme::semibold(12.5))); ui.end_row(); - ui.label(tl!("Navigation tab")); + let label = ui.label(tl!("Navigation tab")); ui.add_enabled_ui(editable, |ui| { egui::ComboBox::from_id_salt("iv-nav") .selected_text(tl!(format!("{:?}", v.navigation) @@ -187,10 +226,12 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { ] { ui.selectable_value(&mut v.navigation, n, l); } - }); + }) + .response + .labelled_by(label.id); }); ui.end_row(); - ui.label(tl!("Page layout")); + let label = ui.label(tl!("Page layout")); ui.add_enabled_ui(editable, |ui| { let names = [ (L::Default, tl!("Default")), @@ -202,14 +243,18 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { (L::TwoUpContinuousCoverPage, tl!("Two-Up Continuous (Cover Page)")), ]; let shown = names.iter().find(|(l, _)| *l == v.layout).map_or(tl!("Default"), |(_, n)| n); - egui::ComboBox::from_id_salt("iv-layout").selected_text(shown).show_ui(ui, |ui| { - for (l, n) in names { - ui.selectable_value(&mut v.layout, l, n); - } - }); + egui::ComboBox::from_id_salt("iv-layout") + .selected_text(shown) + .show_ui(ui, |ui| { + for (l, n) in names { + ui.selectable_value(&mut v.layout, l, n); + } + }) + .response + .labelled_by(label.id); }); ui.end_row(); - ui.label(tl!("Magnification")); + let label = ui.label(tl!("Magnification")); ui.add_enabled_ui(editable, |ui| { ui.horizontal(|ui| { let names = [ @@ -224,21 +269,25 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { M::Percent(p) => format!("{p:.0}%"), m => names.iter().find(|(x, _)| *x == m).map_or(tl!("Default").to_string(), |(_, n)| n.to_string()), }; - egui::ComboBox::from_id_salt("iv-mag").selected_text(shown).show_ui(ui, |ui| { - for (m, n) in names { - ui.selectable_value(&mut v.magnification, m, n); - } - for p in [50.0, 75.0, 125.0, 150.0, 200.0] { - ui.selectable_value(&mut v.magnification, M::Percent(p), format!("{p:.0}%")); - } - }); + egui::ComboBox::from_id_salt("iv-mag") + .selected_text(shown) + .show_ui(ui, |ui| { + for (m, n) in names { + ui.selectable_value(&mut v.magnification, m, n); + } + for p in [50.0, 75.0, 125.0, 150.0, 200.0] { + ui.selectable_value(&mut v.magnification, M::Percent(p), format!("{p:.0}%")); + } + }) + .response + .labelled_by(label.id); }); }); ui.end_row(); - ui.label(tl!("Open to page")); + let label = ui.label(tl!("Open to page")); ui.add_enabled_ui(editable, |ui| { let mut p = v.page + 1; - if ui.add(egui::DragValue::new(&mut p).range(1..=pages.max(1))).changed() { + if ui.add(egui::DragValue::new(&mut p).range(1..=pages.max(1))).labelled_by(label.id).changed() { v.page = p - 1; } ui.label(crate::i18n::fmt(tl!("of {pages}"), &[("pages", &pages.to_string())])); @@ -433,7 +482,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { } PropsTab::Advanced => { row(ui, "PDF version", i.pdf_version.clone()); - row(ui, "Location", crate::bidi::visual(doc.path.as_deref().unwrap_or_default()).into_owned()); + path_row(ui, "Location", doc.path.clone().unwrap_or_default()); row(ui, "File size", format!("{} ({})", human_size(i.file_size), crate::panels::byte_count(i.file_size))); let p = &i.pages[0]; row(ui, "Page size", units.size(f64::from(p.width), f64::from(p.height))); @@ -500,7 +549,9 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { ui.end_row(); } } - }) + // Laid out above, outside the two-column grid. + PropsTab::SpaceAudit => {} + }); }); } Dialog::Split => { @@ -582,10 +633,10 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { d.to = d.to.clamp(1, count); d.from = d.from.clamp(1, d.to); ui.horizontal(|ui| { - ui.label(tl!("Original: replace pages")); - let from = ui.add(egui::DragValue::new(&mut d.from).range(1..=count)); - ui.label(tl!("to")); - let to = ui.add(egui::DragValue::new(&mut d.to).range(1..=count)); + let from_label = ui.label(tl!("Original: replace pages")); + let from = ui.add(egui::DragValue::new(&mut d.from).range(1..=count)).labelled_by(from_label.id); + let to_label = ui.label(tl!("to")); + let to = ui.add(egui::DragValue::new(&mut d.to).range(1..=count)).labelled_by(to_label.id); if from.changed() || to.changed() { d.range_edited = true; } @@ -595,8 +646,8 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { let max_start = d.src_pages.saturating_sub(n) + 1; d.src_from = d.src_from.clamp(1, max_start.max(1)); ui.horizontal(|ui| { - ui.label(crate::i18n::fmt(tl!("Replacement: pages of {name}"), &[("name", &d.name)])); - ui.add(egui::DragValue::new(&mut d.src_from).range(1..=max_start.max(1))); + let replacement_label = ui.label(crate::i18n::fmt(tl!("Replacement: pages of {name}"), &[("name", &d.name)])); + ui.add(egui::DragValue::new(&mut d.src_from).range(1..=max_start.max(1))).labelled_by(replacement_label.id); ui.label(crate::i18n::fmt(tl!("to {n}"), &[("n", &(d.src_from + n - 1).to_string())])); ui.label(egui::RichText::new(crate::i18n::fmt(tl!("of {name}"), &[("name", &d.src_pages.to_string())])).color(t.text_muted)); }); @@ -837,7 +888,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { optimize_now = ok; close = ok || cancel; if std::mem::take(&mut app.optimize_draft.audit) { - app.space_audit = app.active_ids().and_then(|(_, id)| app.session.get(id)).map(|d| d.audit_space()).unwrap_or_default(); + app.refresh_space_audit(); next = Dialog::AuditSpace; } return; @@ -1262,7 +1313,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { } Dialog::About => { // Tabs About · Contributors · Models (craftrules standards/contributors.md). - let tab_id = egui::Id::new("about_tab"); + let tab_id = egui::Id::unique("about_tab"); let mut tab = ui.data_mut(|d| d.get_temp::(tab_id)).unwrap_or(0); ui.horizontal_wrapped(|ui| { for (i, label) in ["About", "Contributors", "Models"].into_iter().enumerate() { @@ -1309,43 +1360,47 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { } ui.add_space(16.0); let changed = draft_changes(app).is_some_and(|c| !c.is_empty()); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if dialog == Dialog::Recovery { - if widgets::pill_button(ui, tl!("Recover"), true).clicked() { - recover = Some(true); + // One row high: on its own, a right-to-left layout centres the buttons in all the height + // left, which after a taller Properties tab is the room that tab left behind. + ui.horizontal(|ui| { + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if dialog == Dialog::Recovery { + if widgets::pill_button(ui, tl!("Recover"), true).clicked() { + recover = Some(true); + close = true; + } + if widgets::pill_button(ui, tl!("Discard"), false).clicked() { + recover = Some(false); + close = true; + } + } else if dialog == Dialog::NumberPages { + if widgets::pill_button(ui, tl!("OK"), true).clicked() { + apply_number = true; + close = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + close = true; + } + } else if dialog == Dialog::Split { + if ui.add_enabled_ui(split_ready.is_some(), |ui| widgets::pill_button(ui, tl!("Split"), true)).inner.clicked() { + split_now = split_ready.clone(); + close = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + close = true; + } + } else if changed { + if widgets::pill_button(ui, tl!("OK"), true).clicked() { + apply = true; + close = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + close = true; + } + } else if widgets::pill_button(ui, tl!("Close"), true).clicked() { close = true; } - if widgets::pill_button(ui, tl!("Discard"), false).clicked() { - recover = Some(false); - close = true; - } - } else if dialog == Dialog::NumberPages { - if widgets::pill_button(ui, tl!("OK"), true).clicked() { - apply_number = true; - close = true; - } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { - close = true; - } - } else if dialog == Dialog::Split { - if ui.add_enabled_ui(split_ready.is_some(), |ui| widgets::pill_button(ui, tl!("Split"), true)).inner.clicked() { - split_now = split_ready.clone(); - close = true; - } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { - close = true; - } - } else if changed { - if widgets::pill_button(ui, tl!("OK"), true).clicked() { - apply = true; - close = true; - } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { - close = true; - } - } else if widgets::pill_button(ui, tl!("Close"), true).clicked() { - close = true; - } + }) }); }); if let Some(yes) = recover { @@ -1854,7 +1909,16 @@ fn remote_link_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) { ui.add_space(6.0); ui.label(tl!("This document is trying to open a local file:")); egui::ScrollArea::vertical().max_height(160.0).show(ui, |ui| { - ui.add(egui::Label::new(egui::RichText::new(pending.path.to_string_lossy()).monospace()).wrap().selectable(true)); + // A local path is file/path chrome: left to right, so its final component stays last. + let mut job = egui::text::LayoutJob::simple( + pending.path.to_string_lossy().into_owned(), + egui::FontId::monospace(13.0), + crate::theme::Tokens::get(ui.ctx()).text, + ui.available_width().max(0.0), + ); + job.direction = egui::epaint::text::TextDirection::LeftToRight; + let galley = ui.painter().layout_job(job); + ui.add(egui::Label::new(egui::WidgetText::Galley(galley)).selectable(true)); }); ui.add_space(8.0); ui.label(tl!("Only allow this if you trust the source document.")); diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs deleted file mode 100644 index 11eb732be3..0000000000 --- a/crates/ui-egui/src/docking.rs +++ /dev/null @@ -1,547 +0,0 @@ -//! Application panel identities and persistence around the shared docking renderer. -//! Document contents remain owned by their existing view implementations. - -use craft_ui::docking::{Action, DockArea, DockStyle, Layout, Location, Node, PanelLimits, Permissions, Placement, Zone}; -use craft_ui::layout::{SplitAxis, SplitSize}; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum Panel { - Canvas, - Tools, - Inspector, -} - -impl Panel { - pub const ALL: &[Self] = &[Self::Canvas, Self::Tools, Self::Inspector]; - pub fn id(self) -> &'static str { - match self { - Self::Canvas => "canvas", - Self::Tools => "tools", - Self::Inspector => "inspector", - } - } - fn label(self) -> &'static str { - match self { - Self::Canvas => "Document", - Self::Tools => "Tools", - Self::Inspector => "Inspector", - } - } - fn default_side(self) -> Zone { - match self { - Self::Canvas => Zone::Right, - Self::Tools => Zone::Left, - Self::Inspector => Zone::Right, - } - } - fn parse(id: &str) -> Result { - Self::ALL.iter().copied().find(|panel| panel.id() == id).ok_or_else(|| format!("Unknown panel: {id}")) - } -} - -fn permissions(panel: &Panel) -> Permissions { - if *panel == Panel::Canvas { Permissions::PROTECTED } else { Permissions::default() } -} - -fn tabs(panel: Panel) -> Node { - Node::Tabs { panels: vec![panel], active: 0 } -} - -fn split(axis: SplitAxis, size: SplitSize, first: Node, second: Node) -> Node { - Node::Split { axis, size, first: Box::new(first), second: Box::new(second) } -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(default)] -pub struct Workspace { - pub layout: Layout, - /// Closed panels retain their most recent group, side, size or floating rectangle. - pub hidden: Vec<(Panel, Location)>, -} - -impl Default for Workspace { - fn default() -> Self { - Self { - layout: Layout { - root: Some(split( - SplitAxis::Horizontal, - SplitSize::FixedFirst(272.0), - tabs(Panel::Tools), - split(SplitAxis::Horizontal, SplitSize::FixedSecond(330.0), tabs(Panel::Canvas), tabs(Panel::Inspector)), - )), - floating: Vec::new(), - }, - hidden: Vec::new(), - } - } -} - -impl Workspace { - /// Side of the Tools panel, including its saved position while closed. - pub fn tools_on_right(&self) -> bool { - let location = self - .layout - .location(&Panel::Tools) - .ok() - .or_else(|| self.hidden.iter().find(|(panel, _)| *panel == Panel::Tools).map(|(_, location)| location.clone())); - location.is_some_and(|location| matches!(location.placement, Placement::Split(Zone::Right))) - } - - /// Place Tools at the outer left or right edge while preserving its open state. - pub fn set_tools_on_right(&mut self, right: bool) -> Result<(), String> { - let was_open = self.layout.contains(&Panel::Tools); - self.set_visible(Panel::Tools, true)?; - let anchor = if right && self.layout.contains(&Panel::Inspector) { Panel::Inspector } else { Panel::Canvas }; - self.apply(Action::Move { panel: Panel::Tools, anchor, placement: Placement::Split(if right { Zone::Right } else { Zone::Left }) })?; - if !was_open { - self.set_visible(Panel::Tools, false)?; - } - Ok(()) - } - - pub fn validate(&self) -> Result<(), String> { - self.layout.validate().map_err(|error| error.to_string())?; - if !self.layout.contains(&Panel::Canvas) || self.hidden.len() > Panel::ALL.len() { - return Err("The panel layout must contain its document view and bounded hidden panels".into()); - } - // Saved settings cannot turn the protected document view into a floating or closable tab. - if self.layout.floating.iter().any(|group| group.panels.contains(&Panel::Canvas)) { - return Err("The document view cannot float".into()); - } - let mut nodes: Vec<_> = self.layout.root.iter().collect(); - while let Some(node) = nodes.pop() { - match node { - Node::Split { first, second, .. } => { - nodes.push(first); - nodes.push(second); - } - Node::Tabs { panels, .. } if panels.contains(&Panel::Canvas) && panels.len() != 1 => { - return Err("The document view cannot share a tab group".into()); - } - Node::Stack { entries } if entries.iter().any(|entry| entry.panel == Panel::Canvas) => { - return Err("The document view cannot be collapsed".into()); - } - _ => {} - } - } - let mut seen = std::collections::HashSet::new(); - for (panel, location) in &self.hidden { - if *panel == Panel::Canvas || self.layout.contains(panel) || !seen.insert(*panel) { - return Err("A hidden panel has an invalid or duplicate identity".into()); - } - if location.anchor == Some(Panel::Canvas) && matches!(location.placement, Placement::Tab { .. } | Placement::Split(Zone::Center)) { - return Err("A hidden panel cannot restore over the document view".into()); - } - let mut probe = self.layout.clone(); - if let Err(error) = probe.restore(*panel, location) - && error != craft_ui::docking::DockError::MissingTarget - { - return Err(error.to_string()); - } - } - Ok(()) - } - - pub fn apply(&mut self, action: Action) -> Result<(), String> { - self.validate()?; - let mut next = self.clone(); - if let Action::Close { panel } = &action { - let location = next.layout.location(panel).map_err(|error| error.to_string())?; - next.hidden.retain(|(id, _)| id != panel); - next.hidden.push((*panel, location)); - } else if let Action::Open { panel, .. } | Action::OpenAt { panel, .. } = &action { - next.hidden.retain(|(id, _)| id != panel); - } - next.layout.apply_with_permissions(action, permissions).map_err(|error| error.to_string())?; - next.validate()?; - *self = next; - Ok(()) - } - - /// Whether a panel's body is exposed by its selected tab or open stack entry. - pub(crate) fn exposed(&self, panel: Panel) -> bool { - if self.layout.floating.iter().any(|group| group.panels.get(group.active) == Some(&panel)) { - return true; - } - let mut pending: Vec<_> = self.layout.root.iter().collect(); - while let Some(node) = pending.pop() { - match node { - Node::Split { first, second, .. } => { - pending.push(first); - pending.push(second); - } - Node::Tabs { panels, active } if panels.get(*active) == Some(&panel) => return true, - Node::Stack { entries } if entries.iter().any(|entry| entry.panel == panel && entry.open) => return true, - _ => {} - } - } - false - } - - pub fn set_visible(&mut self, panel: Panel, visible: bool) -> Result<(), String> { - if self.layout.contains(&panel) == visible { - return Ok(()); - } - if !visible { - return self.apply(Action::Close { panel }); - } - self.validate()?; - let mut next = self.clone(); - let mut location = next.hidden.iter().find(|(id, _)| *id == panel).map(|(_, location)| location.clone()); - next.hidden.retain(|(id, _)| *id != panel); - if let Some(saved) = &mut location - && saved.floating.is_some() - && saved.anchor.as_ref().is_some_and(|anchor| !next.layout.floating.iter().any(|group| group.panels.contains(anchor))) - { - // The other members of a floating group may have been closed too. - saved.anchor = None; - saved.placement = Placement::Tab { before: None }; - } - let restored = location.as_ref().is_some_and(|location| next.layout.restore(panel, location).is_ok()); - if !restored { - next.layout - .apply_with_permissions( - Action::OpenAt { panel, anchor: Panel::Canvas, placement: Placement::Split(panel.default_side()) }, - permissions, - ) - .map_err(|error| error.to_string())?; - } - next.validate()?; - *self = next; - Ok(()) - } - - fn command(&mut self, params: &Value) -> Result<(), String> { - self.validate()?; - if let Some(action) = params.get("action") { - let action: Action = serde_json::from_value(action.clone()).map_err(|error| error.to_string())?; - return self.apply(action); - } - let operation = params.get("operation").and_then(Value::as_str).ok_or("operation is required")?; - if operation == "resizeSplit" { - let path: Vec = - serde_json::from_value(params.get("path").cloned().ok_or("path is required")?).map_err(|error| error.to_string())?; - let size: SplitSize = - serde_json::from_value(params.get("size").cloned().ok_or("size is required")?).map_err(|error| error.to_string())?; - return self.apply(Action::ResizeSplit { path, size }); - } - let panel = Panel::parse(params.get("panel").and_then(Value::as_str).ok_or("panel is required")?)?; - let action = match operation { - "open" => { - self.set_visible(panel, true)?; - return self.apply(Action::Activate { panel }); - } - "close" => Action::Close { panel }, - "activate" => Action::Activate { panel }, - "setStackOpen" => Action::SetStackOpen { panel, open: params.get("open").and_then(Value::as_bool).ok_or("open must be a boolean")? }, - "resizeStack" => { - let value = params.get("height").ok_or("height is required")?; - let height = if value.is_null() { - None - } else { - Some( - value - .as_f64() - .filter(|height| height.is_finite() && (0.0..=1_000_000.0).contains(height)) - .ok_or("height must be finite and bounded")? as f32, - ) - }; - Action::ResizeStack { panel, height } - } - "float" | "moveFloating" => { - let rect = params.get("rect").and_then(Value::as_array).filter(|rect| rect.len() == 4).ok_or("rect must be [x,y,width,height]")?; - let mut values = [0.0f32; 4]; - for (output, value) in values.iter_mut().zip(rect) { - *output = value - .as_f64() - .filter(|number| number.is_finite() && number.abs() <= 1_000_000.0) - .ok_or("rect values must be finite and bounded")? as f32; - } - if operation == "float" { Action::Float { panel, rect: values } } else { Action::MoveFloating { panel, rect: values } } - } - "move" => { - let anchor = Panel::parse(params.get("target").and_then(Value::as_str).ok_or("target is required")?)?; - let placement = match params.get("zone").and_then(Value::as_str).unwrap_or("center") { - "center" => Placement::Split(Zone::Center), - "left" => Placement::Split(Zone::Left), - "right" => Placement::Split(Zone::Right), - "top" => Placement::Split(Zone::Top), - "bottom" => Placement::Split(Zone::Bottom), - _ => return Err("zone must be center, left, right, top or bottom".into()), - }; - let placement = if let Some(before) = params.get("before") { - if !matches!(placement, Placement::Split(Zone::Center)) { - return Err("before only applies to a center move".into()); - } - Placement::Tab { - before: if before.is_null() { - None - } else { - Some(Panel::parse(before.as_str().ok_or("before must be a panel ID or null")?)?) - }, - } - } else { - placement - }; - Action::Move { panel, anchor, placement } - } - _ => return Err("operation must be open, close, activate, float, move, moveFloating, resizeSplit, setStackOpen or resizeStack".into()), - }; - self.apply(action) - } -} - -fn sync(workspace: &mut Workspace, app: &crate::PdfCraftApp) -> Result<(), String> { - workspace.set_visible(Panel::Tools, app.left_open)?; - workspace.set_visible(Panel::Inspector, app.right.is_some()) -} - -fn drain_reveals(workspace: &mut Workspace, app: &mut crate::PdfCraftApp) { - for panel in std::mem::take(&mut app.pending_panel_reveals) { - if workspace.layout.contains(&panel) { - let _ = workspace.apply(Action::Activate { panel }); - } - } -} - -fn publish_visibility(app: &mut crate::PdfCraftApp, workspace: &Workspace) { - app.left_open = workspace.layout.contains(&Panel::Tools); - if !workspace.layout.contains(&Panel::Inspector) { - if app.right.is_some() { - app.choose_right_panel(None); - } - } else if app.right.is_none() { - app.choose_right_panel(Some(crate::RightPanel::Comments)); - } -} - -pub fn command(app: &mut crate::PdfCraftApp, reset: bool, params: &Value) -> Result { - let mut workspace = app.docking.clone(); - if reset { - workspace = Workspace::default(); - workspace.set_visible(Panel::Inspector, app.right.is_some())?; - } else { - sync(&mut workspace, app)?; - workspace.command(params)?; - } - publish_visibility(app, &workspace); - app.docking = workspace; - Ok(json!({"docking": app.docking})) -} - -// Split paths change when a temporarily unavailable pane is pruned. Translate renderer -// paths back to the saved tree before applying geometry changes. -fn saved_split_path(saved: &Layout, rendered: &Layout, path: &[bool]) -> Option> { - let mut target = rendered.root.as_ref()?; - for second in path { - let Node::Split { first, second: other, .. } = target else { return None }; - target = if *second { other } else { first }; - } - let mut pending = vec![(saved.root.as_ref()?, Vec::new())]; - while let Some((node, original_path)) = pending.pop() { - if let Node::Split { first, second, .. } = node { - let mut projection = Layout { root: Some(node.clone()), floating: Vec::new() }; - if projection.contains(&Panel::Inspector) { - let _ = projection.apply(Action::Close { panel: Panel::Inspector }); - } - let has_available_panel = |child: &Node| { - Layout { root: Some(child.clone()), floating: Vec::new() }.panels().into_iter().any(|panel| *panel != Panel::Inspector) - }; - // An ancestor whose Inspector-only side disappeared projects to its surviving - // child. Its divider is absent, so only the child's split may own this resize. - if has_available_panel(first) && has_available_panel(second) && projection.root.as_ref() == Some(target) { - return Some(original_path); - } - let mut second_path = original_path.clone(); - second_path.push(true); - pending.push((second, second_path)); - let mut first_path = original_path; - first_path.push(false); - pending.push((first, first_path)); - } - } - None -} - -pub fn show(app: &mut crate::PdfCraftApp, ui: &mut egui::Ui) { - let t = crate::theme::Tokens::get(ui.ctx()); - let mut workspace = std::mem::take(&mut app.docking); - if let Err(error) = workspace.validate().and_then(|()| sync(&mut workspace, app)) { - app.notify_fmt("Panel layout could not be restored: {error}", &[("error", &error)]); - workspace = Workspace::default(); - let _ = sync(&mut workspace, app); - } - let mut style = DockStyle::from_ui(ui); - style.min_pane = 96.0; - style.float_label = crate::i18n::t("Float panel").into(); - style.close_label = crate::i18n::t("Close panel").into(); - style.move_label = crate::i18n::t("Move to group").into(); - style.panels_label = crate::i18n::t("Panels").into(); - style.resize_label = crate::i18n::t("Resize panels").into(); - style.resize_window_label = crate::i18n::t("Resize panel window").into(); - style.background = t.panel; - style.tab_background = t.chrome; - style.active_background = t.selected; - style.text = t.text; - style.inactive_text = t.text_muted; - style.border = egui::Stroke::new(1.0, t.divider); - style.accent = t.accent; - style.font = crate::theme::medium(12.0); - drain_reveals(&mut workspace, app); - let mut rendered = workspace.layout.clone(); - if app.active.is_none() && rendered.contains(&Panel::Inspector) { - // Availability is a render projection: the saved group, selection and width survive Home. - let _ = rendered.apply(Action::Close { panel: Panel::Inspector }); - } - // Tools docked on its own is drawn bare, without a tab strip, like Acrobat's tool pane. Grouped - // with another panel, or floating, it keeps its tab so it can be dragged away again; moving it - // to the other side stays a preference and a command. - let bare_tools = rendered.location(&Panel::Tools).is_ok_and(|l| l.floating.is_none() && matches!(l.placement, Placement::Split(_))); - let render_permissions = move |panel: &Panel| if bare_tools && *panel == Panel::Tools { Permissions::PROTECTED } else { permissions(panel) }; - let output = DockArea::new(egui::Id::new("pdfcraft-docking")).show_with_limits( - ui, - &rendered, - &style, - |panel| crate::i18n::t(panel.label()).into(), - render_permissions, - panel_limits, - |ui, panel| match panel { - Panel::Canvas => { - ui.painter().rect_filled(ui.max_rect(), 0.0, t.pasteboard); - match app.active { - None if app.combine_showing() => crate::combine_ui::page(app, ui), - None => crate::home::show(app, ui), - Some(index) => match crate::compare_ui::side_by_side(app, index) { - Some((old, new)) => crate::compare_ui::compare_area(app, old, new, ui), - None => crate::canvas::document_area(app, index, ui), - }, - } - } - Panel::Tools => crate::panels::left_panel(app, ui), - Panel::Inspector => { - ui.data_mut(|data| data.insert_temp(egui::Id::new("pdfcraft-inspector-body"), ui.max_rect())); - crate::panels::right_panel(app, ui); - } - }, - ); - let _ = sync(&mut workspace, app); - drain_reveals(&mut workspace, app); - publish_visibility(app, &workspace); - app.docking = workspace; - for mut action in output.actions { - if app.active.is_none() - && let Action::ResizeSplit { path, .. } = &mut action - { - let Some(saved_path) = saved_split_path(&app.docking.layout, &rendered, path) else { continue }; - *path = saved_path; - } - if let Err(error) = - serde_json::to_value(action).map_err(|error| error.to_string()).and_then(|action| command(app, false, &json!({"action": action}))) - { - app.notify_fmt("Panel move was not applied: {error}", &[("error", &error)]); - } - } -} - -fn panel_limits(panel: &Panel) -> PanelLimits { - let (min, max_width) = match panel { - Panel::Canvas => (egui::vec2(160.0, 160.0), 1_000_000.0), - Panel::Tools => (egui::vec2(240.0, 120.0), 520.0), - Panel::Inspector => (egui::vec2(260.0, 120.0), 520.0), - }; - PanelLimits { min, max: egui::vec2(max_width, 1_000_000.0) } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn tools_side_moves_to_both_edges_and_survives_closed_restore() { - let mut workspace = Workspace::default(); - assert!(!workspace.tools_on_right()); - workspace.set_tools_on_right(true).unwrap(); - assert!(workspace.tools_on_right()); - workspace.set_visible(Panel::Tools, false).unwrap(); - assert!(workspace.tools_on_right()); - workspace.set_tools_on_right(false).unwrap(); - assert!(!workspace.tools_on_right()); - assert!(!workspace.layout.contains(&Panel::Tools)); - workspace.set_visible(Panel::Tools, true).unwrap(); - assert!(!workspace.tools_on_right()); - workspace.validate().unwrap(); - } - - #[test] - fn exposed_respects_collapsed_stack_and_inactive_floating_tabs() { - let mut workspace = Workspace { - layout: Layout { - root: Some(split( - SplitAxis::Horizontal, - SplitSize::FixedFirst(272.0), - Node::Stack { - entries: vec![ - craft_ui::docking::StackEntry { panel: Panel::Tools, open: true, height: None }, - craft_ui::docking::StackEntry { panel: Panel::Inspector, open: false, height: None }, - ], - }, - tabs(Panel::Canvas), - )), - floating: Vec::new(), - }, - hidden: Vec::new(), - }; - workspace.validate().unwrap(); - assert!(workspace.exposed(Panel::Tools)); - assert!(!workspace.exposed(Panel::Inspector)); - workspace.apply(Action::Activate { panel: Panel::Inspector }).unwrap(); - assert!(workspace.exposed(Panel::Inspector)); - workspace.apply(Action::Float { panel: Panel::Tools, rect: [10.0, 20.0, 300.0, 400.0] }).unwrap(); - workspace.apply(Action::Move { panel: Panel::Inspector, anchor: Panel::Tools, placement: Placement::Split(Zone::Center) }).unwrap(); - assert!(!workspace.exposed(Panel::Tools)); - assert!(workspace.exposed(Panel::Inspector)); - } - - #[test] - fn unavailable_inspector_split_resize_targets_saved_tools_split() { - let mut workspace = Workspace::default(); - // Inspector at the outer edge makes the visible Tools split move to the root. - workspace.apply(Action::Move { panel: Panel::Inspector, anchor: Panel::Tools, placement: Placement::Split(Zone::Left) }).unwrap(); - let mut rendered = workspace.layout.clone(); - rendered.apply(Action::Close { panel: Panel::Inspector }).unwrap(); - let path = saved_split_path(&workspace.layout, &rendered, &[]).unwrap(); - workspace.apply(Action::ResizeSplit { path, size: SplitSize::FixedFirst(290.0) }).unwrap(); - let mut resized = workspace.layout.clone(); - resized.apply(Action::Close { panel: Panel::Inspector }).unwrap(); - assert!(matches!(resized.root, Some(Node::Split { size: SplitSize::FixedFirst(290.0), .. }))); - } - - #[test] - fn unavailable_outer_inspector_does_not_capture_its_childs_resize() { - let mut workspace = Workspace { - layout: Layout { - root: Some(split( - SplitAxis::Horizontal, - SplitSize::FixedSecond(330.0), - split(SplitAxis::Horizontal, SplitSize::FixedFirst(272.0), tabs(Panel::Tools), tabs(Panel::Canvas)), - tabs(Panel::Inspector), - )), - floating: Vec::new(), - }, - hidden: Vec::new(), - }; - workspace.validate().unwrap(); - let mut rendered = workspace.layout.clone(); - rendered.apply(Action::Close { panel: Panel::Inspector }).unwrap(); - let path = saved_split_path(&workspace.layout, &rendered, &[]).unwrap(); - assert_eq!(path, vec![false]); - workspace.apply(Action::ResizeSplit { path, size: SplitSize::FixedFirst(290.0) }).unwrap(); - assert!(matches!(workspace.layout.root, Some(Node::Split { size: SplitSize::FixedSecond(330.0), .. }))); - let mut resized = workspace.layout.clone(); - resized.apply(Action::Close { panel: Panel::Inspector }).unwrap(); - assert!(matches!(resized.root, Some(Node::Split { size: SplitSize::FixedFirst(290.0), .. }))); - } -} diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 65c7bd780a..4f5630c0b9 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -12,6 +12,7 @@ use pdfcraft_engine::{Edit, ObjectKind, ObjectTarget}; use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; +use crate::page_pointer::PagePointer; const ACCENT: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); @@ -245,6 +246,9 @@ pub struct LineEditor { size: f32, /// Take the keyboard focus on the next frame. pub(crate) focus: bool, + /// Whether the text box had the keyboard at the end of the editor's last frame + /// ([`lost_focus_once`]). + had_focus: bool, /// The Format text panel's values, and what the paragraph had (to send only changes). pub look: pdfcraft_engine::AddedText, look0: pdfcraft_engine::AddedText, @@ -467,6 +471,19 @@ pub struct ImageSelection { settled: Option<(Rect, Rect)>, } +impl ImageSelection { + /// Cancel an in-progress text, rotation or crop drag and restore the crop it was previewing. + pub(crate) fn cancel_pointer_gesture(&mut self) { + self.drag = None; + self.turning = false; + if let Some(crop) = &mut self.crop + && let Some((_, _, before)) = crop.drag.take() + { + crop.keep = before; + } + } +} + /// Cropping a page image: the handles trim instead of resizing, the part that will go is /// dimmed, Enter or Done applies and Esc or Cancel leaves it. #[derive(Clone, Debug, PartialEq)] @@ -497,6 +514,7 @@ fn crop_input( ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, + ptr: &PagePointer, page: usize, info: &DocInfo, view: &mut DocView, @@ -506,12 +524,12 @@ fn crop_input( ) -> bool { let painter = ui.painter(); let Some(mode) = view.image_selection.as_mut().and_then(|s| s.crop.as_mut()) else { return false }; - let hover_pos = ui.input(|inp| inp.pointer.hover_pos()); + let hover_pos = ptr.pos; let keep_screen = |keep: [f64; 4]| xf.user_rect(info, page, keep.map(|v| v as f32)).intersect(b); // A drag in progress moves a grip; the box stays inside the picture and at least 6 px. if allow_interaction && resp.drag_started() - && let Some(o) = ui.input(|inp| inp.pointer.press_origin()) + && let Some(o) = ptr.origin { let k = keep_screen(mode.keep); let grip = crop_handles(k).iter().position(|h| h.distance(o) < 8.0).or_else(|| k.contains(o).then_some(8)); @@ -558,7 +576,7 @@ fn crop_input( max.x = max.x.clamp(min.x + min_w, b.max.x); max.y = max.y.clamp(min.y + min_h, b.max.y); mode.keep = user_box(xf, info, page, Rect::from_min_max(min, max)); - if resp.drag_stopped() || !ui.input(|inp| inp.pointer.primary_down()) { + if resp.drag_stopped() || !ptr.primary_down { mode.drag = None; } } @@ -664,7 +682,7 @@ fn stretched_picture(painter: &egui::Painter, xf: &PageXform, tex: egui::Texture /// Once a selected corner or the rotate knob is grabbed, text under it cannot take over the gesture. The same /// applies after the pointer leaves the image/page while dragging. -pub(crate) fn image_owns_pointer(ui: &egui::Ui, xf: &PageXform, page: usize, info: &DocInfo, view: &DocView) -> bool { +pub(crate) fn image_owns_pointer(ui: &egui::Ui, xf: &PageXform, ptr: &PagePointer, page: usize, info: &DocInfo, view: &DocView) -> bool { let Some(selection) = view.image_selection.as_ref().filter(|s| s.page == page) else { return false }; if selection.drag.is_some() { return true; @@ -672,7 +690,7 @@ pub(crate) fn image_owns_pointer(ui: &egui::Ui, xf: &PageXform, page: usize, inf let Some(image) = view.edit_images.get(&page).and_then(|(_, images)| images.get(selection.index)) else { return false }; let Some(rect) = image.visible_rect else { return false }; let b = xf.user_rect(info, page, rect.map(|v| v as f32)); - let p = ui.input(|i| if i.pointer.primary_down() { i.pointer.press_origin() } else { i.pointer.hover_pos() }); + let p = if ptr.primary_down { ptr.origin } else { ptr.pos }; let knob = rotate_knob(b, ui.clip_rect()).0; p.is_some_and(|p| [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom(), knob].iter().any(|c| c.distance(p) < 8.0)) } @@ -800,6 +818,7 @@ pub(crate) fn image_input( ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, + ptr: &PagePointer, page: usize, info: &DocInfo, images: &[pdfcraft_engine::PageImage], @@ -809,7 +828,7 @@ pub(crate) fn image_input( ) -> bool { let boxes: Vec = images.iter().map(|im| im.visible_rect.map_or(Rect::NOTHING, |r| xf.user_rect(info, page, r.map(|v| v as f32)))).collect(); let painter = ui.painter(); - let hover_pos = ui.input(|i| i.pointer.hover_pos()); + let hover_pos = ptr.pos; let image_contains = |index: usize, point: Pos2| { let (x, y) = xf.screen_to_view(point); let Some(p) = info.pages.get(page) else { return false }; @@ -853,7 +872,7 @@ pub(crate) fn image_input( if let Some(i) = selected.filter(|i| boxes.get(*i).is_some_and(|b| b.is_positive())) { let b = boxes[i]; if view.image_selection.as_ref().is_some_and(|s| s.crop.is_some()) { - return crop_input(ui, resp, xf, page, info, view, i, b, allow_interaction); + return crop_input(ui, resp, xf, ptr, page, info, view, i, b, allow_interaction); } let corners = [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom()]; let handles = |rect: Rect| { @@ -903,10 +922,9 @@ pub(crate) fn image_input( ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); } } - let origin = ui.input(|i| i.pointer.press_origin()); if allow_interaction && resp.drag_started() - && let Some(o) = origin + && let Some(o) = ptr.origin && o.distance(knob) < 8.0 { if let Some(s) = view.image_selection.as_mut() { @@ -915,7 +933,7 @@ pub(crate) fn image_input( } } else if allow_interaction && resp.drag_started() - && let Some(o) = origin + && let Some(o) = ptr.origin { let corner = corners.iter().position(|c| c.distance(o) < 8.0); if corner.is_some() || image_contains(i, o) { @@ -942,7 +960,7 @@ pub(crate) fn image_input( painter.rect(label, CornerRadius::same(3), Color32::WHITE, Stroke::new(1.0, ACCENT), egui::StrokeKind::Middle); painter.galley(label.center() - galley.size() / 2.0, galley, ACCENT); ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); - if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) { + if resp.drag_stopped() || !ptr.primary_down { if let Some(s) = view.image_selection.as_mut() { s.drag = None; s.turning = false; @@ -1000,7 +1018,7 @@ pub(crate) fn image_input( painter.rect_stroke(visible, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); handles(preview); } - if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) { + if resp.drag_stopped() || !ptr.primary_down { if let Some(s) = view.image_selection.as_mut() { s.drag = None; let norm = |r: Rect| { @@ -1194,6 +1212,7 @@ fn open_editor( max_width, size: (l.size as f32 * scale).clamp(8.0, 72.0), focus: true, + had_focus: false, look: look_of(l), look0: look_of(l), family_chosen: false, @@ -1209,6 +1228,7 @@ pub(crate) fn page_input( ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, + ptr: &PagePointer, page: usize, doc: &pdfcraft_engine::Document, lines: &[pdfcraft_engine::TextBlock], @@ -1256,7 +1276,7 @@ pub(crate) fn page_input( if let Some(d) = view.block_drag.filter(|d| d.page == page) && let (Some(b), Some(l)) = (boxes.get(d.block).copied(), lines.get(d.block)) { - let p = ui.input(|i| i.pointer.interact_pos()).unwrap_or(d.start); + let p = ptr.pos.unwrap_or(d.start); let dx = p.x - d.start.x; let preview = match d.grip { Grip::Move => b.translate(p - d.start), @@ -1269,7 +1289,7 @@ pub(crate) fn page_input( draw_handles(preview); } ui.ctx().set_cursor_icon(if resizing { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::Move }); - if resp.drag_stopped() || !ui.input(|i| i.pointer.any_down()) { + if resp.drag_stopped() || !ptr.any_down { view.block_drag = None; let (from, to) = (user_box(xf, info, page, b), user_box(xf, info, page, preview)); let edit = if resizing { @@ -1301,13 +1321,13 @@ pub(crate) fn page_input( // quick flick has left the edge by then. if resp.drag_started() && active.is_none() - && let Some(o) = ui.input(|i| i.pointer.press_origin()) + && let Some(o) = ptr.origin && let Some((block, grip)) = grab_at(&boxes, o, upright) { view.block_drag = Some(BlockDrag { page, block, generation: doc.edit_generation(), start: o, grip }); return true; } - let Some(p) = ui.input(|i| i.pointer.hover_pos()).filter(|p| xf.rect.contains(*p)) else { return false }; + let Some(p) = ptr.within(xf.rect) else { return false }; let Some((hit, grip)) = grab_at(&boxes, p, upright) else { return false }; if active != Some(hit) { painter.rect_stroke(boxes[hit], CornerRadius::same(2), Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); @@ -1553,8 +1573,9 @@ fn editor_galley(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32, width: /// The inline editor; returns the edit once the text is applied. pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -> Option { - // Clicks outside the document (the Format text panel) keep the paragraph open. - let outside = ctx.input(|i| i.pointer.latest_pos()).is_some_and(|p| !view.viewport_rect().contains(p)); + // Clicks outside the document (the Format text panel) keep the paragraph open. Where the + // click landed is `interact_pos`: `latest_pos` is gone on the frame a tap lifts. + let outside = ctx.input(|i| i.pointer.interact_pos()).is_some_and(|p| !view.viewport_rect().contains(p)); let page = view.line_editor.as_ref()?.page; let xf = view.page_xform(page)?; let hold = view.objects.hold_editor; @@ -1607,7 +1628,7 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - // Over the page, so only where the document shows: a paragraph scrolled under the mode bar or // a side panel must leave them their pointer (#2755). It stays at its paragraph, cut off as // the page is, rather than pushed back into the window. - egui::Area::new(egui::Id::new("edit-text-line")) + egui::Area::new(egui::Id::unique("edit-text-line")) .order(egui::Order::Foreground) .fade_in(false) .fixed_pos(Pos2::new(ed.rect.left(), ed.rect.top())) @@ -1669,7 +1690,7 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - if snapshot.changes_with(text.as_str()) { galley } else { unseen(galley) } }; let output = egui::TextEdit::multiline(&mut ed.text) - .id(egui::Id::new("edit-text-line-input")) + .id(egui::Id::unique("edit-text-line-input")) .font(font.clone()) .margin(egui::Margin::ZERO) .layouter(&mut layouter) @@ -1678,6 +1699,8 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - .desired_width(width) .desired_rows(rows) .show(ui); + // The editor paints over the page with no visible label; name it for assistive tech. + output.response.response.clone().accessible_name(tl!("Text editing line")); if ed.has_changes() { if background_ready { reveal = true; @@ -1700,11 +1723,12 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - r.request_focus(); ed.focus = false; } + let lost = lost_focus_once(&r, &mut ed.had_focus); let esc = ui.input(|i| i.key_pressed(egui::Key::Escape)); let apply = ui.input(|i| i.key_pressed(egui::Key::Enter) && i.modifiers.command); if esc { done = Some(false); - } else if apply || (r.lost_focus() && !outside && !hold) { + } else if apply || (lost && !outside && !hold) { done = Some(true); } }); @@ -1739,6 +1763,16 @@ pub(crate) fn finished_edit(ed: LineEditor) -> Option { }) } +/// Whether an editor's text box `r` lost the keyboard this frame, given whether it `had` it at +/// the end of the editor's last frame (updated here). egui's `lost_focus` holds for a second +/// frame as well, and an editor that judges a click away by where the click landed must judge it +/// once, on the click's frame: on the next, a tap's position is gone (egui-winit sends +/// `PointerGone` with every touch release) and a mouse may have moved on. +pub(crate) fn lost_focus_once(r: &egui::Response, had: &mut bool) -> bool { + let has = r.has_focus(); + std::mem::replace(had, has) && !has +} + /// Caret colour that stays visible on a field filled with `fill`: black on light fills, white on dark ones. pub(crate) fn caret_color_on(fill: Color32) -> Color32 { let luma = 299 * u32::from(fill.r()) + 587 * u32::from(fill.g()) + 114 * u32::from(fill.b()); @@ -1796,6 +1830,7 @@ mod tests { max_width: 400.0, size: block.size as f32, focus: true, + had_focus: false, look: look_of(&block), look0: look_of(&block), family_chosen: false, diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index b906b83170..f073a3a38d 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -38,6 +38,11 @@ pub enum SaveTarget { As, } +/// Text typed into a new text box that isn't added to the page yet. +fn new_text_typed(v: &crate::DocView) -> bool { + v.content.draft.as_ref().is_some_and(|d| d.index.is_none() && !d.text.trim().is_empty()) +} + impl PdfCraftApp { /// Apply an edit to the active document. Returns `true` on success; failures are shown. pub fn apply_edit(&mut self, edit: Edit) -> bool { @@ -155,7 +160,7 @@ impl PdfCraftApp { } Edit::AddAnnotation(a) => { // Select the new comment (appended last among the page's comments). - let newest = info.annotations.iter().filter(|x| x.page == a.page && x.in_reply_to.is_none()).map(|x| x.index).max(); + let newest = info.annotations.iter().filter(|x| x.page == a.page && !x.is_reply()).map(|x| x.index).max(); view.comments.selected = newest.map(|n| (a.page, n)); view.comments.reveal = true; // A new highlight opens its note for typing straight away, as in Acrobat, @@ -508,7 +513,7 @@ impl PdfCraftApp { let newest = self .session .get(view.id) - .and_then(|d| d.info.annotations.iter().filter(|a| a.page == page && a.in_reply_to.is_none()).map(|a| a.index).max()); + .and_then(|d| d.info.annotations.iter().filter(|a| a.page == page && !a.is_reply()).map(|a| a.index).max()); view.comments.selected = newest.map(|index| (page, index)); view.comments.reveal = true; self.quick_tool = crate::QuickTool::Select; @@ -640,6 +645,7 @@ impl PdfCraftApp { || v.content.blocked().is_some() || v.forms.focus.as_ref().is_some_and(|f| crate::forms_ui::draft_edit(f, &doc.form).is_some()) || v.fill_text.as_ref().is_some_and(|t| !t.text.trim().is_empty()) + || new_text_typed(v) || v.line_editor.as_ref().map_or_else(|| false, |ed| ed.has_unsaved_text()) } @@ -1613,6 +1619,7 @@ mod revert_draft_tests { calendar: None, calendar_rect: None, calendar_text: None, + had_focus: false, } } @@ -1698,6 +1705,56 @@ mod revert_draft_tests { assert!(app.unsaved_flag.load(Ordering::Relaxed)); } + /// Text typed into a new text box is lost on close unless the save prompt appears; an empty + /// box has nothing to lose. + #[test] + fn text_typed_into_a_new_text_box_counts_as_unsaved_work() { + use crate::content_ui::{TextDraft, default_style}; + let mut app = app(); + let draft = |text: &str| TextDraft { page: 0, index: None, rect: [0.0; 4], text: text.into(), style: default_style(), focus: true }; + assert!(!app.has_unsaved_work(0)); + app.views[0].content.draft = Some(draft(" \n")); + assert!(!app.has_unsaved_work(0)); + app.views[0].content.draft = Some(draft("Typed")); + assert!(!app.session.get(app.views[0].id).unwrap().dirty); + assert!(app.has_unsaved_work(0)); + } + + /// Close ▸ Save, and Quit ▸ Save, write the text typed into a new text box before the tab + /// closes. + #[test] + fn saving_on_close_keeps_text_typed_into_a_new_text_box() { + use crate::content_ui::{TextDraft, default_style}; + use pdfcraft_engine::AddedContent; + for quit in [false, true] { + let dir = std::env::temp_dir().join(format!("pdfcraft-ui-new-text-{}-{quit}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("source.pdf").to_string_lossy().into_owned(); + let bytes = include_bytes!("../tests/data/form.pdf").to_vec(); + std::fs::write(&path, &bytes).unwrap(); + let mut app = PdfCraftApp::new(); + app.open_bytes("source.pdf", Some(path.clone()), bytes).unwrap(); + let rect = [72.0, 72.0, 272.0, 92.0]; + app.views[0].content.draft = Some(TextDraft { page: 0, index: None, rect, text: "Typed".into(), style: default_style(), focus: true }); + let ctx = egui::Context::default(); + if quit { + assert!(!app.request_quit(&ctx)); + } else { + app.request_close_tab(0); + } + assert!(app.close_request.is_some()); + app.resolve_close(&ctx, Some(true)); + assert!(app.views.is_empty(), "the tab closes once saved"); + + let mut reopened = PdfCraftApp::new(); + reopened.open_bytes("source.pdf", Some(path.clone()), std::fs::read(&path).unwrap()).unwrap(); + let doc = reopened.session.get(reopened.views[0].id).unwrap(); + assert!(doc.added().iter().any(|a| matches!(&a.content, AddedContent::Text(t) if t.text == "Typed")), "quit: {quit}"); + let _ = std::fs::remove_dir_all(&dir); + } + } + #[test] fn successful_revert_clears_focused_or_queued_drafts_only_on_its_document() { for queued in [false, true] { diff --git a/crates/ui-egui/src/field_table_ui.rs b/crates/ui-egui/src/field_table_ui.rs index d9b5255bfd..6dacf67a65 100644 --- a/crates/ui-egui/src/field_table_ui.rs +++ b/crates/ui-egui/src/field_table_ui.rs @@ -159,7 +159,7 @@ pub(crate) fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { let mut choice = None; let mut laid = None; egui::Window::new(tl!("Text Field Table")) - .id(egui::Id::new("field_table_window")) + .id(egui::Id::unique("field_table_window")) .open(&mut open) .collapsible(false) .resizable(false) @@ -255,15 +255,20 @@ fn body(ui: &mut egui::Ui, d: &mut FieldTableDraft, info: &PageInfo, form: &[For for (c, column) in d.table.columns.iter_mut().enumerate() { let width = layout.cells.as_ref().ok().and_then(|cells| cells.get(c)).map(|cell| cell.rect[2] - cell.rect[0]); let share = shares.get(c).copied().unwrap_or_default(); + let mut column_label_response = None; ui.horizontal(|ui| { - ui.label(RichText::new(column_label(c)).strong()); + let label = ui.label(RichText::new(column_label(c)).strong()); + column_label_response = Some(label.id); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { let size = width.map_or_else(|| "—".to_owned(), |w| format!("{w:.1} pt · {:.0}%", share * 100.0)); ui.label(RichText::new(size).color(t.text_muted).size(11.0)); }); }); let full = ui.available_width(); - ui.add(widgets::line(&mut column.pattern).desired_width(full).id_salt(("field table column", c))); + let pattern = ui.add(widgets::line(&mut column.pattern).desired_width(full).id_salt(("field table column", c))); + if let Some(label) = column_label_response { + pattern.labelled_by(label); + } ui.add_space(4.0); } }; @@ -375,7 +380,7 @@ fn column_dividers(ui: &mut egui::Ui, d: &mut FieldTableDraft, bar: Rect, ends: let mut active = None; for (i, &x) in ends.iter().enumerate().take(n.saturating_sub(1)) { let hit = Rect::from_min_max(pos2(x - reach, bar.top()), pos2(x + reach, bar.bottom())); - let resp = ui.interact(hit, ui.id().with(("field table divider", i)), Sense::click_and_drag()); + let resp = ui.interact(hit, ui.scope_id().with(("field table divider", i)), Sense::click_and_drag()); let share = d.shares().get(i).copied().unwrap_or_default(); resp.widget_info(|| egui::WidgetInfo::slider(true, (share * 100.0).round(), column_label(i))); let mut delta = f64::from(resp.drag_delta().x) / f64::from(bar.width()); @@ -469,7 +474,7 @@ mod tests { |ui| { let other = ui.button("Other control"); if let Some(i) = focus { - ui.memory_mut(|memory| memory.request_focus(ui.id().with(("field table divider", i)))); + ui.memory_mut(|memory| memory.request_focus(ui.scope_id().with(("field table divider", i)))); } else { other.request_focus(); } diff --git a/crates/ui-egui/src/fill_sign.rs b/crates/ui-egui/src/fill_sign.rs index 1684c80bea..aab071b508 100644 --- a/crates/ui-egui/src/fill_sign.rs +++ b/crates/ui-egui/src/fill_sign.rs @@ -9,6 +9,7 @@ use pdfcraft_engine::{Edit, FillMark, NewAnnotation, PhotoRect, Shape, Signature use pdfcraft_render::{DocInfo, PageInfo}; use crate::canvas::{DocView, PageXform}; +use crate::page_pointer::PagePointer; use crate::theme::Tokens; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -554,7 +555,7 @@ pub(crate) fn signature_entries(ui: &mut egui::Ui, app: &mut crate::PdfCraftApp, ui.horizontal(|ui| { let (rect, response) = ui.allocate_exact_size(vec2((ui.available_width() - 68.0).max(140.0), 52.0), Sense::click()); let use_label = label("Use {what}"); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, use_label.clone())); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, use_label.clone())); let painter = ui.painter_at(rect); painter.rect_filled(rect, CornerRadius::same(4), Color32::WHITE); painter.rect_stroke( @@ -626,6 +627,7 @@ pub(crate) fn page_input( ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, + ptr: &PagePointer, page: usize, info: &DocInfo, tool: FillTool, @@ -637,10 +639,7 @@ pub(crate) fn page_input( author: &str, date_text: &Result, ) -> Option { - let pointer = ui.input(|i| i.pointer.hover_pos())?; - if !resp.contains_pointer() || !xf.rect.contains(pointer) { - return None; - } + let pointer = ptr.within(xf.rect)?; let p = info.pages.get(page)?; let at = to_user(xf, info, page, pointer); let saved = match tool { @@ -719,7 +718,7 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, // Over the page, so only where the document shows: a box scrolled under the mode bar or a // side panel must leave them their pointer (#2755). It stays where it was placed, cut off as // the page is, rather than pushed back into the window. - egui::Area::new(egui::Id::new(("fill-text", view.id.0))) + egui::Area::new(egui::Id::unique(("fill-text", view.id.0))) .fade_in(false) .order(egui::Order::Foreground) .fixed_pos(pos) @@ -733,15 +732,17 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, // The area's max rect is last frame's size, which caps the edit's width; widen it so the // box grows as text is typed. ui.set_min_width(width); - let r = ui.add( - egui::TextEdit::singleline(&mut t.text) - .font(egui::FontId::proportional((t.size as f32 * zoom).max(8.0))) - .desired_width(width) - .background_color(Color32::from_rgba_unmultiplied(255, 255, 255, 230)) - .text_color(Color32::BLACK) - .hint_text(tl!("Type text")) - .id_salt("fill-text-edit"), - ); + let r = ui + .add( + egui::TextEdit::singleline(&mut t.text) + .font(egui::FontId::proportional((t.size as f32 * zoom).max(8.0))) + .desired_width(width) + .background_color(Color32::from_rgba_unmultiplied(255, 255, 255, 230)) + .text_color(Color32::BLACK) + .hint_text(tl!("Type text")) + .id_salt("fill-text-edit"), + ) + .accessible_name(tl!("Text to place")); if t.focus { r.request_focus(); t.focus = false; @@ -912,10 +913,10 @@ fn scan_tab( } let Some(scan) = d.scan.as_mut() else { painter.text(area.center(), egui::Align2::CENTER_CENTER, tl!("No photo chosen"), egui::FontId::proportional(13.0), t.text_muted); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, tl!("Photo"))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Image, true, tl!("Photo"))); return browse; }; - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, tl!("Photo"))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Image, true, tl!("Photo"))); // The photo, fitted into the area; `to_screen`/`to_photo` map between its pixels and points. let [pw, ph] = scan.photo.size(); let (pw, ph) = (pw.max(1) as f32, ph.max(1) as f32); diff --git a/crates/ui-egui/src/fling.rs b/crates/ui-egui/src/fling.rs index cce004ce53..b5eafe69d1 100644 --- a/crates/ui-egui/src/fling.rs +++ b/crates/ui-egui/src/fling.rs @@ -64,7 +64,7 @@ impl Fling { /// (animations off, single-page paging, a zoom, a dialog). pub(crate) fn read(&mut self, events: &[Event], now: f64, glide: bool) { for event in events { - let Event::MouseWheel { unit, delta, phase, modifiers } = event else { continue }; + let Event::MouseWheel { unit, delta, phase, modifiers, .. } = event else { continue }; self.stop(); // Zooming, a mouse wheel and sideways Shift scrolling never glide. if *unit != MouseWheelUnit::Point || modifiers.command || modifiers.ctrl || modifiers.shift { @@ -160,7 +160,13 @@ mod tests { use egui::{Modifiers, vec2}; fn wheel(dy: f32, phase: TouchPhase) -> Event { - Event::MouseWheel { unit: MouseWheelUnit::Point, delta: vec2(0.0, dy), phase, modifiers: Modifiers::NONE } + Event::MouseWheel { + unit: MouseWheelUnit::Point, + delta: vec2(0.0, dy), + phase, + modifiers: Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + } } /// A swipe of `moves` passes of `dy` each, 60 per second, lifted the pass after the last. @@ -213,6 +219,7 @@ mod tests { assert!(!fling.gliding(), "the finger rested before it lifted"); } + /// Check that new finger or wheel input stops an in-progress glide. #[test] fn new_input_stops_a_glide() { let mut fling = Fling::new(true); @@ -222,11 +229,18 @@ mod tests { let mut fling = Fling::new(true); let now = swipe(&mut fling, -25.0, 6); - let notch = Event::MouseWheel { unit: MouseWheelUnit::Line, delta: vec2(0.0, -1.0), phase: TouchPhase::Move, modifiers: Modifiers::NONE }; + let notch = Event::MouseWheel { + unit: MouseWheelUnit::Line, + delta: vec2(0.0, -1.0), + phase: TouchPhase::Move, + modifiers: Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + }; fling.read(&[notch], now + 0.1, true); assert!(!fling.gliding(), "a wheel notch"); } + /// Check that disabled flinging, a false glide flag, and a command-modified zoom gesture prevent glide. #[test] fn no_glide_where_it_is_off_or_not_wanted() { let mut off = Fling::new(false); @@ -242,7 +256,13 @@ mod tests { assert!(!fling.gliding(), "animations off, paging or a dialog"); let mut zoom = Fling::new(true); - let ctrl = |phase| Event::MouseWheel { unit: MouseWheelUnit::Point, delta: vec2(0.0, -25.0), phase, modifiers: Modifiers::COMMAND }; + let ctrl = |phase| Event::MouseWheel { + unit: MouseWheelUnit::Point, + delta: vec2(0.0, -25.0), + phase, + modifiers: Modifiers::COMMAND, + source: egui::MouseWheelSource::Unknown, + }; zoom.read(&[ctrl(TouchPhase::Start)], 0.0, true); zoom.read(&[ctrl(TouchPhase::Move)], dt, true); zoom.read(&[ctrl(TouchPhase::Move)], 2.0 * dt, true); diff --git a/crates/ui-egui/src/folders_ui.rs b/crates/ui-egui/src/folders_ui.rs index 727417e2fc..fe9b2c3706 100644 --- a/crates/ui-egui/src/folders_ui.rs +++ b/crates/ui-egui/src/folders_ui.rs @@ -314,20 +314,21 @@ pub(crate) fn section(app: &mut PdfCraftApp, ui: &mut egui::Ui) { ui.horizontal(|ui| { let (icon, _) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::hover()); icons::paint(ui, icon, "folder", 20.0, t.icon); - ui.label(egui::RichText::new(crate::bidi::visual(&name)).font(theme::semibold(14.0))).on_hover_text(folder); + // Folder names and paths are file/path chrome: explicitly left to right, so an + // Arabic location keeps its `.pdf`/extension at the visual end. + widgets::label_ltr(ui, name.clone(), theme::semibold(14.0), t.text).on_hover_text(folder); ui.add_space(4.0); // Leave room for Unpin: a long location is cut short, never the button. let room = (ui.available_width() - 110.0).max(0.0); ui.scope(|ui| { ui.set_max_width(room); - let place = egui::RichText::new(crate::bidi::visual(&location(folder))).font(theme::regular(12.0)).color(t.text_faint); - ui.add(egui::Label::new(place).truncate()).on_hover_text(folder); + widgets::label_ltr(ui, location(folder), theme::regular(12.0), t.text_faint).on_hover_text(folder); }); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let resp = widgets::ghost_button(ui, "x", tl!("Unpin")); // Every folder has an Unpin button: say which one it is. let label = crate::i18n::fmt(tl!("Unpin {folder}"), &[("folder", &name)]); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &label)); if resp.clicked() { unpin = Some(folder.clone()); } @@ -353,7 +354,7 @@ pub(crate) fn section(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let shown = if expanded { listing.files.len() } else { listing.files.len().min(SHOWN) }; for f in listing.files.iter().take(shown) { let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 38.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &f.name)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &f.name)); if resp.hovered() { ui.painter().rect_filled(rect, CornerRadius::same(8), t.hover); } @@ -369,7 +370,7 @@ pub(crate) fn section(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let detail = if when.is_empty() { size } else { format!("{when} · {size}") }; let detail = ui.painter().text(rect.right_center() - vec2(12.0, 0.0), Align2::RIGHT_CENTER, detail, theme::regular(12.0), t.text_muted); let width = detail.left() - 16.0 - (rect.left() + 62.0); - widgets::row_text(ui, rect.min + vec2(62.0, 19.0), crate::bidi::visual(&f.name), theme::medium(13.0), t.text, width); + widgets::row_text(ui, rect.min + vec2(62.0, 19.0), f.name.as_str(), theme::medium(13.0), t.text, width); if resp.on_hover_text(&f.path).clicked() { open = Some(f.path.clone()); } diff --git a/crates/ui-egui/src/format_text_ui.rs b/crates/ui-egui/src/format_text_ui.rs index 9849920ac6..69a49704a2 100644 --- a/crates/ui-egui/src/format_text_ui.rs +++ b/crates/ui-egui/src/format_text_ui.rs @@ -280,7 +280,7 @@ fn flyout(ui: &egui::Ui, t: &Tokens, resp: egui::Response) -> egui::Response { /// labels. fn labelled_icon(ui: &mut egui::Ui, t: &Tokens, icon: &str, label: &str) -> egui::Response { let (rect, resp) = ui.allocate_exact_size(vec2(LABEL_ICON, 26.0), Sense::hover()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, ui.is_enabled(), label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, ui.is_enabled(), label)); let tint = if ui.is_enabled() { t.icon } else { t.text_faint }; icons::paint(ui, Rect::from_center_size(rect.center(), vec2(18.0, 18.0)), icon, 17.0, tint); resp.on_hover_text(label) @@ -317,7 +317,7 @@ fn number_with_presets(ui: &mut egui::Ui, n: Number<'_>, presets: &[f64], width: /// The text colour as a square swatch that opens the palette. fn colour_button(ui: &mut egui::Ui, t: &Tokens, color: [f64; 3]) -> egui::Response { let (rect, resp) = ui.allocate_exact_size(vec2(28.0, 28.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Colour"))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Colour"))); let [r, g, b] = color.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8); let fill = rect.shrink(4.0); ui.painter().rect_filled(fill, CornerRadius::same(2), Color32::from_rgb(r, g, b)); diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 0d81e547ca..28494160c5 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -9,11 +9,12 @@ use std::collections::HashSet; -use egui::{Color32, CornerRadius, Rect, Stroke, vec2}; +use egui::{Color32, CornerRadius, Pos2, Rect, Stroke, vec2}; use pdfcraft_engine::{Edit, FieldValue, FormField, FormFieldKind, FormWidget, field_flags, form_appearance}; use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; +use crate::page_pointer::PagePointer; use crate::theme::Tokens; const FOCUS_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); @@ -42,6 +43,9 @@ pub struct Focus { /// The text the calendar last saw, so it moves to a typed date once, when the typing becomes /// a whole date, and otherwise keeps the month its arrows turned to. pub calendar_text: Option, + /// Whether the text box had the keyboard at the end of the editor's last frame + /// ([`crate::edit_text_ui::lost_focus_once`]). + pub(crate) had_focus: bool, } #[derive(Clone, Debug, Default)] @@ -120,6 +124,7 @@ fn open_focus(f: &FormField, widget: usize) -> Focus { calendar: None, calendar_rect: None, calendar_text: None, + had_focus: false, } } @@ -165,14 +170,14 @@ pub(crate) fn page_input( ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, + ptr: &PagePointer, page: usize, info: &DocInfo, form: &[FormField], allowed: bool, view: &mut DocView, ) -> FieldPointer { - let pointer = ui.input(|i| i.pointer.hover_pos()); - let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return FieldPointer::Away }; + let Some(p) = ptr.within(xf.rect) else { return FieldPointer::Away }; let hit = form.iter().find_map(|f| { f.widgets .iter() @@ -202,7 +207,7 @@ pub(crate) fn page_input( } // A text field opens on the press rather than the click, so a drag that starts on it // selects its text (#2213). - let pressed = f.kind == FormFieldKind::Text && usable && resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed()); + let pressed = f.kind == FormFieldKind::Text && usable && resp.is_pointer_button_down_on() && ptr.pressed().is_some(); if pressed && view.forms.focus.as_ref().is_none_or(|x| x.name != f.name || x.widget != wi) { commit(view, form); view.forms.focus = Some(Focus { press: Some(p), ..open_focus(f, wi) }); @@ -234,7 +239,7 @@ pub(crate) fn page_input( // The press already committed the previous field and opened this one (above), unless // press and release came in one frame. Committing again would re-run a validation that // refused the value on the press. - FormFieldKind::Text if !ui.input(|i| i.pointer.primary_pressed()) => { + FormFieldKind::Text if ptr.pressed().is_none() => { // An editor opened by the press cannot be hit until a frame has laid it out: the // release lands on the page and would take its focus away. if let Some(x) = view.forms.focus.as_mut().filter(|x| x.name == f.name && x.widget == wi) { @@ -317,7 +322,7 @@ pub(crate) fn commit(view: &mut DocView, form: &[FormField]) { #[allow(clippy::too_many_arguments)] fn combo_editor(ctx: &egui::Context, view: &mut DocView, form: &[FormField], f: &FormField, w: &FormWidget, rect: Rect, zoom: f32, widget: usize) { let mut enter = false; - egui::Area::new(egui::Id::new(("form-editor", view.id.0))) + egui::Area::new(egui::Id::unique(("form-editor", view.id.0))) .fade_in(false) .order(egui::Order::Foreground) .fixed_pos(rect.min) @@ -335,8 +340,9 @@ fn combo_editor(ctx: &egui::Context, view: &mut DocView, form: &[FormField], f: .background_color(FIELD_FILL) .text_color(Color32::BLACK) .margin(egui::Margin::symmetric(3, 1)) - .id(egui::Id::new(("form-field", view.id.0, &f.name, widget))); - let r = ui.add_sized(rect.size(), te); + .id(egui::Id::unique(("form-field", view.id.0, &f.name, widget))); + // The form field's own logical name; not a visual/preordered label. + let r = ui.add_sized(rect.size(), te).accessible_name(f.name.clone()); if std::mem::take(&mut fx.request_focus) { r.request_focus(); // Entered with Tab: typing replaces the value. @@ -376,7 +382,7 @@ fn calendar(ctx: &egui::Context, view: &mut DocView, field: egui::Rect, fmt: &st fx.calendar_text = Some(fx.text.clone()); } let mut picked = None; - let area = egui::Area::new(egui::Id::new(("date-picker", view.id.0))) + let area = egui::Area::new(egui::Id::unique(("date-picker", view.id.0))) .order(egui::Order::Foreground) .fixed_pos(field.left_bottom() + vec2(0.0, 2.0)) .show(ctx, |ui| { @@ -475,9 +481,16 @@ fn calendar(ctx: &egui::Context, view: &mut DocView, field: egui::Rect, fmt: &st picked } -/// Paint focus and hover frames on a page. -pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, form: &[FormField], view: &DocView) { - let pointer = ui.input(|i| i.pointer.hover_pos()); +/// Paint focus and hover frames on a page, the pages' `pointer` over it. +pub(crate) fn paint_page( + painter: &egui::Painter, + xf: &PageXform, + page: usize, + info: &DocInfo, + form: &[FormField], + view: &DocView, + pointer: Option, +) { for f in form { for (wi, w) in f.widgets.iter().enumerate().filter(|(_, w)| w.page == Some(page) && !w.hidden) { let r = widget_rect(xf, info, page, w.rect); @@ -644,6 +657,10 @@ pub(crate) fn overlay( return None; }; let rect = widget_rect(&xf, info, page, w.rect); + // Whether a click this frame landed outside the field. Where it landed is `interact_pos`: a + // tap's release comes with `PointerGone` (egui-winit sends one with every touch release, and + // winit reports Windows pen input as touch), which clears `hover_pos`. + let clicked_outside = || ctx.input(|i| i.pointer.any_click() && !i.pointer.interact_pos().is_some_and(|p| rect.contains(p))); let zoom = xf.zoom(); let t = Tokens::get(ctx); let mut edit = None; @@ -656,7 +673,7 @@ pub(crate) fn overlay( // Over the page, so only where the document shows: a field scrolled under the mode bar // or a side panel must leave them their pointer (#2755). It stays on its field, cut off // as the page is, rather than pushed back into the window. - egui::Area::new(egui::Id::new(("form-editor", view.id.0))) + egui::Area::new(egui::Id::unique(("form-editor", view.id.0))) .fade_in(false) .order(egui::Order::Foreground) .fixed_pos(rect.min) @@ -681,7 +698,7 @@ pub(crate) fn overlay( .text_color(Color32::BLACK) .margin(egui::Margin::symmetric(3, 1)) .password(f.has(field_flags::PASSWORD)) - .id(egui::Id::new(("form-field", view.id.0, &f.name, focus.widget))); + .id(egui::Id::unique(("form-field", view.id.0, &f.name, focus.widget))); if let Some(max) = f.max_len { te = te.char_limit(max); } @@ -689,11 +706,14 @@ pub(crate) fn overlay( te = te.desired_rows(((rect.height() / (font * 1.3)).floor() as usize).max(1)); } let mut laid_out = None; - let r = ui.add_sized(rect.size(), |ui: &mut egui::Ui| { - let out = te.show(ui); - laid_out = Some((out.galley, out.galley_pos)); - out.response.response - }); + let r = ui + .add_sized(rect.size(), |ui: &mut egui::Ui| { + let out = te.show(ui); + laid_out = Some((out.galley, out.galley_pos)); + out.response.response + }) + // The form field's own logical name; not a visual/preordered label. + .accessible_name(f.name.clone()); if let (Some(tb), Some(before)) = (text_box, before) && fx.text != before { @@ -730,6 +750,7 @@ pub(crate) fn overlay( st.store(ui.ctx(), r.id); } } + let lost = crate::edit_text_ui::lost_focus_once(&r, &mut fx.had_focus); let (enter, esc, tab, shift) = ui.input(|i| { (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape), i.key_pressed(egui::Key::Tab), i.modifiers.shift) }); @@ -737,7 +758,7 @@ pub(crate) fn overlay( close = true; } else if tab { next = Some(!shift); - } else if r.lost_focus() + } else if lost && (!multiline || !enter) && !fx.calendar_rect.is_some_and(|c| ui.input(|i| i.pointer.interact_pos().is_some_and(|p| c.contains(p)))) { @@ -767,7 +788,7 @@ pub(crate) fn overlay( if editable_combo(f) { combo_editor(ctx, view, form, f, w, rect, zoom, focus.widget); } - let resp = egui::Area::new(egui::Id::new(("form-choices", view.id.0))) + let resp = egui::Area::new(egui::Id::unique(("form-choices", view.id.0))) .order(egui::Order::Foreground) .fixed_pos(rect.left_bottom() + vec2(0.0, 2.0)) .show(ctx, |ui| { @@ -785,9 +806,7 @@ pub(crate) fn overlay( }); }); }); - let clicked_away = ctx.input(|i| i.pointer.any_click()) - && !resp.response.contains_pointer() - && !ctx.input(|i| i.pointer.hover_pos()).is_some_and(|p| rect.contains(p)); + let clicked_away = !resp.response.contains_pointer() && clicked_outside(); let (tab, shift) = ctx.input(|i| (i.key_pressed(egui::Key::Tab), i.modifiers.shift)); if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { close = true; @@ -821,18 +840,17 @@ pub(crate) fn overlay( edit = Some(Edit::SetFieldValue { name: f.name.clone(), value: FieldValue::Choice(vec![export]) }); } } - let clicked_away = ctx.input(|i| i.pointer.any_click()) && !ctx.input(|i| i.pointer.hover_pos()).is_some_and(|p| rect.contains(p)); - if clicked_away { + if clicked_outside() { close = true; } } FormFieldKind::CheckBox | FormFieldKind::Radio => { // Nothing is drawn over the box, but a widget there holds egui's keyboard focus, so // Space, Tab and the arrows come here instead of turning or scrolling the page. - let id = egui::Id::new(("form-field", view.id.0, &f.name, focus.widget)); + let id = egui::Id::unique(("form-field", view.id.0, &f.name, focus.widget)); let mut has_focus = false; // Not interactable, so clicks still reach the page under it. - let area = egui::Area::new(egui::Id::new(("form-toggle", view.id.0))).order(egui::Order::Foreground).interactable(false); + let area = egui::Area::new(egui::Id::unique(("form-toggle", view.id.0))).order(egui::Order::Foreground).interactable(false); area.fixed_pos(rect.min).show(ctx, |ui| { let r = ui.interact(rect, id, egui::Sense::focusable_noninteractive()); if let Some(fx) = view.forms.focus.as_mut() @@ -859,8 +877,7 @@ pub(crate) fn overlay( None } }); - let clicked_away = ctx.input(|i| i.pointer.any_click() && !i.pointer.hover_pos().is_some_and(|p| rect.contains(p))); - if esc || clicked_away { + if esc || clicked_outside() { close = true; } else if tab { next = Some(!shift); diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 3e70f34e42..bfabf701e6 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -10,7 +10,7 @@ use crate::{PdfCraftApp, icons, panels::human_size, widgets}; const RECOMMENDED: [&str; 5] = ["organize", "comment", "form", "edit", "protect"]; /// A soft shadow that rises on hover and settles when the card is pressed. -fn card_lift(ui: &egui::Ui, t: &Tokens, rect: Rect, press: theme::Press) { +pub(crate) fn card_lift(ui: &egui::Ui, t: &Tokens, rect: Rect, press: theme::Press) { let lift = press.hover * (1.0 - press.down); if lift > 0.04 { let shadow = egui::Shadow { offset: [0, 3], blur: (14.0 * lift) as u8, spread: 0, color: t.page_shadow.gamma_multiply(0.85 * lift) }; @@ -18,6 +18,7 @@ fn card_lift(ui: &egui::Ui, t: &Tokens, rect: Rect, press: theme::Press) { } } +/// Draw the Home page with the welcome text, community card and recent documents. pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { @@ -29,26 +30,34 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { .font(theme::regular(14.0)), ); ui.add_space(14.0); - egui::Frame::NONE - .fill(t.card) - .stroke(Stroke::new(1.0, t.border)) - .corner_radius(CornerRadius::same(12)) - .inner_margin(egui::Margin::same(14)) - .show(ui, |ui| { - ui.set_width(ui.available_width()); - ui.horizontal(|ui| { - widgets::artcraft_mark(ui, 28.0); - ui.vertical(|ui| { - ui.label(egui::RichText::new(tl!("Join the ArtCraft community")).font(theme::semibold(15.0))); - ui.label(egui::RichText::new(tl!("Get help, share feedback and follow development on Discord.")).color(t.text_muted)); + if !app.community_card_closed { + egui::Frame::NONE + .fill(t.card) + .stroke(Stroke::new(1.0, t.border)) + .corner_radius(CornerRadius::same(12)) + .inner_margin(egui::Margin::same(14)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + widgets::artcraft_mark(ui, 28.0); + ui.vertical(|ui| { + ui.label(egui::RichText::new(tl!("Join the ArtCraft community")).font(theme::semibold(15.0))); + ui.label(egui::RichText::new(tl!("Get help, share feedback and follow development on Discord.")).color(t.text_muted)); + }); + // Closed for this run only: the card is back on the next launch. + ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + if icons::button(ui, "x", 24.0, false, tl!("Close")).clicked() { + app.community_card_closed = true; + } + }); }); + ui.add_space(8.0); + if let Some(cmd) = widgets::community_links(ui) { + app.execute(cmd); + } }); - ui.add_space(8.0); - if let Some(cmd) = widgets::community_links(ui) { - app.execute(cmd); - } - }); - ui.add_space(22.0); + ui.add_space(22.0); + } egui::Frame::NONE .fill(t.card) @@ -64,7 +73,7 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { for id in RECOMMENDED { let Some(g) = catalog::group(id) else { continue }; let (rect, resp) = ui.allocate_exact_size(vec2(190.0, 104.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!(g.label))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!(g.label))); let press = theme::Press::track(ui, &resp); card_lift(ui, &t, rect, press); let fill = press.fill(t.card, t.hover, t.pressed); @@ -102,7 +111,7 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { } } let (rect, resp) = ui.allocate_exact_size(vec2(170.0, 104.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Open file"))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Open file"))); let press = theme::Press::track(ui, &resp); card_lift(ui, &t, rect, press); ui.painter().rect( @@ -134,7 +143,7 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { if !app.recent.is_empty() { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let resp = widgets::ghost_button(ui, "trash-2", tl!("Clear")); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Clear Recent Files"))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Clear Recent Files"))); clear = resp.on_hover_text(tl!("Clear Recent Files")).clicked(); }); } @@ -147,7 +156,7 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let mut remove = None; for r in &app.recent { let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &r.name)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &r.name)); let press = theme::Press::track(ui, &resp); // The row, not its response: the pointer stays "in" the row over its remove button. let hovered = ui.rect_contains_pointer(rect); @@ -160,9 +169,9 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { if hovered { // Remove just this file from the list (#430); the file itself is untouched. let x_rect = Rect::from_center_size(rect.right_center() - vec2(26.0, 0.0), vec2(28.0, 28.0)); - let x = ui.interact(x_rect, ui.id().with(("remove-recent", &r.path)), Sense::click()); + let x = ui.interact(x_rect, ui.scope_id().with(("remove-recent", &r.path)), Sense::click()); let label = crate::i18n::fmt(tl!("Remove {name} from Recent"), &[("name", &r.name)]); - x.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &label)); + x.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &label)); if x.hovered() { ui.painter().rect_filled(x_rect, CornerRadius::same(6), t.pressed); } @@ -189,8 +198,8 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { ); // The name and path stop short of the page count; hovering shows the whole path. let width = detail.left() - 16.0 - (body.left() + 46.0); - widgets::row_text(ui, body.min + vec2(46.0, 15.0), crate::bidi::visual(&r.name), theme::medium(13.5), t.text, width); - widgets::row_text(ui, body.min + vec2(46.0, 32.0), crate::bidi::visual(&r.path), theme::regular(11.0), t.text_faint, width); + widgets::row_text(ui, body.min + vec2(46.0, 15.0), r.name.as_str(), theme::medium(13.5), t.text, width); + widgets::row_text(ui, body.min + vec2(46.0, 32.0), r.path.as_str(), theme::regular(11.0), t.text_faint, width); if theme::hand(resp).on_hover_text(&r.path).clicked() { open = Some(r.path.clone()); } diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 32a8f3f301..5eb698c55c 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -16,9 +16,9 @@ # Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) stay in Latin letters; # the trailing "…" of dialog commands is kept. Digits are Western (0-9). # -# Write every entry in logical (typing) order. The interface draws text left to right, so -# i18n/mod.rs puts each entry into display order when it loads the catalog (bidi::display_rtl); -# spaces and a colon at either end of an entry stay at that end, next to what the code appends. +# Write every entry in logical (typing) order. Catalogs stay logical: epaint shapes and +# reorders each line with UAX #9, so preordering here would reverse right-to-left text twice. +# Spaces and a colon at either end of an entry stay at that end, next to what the code appends. Menu القائمة File ملف @@ -184,6 +184,15 @@ Available متاح Ready جاهز Search tools and commands… البحث في الأدوات والأوامر… + +# Accessible names for inputs that have no visible label. + Added text النص المضاف + Link URL عنوان URL للرابط + Search tools and commands البحث في الأدوات والأوامر + Text editing line سطر تحرير النص + Text to place النص المراد وضعه + Tooltip تلميح + Words or phrases to redact الكلمات أو العبارات المراد حجبها No matching tools لا توجد أدوات مطابقة Tool أداة Command أمر @@ -2774,6 +2783,14 @@ certificate Version الإصدار Make properties default جعل الخصائص افتراضية Pages selected الصفحات المحددة Page properties خصائص الصفحة + Create & Edit الإنشاء والتحرير + Review & Compare المراجعة والمقارنة + Forms & Signatures النماذج والتوقيعات + Protect & Standardize الحماية والتوحيد القياسي + Customize & Automate التخصيص والأتمتة + No tools match your search. لا توجد أدوات تطابق بحثك. + Expand all tools توسيع كل الأدوات + Collapse all tools طي كل الأدوات Attach a file… إرفاق ملف… Delete attachment حذف المرفق Attaching files is not available on the web yet إرفاق الملفات غير متاح على الويب بعد @@ -2815,3 +2832,12 @@ certificate Version الإصدار # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today اليوم +@plural {n} reply|{n} replies {n} رد|{n} رد|{n} ردّان|{n} ردود|{n} ردًا|{n} رد + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools أدوات الصفحات + Insert pages from a file after the selection… إدراج صفحات من ملف بعد التحديد… + Rotate the selected pages counterclockwise تدوير الصفحات المحددة عكس اتجاه عقارب الساعة + Rotate the selected pages clockwise تدوير الصفحات المحددة باتجاه عقارب الساعة + Extract the selected pages… استخراج الصفحات المحددة… + Delete the selected pages حذف الصفحات المحددة diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 89a049bea1..72e8f5c3d1 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1840,6 +1840,15 @@ Tool Инструмент Command Команда Search tools and commands… Търсене на инструменти и команди… + +# Accessible names for inputs that have no visible label. + Added text Добавен текст + Link URL URL на връзката + Search tools and commands Търсене на инструменти и команди + Text editing line Ред за редактиране на текст + Text to place Текст за поставяне + Tooltip Подсказка + Words or phrases to redact Думи или фрази за заличаване No matching tools Няма съответстващи инструменти All tools Всички инструменти Read Четене @@ -2460,6 +2469,14 @@ animation effects Off Изключени Make properties default Задаване като свойства по подразбиране Pages selected Избрани страници Page properties Свойства на страницата + Create & Edit Създаване и редактиране + Review & Compare Преглед и сравнение + Forms & Signatures Формуляри и подписи + Protect & Standardize Защита и стандартизация + Customize & Automate Персонализиране и автоматизация + No tools match your search. Няма инструменти, отговарящи на търсенето. + Expand all tools Разгъване на всички инструменти + Collapse all tools Свиване на всички инструменти Attach a file… Прикачване на файл… Delete attachment Изтриване на прикачения файл Attaching files is not available on the web yet Прикачването на файлове още не е налично в уеб версията @@ -2502,3 +2519,12 @@ animation effects Off Изключени # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Днес +@plural {n} reply|{n} replies {n} отговор|{n} отговора + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Инструменти за страници + Insert pages from a file after the selection… Вмъкване на страници от файл след избраното… + Rotate the selected pages counterclockwise Завъртане на избраните страници обратно на часовниковата стрелка + Rotate the selected pages clockwise Завъртане на избраните страници по часовниковата стрелка + Extract the selected pages… Извличане на избраните страници… + Delete the selected pages Изтриване на избраните страници diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 18a058e54a..74e14a47a6 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -178,6 +178,15 @@ Available Disponible Ready Llest Search tools and commands… Cerca eines i ordres… + +# Accessible names for inputs that have no visible label. + Added text Text afegit + Link URL URL de l'enllaç + Search tools and commands Cerca eines i ordres + Text editing line Línia d'edició de text + Text to place Text que cal col·locar + Tooltip Suggeriment + Words or phrases to redact Paraules o frases per censurar No matching tools Cap eina coincident Tool Eina Command Ordre @@ -2662,6 +2671,14 @@ certificate SHA-256 digest Resum SHA-256 Optimizing… merging image fragments S'està optimitzant… fusionant fragments d'imatge Outline colour: Color del contorn: Page properties Propietats de la pàgina + Create & Edit Crea i edita + Review & Compare Revisa i compara + Forms & Signatures Formularis i signatures + Protect & Standardize Protegeix i estandarditza + Customize & Automate Personalitza i automatitza + No tools match your search. Cap eina coincideix amb la cerca. + Expand all tools Desplega totes les eines + Collapse all tools Replega totes les eines Pages counted Pàgines comptades Pages selected Pàgines seleccionades Print as image Imprimeix com a imatge @@ -2800,3 +2817,12 @@ theme Appearance Aspecte Attaching files is not available on the web yet Encara no es poden adjuntar fitxers al web Delete attachment Elimina l'adjunt Delete attachment {key} Elimina l'adjunt {key} +@plural {n} reply|{n} replies {n} resposta|{n} respostes + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Eines de pàgina + Insert pages from a file after the selection… Insereix pàgines d'un fitxer després de la selecció… + Rotate the selected pages counterclockwise Gira les pàgines seleccionades en sentit antihorari + Rotate the selected pages clockwise Gira les pàgines seleccionades en sentit horari + Extract the selected pages… Extreu les pàgines seleccionades… + Delete the selected pages Suprimeix les pàgines seleccionades diff --git a/crates/ui-egui/src/i18n/catalog.rs b/crates/ui-egui/src/i18n/catalog.rs index 864cdb434f..7bf4296662 100644 --- a/crates/ui-egui/src/i18n/catalog.rs +++ b/crates/ui-egui/src/i18n/catalog.rs @@ -155,15 +155,6 @@ impl Catalog { (c, errors) } - /// Rewrite every translation (each plural form on its own), e.g. into display order for a - /// right-to-left language. Keys are untouched. - pub fn map_translations(&mut self, f: impl Fn(&str) -> String) { - let tables = std::iter::once(&mut self.plain).chain(self.contextual.values_mut()).chain(std::iter::once(&mut self.ids)); - for translation in tables.flat_map(HashMap::values_mut).chain(self.plurals.values_mut().flatten()) { - *translation = f(translation); - } - } - pub fn plain(&self, s: &str) -> Option<&str> { self.plain.get(s).map(String::as_str) } diff --git a/crates/ui-egui/src/i18n/ckb.tsv b/crates/ui-egui/src/i18n/ckb.tsv index 582a0474d6..4bf4188b27 100644 --- a/crates/ui-egui/src/i18n/ckb.tsv +++ b/crates/ui-egui/src/i18n/ckb.tsv @@ -16,9 +16,9 @@ # Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) stay in Latin letters; # the trailing "…" of dialog commands is kept. Digits are Western (0-9). # -# Write every entry in logical (typing) order. The interface draws text left to right, so -# i18n/mod.rs puts each entry into display order when it loads the catalog (bidi::display_rtl); -# spaces and a colon at either end of an entry stay at that end, next to what the code appends. +# Write every entry in logical (typing) order. Catalogs stay logical: epaint shapes and +# reorders each line with UAX #9, so preordering here would reverse right-to-left text twice. +# Spaces and a colon at either end of an entry stay at that end, next to what the code appends. Menu پێڕست File فایل @@ -184,6 +184,15 @@ Available بەردەست Ready ئامادە Search tools and commands… گەڕان لە ئامرازەکان و فەرمانەکان… + +# Accessible names for inputs that have no visible label. + Added text دەقی زیادکراو + Link URL ناونیشانی URLی بەستەر + Search tools and commands گەڕان لە ئامرازەکان و فەرمانەکان + Text editing line هێڵی دەستکاریکردنی دەق + Text to place دەقی دانان + Tooltip ڕێنمایی + Words or phrases to redact وشە یان دەستەواژەکان بۆ شاردنەوە No matching tools هیچ ئامرازێکی گونجاو نییە Tool ئامراز Command فەرمان @@ -2728,6 +2737,23 @@ certificate Version وەشان Make properties default کردنی تایبەتمەندییەکان بە بنەڕەتی Pages selected پەڕە هەڵبژێردراوەکان Page properties تایبەتمەندییەکانی پەڕە + Create & Edit دروستکردن و دەستکاریکردن + Review & Compare پێداچوونەوە و بەراوردکردن + Forms & Signatures فۆڕمەکان و واژووەکان + Protect & Standardize پاراستن و ستانداردکردن + Customize & Automate تایبەتکردن و خۆکارکردن + No tools match your search. هیچ ئامرازێک لەگەڵ گەڕانەکەت ناگونجێت. + Expand all tools فراوانکردنی هەموو ئامرازەکان + Collapse all tools کۆکردنەوەی هەموو ئامرازەکان # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today ئەمڕۆ +@plural {n} reply|{n} replies {n} وەڵام|{n} وەڵام + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools ئامرازەکانی پەڕە + Insert pages from a file after the selection… پەڕەکان لە فایلێکەوە لە دوای هەڵبژاردەکە دابنێ… + Rotate the selected pages counterclockwise پەڕە هەڵبژێردراوەکان پێچەوانەی میلی کاتژمێر بسوڕێنە + Rotate the selected pages clockwise پەڕە هەڵبژێردراوەکان بە ئاڕاستەی میلی کاتژمێر بسوڕێنە + Extract the selected pages… پەڕە هەڵبژێردراوەکان دەربهێنە… + Delete the selected pages پەڕە هەڵبژێردراوەکان بسڕەوە diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 7e62bed5af..1b99d8670a 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -305,6 +305,15 @@ key Space Mezerník Available Dostupné Ready Připraveno Search tools and commands… Hledat nástroje a příkazy… + +# Accessible names for inputs that have no visible label. + Added text Přidaný text + Link URL Adresa URL odkazu + Search tools and commands Hledat nástroje a příkazy + Text editing line Řádek pro úpravu textu + Text to place Text k umístění + Tooltip Popisek + Words or phrases to redact Slova nebo fráze k začernění No matching tools Žádné odpovídající nástroje Tool Nástroj Command Příkaz @@ -2571,6 +2580,14 @@ animation effects Off Vypnuto Make properties default Nastavit vlastnosti jako výchozí Pages selected Vybrané stránky Page properties Vlastnosti stránky + Create & Edit Vytváření a úpravy + Review & Compare Revize a porovnání + Forms & Signatures Formuláře a podpisy + Protect & Standardize Ochrana a standardizace + Customize & Automate Přizpůsobení a automatizace + No tools match your search. Hledání neodpovídá žádný nástroj. + Expand all tools Rozbalit všechny nástroje + Collapse all tools Sbalit všechny nástroje Attach a file… Připojit soubor… Delete attachment Odstranit přílohu Attaching files is not available on the web yet Připojování souborů zatím na webu není k dispozici @@ -2846,3 +2863,12 @@ certificate Version Verze # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Dnes +@plural {n} reply|{n} replies {n} odpověď|{n} odpovědi|{n} odpovědí + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Nástroje stránek + Insert pages from a file after the selection… Vložit stránky ze souboru za výběr… + Rotate the selected pages counterclockwise Otočit vybrané stránky proti směru hodinových ručiček + Rotate the selected pages clockwise Otočit vybrané stránky po směru hodinových ručiček + Extract the selected pages… Extrahovat vybrané stránky… + Delete the selected pages Odstranit vybrané stránky diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 1dc9a27441..cd9d181c8b 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -157,6 +157,15 @@ Available Verfügbar Ready Bereit Search tools and commands… Tools und Befehle suchen… + +# Accessible names for inputs that have no visible label. + Added text Hinzugefügter Text + Link URL URL des Links + Search tools and commands Tools und Befehle suchen + Text editing line Textbearbeitungszeile + Text to place Einzufügender Text + Tooltip Tooltip + Words or phrases to redact Zu schwärzende Wörter oder Ausdrücke No matching tools Keine passenden Werkzeuge Tool Werkzeug Command Befehl @@ -2498,6 +2507,14 @@ animation effects Off Aus Make properties default Eigenschaften als Standard festlegen Pages selected Ausgewählte Seiten Page properties Seiteneigenschaften + Create & Edit Erstellen & Bearbeiten + Review & Compare Prüfen & Vergleichen + Forms & Signatures Formulare & Signaturen + Protect & Standardize Schützen & Standardisieren + Customize & Automate Anpassen & Automatisieren + No tools match your search. Keine Werkzeuge entsprechen Ihrer Suche. + Expand all tools Alle Werkzeuge ausklappen + Collapse all tools Alle Werkzeuge einklappen Attach a file… Datei anhängen… Delete attachment Anhang löschen Attaching files is not available on the web yet Dateien anhängen ist im Web noch nicht verfügbar @@ -2540,3 +2557,12 @@ animation effects Off Aus # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Heute +@plural {n} reply|{n} replies {n} Antwort|{n} Antworten + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Seitenwerkzeuge + Insert pages from a file after the selection… Seiten aus einer Datei nach der Auswahl einfügen… + Rotate the selected pages counterclockwise Ausgewählte Seiten gegen den Uhrzeigersinn drehen + Rotate the selected pages clockwise Ausgewählte Seiten im Uhrzeigersinn drehen + Extract the selected pages… Ausgewählte Seiten extrahieren… + Delete the selected pages Ausgewählte Seiten löschen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index f5048dcf9d..77b104e4e6 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2175,6 +2175,15 @@ View more Περισσότερα Available Διαθέσιμο Search tools and commands… Αναζήτηση εργαλείων και εντολών… + +# Accessible names for inputs that have no visible label. + Added text Προστιθέμενο κείμενο + Link URL Διεύθυνση URL συνδέσμου + Search tools and commands Αναζήτηση εργαλείων και εντολών + Text editing line Γραμμή επεξεργασίας κειμένου + Text to place Κείμενο προς τοποθέτηση + Tooltip Συμβουλή εργαλείου + Words or phrases to redact Λέξεις ή φράσεις για απόκρυψη No matching tools Δεν βρέθηκαν εργαλεία Tool Εργαλείο Command Εντολή @@ -2503,6 +2512,14 @@ animation effects Off Ανενεργά Make properties default Ορισμός ιδιοτήτων ως προεπιλογή Pages selected Επιλεγμένες σελίδες Page properties Ιδιότητες σελίδας + Create & Edit Δημιουργία και επεξεργασία + Review & Compare Αναθεώρηση και σύγκριση + Forms & Signatures Φόρμες και υπογραφές + Protect & Standardize Προστασία και τυποποίηση + Customize & Automate Προσαρμογή και αυτοματοποίηση + No tools match your search. Κανένα εργαλείο δεν ταιριάζει με την αναζήτησή σας. + Expand all tools Ανάπτυξη όλων των εργαλείων + Collapse all tools Σύμπτυξη όλων των εργαλείων Attach a file… Επισύναψη αρχείου… Delete attachment Διαγραφή συνημμένου Attaching files is not available on the web yet Η επισύναψη αρχείων δεν είναι ακόμη διαθέσιμη στον ιστό @@ -2545,3 +2562,12 @@ animation effects Off Ανενεργά # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Σήμερα +@plural {n} reply|{n} replies {n} απάντηση|{n} απαντήσεις + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Εργαλεία σελίδων + Insert pages from a file after the selection… Εισαγωγή σελίδων από αρχείο μετά την επιλογή… + Rotate the selected pages counterclockwise Περιστροφή των επιλεγμένων σελίδων αριστερόστροφα + Rotate the selected pages clockwise Περιστροφή των επιλεγμένων σελίδων δεξιόστροφα + Extract the selected pages… Εξαγωγή των επιλεγμένων σελίδων… + Delete the selected pages Διαγραφή των επιλεγμένων σελίδων diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index bc5d3ca19a..0d136aae11 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -180,6 +180,15 @@ Available Disponible Ready Listo Search tools and commands… Buscar herramientas y comandos… + +# Accessible names for inputs that have no visible label. + Added text Texto añadido + Link URL URL del enlace + Search tools and commands Buscar herramientas y comandos + Text editing line Línea de edición de texto + Text to place Texto que colocar + Tooltip Información sobre herramientas + Words or phrases to redact Palabras o frases que censurar No matching tools Ninguna herramienta coincide Tool Herramienta Command Comando @@ -2640,6 +2649,14 @@ animation effects Off Desactivados Make properties default Usar como propiedades predeterminadas Pages selected Páginas seleccionadas Page properties Propiedades de página + Create & Edit Crear y editar + Review & Compare Revisar y comparar + Forms & Signatures Formularios y firmas + Protect & Standardize Proteger y estandarizar + Customize & Automate Personalizar y automatizar + No tools match your search. Ninguna herramienta coincide con la búsqueda. + Expand all tools Expandir todas las herramientas + Collapse all tools Contraer todas las herramientas Font Fuente Bulleted list Lista con viñetas Numbered list Lista numerada @@ -2862,3 +2879,12 @@ theme Appearance Aspecto # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Hoy +@plural {n} reply|{n} replies {n} respuesta|{n} respuestas + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Herramientas de página + Insert pages from a file after the selection… Insertar páginas de un archivo después de la selección… + Rotate the selected pages counterclockwise Girar las páginas seleccionadas en sentido antihorario + Rotate the selected pages clockwise Girar las páginas seleccionadas en sentido horario + Extract the selected pages… Extraer las páginas seleccionadas… + Delete the selected pages Eliminar las páginas seleccionadas diff --git a/crates/ui-egui/src/i18n/fa.tsv b/crates/ui-egui/src/i18n/fa.tsv new file mode 100644 index 0000000000..5cfc217533 --- /dev/null +++ b/crates/ui-egui/src/i18n/fa.tsv @@ -0,0 +1,2806 @@ +# Persian (fa) catalog. Columns, separated by a TAB: context source translation +# +# context empty a plain UI string; `source` is the English text in the code +# @id a command id (e.g. file.saveAs): wins over the English text for +# that menu item, so one id can read differently from its twin +# @plural `source` is "one|other" (English forms); the translation lists the +# language's forms separated by "|" (see `plural` in i18n/mod.rs) +# anything else a disambiguating context passed to `tr_ctx` +# escapes \n \t \\ inside a column. `{name}` placeholders must appear in both columns. +# lines starting with # and blank lines are ignored. +# +# Clean-room: every entry was written from the plain meaning of the English label using ordinary +# Persian computing vocabulary. No proprietary localisation resource, string table or product UI +# (Acrobat's included) was consulted or copied; add entries the same way. +# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) stay in Latin letters. +# +# Right to left: like Arabic and Kurdish, entries stay in logical (typing) order. +# `rtl: true` in i18n/mod.rs selects explicit right-to-left text direction; epaint reorders +# each rendered line. The Persian comma \u060c and question mark \u061f are used, and \u200c (ZWNJ) joins +# compound words. + + Menu منو + File فایل + Edit ویرایش + Pages صفحه‌ها + View نمایش + Help راهنما + Preferences ترجیحات + Preferences… ترجیحات… + Interface language زبان رابط کاربری + Interface size اندازه رابط کاربری + Auto ({percent}%) خودکار ({percent}%) + Auto follows the system's text size. Pages always render at the display's full resolution. حالت خودکار از اندازه متن سیستم پیروی می‌کند. صفحه‌ها همیشه با وضوح کامل نمایشگر رندر می‌شوند. + Identity هویت + Name on new comments نام روی یادداشت‌های جدید + Hide hover previews for highlights without comment text پنهان کردن پیش‌نمایش شناور برای برجسته‌سازی‌های بدون متن یادداشت + Open… باز کردن… + Open Recent باز کردن موارد اخیر + Clear Recent Files پاک کردن فایل‌های اخیر + Remove from Recent حذف از موارد اخیر + Remove {name} from Recent حذف {name} از موارد اخیر + Pin folder to Home… سنجاق کردن پوشه به خانه… + Folders پوشه‌ها + Pin a folder… سنجاق کردن پوشه… + Pin a folder to Home سنجاق کردن پوشه به خانه + Pin a folder to list its newest PDFs here, such as where your scanner or a synced cloud drive saves them. یک پوشه را سنجاق کنید تا جدید‌ترین PDFهای آن اینجا فهرست شوند، مثلاً جایی که اسکنر یا درایو ابری هم‌گام‌شده آن‌ها را ذخیره می‌کند. + Unpin برداشتن سنجاق + Unpin {folder} برداشتن سنجاق {folder} + Looking for PDFs… جستوجوی PDFها… + This folder isn't available right now. It may have moved, or its drive or cloud sync is offline. این پوشه هم اکنون در دسترس نیست. ممکن است جابه جا شده باشد یا درایو یا هم‌گام‌سازی ابری آن آفلاین باشد. + No PDFs in this folder yet هنوز PDFی در این پوشه نیست + Show less نمایش کمتر + Show {n} more نمایش {n} مورد بیشتر + The newest {n} of {total} PDFs {n} PDF جدید‌تر از {total} PDF + Pinned folders need the desktop app پوشه‌های سنجاق‌شده به برنامه دسکتاپ نیاز دارند + Choose a folder on this computer to pin یک پوشه روی این رایانه برای سنجاق کردن انتخاب کنید + {folder} is already pinned to Home {folder} از قبل به خانه سنجاق شده است + You can pin up to {n} folders. Unpin one first. می‌توانید تا {n} پوشه را سنجاق کنید. ابتدا یکی را بردارید. + Pinned {folder} to Home {folder} به خانه سنجاق شد + No recent files فایل اخیری وجود ندارد + New blank PDF PDF خالی جدید + Create PDF from file… ایجاد PDF از فایل… + Create PDF from images… ایجاد PDF از تصاویر… + Create PDF from multiple files… ایجاد PDF از چند فایل… + Create separate PDFs… ایجاد PDFهای جداگانه… + Create PDF from clipboard ایجاد PDF از بریده دان + Combine files… ترکیب فایل‌ها… + Save ذخیره + Save as… ذخیره به‌عنوان… + Close file بستن فایل + Close all بستن همه + Revert باز‌گرداندن + Print… چاپ… + Document properties… ویژگی‌های سند… + Undo واگرد + Redo ازنو + Find… یافتن… + Advanced search… جستوجوی پیشرفته… + Copy pages رونوشت صفحه‌ها + Cut pages برش صفحه‌ها + Paste pages چسباندن صفحه‌ها + Fit visible تناسب با بخش قابل مشاهده + Marquee zoom بزرگنمایی ناحی‌های + Take a snapshot گرفتن تصویر لحظه ای + Column select انتخاب ستونی + Full screen mode حالت تمامصفحه + Read mode حالت مطالعه + Next tab زبانه بعدی + Previous tab زبانه قبلی + Switch light / dark theme تغییر بین پوسته روشن / تیره + Comments panel پنل یادداشت‌ها + Form fields panel پنل فیلد‌های فرم + Clear form پاک کردن فرم + Find tools and commands… یافتن ابزار‌ها و فرمان‌ها… + Zoom بزرگنمایی + Actual size اندازه واقعی + Zoom to page level بزرگنمایی تا سطح صفحه + Fit to width تناسب با عرض + Display theme پوسته نمایش + Side panels پنل‌های کناری + Enable Acrobat JavaScript فعال کردن Acrobat JavaScript + Flatten Fill & Sign when saving ادغام کردن پر کردن و امضا هنگام ذخیره + Text, marks and signatures become part of the page. Other comments stay editable. متن، نشانه‌ها و امضا‌ها بخشی از صفحه می‌شوند. سایر یادداشت‌ها قابل‌ویرایش می‌مانند. + OK تأیید + Join our Discord به دیسکورد ما بپیوندید + Close بستن + Next / previous match تطابق بعدی / قبلی + Copy selected text رونوشت متن انتخاب‌شده + Double-click دوبار کلیک + Select a word انتخاب یک واژه + Clear selection / close find پاک کردن انتخاب / بستن جستوجو + Zoom in / out (also pinch or {key}-scroll) بزرگنمایی / کوچکنمایی (همچنین با دو انگشت یا {key} + پیمایش) + Rotate view چرخش نما + First / last page صفحه اول / آخر + Previous / next page صفحه قبلی / بعدی + Delete selected pages (Organize) حذف صفحه‌های انتخاب‌شده (سازمان‌دهی) + Select all pages (Organize) انتخاب همه صفحه‌ها (سازمان‌دهی) + Home خانه + Open باز کردن + Open a PDF ({key}) باز کردن یک PDF ({key}) + Dark gray theme پوسته خاکستری تیره + Light theme پوسته روشن + Appearance mode حالت ظاهر + Light روشن + Dark تیره + Active فعال + Dark theme پوسته تیره + Appearance settings… تنظیمات ظاهر… + All tools همه ابزار‌ها + Read خواندن + Convert تبدیل + E-Sign امضای الکترونیکی + Print ({key}) چاپ ({key}) + Save ({key}) ذخیره ({key}) + Document properties ({key}) ویژگی‌های سند ({key}) + Find tools and commands یافتن ابزار‌ها و فرمان‌ها + Fit to height تناسب با ارتفاع + Zoom in بزرگنمایی + Zoom out کوچکنمایی + Rotate view clockwise چرخش نما در جهت ساعتگرد + Rotate view counterclockwise چرخش نما در جهت پادساعتگرد + Page navigation پیمایش صفحه + Previous view نمای قبلی + Next view نمای بعدی + Page display نمایش صفحه + Continuous scrolling پیمایش پیوسته + Show gaps between pages نمایش فاصله بین صفحه‌ها + Hide gaps to join page edges. Margins inside the PDF remain unchanged. فاصله‌ها را برای اتصال لبه‌های صفحه پنهان کنید. حاشیه‌های داخل PDF تغییر نمی‌کنند. + Two-page view نمای دو صفح‌های + Single page صفحه تکی + Show cover page in two-page view نمایش صفحه جلد در نمای دو صفح‌های + Use system setting استفاده از تنظیمات سیستم + Light gray خاکستری روشن + Dark gray خاکستری تیره + Comments یادداشت‌ها + Bookmarks نشانک‌ها + Fields فیلد‌ها + Layers لایه‌ها + Attachments پیوست‌ها + Signatures امضا‌ها + Accessibility Checker بررسی‌کننده دسترس‌پذیری + Search جستوجو + Compare مقایسه + Page thumbnails بندانگشتی صفحه‌ها + Form fields فیلد‌های فرم + Zoom out ({key}) کوچکنمایی ({key}) + Zoom in ({key}) بزرگنمایی ({key}) + Rotate view clockwise ({key}) چرخش نما در جهت ساعتگرد ({key}) + Toggle fit page / fit width تغییر بین تناسب با صفحه / تناسب با عرض + Next page صفحه بعدی + Previous page صفحه قبلی + Current page — type a page number or label (such as iv) and press Enter صفحه فعلی — یک شماره یا برچسب صفحه (مانند iv) وارد کنید و Enter را فشار دهید + Zoom — type a percentage and press Enter بزرگنمایی — یک درصد وارد کنید و Enter را فشار دهید + Welcome to PdfCraft به PdfCraft خوش آمدید + An open-source PDF workbench — local, private, and scriptable. یک میزکار PDF متن باز — محلی، خصوصی و قابل اسکریپت‌نویسی. + Join the ArtCraft community به جامعه ArtCraft بپیوندید + Get help, share feedback and follow development on Discord. کمک بگیرید، بازخورد بدهید و توسعه را در دیسکورد دنبال کنید. + Recommended tools ابزار‌های پیشنهادی + Use now همین حالا استفاده کنید + Open file باز کردن فایل + Recent اخیر + Files you open in PdfCraft appear here. Drop a PDF anywhere to open it. فایل‌هایی که در PdfCraft باز می‌کنید اینجا نمایش داده می‌شوند. برای باز کردن، یک PDF را هرجایی رها کنید. + Privacy حریم خصوصی + PdfCraft works offline. No telemetry, no account, and no cloud processing unless you add a provider. PdfCraft آفلاین کار می‌کند. بدون تله سنجی، بدون حساب کاربری و بدون پردازش ابری، مگر اینکه ارائه دهند‌ه ای اضافه کنید. + Scripts run in a sandbox without file or network access. With JavaScript off, Acrobat's standard format, validate and calculate functions still work. اسکریپت‌ها در یک محیط ایزوله بدون دسترسی به فایل یا شبکه اجرا می‌شوند. با خاموش بودن JavaScript، توابع استاندارد قالب‌بندی، اعتبارسنجی و محاسبه Acrobat همچنان کار می‌کنند. + Back to all tools بازگشت به همه ابزار‌ها + Close panel بستن پنل + View less نمایش کمتر + View more نمایش بیشتر + Available در دسترس + Ready آماده + Search tools and commands… جستوجوی ابزار‌ها و فرمان‌ها… + No matching tools ابزار منطبقی نیست + Tool ابزار + Command فرمان + Add a sticky note افزودن یادداشت چسبان + Add a text box افزودن کادر متن + Highlight text برجسته‌سازی متن + Underline text زیرخط متن + Strikethrough text خط خوردگی متن + Squiggly underline text زیرخط موجدار متن + Draw freehand ترسیم آزاد + Draw a line ترسیم خط + Draw an arrow ترسیم پیکان + Draw a rectangle ترسیم مستطیل + Draw an oval ترسیم بیضی + Draw a polygon ترسیم چند‌ضلعی + Draw connected lines ترسیم خطوط متصل + Draw a cloud ترسیم ابر + Add a callout افزودن فراخوان + Insert text درج متن + Replace text جای‌گزینی متن + Attach a file پیوست کردن فایل + Erase drawings پاک کردن ترسیم‌ها + Flatten comments ادغام یادداشت‌ها + Import comments… درونبرد یادداشت‌ها… + Add a stamp افزودن مهر + Export all comments to data file… برونبرد همه یادداشت‌ها به فایل داده… + Hide all comments پنهان کردن همه یادداشت‌ها + Summarize comments خلاصه‌سازی یادداشت‌ها + Import form data… درونبرد داده‌های فرم… + Export form data… برونبرد داده‌های فرم… + PDF/A… PDF/A… + Action Wizard… جادوگر اقدام… + Prepare for distribution… آماده‌سازی برای توزیع… + Optimize scanned documents… بهینه‌سازی اسناد اسکن شده… + Compare files… مقایسه فایل‌ها… + Detect form fields تشخیص فیلد‌های فرم + Merge data files into spreadsheet… ادغام فایل‌های داده در صفحهگسترده… + Flatten form fields ادغام فیلد‌های فرم + Prepare a form آماده‌سازی فرم + Tab order: by rows ترتیب زبانه: بر اساس ردیف‌ها + Tab order: by columns ترتیب زبانه: بر اساس ستون‌ها + Tab order: by document structure ترتیب زبانه: بر اساس ساختار سند + Add text افزودن متن + Edit text & images ویرایش متن و تصاویر + Add or edit links افزودن یا ویرایش پیوند‌ها + Create links from URLs ایجاد پیوند از URLها + Remove all links حذف همه پیوند‌ها + No links to remove پیوندی برای حذف وجود ندارد + Removed 1 link ۱ پیوند حذف شد + Removed {n} links {n} پیوند حذف شد + Add Bates numbering… افزودن شماره‌گذاری Bates… + Add image… افزودن تصویر… + Redact text and images سیاه‌کردن متن و تصاویر + Redact pages… سیاه‌کردن صفحه‌ها… + Find text and redact… یافتن متن و سیاه‌کردن… + Redaction properties… ویژگی‌های سیاه‌کردن… + Apply redactions… اعمال سیاه‌کردن‌ها… + Clear redaction marks پاک کردن نشانه‌های سیاه‌کردن + Remove hidden information… حذف اطلاعات پنهان… + Sanitize document… پاک‌سازی سند… + Field properties… ویژگی‌های فیلد… + Add a text field افزودن فیلد متن + Add a checkbox افزودن جعبه انتخاب + Add a radio button افزودن دکمه رادیویی + Add a drop-down list افزودن فهرست کشویی + Add a list box افزودن کادر فهرست + Add a button افزودن دکمه + Add an image field افزودن فیلد تصویر + Add a date field افزودن فیلد تاریخ + Add a digital signature field افزودن فیلد امضای دیجیتال + Digitally sign امضای دیجیتال + Certify (visible signature) گواهی (امضای قابل مشاهده) + Certify (invisible signature) گواهی (امضای نامرئی) + Validate all signatures اعتبارسنجی همه امضا‌ها + Signatures panel پنل امضا‌ها + Fill & Sign: add text پر کردن و امضا: افزودن متن + Fill & Sign: checkmark پر کردن و امضا: علامت تیک + Fill & Sign: cross پر کردن و امضا: ضربدر + Fill & Sign: dot پر کردن و امضا: نقطه + Fill & Sign: line پر کردن و امضا: خط + Fill & Sign: date پر کردن و امضا: تاریخ + Fill & Sign: sign پر کردن و امضا: امضا + Fill & Sign: initials پر کردن و امضا: حروف اول + Export to image… برونبرد به تصویر… + Reduce file size… کاهش اندازه فایل… + Optimize PDF… بهینه‌سازی PDF… + Export to text… برونبرد به متن… + Export to Word… برونبرد به Word… + Export to PowerPoint… برونبرد به PowerPoint… + Export to HTML… برونبرد به HTML… + Export to RTF… برونبرد به RTF… + JavaScript console… کنسول JavaScript… + Document JavaScripts… JavaScriptهای سند… + Recognize text… تشخیص متن… + Recognize text in multiple files… تشخیص متن در چند فایل… + Check for accessibility… بررسی دسترس‌پذیری… + Open accessibility report باز کردن گزارش دسترس‌پذیری + Change reading options… تغییر گزینه‌های خواندن… + Add alternate text… افزودن متن جانشین… + Export all images… برونبرد همه تصاویر… + Add header & footer… افزودن سرصفحه و پاصفحه… + Update header & footer… به‌روز‌رسانی سرصفحه و پاصفحه… + Remove header & footer حذف سرصفحه و پاصفحه + Add watermark… افزودن چاپ سایه… + Update watermark… به‌روز‌رسانی چاپ سایه… + Remove watermark حذف چاپسایه + Add background… افزودن پس‌زمینه… + Update background… به‌روز‌رسانی پس‌زمینه… + Remove background حذف پس‌زمینه + Protect using password… محافظت با گذرواژه… + Remove security حذف امنیت + Security properties… ویژگی‌های امنیت… + Organize pages سازمان‌دهی صفحه‌ها + New bookmark نشانک جدید + New bookmarks from structure نشانک‌های جدید از ساختار + Rotate pages clockwise چرخش صفحه‌ها در جهت ساعتگرد + Rotate pages counterclockwise چرخش صفحه‌ها در جهت پادساعتگرد + Delete pages حذف صفحه‌ها + Insert blank page درج صفحه خالی + Rotate pages… چرخش صفحه‌ها… + Duplicate pages تکثیر صفحه‌ها + Crop pages برش صفحه‌ها + Set page boxes… تنظیم کادر‌های صفحه… + Insert pages from file… درج صفحه‌ها از فایل… + Replace pages… جای‌گزینی صفحه‌ها… + Extract pages… استخراج صفحه‌ها… + Split document… تقسیم سند… + Number pages… شماره‌گذاری صفحه‌ها… + Keyboard shortcuts میانبر‌های صفحه کلید + Join the ArtCraft Discord به دیسکورد ArtCraft بپیوندید + PdfCraft web page صفحه وب PdfCraft + PdfCraft on GitHub PdfCraft در GitHub + ArtCraft website وبسایت ArtCraft + Check for updates… بررسی به‌روز‌رسانی‌ها… + About PdfCraft درباره PdfCraft + Export a PDF برونبرد یک PDF + Export to برونبرد به + Microsoft Word (.docx) Microsoft Word (.docx) + Spreadsheet (.xlsx) صفحه گسترده (.xlsx) + Presentation (.pptx) ارائه (.pptx) + Image (PNG) تصویر (PNG) + Export all images برونبرد همه تصاویر + HTML web page صفحه وب HTML + Rich Text Format (.rtf) قالب متن غنی (.rtf) + Text (plain) متن (ساده) + PostScript / EPS PostScript / EPS + Fill & Sign پر کردن و امضا + Fill پر کردن + Checkmark علامت تیک + Cross ضرب در + Dot نقطه + Line خط + Date تاریخ + Signature امضا + Initials حروف اول + Edit a PDF ویرایش یک PDF + Modify page تغییر صفحه + Rotate pages چرخش صفحه‌ها + Insert pages درج صفحه‌ها + Extract pages استخراج صفحه‌ها + Add content افزودن محتوا + Text متن + Image تصویر + Header and footer سرصفحه و پاصفحه + Watermark چاپ سایه + Link پیوند + Bates numbering شماره‌گذاری Bates + Background پس‌زمینه + Attach file پیوست کردن فایل + Create a PDF ایجاد یک PDF + Create from ایجاد از + Single file فایل تکی + Multiple files چند فایل + Separate PDFs PDFهای جداگانه + Images تصاویر + Clipboard بریده دان + Blank page صفحه خالی + Combine files ترکیب فایل‌ها + Combine ترکیب + Add files to combine افزودن فایل‌ها برای ترکیب + Page options گزینه‌های صفحه + Page grid شبکه صفحه + Rotate چرخش + Delete حذف + Extract استخراج + Insert درج + Replace جای‌گزینی + Split تفکیک + More بیشتر + Set page boxes تعیین جعبه‌های صفحه + Set page transitions تعیین انتقال‌های صفحه + Page labels برچسب‌های صفحه + Page transitions انتقال‌های صفحه + Add comments افزودن یادداشت‌ها + Markup نشانه‌گذاری + Sticky note یادداشت چسبان + Underline زیرخط + Strikethrough خط خوردگی + Squiggly underline زیرخط موجدار + Text box جعبه متن + Drawing ترسیم + Rectangle مستطیل + Oval بیضی + Arrow پیکان + Cloud ابر + Stamp مهر + Review باز‌بینی + Comment list فهرست یادداشت‌ها + Scan & OCR پویش و OCR + Recognize text شناسایی متن + In this file در این فایل + In multiple files در چندین فایل + Enhance scanned file بهبود فایل پویش‌شده + Correct recognized text تصحیح متن شناسایی‌شده + Protect a PDF محافظت از یک PDF + Protect محافظت + Protect with password محافظت با گذرواژه + Remove hidden information حذف اطلاعات پنهان + Advanced options گزینه‌های پیشرفته + Encrypt with certificate رمز‌گذاری با گواهی + Security properties ویژگی‌های امنیت + Redact a PDF سیاه‌کردن یک PDF + Redact سیاه‌کردن + Redact pages سیاه‌کردن صفحه‌ها + Find text and redact یافتن متن و سیاه‌کردن + Set properties تعیین ویژگی‌ها + Sanitize document پاک‌سازی سند + Compress a PDF فشرده‌سازی یک PDF + Optimize بهینه‌سازی + Reduce file size کاهش اندازه فایل + Advanced optimization بهینه‌سازی پیشرفته + Audit space usage بررسی میزان فضای مصرفی + Field list فهرست فیلد‌ها + Field properties ویژگی‌های فیلد + Tab order by rows ترتیب زبانه بر اساس سطر‌ها + Tab order by columns ترتیب زبانه بر اساس ستون‌ها + Import data درون‌ریزی داده‌ها + Export data برونبری داده‌ها + Add form components افزودن اجزای فرم + Text field فیلد متن + Checkbox کادر انتخاب + Radio button دکمه رادیویی + Drop-down list فهرست کشویی + List box جعبه فهرست + Button دکمه + Image field فیلد تصویر + Date field فیلد تاریخ + Digital signature امضای دیجیتال + Stamps مهر‌ها + Stamp palette پالت مهر‌ها + Use a certificate استفاده از یک گواهی + Certificates گواهی‌ها + Timestamp مهر زمانی + Use print production استفاده از تولید چاپ + Print production تولید چاپ + Output preview پیش‌نمایش خروجی + Preflight پیش‌بررسی + Convert colors تبدیل رنگ‌ها + Add printer marks افزودن نشانه‌های چاپگر + Fix hairlines اصلاح خطوط مویی + Measure objects اندازه‌گیری اشیا + Measure اندازه‌گیری + Measuring tool ابزار اندازه‌گیری + Geospatial location موقعیت مکانی جغرافیایی + Compare files مقایسه فایل‌ها + Select files to compare انتخاب فایل‌ها برای مقایسه + Use guided actions استفاده از کنش‌های هدایت‌شده + Actions list فهرست کنش‌ها + Action Wizard جادوگر کنش + Make accessible دسترس‌پذیر کردن + Prepare for distribution آماده‌سازی برای توزیع + Optimize scanned documents بهینه‌سازی اسناد پویش‌شده + Archive documents بایگانی اسناد + Prepare for accessibility آماده‌سازی برای دسترس‌پذیری + Accessibility دسترس‌پذیری + Automatically tag PDF برچسب‌گذاری خودکار PDF + Change reading options تغییر گزینه‌های خواندن + Check for accessibility بررسی دسترس‌پذیری + Add alternate text افزودن متن جانشین + Fix reading order اصلاح ترتیب خواندن + Apply PDF standards اعمال استاندارد‌های PDF + Standards استاندارد‌ها + Save as PDF/A ذخیره به‌صورت PDF/A + Save as PDF/X ذخیره به‌صورت PDF/X + Save as PDF/UA ذخیره به‌صورت PDF/UA + Add search index افزودن نمایه جستوجو + Index نمایه + Manage embedded index مدیریت نمایه جا‌سازی‌شده + Use JavaScript استفاده از JavaScript + JavaScript JavaScript + Console کنسول + Document JavaScripts JavaScriptهای سند + Document actions کنش‌های سند + AI assistant دستیار هوش مصنوعی + Bring your own model (off by default) مدل خودتان را بی‌آورید (به‌طور پیش‌فرض خاموش) + Summarize خلاصه‌سازی + Ask about this document پرسش درباره این سند + Translate ترجمه + pages صفحه‌ها + Planned برنامه‌ریزی‌شده + Planned for milestone برنامه‌ریزی‌شده برای نقطه عطف + open to see what it will include باز کنید تا ببینید چه چیزی را در بر می‌گیرد + Optional: needs an AI provider you configure اختیاری: به یک ارائه دهنده هوش مصنوعی که خودتان پیکر‌بندی می‌کنید نیاز دارد + Coming in milestone در راه در نقطه عطف + Items marked Ready work today. مواردی که با برچسب آماده مشخص شدهاند امروز کار می‌کنند. + Preview پیش‌نمایش + Edit fields ویرایش فیلد‌ها + Try the form as people filling it in will see it فرم را همان‌گونه که افرادی که آن را پر می‌کنند می‌بینند امتحان کنید + Redact all سیاه‌کردن همه + Clear all پاک کردن همه + Rotate page چرخش صفحه + Delete page حذف صفحه + Move page جابه‌جایی صفحه + Move pages جابه‌جایی صفحه‌ها + Duplicate page تکثیر صفحه + Replace page جای‌گزینی صفحه + Replace pages جای‌گزینی صفحه‌ها + Crop page برش صفحه + Cancel لغو + Don't save ذخیره نکن + Apply اعمال + Run اجرا + Clear پاک کردن + Sanitize پاک‌سازی + Mark all علامت‌گذاری همه + Save changes to “{name}” before closing? تغییرات «{name}» پیش از بستن ذخیره شود؟ + Your changes will be lost if you don't save them. اگر آن‌ها را ذخیره نکنید، تغییراتتان از دست می‌رود. + Password required گذرواژه لازم است + “{name}” is protected. Enter a password to open it. «{name}» محافظت‌شده است. برای باز کردن آن گذرواژ‌های وارد کنید. + Password گذرواژه + Incorrect password. Try again. گذرواژه نادرست است. دوباره تلاش کنید. + Recognize Text شناسایی متن + Password protection will be applied when you save محافظت با گذرواژه هنگام ذخیره اعمال می‌شود + Saved the print-ready PDF to {path} PDF آماده چاپ در {path} ذخیره شد + Could not save: {e} ذخیره نشد: {e} + Earlier in tab order پیش‌تر در ترتیب زبانه + Later in tab order بعد‌تر در ترتیب زبانه + Calculation order ترتیب محاسبه + Fix order اصلاح ترتیب + Move only what must move, so every field calculates after the fields it reads. فقط آنچه باید جابه جا شود را جابه جا کنید تا هر فیلد پس از فیلد‌هایی که می‌خواند محاسبه شود. + Earlier in calculation order پیش‌تر در ترتیب محاسبه + Later in calculation order بعد‌تر در ترتیب محاسبه + Too early خیلی زود + Reads {name}, which calculates later, so it shows an old value. {name} را می‌خواند که بعد‌تر محاسبه می‌شود، پس مقدار قدیمی را نشان می‌دهد. + Loop حلقه + Reads {names}, which read it back. No order can fix a loop: change one of the formulas. {names} را می‌خواند که به نوبه خود آن را می‌خوانند. هیچ ترتیبی حلقه را اصلاح نمی‌کند: یکی از فرمول‌ها را تغییر دهید. + Not in order نامرتب + Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. در ترتیب محاسب‌های که در PDF ذخیره شده وجود ندارد: سایر نمایشگر‌های PDF ممکن است آن را محاسبه نکنند. + Back to Prepare a form بازگشت به آماده‌سازی فرم + No field in this form calculates its value yet. Give a field a calculation on the Calculate tab of its properties, and it shows here. هنوز هیچ فیلدی در این فرم مقدارش را محاسبه نمی‌کند. به یک فیلد در برگه محاسبه از ویژگی‌هایش یک محاسبه بدهید تا اینجا نمایش داده شود. + {names} and {last} {names} و {last} + {field} reads no other field {field} هیچ فیلد دی‌گری را نمی‌خواند + {field} reads {fields} {field} {fields} را می‌خواند + No field reads it هیچ فیلدی آن را نمی‌خواند + The order saved in the PDF needs tidying ترتیب ذخیره‌شده در PDF نیاز به مرتب‌سازی دارد + Moves only what must move فقط آنچه باید جابه جا شود را جابه جا می‌کند + Every field calculates after the fields it reads. هر فیلد پس از فیلد‌هایی که می‌خواند محاسبه می‌شود. + Every field outside a loop calculates after the fields it reads. هر فیلد بیرون از یک حلقه پس از فیلد‌هایی که می‌خواند محاسبه می‌شود. + {field} reads its own value {field} مقدار خودش را می‌خواند + {fields} read each other {fields} یکدیگر را می‌خوانند + No order can fix a loop. Change one of the formulas. هیچ ترتیبی حلقه را اصلاح نمی‌کند. یکی از فرمول‌ها را تغییر دهید. + Edit {field}'s formula ویرایش فرمول {field} + Order · top calculates first ترتیب · بالا اول محاسبه می‌شود + Script اسکریپت + Links on page پیوند‌های روی صفحه + Show on the page which fields each field reads روی صفحه نشان بده که هر فیلد کدام فیلد‌ها را می‌خواند + Drag a field to move it, or select it and press {up} or {down}. یک فیلد را برای جابه‌جایی بکشید، یا آن را انتخاب کنید و {up} یا {down} را فشار دهید. + From: از: + To: تا: + High Resolution تفکیک‌پذیری بالا + Print چاپ + Verify to see what the document needs. برای دیدن نیاز‌های سند، بررسی کنید. + Set Page Boxes تعیین جعبه‌های صفحه + Signature panel پنل امضا + {month} {day}, {y} {day} {month} {y} + The document's security settings don't allow filling in form fields تنظیمات امنیتی سند اجازه پر کردن فیلد‌های فرم را نمی‌دهد + {name} is read-only {name} فقط خواندنی است + {name} has no action {name} هیچ کنشی ندارد + Previous month ماه پیش + Next month ماه بعد + January ژانویه + February فوریه + March مارس + April آوریل + May مه + June ژوئن + July ژوئیه + August اوت + September سپتامبر + October اکتبر + November نوامبر + December دسامبر + Done انجام شد + This certificate is in your list of trusted certificates. این گواهی در فهرست گواهی‌های مورد اعتماد شماست. + This certificate is trusted through a certificate above it in the chain. این گواهی از طریق گواهی بالا‌تر از آن در زنجیره مورد اعتماد است. + This certificate is not trusted. Signatures made with it show an unknown identity. این گواهی مورد اعتماد نیست. امضا‌های ساخته‌شده با آن هویتی ناشناخته نشان می‌دهند. + Add to Trusted Certificates افزودن به گواهی‌های مورد اعتماد + Export… برونبری… + PDF Optimizer بهینه‌ساز PDF + Discard Objects دور انداختن اشیا + Discard User Data دور انداختن داده‌های کاربر + Clean Up پاک‌سازی + Color Images تصاویر رنگی + Grayscale Images تصاویر خاکستری + Bicubic downsampling to نمونه برداری کاهشی دو مکعبی به + for images above برای تصاویر بالا‌تر از + Compression فشرده‌سازی + ZIP ZIP + Retain existing نگهداشتن موجود + Minimum کمینه + Low پایین + High بالا + Maximum بیشینه + Each image is measured where pages draw it; an image is replaced only if the result is smaller. هر تصویر همان‌جا که صفحه‌ها آن را ترسیم می‌کنند اندازه‌گیری می‌شود؛ تصویر تن‌ها در صورتی جایگزین می‌شود که نتیجه کوچک‌تر باشد. + Discard all links, actions and JavaScript دور انداختن همه پیوند‌ها، کنش‌ها و JavaScript + Discard alternate images دور انداختن تصاویر جانشین + Discard embedded page thumbnails دور انداختن تصاویر بندانگشتی جا‌سازی‌شده صفحه + Discard document tags دور انداختن برچسب‌های سند + Discard embedded print settings دور انداختن تنظیمات چاپ جا‌سازی‌شده + Discard bookmarks دور انداختن نشانک‌ها + Discard hidden layer content دور انداختن محتوای لایه پنهان + Discard all comments, forms and multimedia دور انداختن همه یادداشت‌ها، فرم‌ها و چند‌رسان‌های + Discard document information and metadata دور انداختن اطلاعات و فراداده‌های سند + Discard all object data (file attachments) دور انداختن همه داده‌های شیء (پیوست‌های فایل) + Discard private data of other applications دور انداختن داده‌های خصوصی سایر برنامه‌ها + Discard hidden text دور انداختن متن پنهان + Use Flate to encode streams that are not encoded استفاده از Flate برای کد‌گذاری جریان‌هایی که کد‌گذاری نشدهاند + In streams that use LZW encoding, use Flate instead در جریان‌هایی که از کد‌گذاری LZW استفاده می‌کنند، به‌جای آن از Flate استفاده کنید + Remove invalid links and bookmarks حذف پیوند‌ها و نشانک‌های نامعتبر + Remove unreferenced named destinations حذف مقصد‌های نامدار بدون ارجاع + Compress document structure (object streams) فشرده‌سازی ساختار سند (جریان‌های شیء) + Remove unused objects and merge identical ones حذف اشیای بدون استفاده و ادغام اشیای یکسان + Audit space usage… بررسی میزان فضای مصرفی… + Space Audit بررسی فضا + What takes up the space in this file, by kind of content. چه چیزی فضای این فایل را اشغال می‌کند، بر حسب نوع محتوا. + Bytes بایت + Content Streams جریان‌های محتوا + X Object Forms فرم‌های XObject + Structure Info اطلاعات ساختار + Thumbnails تصاویر بندانگشتی + Named Destinations مقصد‌های نامدار + Color Spaces فضا‌های رنگ + Shadings سایه زنی‌ها + Patterns (graphics objects) الگو‌ها (اشیای گرافیکی) + Extended Graphics States حالت‌های گرافیکی توسعه یافته + Embedded Files فایل‌های جا‌سازی‌شده + Document Overhead سربار سند + Total مجموع + Text Field Properties ویژگی‌های فیلد متن + Check Box Properties ویژگی‌های کادر انتخاب + Radio Button Properties ویژگی‌های دکمه رادیویی + Dropdown Properties ویژگی‌های فهرست کشویی + List Box Properties ویژگی‌های جعبه فهرست + Button Properties ویژگی‌های دکمه + Digital Signature Properties ویژگی‌های امضای دیجیتال + Position موقعیت + Options گزینه‌ها + Validate اعتبارسنجی + Calculate محاسبه + Actions کنش‌ها + This field is locked. Unlock it to change its properties. این فیلد قفل است. برای تغییر ویژگی‌هایش آن را باز کنید. + Tooltip: راهنمای ابزار: + Common Properties ویژگی‌های مشترک + Read Only فقط خواندنی + Required الزامی + Font Size: اندازه قلم: + Auto خودکار + Text Color: رنگ متن: + Borders and Colors حاشیه‌ها و رنگ‌ها + Border Color: رنگ حاشیه: + Fill Color: رنگ پرشدگی: + No color بدون رنگ + Beveled پخدار + Left: چپ: + Bottom: پایین: + Width: عرض: + Height: ارتفاع: + Rotation: چرخش: + Points from the page's bottom-left corner. نقاط از گوشه پایین چپ صفحه. + Alignment: تراز‌بندی: + Default Value: مقدار پیش‌فرض: + Multi-line چند‌خطی + Scroll long text پیمایش متن طولانی + Allow Rich Text Formatting اجازه قالب‌بندی متن غنی + Limit of محدودیت + characters نویسه + Field is used for file selection فیلد برای انتخاب فایل استفاده می‌شود + Check spelling بررسی املا + Comb of شان‌های + Export Value: {on} مقدار برونبری: {on} + Check box is checked by default کادر به‌طور پیش‌فرض علامت خورده است + Button is checked by default دکمه به‌طور پیش‌فرض علامت خورده است + Buttons with the same name and value are selected in unison دکمه‌هایی با نام و مقدار یکسان هم‌زمان انتخاب می‌شوند + Item: مورد: + Item List فهرست موارد + Up بالا + Delete {o} حذف {o} + Add the choices people pick from. گزینه‌هایی که افراد از میانشان انتخاب می‌کنند را بی‌فزایید. + Default: پیش‌فرض: + (none) (هیچکدام) + Sort items مرتب‌سازی موارد + Allow user to enter custom text اجازه دادن به کاربر برای وارد کردن متن سفارشی + Multiple selection انتخاب چند‌گانه + Commit selected value immediately ثبت فوری مقدار انتخاب‌شده + Number عدد + Percentage درصد + Time زمان + Special ویژه + Select format category: دسته قالب را انتخاب کنید: + The value is shown as typed. مقدار همان‌گونه که تایپ شده نشان داده می‌شود. + Decimal places: ارقام اعشار: + Separator style: سبک جدا‌کننده: + Currency symbol: نماد واحد پول: + Before the number پیش از عدد + Negative number style: سبک اعداد منفی: + Format: قالب: + Custom: سفارشی: + Zip Code کد پستی + Zip Code + 4 کد پستی + ۴ + Phone Number شماره تلفن + Social Security Number شماره امنیت اجتماعی + Arbitrary Mask ماسک دلبخواه + Mask: ماسک: + 9 digit, A letter, O letter or digit, X any character ۹: رقم؛ A: حرف؛ O: حرف یا رقم؛ X: هر نویسه + Example: {sample} نمونه: {sample} + Field value is not validated مقدار فیلد اعتبارسنجی نمی‌شود + Field value is in range: مقدار فیلد در بازه است: + Add an Action افزودن یک کنش + Select Trigger: انتخاب محرک: + Mouse Up رها کردن دکمه ماوس + Mouse Down فشار دادن دکمه ماوس + Mouse Enter ورود ماوس + Mouse Exit خروج ماوس + On Focus هنگام دریافت فوکوس + On Blur هنگام از دست دادن فوکوس + Select Action: انتخاب کنش: + Open a web link باز کردن یک پیوند وب + Reset a form باز‌نشانی یک فرم + Execute a menu item اجرای یک مورد منو + Show a field نمایش یک فیلد + Hide a field پنهان کردن یک فیلد + Submit a form ارسال یک فرم + Fields to reset (comma-separated; empty: all) فیلد‌های باز‌نشانی (جدا شده با کاما؛ خالی: همه) + Print, NextPage, PrevPage, FirstPage or LastPage Print، NextPage، PrevPage، FirstPage یا LastPage + Field names (comma-separated) نام‌های فیلد (جدا شده با کاما) + URL to submit to URL برای ارسال به آن + Script: اسکریپت: + Value: مقدار: + No actions بدون کنش + Value is not calculated مقدار محاسبه نمی‌شود + Value is the مقدار برابر است با + sum (+) مجموع (+) + product (x) حاصلضرب (x) + average میانگین + minimum کمینه + maximum بیشینه + of the following fields: از فیلد‌های زیر: + Simplified field notation: نشانه‌گذاری ساده‌شده فیلد: + Field names with + - * / and parentheses; put \\ before spaces in names. نام فیلد‌ها با + - * / و پرانتز‌ها؛ قبل از فاصله‌ها در نام‌ها \\ بگذارید. + Finding document fonts… یافتن قلم‌های سند… + Installed fonts… قلم‌های نصب‌شده… + Keep source fonts حفظ قلم‌های منبع + Load font… بار‌گذاری قلم… + No openly licensed document fonts found. Load a font file. هیچ قلم سند با مجوز باز یافت نشد. یک فایل قلم بار‌گذاری کنید. + Format text قالب‌بندی متن + Bold پررنگ + Italic کج + Align left تراز‌بندی چپ + Centre وسطچین + Align right تراز‌بندی راست + Justify هم‌تراز + Thai and Arabic use regular fallback fonts when available. تایلندی و عربی در صورت موجود بودن از قلم‌های جانشین عادی استفاده می‌کنند. + Edit image ویرایش تصویر + Rotate counterclockwise چرخش پادساعتگرد + Rotate clockwise چرخش ساعتگرد + Flip horizontal وارونگی افقی + Flip vertical وارونگی عمودی + Replace image جای‌گزینی تصویر + Crop (% trimmed from each side) برش (درصد کاسته‌شده از هر طرف) + Click on the page to add text; each click starts a new box, and ✓ finishes. Drag items to move them, drag a corner to resize, double-click text to edit it. روی صفحه کلیک کنید تا متن اضافه شود؛ هر کلیک یک کادر جدید آغاز می‌کند و ✓ پایان می‌دهد. برای جابه‌جایی موارد را بکشید، برای تغییر اندازه گوش‌های را بکشید و برای ویرایش متن دوبار کلیک کنید. + Choose an image انتخاب یک تصویر + Export to Image صدور به تصویر + Export to Text صدور به متن + Export All Images صدور همه تصاویر + Resolution تفکیک‌پذیری + Format قالب + Quality کیفیت + One {f} file per page, named after the document. یک فایل {f} در هر صفحه، نام‌گذاری‌شده بر اساس سند. + Exclude images smaller than کنار گذاشتن تصاویر کوچک‌تر از + No limit بدون محدودیت + Each image once, named after the document and page. JPEG images are saved unchanged; others as PNG. هر تصویر یک بار، نام‌گذاری‌شده بر اساس سند و صفحه. تصاویر JPEG بدون تغییر ذخیره می‌شوند؛ بقیه به‌صورت PNG. + Plain text in reading order; pages are separated by form feeds. متن ساده به ترتیب خواندن؛ صفحه‌ها با نویسه‌های فرمفید از هم جدا می‌شوند. + Create {what} ایجاد {what} + initials حروف اول + signature امضا + Create Custom Stamp ایجاد مهر سفارشی + From {file} از {file} + Category: دسته: + e.g. My stamps مثلاً مهر‌های من + Name: نام: + Custom سفارشی + Delete stamp حذف مهر + Create custom stamp… ایجاد مهر سفارشی… + From a PDF page or an image از یک صفحه PDF یا یک تصویر + Line spacing فاصله خطوط + Character spacing فاصله نویسه‌ها + Horizontal scale مقیاس افقی + Rotate Clockwise چرخش ساعتگرد + Rotate Counterclockwise چرخش پادساعتگرد + Flip Horizontal وارونگی افقی + Flip Vertical وارونگی عمودی + Replace Image… جای‌گزینی تصویر… + Save Image As… ذخیره تصویر به‌عنوان… + Copy Image کپی تصویر + The image has been copied ({w} × {h} pixels) تصویر کپی شد ({w} × {h} پیکسل) + Couldn't copy the image: {e} کپی تصویر انجام نشد: {e} + Copy Image in Original Format کپی تصویر در قالب اصلی + The image has been copied as a {ext} file تصویر به‌صورت یک فایل {ext} کپی شد + The page has changed; select the image again صفحه تغییر کرده است؛ تصویر را دوباره انتخاب کنید + Check for updates بررسی به‌روز‌رسانی‌ها + No check has run yet. هنوز هیچ بررسیای انجام نشده است. + Checking for a newer version… بررسی برای نگارش تازه‌تر… + PdfCraft {v} is available. PdfCraft {v} در دسترس است. + You have version {c}. Download the new version from its release page. شما نگارش {c} را دارید. نگارش جدید را از صفحه انتشار آن دانلود کنید. + PdfCraft {c} is up to date. PdfCraft {c} به‌روز است. + Couldn't check for updates: {e} بررسی به‌روز‌رسانی‌ها انجام نشد: {e} + You have version {c}. All releases are listed at {page}. شما نگارش {c} را دارید. همه انتشار‌ها در {page} فهرست شدهاند. + Asks GitHub for the latest release. Nothing is downloaded or installed automatically. آخرین انتشار را از GitHub می‌پرسد. هیچ چیزی به‌طور خودکار دانلود یا نصب نمی‌شود. + Download دانلود + Later بعداً + none هیچکدام + Declared conformance: {shown} انطباق اعلام‌شده: {shown} + Output intent: {list} قصد خروجی: {list} + Conformance level: سطح انطباق: + No problems found: the document conforms to {level}. مشکلی یافت نشد: سند با {level} منطبق است. + (page {p}) (صفحه {p}) + · not fixable here · اینجا قابل رفع نیست + Verify تأیید + Box کادر + Units واحد‌ها + Set to zero تنظیم به صفر + Current page ({n}) صفحه کنونی ({n}) + Subset زیرمجموعه + Update به‌روز‌رسانی + Add افزودن + {verb} Header and Footer {verb} سرصفحه و پاصفحه + {verb} Watermark {verb} واترمارک + {verb} Background {verb} پس‌زمینه + Margins حاشیه‌ها + Left Header Text متن سرصفحه چپ + Center Header Text متن سرصفحه وسط + Right Header Text متن سرصفحه راست + Left Footer Text متن پاصفحه چپ + Center Footer Text متن پاصفحه وسط + Right Footer Text متن پاصفحه راست + Insert Page Number درج شماره صفحه + Insert Date درج تاریخ + Start page number شماره صفحه شروع + Shrink the page content to make room for the header and footer کوچک کردن محتوای صفحه برای ایجاد جا برای سرصفحه و پاصفحه + Source منبع + Fit to page اندازه با صفحه + Rotation چرخش + Appear behind page نمایش پشت صفحه + Appear on top of page نمایش روی صفحه + Offset from centre فاصله از مرکز + From colour از رنگ + Page Range Options گزینه‌های بازه صفحه + Preview: page {p} of {n} پیش‌نمایش: صفحه {p} از {n} + Applies to 1 page روی ۱ صفحه اعمال می‌شود + Applies to {n} pages روی {n} صفحه اعمال می‌شود + Replaces the existing one on those pages. مورد موجود در آن صفحه‌ها را جایگزین می‌کند. + PDF or image PDF یا تصویر + No file chosen هیچ فایلی انتخاب نشده + Page number شماره صفحه + Scale relative to target page مقیاس نسبت به صفحه هدف + Link Properties ویژگی‌های پیوند + Create Link ایجاد پیوند + Link Appearance ظاهر پیوند + Link Type: نوع پیوند: + Visible Rectangle مستطیل قابل مشاهده + Invisible Rectangle مستطیل نامرئی + Highlight Style: سبک برجسته‌سازی: + Invert وارون + Outline خط دور + Inset فرورفته + Line Thickness: ضخامت خط: + Thin نازک + Thick ضخیم + Line Style: سبک خط: + Solid پیوسته + Dashed خطچین + Color: رنگ: + Link Action کنش پیوند + Open a web page باز کردن یک صفحه وب + Go to a page view رفتن به نمایش صفحه + Type the web address. نشانی وب را تایپ کنید. + This document has no signatures. این سند هیچ امضایی ندارد. + The Keychain's digital IDs couldn't be listed: {e} شناسه‌های دیجیتال Keychain فهرست نشدند: {e} + Signed and saved to {path} امضا و در {path} ذخیره شد + Couldn't open revision {n}: {e} باز کردن باز‌نگری {n} انجام نشد: {e} + Couldn't open the signed version: {e} باز کردن نگارش امضا‌شده انجام نشد: {e} + Sign with a Digital ID امضا با شناسه دیجیتال + Choose the digital ID that you want to use for signing: شناسه دیجیتالی را که می‌خواهید برای امضا استفاده کنید انتخاب کنید: + Keychain · Keychain · + Windows store · فروشگاه Windows · + Issued by: صادر‌شده توسط: + , Expires: ، انقضا: + Digitally signed by امضا‌شده دیجیتالی توسط + DN: DN: + Reason: دلیل: + Location: مکان: + Date: تاریخ: + The labels are in English: the signature's font can't draw them in this language. برچسب‌ها به انگلیسی هستند: قلم امضا نمی‌تواند آن‌ها را به این زبان بکشد. + No digital IDs yet. هنوز هیچ شناسه دیجیتالی وجود ندارد. + Configure New Digital ID پیکر‌بندی شناسه دیجیتال جدید + Remove "{name}" from this list? The file itself is not deleted. «{name}» از این فهرست حذف شود؟ خود فایل حذف نمی‌شود. + Continue ادامه + Configure a Digital ID for Signing پیکر‌بندی یک شناسه دیجیتال برای امضا + Use a Digital ID from a file استفاده از یک شناسه دیجیتال از یک فایل + Create a new Digital ID (self-signed, saved to a password-protected file) ایجاد یک شناسه دیجیتال جدید (خودامضا، ذخیره‌شده در فایلی محافظت‌شده با گذرواژه) + Name نام + Organizational Unit واحد سازمانی + Organization Name نام سازمان + Email Address نشانی ایمیل + Country/Region کشور/منطقه + Key Algorithm الگوریتم کلید + Confirm Password تأیید گذرواژه + Valid for 5 years, for digital signatures. Self-signed IDs are usually not trusted by others. برای امضا‌های دیجیتال، به مدت ۵ سال معتبر است. شناسه‌های خودامضا معمولاً مورد اعتماد دیگران نیستند. + File (.p12, .pfx) فایل (.p12، .pfx) + Digital ID شناسه دیجیتال + Browse… مرور… + {verb} as "{name}" {verb} به‌عنوان «{name}» + Certify گواهی کردن + The signature will be invisible. امضا نامرئی خواهد بود. + Distinguished name نام ممتاز + Labels برچسب‌ها + Graphic نگاره + Signer's name نام امضا‌کننده + Fill & Sign signature امضای پُرکردن و امضا + Choose image… انتخاب تصویر… + Permitted actions after certifying کنش‌های مجاز پس از گواهی کردن + No changes allowed هیچ تغییری مجاز نیست + Annotations, form fill-in, and digital signatures حاشیه‌نویسی‌ها، پُرکردن فرم و امضا‌های دیجیتال + Form fill-in and digital signatures پُرکردن فرم و امضا‌های دیجیتال + The key is in the macOS Keychain, which may ask to allow PdfCraft to use it. کلید در Keychain macOS است، که ممکن است برای اجازه دادن به PdfCraft برای استفاده از آن درخواست کند. + The key is in the Windows certificate store, which may ask to allow PdfCraft to use it. کلید در مخزن گواهی Windows است، که ممکن است برای اجازه دادن به PdfCraft برای استفاده از آن درخواست کند. + Digital ID password گذرواژه شناسه دیجیتال + Back بازگشت + Validate all اعتبارسنجی همه + Sign this field… امضای این فیلد… + Signature is valid: امضا معتبر است: + Signature validity is UNKNOWN: اعتبار امضا ناشناخته است: + Signature is INVALID: امضا نامعتبر است: + Signature Details جزئیات امضا + Reason دلیل + Signing time زمان امضا + Algorithm الگوریتم + Signer امضا‌کننده + Issued by صادر‌شده توسط + Valid معتبر + Field فیلد + Field: {field} on page {p} فیلد: {field} در صفحه {p} + Add to trusted certificates افزودن به گواهی‌های مورد اعتماد + View signed version نمایش نگارش امضا‌شده + Show certificate… نمایش گواهی… + Export certificate… صدور گواهی… + Certificate Viewer نمایشگر گواهی + This dialog shows the details of a certificate and its chain. این گفتوگو جزئیات یک گواهی و زنجیره آن را نشان می‌دهد. + Trust اعتماد + Issued to صادر‌شده برای + Valid from معتبر از + Valid to معتبر تا + Intended usage کاربرد موردنظر + Any هر + Version نگارش + Serial number شماره سریال + Issuer صادر‌کننده + Validity starts آغاز اعتبار + Validity ends پایان اعتبار + Public key کلید عمومی + Key usage کاربرد کلید + Not present موجود نیست + Self-signed خودامضا + Edit Action ویرایش کنش + New Action کنش جدید + Action name: نام کنش: + Description: توضیح: + Steps گام‌ها + Add the steps this action runs, in order. گام‌هایی را که این کنش به ترتیب اجرا می‌کند اضافه کنید. + Add Step افزودن گام + An action with this name already exists. کنشی با این نام از قبل وجود دارد. + New Action… کنش جدید… + Edit… ویرایش… + Add watermark افزودن واترمارک + Add header افزودن سرصفحه + Add footer افزودن پاصفحه + Set document title تعیین عنوان سند + Run a JavaScript اجرای یک JavaScript + Prepare for Distribution آماده‌سازی برای توزیع + Remove hidden information and flatten comments and form fields, so recipients see only the pages. حذف اطلاعات پنهان و تخت‌کردن یادداشت‌ها و فیلد‌های فرم، تا گیرندگان فقط صفحه‌ها را ببینند. + Optimize Scanned Documents بهینه‌سازی اسناد اسک‌نشده + Make scans searchable, then make them smaller. اسکن‌ها را قابل جستوجو کنید، سپس حجمشان را کاهش دهید. + Add Page Numbers افزودن شماره‌های صفحه + Number every page in the footer. هر صفحه را در پاصفحه شماره‌گذاری کنید. + Mark as Confidential علامت‌گذاری به‌عنوان محرمانه + A diagonal CONFIDENTIAL watermark on every page. یک واترمارک مورب CONFIDENTIAL روی هر صفحه. + Sanitize Documents پاک‌سازی اسناد + Remove hidden information, metadata, scripts and embedded content, and rewrite the files. حذف اطلاعات پنهان، فراداده، اسکریپت‌ها و محتوای جا‌سازی‌شده، و باز‌نویسی فایل‌ها. + Type text تایپ متن + Text to insert متن برای درج + Inserted text متن درج‌شده + Replacement text متن جایگزین + Edit text… ویرایش متن… + Add a sticky note here افزودن یک یادداشت چسبان در اینجا + {kind} Properties ویژگی‌های {kind} + Appearance ظاهر + General عمومی + Review History تاریخچه باز‌بینی + This comment's appearance can't be changed yet. ظاهر این یادداشت هنوز قابل تغییر نیست. + Icon نماد + Thickness ضخامت + Line ending پایان خط + Modified تغییریافته + No status has been set. هیچ وضعیتی تعیین نشده است. + Compare Files مقایسه فایل‌ها + Old file: فایل قدیمی: + Open the older version first ابتدا نگارش قدیمی‌تر را باز کنید + New file: فایل جدید: + Text is compared word by word; the differences are shown on the new file. متن واژهبهواژه مقایسه می‌شود؛ تفاوت‌ها روی فایل جدید نشان داده می‌شوند. + Compare this file with an older version: Compare files… in All tools. این فایل را با یک نگارش قدیمی‌تر مقایسه کنید: مقایسه فایل‌ها… در همه ابزار‌ها. + No differences in the text. هیچ تفاوتی در متن نیست. + {n} {kind} {n} {kind} + Inserted درج‌شده + Deleted حذف‌شده + Replaced جایگزی‌نشده + Visual بصری + Mark as comments علامت‌گذاری به‌عنوان یادداشت + Report… گزارش… + {kind} · page {p} {kind} · صفحه {p} + Set Alternate Text تعیین متن جانشین + This document has no tagged figures. این سند هیچ شکل برچسب گذاری شد‌ه ای ندارد. + on page {p} در صفحه {p} + Figure {i} of {n}{page} شکل {i} از {n}{page} + No preview بدون پیش‌نمایش + Alternate text متن جانشین + Describe the figure توصیف شکل + Decorative figure شکل تزئینی + Previous figure شکل قبلی + Next figure شکل بعدی + Save & Close ذخیره و بستن + Accessibility Checker Options گزینه‌های بررسی‌کننده دسترس‌پذیری + Report Options گزینه‌های گزارش + Create accessibility report ایجاد گزارش دسترس‌پذیری + Page Range بازه صفحه + All pages in document همه صفحه‌های سند + Checking Options ({on} of 32 in all categories) گزینه‌های بررسی ({on} از ۳۲ در همه دسته‌ها) + Show this dialog when the Checker starts نمایش این گفتوگو هنگام شروع بررسی‌کننده + Select All انتخاب همه + Clear All پاک کردن همه + Start Checking شروع بررسی + This document hasn't been checked yet. این سند هنوز بررسی نشده است. + No issues found مشکلی یافت نشد + 1 issue ۱ مشکل + {n} issues {n} مشکل + {label} (1 issue) {label} (۱ مشکل) + {label} ({n} issues) {label} ({n} مشکل) + Check again بررسی دوباره + Report گزارش + Show the accessibility report نمایش گزارش دسترس‌پذیری + Collapse جمع کردن + Expand باز کردن + {rule} - {status} {rule} - {status} + Passed موفق + Failed ناموفق + Needs manual check نیاز‌مند بررسی دستی + Skipped نادیده‌گرفته‌شده + Fix اصلاح + Skip Rule نادیده‌گرفتن قانون + Explain توضیح + Check Again بررسی مجدد + Show Report نمایش گزارش + Options… گزینه‌ها… + Go to the page رفتن به صفحه + Page Content محتوای صفحه + Forms فرم‌ها + Alternate Text متن جایگزین + Tables جدول‌ها + Lists فهرست‌ها + Headings سرتیتر‌ها + Accessibility permission flag پرچم مجوز دسترس‌پذیری + Accessibility permission flag is set پرچم مجوز دسترس‌پذیری تنظیم شده است + Security settings must allow assistive technology to read the content ("Enable text access for screen reader devices"). Change the document's security to allow it. تنظیمات امنیت باید به فناوری کمکی اجازه دهد محتوا را بخواند («فعال‌کردن دسترسی متنی برای دستگاه‌های صفحه خوان»). امنیت سند را تغییر دهید تا اجازه داده شود. + Image-only PDF PDF فقطتصویری + Document is not image-only PDF سند PDF فقطتصویری نیست + Pages that are only pictures of text can't be read aloud or searched. Recognize text (OCR) to add a text layer. صفحه‌هایی که تن‌ها تصویر متن هستند را نمی‌توان با صدای بلند خواند یا جستوجو کرد. متن را تشخیص دهید (OCR) تا لایه متنی افزوده شود. + Document is tagged PDF سند PDF برچسبدار است + Tags give the document a logical structure that assistive technology follows. Tag the document (Automatically tag PDF). برچسب‌ها به سند ساختاری منطقی می‌دهند که فناوری کمکی از آن پیروی می‌کند. سند را برچسب بزنید (برچسبزدن خودکار PDF). + Logical Reading Order ترتیب خواندن منطقی + Document structure provides a logical reading order ساختار سند ترتیب خواندن منطقی را فراهم می‌کند + Check that the tags read in the order a sighted reader would. Use the Reading Order tool or the Tags panel to correct it. بررسی کنید که برچسب‌ها به همان ترتیبی خوانده شوند که یک خواننده بینا می‌خواند. از ابزار ترتیب خواندن یا پنل برچسب‌ها برای اصلاح آن استفاده کنید. + Primary language زبان اصلی + Text language is specified زبان متن مشخص شده است + Screen readers choose their voice from the document's language. Set it in Document Properties > Advanced > Language. صفحهخوان‌ها صدا را بر پایه زبان سند انتخاب می‌کنند. آن را در ویژگی‌های سند > پیشرفته > زبان تنظیم کنید. + Document title is showing in title bar عنوان سند در نوار عنوان نمایش داده می‌شود + The window should show a meaningful title rather than the file name. Set a title and Initial View > Show: Document Title. پنجره باید به‌جای نام فایل عنوانی معنادار نشان دهد. عنوانی تنظیم کنید و نمایش اولیه > نمایش: عنوان سند. + Bookmarks are present in large documents نشانک‌ها در اسناد بزرگ وجود دارند + Documents of more than 20 pages need bookmarks for navigation. Add bookmarks for the main sections. اسناد بیش از ۲۰ صفحه برای پیمایش به نشانک نیاز دارند. برای بخش‌های اصلی نشانک بی‌فزایید. + Color contrast کنتراست رنگ + Document has appropriate color contrast سند کنتراست رنگ مناسب دارد + Text must contrast enough with its background for people with low vision. Check the colours by eye or with a contrast tool. متن باید برای افراد کمبینا بهاندازه کافی با پس‌زمینه کنتراست داشته باشد. رنگ‌ها را چشمی یا با ابزار کنتراست بررسی کنید. + Tagged content محتوای برچسبدار + All page content is tagged تمام محتوای صفحه برچسبدار است + Everything on a page must be tagged or marked as an artifact (decoration, headers, footers). Tag the untagged content. هر چیزی در صفحه باید برچسب بخورد یا به‌عنوان مصنوع (تزئین، سرصفحه، پاصفحه) علامت‌گذاری شود. محتوای بدون برچسب را برچسب بزنید. + Tagged annotations یادداشت‌های برچسبدار + All annotations are tagged تمام یادداشت‌ها برچسبدار هستند + Comments, links and fields must be in the tag tree to be announced. Tag the annotations. یادداشت‌ها، پیوند‌ها و فیلد‌ها باید در درخت برچسب باشند تا اعلام شوند. یادداشت‌ها را برچسب بزنید. + Tab order ترتیب تب + Tab order is consistent with structure order ترتیب تب با ترتیب ساختار سازگار است + Pages with links, comments or fields should tab in structure order. The fix sets each page's tab order to Use Document Structure. صفحه‌های دارای پیوند، یادداشت یا فیلد باید به ترتیب ساختار تب شوند. اصلاح، ترتیب تب هر صفحه را روی استفاده از ساختار سند تنظیم می‌کند. + Character encoding رمز‌گذاری نویسه + Reliable character encoding is provided رمز‌گذاری نویسه قابل‌اطمینان فراهم شده است + Fonts must map their characters to Unicode so text can be read and copied. Fix the fonts or recreate the PDF. قلم‌ها باید نویسه‌های خود را به یونیکد نگاشت کنند تا متن خوانده و رو‌نویسی شود. قلم‌ها را اصلاح کنید یا PDF را دوباره بسازید. + Tagged multimedia چند‌رسان‌های برچسبدار + All multimedia objects are tagged تمام اشیای چند‌رسان‌های برچسبدار هستند + Video, sound and rich media must be tagged to be announced. Tag the multimedia annotations. ویدئو، صدا و چند‌رسان‌های غنی باید برچسب بخورند تا اعلام شوند. یادداشت‌های چند‌رسانه ای را برچسب بزنید. + Screen flicker سوسوی صفحه + Page will not cause screen flicker صفحه باعث سوسوی صفحه نمی‌شود + Animation that flashes can cause seizures. Check scripts and media for flicker. پویانمایی چشمکزن می‌تواند باعث تشنج شود. اسکریپت‌ها و رسانه‌ها را از نظر سوسو بررسی کنید. + Scripts اسکریپت‌ها + No inaccessible scripts بدون اسکریپت غیر‌قابلدسترس + Timed responses پاسخ‌های زماندار + Page does not require timed responses صفحه به پاسخ‌های زماندار نیاز ندارد + People may need more time to respond. Check that nothing has a time limit. ممکن است افراد به زمان بیشتری برای پاسخ نیاز داشته باشند. بررسی کنید که چیزی محدودیت زمانی نداشته باشد. + Navigation links پیوند‌های پیمایش + Navigation links are not repetitive پیوند‌های پیمایش تکراری نیستند + Repeated navigation links should be skippable. Check links that appear on every page. پیوند‌های پیمایش تکراری باید قابل‌نادیدهگرفتن باشند. پیوند‌هایی را که در هر صفحه ظاهر می‌شوند بررسی کنید. + Tagged form fields فیلد‌های فرم برچسبدار + All form fields are tagged تمام فیلد‌های فرم برچسبدار هستند + Form fields must be in the tag tree (Form tags) to be announced. Tag the form fields. فیلد‌های فرم باید در درخت برچسب (برچسب‌های Form) باشند تا اعلام شوند. فیلد‌های فرم را برچسب بزنید. + Field descriptions توضیحات فیلد + All form fields have description تمام فیلد‌های فرم توضیح دارند + Each field needs a tooltip that says what to enter. Add one in Field Properties > General > Tooltip. هر فیلد به راهنمای ابزار نیاز دارد که بگوید چه چیزی وارد شود. یکی در ویژگی‌های فیلد > عمومی > راهنمای ابزار بی‌فزایید. + Figures alternate text متن جایگزین شکل‌ها + Figures require alternate text شکل‌ها به متن جایگزین نیاز دارند + Pictures need alternate text that describes them, or must be marked decorative. Add alternate text to each figure. تصویر‌ها به متن جای‌گزینی نیاز دارند که آن‌ها را توصیف کند، یا باید تزئینی علامت‌گذاری شوند. به هر شکل متن جایگزین بی‌فزایید. + Nested alternate text متن جایگزین تودرتو + Alternate text that will never be read متن جای‌گزینی که هرگز خوانده نمی‌شود + Alternate text inside an element that already has alternate text is never read. Remove the inner alternate text. متن جایگزین درون عنصری که از قبل متن جایگزین دارد هرگز خوانده نمی‌شود. متن جایگزین درونی را حذف کنید. + Associated with content مرتبط با محتوا + Alternate text must be associated with some content متن جایگزین باید با محتوایی مرتبط باشد + Alternate text on an element without page content is never reached. Remove it or attach the element to content. متن جایگزین روی عنصری بدون محتوای صفحه هرگز در دسترس قرار نمی‌گیرد. آن را حذف کنید یا عنصر را به محتوا پیوند دهید. + Hides annotation یادداشت را پنهان می‌کند + Alternate text should not hide annotation متن جایگزین نباید یادداشت را پنهان کند + Alternate text on an element that contains a link or field hides that annotation from assistive technology. Move the alternate text. متن جایگزین روی عنصری که شامل پیوند یا فیلد است، آن یادداشت را از فناوری کمکی پنهان می‌کند. متن جایگزین را جابهجا کنید. + Other elements alternate text متن جایگزین سایر عناصر + Other elements that require alternate text سایر عناصری که به متن جایگزین نیاز دارند + Formulas and similar non-text elements need alternate text. Add alternate text to them. فرمول‌ها و عناصر غیر‌متنی مشابه به متن جایگزین نیاز دارند. به آن‌ها متن جایگزین بی‌فزایید. + Rows سطر‌ها + TR must be a child of Table, THead, TBody, or TFoot TR باید فرزند Table، THead، TBody یا TFoot باشد + Table cells belong in a row. Fix the table's tags. خانه‌های جدول درون سطر قرار می‌گیرند. برچسب‌های جدول را اصلاح کنید. + Headers سرصفحه‌ها + Tables should have headers جدول‌ها باید سرصفحه داشته باشند + Header cells (TH) tell listeners what each column or row holds. Mark the header cells. خانه‌های سرصفحه (TH) به شنوندگان می‌گویند هر ستون یا سطر چه دارد. خانه‌های سرصفحه را علامت‌گذاری کنید. + Regularity نظم + Tables must contain the same number of columns in each row and rows in each column جدول‌ها باید در هر سطر تعداد ستون یکسان و در هر ستون تعداد سطر یکسان داشته باشند + Irregular tables confuse cell navigation. Give every row the same number of cells (counting spans). جدول‌های نامنظم پیمایش خانه‌ها را گیج‌کننده می‌کنند. به هر سطر تعداد یکسان خانه بدهید (با احتساب ادغام‌ها). + Summary خلاصه + Tables must have a summary جدول‌ها باید خلاصه داشته باشند + A summary describes the table's purpose and layout. Add a Summary attribute to each table. خلاصه هدف و چیدمان جدول را توصیف می‌کند. به هر جدول ویژگی Summary بی‌فزایید. + List items عناصر فهرست + LI must be a child of L LI باید فرزند L باشد + List items belong in a list. Fix the list's tags. عناصر فهرست درون فهرست قرار می‌گیرند. برچسب‌های فهرست را اصلاح کنید. + Lbl and LBody Lbl و LBody + Lbl and LBody must be children of LI Lbl و LBody باید فرزند LI باشند + List labels and bodies belong in a list item. Fix the list's tags. برچسب‌ها و بدنه‌های فهرست درون عنصر فهرست قرار می‌گیرند. برچسب‌های فهرست را اصلاح کنید. + Appropriate nesting تودرتویی مناسب + Heading levels shouldn't skip (an H1 followed by an H3). Retag the headings. سطوح سرتیتر نباید پرش کنند (یک H1 و سپس H3). سرتیتر‌ها را دوباره برچسب بزنید. + TH and TD TH و TD + TH and TD must be children of TR TH و TD باید فرزند TR باشند + Mark Page Range علامت‌گذاری بازه صفحه + Pages from صفحه‌ها از + Single word or phrase واژه یا عبارت تکی + Text to find متن برای یافتن + Patterns الگو‌ها + Phone Numbers شماره‌های تلفن + Email Addresses نشانی‌های رایانامه + Credit Cards کارت‌های اعتباری + Social Security Numbers شماره‌های امنیت اجتماعی + Dates تاریخ‌ها + Text in images isn't found; recognise text (OCR) first. متن درون تصویر‌ها یافت نمی‌شود؛ نخست متن را تشخیص دهید (OCR). + No matches. بدون تطابق. + Some bookmarks are not shown here: the panel lists a limited number, nested a limited depth. برخی نشانک‌ها اینجا نمایش داده نمی‌شوند: پنل تعداد محدودی را فهرست می‌کند، با عمق تودرتویی محدود. + {n} match(es) marked for redaction. {n} تطابق برای سیاه‌کردن علامت‌گذاری شد. + Multiple words or phrases چندین واژه یا عبارت + One word or phrase per line یک واژه یا عبارت در هر خط + Import list… درون‌ریزی فهرست… + {n} word(s) or phrase(s) {n} واژه یا عبارت + {name} is too large for a word list (at most 1 MB). {name} برای فهرست واژه‌ها بیش‌از‌حد بزرگ است (حداکثر ۱ مگابایت). + Redaction Tool Properties ویژگی‌های ابزار سیاه‌کردن + Redacted area fill colour: رنگ پر‌کردن ناحیه سیاه‌شده: + Use overlay text استفاده از متن روکش + Custom text: متن سفارشی: + Redaction code: کد سیاه‌کردن: + Font: قلم: + Font size: اندازه قلم: + Font size اندازه قلم + Auto-size text to fit redaction region تغییر خودکار اندازه متن برای جا شدن در ناحیه سیاه‌کردن + Font colour: رنگ قلم: + Repeat overlay text تکرار متن روکش + Text alignment: تراز‌بندی متن: + Center وسط + Apply redactions اعمال سیاه‌کردن‌ها + You are about to apply 1 redaction mark. Text, images and drawings under the marks, and comments and form fields that overlap them, are removed permanently. در آستانه اعمال ۱ نشانه سیاه‌کردن هستید. متن، تصویر‌ها و ترسیم‌های زیر نشانه‌ها، و یادداشت‌ها و فیلد‌های فرمی که با آن‌ها هم‌پوشانی دارند، برای همیشه حذف می‌شوند. + You are about to apply {n} redaction marks. Text, images and drawings under the marks, and comments and form fields that overlap them, are removed permanently. در آستانه اعمال {n} نشانه سیاه‌کردن هستید. متن، تصویر‌ها و ترسیم‌های زیر نشانه‌ها، و یادداشت‌ها و فیلد‌های فرمی که با آن‌ها هم‌پوشانی دارند، برای همیشه حذف می‌شوند. + Save the document afterwards: saving rewrites the whole file so no trace of the removed content stays in it. سند را پس از آن ذخیره کنید: ذخیره‌سازی کل فایل را باز‌نویسی می‌کند تا هیچ اثری از محتوای حذف‌شده در آن نماند. + Select the items to remove from this document. مواردی را برای حذف از این سند انتخاب کنید. + Metadata فراداده + File attachments پیوست‌های فایل + Comments and markups یادداشت‌ها و علامت‌گذاری‌ها + Hidden text متن پنهان + Hidden layers لایه‌های پنهان + Links, actions and JavaScripts پیوند‌ها، کنش‌ها و JavaScriptها + Private data of other applications داده‌های خصوصی سایر برنامه‌ها + None found چیزی یافت نشد + Form fields are flattened: their values stay visible. Saving rewrites the whole file. فیلد‌های فرم تخت می‌شوند: مقادیرشان نمایان می‌مانند. ذخیره‌سازی کل فایل را باز‌نویسی می‌کند. + Remove حذف + Sanitizing removes hidden information from the document: metadata, file attachments, comments, form fields (flattened), hidden text and layers, bookmarks, links, actions and scripts, and private application data. پاک‌سازی اطلاعات پنهان را از سند حذف می‌کند: فراداده، پیوست‌های فایل، یادداشت‌ها، فیلد‌های فرم (تخت‌شده)، متن و لایه‌های پنهان، نشانک‌ها، پیوند‌ها، کنش‌ها و اسکریپت‌ها، و داده‌های خصوصی برنامه‌ها. + Save the document afterwards; saving rewrites the whole file so nothing removed stays in it. سند را پس از آن ذخیره کنید؛ ذخیره‌سازی کل فایل را باز‌نویسی می‌کند تا هیچ چیز حذف‌شده در آن نماند. + Printer: چاپگر: + Save as PDF ذخیره به‌صورت PDF + {name} (default) {name} (پیش‌فرض) + Copies: نسخه‌ها: + Collate مرتب‌سازی + Print in grayscale چاپ در مقیاس خاکستری + Two-sided: دورو: + Off خاموش + Flip on long edge چرخش روی لبه بلند + Flip on short edge چرخش روی لبه کوتاه + Printer default پیش‌فرض چاپگر + Paper: کاغذ: + Pages to Print صفحه‌ها برای چاپ + More options: گزینه‌های بیشتر: + All pages in range تمام صفحه‌ها در بازه + Odd pages only فقط صفحه‌های فرد + Even pages only فقط صفحه‌های زوج + Reverse pages معکوس‌کردن صفحه‌ها + Page Sizing & Handling اندازه و مدیریت صفحه + Size اندازه + Poster پوستر + Multiple چند‌گانه + Booklet دفترچه + Fit جا دادن + Shrink oversized pages کوچک‌کردن صفحه‌های بزرگ‌تر از حد + Custom scale: مقیاس سفارشی: + Tile scale: مقیاس کاشی: + Overlap: هم‌پوشانی: + Cut marks نشانه‌های برش + Pages per sheet: صفحه در هر برگ: + Page order: ترتیب صفحه‌ها: + Horizontal افقی + Horizontal reversed افقی معکوس + Vertical عمودی + Vertical reversed عمودی معکوس + Print page border چاپ حاشیه صفحه + Auto-rotate pages چرخش خودکار صفحه‌ها + Booklet subset: زیرمجموعه دفترچه: + Both sides دو طرف + Front side only فقط روی + Back side only فقط پشت + Binding: صحافی: + Left چپ + Right راست + Orientation: جهت: + Auto portrait/landscape خودکار عمودی/افقی + Portrait عمودی + Landscape افقی + Comments & Forms یادداشت‌ها و فرم‌ها + Document سند + Document and markups سند و علامت‌گذاری‌ها + Document and stamps سند و مهر‌ها + Form fields only فقط فیلد‌های فرم + Previous sheet برگ قبلی + Next sheet برگ بعدی + No sheets بدون برگ + Sheet {s} of {n} برگ {s} از {n} + Removed 1 hidden item. Save to remove them from the file. ۱ مورد پنهان حذف شد. برای حذف آن‌ها از فایل ذخیره کنید. + Removed {n} hidden items. Save to remove them from the file. {n} مورد پنهان حذف شد. برای حذف آن‌ها از فایل ذخیره کنید. + Document sanitized. Save to finish: saving rewrites the whole file. سند پاک‌سازی شد. برای پایان ذخیره کنید: ذخیره‌سازی کل فایل را باز‌نویسی می‌کند. + Applied 1 redaction mark. Save to remove the content from the file. ۱ نشانه سیاه‌کردن اعمال شد. برای حذف محتوا از فایل ذخیره کنید. + Applied {n} redaction marks. Save to remove the content from the file. {n} نشانه سیاه‌کردن اعمال شد. برای حذف محتوا از فایل ذخیره کنید. + JavaScript Console کنسول JavaScript + JavaScript is turned off (Preferences ▸ JavaScript). JavaScript خاموش است (ترجیحات ▸ JavaScript). + Output appears here. خروجی اینجا نمایش داده می‌شود. + Script Name: نام اسکریپت: + This document has no document-level scripts. این سند اسکریپت سطح سند ندارد. + No form fields were detected هیچ فیلد فرمی شناسایی نشد + Detected 1 form field ۱ فیلد فرم شناسایی شد + Detected {n} form fields {n} فیلد فرم شناسایی شد + {field}: {e} {field}: {e} + Current page صفحه جاری + Selected pages صفحه‌های انتخاب‌شده + Document language زبان سند + Output خروجی + Searchable Image (Exact) تصویر قابل‌جستوجو (دقیق) + Adds invisible text over each word; the page image is not changed متن نامرئی روی هر واژه می‌افزاید؛ تصویر صفحه تغییر نمی‌کند + Downsample to کاهش نمونهبرداری به + Also recognize pages that already have text همچنین صفحه‌هایی که از قبل متن دارند را تشخیص بده + Pictures on those pages are recognized too; words already in the page's text are not added again تصویر‌های آن صفحه‌ها نیز تشخیص داده می‌شوند؛ واژه‌هایی که از قبل در متن صفحه هستند دوباره افزوده نمی‌شوند + Text recognition isn't installed: its model files are missing. Reinstall PdfCraft, or set PDFCRAFT_MODELS to the folder that holds them. تشخیص متن نصب نیست: فایل‌های مدل آن وجود ندارند. PdfCraft را دوباره نصب کنید، یا PDFCRAFT_MODELS را به پوشه ای که آن‌ها را دارد تنظیم کنید. + Settings تنظیمات + Optional اختیاری + Planned · {m} برنامه‌ریزی‌شده · {m} + Planned for milestone {m} — open to see what it will include برنامه‌ریزی‌شده برای نقطهعطف {m} — باز کنید تا ببینید چه چیزی را شامل می‌شود + Coming in milestone {m}. Items marked Ready work today. در نقطهعطف {m} می‌آید. موارد علامتخورده به‌عنوان آماده امروز کار می‌کنند. + 1 mark ۱ نشانه + {n} marks {n} نشانه + Dynamic پویا + Sign Here امضا اینجا + Standard Business تجاری استاندارد + Choose a stamp, then click on the page to place it. مهری انتخاب کنید، سپس برای قرار دادن آن روی صفحه کلیک کنید. + Note یادداشت + Polyline چند‌خطی + Attachment پیوست + Callout فراخوان + Typewriter ماشین تحریر + Checked علامتخورده + Unchecked بدون علامت + Marked علامت‌گذاری‌شده + Locked قفل‌شده + Search comments جستوجوی یادداشت‌ها + Add a comment افزودن یادداشت + No comments yet. هنوز یادداشتی نیست. + Pick a tool in the quick bar, or type above. ابزاری در نوار سریع انتخاب کنید، یا در بالا تایپ کنید. + No comments match. هیچ یادداشتی تطابق ندارد. + Unknown author نویسنده ناشناس + No colour بدون رنگ + Remove checkmark حذف علامت تیک + Mark with checkmark علامت‌گذاری با تیک + Reply پاسخ + Add a reply افزودن پاسخ + Post ارسال + Page {label} صفحه {label} + {kind} — click to show it on the page {kind} — برای نمایش آن روی صفحه کلیک کنید + Set status تعیین وضعیت + Copy text رو‌نویسی متن + Properties… ویژگی‌ها… + Make Current Properties Default پیش‌فرض‌کردن ویژگی‌های جاری + Make Properties Default پیش‌فرض‌کردن ویژگی‌ها + More options گزینه‌های بیشتر + Sort by مرتب‌سازی بر اساس + Author نویسنده + Type نوع + Colour رنگ + Show all comments نمایش همه یادداشت‌ها + Create PDF comment summary… ساخت خلاصه یادداشت‌های PDF… + Export all to data file… برونبری همه به فایل داده… + Filter comments پالایش یادداشت‌ها + Status وضعیت + Accepted پذیرفته‌شده + Rejected رد‌شده + Cancelled لغو‌شده + Completed تکمیل‌شده + Show all نمایش همه + Yellow زرد + Orange نارنجی + Red قرمز + Pink صورتی + Purple بنفش + Blue آبی + Light blue آبی روشن + Green سبز + Gray خاکستری + Black سیاه + Bookmark options گزینه‌های نشانک + Expand all bookmarks گشادن همه نشانک‌ها + Expand top-level bookmarks گشادن نشانک‌های سطح اول + Collapse all bookmarks بستن همه نشانک‌ها + New bookmark ({key}) نشانک جدید ({key}) + This document has no bookmarks. این سند نشانک ندارد. + This document has no layers. این سند لایه ندارد. + This document has no attachments. این سند پیوست ندارد. + Hide layer پنهان‌کردن لایه + Layer options گزینه‌های لایه + Reset to Initial Visibility باز‌نشانی به نمایانبودن اولیه + Show layer نمایش لایه + Set layer visibility تعیین نمایانبودن لایه + on page {label} در صفحه {label} + Save attachment… ذخیره پیوست… + Open in a new tab باز کردن در برگه جدید + This document has no pages. این سند صفحه ندارد. + Find text یافتن متن + No matches بدون تطابق + Searching… {s}/{p} جستوجو… {s}/{p} + {c} of {n}{more} {c} از {n}{more} + Previous ({key}) قبلی ({key}) + Next ({key}) بعدی ({key}) + Find options گزینه‌های یافتن + Whole words only فقط واژه‌های کامل + Case-sensitive حساس به بزرگی و کوچکی حروف + Close (Esc) بستن (Esc) + Align تراز‌بندی + Top بالا + Bottom پایین + Center horizontally تراز افقی در وسط + Center vertically تراز عمودی در وسط + Distribute توزیع + Horizontally افقی + Vertically عمودی + Set Fields to Same Size هم‌اندازه‌کردن فیلد‌ها + Height ارتفاع + Width پهنا + Both هر دو + Duplicate… تکثیر… + All pages همه صفحه‌ها + Odd pages صفحه‌های فرد + Even pages صفحه‌های زوج + Landscape pages صفحه‌های افقی + Portrait pages صفحه‌های عمودی + Move earlier جابه‌جایی به قبل + Move later جابه‌جایی به بعد + Back to the document بازگشت به سند + Protect Using Password محافظت با گذرواژه + Requires user to enter a password for: نیاز‌مند وارد کردن گذرواژه توسط کاربر برای: + Viewing نمایش + Editing ویرایش + Type Password گذرواژه را تایپ کنید + Re-type Password گذرواژه را دوباره تایپ کنید + Strength: {s} قدرت: {s} + Strong قوی + Medium متوسط + Weak ضعیف + Advanced Options {chevron} گزینه‌های پیشرفته {chevron} + Compatibility سازگاری + Acrobat X and later (256-bit AES) Acrobat X و بعد از آن (AES ۲۵۶-بیتی) + Acrobat 7.0 and later (128-bit AES) Acrobat 7.0 و بعد از آن (AES ۱۲۸-بیتی) + Acrobat 5.0 and later (128-bit RC4) Acrobat 5.0 و بعد از آن (RC4 ۱۲۸-بیتی) + Acrobat 3.0 and later (40-bit RC4) Acrobat 3.0 و بعد از آن (RC4 ۴۰-بیتی) + Encrypt رمز‌گذاری + All document contents همه محتوای سند + All document contents except metadata همه محتوای سند بهجز فراداده + Printing allowed چاپ مجاز + Low Resolution (150 dpi) تفکیک‌پذیری پایین (۱۵۰ dpi) + Changes allowed تغییرات مجاز + Inserting, deleting, and rotating pages افزودن، حذف و چرخش صفحه‌ها + Filling in form fields and signing existing signature fields پر کردن فیلد‌های فرم و امضای فیلد‌های امضای موجود + Commenting, filling in form fields, and signing existing signature fields یادداشت‌گذاری، پر کردن فیلد‌های فرم و امضای فیلد‌های امضای موجود + Any except extracting pages هر چیزی بهجز استخراج صفحه‌ها + Enable copying of text, images, and other content امکان کپی متن، تصویر‌ها و سایر محتوا + Enable text access for screen reader devices for the visually impaired امکان دسترسی متنی برای دستگاه‌های صفحهخوان برای افراد کمبینا + Type a password. گذرواژ‌های تایپ کنید. + The passwords don't match. گذرواژه‌ها مطابقت ندارند. + This compatibility level supports only plain ASCII passwords. این سطح سازگاری فقط از گذرواژه‌های ASCII ساده پشتیبانی می‌کند. + Anyone opening the document will need this password. It's applied when you save. هر کسی که سند را باز کند به این گذرواژه نیاز دارد. هنگام ذخیره اعمال می‌شود. + The document opens without a password; this password is needed to change the restrictions. It's applied when you save. سند بدون گذرواژه باز می‌شود؛ این گذرواژه برای تغییر محدودیت‌ها لازم است. هنگام ذخیره اعمال می‌شود. + Recover unsaved documents? باز‌یابی سند‌های ذخیره‌نشده؟ + PdfCraft didn't shut down normally. These documents had changes that were autosaved: PdfCraft به درستی بسته نشد. این سند‌ها تغییراتی داشتند که ذخیره خودکار شدهاند: + just now همین حالا + {n} min ago {n} دقیقه پیش + {n} h ago {n} ساعت پیش + · password-protected · محافظت‌شده با گذرواژه + Recover باز‌یابی + Discard دور انداختن + Version {v} نگارش {v} + A clean-room, open-source PDF application written in Rust. MIT OR Apache-2.0. یک برنامه PDF متن باز و clean-room نوشته‌شده با Rust. MIT OR Apache-2.0. + Part of بخشی از + Summarize Options گزینه‌های خلاصه‌سازی + Choose a layout: یک چیدمان انتخاب کنید: + Comments only فقط یادداشت‌ها + Sort comments by: مرتب‌سازی یادداشت‌ها بر اساس: + Create PDF Comment Summary ایجاد خلاصه یادداشت‌های PDF + Revert to the previously saved version of “{name}”? Changes since then can't be undone afterwards. باز‌گرداندن به نگارش ذخیره‌شده پیشین «{name}»؟ تغییرات پس از آن دیگر قابل واگردانی نیست. + Save over the changed file? روی فایل تغییریافته ذخیره شود؟ + “{name}” changed on disk after it was opened or last saved. Saving now replaces that newer version with this one. «{name}» پس از باز شدن یا آخرین ذخیره، روی دیسک تغییر کرده است. ذخیره کردن اکنون آن نگارش جدید‌تر را با این یکی جایگزین می‌کند. + Save anyway به هرحال ذخیره کن + Number pages شماره‌گذاری صفحه‌ها + Style سبک + None (prefix only) هیچکدام (فقط پیشوند) + Prefix پیشوند + Start شروع + Labels: {preview}. Later pages keep their labels. برچسب‌ها: {preview}. صفحه‌های بعدی برچسب‌های خود را حفظ می‌کنند. + Even and Odd Pages صفحه‌های زوج و فرد + Even Pages Only فقط صفحه‌های زوج + Odd Pages Only فقط صفحه‌های فرد + Landscape and Portrait Pages صفحه‌های افقی و عمودی + Landscape Pages صفحه‌های افقی + Portrait Pages صفحه‌های عمودی + Duplicate Field تکثیر فیلد + Duplicate "{name}" onto: تکثیر «{name}» روی: + 1 page selected. ۱ صفحه انتخاب شده است. + {n} pages selected. {n} صفحه انتخاب شده است. + Delete pages after extracting حذف صفحه‌ها پس از استخراج + Extract pages as separate files استخراج صفحه‌ها به‌عنوان فایل‌های جداگانه + Rotate Pages چرخش صفحه‌ها + Direction: جهت: + Clockwise 90 degrees ۹۰ درجه در جهت عقربه‌های ساعت + Counterclockwise 90 degrees ۹۰ درجه در خلاف جهت عقربه‌های ساعت + 180 degrees ۱۸۰ درجه + Pages: صفحه‌ها: + All همه + Selection انتخاب + From از + Rotate: چرخش: + Repairs تعمیرات + Repair log گزارش تعمیرات + Split document تقسیم سند + Number of pages تعداد صفحه‌ها + Pages per file صفحه‌ها در هر فایل + File size اندازه فایل + At most حداکثر + Top-level bookmarks ({n}) نشانک‌های سطح بالا ({n}) + Before each selected page (select pages in Organize) پیش از هر صفحه انتخاب‌شده (صفحه‌ها را در سازمان‌دهی انتخاب کنید) + Creates 1 file from {n} pages. ۱ فایل از {n} صفحه می‌سازد. + Creates {f} files from {n} pages. {f} فایل از {n} صفحه می‌سازد. + Each file holds as many of the {n} pages as fit. هر فایل تا جایی که جا شود از {n} صفحه را در خود می‌گنجاند. + Replace Pages جای‌گزینی صفحه‌ها + Original: replace pages اصلی: جای‌گزینی صفحه‌ها + to تا + to {n} تا {n} + Replacement: pages of {name} جایگزین: صفحه‌های {name} + of {name} از {name} + Only the page content changes: links, comments, form fields and bookmarks on the original pages stay. فقط محتوای صفحه تغییر می‌کند: پیوند‌ها، یادداشت‌ها، فیلد‌های فرم و نشانک‌های صفحه‌های اصلی باقی می‌مانند. + The replacement file doesn't have that many pages. فایل جایگزین آن تعداد صفحه را ندارد. + Tagged PDF PDF برچسبدار + Document Properties ویژگی‌های سند + Description توضیحات + Initial View نمای اولیه + Security امنیت + Fonts قلم‌ها + Advanced پیشرفته + Title عنوان + Subject موضوع + Keywords کلیدواژه‌ها + Application برنامه + PDF producer تولید‌کننده PDF + Layout and Magnification چیدمان و بزرگنمایی + Navigation tab زبانه پیمایش + Page Only فقط صفحه + Bookmarks Panel and Page پنل نشانک‌ها و صفحه + Pages Panel and Page پنل صفحه‌ها و صفحه + Attachments Panel and Page پنل پیوست‌ها و صفحه + Layers Panel and Page پنل لایه‌ها و صفحه + Page layout چیدمان صفحه + Default پیش‌فرض + Single Page تک‌صفحه + Single Page Continuous تک‌صفحه پیوسته + Two-Up (Facing) دوصفحه (روبهرو) + Two-Up Continuous (Facing) دوصفحه پیوسته (روبهرو) + Two-Up (Cover Page) دوصفحه (صفحه جلد) + Two-Up Continuous (Cover Page) دوصفحه پیوسته (صفحه جلد) + Magnification بزرگنمایی + Actual Size اندازه واقعی + Fit Page تناسب با صفحه + Fit Width تناسب با عرض + Fit Height تناسب با ارتفاع + Fit Visible تناسب با محتوای قابل مشاهده + Open to page باز شدن روی صفحه + of {pages} از {pages} + Window Options گزینه‌های پنجره + Resize window to initial page تغییر اندازه پنجره به صفحه اولیه + Center window on screen وسط چین کردن پنجره روی صفحه نمایش + Open in Full Screen mode باز شدن در حالت تمام صفحه + Show: نمایش: + File Name نام فایل + Document Title عنوان سند + User Interface Options گزینه‌های رابط کاربری + Hide menu bar پنهان کردن نوار منو + Hide toolbars پنهان کردن نوار‌های ابزار + Hide window controls پنهان کردن کنترل‌های پنجره + Security method روش امنیت + No security بدون امنیت + Restrictions محدودیت‌ها + None — everything is allowed هیچکدام — همه‌چیز مجاز است + Password security امنیت گذرواژه + Encryption رمز‌گذاری + Opened with باز‌شده با + — (protection is applied when you save) — (محافظت هنگام ذخیره اعمال می‌شود) + Owner password (no restrictions apply) گذرواژه مالک (هیچ محدودیتی اعمال نمی‌شود) + User password گذرواژه کاربر + Change settings… تغییر تنظیمات… + Printing چاپ + Not allowed مجاز نیست + Allowed مجاز + High resolution تفکیک‌پذیری بالا + Low resolution تفکیک‌پذیری پایین + Changing the document تغییر سند + Document assembly مونتاژ سند + Content copying کپی محتوا + Content copying for accessibility کپی محتوا برای دسترس‌پذیری + Commenting یادداشت‌گذاری + Filling of form fields پر کردن فیلد‌های فرم + No fonts are referenced by the pages. هیچ قلمی توسط صفحه‌ها ارجاع داده نشده است. + PDF version نگارش PDF + Location مکان + Page size اندازه صفحه + Yes بله + No خیر + Revisions باز‌بینی‌ها + View revision {n} نمایش باز‌بینی {n} + Open the file as it was saved then باز کردن فایل همان‌گونه که آن زمان ذخیره شده بود + Binding صحافی + Left Edge لبه چپ + Right Edge لبه راست + Language زبان + Saved {place}: {before} → {after} ({pct}% smaller){detail} ذخیره‌شده {place}: {before} → {after} ({pct}% کوچک‌تر){detail} + This file is already as small as it can be made ({size}). No copy was saved. این فایل از قبل تا حد امکان کوچک است ({size}). هیچ نسخ‌های ذخیره نشد. + Saved {place}: {before} → {after} (not smaller){detail} ذخیره‌شده {place}: {before} → {after} (کوچک‌تر نشد){detail} + Couldn't save the image: {e} ذخیره تصویر ممکن نشد: {e} + Adding images arrives on the web with file pickers for images افزودن تصویر‌ها در نسخه وب با انتخابگر فایل برای تصویر‌ها ارائه می‌شود + Replacing images on page {p} arrives on the web with image pickers ({i}) جای‌گزینی تصویر‌ها در صفحه {p} در نسخه وب با انتخابگر تصویر ارائه می‌شود ({i}) + {name}: choosing images arrives on the web with file pickers for images {name}: انتخاب تصویر‌ها در نسخه وب با انتخابگر فایل برای تصویر‌ها ارائه می‌شود + Replacing images arrives on the web with image file pickers جای‌گزینی تصویر‌ها در نسخه وب با انتخابگر فایل تصویر ارائه می‌شود + Couldn't create a PDF: {e} ایجاد PDF ممکن نشد: {e} + The clipboard has no image or text بریدهدان هیچ تصویر یا متنی ندارد + On the web, open or drop an image to convert it در نسخه وب، تصویری را باز کنید یا رها کنید تا تبدیل شود + Couldn't optimize the file: {e} بهینه‌سازی فایل ممکن نشد: {e} + Couldn't write {path}: {e} نوشتن {path} ممکن نشد: {e} + The form asks to be submitted to {u}; PdfCraft doesn't send form data. Save the document to keep your entries. فرم درخواست ارسال به {u} را دارد؛ PdfCraft داده‌های فرم را ارسال نمی‌کند. سند را ذخیره کنید تا ورودی‌هایتان حفظ شوند. + This PDF uses the unsupported {s} action. این PDF از کنش {s} پشتیبانی‌نشده استفاده می‌کند. + {name}: the {other} action isn't supported yet {name}: کنش {other} هنوز پشتیبانی نمی‌شود + {name} submits the form to {url}; PdfCraft doesn't send form data. Save the document to keep your entries. {name} فرم را به {url} ارسال می‌کند؛ PdfCraft داده‌های فرم را ارسال نمی‌کند. سند را ذخیره کنید تا ورودی‌هایتان حفظ شوند. + Export stopped: {e} برونبری متوقف شد: {e} + Exported 1 image ۱ تصویر برونبری شد + Exported {n} images {n} تصویر برونبری شد + ({s} not exported: {first}) ({s} برونبری نشد: {first}) + Exported the text of 1 page متن ۱ صفحه برونبری شد + Exported the text of {n} pages متن {n} صفحه برونبری شد + {msg} to {dir} {msg} به {dir} + Exporting… {d} of {t} در حال برونبری… {d} از {t} + Choose a folder for the exported files یک پوشه برای فایل‌های برونبری‌شده انتخاب کنید + New {tool} comments will look like this one یادداشت‌های جدید {tool} به این شکل خواهند بود + This document can't be checked: it couldn't be read for editing این سند بررسی نمی‌شود: برای ویرایش خوانده نشد + The checker found no problems in this document. بررسی‌کننده هیچ مشکلی در این سند پیدا نکرد. + The checker found 1 problem which may prevent the document from being fully accessible. بررسی‌کننده ۱ مشکل پیدا کرد که ممکن است مانع دسترس‌پذیری کامل سند شود. + The checker found {n} problems which may prevent the document from being fully accessible. بررسی‌کننده {n} مشکل پیدا کرد که ممکن است مانع دسترس‌پذیری کامل سند شوند. + The text is the same متن یکسان است + 1 difference found ۱ تفاوت پیدا شد + {n} differences found {n} تفاوت پیدا شد + Added 1 comment ۱ یادداشت افزوده شد + Added {n} comments {n} یادداشت افزوده شد + Choose a folder for the results یک پوشه برای نتایج انتخاب کنید + Files for {name} فایل‌های {name} + An action is already running یک کنش از قبل در حال اجراست + Running action… file {d} of {t} در حال اجرای کنش… فایل {d} از {t} + No unlinked web addresses were found in the text هیچ نشانی وب پیوندنشد‌های در متن پیدا نشد + Created 1 link ۱ پیوند ایجاد شد + Created {n} links {n} پیوند ایجاد شد + Text recognition is already running باز‌شناسی متن از قبل در حال اجراست + Optimizing… removing user data در حال بهینه‌سازی… حذف داده‌های کاربر + Optimizing… image {d} of {t} در حال بهینه‌سازی… تصویر {d} از {t} + Optimizing… cleaning up در حال بهینه‌سازی… پاک‌سازی + Optimizing… merging identical objects در حال بهینه‌سازی… ادغام اشیای یکسان + Optimizing… writing the copy در حال بهینه‌سازی… نوشتن نسخه + Cancelling… در حال لغو… + Optimization is already running بهینه‌سازی از قبل در حال اجراست + Optimization cancelled بهینه‌سازی لغو شد + Couldn't optimize the file بهینه‌سازی فایل ممکن نشد + Recognizing text… file {d} of {t} در حال باز‌شناسی متن… فایل {d} از {t} + Recognizing text… page {d} of {t} در حال باز‌شناسی متن… صفحه {d} از {t} + Recognized 1 word ۱ واژه باز‌شناسی شد + Recognized {n} words {n} واژه باز‌شناسی شد + {words} on {pages} {words} در {pages} + ; 1 page already had text ؛ ۱ صفحه از قبل متن داشت + ; {n} pages already had text ؛ {n} صفحه از قبل متن داشتند + Recognized {w} words in 1 file {w} واژه در ۱ فایل باز‌شناسی شد + Recognized {w} words in {n} files {w} واژه در {n} فایل باز‌شناسی شد + ; failed: {list} ؛ ناموفق: {list} + 1 file done ۱ فایل انجام شد + {n} files done {n} فایل انجام شد + The selected area has been copied ({w} × {h} pixels) ناحیه انتخاب‌شده کپی شد ({w} × {h} پیکسل) + Couldn't take the snapshot: {e} گرفتن تصویر لحظ‌های ممکن نشد: {e} + Couldn't recover {name}: {e} باز‌یابی {name} ممکن نشد: {e} + The document now conforms to {level}; save it to keep the changes سند اکنون با {level} منطبق است؛ برای حفظ تغییرات آن را ذخیره کنید + Fixed what could be fixed; 1 problem remains آنچه قابل رفع بود رفع شد؛ ۱ مشکل باقی مانده است + Fixed what could be fixed; {n} problems remain آنچه قابل رفع بود رفع شد؛ {n} مشکل باقی مانده است + Custom stamps arrive on the web with file pickers for images مهر‌های سفارشی در نسخه وب با انتخابگر فایل برای تصویر‌ها ارائه می‌شوند + {file} is too large for a stamp (at most {n} MB) {file} برای یک مهر بیش از حد بزرگ است (حداکثر {n} MB) + Sent to {printer} به {printer} فرستاده شد + Sent to {printer}: {msg} به {printer} فرستاده شد: {msg} + Looking for printers… در حال جستوجوی چاپگر‌ها… + Sending sheet {s} of {n} to {printer}… در حال فرستادن برگ {s} از {n} به {printer}… + No printers were found. Check that the printer is installed and the print spooler is running, then open Print again. هیچ چاپ‌گری پیدا نشد. بررسی کنید که چاپگر نصب شده و صف چاپ در حال اجراست، سپس دوباره چاپ را باز کنید. + Reading the printer's settings… در حال خواندن تنظیمات چاپگر… + Sending to {printer}… در حال فرستادن به {printer}… + Choose a folder for the searchable files یک پوشه برای فایل‌های قابل جستوجو انتخاب کنید + Files are combined in this order. Leave Pages empty to take every page. فایل‌ها به این ترتیب ترکیب می‌شوند. صفحه‌ها را خالی بگذارید تا همه صفحه‌ها گرفته شوند. + Files are combined from top to bottom. Leave Pages empty to take every page. فایل‌ها از بالا به پایین ترکیب می‌شوند. صفحه‌ها را خالی بگذارید تا همه صفحه‌ها گرفته شوند. + Unlock… باز کردن قفل… + Select a password-protected file first نخست یک فایل محافظت‌شده با گذرواژه انتخاب کنید + Enter the permissions password to allow combining گذرواژه مجوز‌ها را وارد کنید تا ترکیب مجاز شود + Enter the password that opens it گذرواژ‌های را وارد کنید که آن را باز می‌کند + Unlock {name} باز کردن قفل {name} + Unlock {n} files باز کردن قفل {n} فایل + The same password is tried on each file. گذرواژه یکسان روی هر فایل آزموده می‌شود. + Hide password پنهان کردن گذرواژه + Show password نمایش گذرواژه + Wrong password گذرواژه نادرست + The password is only used to read the file; it isn't saved. گذرواژه فقط برای خواندن فایل استفاده می‌شود؛ ذخیره نمی‌شود. + Unlock باز کردن قفل + Opened, but its security settings don't allow combining. Enter the permissions password. باز شد، اما تنظیمات امنیتی آن ترکیب را مجاز نمی‌کند. گذرواژه مجوز‌ها را وارد کنید. + Unlocked: the combined file won't be password-protected قفل باز شد: فایل ترکیب‌شده محافظت‌شده با گذرواژه نخواهد بود + Unlocked {n} of {m} files قفل {n} از {m} فایل باز شد + Drop PDFs, images, text files or folders here, or choose them. فایل‌های PDF، تصویر‌ها، فایل‌های متنی یا پوشه‌ها را اینجا رها کنید، یا آن‌ها را انتخاب کنید. + Add folder… افزودن پوشه… + Add folder and subfolders… افزودن پوشه و زیرپوشه‌ها… + More ways to add files راه‌های بیشتر برای افزودن فایل + Every PDF, image and text file in the folder همه فایل‌های PDF، تصویر و متنی در پوشه + Every PDF, image and text file in the folder and the folders inside it همه فایل‌های PDF، تصویر و متنی در پوشه و پوشه‌های داخل آن + Add folder افزودن پوشه + Add folder and subfolders افزودن پوشه و زیرپوشه‌ها + No PDFs, images or text files found in {folder} هیچ فایل PDF، تصویر یا متنی در {folder} پیدا نشد + Added the first {n} files in {folder} نخستین {n} فایل در {folder} افزوده شد +comment menu Edit ویرایش + Undo {action} واگردانی {action} + Redo {action} ازنو انجام دادن {action} +signature pad Type تایپ +certificate Subject دارنده +action wizard Start شروع + Click to sort, drag to move the column. Drag its edge to resize it. برای مرتب‌سازی کلیک کنید، برای جابه‌جایی ستون آن را بکشید. برای تغییر انداز‌هاش لبه آن را بکشید. + Reset columns باز‌نشانی ستون‌ها + Remove {n} files حذف {n} فایل + {n} selected {n} انتخاب‌شده + sorted ascending مرتب‌شده صعودی + sorted descending مرتب‌شده نزولی + Protected: the combined file won't keep its security settings محافظت‌شده: فایل ترکیب‌شده تنظیمات امنیتی خود را حفظ نخواهد کرد + Add open documents افزودن سند‌های باز + All open documents همه سند‌های باز + No documents are open هیچ سندی باز نیست + Select a file first نخست یک فایل انتخاب کنید + 1 file ۱ فایل + {n} files {n} فایل + 1 file needs attention ۱ فایل نیاز به توجه دارد + {n} files need attention {n} فایل نیاز به توجه دارند + {name} can't be combined: {why} {name} ترکیب نمی‌شود: {why} + Check the pages of {name}: {why} صفحه‌های {name} را بررسی کنید: {why} + Add the files to combine فایل‌هایی را که می‌خواهید ترکیب کنید بی‌فزایید + File name نام فایل + Warnings هشدار‌ها + Drag to reorder برای مرتب‌سازی مجدد بکشید + {k} of {n} {k} از {n} + ? of {n} ؟ از {n} + Added more than once بیش از یک بار افزوده شده + yesterday دیروز + {n} days ago {n} روز پیش + No pages selected هیچ صفح‌های انتخاب نشده + Password-protected محافظت‌شده با گذرواژه + Its security settings don't allow copying pages تنظیمات امنیتی آن کپی صفحه‌ها را مجاز نمی‌کند + Can't be read: {e} خوانده نمی‌شود: {e} + Leave out files that can't be read? فایل‌هایی که خوانده نمی‌شوند کنار گذاشته شوند؟ + These files can't be read, so they won't be in the combined PDF: این فایل‌ها خوانده نمی‌شوند، بنابراین در PDF ترکیب‌شده نخواهند بود: + Combine the rest ترکیب بقیه + and {n} more و {n} مورد دیگر + Includes unsaved changes شامل تغییرات ذخیره‌نشده + Pages of {name} to combine, e.g. 1-3, 6 صفحه‌های {name} برای ترکیب، مثلاً ۱-۳، ۶ + Remove {name} حذف {name} + Add files… افزودن فایل‌ها… + Add at least two files دستکم دو فایل بی‌فزایید + Couldn't add {name}: {e} افزودن {name} ممکن نشد: {e} + Couldn't combine files: {e} ترکیب فایل‌ها ممکن نشد: {e} + Export comments برونبری یادداشت‌ها + Export form data برونبری داده‌های فرم + Comment and form data داده‌های یادداشت و فرم + Export برونبری + Select data files to merge فایل‌های داده برای ادغام را انتخاب کنید + Form data and PDF forms داده‌های فرم و فرم‌های PDF + Save the spreadsheet ذخیره صفحهگسترده + Save the accessibility report ذخیره گزارش دسترس‌پذیری + Save image ذخیره تصویر + Export certificate برونبری گواهی + Choose a folder for the extracted pages یک پوشه برای صفحه‌های استخراج‌شده انتخاب کنید + Choose a folder for the split files پوشه ای برای فایل‌های تقسیم‌شده انتخاب کنید + Couldn't use {name}: {e} استفاده از {name} ممکن نشد: {e} + Couldn't extract pages: {e} استخراج صفحه‌ها ممکن نشد: {e} + Couldn't write {name}: {e} نوشتن {name} ممکن نشد: {e} + the name is not a plain file name نام یک نام فایل ساده نیست + Wrote 1 file to {dir} ۱ فایل در {dir} نوشته شد + Wrote {n} files to {dir} {n} فایل در {dir} نوشته شد + No pages match those choices هیچ صفحه ای با آن گزینه‌ها مطابقت ندارد + Couldn't split: {e} تقسیم ممکن نشد: {e} + Couldn't summarize comments: {e} خلاصه‌کردن یادداشت‌ها ممکن نشد: {e} + Couldn't open the result: {e} باز کردن نتیجه ممکن نشد: {e} + Importing data arrives on the web with file pickers for data files ورود داده در نسخه وب با انتخابگر فایل برای فایل‌های داده ارائه می‌شود + Exported to {path} به {path} خروجی گرفته شد + Merging data files needs the desktop app ادغام فایل‌های داده نیاز‌مند برنامه رومیزی است + Merged 1 file into {path} ۱ فایل در {path} ادغام شد + Merged {n} files into {path} {n} فایل در {path} ادغام شد + Combined {n} files {n} فایل ترکیب شد + Extracted 1 page ۱ صفحه استخراج شد + Extracted {n} pages {n} صفحه استخراج شد + Created a comment summary خلاصه یادداشت‌ها ساخته شد + Couldn't open {name}: {e} باز کردن {name} ممکن نشد: {e} + Couldn't read {name}: {e} خواندن {name} ممکن نشد: {e} + Portable mode is off: {folder} can't be written ({error}). Settings are kept in your user folder instead. حالت قابل حمل خاموش است: نوشتن در {folder} ممکن نیست ({error}). در عوض تنظیمات در پوشه کاربر شما نگهداری می‌شود. + PdfCraft is drawing without a graphics processor, so it may be slow. Updating the graphics driver may help. PdfCraft بدون پردازنده گرافیکی رسم می‌کند، بنابراین ممکن است کند باشد. به‌روز‌رسانی درایور گرافیکی می‌تواند کمک کند. + Couldn't save: {e} ذخیره ممکن نشد: {e} + Downloading attachments on the web arrives with M3.10 ({n} bytes ready) دانلود پیوست‌ها در نسخه وب با M3.10 ارائه می‌شود ({n} بایت آماده) + `{command}` {when} `{command}` {when} + ships in milestone {m} در نقطهعطف {m} ارائه می‌شود + needs an AI provider (off by default) نیاز‌مند یک ارائهدهنده هوش مصنوعی است (به‌طور پیش‌فرض خاموش) + is available در دسترس است + is not available yet هنوز در دسترس نیست + {label} failed: {e} {label} ناموفق بود: {e} + Undid {label} {label} واگردانی شد + Redid {label} {label} ازنو انجام شد + A document needs at least one page: copy instead یک سند به حداقل یک صفحه نیاز دارد: در عوض رونوشت بگیرید + Cut {what} {what} بریده شد + Copied {what} {what} رونوشت شد + 1 page ۱ صفحه + {n} pages {n} صفحه + Copy or cut pages first ابتدا صفحه‌ها را رونوشت یا ببرید + Couldn't save {name}: {e} ذخیره {name} ممکن نشد: {e} + Saved {name} {name} ذخیره شد + Saved, but reopening failed: {e} ذخیره شد، اما باز‌گشایی مجدد ناموفق بود: {e} + Downloaded {name} {name} دانلود شد + Couldn't download {name}: {e} دانلود {name} ممکن نشد: {e} + Reverted to the last saved version به آخرین نسخه ذخیره‌شده باز‌گردانده شد + That didn't work: an internal error stopped it ({m}). Your documents are unchanged. این کار نشد: یک خطای داخلی آن را متوقف کرد ({m}). اسناد شما تغییری نکرده اند. + Nothing to undo چیزی برای واگردانی نیست + Nothing to redo چیزی برای ازنو انجام دادن نیست + This document has no form fields you can fill in این سند هیچ فیلد فرمی ندارد که بتوانید پر کنید + This document has no comments to flatten این سند هیچ یادداشتی برای مسطح‌کردن ندارد + This document has no form fields to flatten این سند هیچ فیلد فرمی برای مسطح‌کردن ندارد + There are no redaction marks (mark text, areas or pages first) هیچ علامت سیاهکردنی وجود ندارد (ابتدا متن، ناحیه یا صفحه‌ها را علامت بزنید) + This document has no {kind} to change این سند هیچ {kind} برای تغییر ندارد + header or footer سرصفحه یا پاصفحه + watermark واترمارک + background پس‌زمینه + This document isn't password-protected این سند با گذرواژه محافظت نشده است + Enter permissions password… گذرواژه مجوز‌ها را وارد کنید… + Permissions password گذرواژه مجوز‌ها + “{name}” has security restrictions. Enter its permissions password to lift them. «{name}» محدودیت‌های امنیتی دارد. برای برداشتن آن‌ها گذرواژه مجوز‌هایش را وارد کنید. + Restrictions lifted محدودیت‌ها برداشته شد + Couldn't unlock {name}: {e} باز کردن قفل {name} ممکن نشد: {e} + This document has no restrictions to lift این سند هیچ محدودیتی برای برداشتن ندارد + The document's security settings don't allow this change تنظیمات امنیتی سند این تغییر را مجاز نمی‌داند + The document's security settings don't allow copying text and images تنظیمات امنیتی سند رونوشت متن و تصویر را مجاز نمی‌داند + The document's security settings don't allow printing تنظیمات امنیتی سند چاپ را مجاز نمی‌داند + Open a document first ابتدا یک سند باز کنید + Security will be removed when you save با ذخیره کردن، امنیت برداشته می‌شود + Click text or an image to edit it روی یک متن یا تصویر کلیک کنید تا ویرایشش کنید + Drag a rectangle to create a link; double-click a link to edit it برای ساختن پیوند یک مستطیل بکشید؛ برای ویرایش پیوند روی آن دوبار کلیک کنید + There are no redaction marks to apply هیچ علامت سیاه کردنی برای اعمال وجود ندارد + Drag to draw the area where the signature should appear. بکشید تا ناحیه ای که امضا باید در آن ظاهر شود رسم کنید. + Couldn't fit the visible content: {e} جا دادن محتوای قابل مشاهده ممکن نشد: {e} + Drag a rectangle around the area to copy دور ناحی‌های که می‌خواهید رونوشت بگیرید یک مستطیل بکشید + Click on the page to add a {tool}, or drag to set its size برای افزودن {tool} روی صفحه کلیک کنید، یا برای تعیین انداز‌هاش بکشید + Drag a rectangle on a page to crop it; double-click a page for Set Page Boxes برای برش صفحه، روی آن یک مستطیل بکشید؛ برای «تعیین کادر‌های صفحه» روی یک صفحه دوبار کلیک کنید + Keep tool selected: on نگه داشتن ابزار انتخاب‌شده: روشن + Keep tool selected نگه داشتن ابزار انتخاب‌شده + Opacity کدری + Line thickness ضخامت خط + What word or phrase would you like to search for? چه واژه یا عبارتی را می‌خواهید جستوجو کنید؟ + Searching… {s} of {p} pages در حال جستوجو… {s} از {p} صفحه + (searching… {s} of {p} pages) (در حال جستوجو… {s} از {p} صفحه) + 1 instance ۱ مورد + {n} instances {n} مورد + Go to this result رفتن به این نتیجه + Highlight برجسته‌سازی + Draw رسم + Polygon چند‌ضلعی + Connected lines خطوط متصل + Eraser پاککن + Sign امضا کردن + Certified by {by} گواهی‌شده توسط {by} + At least one signature is invalid. حداقل یک امضا نامعتبر است. + Signed and all signatures are valid. امضا شده و همه امضا‌ها معتبرند. + At least one signature has problems. حداقل یک امضا مشکل دارد. + This document is secured. Some changes are restricted by its security settings. این سند محافظت شده است. برخی تغییر‌ها توسط تنظیمات امنیتی آن محدود شدهاند. + This is a dynamic XFA form, which PdfCraft can't display yet. What you see is the file's placeholder page. این یک فرم XFA پویاست که PdfCraft هنوز نمی‌تواند نمایش دهد. آنچه می‌بینید صفحه جانگهدار فایل است. + This form also contains XFA data, which PdfCraft doesn't read yet. You can fill its fields, but Acrobat may show the XFA values instead. این فرم همچنین داده XFA دارد که PdfCraft هنوز نمی‌خواند. می‌توانید فیلد‌هایش را پر کنید، اما Acrobat ممکن است به‌جای آن مقادیر XFA را نشان دهد. + This document contains {n} interactive form fields. این سند {n} فیلد فرم تعاملی دارد. + This file was damaged and has been repaired. Saving keeps the repaired version. این فایل آسیب دیده بود و ترمیم شده است. ذخیره کردن نسخه ترمیم‌شده را حفظ می‌کند. + Dismiss بستن + Hide field highlights پنهان‌کردن برجسته‌سازی فیلد‌ها + Highlight fields برجسته‌سازی فیلد‌ها + Security settings تنظیمات امنیت + Details جزئیات + Select (V) انتخاب (V) + Hand (H) دست (H) + Delete pages (Delete) حذف صفحه‌ها (Delete) + Insert a blank page after the selection درج یک صفحه خالی پس از انتخاب + Insert pages from a file… درج صفحه‌ها از یک فایل… + Extract pages to a new document استخراج صفحه‌ها به یک سند جدید + Split into files… تقسیم به فایل‌ها… + Select pages انتخاب صفحه‌ها + Cut برش + Copy رونوشت + Paste after چسباندن پس از + Rename تغییر نام + Set to current page ({n}) تعیین به‌عنوان صفحه فعلی ({n}) + Move up بالا بردن + Move down پایین بردن + Indent افزایش تورفتگی + Outdent کاهش تورفتگی + This document has no form fields. این سند هیچ فیلد فرمی ندارد. + Tab order: move a field with its arrows. ترتیب تب: یک فیلد را با فلش‌هایش جابهجا کنید. + Unplaced جاینگرفته + Has JavaScript actions (run in M6) دارای کنش‌های JavaScript است (در M6 اجرا می‌شوند) + Page صفحه + None هیچکدام + Delete selected fields حذف فیلد‌های انتخاب‌شده + Diagnostic details جزئیات تشخیصی + The operation failed. عملیات ناموفق بود. + no such document چنین سندی وجود ندارد + nothing to undo چیزی برای واگردانی نیست + nothing to redo چیزی برای ازنو انجام دادن نیست + this document is signed: rewriting it would invalidate its signatures (save it incrementally instead) این سند امضا شده است: باز‌نویسی آن امضا‌هایش را بی‌اعتبار می‌کند (در عوض آن را به‌صورت افزایشی ذخیره کنید) + the document is protected by a password سند با گذرواژه محافظت شده است + the password is incorrect گذرواژه نادرست است + the document has no page tree سند هیچ درخت صفح‌های ندارد + a document must keep at least one page یک سند باید حداقل یک صفحه را حفظ کند + a bookmark cannot be moved inside itself نشانک را نمی‌توان داخل خودش جابهجا کرد + the title must not be empty عنوان نباید خالی باشد + the document has no form fields سند هیچ فیلد فرمی ندارد + there are no redaction marks to apply هیچ علامت سیاهکردنی برای اعمال وجود ندارد + there are no pages to print هیچ صفح‌های برای چاپ وجود ندارد + the document has no tags سند هیچ برچسبی ندارد + {0}: {1} {0}: {1} + the text recognition models are not installed (reinstall PdfCraft, or set PDFCRAFT_MODELS to the folder that holds them) مدل‌های تشخیص متن نصب نشدهاند (PdfCraft را دوباره نصب کنید، یا PDFCRAFT_MODELS را روی پوش‌های که آن‌ها را نگه می‌دارد تنظیم کنید) + the image is empty تصویر خالی است + this file isn't XFDF, FDF, XML or text form data این فایل داده فرم XFDF، FDF، XML یا متنی نیست + nothing in the file matches this document هیچچیز در فایل با این سند مطابقت ندارد + This page couldn't be displayed.\n{e} نمایش این صفحه ممکن نشد.\n{e} + Go to page {p} رفتن به صفحه {p} + {s} action کنش {s} + Page {p} of {n} صفحه {p} از {n} + 1 page selected ۱ صفحه انتخاب شد + {n} pages selected {n} صفحه انتخاب شد + {name} — unsaved changes {name} — تغییر‌های ذخیره‌نشده + {kind} field فیلد {kind} + Select a file for the stamp یک فایل برای مهر انتخاب کنید + JavaScript, e.g. getField("total").value JavaScript، مثلاً getField("total").value + Font: Helvetica قلم: Helvetica + e.g. en-US مثلاً fa-IR + Rendering: hayro (bootstrap) · UI: egui · Icons: Lucide (ISC) · Fonts: Inter, JetBrains Mono, Dancing Script (OFL) رندر: hayro (bootstrap) · رابط کاربری: egui · آیکون‌ها: Lucide (ISC) · قلم‌ها: Inter, JetBrains Mono, Dancing Script (OFL) + CheckBox کادر انتخاب + Radio دکمه رادیویی + PushButton دکمه فشاری + Combo فهرست باز‌شو + List فهرست + Unknown ناشناخته + The document has no XMP metadata سند هیچ فراداده XMP ندارد + The title in the metadata and the document information differ عنوان در فراداده و اطلاعات سند متفاوتاند + The document is encrypted سند رمز‌گذاری شده است + Device colour is used, but there is no PDF/A output intent از رنگ دستگاه استفاده می‌شود، اما هیچ هدف خروجی PDF/A وجود ندارد + DeviceCMYK is used: it needs a CMYK output intent (not added automatically) از DeviceCMYK استفاده می‌شود: به یک هدف خروجی CMYK نیاز دارد (به‌طور خودکار افزوده نمی‌شود) + DeviceCMYK is used, but the PDF/A output intent isn't a CMYK profile از DeviceCMYK استفاده می‌شود، اما هدف خروجی PDF/A یک نمایه CMYK نیست + DeviceRGB is used, but the PDF/A output intent isn't an RGB profile از DeviceRGB استفاده می‌شود، اما هدف خروجی PDF/A یک نمایه RGB نیست + The document has additional actions (/AA) سند کنش‌های افزودنی دارد (/AA) + The document has document-level JavaScript سند JavaScript در سطح سند دارد + The form asks viewers to make appearances (NeedAppearances) فرم از نمایشگر‌ها می‌خواهد ظاهر‌ها را بسازند (NeedAppearances) + A form field has additional actions یک فیلد فرم کنش‌های افزودنی دارد + A stream uses LZW compression یک جریان از فشرده‌سازی LZW استفاده می‌کند + A stream refers to an external file یک جریان به یک فایل خارجی ارجاع می‌دهد + An image asks to be interpolated یک تصویر درخواست درون‌یابی دارد + An image has alternates or OPI information یک تصویر جانشین‌ها یا اطلاعات OPI دارد + PostScript XObjects aren't allowed XObjectهای PostScript مجاز نیستند + Embedded files must be PDF/A files themselves (use PDF/A-3 for others) فایل‌های جا‌سازی‌شده خودشان باید فایل PDF/A باشند (برای سایرین از PDF/A-3 استفاده کنید) + An embedded file has no AFRelationship یک فایل جا‌سازی‌شده AFRelationship ندارد + Select Icon انتخاب آیکون + Choose images انتخاب تصویر‌ها + {dpi} pixels/inch {dpi} پیکسل بر اینچ + Type your {what}. {what} خود را تایپ کنید. + Draw your {what} below. {what} خود را در پایین رسم کنید. + Your name نام شما + Images and text (converted to PDF) تصویر‌ها و متن (تبدیل‌شده به PDF) + Images and text تصویر‌ها و متن + Check Box Style: سبک کادر انتخاب: + Button Style: سبک دکمه: + Check تیک + Circle دایره + Diamond لوزی + Square مربع + Star ستاره + {month} {y} {month} {y} + Change {key} تغییر {key} + Insert pages from {name} درج صفحه‌ها از {name} + Insert pages from {name} — 1 field renamed درج صفحه‌ها از {name} — ۱ فیلد تغییر نام یافت + Insert pages from {name} — fields renamed درج صفحه‌ها از {name} — فیلد‌ها تغییر نام یافتند + Fill in {name} پر کردن {name} + Set the image of {name} تعیین تصویر {name} + Edit script of {name} ویرایش اسکریپت {name} + Import {name} درون‌ریزی {name} + Add bookmark افزودن نشانک + Rename bookmark تغییر نام نشانک + Delete bookmark حذف نشانک + Move bookmark جابه‌جایی نشانک + Set bookmark destination تعیین مقصد نشانک + Add stamp افزودن مهر + Delete comment حذف یادداشت + Edit comment ویرایش یادداشت + Erase پاک کردن + Lock comment قفل کردن یادداشت + Unlock comment باز کردن قفل یادداشت + Move comment جابه‌جایی یادداشت + Resize comment تغییر اندازه یادداشت + Change comment properties تغییر ویژگی‌های یادداشت + Add field افزودن فیلد + Change field properties تغییر ویژگی‌های فیلد + Delete field حذف فیلد + Duplicate field تکثیر فیلد + Set tab order تعیین ترتیب تب + Set calculation order تعیین ترتیب محاسبه + Change initial view تغییر نمای اولیه + Set alternate text تعیین متن جانشین + Mark figure as decorative علامت‌گذاری تصویر به‌عنوان تزئینی + Run JavaScript اجرای JavaScript + Delete document JavaScript حذف JavaScript سند + Edit document JavaScript ویرایش JavaScript سند + Edit text ویرایش متن + Load document font بار‌گذاری قلم سند + Move image جابه‌جایی تصویر + Rotate image چرخاندن تصویر + Flip image وارونه کردن تصویر + Delete image حذف تصویر + Add header & footer افزودن سرصفحه و پاصفحه + Update header & footer به‌روز‌رسانی سرصفحه و پاصفحه + Update watermark به‌روز‌رسانی واترمارک + Add background افزودن پس‌زمینه + Update background به‌روز‌رسانی پس‌زمینه + Add image افزودن تصویر + Edit content ویرایش محتوا + Delete content حذف محتوا + Remove redaction marks برداشتن علامت‌های سیاه‌کردن + Add link افزودن پیوند + Change link properties تغییر ویژگی‌های پیوند + Delete link حذف پیوند + Create link ساختن پیوند + Flatten مسطح‌سازی + Flatten Fill & Sign مسطح‌سازی پر کردن و امضا + Add sticky note افزودن یادداشت چسبان + Add highlight افزودن برجسته‌سازی + Add underline افزودن زیرخط + Add strikethrough افزودن خط خوردگی + Add squiggly underline افزودن زیرخط موجدار + Add rectangle افزودن مستطیل + Add oval افزودن بیضی + Add arrow افزودن پیکان + Add line افزودن خط + Add drawing افزودن ترسیم + Add text box افزودن جعبه متن + Add checkmark افزودن تیک + Add cross افزودن ضربدر + Add dot افزودن نقطه + Add signature افزودن امضا + Add redaction mark افزودن علامت سیاه‌کردن + Add cloud افزودن ابر + Add polygon افزودن چند‌ضلعی + Add connected lines افزودن خطوط متصل + Add callout افزودن فراخوان + Add inserted text افزودن متن درج‌شده + Add file attachment افزودن پیوست فایل + Set status Accepted تعیین وضعیت پذیرفته‌شده + Set status Rejected تعیین وضعیت رد‌شده + Set status Cancelled تعیین وضعیت لغو‌شده + Set status Completed تعیین وضعیت تکمیل‌شده + Set status None تعیین وضعیت هیچکدام + Set status Unmarked تعیین وضعیت علامتنخورده + Save as PDF/A-1b ذخیره به‌عنوان PDF/A-1b + Save as PDF/A-2b ذخیره به‌عنوان PDF/A-2b + Save as PDF/A-3b ذخیره به‌عنوان PDF/A-3b + Fill & Sign: change signature پر کردن و امضا: تغییر امضا + Fill & Sign: change initials پر کردن و امضا: تغییر حروف اول + Fill & Sign: remove saved signature پر کردن و امضا: برداشتن امضای ذخیره‌شده + Fill & Sign: remove saved initials پر کردن و امضا: برداشتن حروف اول ذخیره‌شده + Change signature تغییر امضا + Change initials تغییر حروف اول + Measure distance اندازه‌گیری فاصله + Measure perimeter اندازه‌گیری محیط + Measure area اندازه‌گیری مساحت + Set measurement scale تعیین مقیاس اندازه‌گیری + Measurement information اطلاعات اندازه‌گیری + Measurement snapping چسبیدن اندازه‌گیری + Export measurements as CSV خروجی اندازه‌گیری‌ها به‌صورت CSV + Distance فاصله + Perimeter محیط + Area مساحت + Drawing scale مقیاس ترسیم + Scale مقیاس + Common scales مقیاس‌های رایج + Enter a scale such as 1:50. مقیاسی مانند ۱:۵۰ وارد کنید. + Applying a scale switches units to m / mm. اعمال مقیاس، واحد‌ها را به m / mm تغییر می‌دهد. + Snapping چسبیدن + Export measurements خروجی اندازه‌گیری‌ها + Open a PDF to measure it. برای اندازه‌گیری آن یک PDF باز کنید. + Click vertices. Enter or double-click finishes a perimeter or area. Click the first point to close an area. روی رأس‌ها کلیک کنید. Enter یا دوبار کلیک یک محیط یا مساحت را تمام می‌کند. برای بستن یک مساحت روی نقطه اول کلیک کنید. + Shift constrains to 45 degrees. Backspace removes a vertex. Escape cancels. Shift به ۴۵ درجه محدود می‌کند. Backspace یک رأس را برمی‌دارد. Escape لغو می‌کند. + Angle زاویه + Click the first point to begin. برای شروع روی نقطه اول کلیک کنید. + Snap to drawing چسبیدن به ترسیم + Endpoints نقاط انت‌هایی + Midpoints نقاط میانی + Intersections تقاطع‌ها + Paths مسیر‌ها + Sensitivity حساسیت + Unit واحد + Decimals اعشار + Calibrate from two points کالیبره کردن از دو نقطه + Entire page تمام صفحه + Viewport rectangle in page points (x, y, width, height) مستطیل نمایشگر در نقاط صفحه (x، y، عرض، ارتفاع) + Viewport نمایشگر + Apply scale اعمال مقیاس + Saved measurements اندازه‌گیری‌های ذخیره‌شده + Saved measurements. اندازه‌گیری‌های ذخیره‌شده. + Endpoint نقطه انت‌هایی + Midpoint نقطه میانی + Intersection تقاطع + Path مسیر + This drawing exceeds the snapping limit; only the first paths are used. این ترسیم از حد چسبیدن فرا‌تر می‌رود؛ فقط مسیر‌های اول استفاده می‌شوند. + Some page content couldn't be read; snapping uses the rest. بخشی از محتوای صفحه خوانده نشد؛ چسبیدن از بقیه استفاده می‌کند. + Unsupported measurements اندازه‌گیری‌های پشتیبانی‌نشده + Label برچسب + {n} measurements use an unsupported format and are left out. {n} اندازه‌گیری از یک قالب پشتیبانی‌نشده استفاده می‌کنند و کنار گذاشته می‌شوند. + This area has too many overlapping paths; intersection snapping is limited. این ناحیه مسیر‌های هم‌پوشان بیش از حد دارد؛ چسبیدن به تقاطع‌ها محدود است. + This measurement needs at least {n} points. این اندازه‌گیری به حداقل {n} نقطه نیاز دارد. + Change {what} تغییر {what} + Cut and stack برش و رویهمچینی + Default workspace mode حالت پیش‌فرض فضای کاری + Documents and view سند‌ها و نمایش + Open email app باز کردن برنامهٔ ایمیل + Open link باز کردن پیوند + Open this web page? این صفحهٔ وب باز شود؟ + Scripts run in a sandbox without file or network access. A script that asks to open a web page needs your permission first. With JavaScript off, Acrobat's standard format, validate and calculate functions still work. اسکریپت‌ها در محیطی ایزوله و بدون دسترسی به فایل یا شبکه اجرا می‌شوند. اسکریپتی که درخواست باز کردن یک صفحهٔ وب را دارد، ابتدا به اجازهٔ شما نیاز دارد. با خاموش بودن JavaScript، توابع استاندارد قالب‌بندی، اعتبارسنجی و محاسبهٔ Acrobat همچنان کار می‌کنند. + Sign yourself خودتان امضا کنید + Used when opening PDFs. An explicit launch or control mode takes precedence for the session. هنگام باز کردن PDFها استفاده می‌شود. حالت اجرا یا کنترل صریح برای آن نشست اولویت دارد. + Write this email? این ایمیل نوشته شود؟ + {who} in this document wants to open this address in your browser: {who} در این سند می‌خواهد این نشانی را در مرورگر شما باز کند: + {who} in this document wants to open your email app with this address: {who} در این سند می‌خواهد برنامهٔ ایمیل شما را با این نشانی باز کند: + This web address mixes letters from different alphabets, a common way to imitate another site's name. این نشانی وب حروفی از الفبا‌های مختلف را با هم می‌آمیزد؛ روشی رایج برای جعل نام سایت دیگر. + This web address uses letters from another alphabet, which can look like familiar ones. این نشانی وب از حروف الفبای دی‌گری استفاده می‌کند که می‌توانند شبیه حروف آشنا به نظر برسند. + Create PDF from images ساخت PDF از تصاویر + Selected image files: {count} فایل‌های تصویری انتخاب‌شده: {count} + Use image resolution استفاده از وضوح تصویر + Use each image's embedded DPI; use 72 DPI when it is absent. از DPI جا‌سازی‌شدهٔ هر تصویر استفاده کن؛ اگر نبود، ۷۲ DPI به‌کار ببر. + Use 72 DPI (one point per pixel) استفاده از ۷۲ DPI (یک نقطه بر هر پیکسل) + Use custom DPI استفاده از DPI سفارشی + DPI sets the printed page size without resampling the image. DPI اندازهٔ صفحهٔ چاپی را بدون نمونهبرداری مجدد تصویر تعیین می‌کند. + Create ساخت + Dynamic XFA form laid out from its template: {pages}, {fields}. فرم XFA پویا که از قالب آن چیده شده است: {pages}، {fields}. + This form's scripts are turned off because one ran too long. Reopen the document to run them again. اسکریپت‌های این فرم خاموش شدهاند چون یکی از آن‌ها بیش از حد طول کشید. سند را دوباره باز کنید تا دوباره اجرا شوند. + XFA form: its fields and its XFA data are kept in step, so other viewers show what you fill in. فرم XFA: فیلد‌ها و داده‌های XFA آن هم‌گام نگه داشته می‌شوند، تا سایر نمایشگر‌ها آنچه را پر می‌کنید نشان دهند. +@plural {n} page|{n} pages {n} صفحه|{n} صفحه +@plural {n} field|{n} fields {n} فیلد|{n} فیلد +@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} نام فیلد با فایل‌های قبلی تداخل دارد و تغییر نام داده می‌شود: {list}|{n} نام فیلد با فایل‌های قبلی تداخل دارند و تغییر نام داده می‌شوند: {list} + Switch to two-page view first to show the cover page برای نمایش صفحهٔ جلد، ابتدا به نمایش دوصفح‌های بروید + Page display: {layout} نمایش صفحه: {layout} + Fit to width scrolling پیمایش متناسب با عرض + Fit one full page تناسب یک صفحهٔ کامل + Default page display نمایش صفحهٔ پیش‌فرض + Default zoom بزرگنمایی پیش‌فرض + Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. وقتی PDF طرح‌بندی یا بزرگنمایی خاصی درخواست نمی‌کند استفاده می‌شود. پیمایش پیوسته هرگز بین صفحه‌ها نمی‌پرد. + Show نمایش + Sort مرتب‌سازی + Reverse the order معکوس کردن ترتیب + Grab bag متفرقه + Table جدول + Username نام کاربری + Display name نام نمایشی + Real name نام واقعی + Name (A–Z) نام (A–Z) + Merged PRs PRهای ادغام‌شده + PRs PRها + Commits کامیت‌ها + Lines added خطوط افزوده‌شده + +LOC +LOC + Lines deleted خطوط حذف‌شده + −LOC −LOC + Line delta دلتای خطوط + ΔLOC ΔLOC + Binary assets added دارایی‌های دودویی افزوده‌شده + +Bin +Bin + Binary assets removed دارایی‌های دودویی حذف‌شده + −Bin −Bin + First commit نخستین کامیت + First نخستین + Last commit آخرین کامیت + Last آخرین + No contributor data was built into this copy. هیچ داد‌های از هم‌کاران در این نسخه گنجانده نشده است. + No model credits were built into this copy. هیچ اعتبار مدلی در این نسخه گنجانده نشده است. + {n} contributors · {c} commits {n} هم‌کار · {c} کامیت + Company شرکت + Model مدل + % of all commits ٪ از همهٔ کامیت‌ها + Lines +/− خطوط +/− + @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last} @{login}: {prs} PR، {commits} کامیت، +{added} / −{deleted} خط (Δ {delta})، +{bin_added} / −{bin_deleted} دارایی دودویی، {first} – {last} + Reopen the files that were open when PdfCraft last closed باز‌گشایی فایل‌هایی که هنگام آخرین بسته شدن PdfCraft باز بودند + Restore the last view (page, zoom and page display) when a document is opened again باز‌یابی آخرین نما (صفحه، بزرگنمایی و نمایش صفحه) هنگام باز شدن دوبارهٔ سند + Add {what} افزودن {what} + Use {what} استفاده از {what} + Place saved {what} قرار دادن {what} ذخیره‌شده + Remove saved {what} حذف {what} ذخیره‌شده + A link یک پیوند + A button یک دکمه + A script یک اسکریپت + {who} in this document tried to open an address PdfCraft won't open: {e}. {who} در این سند تلاش کرد نشانیای را باز کند که PdfCraft باز نمی‌کند: {e}. + {who} in this document tried to open an address PdfCraft won't open: {e}. Only web (http, https) and email (mailto) links open from documents. {who} در این سند تلاش کرد نشانیای را باز کند که PdfCraft باز نمی‌کند: {e}. از میان سند‌ها فقط پیوند‌های وب (http، https) و ایمیل (mailto) باز می‌شوند. + Another file dialog is still open. Finish with it first. یک پنجرهٔ گفتوگوی فایل دیگر هنوز باز است. ابتدا آن را تمام کنید. + Couldn't show the file picker. Please try again. انتخابگر فایل نمایش داده نشد. لطفاً دوباره تلاش کنید. + The document changed while you were choosing a file, so nothing was changed. سند در حین انتخاب فایل تغییر کرد، بنابراین چیزی تغییر نکرد. + The document was closed before it could be saved. سند پیش از آنکه ذخیره شود بسته شد. + The pages were extracted but not deleted, because the document changed while you were choosing a folder. صفحه‌ها استخراج شدند اما حذف نشدند، زیرا سند در حین انتخاب پوشه تغییر کرد. + That didn't work: an internal error stopped it ({m}). انجام نشد: یک خطای داخلی آن را متوقف کرد ({m}). + The Windows store's digital IDs couldn't be listed: {e} شناسه‌های دیجیتال مخزن Windows فهرست نشدند: {e} + Align fields هم‌ترازی فیلد‌ها + Distribute fields توزیع فیلد‌ها + Match field sizes یکسان‌سازی اندازهٔ فیلد‌ها + Mark for redaction علامت‌گذاری برای سیاه‌کردن + Mark pages for redaction علامت‌گذاری صفحه‌ها برای سیاه‌کردن + Change document properties تغییر ویژگی‌های سند + Mark differences علامت‌گذاری تفاوت‌ها + Run form script اجرای اسکریپت فرم + Scripted content must be usable with a keyboard and assistive technology. Check the document's scripts. محتوای اسکریپتی باید با صفحه کلید و فناوری‌های کمکی قابل استفاده باشد. اسکریپت‌های سند را بررسی کنید. + Table rows belong directly in a table or its head, body or foot. Fix the table's tags. سطر‌های جدول مستقیماً به جدول یا سر، بدنه یا پای آن تعلق دارند. برچسب‌های جدول را اصلاح کنید. + Basic constraints محدودیت‌های پایه + SHA-1 digest چکیده SHA-1 + SHA-256 digest چکیده SHA-256 + Squiggly موجدار + Home / End Home / End + H / Space (hold) H / فاصله (نگهداشتن) + Reload documents when their files change on disk بار‌گذاری مجدد سند‌ها وقتی فایل‌هایشان روی دیسک تغییر می‌کنند + Don't show again دیگر نشان نده + Show a notice when a document is secured نمایش اعلان وقتی سند محافظت شده است + Always use my default zoom and page layout همیشه از بزرگنمایی و طرح‌بندی صفحهٔ پیش‌فرض من استفاده کن + This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. این فایل روی دیسک تغییر کرد. «بار‌گذاری مجدد» نسخهٔ جدید را نشان می‌دهد و تغییرات ذخیره‌نشدهٔ شما را از دست می‌دهد؛ «ذخیره» آن را با این نسخه جایگزین می‌کند. + Reload بار‌گذاری مجدد + Couldn't reload {name}: {e} بار‌گذاری مجدد {name} ممکن نشد: {e} + Created a PDF from {n} files یک PDF از {n} فایل ساخته شد + PDF, images and text PDF، تصاویر و متن + Insert a file before page {label} درج یک فایل پیش از صفحهٔ {label} + Insert a file at the end درج یک فایل در پایان + Save pages ذخیرهٔ صفحه‌ها + Save these pages as one PDF ذخیرهٔ این صفحه‌ها به‌صورت یک PDF + No changes to save تغییری برای ذخیره نیست + Created a PDF from {n} files; left out: {names} یک PDF از {n} فایل ساخته شد؛ کنار گذاشته شد: {names} + Created a PDF from the first {n} files یک PDF از {n} فایل نخست ساخته شد + None of those files can be made into a PDF; use PDFs, images or .txt files هیچ یک از آن فایل‌ها را نمی‌توان به PDF تبدیل کرد؛ از فایل‌های PDF، تصویر یا .txt استفاده کنید + Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. یک تصویر PNG یا JPEG انتخاب کنید (حداکثر ۴ MiB). شفافیت حفظ می‌شود. + Couldn't import the signature image: {e} تصویر امضا وارد نشد: {e} + Go to page… رفتن به صفحه… + About درباره + Contributors هم‌کاران + Models مدل‌ها + Only continue if you trust this document. An address can carry information from the document, such as what you typed into its form. فقط اگر به این سند اعتماد دارید ادامه دهید. یک نشانی می‌تواند اطلاعاتی از سند را با خود حمل کند، مانند آنچه در فرم آن تایپ کردهاید. + Print single-sided. Keep the sheets in order, cut at the marks, then stack the piles left to right, top to bottom. یکرو چاپ کنید. برگه‌ها را به ترتیب نگه دارید، روی علامت‌ها ببرید، سپس دسته‌ها را از چپ به راست و از بالا به پایین روی هم بگذارید. + For page sizes and margins. برای اندازه‌های صفحه و حاشیه‌ها. + The PDF text fonts can't draw “{c}” ({code}). Choose supported text. قلم‌های متن PDF نمی‌توانند «{c}» را بکشند ({code}). متن پشتیبانی‌شده انتخاب کنید. + The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. قلم‌های استاندارد نمی‌توانند «{c}» را بکشند ({code}). فعلاً فقط نویسه‌های اروپای غربی می‌توانند به‌عنوان متن افزوده شوند. + This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. قلم این فیلد نمی‌تواند «{c}» را بکشد ({code})، بنابراین PdfCraft به‌جای آن ? نشان می‌دهد. مقدار ذخیره می‌شود و سایر نمایشگر‌های PDF فیلد را از آن باز‌ترسیم می‌کنند. + The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. قلم‌هایی که PdfCraft با آن‌ها می‌نویسد نمی‌توانند «{c}» را بکشند ({code})، بنابراین در همهٔ نمایشگر‌های PDF به‌صورت ? نمایش داده می‌شود. متن ذخیره می‌شود. + Open arrow پیکان باز + Closed arrow پیکان بسته + Butt انت‌های صاف + Reverse open arrow پیکان باز معکوس + Reverse closed arrow پیکان بستهٔ معکوس + Slash اسلش + Larger pages صفحه‌های بزرگ‌تر + Smaller pages صفحه‌های کوچک‌تر + Reset page size باز‌نشانی اندازهٔ صفحه + Date format قالب تاریخ + Fill & Sign dates تاریخ‌های Fill & Sign + Pattern الگو + Today: {date} امروز: {date} + yyyy or yy year · m or mm month · mmm or mmmm month name · d or dd day · ddd or dddd weekday. H, h, M, s and t are time letters and can't be used on their own: put {escape} before a letter to show it as is. yyyy یا yy سال · m یا mm ماه · mmm یا mmmm نام ماه · d یا dd روز · ddd یا dddd روز هفته. H، h، M، s و t حروف زمان هستند و نمی‌توانند بهتن‌هایی استفاده شوند: برای نمایش حرف به همان صورت، {escape} را پیش از آن بگذارید. + Same as interface ({language}) یکسان با رابط کاربری ({language}) + {chars} can't be written into the PDF yet: Fill & Sign text is Western European only. Pick a numeric format such as dd.mm.yyyy. {chars} هنوز نمی‌تواند در PDF نوشته شود: متن Fill & Sign فقط اروپای غربی است. قالبی عددی مانند dd.mm.yyyy انتخاب کنید. + Opens the printer driver's preferences. They are saved for every print from this computer; the copies, two-sided, grayscale and paper chosen here still apply. ترجیحات درایور چاپگر را باز می‌کند. آن‌ها برای هر چاپ از این رایانه ذخیره می‌شوند؛ تعداد نسخه، دورو، مقیاس خاکستری و کاغذ انتخاب‌شده در اینجا همچنان اعمال می‌شوند. + The printer driver's own settings for this print: paper tray, paper type, finishing and more. تنظیمات خود درایور چاپگر برای این چاپ: سینی کاغذ، نوع کاغذ، تکمیل و موارد دیگر. + Couldn't open the printer's preferences: {e} ترجیحات چاپگر باز نشد: {e} + Printer properties ویژگی‌های چاپگر + Reset to printer defaults باز‌نشانی به پیش‌فرض‌های چاپگر + This printer offers no settings of its own. این چاپگر تنظیمات اختصاصی خود را ارائه نمی‌دهد. + Scroll tabs left پیمایش زبانه‌ها به چپ + Scroll tabs right پیمایش زبانه‌ها به راست + Shared Field Properties ویژگی‌های مشترک فیلد‌ها + {n} fields selected {n} فیلد انتخاب‌شده + Mixed متفاوت + Custom font قلم سفارشی + Only checked properties will change. Mixed means the selected fields have different values. فقط ویژگی‌های علامتخورده تغییر می‌کنند. «متفاوت» یعنی فیلد‌های انتخاب‌شده مقادیر متفاوتی دارند. + Shift- or Ctrl/Command-click to select several fields. برای انتخاب چند فیلد، با Shift یا Ctrl/Command کلیک کنید. + Font size 0 uses automatic sizing. اندازهٔ قلم ۰ از اندازه‌گذاری خودکار استفاده می‌کند. + Character limit: محدودیت نویسه: + Character limit 0 means no limit. A comb of characters needs a limit. محدودیت نویسهٔ ۰ یعنی بدون محدودیت. شانهٔ نویسه‌ها به محدودیت نیاز دارد. + To change locked fields, also set Locked to No. برای تغییر فیلد‌های قفل‌شده، «قفل‌شده» را هم روی «خیر» بگذارید. + Comb of characters شانهٔ نویسه‌ها + The document changed while you were editing field properties. سند در حین ویرایش ویژگی‌های فیلد تغییر کرد. + The document is no longer open. سند دیگر باز نیست. + The field properties dialog is no longer open. پنجرهٔ ویژگی‌های فیلد دیگر باز نیست. + The field properties could not be changed. ویژگی‌های فیلد تغییر نکردند. + The selected fields could not be found. Select them again. فیلد‌های انتخاب‌شده یافت نشدند. دوباره آن‌ها را انتخاب کنید. + The field {name} no longer exists. فیلد {name} دیگر وجود ندارد. + A selected field has no widget on any page, so the fields can't be edited together. یک فیلد انتخاب‌شده در هیچ صفحه ای ویجت ندارد، بنابراین فیلد‌ها را نمی‌توان با هم ویرایش کرد. + ({n} at {dpi} dpi, the largest size they can be drawn at) ({n} در {dpi} dpi، بزرگ‌ترین انداز‌های که می‌توانند در آن کشیده شوند) + The security settings don't allow screen readers to read the text تنظیمات امنیتی اجازه نمی‌دهند صفحه خوان‌ها متن را بخوانند + No page has text: the document appears to be images of pages هیچ صفحه ای متن ندارد: به نظر می‌رسد سند تصویر‌هایی از صفحه‌ها باشد + The document has no tags (structure tree) سند برچسبی ندارد (درخت ساختار) + The document isn't marked as tagged (MarkInfo Marked) سند به‌عنوان برچسبدار علامت‌گذاری نشده است (MarkInfo Marked) + No document language is set زبان سند تعیین نشده است + The document has no title سند عنوانی ندارد + The window shows the file name, not the title (Initial View > Show) پنجره نام فایل را نشان می‌دهد، نه عنوان را (نمای اولیه > نمایش) + {n} pages and no bookmarks {n} صفحه و بدون نشانک + The document isn't tagged, so none of its content is سند برچسبدار نیست، بنابراین هیچ یک از محتوای آن نیز برچسب ندارد + Page {p}: {n} untagged content item صفحهٔ {p}: {n} مورد محتوای بدون برچسب + Page {p}: {n} untagged content items صفحهٔ {p}: {n} مورد محتوای بدون برچسب + Form field on page {p} isn't tagged فیلد فرم در صفحهٔ {p} برچسبدار نیست + {} annotation on page {p} isn't tagged یادداشت {} در صفحهٔ {p} برچسبدار نیست + Page {p} doesn't tab in structure order صفحهٔ {p} به ترتیب ساختار پیمایش زبانه نمی‌شود + Font {} on page {p} doesn't map its characters to Unicode قلم {} در صفحهٔ {p} نویسه‌هایش را به Unicode نگاشت نمی‌کند + Font (unnamed) on page {p} doesn't map its characters to Unicode قلم (بینام) در صفحهٔ {p} نویسه‌هایش را به Unicode نگاشت نمی‌کند + Field "{}" on page {p} has no description (tooltip) فیلد «{}» در صفحهٔ {p} توضیحی ندارد (راهنمای ابزار) + {ty} element: {what} عنصر {ty}: {what} + {ty} element on page {p}: {what} عنصر {ty} در صفحهٔ {p}: {what} + no alternate text بدون متن جایگزین + its alternate text is inside another element's and will never be read متن جایگزین آن داخل متن عنصری دیگر است و هرگز خوانده نمی‌شود + alternate text on an element with no page content متن جایگزین روی عنصری بدون محتوای صفحه + alternate text hides an annotation inside it متن جایگزین یادداشتی را در داخل خود پنهان می‌کند + not in a Table, THead, TBody or TFoot داخل Table، THead، TBody یا TFoot نیست + not in a TR داخل TR نیست + no header cells (TH) بدون سلول‌های سرصفحه (TH) + rows have between {min} and {max} columns سطر‌ها بین {min} و {max} ستون دارند + no summary بدون خلاصه + not in an L (list) داخل L (فهرست) نیست + not in an LI (list item) داخل LI (مورد فهرست) نیست + the first heading is an H{n}; headings should not skip levels نخستین سرفصل یک H{n} است؛ سرفصل‌ها نباید سطوح را جا بی‌ندازند + H{n} follows an H{d}; headings should not skip levels H{n} بعد از H{d} می‌آید؛ سرفصل‌ها نباید سطوح را جا بی‌ندازند + … and {n} more … و {n} مورد دیگر + Accessibility Report گزارش دسترس‌پذیری + Filename نام فایل + Report created گزارش ایجاد شد + The checker found problems which may prevent the document from being fully accessible. بررسی‌کننده مشکلاتی یافت که ممکن است مانع دسترس‌پذیری کامل سند شوند. + Detailed Report گزارش تفصیلی + Rule Name نام قاعده + The metadata doesn't identify the file as {} فراداده فایل را به‌عنوان {} شناسایی نمی‌کند + Page {} has additional actions صفحهٔ {} اقدام‌های اضافی دارد + A {} action isn't allowed اقدام {} مجاز نیست + {} annotations aren't allowed یادداشت‌های {} مجاز نیستند + A {} annotation isn't set to print, or is hidden یادداشت {} برای چاپ تنظیم نشده، یا پنهان است + A {} annotation has no appearance stream یادداشت {} جریان ظاهری ندارد + A {} annotation has additional actions یادداشت {} اقدام‌های اضافی دارد + The font {} isn't embedded قلم {} جا‌سازی نشده است + A font isn't embedded قلمی جا‌سازی نشده است + The signature has no valid byte range. امضا باز‌های از بایت‌های معتبر ندارد. + The signature's byte range does not match the file. بازهٔ بایت‌های امضا با فایل مطابقت ندارد. + The signature's byte range does not exclude exactly its contents. بازهٔ بایت‌های امضا دقیقاً محتوای آن را مستثنا نمی‌کند. + The signature contents are not hexadecimal. محتوای امضا هگزادسیمال نیست. + The signature could not be read ({}). امضا خوانده نشد ({}). + PdfCraft can't check this signature yet: {}. PdfCraft هنوز نمی‌تواند این امضا را بررسی کند: {}. + The signature uses the legacy adbe.x509.rsa_sha1 format. امضا از قالب قدیمی adbe.x509.rsa_sha1 استفاده می‌کند. + The signature is BER-encoded (indefinite lengths) rather than DER; it was read leniently. امضا به‌صورت BER رمز‌گذاری شده است (طول‌های نامعین) نه DER؛ با اغماض خوانده شد. + The signed content is a constructed (segmented) OCTET STRING, as BER allows; it was read anyway. محتوای امضا‌شده یک OCTET STRING ساخته‌شده (بخشبخش) است، همان‌طور که BER اجازه می‌دهد؛ به هرحال خوانده شد. + The RSA signature value does not have the length of the key's modulus (leading zero bytes dropped or added); it was read anyway. مقدار امضای RSA طول پیمان‌های کلید را ندارد (بایت‌های صفر ابتدایی حذف یا افزوده شدهاند)؛ به هرحال خوانده شد. + The RSA signature's DigestInfo has no NULL parameter (older signers write it so); it was read anyway. DigestInfo امضای RSA پارامتر NULL ندارد (امضاکنندگان قدیمی‌تر آن را چنین می‌نویسند)؛ به هرحال خوانده شد. + The ECDSA signature is not in canonical DER (trailing bytes, padding or a missing sign byte); it was read anyway. امضای ECDSA در DER متعارف نیست (بایت‌های انت‌هایی، پرشدگی یا نبود بایت علامت)؛ بههرحال خوانده شد. + The ECDSA signature is the raw r and s values instead of a DER sequence; it was read anyway. امضای ECDSA مقادیر خام r و s است نه یک دنبالهٔ DER؛ بههرحال خوانده شد. + The signature algorithm and the SignerInfo's digest algorithm name different hashes; the signature was made with the SignerInfo's. الگوریتم امضا و الگوریتم چکیدهٔ SignerInfo هش‌های متفاوتی را نام می‌برند؛ امضا با هش SignerInfo ساخته شده است. + The signature's signed attributes have no content type, which CMS requires; it was read anyway. ویژگی‌های امضا‌شدهٔ امضا نوع محتوا ندارند، که CMS آن را الزامی می‌داند؛ بههرحال خوانده شد. + This signature uses SHA-1, a weak hash algorithm: collisions in it have been demonstrated. It still validates because many documents signed years ago use it, but it should not be relied on. این امضا از SHA-1 استفاده می‌کند، یک الگوریتم هش ضعیف: برخورد‌ها در آن به اثبات رسیده است. همچنان معتبر است چون بسیاری از اسناد امضا‌شده سال‌ها پیش از آن استفاده می‌کنند، اما نباید به آن اعتماد کرد. + This signature uses RIPEMD-160, a legacy 160-bit hash algorithm that is no longer recommended for signatures. It still validates, but it should not be relied on. این امضا از RIPEMD-160 استفاده می‌کند، یک الگوریتم هش قدیمی ۱۶۰ بیتی که دیگر برای امضا توصیه نمی‌شود. همچنان معتبر است، اما نباید به آن اعتماد کرد. + This signature uses an RSA key shorter than 2048 bits, which is no longer considered strong enough for signatures. It still validates, but it should not be relied on. این امضا از یک کلید RSA کوتاه‌تر از ۲۰۴۸ بیت استفاده می‌کند که دیگر برای امضا‌ها بهاندازهٔ کافی قوی در نظر گرفته نمی‌شود. همچنان معتبر است، اما نباید به آن اعتماد کرد. + The signature's signed attributes have no message digest, so the signature does not cover the document. ویژگی‌های امضا‌شدهٔ امضا چکیدهٔ پیام ندارند، بنابراین امضا سند را پوشش نمی‌دهد. + The signature's signed attributes name a content type other than data, so it is not a signature of the document. ویژگی‌های امضا‌شدهٔ امضا نوع محتوایی غیر از داده را نام می‌برند، بنابراین امضای سند نیست. + The document has been altered or corrupted since the signature was applied. سند از زمان اعمال امضا تغییر کرده یا خراب شده است. + The signer's certificate is not in the signature. گواهی امضا‌کننده در امضا نیست. + The signature value does not match the signer's certificate: the signature is corrupt. مقدار امضا با گواهی امضا‌کننده مطابقت ندارد: امضا خراب است. + The signature could not be checked ({}). امضا بررسی نشد ({}). + The signature could not be verified ({}). امضا تأیید نشد ({}). + This document has not been modified since this signature was applied. این سند از زمان اعمال این امضا تغییر نکرده است. + The document has been modified since this signature was applied, but the changes are permitted ({}). سند از زمان اعمال این امضا تغییر کرده است، اما تغییرات مجاز هستند ({}). + The document has been altered since this signature was applied in ways it does not permit ({}). سند از زمان اعمال این امضا به روش‌هایی تغییر کرده که مجاز نمی‌داند ({}). + The signer's certificate was not valid at the time of signing. گواهی امضا‌کننده در زمان امضا معتبر نبود. + The signer's certificate does not allow digital signatures (its key usage has neither digital signature nor non-repudiation). گواهی امضا‌کننده امضا‌های دیجیتال را مجاز نمی‌داند (کاربرد کلید آن نه امضای دیجیتال دارد و نه انکارنا‌پذیری). + The signer's certificate is not issued for signing documents (its extended key usage has no document-signing purpose). گواهی امضا‌کننده برای امضای اسناد صادر نشده است (کاربرد کلید گستردهٔ آن هیچ هدف امضای سند ندارد). + The signer's certificate has critical extensions PdfCraft does not recognize ({}). گواهی امضا‌کننده افزونه‌های حیاتی دارد که PdfCraft آن‌ها را نمی‌شناسد ({}). + The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. گواهی {} یک گواهی CA که بتواند گواهی صادر کند نیست، بنابراین برای تأیید {} استفاده نمی‌شود. + The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. گواهی {} افزونه‌های حیاتی دارد که PdfCraft آن‌ها را نمی‌شناسد، بنابراین برای تأیید {} استفاده نمی‌شود. + The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. گواهی {} این تعداد گواهی CA در زیر خود را مجاز نمی‌داند (طول مسیر)، بنابراین برای تأیید {} استفاده نمی‌شود. + The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. گواهی {} در زمان امضا معتبر نبود، بنابراین برای تأیید {} استفاده نمی‌شود. + The signer's identity is unknown because it has not been included in your list of trusted certificates and none of its parent certificates are trusted certificates. هویت امضا‌کننده ناشناخته است زیرا در فهرست گواهی‌های مورد اعتماد شما گنجانده نشده و هیچیک از گواهی‌های والد آن گواهی مورد اعتماد نیستند. + The signer's identity is valid. هویت امضا‌کننده معتبر است. + The signature includes an embedded timestamp. امضا شامل یک مهر زمانی جا‌سازی‌شده است. + Signing time is from the clock on the signer's computer. زمان امضا از ساعت رایانهٔ امضا‌کننده گرفته شده است. + The signature's timestamp token does not cover the signature value. نشانهٔ مهر زمانی امضا مقدار امضا را پوشش نمی‌دهد. + The signature's timestamp token could not be verified ({}). نشانهٔ مهر زمانی امضا تأیید نشد ({}). + The embedded timestamp token is valid and its authority is trusted; trusted time is {}. نشانهٔ مهر زمانی جا‌سازی‌شده معتبر است و مرجع آن مورد اعتماد است؛ زمان مورد اعتماد {} است. + The signature carries an unverified timestamp ({}): the timestamp authority is not in your list of trusted certificates, so the signing time claimed by the signer is used instead. امضا یک مهر زمانی تأیید‌نشده حمل می‌کند ({}): مرجع مهر زمانی در فهرست گواهی‌های مورد اعتماد شما نیست، بنابراین در عوض از زمان امضایی که امضا‌کننده ادعا می‌کند استفاده می‌شود. + The signer's certificate has been revoked ({}). گواهی امضا‌کننده باطل شده است ({}). + The document's embedded revocation information shows that the signer's certificate has not been revoked. اطلاعات ابطال جا‌سازی‌شده در سند نشان می‌دهد که گواهی امضا‌کننده باطل نشده است. + The timestamp has no valid byte range. مهر زمانی بازهٔ بایت‌های معتبر ندارد. + The timestamp's byte range does not match the file. بازهٔ بایت‌های مهر زمانی با فایل مطابقت ندارد. + The timestamp's byte range does not exclude exactly its contents. بازهٔ بایت‌های مهر زمانی دقیقاً محتوای آن را مستثنا نمی‌کند. + The timestamp contents are not hexadecimal. محتوای مهر زمانی هگزادسیمال نیست. + The timestamp token could not be read ({}). نشانهٔ مهر زمانی خوانده نشد ({}). + The document has been altered or corrupted since the timestamp was applied. سند از زمان اعمال مهر زمانی تغییر کرده یا خراب شده است. + The timestamp authority's certificate was not valid at the time of timestamping. گواهی مرجع مهر زمانی در زمان مهر زدن معتبر نبود. + The timestamp authority's certificate is not issued for time stamping. گواهی مرجع مهر زمانی برای مهر زدن زمان صادر نشده است. + This document has not been modified since this timestamp was applied. این سند از زمان اعمال این مهر زمانی تغییر نکرده است. + The document has been modified since this timestamp was applied, but the changes are permitted ({}). سند از زمان اعمال این مهر زمانی تغییر کرده است، اما تغییرات مجاز هستند ({}). + The document has been altered since this timestamp was applied in ways it does not permit ({}). سند از زمان اعمال این مهر زمانی به روش‌هایی تغییر کرده که مجاز نمی‌داند ({}). + The timestamp token is valid and its authority is trusted. نشانهٔ مهر زمانی معتبر است و مرجع آن مورد اعتماد است. + The timestamp token is valid, but the timestamp authority is not in your list of trusted certificates. نشانهٔ مهر زمانی معتبر است، اما مرجع مهر زمانی در فهرست گواهی‌های مورد اعتماد شما نیست. + Unsigned signature field فیلد امضای امضا‌نشده + Signature is valid امضا معتبر است + Signature validity is unknown اعتبار امضا ناشناخته است + Signature is invalid امضا نامعتبر است + page content محتوای صفحه + form fill پرشدن فرم + comments یادداشت‌ها + document security store مخزن امنیت سند + metadata فراداده + links پیوند‌ها + document structure ساختار سند + pages added or removed صفحه‌های افزوده یا حذف‌شده + other changes تغییرات دیگر + the signed version could not be read نسخهٔ امضا‌شده خوانده نشد + page {n} is out of range (1–{count}) صفحهٔ {n} خارج از محدوده است (۱–{count}) + {page} is not a page number or label {page} یک شماره یا برچسب صفحه نیست + Change Title تغییر عنوان + Change Author تغییر نویسنده + Change Subject تغییر موضوع + Change Keywords تغییر کلیدواژه‌ها + Save as {level} ذخیره به‌صورت {level} + Remove {standard} identification حذف شناسهٔ {standard} + Create links ایجاد پیوند‌ها +@plural {n} object selected|{n} objects selected {n} شیء انتخاب‌شده|{n} شیء انتخاب‌شده + Drag the selection to move it. Esc clears the selection. برای جابه‌جایی، انتخاب را بکشید. Esc انتخاب را پاک می‌کند. + Shift-click to select several objects. Drag empty space to select an area. برای انتخاب چند شیء، با Shift کلیک کنید. برای انتخاب یک ناحیه، فضای خالی را بکشید. + Select at most 1000 objects at a time. حداکثر ۱۰۰۰ شیء را در یک زمان انتخاب کنید. + Finish or discard the text being edited before selecting several objects. پیش از انتخاب چند شیء، ویرایش متن را تمام کنید یا رهایش کنید. + Move object جابه‌جایی شیء + Move objects جابه‌جایی اشیا + Vertical text cannot be moved with this selection. متن عمودی را نمی‌توان با این انتخاب جابهجا کرد. + Text used as a clipping path cannot be moved with this selection. متنی که به‌عنوان مسیر برش استفاده شده را نمی‌توان با این انتخاب جابهجا کرد. + These objects could not be moved. Select them again and try a smaller move. این اشیا جابه جا نشدند. دوباره آن‌ها را انتخاب کنید و جابه‌جایی کوچکتری را امتحان کنید. + This page contains objects that cannot be selected together. این صفحه شامل اشیایی است که نمی‌توان آن‌ها را با هم انتخاب کرد. + Fill colour رنگ پر‌کردن + No fill بدون پرشدگی + Fill colour: none رنگ پر‌کردن: هیچ +key Ctrl Ctrl +key Shift Shift +key Delete Delete +key Esc Esc +key Home Home +key End End +key Space فاصله + Grid view نمای شبکه ای + List view نمای فهرستی + Files are combined in the order shown. Drag them to change it. فایل‌ها به ترتیب نمایش‌داده‌شده ترکیب می‌شوند. برای تغییر آن، آن‌ها را بکشید. + Preview unavailable: {e} پیش‌نمایش در دسترس نیست: {e} + Pages: {k} of {n} صفحه‌ها: {k} از {n} + Check the pages صفحه‌ها را بررسی کنید + Switch to List view to change the pages it takes. برای تغییر صفحه‌هایی که برمی‌دارد، به نمای فهرستی بروید. + Expand all گسترش همه + Collapse all جمع کردن همه + Collapse all pages جمع کردن همهٔ صفحه‌ها + Expand all pages گسترش همهٔ صفحه‌ها + Remove page حذف صفحه + Remove {n} pages حذف {n} صفحه + Reset panel layout باز‌نشانی چیدمان پنل‌ها + Float panel شناور کردن پنل + Move to group انتقال به گروه + Panels پنل‌ها + Resize panels تغییر اندازهٔ پنل‌ها + Resize panel window تغییر اندازهٔ پنجرهٔ پنل + Tools ابزار‌ها + Inspector بازرس + PDF page: {size} صفحهٔ PDF: {size} + Match page at 100% تناسب کاغذ با صفحه در ۱۰۰٪ + PDF scale: {scale}% مقیاس PDF: {scale}% + Printer paper and scaling may differ. Check driver settings. کاغذ و مقیاس چاپگر ممکن است متفاوت باشد. تنظیمات درایور را بررسی کنید. + Drawing scale changes. Use Actual size for full-scale plans. مقیاس نقشه تغییر می‌کند. برای نقشه‌های با اندازه کامل از «اندازه واقعی» استفاده کنید. + Select page {label} انتخاب صفحه {label} + Rotate page {label} counterclockwise چرخش صفحه {label} در خلاف جهت عقربه‌های ساعت + Rotate page {label} clockwise چرخش صفحه {label} در جهت عقربه‌های ساعت + Delete page {label} حذف صفحه {label} + Scroll speed (mouse wheel and arrow keys) سرعت پیمایش (چرخ ماوس و کلید‌های جهت) +scroll speed Slow کند +scroll speed Normal عادی +scroll speed Fast تند + Animation effects جلوه‌های پویانمایی +animation effects On روشن +animation effects Off خاموش + Enhance camera image بهبود تصویر دوربین + Drag the corners onto the document's edges, then choose Enhance page. گوشه‌ها را روی لبه‌های سند بکشید، سپس «بهبود صفحه» را انتخاب کنید. + Enhance page بهبود صفحه + Done adding text پایان افزودن متن + Discard this text (Esc) کنار گذاشتن این متن (Esc) + Certificate authority مرجع گواهی + End entity موجودیت نهایی + Clean-up پاک‌سازی + Original اصلی + Greyscale خاکستری + Black and white سیاه و سفید + This printer's driver doesn't take two-sided settings from other programs, so its own setting applies. Change it in Properties… درایور این چاپگر تنظیمات دوطرفه را از برنامه‌های دیگر نمی‌پذیرد، بنابراین تنظیم خودش اعمال می‌شود. آن را در «ویژگی‌ها…» تغییر دهید… + Browser's print dialog پنجره چاپ مرورگر + Copies, two-sided printing and colour are chosen in the browser's print dialog. تعداد نسخه‌ها، چاپ دوطرفه و رنگ در پنجره چاپ مرورگر انتخاب می‌شوند. + Preparing {n} sheets for printing… آماده‌سازی {n} برگ برای چاپ… + Preparing sheet {done} of {total} for printing… آماده‌سازی برگ {done} از {total} برای چاپ… + Opening the browser's print dialog… باز کردن پنجره چاپ مرورگر… + Couldn't print from the browser ({e}); downloaded {name} to print instead چاپ از مرورگر ممکن نشد ({e})؛ در عوض {name} برای چاپ بار‌گیری شد + Tools panel side سمت پنل ابزار‌ها + Add comment افزودن یادداشت + Change tag properties تغییر ویژگی‌های برچسب + Tags panel پنل برچسب‌ها + Tags برچسب‌ها + Tag type نوع برچسب + Actual text متن واقعی + Find a tag یافتن برچسب + Collapse tag جمع‌کردن برچسب + Expand tag گستردن برچسب + Revert changes باز‌گرداندن تغییرات + Save tag properties ذخیره ویژگی‌های برچسب + This document has no editable structure tags. این سند هیچ برچسب ساختاری قابل ویرایشی ندارد. + {n} tags {n} برچسب + Save or revert changes before selecting another tag. پیش از انتخاب برچسبی دیگر، تغییرات را ذخیره یا باز‌گردانید. + Left out: {names} کنار گذاشته‌شده: {names} + already a PDF از قبل PDF است + no unused output name نام خروجی بدون استفاده موجود نیست + no output folder was chosen هیچ پوشه خروجی انتخاب نشد +@plural Downloaded {n} PDF|Downloaded {n} PDFs {n} PDF بار‌گیری شد|{n} PDF بار‌گیری شد +@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} محاسبه اکنون از نام جدید استفاده می‌کند.|{n} محاسبه اکنون از نام جدید استفاده می‌کنند. +@plural The calculation order has {n} entry that calculates nothing. Fix order removes it.|The calculation order has {n} entries that calculate nothing. Fix order removes them. ترتیب محاسبه {n} مورد دارد که چیزی محاسبه نمی‌کند. «اصلاح ترتیب» آن را حذف می‌کند.|ترتیب محاسبه {n} مورد دارد که چیزی محاسبه نمی‌کنند. «اصلاح ترتیب» آن‌ها را حذف می‌کند. +@plural {fields} reads it|{fields} read it {fields} آن را می‌خواند|{fields} آن را می‌خوانند +@plural {n} field shows an old value|{n} fields show old values {n} فیلد مقدار قدیمی نشان می‌دهد|{n} فیلد مقدار‌های قدیمی نشان می‌دهند +@plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} پیش از {fields} محاسبه می‌شود، در حالی که آن را می‌خواند.|{field} پیش از {fields} محاسبه می‌شود، در حالی که آن‌ها را می‌خواند. +@plural {n} more field calculates too early.|{n} more fields calculate too early. {n} فیلد دیگر زود‌تر از موعد محاسبه می‌شود.|{n} فیلد دیگر زود‌تر از موعد محاسبه می‌شوند. +@plural {fields} is missing from the order saved in the PDF, so other PDF viewers may not calculate it.|{fields} are missing from the order saved in the PDF, so other PDF viewers may not calculate them. {fields} در ترتیب ذخیره‌شده در PDF وجود ندارد، بنابراین سایر نمایشگر‌های PDF ممکن است آن را محاسبه نکنند.|{fields} در ترتیب ذخیره‌شده در PDF وجود ندارند، بنابراین سایر نمایشگر‌های PDF ممکن است آن‌ها را محاسبه نکنند. + Scan اسکن + Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. روی یک برگ کاغذ سفید امضا کنید و از آن در نور روشن و یکنواخت عکس بگیرید (از فلاش استفاده کنید). عکس را انتخاب کنید یا آن را اینجا رها کنید: PdfCraft امضا را پیدا می‌کند و کاغذ را حذف می‌کند. + Choose photo… انتخاب عکس… + No photo chosen هیچ عکسی انتخاب نشده + Photo عکس + Ink sensitivity حساسیت جوهر + No signature was found in the frame. Move the frame over the signature or raise the ink sensitivity. هیچ امضایی در کادر یافت نشد. کادر را روی امضا ببرید یا حساسیت جوهر را بالا ببرید. + Couldn't use the photo: {e} استفاده از عکس ممکن نشد: {e} + Close Tab بستن زبانه + Reopen Closed Tab باز‌گشایی زبانه بسته‌شده + Reveal in Finder نمایش در Finder + Reveal in File Explorer نمایش در File Explorer + Other colour… رنگ دیگر… + Recent colours رنگ‌های اخیر + Reading colours رنگ‌های خواندن + Replace document colours جای‌گزینی رنگ‌های سند + Page background پس‌زمینه صفحه + Only change the colour of black text or line art فقط رنگ متن سیاه یا طرح‌های خطی را تغییر دهید + Change the colour of line art as well as text رنگ طرح‌های خطی و همچنین متن را تغییر دهید + Colours are remapped while rendering; the document itself never changes. رنگ‌ها هنگام نمایش باز‌تصویر می‌شوند؛ خود سند هرگز تغییر نمی‌کند. + Black on white سیاه روی سفید + White on black سفید روی سیاه + Yellow on black زرد روی سیاه + Green on black سبز روی سیاه + Couldn't follow linked PDF: {reason} پی‌گیری PDF پیوند‌شده ممکن نشد: {reason} + The linked PDF opened, but its named destination was not applied. PDF پیوند‌شده باز شد، اما مقصد نامدار آن اعمال نشد. + The linked PDF opened, but its page destination was out of range. PDF پیوند‌شده باز شد، اما مقصد صفحه آن خارج از محدوده بود. + Local cross-PDF links require the desktop app پیوند‌های محلی میان PDF به کاربردسطح رومیزی نیاز دارند + The existing action will be preserved. کنش موجود حفظ خواهد شد. + Cut Image برش تصویر + Redact Image سیاه‌کردن تصویر + Create Link… ایجاد پیوند… + Recognize Text… تشخیص متن… + Minimize کوچک‌کردن + Maximize بزرگ‌کردن + Restore باز‌گرداندن + Use system title bar استفاده از نوار عنوان سیستم + Show a basic title bar for the window buttons instead of the tab strip. GNOME doesn't draw title bars for apps, so this one doesn't follow its theme. به‌جای نوار زبانه‌ها، یک نوار عنوان ساده برای دکمه‌های پنجره نمایش دهید. GNOME برای برنامه‌ها نوار عنوان نمی‌کشد، بنابراین این یکی از پوسته آن پیروی نمی‌کند. + Allow اجازه دادن + Only allow this if you trust the source document. فقط اگر به سند منبع اعتماد دارید این را اجازه دهید. + Open in Full Screen mode? در حالت تمامصفحه باز شود؟ + This document asks to open in Full Screen mode: the page fills the screen and nothing else shows. Press Esc at any time to leave it. این سند درخواست باز شدن در حالت تمام صفحه را دارد: صفحه تمام نمایشگر را پر می‌کند و چیز دی‌گری نمایش داده نمی‌شود. هر زمان برای خروج Esc را فشار دهید. + Full Screen تمامصفحه + Not now اکنون نه + Open this local PDF? این PDF محلی باز شود؟ + This document is trying to open a local file: این سند تلاش می‌کند یک فایل محلی را باز کند: + Move tag earlier انتقال برچسب به قبل + Move tag later انتقال برچسب به بعد + Turn upside down (180°) چرخش وارونه (۱۸۰°) + 4 x 6 in label (101.6 x 152.4 mm) برچسب 4 x 6 in (101.6 x 152.4 mm) + Cuts each page to its printed area, turns it upright and fits it to the sheet without stretching. One label per sheet. هر صفحه را به ناحیه چاپ شد‌ه اش می‌برد، آن را مستقیم می‌کند و بدون کشیدگی روی برگ جا می‌دهد. یک برچسب در هر برگ. + Find untagged content یافتن محتوای بدون برچسب + No untagged content was found. هیچ محتوای بدون برچسبی یافت نشد. + Found {n} untagged content problems. {n} مشکل محتوای بدون برچسب یافت شد. + Tag form fields برچسب‌گذاری فیلد‌های فرم + Mark as artifact علامت‌گذاری به‌عنوان قطعه + Remove this leaf tag from reading order and mark its direct content as an artifact این برچسب برگ را از ترتیب خواندن حذف کنید و محتوای مستقیم آن را به‌عنوان قطعه علامت‌گذاری کنید + Mark content as artifact علامت‌گذاری محتوا به‌عنوان قطعه + Word count شمارش واژه‌ها + Current page only فقط صفحه کنونی + Exclude tagged tables کنار گذاشتن جدول‌های برچسبدار + Exclude tagged code blocks کنار گذاشتن بلوک‌های کد برچسبدار + Exclude image/OCR text کنار گذاشتن متن تصویر/OCR + Exclusions use PDF tags. Untagged tables and code remain included. Image exclusion also removes invisible text. کنارگذاشتن‌ها از برچسب‌های PDF استفاده می‌کنند. جدول‌ها و کد‌های بدون برچسب همچنان شامل می‌شوند. کنار گذاشتن تصویر متن نامرئی را نیز حذف می‌کند. + Words واژه‌ها + Characters نویسه‌ها + Characters without spaces نویسه‌ها بدون فاصله + Pages counted صفحه‌های شمرده‌شده + Some pages contain no countable text. Scanned images need OCR first. برخی صفحه‌ها هیچ متن قابل شمارشی ندارند. تصویر‌های اسک‌ن شده نخست به OCR نیاز دارند. + Some content could not be classified safely and was kept in the count. برخی محتوا به‌طور ایمن طبقه‌بندی نشد و در شمارش نگه داشته شد. + Counting words… {n} pages read شمارش واژه‌ها… {n} صفحه خوانده شد + Word counting stopped unexpectedly. شمارش واژه‌ها به‌طور غیر‌منتظره متوقف شد. + Word count… شمارش واژه‌ها… + Collapse all tools جمع‌کردن همه ابزارها + Expand all tools گسترش همه ابزارها + Create & Edit ایجاد و ویرایش + Customize & Automate شخصی‌سازی و خودکارسازی + Forms & Signatures فرم‌ها و امضاها + Protect & Standardize محافظت و استانداردسازی + Review & Compare بازبینی و مقایسه + No tools match your search. هیچ ابزاری با جست‌وجوی شما مطابقت ندارد. +@plural {n} reply|{n} replies {n} پاسخ|{n} پاسخ +@id file.word_count شمارش واژه‌ها… + Standard استاندارد + Mobile موبایل + Merge image fragments ادغام قطعه‌های تصویر + Optimizing… merging image fragments بهینه‌سازی… ادغام قطعه‌های تصویر + Side by side کنار هم + New file only فقط فایل جدید + Old file: {name} فایل قدیمی: {name} + New file: {name} فایل جدید: {name} + Print as image چاپ به‌صورت تصویر + Resolution: تفکیک‌پذیری: + Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. زمانی از آن استفاده کنید که چاپگر متن، سایه یا شفافیت را نادیده می‌گیرد. چاپ دیگر متن قابل جستوجو نیست؛ با «چاپ در مقیاس خاکستری»، تصاویر خاکستری می‌شوند. + Crop Image برش تصویر + Remove Crop حذف برش + Trim the picture with its handles; Enter or Done applies تصویر را با دستگیره‌هایش برش دهید؛ Enter یا «انجام شد» آن را اعمال می‌کند + Redaction mark appearance ظاهر نشانه سیاه‌کردن + Outline colour: رنگ خط دور: + Fill colour: رنگ پر‌کردن: + Shown while the pointer is over a mark. زمانی که اشاره گر روی یک نشانه است نمایش داده می‌شود. + Opacity: کدری: + Make properties default ویژگی‌ها را پیش‌فرض کن + Pages selected صفحه‌های انتخاب‌شده + Page properties ویژگی‌های صفحه + Font قلم + Bulleted list فهرست نشانهدار + Numbered list فهرست شمارهدار + Alignment هم‌ترازی + Superscript بالانویس + Subscript زیرنویس + Paragraph spacing فاصله بند‌ها + Less کمتر + Other options گزینه‌های دیگر + You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. شما در حال مشاهده نسخه امضا‌شده «{from}» (باز‌نگری {rev}) هستید. این یک تصویر لحظ‌های است: تغییرات اینجا بر نسخه اصلی اثر نمی‌گذارد. + You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. شما در حال مشاهده باز‌نگری {rev} از «{from}» هستید. این یک تصویر لحظ‌های است: تغییرات اینجا بر نسخه اصلی اثر نمی‌گذارد. + Attach a file… پیوست یک فایل… + Delete attachment حذف پیوست + Attaching files is not available on the web yet پیوست‌کردن فایل‌ها هنوز در وب در دسترس نیست + Attach {name} پیوست {name} + Delete attachment {key} حذف پیوست {key} + Thumbnail size اندازه بندانگشتی‌ها + Smaller thumbnails بندانگشتی‌های کوچک‌تر + Larger thumbnails بندانگشتی‌های بزرگ‌تر + Reshape comment تغییر شکل یادداشت +calendar date January ژانویه +calendar date February فوریه +calendar date March مارس +calendar date April آوریل +calendar date May مه +calendar date June ژوئن +calendar date July ژوئیه +calendar date August اوت +calendar date September سپتامبر +calendar date October اکتبر +calendar date November نوامبر +calendar date December دسامبر +calendar weekday Mo دو +calendar weekday Tu سه +calendar weekday We چه +calendar weekday Th پن +calendar weekday Fr جم +calendar weekday Sa شن +calendar weekday Su یک +certificate Issued to صادر‌شده برای +certificate Issued by صادر‌شده توسط +certificate Issuer صادر‌کننده +certificate Serial number شماره سریال +certificate Version نگارش +certificate Public key کلید عمومی +certificate Key usage کاربرد کلید +certificate Intended usage کاربرد موردنظر +certificate Basic constraints محدودیت‌های پایه +certificate Self-signed خودامضا +certificate Valid from معتبر از +certificate Valid to معتبر تا +certificate Validity starts آغاز اعتبار +certificate Validity ends پایان اعتبار +certificate SHA-1 digest چکیده SHA-1 +certificate SHA-256 digest چکیده SHA-256 + Digital Signature امضای دیجیتال + Non-Repudiation انکارنا‌پذیری + Key Encipherment رمز‌گذاری کلید + Data Encipherment رمز‌گذاری داده + Key Agreement توافق کلید + Certificate Signing امضای گواهی + CRL Signing امضای CRL + Encipher Only فقط رمز‌گذاری + Decipher Only فقط رمزگشایی + {who}, certificate issued by {issuer}. {who}، گواهی صادر‌شده توسط {issuer}. + {who}, certificate issued by an unknown issuer. {who}، گواهی صادر‌شده توسط صادرکنند‌های ناشناخته. + Rev. {rev}: Signed by {name} باز‌نگری {rev}: امضا‌شده توسط {name} + Rev. {rev}: Signed by an unknown signer باز‌نگری {rev}: امضا‌شده توسط امضاکنند‌های ناشناخته + Rev. {rev}: Certified by {name} باز‌نگری {rev}: گواهی‌شده توسط {name} + Rev. {rev}: Certified by an unknown signer باز‌نگری {rev}: گواهی‌شده توسط امضاکنند‌های ناشناخته + Unsigned signature field: {name} فیلد امضای بدون امضا: {name} + Choose a digital ID. یک شناسه دیجیتال انتخاب کنید. + There is no folder to save the digital ID in. هیچ پوش‌های برای ذخیره شناسه دیجیتال وجود ندارد. + The password is incorrect. گذرواژه نادرست است. + The password must have at least 6 characters. گذرواژه باید دستکم ۶ نویسه داشته باشد. + The password must have at least 8 characters. گذرواژه باید دستکم ۸ نویسه داشته باشد. + The passwords do not match. گذرواژه‌ها یکسان نیستند. + Only the document's owner can change its security (open it with the permissions password) فقط مالک سند می‌تواند امنیت آن را تغییر دهد (آن را با گذرواژه مجوز‌ها باز کنید) +@plural {n} byte|{n} bytes {n} بایت|{n} بایت +@plural {n} pixel|{n} pixels {n} پیکسل|{n} پیکسل +@plural {n} file|{n} files {n} فایل|{n} فایل +@plural {n} comment|{n} comments {n} یادداشت|{n} یادداشت +@plural {n} result|{n} results {n} نتیجه|{n} نتیجه +@plural {n} image optimized|{n} images optimized {n} تصویر بهینه شد|{n} تصویر بهینه شد +@plural {n} item discarded|{n} items discarded {n} مورد کنار گذاشته شد|{n} مورد کنار گذاشته شد +@plural Delete {n} field|Delete {n} fields حذف {n} فیلد|حذف {n} فیلد + Add the PDFs to combine PDFهایی را که می‌خواهید ادغام کنید اضافه کنید + Drop PDFs or folders here, or choose them. PDFها یا پوشه‌ها را اینجا رها کنید، یا آن‌ها را انتخاب کنید. + Every PDF in the folder هر PDF درون پوشه + Every PDF in the folder and the folders inside it هر PDF درون پوشه و پوشه‌های داخل آن + Added the first {n} PDFs in {folder} نخستین {n} PDF در {folder} افزوده شد + No PDFs found in {folder} هیچ PDFی در {folder} یافت نشد + Panel layout could not be restored: {error} چیدمان پنل باز‌گردانده نشد: {error} + Panel move was not applied: {error} جابه‌جایی پنل اعمال نشد: {error} + Rendering… در حال نمایش… + Still rendering — this page is unusually complex… هنوز در حال نمایش — این صفحه به‌طور غیر‌معمول پیچیده است… + Loading… در حال بار‌گذاری… + This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. این فایل روی دیسک تغییر کرده است. برای دیدن نسخه جدید آن را دوباره بار‌گذاری کنید؛ تغییرات ذخیره‌نشده شما از دست خواهد رفت. + Text recognition isn't installed: its models are missing (run `cargo xtask models`, or set PDFCRAFT_MODELS). تشخیص متن نصب نشده است: مدل‌های آن وجود ندارند (`cargo xtask models` را اجرا کنید یا PDFCRAFT_MODELS را تنظیم کنید). + Cut and stack needs Two-sided: Off. Print single-sided sheets. برش و چیدن به «دوطرفه: خاموش» نیاز دارد. برگ‌ها را یکرو چاپ کنید. + Error خطا + Warning هشدار + Information اطلاعات + Success موفق + Enter a name. نامی وارد کنید. + Enter a page number شماره صفحه وارد کنید + Enter a URL نشانی URL وارد کنید + Enter the field names نام فیلد‌ها را وارد کنید + Choose… انتخاب… + Choose a file… انتخاب یک فایل… + Choose a folder… انتخاب یک پوشه… + Create folder ایجاد پوشه + Open folder باز کردن پوشه + Save file ذخیره فایل + Save changes? تغییرات ذخیره شود؟ + Theme پوسته +theme Appearance ظاهر + System سیستم + English انگلیسی + Always همیشه + Never هرگز + Automatically به‌طور خودکار + On روشن + Enabled فعال + Disabled غیر‌فعال + Mouse wheel scroll speed سرعت پیمایش چرخ ماوس + Margins (in) حاشیه‌ها (in) + Color رنگ + Folder پوشه + Comment یادداشت + Comment and review یادداشت و باز‌بینی + Creator سازنده + Producer تولید‌کننده + Organize سازمان‌دهی + Import درون‌ریزی + Move جابه‌جایی + Duplicate تکثیر + Paste جای‌گذاری + Select all انتخاب همه + Refresh تازه‌سازی + Next بعدی + Previous قبلی + of از + No results بدون نتیجه + Search results نتایج جستوجو + Copy to clipboard رونوشت در بریدهدان + Copied to clipboard در بریده دان رونوشت شد + Help… راهنما… + on page {label} در صفحه {label} + Page {n} صفحه {n} + Page {n} of {total} صفحه {n} از {total} + Open a PDF (⌘O) باز کردن یک PDF (⌘O) + Print (⌘P) چاپ (⌘P) + Document properties (⌘D) ویژگی‌های سند (⌘D) + New bookmark (⌘B) نشانک جدید (⌘B) + Next (⌘G) بعدی (⌘G) + Previous (⇧⌘G) قبلی (⇧⌘G) + Zoom in (⌘+) بزرگنمایی (⌘+) + Zoom out (⌘−) کوچکنمایی (⌘−) + Zoom in / out (also pinch or ⌘-scroll) بزرگنمایی / کوچکنمایی (همچنین با دو انگشت یا ⌘ + پیمایش) + Rotate view clockwise (⇧⌘+) چرخش نما در جهت عقربه‌های ساعت (⇧⌘+) + Save (M4) ذخیره (M4) + Add text field table افزودن جدول فیلد‌های متنی + Text field table جدول فیلد‌های متنی + Text Field Table جدول فیلد‌های متنی + Columns ستون‌ها + Row spacing فاصله سطر‌ها + Column spacing فاصله ستون‌ها + First row number شماره نخستین سطر + Column widths پهنای ستون‌ها + Distribute evenly تقسیم یکنواخت + Field names نام فیلد‌ها + N or {row} is the row number, {col} the column number, {seq} the cell number. With none of them, the row number goes last. N یا {row} شماره سطر است، {col} شماره ستون و {seq} شماره خانه. اگر هیچکدام نباشد، شماره سطر در آخر می‌آید. + Column {n} ستون {n} + Add fields افزودن فیلد‌ها + a table has 1 to {max} rows یک جدول از ۱ تا {max} سطر دارد + a table has 1 to {max} columns یک جدول از ۱ تا {max} ستون دارد + the first row number can be at most {max} شماره نخستین سطر حداکثر می‌تواند {max} باشد + the area, the spacing and the column widths must be numbers, the spacing 0 or more and each width more than 0 ناحیه، فاصله و پهنای ستون‌ها باید عدد باشند، فاصله ۰ یا بیشتر و هر پهنا بیشتر از ۰ + the rows are too short: make the area taller, or use fewer rows or less row spacing سطر‌ها بیش از حد کوتاه اند: ناحیه را بلند‌تر کنید، یا سطر‌های کمتر یا فاصله سطر کمتری به کار ببرید + a column is too narrow: make the area wider, give the column more of it, or use fewer columns or less column spacing یک ستون بیش از حد باریک است: ناحیه را پهن‌تر کنید، سهم بیشتری به ستون بدهید، یا ستون‌های کمتر یا فاصله ستون کمتری به کار ببرید + field names can't contain a period: {name} نام فیلد‌ها نمی‌تواند شامل نقطه باشد: {name} + two cells would both be named {name} دو خانه هر دو با نام {name} نام‌گذاری می‌شوند + a field named {name} already exists فیلدی با نام {name} از قبل وجود دارد + Today امروز diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index ed3fa534a2..ccdd14dc01 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -178,6 +178,15 @@ Available Käytettävissä Ready Valmis Search tools and commands… Hae työkaluja ja komentoja… + +# Accessible names for inputs that have no visible label. + Added text Lisätty teksti + Link URL Linkin URL-osoite + Search tools and commands Hae työkaluja ja komentoja + Text editing line Tekstin muokkausrivi + Text to place Sijoitettava teksti + Tooltip Työkaluvihje + Words or phrases to redact Peitettävät sanat tai lauseet No matching tools Ei vastaavia työkaluja Tool Työkalu Command Komento @@ -2706,6 +2715,14 @@ certificate SHA-256 digest SHA-256-tiiviste Make properties default Aseta ominaisuudet oletuksiksi Pages selected Valitut sivut Page properties Sivun ominaisuudet + Create & Edit Luo ja muokkaa + Review & Compare Tarkasta ja vertaa + Forms & Signatures Lomakkeet ja allekirjoitukset + Protect & Standardize Suojaa ja standardoi + Customize & Automate Mukauta ja automatisoi + No tools match your search. Hakuasi vastaavia työkaluja ei löytynyt. + Expand all tools Laajenna kaikki työkalut + Collapse all tools Kutista kaikki työkalut The password must have at least 8 characters. Salasanassa on oltava vähintään 8 merkkiä. theme Appearance Teema # Labels added upstream 2026-10-16…18. @@ -2809,3 +2826,12 @@ theme Appearance Teema Attaching files is not available on the web yet Tiedostojen liittäminen ei ole vielä käytettävissä verkossa Delete attachment Poista liite Delete attachment {key} Poista liite {key} +@plural {n} reply|{n} replies {n} vastaus|{n} vastausta + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Sivutyökalut + Insert pages from a file after the selection… Lisää sivuja tiedostosta valinnan jälkeen… + Rotate the selected pages counterclockwise Kierrä valittuja sivuja vastapäivään + Rotate the selected pages clockwise Kierrä valittuja sivuja myötäpäivään + Extract the selected pages… Pura valitut sivut… + Delete the selected pages Poista valitut sivut diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index b09861319a..b3138a01fb 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -157,6 +157,15 @@ Available Disponible Ready Prêt Search tools and commands… Rechercher des outils et des commandes… + +# Accessible names for inputs that have no visible label. + Added text Texte ajouté + Link URL URL du lien + Search tools and commands Rechercher des outils et des commandes + Text editing line Ligne de modification du texte + Text to place Texte à placer + Tooltip Info-bulle + Words or phrases to redact Mots ou expressions à biffer No matching tools Aucun outil correspondant Tool Outil Command Commande @@ -2497,6 +2506,14 @@ animation effects Off Désactivés Make properties default Définir comme propriétés par défaut Pages selected Pages sélectionnées Page properties Propriétés de la page + Create & Edit Créer et modifier + Review & Compare Réviser et comparer + Forms & Signatures Formulaires et signatures + Protect & Standardize Protéger et normaliser + Customize & Automate Personnaliser et automatiser + No tools match your search. Aucun outil ne correspond à votre recherche. + Expand all tools Développer tous les outils + Collapse all tools Réduire tous les outils Attach a file… Joindre un fichier… Delete attachment Supprimer la pièce jointe Attaching files is not available on the web yet Joindre des fichiers n'est pas encore disponible sur le web @@ -2805,3 +2822,12 @@ certificate Version Version # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Aujourd'hui +@plural {n} reply|{n} replies {n} réponse|{n} réponses + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Outils de page + Insert pages from a file after the selection… Insérer des pages d'un fichier après la sélection… + Rotate the selected pages counterclockwise Faire pivoter les pages sélectionnées dans le sens antihoraire + Rotate the selected pages clockwise Faire pivoter les pages sélectionnées dans le sens horaire + Extract the selected pages… Extraire les pages sélectionnées… + Delete the selected pages Supprimer les pages sélectionnées diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index c2e5208244..660e6e2ec7 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -162,6 +162,15 @@ Available उपलब्ध Ready तैयार Search tools and commands… टूल और कमांड खोजें… + +# Accessible names for inputs that have no visible label. + Added text जोड़ा गया टेक्स्ट + Link URL लिंक का URL + Search tools and commands टूल और कमांड खोजें + Text editing line टेक्स्ट संपादन पंक्ति + Text to place रखने के लिए टेक्स्ट + Tooltip टूलटिप + Words or phrases to redact रेडैक्ट करने के लिए शब्द या वाक्यांश No matching tools कोई मेल खाता टूल नहीं Tool टूल Command कमांड @@ -2690,6 +2699,14 @@ certificate SHA-256 digest SHA-256 डाइजेस्ट Make properties default गुण डिफ़ॉल्ट बनाएँ Pages selected चयनित पृष्ठ Page properties पृष्ठ गुण + Create & Edit बनाएँ और संपादित करें + Review & Compare समीक्षा और तुलना करें + Forms & Signatures फ़ॉर्म और हस्ताक्षर + Protect & Standardize सुरक्षित और मानकीकृत करें + Customize & Automate अनुकूलित और स्वचालित करें + No tools match your search. आपकी खोज से कोई टूल मेल नहीं खाता। + Expand all tools सभी टूल विस्तृत करें + Collapse all tools सभी टूल समेटें The password must have at least 8 characters. पासवर्ड में कम से कम 8 वर्ण होने चाहिए। theme Appearance स्वरूप Zoom — type a percentage and press Enter ज़ूम — प्रतिशत टाइप करें और Enter दबाएँ @@ -2776,3 +2793,12 @@ theme Appearance स्वरूप Attaching files is not available on the web yet वेब पर अभी फ़ाइलें संलग्न करना उपलब्ध नहीं है Delete attachment अनुलग्नक हटाएँ Delete attachment {key} अनुलग्नक {key} हटाएँ +@plural {n} reply|{n} replies {n} जवाब + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools पृष्ठ उपकरण + Insert pages from a file after the selection… चयन के बाद किसी फ़ाइल से पृष्ठ डालें… + Rotate the selected pages counterclockwise चयनित पृष्ठों को वामावर्त घुमाएँ + Rotate the selected pages clockwise चयनित पृष्ठों को दक्षिणावर्त घुमाएँ + Extract the selected pages… चयनित पृष्ठ निकालें… + Delete the selected pages चयनित पृष्ठ हटाएँ diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index aa1456953f..4dfb7cf378 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -155,6 +155,15 @@ Available Elérhető Ready Kész Search tools and commands… Eszközök és parancsok keresése… + +# Accessible names for inputs that have no visible label. + Added text Hozzáadott szöveg + Link URL Hivatkozás URL-je + Search tools and commands Eszközök és parancsok keresése + Text editing line Szövegszerkesztő sor + Text to place Elhelyezendő szöveg + Tooltip Elemleírás + Words or phrases to redact Kitakarni kívánt szavak vagy kifejezések No matching tools Nincs illeszkedő eszköz Tool Eszköz Command Parancs @@ -2496,6 +2505,14 @@ animation effects Off Ki Make properties default Tulajdonságok beállítása alapértelmezettként Pages selected Kijelölt oldalak Page properties Oldaltulajdonságok + Create & Edit Létrehozás és szerkesztés + Review & Compare Áttekintés és összehasonlítás + Forms & Signatures Űrlapok és aláírások + Protect & Standardize Védelem és szabványosítás + Customize & Automate Testreszabás és automatizálás + No tools match your search. Egy eszköz sem felel meg a keresésnek. + Expand all tools Az összes eszköz kibontása + Collapse all tools Az összes eszköz összecsukása Attach a file… Fájl csatolása… Delete attachment Melléklet törlése Attaching files is not available on the web yet Fájlok csatolása a weben még nem érhető el @@ -2538,3 +2555,12 @@ animation effects Off Ki # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Ma +@plural {n} reply|{n} replies {n} válasz|{n} válasz + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Oldaleszközök + Insert pages from a file after the selection… Oldalak beszúrása fájlból a kijelölés után… + Rotate the selected pages counterclockwise A kijelölt oldalak forgatása az óramutató járásával ellentétesen + Rotate the selected pages clockwise A kijelölt oldalak forgatása az óramutató járásával megegyezően + Extract the selected pages… A kijelölt oldalak kibontása… + Delete the selected pages A kijelölt oldalak törlése diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 42aa4d286e..3e3094111d 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -1019,6 +1019,15 @@ Scroll tabs right Gulir tab ke kanan Search comments Cari komentar Search tools and commands… Cari alat dan perintah… + +# Accessible names for inputs that have no visible label. + Added text Teks yang ditambahkan + Link URL URL tautan + Search tools and commands Cari alat dan perintah + Text editing line Baris penyuntingan teks + Text to place Teks yang akan ditempatkan + Tooltip Tooltip + Words or phrases to redact Kata atau frasa untuk disensor Searchable Image (Exact) Gambar yang Dapat Dicari (Tepat) Searching… {s} of {p} pages Mencari… {s} dari {p} halaman Searching… {s}/{p} Mencari… {s}/{p} @@ -2691,6 +2700,14 @@ key Space Space Make properties default Jadikan properti default Pages selected Halaman yang dipilih Page properties Properti halaman + Create & Edit Buat & Edit + Review & Compare Tinjau & Bandingkan + Forms & Signatures Formulir & Tanda Tangan + Protect & Standardize Lindungi & Standarkan + Customize & Automate Sesuaikan & Otomatiskan + No tools match your search. Tidak ada alat yang cocok dengan pencarian Anda. + Expand all tools Perluas semua alat + Collapse all tools Ciutkan semua alat The password must have at least 8 characters. Kata sandi harus memiliki minimal 8 karakter. theme Appearance Tampilan Zoom — type a percentage and press Enter Zoom — ketik persentase dan tekan Enter @@ -2777,3 +2794,12 @@ theme Appearance Tampilan Attaching files is not available on the web yet Melampirkan file belum tersedia di web Delete attachment Hapus lampiran Delete attachment {key} Hapus lampiran {key} +@plural {n} reply|{n} replies {n} balasan + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Alat halaman + Insert pages from a file after the selection… Sisipkan halaman dari file setelah pilihan… + Rotate the selected pages counterclockwise Putar halaman yang dipilih berlawanan arah jarum jam + Rotate the selected pages clockwise Putar halaman yang dipilih searah jarum jam + Extract the selected pages… Ekstrak halaman yang dipilih… + Delete the selected pages Hapus halaman yang dipilih diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index b7b611b670..69bfaa7db5 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -181,6 +181,15 @@ Available Disponibile Ready Pronto Search tools and commands… Cerca strumenti e comandi… + +# Accessible names for inputs that have no visible label. + Added text Testo aggiunto + Link URL URL del collegamento + Search tools and commands Cerca strumenti e comandi + Text editing line Riga di modifica del testo + Text to place Testo da posizionare + Tooltip Descrizione comando + Words or phrases to redact Parole o frasi da oscurare No matching tools Nessuno strumento corrispondente Tool Strumento Command Comando @@ -2709,6 +2718,14 @@ certificate Key usage Utilizzo chiave certificate Self-signed Autofirmato certificate SHA-1 digest Impronta SHA-1 certificate SHA-256 digest Impronta SHA-256 + Create & Edit Crea e modifica + Review & Compare Revisione e confronto + Forms & Signatures Moduli e firme + Protect & Standardize Proteggi e standardizza + Customize & Automate Personalizza e automatizza + No tools match your search. Nessuno strumento corrisponde alla ricerca. + Expand all tools Espandi tutti gli strumenti + Collapse all tools Comprimi tutti gli strumenti Attach a file… Allega un file… Delete attachment Elimina allegato Attaching files is not available on the web yet Allegare file non è ancora disponibile sul web @@ -2771,3 +2788,12 @@ certificate SHA-256 digest Impronta SHA-256 # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Oggi +@plural {n} reply|{n} replies {n} risposta|{n} risposte + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Strumenti pagina + Insert pages from a file after the selection… Inserisci pagine da un file dopo la selezione… + Rotate the selected pages counterclockwise Ruota le pagine selezionate in senso antiorario + Rotate the selected pages clockwise Ruota le pagine selezionate in senso orario + Extract the selected pages… Estrai le pagine selezionate… + Delete the selected pages Elimina le pagine selezionate diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index f433c6042b..483e12fab7 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -184,6 +184,15 @@ Available 使用可能 Ready 使用可能 Search tools and commands… ツールとコマンドを検索… + +# Accessible names for inputs that have no visible label. + Added text 追加したテキスト + Link URL リンクの URL + Search tools and commands ツールとコマンドを検索 + Text editing line テキスト編集行 + Text to place 配置するテキスト + Tooltip ツールチップ + Words or phrases to redact 墨消しする単語または語句 No matching tools 該当するツールがありません Tool ツール Command コマンド @@ -2497,6 +2506,14 @@ animation effects Off オフ Make properties default プロパティをデフォルトに設定 Pages selected 選択したページ Page properties ページのプロパティ + Create & Edit 作成と編集 + Review & Compare レビューと比較 + Forms & Signatures フォームと署名 + Protect & Standardize 保護と標準化 + Customize & Automate カスタマイズと自動化 + No tools match your search. 検索に一致するツールはありません。 + Expand all tools すべてのツールを展開 + Collapse all tools すべてのツールを折りたたむ Attach a file… ファイルを添付… Delete attachment 添付ファイルを削除 Attaching files is not available on the web yet Web 版ではまだファイルを添付できません @@ -2539,3 +2556,12 @@ animation effects Off オフ # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today 今日 +@plural {n} reply|{n} replies {n} 件の返信 + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools ページツール + Insert pages from a file after the selection… 選択範囲の後にファイルからページを挿入… + Rotate the selected pages counterclockwise 選択したページを反時計回りに回転 + Rotate the selected pages clockwise 選択したページを時計回りに回転 + Extract the selected pages… 選択したページを抽出… + Delete the selected pages 選択したページを削除 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index c4924fb59f..168e2c9b81 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -145,6 +145,15 @@ Available Қолжетімді Ready Дайын Search tools and commands… Құралдар мен командаларды іздеу… + +# Accessible names for inputs that have no visible label. + Added text Қосылған мәтін + Link URL Сілтеменің URL мекенжайы + Search tools and commands Құралдар мен командаларды іздеу + Text editing line Мәтінді өңдеу жолы + Text to place Орналастырылатын мәтін + Tooltip Қалтқы + Words or phrases to redact Жасырылатын сөздер немесе сөз тіркестері No matching tools Сәйкес құралдар жоқ Tool Құрал Command Команда @@ -2495,6 +2504,14 @@ animation effects Off Өшірулі Make properties default Қасиеттерді әдепкі ету Pages selected Таңдалған беттер Page properties Бет қасиеттері + Create & Edit Жасау және өңдеу + Review & Compare Шолу және салыстыру + Forms & Signatures Пішіндер және қолтаңбалар + Protect & Standardize Қорғау және стандарттау + Customize & Automate Баптау және автоматтандыру + No tools match your search. Іздеуге сәйкес құралдар жоқ. + Expand all tools Барлық құралдарды жаю + Collapse all tools Барлық құралдарды жию Attach a file… Файл тіркеу… Delete attachment Тіркемені жою Attaching files is not available on the web yet Вебте файл тіркеу әзірге қолжетімсіз @@ -2537,3 +2554,12 @@ animation effects Off Өшірулі # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Бүгін +@plural {n} reply|{n} replies {n} жауап|{n} жауап + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Бет құралдары + Insert pages from a file after the selection… Таңдаудан кейін файлдан беттер кірістіру… + Rotate the selected pages counterclockwise Таңдалған беттерді сағат тіліне қарсы бұру + Rotate the selected pages clockwise Таңдалған беттерді сағат тілімен бұру + Extract the selected pages… Таңдалған беттерді шығарып алу… + Delete the selected pages Таңдалған беттерді жою diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 0536d32982..76c274494a 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -162,6 +162,15 @@ Available 사용 가능 Ready 준비됨 Search tools and commands… 도구 및 명령 검색… + +# Accessible names for inputs that have no visible label. + Added text 추가한 텍스트 + Link URL 링크 URL + Search tools and commands 도구 및 명령 검색 + Text editing line 텍스트 편집 줄 + Text to place 배치할 텍스트 + Tooltip 도구 설명 + Words or phrases to redact 민감 정보를 제거할 단어 또는 구문 No matching tools 일치하는 도구가 없습니다 Tool 도구 Command 명령 @@ -2690,6 +2699,14 @@ certificate SHA-256 digest SHA-256 다이제스트 Make properties default 속성을 기본값으로 설정 Pages selected 선택한 페이지 Page properties 페이지 속성 + Create & Edit 만들기 및 편집 + Review & Compare 검토 및 비교 + Forms & Signatures 양식 및 서명 + Protect & Standardize 보호 및 표준화 + Customize & Automate 사용자 지정 및 자동화 + No tools match your search. 검색과 일치하는 도구가 없습니다. + Expand all tools 모든 도구 펼치기 + Collapse all tools 모든 도구 접기 The password must have at least 8 characters. 암호는 8자 이상이어야 합니다. theme Appearance 모양 Zoom — type a percentage and press Enter 줌 — 백분율을 입력하고 Enter를 누르세요 @@ -2776,3 +2793,12 @@ theme Appearance 모양 Attaching files is not available on the web yet 웹에서는 아직 파일을 첨부할 수 없습니다 Delete attachment 첨부 파일 삭제 Delete attachment {key} 첨부 파일 {key} 삭제 +@plural {n} reply|{n} replies 답글 {n}개 + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools 페이지 도구 + Insert pages from a file after the selection… 선택 영역 뒤에 파일에서 페이지 삽입… + Rotate the selected pages counterclockwise 선택한 페이지를 시계 반대 방향으로 회전 + Rotate the selected pages clockwise 선택한 페이지를 시계 방향으로 회전 + Extract the selected pages… 선택한 페이지 추출… + Delete the selected pages 선택한 페이지 삭제 diff --git a/crates/ui-egui/src/i18n/lo.tsv b/crates/ui-egui/src/i18n/lo.tsv index 3571ff3c53..2aba18ab59 100644 --- a/crates/ui-egui/src/i18n/lo.tsv +++ b/crates/ui-egui/src/i18n/lo.tsv @@ -182,6 +182,15 @@ Available ພ້ອມໃຊ້ງານ Ready ພ້ອມແລ້ວ Search tools and commands… ຄົ້ນຫາເຄື່ອງມື ແລະ ຄຳສັ່ງ… + +# Accessible names for inputs that have no visible label. + Added text ຂໍ້ຄວາມທີ່ເພີ່ມ + Link URL URL ຂອງລິ້ງຄ໌ + Search tools and commands ຄົ້ນຫາເຄື່ອງມື ແລະ ຄຳສັ່ງ + Text editing line ແຖວແກ້ໄຂຂໍ້ຄວາມ + Text to place ຂໍ້ຄວາມທີ່ຈະວາງ + Tooltip ຄຳແນະນຳເຄື່ອງມື + Words or phrases to redact ຄຳ ຫຼື ວະລີທີ່ຈະປິດບັງ No matching tools ບໍ່ພົບເຄື່ອງມືທີ່ກົງກັນ Tool ເຄື່ອງມື Command ຄຳສັ່ງ @@ -2444,6 +2453,23 @@ animation effects Off ປິດ Make properties default ກຳນົດຄຸນສົມບັດເປັນຄ່າເລີ່ມຕົ້ນ Pages selected ໜ້າທີ່ເລືອກ Page properties ຄຸນສົມບັດໜ້າ + Create & Edit ສ້າງ & ແກ້ໄຂ + Review & Compare ກວດກາຄືນ & ປຽບທຽບ + Forms & Signatures ຟອມ & ລາຍເຊັນ + Protect & Standardize ປ້ອງກັນ & ມາດຕະຖານ + Customize & Automate ປັບແຕ່ງ & ອັດຕະໂນມັດ + No tools match your search. ບໍ່ມີເຄື່ອງມືທີ່ກົງກັບການຄົ້ນຫາຂອງທ່ານ. + Expand all tools ຂະຫຍາຍເຄື່ອງມືທັງໝົດ + Collapse all tools ຫຍໍ້ເຄື່ອງມືທັງໝົດເຂົ້າ # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today ມື້ນີ້ +@plural {n} reply|{n} replies ການຕອບກັບ {n} ລາຍການ + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools ເຄື່ອງມືໜ້າ + Insert pages from a file after the selection… ແຊກໜ້າຈາກໄຟລ໌ຫຼັງການເລືອກ… + Rotate the selected pages counterclockwise ໝູນໜ້າທີ່ເລືອກທວນເຂັມໂມງ + Rotate the selected pages clockwise ໝູນໜ້າທີ່ເລືອກຕາມເຂັມໂມງ + Extract the selected pages… ແຍກໜ້າທີ່ເລືອກອອກ… + Delete the selected pages ລຶບໜ້າທີ່ເລືອກ diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index f2fc1536c7..92c5a22432 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -1075,6 +1075,15 @@ Scroll tabs right Slinkti korteles dešinėn Search comments Ieškoti komentaruose Search tools and commands… Ieškoti įrankių ir komandų… + +# Accessible names for inputs that have no visible label. + Added text Pridėtas tekstas + Link URL Saito URL + Search tools and commands Ieškoti įrankių ir komandų + Text editing line Teksto redagavimo eilutė + Text to place Tekstas, kurį reikia įdėti + Tooltip Patarimas + Words or phrases to redact Šalinami žodžiai ar frazės Searchable Image (Exact) Paveikslėlis su tiksliu atpažinimu Searching… {s} of {p} pages Ieškoma… {s} iš {p} puslapių Searching… {s}/{p} Ieškoma… {s}/{p} @@ -2747,6 +2756,14 @@ key Space Tarpas Make properties default Nustatyti ypatybes kaip numatytąsias Pages selected Pasirinkti puslapiai Page properties Puslapio ypatybės + Create & Edit Kūrimas ir redagavimas + Review & Compare Peržiūra ir palyginimas + Forms & Signatures Formos ir parašai + Protect & Standardize Apsauga ir standartizavimas + Customize & Automate Pritaikymas ir automatizavimas + No tools match your search. Paieškos neatitinka joks įrankis. + Expand all tools Išskleisti visus įrankius + Collapse all tools Suskleisti visus įrankius # AI-assisted additions for concurrently merged UI features (2026-10-15). # These additions have not had separate native-speaker review. @@ -2800,3 +2817,12 @@ theme Appearance Išvaizda # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Šiandien +@plural {n} reply|{n} replies {n} atsakymas|{n} atsakymai|{n} atsakymų + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Puslapių įrankiai + Insert pages from a file after the selection… Įterpti puslapius iš failo po pasirinkimo… + Rotate the selected pages counterclockwise Pasukti pasirinktus puslapius prieš laikrodžio rodyklę + Rotate the selected pages clockwise Pasukti pasirinktus puslapius pagal laikrodžio rodyklę + Extract the selected pages… Išskleisti pasirinktus puslapius… + Delete the selected pages Ištrinti pasirinktus puslapius diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 8f17a8acaf..f57894f6c4 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -44,8 +44,8 @@ pub struct LangInfo { /// Plural form index for a count (English: 0 = one, 1 = other; Japanese: always 0). A catalog's /// `@plural` entries list one form per index. pub plural: fn(u64) -> usize, - /// The language is written right to left. egui lays text out left to right, so the catalog's - /// translations are put into display order when it loads ([`crate::bidi::display_rtl`]). + /// The language is written right to left. Catalogs stay in logical order: the egui 0.37 fork + /// owns bidi shaping/reordering (E5/E12), so preordering here would reverse Arabic twice. pub rtl: bool, catalog: OnceLock, } @@ -159,7 +159,7 @@ fn plural_sl(n: u64) -> usize { } /// The registry. English first: it is the fallback and the source language. -pub static LANGUAGES: [LangInfo; 34] = [ +pub static LANGUAGES: [LangInfo; 35] = [ LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, rtl: false, catalog: OnceLock::new() }, LangInfo { code: "ja", name: "日本語", source: include_str!("ja.tsv"), plural: plural_none, rtl: false, catalog: OnceLock::new() }, // Simplified Chinese; `zh`, `zh-CN`, `zh-SG` and `zh-Hans-*` locales resolve here (see `candidates`). @@ -215,6 +215,10 @@ pub static LANGUAGES: [LangInfo; 34] = [ LangInfo { code: "ckb", name: "کوردیی ناوەندی", source: include_str!("ckb.tsv"), plural: plural_one_other, rtl: true, catalog: OnceLock::new() }, + // Persian (Farsi); every `fa-*` locale (`fa-IR`, `fa_IR.UTF-8`, `fa-AF` ...) resolves here. Right + // to left like Arabic: see `LangInfo::rtl`. CLDR `fa` counts have two forms (one: i = 0 or n = 1, + // other), which for whole counts is the French rule, so `@plural` entries list two forms. + LangInfo { code: "fa", name: "فارسی", source: include_str!("fa.tsv"), plural: plural_fr, rtl: true, catalog: OnceLock::new() }, // Polish; every `pl-*` locale (`pl-pl`, `pl_pl.UTF-8`) resolves here. LangInfo { code: "pl", name: "Polski", source: include_str!("pl.tsv"), plural: plural_polish, rtl: false, catalog: OnceLock::new() }, // Slovene; every `sl-*` locale (`sl-SI`) resolves here. Four plural forms (one, two, few, other). @@ -261,15 +265,16 @@ impl LangInfo { (0..=1000).map(self.plural).max().unwrap_or(0).saturating_add(1) } + /// Return the parsed catalogue, building it on first use and warning about entries that fall back to + /// English. fn catalog(&self) -> &Catalog { self.catalog.get_or_init(|| { - let (mut catalog, errors) = Catalog::parse(self.source, self.plural_forms()); + let (catalog, errors) = Catalog::parse(self.source, self.plural_forms()); for error in errors { log::warn!("{} interface catalog: {error}; that entry shows in English", self.code); } - if self.rtl { - catalog.map_translations(crate::bidi::display_rtl); - } + // Catalogs keep logical text: the fork shapes and reorders each run once. Accessibility + // names, copied text and search therefore see the same logical string as the source. catalog }) } @@ -321,8 +326,17 @@ impl Lang { /// Čeština sits under C rather than after Z. Names in other scripts follow the Latin ones in /// code point order: Greek, Cyrillic, Arabic, then the CJK languages. pub fn for_picker() -> Vec { + Self::for_picker_by(Lang::name) + } + + /// [`for_picker`](Self::for_picker), ordered by the names the picker actually shows. + /// + /// `shown` must return each language's displayed name. Pass [`Lang::picker_name`] to keep a + /// Latin-script fallback with the other Latin names when a script's face is missing, rather + /// than sorting it into a block of unrenderable labels. + pub fn for_picker_by(mut shown: impl FnMut(Lang) -> &'static str) -> Vec { let mut rest: Vec = Lang::all().filter(|l| l.code() != Lang::EN.code()).collect(); - rest.sort_by_cached_key(|l| name_sort_key(l.name())); + rest.sort_by_cached_key(|l| name_sort_key(shown(*l))); std::iter::once(Lang::EN).chain(rest).collect() } @@ -330,8 +344,61 @@ impl Lang { self.0.name } - /// Whether the language is written right to left. Its translations come back from the - /// lookups in display order (see [`LangInfo::rtl`]). + /// The language's name in English, used by [`picker_name`](Self::picker_name) when this + /// build's fonts cannot draw [`name`](Self::name). + pub fn english_name(self) -> &'static str { + match self.code() { + "en" => "English", + "ja" => "Japanese", + "zh-hans" => "Simplified Chinese", + "zh-hant" => "Traditional Chinese", + "cs" => "Czech", + "pt-br" => "Portuguese (Brazil)", + "de" => "German", + "es" => "Spanish", + "fr" => "French", + "ru" => "Russian", + "kk" => "Kazakh", + "bg" => "Bulgarian", + "te" => "Telugu", + "hu" => "Hungarian", + "uk" => "Ukrainian", + "it" => "Italian", + "ar" => "Arabic", + "ckb" => "Central Kurdish", + "fa" => "Persian", + "pl" => "Polish", + "sl" => "Slovenian", + "el" => "Greek", + "tr" => "Turkish", + "lt" => "Lithuanian", + "id" => "Indonesian", + "vi" => "Vietnamese", + "fi" => "Finnish", + "nb" => "Norwegian Bokmål", + "sq" => "Albanian", + "th" => "Thai", + "ca" => "Catalan", + "ro" => "Romanian", + "lo" => "Lao", + "hi" => "Hindi", + "ko" => "Korean", + // Only reached if a language is added to `LANGUAGES` without an English name; the + // code still tells the reader which entry it is. + _ => self.0.code, + } + } + + /// The name to show in the language picker: the native name when the current fonts can draw + /// it, otherwise the English name. With egui 0.37's default `MissingGlyphPolicy::Panic`, a + /// native name whose script has no face (for example Hindi in a build without a Devanagari + /// face and with installed fonts turned off) would otherwise abort while the list opens. + pub fn picker_name(self, drawable: impl FnOnce(&str) -> bool) -> &'static str { + if drawable(self.name()) { self.name() } else { self.english_name() } + } + + /// Whether the language is written right to left. Lookups stay logical; the egui fork + /// shapes and reorders each run for display (see [`LangInfo::rtl`]). pub fn rtl(self) -> bool { self.0.rtl } @@ -707,6 +774,20 @@ mod tests { set_current(previous); } + /// Regression test: a language picker uses its native name only when a face can draw it, otherwise its + /// English name. + #[test] + fn picker_names_fall_back_to_english_when_their_script_has_no_face() { + let hi = Lang::from_code("hi").expect("hi registered"); + assert_eq!(hi.picker_name(|_| true), "हिन्दी", "the native name is kept when it can be drawn"); + assert_eq!(hi.picker_name(|_| false), "Hindi", "no Devanagari face: an English name, not an unrenderable label"); + assert_eq!(Lang::EN.picker_name(|_| false), "English"); + for language in Lang::all() { + assert!(!language.english_name().is_empty(), "{}", language.code()); + assert_ne!(language.english_name(), language.code(), "{} needs an English picker fallback", language.code()); + } + } + #[test] fn tags_map_to_languages() { assert_eq!(lang_from_tag("ja_JP.UTF-8"), Some(JA())); @@ -1672,7 +1753,7 @@ mod tests { assert_eq!(ar.name(), "العربية"); assert!(ar.rtl()); let rtl: Vec<&str> = Lang::all().filter(|l| l.rtl()).map(|l| l.code()).collect(); - assert_eq!(rtl, ["ar", "ckb"], "Arabic and Kurdish are the right-to-left languages"); + assert_eq!(rtl, ["ar", "ckb", "fa"], "Arabic, Kurdish and Persian are the right-to-left languages"); assert_eq!(normalize_pref("AR"), Some("ar")); for tag in ["ar", "ar-MA", "ar_EG.UTF-8", "AR-sa", "ar_DZ@variant"] { assert_eq!(lang_from_tag(tag), Some(ar), "{tag}"); @@ -1690,25 +1771,24 @@ mod tests { assert_eq!(restored.language, "ar"); } - /// Lookups return Arabic in display order: egui draws the words left to right as given, and - /// joins the letters of each word itself. + /// Catalog lookups stay in logical order; the egui 0.37 fork owns bidi shaping and display + /// reordering (E5/E12), so preordering here would reverse Arabic twice. Accessibility names, + /// copied labels and search therefore see the source string. #[test] - fn arabic_lookups_are_in_display_order() { + fn arabic_catalog_stays_logical() { let ar = arabic(); - // "حفظ باسم…" as typed; the ellipsis ends the phrase on the left. - assert_eq!(tr(ar, "Save as…"), "…باسم حفظ"); - // Latin names keep their own direction inside the phrase. - assert_eq!(tr(ar, "About PdfCraft"), "PdfCraft حول"); - // A caption's colon stays beside the widget on its right. + assert_eq!(tr(ar, "Save as…"), "حفظ باسم…"); + assert_eq!(tr(ar, "About PdfCraft"), "حول PdfCraft"); assert_eq!(tr(ar, "Name:"), "الاسم:"); - assert_eq!(tr(ar, "Issued by: "), "عن صادرة: "); - // Placeholders survive and are filled in place. - assert_eq!(tr(ar, "Couldn't open {name}: {e}"), "{name}: {e} فتح تعذّر"); - assert_eq!(fmt(tr(ar, "Couldn't open {name}: {e}"), &[("name", "a {e}.pdf"), ("e", "x")]), "a {e}.pdf: x فتح تعذّر"); - assert_eq!(fmt(tr(ar, "Page {p} of {n}"), &[("p", "3"), ("n", "12")]), "12 من 3 الصفحة"); + assert_eq!(tr(ar, "Issued by: "), "صادرة عن: "); + // Placeholders survive in their logical positions. + assert_eq!(tr(ar, "Couldn't open {name}: {e}"), "تعذّر فتح {name}: {e}"); + assert_eq!(fmt(tr(ar, "Couldn't open {name}: {e}"), &[("name", "a {e}.pdf"), ("e", "x")]), "تعذّر فتح a {e}.pdf: x"); + assert_eq!(fmt(tr(ar, "Page {p} of {n}"), &[("p", "3"), ("n", "12")]), "الصفحة 3 من 12"); set_current(ar); - assert_eq!(command_label("Undo Insert pages from a.pdf"), "a.pdf من صفحات إدراج عن التراجع"); - assert_eq!(menu_label("file.saveAs", "Save as…"), "…باسم حفظ"); + let command = command_label("Undo Insert pages from a.pdf"); + assert!(command.contains("a.pdf") && command.contains("إدراج صفحات"), "logical command label: {command:?}"); + assert_eq!(menu_label("file.saveAs", "Save as…"), "حفظ باسم…"); set_current(Lang::EN); } @@ -1723,6 +1803,39 @@ mod tests { assert_eq!([100, 101, 102, 200, 1000, u64::MAX - 13].map(form), [5; 6]); } + fn persian() -> Lang { + Lang::from_code("fa").expect("fa registered") + } + + #[test] + fn persian_is_registered_and_persists() { + let fa = persian(); + assert_eq!(fa.name(), "فارسی"); + assert!(fa.rtl()); + // CLDR `fa` (one: i = 0 or n = 1): 0 and 1 are singular, every other count the plural. + assert_eq!(fa.0.plural_forms(), 2); + assert_eq!([0, 1, 2, 11].map(|n| (fa.0.plural)(n)), [0, 0, 1, 1]); + assert_eq!(normalize_pref("FA"), Some("fa")); + for tag in ["fa", "fa-IR", "fa_IR.UTF-8", "FA-af", "fa_IR@calendar=persian"] { + assert_eq!(lang_from_tag(tag), Some(fa), "{tag}"); + } + assert_eq!(first_supported("fa-IR\r\nen-US"), Some(fa)); + // Lookups return Persian in logical order; epaint handles visual ordering. + assert_eq!(tr(fa, "Save"), "ذخیره"); + assert_eq!(tr(fa, "Cancel"), "لغو"); + assert_eq!(tr(fa, "File"), "فایل"); + // Unknown strings and user text are never reordered by a lookup. + assert_eq!(tr(fa, "گزارش نهایی {n}.pdf"), "گزارش نهایی {n}.pdf"); + let mut app = crate::PdfCraftApp::default(); + app.set_option("language", "FA").unwrap(); + assert_eq!(app.language, "fa"); + let mut restored = crate::PdfCraftApp::default(); + restored.restore(&app.persist()); + assert_eq!(restored.language, "fa"); + assert!(restored.set_option("language", "xx").is_err()); + assert_eq!(restored.language, "fa"); + } + /// With a craft-fonts Arabic face, every Arabic translation has glyphs: with all interface /// faces (desktop) and with the Arabic face as the only craft-fonts face (the web build keeps /// it; Arabic labels must not need a Japanese face). @@ -1794,6 +1907,7 @@ mod tests { section.items.iter().for_each(|item| add(item.label)); } } + pdfcraft_engine::catalog::TOOL_CATEGORIES.iter().for_each(|category| add(category.label)); let engine: [&[&str]; 9] = [ pdfcraft_engine::a11y::WORDINGS, pdfcraft_engine::a11y::REPORT_WORDS, diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index eef5f0c2ea..ba7abd7e08 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -1028,6 +1028,15 @@ Scroll tabs right Rull faner til høyre Search comments Søk i kommentarer Search tools and commands… Søk etter verktøy og kommandoer… + +# Accessible names for inputs that have no visible label. + Added text Lagt til tekst + Link URL URL for lenken + Search tools and commands Søk etter verktøy og kommandoer + Text editing line Tekstredigeringslinje + Text to place Tekst som skal plasseres + Tooltip Verktøytips + Words or phrases to redact Ord eller fraser som skal sladdes Searchable Image (Exact) Søkbart bilde (eksakt) Searching… {s} of {p} pages Søker… {s} av {p} sider Searching… {s}/{p} Søker… {s}/{p} @@ -2622,6 +2631,14 @@ Make properties default Bruk egenskapene som standard Pages selected Valgte sider Page properties Sideegenskaper + Create & Edit Opprett og rediger + Review & Compare Gjennomgå og sammenlign + Forms & Signatures Skjemaer og signaturer + Protect & Standardize Beskytt og standardiser + Customize & Automate Tilpass og automatiser + No tools match your search. Ingen verktøy samsvarer med søket. + Expand all tools Utvid alle verktøy + Collapse all tools Fold sammen alle verktøy Cut Image Klipp ut bilde Redact Image Sladd bilde Create Link… Opprett lenke… @@ -2715,3 +2732,12 @@ key Space Mellomrom # AI-assisted additions for restoring the last view (#1767) (2026-10-22); not reviewed by a native speaker. Restore the last view (page, zoom and page display) when a document is opened again Gjenopprett den siste visningen (side, zoom og sidevisning) når et dokument åpnes igjen +@plural {n} reply|{n} replies {n} svar|{n} svar + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Sideverktøy + Insert pages from a file after the selection… Sett inn sider fra en fil etter utvalget… + Rotate the selected pages counterclockwise Roter de valgte sidene mot klokken + Rotate the selected pages clockwise Roter de valgte sidene med klokken + Extract the selected pages… Trekk ut de valgte sidene… + Delete the selected pages Slett de valgte sidene diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 4ea802a1cd..45fb28c420 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -180,6 +180,15 @@ Available Dostępne Ready Gotowe Search tools and commands… Szukaj narzędzi i poleceń… + +# Accessible names for inputs that have no visible label. + Added text Dodany tekst + Link URL Adres URL łącza + Search tools and commands Szukaj narzędzi i poleceń + Text editing line Wiersz edycji tekstu + Text to place Tekst do umieszczenia + Tooltip Podpowiedź + Words or phrases to redact Słowa lub frazy do utajenia No matching tools Brak pasujących narzędzi Tool Narzędzie Command Polecenie @@ -2473,6 +2482,14 @@ animation effects Off Wyłączone Make properties default Ustaw właściwości jako domyślne Pages selected Zaznaczone strony Page properties Właściwości strony + Create & Edit Tworzenie i edycja + Review & Compare Recenzja i porównanie + Forms & Signatures Formularze i podpisy + Protect & Standardize Ochrona i standaryzacja + Customize & Automate Dostosowanie i automatyzacja + No tools match your search. Żadne narzędzie nie pasuje do wyszukiwania. + Expand all tools Rozwiń wszystkie narzędzia + Collapse all tools Zwiń wszystkie narzędzia Attach a file… Dołącz plik… Delete attachment Usuń załącznik Attaching files is not available on the web yet Dołączanie plików nie jest jeszcze dostępne w wersji webowej @@ -2635,3 +2652,12 @@ animation effects Off Wyłączone # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Dzisiaj +@plural {n} reply|{n} replies {n} odpowiedź|{n} odpowiedzi|{n} odpowiedzi + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Narzędzia stron + Insert pages from a file after the selection… Wstaw strony z pliku po zaznaczeniu… + Rotate the selected pages counterclockwise Obróć zaznaczone strony w lewo + Rotate the selected pages clockwise Obróć zaznaczone strony w prawo + Extract the selected pages… Wyodrębnij zaznaczone strony… + Delete the selected pages Usuń zaznaczone strony diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 6ba44cdbfa..a603414c81 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1713,6 +1713,15 @@ Tool Ferramenta Command Comando Search tools and commands… Pesquisar ferramentas e comandos… + +# Accessible names for inputs that have no visible label. + Added text Texto adicionado + Link URL URL do link + Search tools and commands Pesquisar ferramentas e comandos + Text editing line Linha de edição de texto + Text to place Texto a colocar + Tooltip Dica de ferramenta + Words or phrases to redact Palavras ou frases para ocultar No matching tools Nenhuma ferramenta correspondente All tools Todas as ferramentas Read Ler @@ -2511,6 +2520,14 @@ animation effects Off Desativados Make properties default Tornar propriedades padrão Pages selected Páginas selecionadas Page properties Propriedades da página + Create & Edit Criar e editar + Review & Compare Revisar e comparar + Forms & Signatures Formulários e assinaturas + Protect & Standardize Proteger e padronizar + Customize & Automate Personalizar e automatizar + No tools match your search. Nenhuma ferramenta corresponde à pesquisa. + Expand all tools Expandir todas as ferramentas + Collapse all tools Recolher todas as ferramentas Attach a file… Anexar um arquivo… Delete attachment Excluir anexo Attaching files is not available on the web yet Anexar arquivos ainda não está disponível na web @@ -2824,3 +2841,12 @@ theme Appearance Aparência # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Hoje +@plural {n} reply|{n} replies {n} resposta|{n} respostas + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Ferramentas de página + Insert pages from a file after the selection… Inserir páginas de um arquivo após a seleção… + Rotate the selected pages counterclockwise Girar as páginas selecionadas no sentido anti-horário + Rotate the selected pages clockwise Girar as páginas selecionadas no sentido horário + Extract the selected pages… Extrair as páginas selecionadas… + Delete the selected pages Excluir as páginas selecionadas diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index 3f2b716bb1..05b92a6b4d 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -1028,6 +1028,15 @@ Scroll tabs right Derulare file la dreapta Search comments Căutare în comentarii Search tools and commands… Căutare instrumente și comenzi… + +# Accessible names for inputs that have no visible label. + Added text Text adăugat + Link URL URL-ul linkului + Search tools and commands Căutare instrumente și comenzi + Text editing line Linie de editare a textului + Text to place Text de plasat + Tooltip Sfat ecran + Words or phrases to redact Cuvinte sau expresii de anonimizat Searchable Image (Exact) Imagine în care se poate căuta (exactă) Searching… {s} of {p} pages Se caută… {s} din {p} pagini Searching… {s}/{p} Se caută… {s}/{p} @@ -2662,6 +2671,14 @@ key Space Spațiu Optimizing… merging image fragments Se optimizează… unificare fragmente de imagine Outline colour: Culoare contur: Page properties Proprietăți pagină + Create & Edit Creare și editare + Review & Compare Revizuire și comparare + Forms & Signatures Formulare și semnături + Protect & Standardize Protejare și standardizare + Customize & Automate Personalizare și automatizare + No tools match your search. Niciun instrument nu corespunde căutării. + Expand all tools Extinderea tuturor instrumentelor + Collapse all tools Restrângerea tuturor instrumentelor Pages counted Pagini numărate Pages selected Pagini selectate Print as image Imprimare ca imagine @@ -2800,3 +2817,12 @@ theme Appearance Aspect Attaching files is not available on the web yet Atașarea fișierelor nu este încă disponibilă pe web Delete attachment Ștergere atașament Delete attachment {key} Ștergere atașament {key} +@plural {n} reply|{n} replies {n} răspuns|{n} răspunsuri|{n} de răspunsuri + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Instrumente pentru pagini + Insert pages from a file after the selection… Inserează pagini dintr-un fișier după selecție… + Rotate the selected pages counterclockwise Rotește paginile selectate în sens antiorar + Rotate the selected pages clockwise Rotește paginile selectate în sens orar + Extract the selected pages… Extrage paginile selectate… + Delete the selected pages Șterge paginile selectate diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 3de87c1136..495b096859 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -177,6 +177,15 @@ Available Доступно Ready Готово Search tools and commands… Поиск инструментов и команд… + +# Accessible names for inputs that have no visible label. + Added text Добавленный текст + Link URL URL-адрес ссылки + Search tools and commands Поиск инструментов и команд + Text editing line Строка редактирования текста + Text to place Текст для размещения + Tooltip Подсказка + Words or phrases to redact Слова или фразы для скрытия No matching tools Нет подходящих инструментов Tool Инструмент Command Команда @@ -2410,6 +2419,14 @@ animation effects Off Выкл Make properties default Сделать свойства стандартными Pages selected Выделенные страницы Page properties Свойства страницы + Create & Edit Создание и редактирование + Review & Compare Рецензирование и сравнение + Forms & Signatures Формы и подписи + Protect & Standardize Защита и стандартизация + Customize & Automate Настройка и автоматизация + No tools match your search. Нет инструментов, соответствующих запросу. + Expand all tools Развернуть все инструменты + Collapse all tools Свернуть все инструменты Attach a file… Прикрепить файл… Delete attachment Удалить вложение Attaching files is not available on the web yet Прикрепление файлов в веб-версии пока недоступно @@ -2452,3 +2469,12 @@ animation effects Off Выкл # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Сегодня +@plural {n} reply|{n} replies {n} ответ|{n} ответа|{n} ответов + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Инструменты страниц + Insert pages from a file after the selection… Вставить страницы из файла после выделения… + Rotate the selected pages counterclockwise Повернуть выбранные страницы против часовой стрелки + Rotate the selected pages clockwise Повернуть выбранные страницы по часовой стрелке + Extract the selected pages… Извлечь выбранные страницы… + Delete the selected pages Удалить выбранные страницы diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index a21088acb8..675e8ee5fe 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -1644,6 +1644,15 @@ Search Iskanje Search comments Išči po komentarjih Search tools and commands… Poišči orodja in ukaze… + +# Accessible names for inputs that have no visible label. + Added text Dodano besedilo + Link URL URL povezave + Search tools and commands Poišči orodja in ukaze + Text editing line Vrstica za urejanje besedila + Text to place Besedilo za postavitev + Tooltip Namig + Words or phrases to redact Besede ali fraze za počrnitev Searchable Image (Exact) Iskalna slika (natančna) Searching… {s} of {p} pages Iskanje… {s} od {p} strani Searching… {s}/{p} Iskanje… {s}/{p} @@ -2502,6 +2511,14 @@ animation effects Off Izklopljeni Make properties default Nastavi lastnosti kot privzete Pages selected Izbrane strani Page properties Lastnosti strani + Create & Edit Ustvarjanje in urejanje + Review & Compare Pregled in primerjava + Forms & Signatures Obrazci in podpisi + Protect & Standardize Zaščita in standardizacija + Customize & Automate Prilagajanje in avtomatizacija + No tools match your search. Iskanju ne ustreza nobeno orodje. + Expand all tools Razširi vsa orodja + Collapse all tools Strni vsa orodja Attach a file… Priloži datoteko… Delete attachment Izbriši prilogo Attaching files is not available on the web yet Prilaganje datotek na spletu še ni na voljo @@ -2544,3 +2561,12 @@ animation effects Off Izklopljeni # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Danes +@plural {n} reply|{n} replies {n} odgovor|{n} odgovora|{n} odgovori|{n} odgovorov + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Orodja za strani + Insert pages from a file after the selection… Vstavi strani iz datoteke za izborom… + Rotate the selected pages counterclockwise Zavrti izbrane strani v nasprotni smeri urnega kazalca + Rotate the selected pages clockwise Zavrti izbrane strani v smeri urnega kazalca + Extract the selected pages… Izvleci izbrane strani… + Delete the selected pages Izbriši izbrane strani diff --git a/crates/ui-egui/src/i18n/sq.tsv b/crates/ui-egui/src/i18n/sq.tsv index 3e860fa1db..1c4c2df8d6 100644 --- a/crates/ui-egui/src/i18n/sq.tsv +++ b/crates/ui-egui/src/i18n/sq.tsv @@ -1593,6 +1593,15 @@ Search Kërko Search comments Kërko te komentet Search tools and commands… Kërko mjete dhe komanda… + +# Accessible names for inputs that have no visible label. + Added text Tekst i shtuar + Link URL URL e lidhjes + Search tools and commands Kërko mjete dhe komanda + Text editing line Rreshti i redaktimit të tekstit + Text to place Teksti për t'u vendosur + Tooltip Këshilla e mjetit + Words or phrases to redact Fjalë ose fraza për redaktim Searchable Image (Exact) Imazh i kërkueshëm (i saktë) Searching… {s} of {p} pages Po kërkohet… {s} nga {p} faqe Searching… {s}/{p} Po kërkohet… {s}/{p} @@ -2428,6 +2437,14 @@ animation effects Off Joaktiv Other options Other options Outline colour: Outline colour: Page properties Page properties + Create & Edit Krijo dhe redakto + Review & Compare Rishiko dhe krahaso + Forms & Signatures Formularë dhe nënshkrime + Protect & Standardize Mbro dhe standardizo + Customize & Automate Personalizo dhe automatizo + No tools match your search. Asnjë mjet nuk përputhet me kërkimin tuaj. + Expand all tools Zgjero të gjitha mjetet + Collapse all tools Palos të gjitha mjetet Pages counted Pages counted Pages selected Pages selected Paragraph spacing Paragraph spacing @@ -2526,3 +2543,12 @@ animation effects Off Joaktiv Attaching files is not available on the web yet Bashkëngjitja e skedarëve nuk është ende e disponueshme në ueb Delete attachment Fshi bashkëngjitjen Delete attachment {key} Fshi bashkëngjitjen {key} +@plural {n} reply|{n} replies {n} përgjigje|{n} përgjigje + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Mjetet e faqes + Insert pages from a file after the selection… Fut faqe nga një skedar pas përzgjedhjes… + Rotate the selected pages counterclockwise Rrotullo faqet e përzgjedhura në drejtim të kundërt me orën + Rotate the selected pages clockwise Rrotullo faqet e përzgjedhura në drejtim të orës + Extract the selected pages… Nxirr faqet e përzgjedhura… + Delete the selected pages Fshi faqet e përzgjedhura diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index ebc54e0429..7d401e30e6 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -161,6 +161,15 @@ comment menu Edit సవరించు Available అందుబాటులో ఉంది Ready సిద్ధం Search tools and commands… సాధనాలు, కమాండ్‌లలో వెతుకు… + +# Accessible names for inputs that have no visible label. + Added text జోడించిన టెక్స్ట్ + Link URL లింక్ URL + Search tools and commands సాధనాలు, కమాండ్‌లలో వెతుకు + Text editing line టెక్స్ట్ సవరణ పంక్తి + Text to place ఉంచాల్సిన టెక్స్ట్ + Tooltip టూల్‌టిప్ + Words or phrases to redact రెడాక్ట్ చేయాల్సిన పదాలు లేదా పదబంధాలు No matching tools సరిపోలే సాధనాలు లేవు Tool సాధనం Command కమాండ్ @@ -2406,6 +2415,14 @@ animation effects Off ఆఫ్ Make properties default లక్షణాలను డిఫాల్ట్‌గా చేయి Pages selected ఎంచుకున్న పేజీలు Page properties పేజీ లక్షణాలు + Create & Edit సృష్టించు & సవరించు + Review & Compare సమీక్షించు & పోల్చు + Forms & Signatures ఫారమ్‌లు & సంతకాలు + Protect & Standardize రక్షించు & ప్రామాణీకరించు + Customize & Automate అనుకూలీకరించు & ఆటోమేట్ చేయి + No tools match your search. మీ శోధనకు సరిపోలే సాధనాలు లేవు. + Expand all tools అన్ని సాధనాలను విస్తరించు + Collapse all tools అన్ని సాధనాలను కుదించు Attach a file… ఫైల్‌ను జోడించు… Delete attachment జోడింపును తొలగించు Attaching files is not available on the web yet వెబ్‌లో ఫైల్‌లను జోడించడం ఇంకా అందుబాటులో లేదు @@ -2448,3 +2465,12 @@ animation effects Off ఆఫ్ # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today ఈరోజు +@plural {n} reply|{n} replies {n} ప్రత్యుత్తరం|{n} ప్రత్యుత్తరాలు + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools పేజీ సాధనాలు + Insert pages from a file after the selection… ఎంపిక తర్వాత ఫైల్ నుండి పేజీలను చొప్పించు… + Rotate the selected pages counterclockwise ఎంచుకున్న పేజీలను అపసవ్య దిశలో తిప్పు + Rotate the selected pages clockwise ఎంచుకున్న పేజీలను సవ్య దిశలో తిప్పు + Extract the selected pages… ఎంచుకున్న పేజీలను సంగ్రహించు… + Delete the selected pages ఎంచుకున్న పేజీలను తొలగించు diff --git a/crates/ui-egui/src/i18n/th.tsv b/crates/ui-egui/src/i18n/th.tsv index c8eec9c404..c60dffd9b0 100644 --- a/crates/ui-egui/src/i18n/th.tsv +++ b/crates/ui-egui/src/i18n/th.tsv @@ -172,6 +172,15 @@ Available พร้อมใช้งาน Ready พร้อมใช้ Search tools and commands… ค้นหาเครื่องมือและคำสั่ง… + +# Accessible names for inputs that have no visible label. + Added text ข้อความที่เพิ่ม + Link URL URL ของลิงก์ + Search tools and commands ค้นหาเครื่องมือและคำสั่ง + Text editing line บรรทัดแก้ไขข้อความ + Text to place ข้อความที่จะวาง + Tooltip คำแนะนำเครื่องมือ + Words or phrases to redact คำหรือวลีที่จะปิดทับ No matching tools ไม่มีเครื่องมือที่ตรงกัน Tool เครื่องมือ Command คำสั่ง @@ -2694,6 +2703,14 @@ key Space Space Make properties default ตั้งคุณสมบัติเป็นค่าเริ่มต้น Pages selected หน้าที่เลือก Page properties คุณสมบัติหน้า + Create & Edit สร้างและแก้ไข + Review & Compare ตรวจทานและเปรียบเทียบ + Forms & Signatures แบบฟอร์มและลายเซ็น + Protect & Standardize ป้องกันและกำหนดมาตรฐาน + Customize & Automate ปรับแต่งและทำงานอัตโนมัติ + No tools match your search. ไม่มีเครื่องมือที่ตรงกับการค้นหาของคุณ + Expand all tools ขยายเครื่องมือทั้งหมด + Collapse all tools ยุบเครื่องมือทั้งหมด The password must have at least 8 characters. รหัสผ่านต้องมีอย่างน้อย 8 ตัวอักษร theme Appearance ลักษณะที่ปรากฏ @@ -2702,3 +2719,12 @@ theme Appearance ลักษณะที่ปรากฏ # AI-assisted additions for restoring the last view (#1767) (2026-10-22); not reviewed by a native speaker. Restore the last view (page, zoom and page display) when a document is opened again คืนค่ามุมมองล่าสุด (หน้า การซูม และการแสดงหน้า) เมื่อเปิดเอกสารอีกครั้ง +@plural {n} reply|{n} replies การตอบกลับ {n} รายการ + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools เครื่องมือหน้า + Insert pages from a file after the selection… แทรกหน้าจากไฟล์หลังส่วนที่เลือก… + Rotate the selected pages counterclockwise หมุนหน้าที่เลือกทวนเข็มนาฬิกา + Rotate the selected pages clockwise หมุนหน้าที่เลือกตามเข็มนาฬิกา + Extract the selected pages… แยกหน้าที่เลือกออก… + Delete the selected pages ลบหน้าที่เลือก diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index b19a5ba637..af2c4de64c 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -142,6 +142,15 @@ Available Kullanılabilir Ready Hazır Search tools and commands… Araçları ve komutları ara… + +# Accessible names for inputs that have no visible label. + Added text Eklenen metin + Link URL Bağlantı URL'si + Search tools and commands Araçları ve komutları ara + Text editing line Metin düzenleme satırı + Text to place Yerleştirilecek metin + Tooltip İpucu + Words or phrases to redact Karartılacak sözcükler veya ifadeler No matching tools Eşleşen araç yok Tool Araç Command Komut @@ -2493,6 +2502,14 @@ animation effects Off Kapalı Make properties default Özellikleri varsayılan yap Pages selected Seçilen sayfalar Page properties Sayfa özellikleri + Create & Edit Oluştur ve Düzenle + Review & Compare İncele ve Karşılaştır + Forms & Signatures Formlar ve İmzalar + Protect & Standardize Koru ve Standartlaştır + Customize & Automate Özelleştir ve Otomatikleştir + No tools match your search. Aramanızla eşleşen araç yok. + Expand all tools Tüm araçları genişlet + Collapse all tools Tüm araçları daralt Attach a file… Dosya ekle… Delete attachment Eki sil Attaching files is not available on the web yet Dosya eklemek web'de henüz kullanılamıyor @@ -2535,3 +2552,12 @@ animation effects Off Kapalı # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Bugün +@plural {n} reply|{n} replies {n} yanıt + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Sayfa araçları + Insert pages from a file after the selection… Seçimin ardından dosyadan sayfa ekle… + Rotate the selected pages counterclockwise Seçili sayfaları saat yönünün tersine döndür + Rotate the selected pages clockwise Seçili sayfaları saat yönünde döndür + Extract the selected pages… Seçili sayfaları ayıkla… + Delete the selected pages Seçili sayfaları sil diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 5ac041b69f..2013a206d8 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -182,6 +182,15 @@ Available Доступно Ready Готово Search tools and commands… Пошук інструментів і команд… + +# Accessible names for inputs that have no visible label. + Added text Доданий текст + Link URL URL-адреса посилання + Search tools and commands Пошук інструментів і команд + Text editing line Рядок редагування тексту + Text to place Текст для розміщення + Tooltip Підказка + Words or phrases to redact Слова або фрази для вилучення No matching tools Відповідних інструментів немає Tool Інструмент Command Команда @@ -2519,6 +2528,14 @@ animation effects Off Вимкнено Make properties default Зробити властивості типовими Pages selected Вибрані сторінки Page properties Властивості сторінки + Create & Edit Створення й редагування + Review & Compare Рецензування й порівняння + Forms & Signatures Форми й підписи + Protect & Standardize Захист і стандартизація + Customize & Automate Налаштування й автоматизація + No tools match your search. Немає інструментів, що відповідають пошуку. + Expand all tools Розгорнути всі інструменти + Collapse all tools Згорнути всі інструменти Attach a file… Прикріпити файл… Delete attachment Видалити вкладення Attaching files is not available on the web yet Прикріплення файлів у веб-версії поки недоступне @@ -2561,3 +2578,12 @@ animation effects Off Вимкнено # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Сьогодні +@plural {n} reply|{n} replies {n} відповідь|{n} відповіді|{n} відповідей + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Інструменти сторінок + Insert pages from a file after the selection… Вставити сторінки з файлу після виділення… + Rotate the selected pages counterclockwise Повернути вибрані сторінки проти годинникової стрілки + Rotate the selected pages clockwise Повернути вибрані сторінки за годинниковою стрілкою + Extract the selected pages… Витягнути вибрані сторінки… + Delete the selected pages Видалити вибрані сторінки diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index bb53824dce..60eef4119b 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1065,6 +1065,15 @@ theme Appearance Giao diện Scroll tabs right Cuộn các thẻ sang phải Search comments Tìm bình luận Search tools and commands… Tìm công cụ và lệnh… + +# Accessible names for inputs that have no visible label. + Added text Văn bản đã thêm + Link URL URL của liên kết + Search tools and commands Tìm công cụ và lệnh + Text editing line Dòng chỉnh sửa văn bản + Text to place Văn bản cần đặt + Tooltip Chú giải công cụ + Words or phrases to redact Từ hoặc cụm từ cần xóa Searchable Image (Exact) Ảnh có thể tìm kiếm (Chính xác) Searching… {s} of {p} pages Đang tìm… {s} trên {p} trang Searching… {s}/{p} Đang tìm… {s}/{p} @@ -2684,6 +2693,14 @@ key Space Space Redact Image Xóa nội dung nhạy cảm của hình ảnh Create Link… Tạo liên kết… Recognize Text… Nhận dạng văn bản… + Create & Edit Tạo và chỉnh sửa + Review & Compare Xem xét và so sánh + Forms & Signatures Biểu mẫu và chữ ký + Protect & Standardize Bảo vệ và chuẩn hóa + Customize & Automate Tùy chỉnh và tự động hóa + No tools match your search. Không có công cụ nào khớp với tìm kiếm. + Expand all tools Mở rộng tất cả công cụ + Collapse all tools Thu gọn tất cả công cụ # AI-assisted additions for concurrently merged UI features (2026-10-15). # These additions have not had separate native-speaker review. @@ -2806,3 +2823,12 @@ key Space Space # AI-assisted additions for restoring the last view (#1767) (2026-10-16); not reviewed by a native speaker. Restore the last view (page, zoom and page display) when a document is opened again Khôi phục chế độ xem gần nhất (trang, thu phóng và hiển thị trang) khi mở lại tài liệu +@plural {n} reply|{n} replies {n} câu trả lời + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools Công cụ trang + Insert pages from a file after the selection… Chèn trang từ tệp sau vùng chọn… + Rotate the selected pages counterclockwise Xoay các trang đã chọn ngược chiều kim đồng hồ + Rotate the selected pages clockwise Xoay các trang đã chọn theo chiều kim đồng hồ + Extract the selected pages… Trích xuất các trang đã chọn… + Delete the selected pages Xóa các trang đã chọn diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 5592288ce3..ff517a1102 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1847,6 +1847,15 @@ Tool 工具 Command 命令 Search tools and commands… 搜索工具和命令… + +# Accessible names for inputs that have no visible label. + Added text 已添加的文本 + Link URL 链接 URL + Search tools and commands 搜索工具和命令 + Text editing line 文本编辑行 + Text to place 要放置的文本 + Tooltip 提示 + Words or phrases to redact 要密文的单词或短语 No matching tools 没有匹配的工具 All tools 所有工具 Read 阅读 @@ -2461,6 +2470,14 @@ animation effects Off 关 Make properties default 将属性设为默认 Pages selected 选定的页面 Page properties 页面属性 + Create & Edit 创建和编辑 + Review & Compare 审阅和比较 + Forms & Signatures 表单和签名 + Protect & Standardize 保护和标准化 + Customize & Automate 自定义和自动化 + No tools match your search. 没有与搜索匹配的工具。 + Expand all tools 展开所有工具 + Collapse all tools 折叠所有工具 Attach a file… 附加文件… Delete attachment 删除附件 Attaching files is not available on the web yet 网页版尚不支持附加文件 @@ -2503,3 +2520,12 @@ animation effects Off 关 # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today 今天 +@plural {n} reply|{n} replies {n} 条回复 + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools 页面工具 + Insert pages from a file after the selection… 在所选内容后插入文件中的页面… + Rotate the selected pages counterclockwise 逆时针旋转所选页面 + Rotate the selected pages clockwise 顺时针旋转所选页面 + Extract the selected pages… 提取所选页面… + Delete the selected pages 删除所选页面 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 0cbccccc7a..5fb6bebd6e 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1791,6 +1791,15 @@ Tool 工具 Command 指令 Search tools and commands… 搜尋工具與指令… + +# Accessible names for inputs that have no visible label. + Added text 已新增的文字 + Link URL 連結 URL + Search tools and commands 搜尋工具與指令 + Text editing line 文字編輯行 + Text to place 要放置的文字 + Tooltip 提示 + Words or phrases to redact 要遮蔽的字詞或片語 No matching tools 找不到相符的工具 All tools 所有工具 Read 閱讀 @@ -2447,6 +2456,14 @@ animation effects Off 關 Make properties default 將內容設為預設 Pages selected 選取的頁面 Page properties 頁面屬性 + Create & Edit 建立與編輯 + Review & Compare 審閱與比較 + Forms & Signatures 表單與簽名 + Protect & Standardize 保護與標準化 + Customize & Automate 自訂與自動化 + No tools match your search. 沒有符合搜尋的工具。 + Expand all tools 展開所有工具 + Collapse all tools 摺疊所有工具 Attach a file… 附加檔案… Delete attachment 刪除附件 Attaching files is not available on the web yet 網頁版尚不支援附加檔案 @@ -2489,3 +2506,12 @@ animation effects Off 關 # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today 今天 +@plural {n} reply|{n} replies {n} 則回覆 + +# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed) + Page tools 頁面工具 + Insert pages from a file after the selection… 在所選內容後插入檔案中的頁面… + Rotate the selected pages counterclockwise 逆時針旋轉所選頁面 + Rotate the selected pages clockwise 順時針旋轉所選頁面 + Extract the selected pages… 擷取所選頁面… + Delete the selected pages 刪除所選頁面 diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 79254afa2f..5ad5425e8d 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -20,7 +20,7 @@ pub fn exists(name: &str) -> bool { white_icons().contains(name) } -fn source(name: &str) -> egui::ImageSource<'static> { +pub(crate) fn source(name: &str) -> egui::ImageSource<'static> { // PdfCraft caches an unknown name under the resolved fallback name. white_icons().source(white_icons().canonical_name(name).unwrap_or("square")) } diff --git a/crates/ui-egui/src/js_ui.rs b/crates/ui-egui/src/js_ui.rs index 49611293ac..84aa706576 100644 --- a/crates/ui-egui/src/js_ui.rs +++ b/crates/ui-egui/src/js_ui.rs @@ -77,9 +77,6 @@ impl PdfCraftApp { }; // Show/Hide: a panel already showing closes. self.right = (self.right != Some(panel)).then_some(panel); - if self.right.is_some() { - self.pending_panel_reveals.push(crate::docking::Panel::Inspector); - } } Request::Focus(_) | Request::Beep | Request::Reset(_) => {} } @@ -289,24 +286,66 @@ pub(crate) const PREFERENCES_BUTTON_ROW: f32 = 52.0; /// leaves 173 pt): a taller floor would push OK off the screen again. pub(crate) const PREFERENCES_MIN_SCROLL: f32 = 60.0; +/// Whether the interface can draw a language picker's native name. On the web the browser's glyph +/// rasterizer draws scripts no embedded face has, so the native names stay; on native, ask the +/// fonts this build installed (including an installed fallback face, unless system fonts are off). +fn picker_text_is_drawable(ui: &egui::Ui, text: &str) -> bool { + #[cfg(target_arch = "wasm32")] + { + let _ = (ui, text); + true + } + #[cfg(not(target_arch = "wasm32"))] + { + ui.fonts_mut(|fonts| fonts.has_glyphs(&egui::FontId::proportional(13.0), text)) + } +} + +/// The date picker's displayed name. Its languages are the interface languages, so it uses the +/// same drawable-name fallback: a native name whose script this build has no face for would +/// otherwise abort under egui 0.37's `MissingGlyphPolicy::Panic` (for example Hindi without a +/// Devanagari face and installed system fonts turned off). +fn date_language_picker_name(l: &pdfcraft_engine::dates::DateLanguage, drawable: impl FnOnce(&str) -> bool) -> &'static str { + match crate::i18n::Lang::from_code(l.code) { + Some(language) => language.picker_name(drawable), + None => l.name, + } +} + /// Preferences: interface language and size, appearance, identity and JavaScript. Drawn in a scroll /// area; [`preferences_buttons`] goes below it, outside the scroll, so OK is always in reach. pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { ui.label(egui::RichText::new(tl!("Preferences")).font(theme::semibold(18.0))); ui.horizontal(|ui| { - ui.label(tl!("Interface language")); - let selected = crate::i18n::Lang::from_code(&app.language).map_or(tl!("Auto"), crate::i18n::Lang::name); + let label = ui.label(tl!("Interface language")); + // A native name is only drawn when these fonts can draw it; otherwise the English name + // keeps the list readable instead of aborting on a missing glyph (for example Hindi + // without a Devanagari face, and installed system fonts turned off). + let (selected, picker) = { + let drawable = |text: &str| picker_text_is_drawable(ui, text); + let selected = crate::i18n::Lang::from_code(&app.language) + .map_or_else(|| tl!("Auto").to_owned(), |language| language.picker_name(drawable).to_owned()); + let picker = crate::i18n::Lang::for_picker_by(|language| language.picker_name(drawable)) + .into_iter() + .map(|language| (language, language.picker_name(drawable))) + .collect::>(); + (selected, picker) + }; let before = app.language.clone(); - egui::ComboBox::from_id_salt("interface-language").selected_text(selected).show_ui(ui, |ui| { - if ui.selectable_value(&mut app.language, crate::i18n::AUTO.to_string(), tl!("Auto")).clicked() { - ui.close(); - } - for language in crate::i18n::Lang::for_picker() { - if ui.selectable_value(&mut app.language, language.code().to_string(), language.name()).clicked() { + egui::ComboBox::from_id_salt("interface-language") + .selected_text(selected) + .show_ui(ui, |ui| { + if ui.selectable_value(&mut app.language, crate::i18n::AUTO.to_string(), tl!("Auto")).clicked() { ui.close(); } - } - }); + for (language, name) in &picker { + if ui.selectable_value(&mut app.language, language.code().to_string(), *name).clicked() { + ui.close(); + } + } + }) + .response + .labelled_by(label.id); // Relabel the rest of this dialog in the new language right away, not next frame. if app.language != before { crate::i18n::set_current(crate::i18n::Lang::from_pref(&app.language)); @@ -314,24 +353,28 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok }); ui.horizontal(|ui| { use crate::ui_scale; - ui.label(tl!("Interface size")); + let label = ui.label(tl!("Interface size")); // Auto shows the size it draws at: the display's scale times the system's text scale. let native = ui.ctx().native_pixels_per_point().unwrap_or(1.0); let auto_percent = ui_scale::percent(ui_scale::drawn(None, app.system_text_scale, native)); let auto = crate::i18n::fmt(tl!("Auto ({percent}%)"), &[("percent", &auto_percent.to_string())]); let selected = app.ui_scale.map_or_else(|| auto.clone(), |f| format!("{}%", ui_scale::percent(f))); - egui::ComboBox::from_id_salt("interface-size").selected_text(selected).show_ui(ui, |ui| { - if ui.selectable_value(&mut app.ui_scale, None, auto).clicked() { - ui.close(); - } - for p in ui_scale::PRESETS { - let factor = f32::from(p) / 100.0; - if ui.selectable_label(app.ui_scale.is_some_and(|f| ui_scale::percent(f) == u32::from(p)), format!("{p}%")).clicked() { - app.ui_scale = Some(factor); + egui::ComboBox::from_id_salt("interface-size") + .selected_text(selected) + .show_ui(ui, |ui| { + if ui.selectable_value(&mut app.ui_scale, None, auto).clicked() { ui.close(); } - } - }); + for p in ui_scale::PRESETS { + let factor = f32::from(p) / 100.0; + if ui.selectable_label(app.ui_scale.is_some_and(|f| ui_scale::percent(f) == u32::from(p)), format!("{p}%")).clicked() { + app.ui_scale = Some(factor); + ui.close(); + } + } + }) + .response + .labelled_by(label.id); }); ui.label( egui::RichText::new(tl!("Auto follows the system's text size. Pages always render at the display's full resolution.")) @@ -340,16 +383,12 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok ); ui.horizontal(|ui| { ui.label(tl!("Tools panel side")); - let right = app.docking.tools_on_right(); - if ui.selectable_label(!right, tl!("Left")).clicked() - && let Err(error) = app.docking.set_tools_on_right(false) - { - app.notify(error); + let right = app.side_panels.tools_on_right(); + if ui.selectable_label(!right, tl!("Left")).clicked() { + app.side_panels.tools_side = crate::side_panels::ToolsSide::Left; } - if ui.selectable_label(right, tl!("Right")).clicked() - && let Err(error) = app.docking.set_tools_on_right(true) - { - app.notify(error); + if ui.selectable_label(right, tl!("Right")).clicked() { + app.side_panels.tools_side = crate::side_panels::ToolsSide::Right; } }); ui.add_space(8.0); @@ -444,38 +483,42 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok // is app-wide: every open document follows it, and documents open with it. ui.label(egui::RichText::new(tl!("Reading colours")).font(theme::semibold(13.0))); ui.horizontal(|ui| { - ui.label(tl!("Replace document colours")); + let label = ui.label(tl!("Replace document colours")); let mode = app.reading_colors.mode_name(); - egui::ComboBox::from_id_salt("reading-colors").selected_text(tl!(reading_mode_label(mode))).show_ui(ui, |ui| { - for (m, label) in [ - ("off", "Off"), - ("black-on-white", "Black on white"), - ("white-on-black", "White on black"), - ("yellow-on-black", "Yellow on black"), - ("green-on-black", "Green on black"), - ("custom", "Custom"), - ] { - if ui.selectable_label(mode == m, tl!(label)).clicked() { - match m { - "off" => app.set_reading_colors(pdfcraft_render::ReadingColors::default()), - "custom" => { - // Custom starts from the colours already chosen, or black on white. - let colors = app.reading_colors.params.map_or(pdfcraft_render::ReadingColors::BLACK_ON_WHITE, |params| { - pdfcraft_render::ReadingColors { params: Some(params) } - }); - app.set_reading_colors(colors); - } - // `parse` knows these names; a preset comes with its own flags. - preset => { - if let Ok(colors) = pdfcraft_render::ReadingColors::parse(preset, None, None, None, None) { + egui::ComboBox::from_id_salt("reading-colors") + .selected_text(tl!(reading_mode_label(mode))) + .show_ui(ui, |ui| { + for (m, label) in [ + ("off", "Off"), + ("black-on-white", "Black on white"), + ("white-on-black", "White on black"), + ("yellow-on-black", "Yellow on black"), + ("green-on-black", "Green on black"), + ("custom", "Custom"), + ] { + if ui.selectable_label(mode == m, tl!(label)).clicked() { + match m { + "off" => app.set_reading_colors(pdfcraft_render::ReadingColors::default()), + "custom" => { + // Custom starts from the colours already chosen, or black on white. + let colors = app.reading_colors.params.map_or(pdfcraft_render::ReadingColors::BLACK_ON_WHITE, |params| { + pdfcraft_render::ReadingColors { params: Some(params) } + }); app.set_reading_colors(colors); } + // `parse` knows these names; a preset comes with its own flags. + preset => { + if let Ok(colors) = pdfcraft_render::ReadingColors::parse(preset, None, None, None, None) { + app.set_reading_colors(colors); + } + } } + ui.close(); } - ui.close(); } - } - }); + }) + .response + .labelled_by(label.id); }); if app.reading_colors.params.is_some() { let mut changed = false; @@ -595,10 +638,20 @@ fn date_format(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { } ui.horizontal(|ui| { let label = ui.label(tl!("Language")); - let interface = crate::i18n::current().name(); - let follow = crate::i18n::fmt(tl!("Same as interface ({language})"), &[("language", interface)]); - let current = app.session.date_language().map(str::to_string); - let selected = DATE_LANGUAGES.iter().find(|l| current.as_deref() == Some(l.code)).map_or(follow.clone(), |l| l.name.to_string()); + // Build every label before the combo box mutably borrows `ui`, with the same drawable + // fallback the interface language picker uses. + let (follow, selected, current, names) = { + let drawable = |text: &str| picker_text_is_drawable(ui, text); + let interface = crate::i18n::current().picker_name(drawable); + let follow = crate::i18n::fmt(tl!("Same as interface ({language})"), &[("language", interface)]); + let current = app.session.date_language().map(str::to_string); + let selected = DATE_LANGUAGES + .iter() + .find(|l| current.as_deref() == Some(l.code)) + .map_or_else(|| follow.clone(), |l| date_language_picker_name(l, drawable).to_string()); + let names = DATE_LANGUAGES.iter().map(|l| (l, date_language_picker_name(l, drawable))).collect::>(); + (follow, selected, current, names) + }; egui::ComboBox::from_id_salt("date-language") .selected_text(selected) .width(240.0) @@ -606,9 +659,8 @@ fn date_format(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { if ui.selectable_label(current.is_none(), follow).clicked() && app.session.set_date_language(None).is_ok() { ui.close(); } - for l in &DATE_LANGUAGES { - if ui.selectable_label(current.as_deref() == Some(l.code), l.name).clicked() - && app.session.set_date_language(Some(l.code)).is_ok() + for (l, name) in &names { + if ui.selectable_label(current.as_deref() == Some(l.code), *name).clicked() && app.session.set_date_language(Some(l.code)).is_ok() { ui.close(); } @@ -626,3 +678,21 @@ fn date_format(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { .color(t.text_muted), ); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn date_language_names_fall_back_when_the_native_name_cannot_be_drawn() { + let date_language = |code: &str| pdfcraft_engine::dates::DATE_LANGUAGES.iter().find(|l| l.code == code).expect("date language"); + let hindi = date_language("hi"); + assert_eq!(date_language_picker_name(hindi, |_| true), hindi.name, "the native name is kept when it can be drawn"); + assert_eq!(date_language_picker_name(hindi, |_| false), "Hindi", "no Devanagari face: show the English name"); + let chinese = date_language("zh-hans"); + assert_eq!(date_language_picker_name(chinese, |_| false), "Simplified Chinese"); + for language in &pdfcraft_engine::dates::DATE_LANGUAGES { + assert!(!date_language_picker_name(language, |_| false).is_empty(), "{}", language.code); + } + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4cec41ee9f..0a70a6b2ba 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -9,6 +9,8 @@ // The settings object in `persist` is one `json!` literal past the default limit of 128. #![recursion_limit = "256"] +use crate::control::Host as _; + /// Whether `a` and `b` name the same file: equal, or equal once made absolute against the /// current directory (links are not resolved, as in `last_session`). pub(crate) fn same_file(a: &str, b: &str) -> bool { @@ -32,6 +34,7 @@ macro_rules! tl_ctx { mod a11y_ui; mod actions_ui; +mod all_tools_ui; mod appearance; mod calc_order_ui; pub mod canvas; @@ -41,7 +44,6 @@ mod image_pick; mod tags_ui; pub use combine_grid::ThumbState as CombineThumb; mod combine_ui; -pub mod docking; pub use combine_ui::{Columns as CombineColumns, CombineView, Lock as CombineLock, SortKey}; mod camera_ui; mod commands; @@ -61,9 +63,11 @@ mod js_ui; mod macos_association; mod marks_ui; mod measure_ui; +mod menus; mod ocr_ui; mod optimize_ui; mod search_ui; +pub mod side_panels; mod sign_ui; mod stamps_ui; mod standards_ui; @@ -101,6 +105,7 @@ pub mod icons; pub mod last_session; pub mod last_view; pub mod motion; +mod page_pointer; mod page_selection_ui; mod pageboxes; mod palette; @@ -200,7 +205,7 @@ pub enum LeftPanel { } /// Right-hand panels, opened from the rail. -#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub enum RightPanel { Comments, Bookmarks, @@ -279,8 +284,8 @@ struct GridDrop { /// Dropped on the Pages panel (its gap decides where they go), not the page grid. panel: bool, files: Vec, - /// When to stop waiting for the pointer (egui time, seconds). - deadline: f64, + /// When to stop waiting for the pointer (monotonic: `logic` may run without a UI pass). + deadline: web_time::Instant, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -376,6 +381,7 @@ pub enum PropsTab { Fonts, Custom, Advanced, + SpaceAudit, } /// Document Properties ▸ Custom being edited: the properties as they will be after OK, in order, @@ -620,8 +626,8 @@ pub struct RecentFile { } pub struct PdfCraftApp { - pub docking: docking::Workspace, - pub(crate) pending_panel_reveals: Vec, + /// Tools side and pane widths; which panes are open is `left_open` and `right`. + pub side_panels: side_panels::SidePanels, pub session: Session, pub views: Vec, pub(crate) closed_tabs: Vec, @@ -644,6 +650,8 @@ pub struct PdfCraftApp { pub left: LeftPanel, pub left_open: bool, pub right: Option, + /// The side panel F4 reopens: the one open last. + pub(crate) last_right: RightPanel, /// The right panel that was open when the user switched to Read mode, which starts with the /// panes closed; leaving Read mode brings it back unless the user opened or closed a pane in /// Read mode meanwhile. @@ -693,7 +701,6 @@ pub struct PdfCraftApp { pub fill_text_size: f64, pub dialog: Option, /// How to ask for the latest release (the desktop app sets it; see `updates`). - pub update_source: Option, pub(crate) updates: updates::Updates, /// Lists the Print dialog's printers in the background (the desktop app sets it; see /// [`PrinterSource`]). @@ -706,6 +713,12 @@ pub struct PdfCraftApp { pub palette_open: bool, pub palette_query: String, pub all_tools_expanded: bool, + /// The All tools view fills the workspace (its sidebar's expand button). + pub all_tools_full: bool, + pub all_tools_query: String, + /// The tab that showed when the expanded All tools view opened ([`Self::shown_tab`]). Another + /// tab coming to the front (Home, a document, Combine files) puts the view away. + all_tools_tab: (Option, bool), pub recent: Vec, /// Preferences: reopen the files that were open when PdfCraft last closed (#442). pub reopen_last_session: bool, @@ -848,6 +861,8 @@ pub struct PdfCraftApp { pub cert_viewer: Option, /// The last space audit. pub space_audit: Vec, + /// The document and edit generation `space_audit` was measured from. + pub(crate) space_audit_of: Option<(DocId, u64)>, /// Combine files: the files staged so far. pub combine_draft: Vec, /// The combine list's field-name conflict preview (computed once per list, never saved). @@ -908,11 +923,15 @@ pub struct PdfCraftApp { /// Entries left by a previous session, offered in the Recovery dialog. pub recoverable: Vec, recovery_keys: std::collections::HashMap, - last_autosave: f64, + last_autosave: Option, allow_quit: bool, /// Shortcuts pressed while a text field had the keyboard, run on the next frame (see /// `registry_shortcuts`). deferred_commands: Vec<(&'static str, Option)>, + /// The native menu bar (macOS), while it is installed; the in-window bar shows otherwise. + pub(crate) native_menu: Option, + /// Input a menu click stands for (⌘C, Delete…), added to the next frame's input in `logic`. + pub(crate) menu_input: Vec, /// The dialog seen at the last check, and a counter bumped whenever it changes (see /// [`Self::dialog_epoch`]). dialog_seen: Option, @@ -979,6 +998,8 @@ pub struct PdfCraftApp { pub replace_draft: Option, /// The last web link the app asked the system to open (tests and automation). pub last_opened_url: Option, + /// The home screen's community card was closed; it comes back next launch (not remembered). + pub community_card_closed: bool, /// A document asked to open this address; the user hasn't answered yet (#90, #91). pub pending_link: Option, /// A document just opened asks to be shown in Full Screen mode (`/PageMode /FullScreen`): @@ -1077,6 +1098,7 @@ pub enum OpenOutcome { } impl PdfCraftApp { + /// Construct an application with the default session, panels, theme and control state. pub fn new() -> Self { Self { session: Session::new(), @@ -1090,10 +1112,10 @@ impl PdfCraftApp { date_format_draft: None, mode_override: None, left: LeftPanel::AllTools, - docking: docking::Workspace::default(), - pending_panel_reveals: Vec::new(), + side_panels: Default::default(), left_open: true, right: None, + last_right: RightPanel::Comments, right_before_read: None, comments_panel_closed: false, right_panel_closed: false, @@ -1119,7 +1141,6 @@ impl PdfCraftApp { flatten_fill_sign_on_save: false, fill_text_size: fill_sign::TEXT_SIZE, dialog: None, - update_source: None, updates: updates::Updates::default(), printer_source: None, options_source: None, @@ -1127,6 +1148,9 @@ impl PdfCraftApp { palette_open: false, palette_query: String::new(), all_tools_expanded: false, + all_tools_full: false, + all_tools_query: String::new(), + all_tools_tab: (None, false), recent: Vec::new(), reopen_last_session: false, restore_last_view: true, @@ -1208,6 +1232,7 @@ impl PdfCraftApp { os_key_store_ids: false, cert_viewer: None, space_audit: Vec::new(), + space_audit_of: None, combine_draft: Vec::new(), combine_conflicts: Default::default(), combine_tab: Default::default(), @@ -1238,9 +1263,11 @@ impl PdfCraftApp { recovery: None, recoverable: Vec::new(), recovery_keys: Default::default(), - last_autosave: 0.0, + last_autosave: None, allow_quit: false, deferred_commands: Vec::new(), + native_menu: None, + menu_input: Vec::new(), dialog_seen: None, dialog_epoch: 0, ctx: None, @@ -1252,6 +1279,7 @@ impl PdfCraftApp { control: None, bookmark_rename: None, last_opened_url: None, + community_card_closed: false, pending_link: None, pending_full_screen: None, pending_remote_link: None, @@ -1593,6 +1621,10 @@ impl PdfCraftApp { { view.open_organize(); } + // The document shows, not the expanded All tools view over it (whichever workspace opens). + if activate { + self.all_tools_full = false; + } if activate && let Some(mode) = self.mode_override { // Explicit mode options do not reset independent --tool / --left choices. self.mode = mode; @@ -1699,7 +1731,7 @@ impl PdfCraftApp { // Where there is no telling where the pointer is during the drag, its place arrives // with the first move after the drop. ctx.request_repaint(); - self.grid_drop = Some(GridDrop { doc: id, panel, files, deadline: ctx.input(|i| i.time) + 1.0 }); + self.grid_drop = Some(GridDrop { doc: id, panel, files, deadline: web_time::Instant::now() + std::time::Duration::from_secs(1) }); } Vec::new() } @@ -1723,7 +1755,7 @@ impl PdfCraftApp { let Some(view) = self.active.and_then(|i| self.views.get_mut(i)) else { return }; let gap = match if panel { view.panel_gap } else { view.grid_gap } { Some(gap) => gap, - None if ctx.input(|i| i.time) >= deadline => usize::MAX, + None if web_time::Instant::now() >= deadline => usize::MAX, None => return ctx.request_repaint(), }; view.insert_at = Some(gap); @@ -2449,12 +2481,13 @@ impl PdfCraftApp { pub(crate) fn reveal_tools(&mut self) { self.left_open = true; - self.pending_panel_reveals.push(docking::Panel::Tools); } /// Enter a tool from the catalogue, Home, palette or control channel. Organize is a /// workspace, so opening its panel also opens the page grid (without toggling it off). pub(crate) fn select_tool(&mut self, id: &'static str) { + // The tool's panel shows, not the expanded All tools view over it. + self.all_tools_full = false; self.left = LeftPanel::Tool(id); self.reveal_tools(); if let Some(view) = self.active.and_then(|i| self.views.get_mut(i)) { @@ -2489,6 +2522,10 @@ impl PdfCraftApp { /// Edit puts the editing tools down again and applies a paragraph still open in the editor. pub(crate) fn enter_mode(&mut self, mode: Mode) { self.mode = mode; + // Another workspace puts the expanded All tools view away. + if mode != Mode::AllTools { + self.all_tools_full = false; + } if mode == Mode::Edit { self.quick_tool = QuickTool::EditText; } else if matches!(self.quick_tool, QuickTool::EditText | QuickTool::AddText) { @@ -2501,6 +2538,21 @@ impl PdfCraftApp { } } + /// Fill the workspace with the expanded All tools view, entering All tools the way the mode + /// bar does: leaving Read restores its right panel and leaving Edit puts the editing tool down. + pub(crate) fn expand_all_tools(&mut self) { + if self.mode != Mode::AllTools { + self.switch_mode(Mode::AllTools, true); + } + self.all_tools_full = true; + self.all_tools_tab = self.shown_tab(); + } + + /// The tab at the front: the active document, or else whether Combine files shows over Home. + fn shown_tab(&self) -> (Option, bool) { + (self.active.and_then(|i| self.views.get(i)).map(|v| v.id), self.combine_showing()) + } + /// Back to the Select tool from an editing tool. A paragraph still open in the editor is /// applied, as clicking away from it does; left open, its page would keep showing the /// paragraph's text removed with no editor over it. @@ -2537,9 +2589,6 @@ impl PdfCraftApp { if self.right.is_none() { self.right = before; } - if self.right.is_some() { - self.pending_panel_reveals.push(docking::Panel::Inspector); - } } } @@ -2561,17 +2610,21 @@ impl PdfCraftApp { self.right_panel_closed = true; } self.right = panel; - if panel.is_some() { - self.pending_panel_reveals.push(docking::Panel::Inspector); + if let Some(panel) = panel { + self.last_right = panel; } } + /// F4: closes the side panel, or reopens the one open last. + pub(crate) fn toggle_side_panel(&mut self) { + self.choose_right_panel(if self.right.is_some() { None } else { Some(self.last_right) }); + } + /// Serialize the user's persistent state (recent files, theme). Local only. pub fn persist(&self) -> String { let trusted: Vec = self.session.trusted_certificates().iter().map(pdfcraft_engine::sign::x509::to_pem).collect(); let mut v = serde_json::json!({ - "docking": self.docking, - "docking_left_open": self.left_open, + "side_panels": self.side_panels.to_json(self.left_open), "docking_right": self.right, "recent": self.recent, "reopen_last_session": self.reopen_last_session, @@ -2648,14 +2701,13 @@ impl PdfCraftApp { /// Restore state written by `persist`. Unknown or malformed data is ignored. pub fn restore(&mut self, json: &str) { let Ok(v) = serde_json::from_str::(json) else { return }; - if let Some(saved) = v.get("docking") - && let Ok(workspace) = serde_json::from_value::(saved.clone()) - && workspace.validate().is_ok() - { - self.docking = workspace; - self.left_open = - v.get("docking_left_open").and_then(serde_json::Value::as_bool).unwrap_or(self.docking.layout.contains(&docking::Panel::Tools)); - self.right = v.get("docking_right").and_then(|value| serde_json::from_value(value.clone()).ok()).flatten(); + let panels = side_panels::restore(&v); + self.side_panels = panels.panels; + if let Some(open) = panels.tools_open { + self.left_open = open; + } + if let Some(right) = v.get("docking_right") { + self.right = serde_json::from_value(right.clone()).ok().flatten().filter(|_| !panels.side_closed); } self.combine_columns = combine_ui::Columns::from_json(&v["combine_columns"]); if let Some(view) = v["combine_view"].as_str().and_then(combine_ui::CombineView::parse) { @@ -2885,6 +2937,10 @@ impl PdfCraftApp { let mode = Mode::parse(value).unwrap_or(Mode::AllTools); self.mode_override = Some(mode); self.mode = mode; + // As switch_mode: another workspace puts the expanded All tools view away. + if mode != Mode::AllTools { + self.all_tools_full = false; + } } ("default-mode", _) => { self.default_mode = Mode::parse(value).ok_or("default-mode must be all, read, edit, convert or sign")?; @@ -2896,7 +2952,12 @@ impl PdfCraftApp { self.select_tool(g.id); } ("left", _) => self.left_open = value != "closed", - ("home", _) => self.active = None, + ("home", _) => { + self.active = None; + // With no document open the expanded All tools view covers Home itself, so no + // other tab comes forward to put it away. + self.all_tools_full = false; + } ("dialog", _) => { self.dialog = match value { "properties" => Some(Dialog::Properties(PropsTab::Description)), @@ -2904,6 +2965,7 @@ impl PdfCraftApp { "fonts" => Some(Dialog::Properties(PropsTab::Fonts)), "custom" => Some(Dialog::Properties(PropsTab::Custom)), "advanced" => Some(Dialog::Properties(PropsTab::Advanced)), + "space-audit" => Some(Dialog::Properties(PropsTab::SpaceAudit)), "shortcuts" => Some(Dialog::Shortcuts), "split" => Some(Dialog::Split), "protect" => Some(Dialog::Protect), @@ -2943,7 +3005,11 @@ impl PdfCraftApp { self.marks_draft.opened(kind); } } - ("tools", _) => self.all_tools_expanded = value != "collapsed", + ("tools", _) if value == "full" => self.expand_all_tools(), + ("tools", _) => { + self.all_tools_full = false; + self.all_tools_expanded = value != "collapsed"; + } ("palette", _) => { self.palette_open = true; self.palette_query = value.to_string(); @@ -3008,11 +3074,13 @@ impl PdfCraftApp { _ => return Err("always-default-view must be on or off".into()), }; } - ("tools-side", _) => self.docking.set_tools_on_right(match value { - "left" => false, - "right" => true, - _ => return Err("tools-side must be left or right".into()), - })?, + ("tools-side", _) => { + self.side_panels.tools_side = match value { + "left" => side_panels::ToolsSide::Left, + "right" => side_panels::ToolsSide::Right, + _ => return Err("tools-side must be left or right".into()), + } + } ("organize", Some(v)) => { if value == "off" { v.organize = false; @@ -3200,12 +3268,6 @@ impl PdfCraftApp { } (other, _) => return Err(format!("unknown option {other}")), } - if key == "panel" && self.right.is_some() { - self.pending_panel_reveals.push(docking::Panel::Inspector); - } - if key == "left" && self.left_open { - self.pending_panel_reveals.push(docking::Panel::Tools); - } Ok(()) } @@ -3239,7 +3301,12 @@ impl PdfCraftApp { { return; } - self.registry_shortcuts(ctx); + // The in-window menu bar holds the keys while one of its menus is open or it has the + // keyboard (Alt, F10); a command shortcut leaves it and runs. + craft_menu::bar::leave_for_shortcut(ctx); + if !craft_menu::bar::is_open(ctx) && !craft_menu::bar::is_focused(ctx) { + self.registry_shortcuts(ctx); + } // (Esc closes a Combine file shown large first.) if self.full_screen && self.combine_tab.preview.is_none() && ctx.input(|i| i.key_pressed(Key::Escape)) { self.set_full_screen(ctx, false); @@ -3396,6 +3463,64 @@ impl PdfCraftApp { } } +/// Root-level panel salts whose 0.36 persisted state key changed in 0.37. +/// +/// The shared rail and side panes now own their state. The legacy `rail` and +/// `read_right_panel` entries are inert; the former `rail-nav` footer is no longer migrated. +const PANEL_SALTS: [&str; 4] = ["tab_strip", "mode_bar", "rail", "read_right_panel"]; + +/// Copy persisted 0.36 panel sizes to 0.37's parent-scope key, once, before the first panel. +/// +/// 0.36 stored [`egui::PanelState`] under the panel's own `Id::unique(salt)`; 0.37 derives it +/// from the parent `Ui`'s scope plus an `IdSalt`. A missing copy would reset a width the user +/// chose. The old entry is left untouched, so rolling back to 0.36 still finds it. +fn migrate_panel_state(ui: &egui::Ui) { + if ui.ctx().viewport_id() != egui::ViewportId::ROOT { + return; + } + for salt in PANEL_SALTS { + migrate_panel_state_from(ui, salt); + } +} + +/// Copy one 0.36 panel's state to the key derived from this exact parent `Ui`. +/// +/// The parent must be the `Ui` the panel is shown in (for a nested panel, the panel's content +/// `Ui`), because 0.37's key includes that `Ui`'s scope. +pub(crate) fn migrate_panel_state_from(ui: &egui::Ui, salt: &str) { + let old = egui::Id::unique(salt); + let new = ui.scope_id().with_salt(egui::IdSalt::new(salt)); + if old == new || egui::PanelState::load(ui.ctx(), new).is_some() { + return; + } + if let Some(state) = egui::PanelState::load(ui.ctx(), old) { + ui.ctx().data_mut(|data| data.insert_persisted(new, state)); + log::debug!("migrated panel {salt} state {:?}", state.size()); + } +} + +impl PdfCraftApp { + /// The parts of [`eframe::App::raw_input_hook`] that must run before egui's input hooks: + /// publish this pass's input-target generation to the control plugin, and hand the wheel + /// input to the reduced-motion reader. + /// + /// A test host that drives the app without eframe's raw hook can call this first, so its + /// frame ordering matches production instead of hiding focus-change bugs. + #[doc(hidden)] + pub fn prepare_raw_input(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + // The control plugin's input hook runs next: give it this pass's input generation. + if let Some(control) = &self.control { + control.set_epoch(self.input_epoch()); + } + // Before egui reads this frame's wheel input. + self.scroll_speed.apply(ctx, raw); + // The native menu's key equivalents. + if let Some(native) = self.native_menu.as_mut() { + native.raw_input(raw); + } + } +} + impl eframe::App for PdfCraftApp { /// While files are dragged over the window the system sends no pointer moves, so egui /// would keep the place the pointer entered at: tell it where the pointer really is, so the @@ -3403,8 +3528,7 @@ impl eframe::App for PdfCraftApp { fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { // The button's release a window move or resize kept from egui. window_controls::release_after_desktop_grab(ctx, raw); - // Before egui reads this frame's wheel input. - self.scroll_speed.apply(ctx, raw); + self.prepare_raw_input(ctx, raw); if raw.hovered_files.is_empty() && raw.dropped_files.is_empty() { return; } @@ -3464,8 +3588,8 @@ impl eframe::App for PdfCraftApp { self.startup_superseded |= !self.views.is_empty() || self.combine_showing() || self.dialog.is_some() || self.password_prompt.is_some(); // Also done in `raw_input_hook`, before input; here for hosts that don't call the hook. self.scroll_speed.set_line_speed(ctx); - // Before anything reads this frame's wheel input or draws, and with the line speed just set. - self.motion.begin_pass(ctx, self.animation_effects); + // Motion reads this frame's wheel input, so it begins in `ui`, in a known UI phase: + // `logic` also runs alone while hidden, where the input events and time are stale. // Before taking this frame's drop: the grid must be drawn once with the pointer where // the files were let go before the gap is read. self.finish_grid_drop(ctx); @@ -3549,9 +3673,9 @@ impl eframe::App for PdfCraftApp { } self.guard_quit(ctx); // macOS: quit through `terminate:` instead of dropping the window (Touch Bar crash, #1136). + #[cfg(target_os = "macos")] craft_quit::on_close_request(ctx); - let now = ctx.input(|i| i.time); - self.autosave_tick(ctx, now); + self.autosave_tick(ctx, web_time::Instant::now()); // Reload documents whose files a build rewrote (#431). #[cfg(not(target_arch = "wasm32"))] self.watch_files(); @@ -3565,6 +3689,12 @@ impl eframe::App for PdfCraftApp { self.poll_print(); // Shortcuts deferred last frame: the text field has taken that frame's typing since. let deferred = std::mem::take(&mut self.deferred_commands); + self.native_menu_clicks(ctx); + // The keys a menu click stands for (⌘C, Delete…), before anything reads this frame's keys. + if !self.menu_input.is_empty() { + let events = std::mem::take(&mut self.menu_input); + ctx.input_mut(|i| i.events.extend(events)); + } self.shortcuts(ctx); // Scrolling and group-drag previews are transient: never resume after changing tabs, // opening a modal/palette, or returning to a window that lost focus. @@ -3595,16 +3725,23 @@ impl eframe::App for PdfCraftApp { self.process_picked(); // Last, after everything that can open or answer the "save changes?" question. self.attend_save_prompt(ctx); + self.sync_native_menu(ctx); } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { let ctx = ui.ctx().clone(); i18n::set_current(i18n::Lang::from_pref(&self.language)); + // A language change switches stock chrome between LTR and RTL before anything is drawn. + theme::sync_direction(&ctx); // Fonts registered via set_fonts only take effect next frame; named families would panic now. if !self.fonts_ready { ctx.request_repaint(); return; } + // Before any widget reads this frame's wheel input or draws. + self.motion.begin_pass(&ctx, self.animation_effects); + // One-time 0.36 -> 0.37 panel persistence migration, before any panel stores or reads state. + migrate_panel_state(ui); for view in &mut self.views { view.begin_render_frame(); } @@ -3618,6 +3755,9 @@ impl eframe::App for PdfCraftApp { ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone())); self.window_title = title; } + // The accessible window takes the same name, so a screen reader (AT-SPI on Linux, UIA on + // Windows) can tell it apart (#4614). egui builds the root node afresh every pass. + ctx.accesskit_node_builder(egui::accesskit_root_id(), |root| root.set_label(self.window_title.as_str())); if self.full_screen && self.active.is_some() { // Full screen: the page on black, nothing else, as Acrobat's Full Screen Mode (Esc // or ⌘L to leave). @@ -3634,6 +3774,10 @@ impl eframe::App for PdfCraftApp { self.show_progress(&ctx); widgets::toast(self, &ctx); self.finish_render_frame(&ctx); + if let Some(mut control) = self.control.take() { + control.finish_frame(&ctx, self); + self.control = Some(control); + } return; } self.sync_window_frame(&ctx); @@ -3642,23 +3786,16 @@ impl eframe::App for PdfCraftApp { } chrome::tab_strip(self, ui); chrome::mode_bar(self, ui); - if self.active.is_some() { - chrome::right_rail(self, ui); - // Read mode hides the tools, not the navigation panes: the rail stays visible - // there, so its buttons (Bookmarks, Page thumbnails, ...) must open their panels. - // Outside Read mode the docking workspace draws the inspector. - if self.mode == Mode::Read && self.right.is_some() { - egui::Panel::right("read_right_panel") - .resizable(true) - .default_size(330.0) - .size_range(260.0..=520.0) - .frame(egui::Frame::NONE) - .show(ui, |ui| panels::right_panel(self, ui)); - } + // Whichever way another tab came to the front, it shows instead of the expanded view. + if self.all_tools_full && self.all_tools_tab != self.shown_tab() { + self.all_tools_full = false; } + side_panels::show(self, ui); let t = theme::Tokens::get(&ctx); egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.pasteboard)).show(ui, |ui| { - if self.mode == Mode::Read { + if self.mode == Mode::AllTools && self.all_tools_full { + all_tools_ui::show(self, ui); + } else { match self.active { None if self.combine_showing() => combine_ui::page(self, ui), None => home::show(self, ui), @@ -3667,8 +3804,6 @@ impl eframe::App for PdfCraftApp { None => canvas::document_area(self, index, ui), }, } - } else { - docking::show(self, ui); } }); self.process_pending_edits(); @@ -3677,6 +3812,11 @@ impl eframe::App for PdfCraftApp { self.show_progress(&ctx); widgets::toast(self, &ctx); self.finish_render_frame(&ctx); + // Screenshots are issued only after a settled pass that really drew the window. + if let Some(mut control) = self.control.take() { + control.finish_frame(&ctx, self); + self.control = Some(control); + } } } @@ -3706,3 +3846,75 @@ impl PdfCraftApp { } } } + +#[cfg(test)] +mod panel_migration_tests { + use super::*; + + fn state(width: f32) -> egui::PanelState { + egui::PanelState { outer_rect: egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(width, 900.0)) } + } + + /// 0.36 stored a panel's persisted size under `Id::unique(salt)`. 0.37 derives the key from + /// the root `Ui`'s scope; the upgrade must carry the chosen width over, not reset it. + #[test] + fn upgrade_from_036_panel_memory_keeps_width() { + let ctx = egui::Context::default(); + let old = egui::Id::unique("rail"); + ctx.data_mut(|d| d.insert_persisted(old, state(287.0))); + let mut migrated = None; + let mut new_id = None; + let output = ctx.run_ui(egui::RawInput::default(), |ui| { + migrate_panel_state(ui); + new_id = Some(ui.scope_id().with_salt(egui::IdSalt::new("rail"))); + migrated = egui::PanelState::load(ui.ctx(), new_id.expect("an id")); + }); + output.drop_without_applying_deltas(); + let migrated = migrated.expect("the 0.36 panel width migrates to the 0.37 key"); + assert!((migrated.size().x - 287.0).abs() <= 1.0, "width survives: {:?}", migrated.size()); + assert_eq!(new_id.unwrap(), ctx.viewport_id().root_ui_id().with_salt(egui::IdSalt::new("rail"))); + assert!(egui::PanelState::load(&ctx, old).is_some(), "the old key stays for rollback"); + } + + /// A synthetic nested panel is keyed from its own parent `Ui`, not the root. + /// Migrating it at the root would silently miss its 0.36 state; the fixture uses + /// legacy rail names but does not model the current shared rail implementation. + #[test] + fn nested_panel_state_migrates_from_its_actual_parent_ui() { + let ctx = egui::Context::default(); + let old = egui::Id::unique("rail-nav"); + ctx.data_mut(|d| d.insert_persisted(old, state(140.0))); + let mut nested_id = None; + let mut migrated = None; + let output = ctx.run_ui(egui::RawInput::default(), |ui| { + egui::Panel::right("rail").resizable(false).exact_size(48.0).show(ui, |ui| { + migrate_panel_state_from(ui, "rail-nav"); + // The key egui will use for the nested panel shown in this same Ui. + let key = ui.scope_id().with_salt(egui::IdSalt::new("rail-nav")); + nested_id = Some(key); + migrated = egui::PanelState::load(ui.ctx(), key); + egui::Panel::bottom("rail-nav").resizable(false).show(ui, |_ui| {}); + }); + }); + output.drop_without_applying_deltas(); + let migrated = migrated.expect("the nested 0.36 state migrates to its parent-scoped key"); + assert_eq!(migrated.size().x, 140.0); + assert_ne!(nested_id.unwrap(), ctx.viewport_id().root_ui_id().with_salt(egui::IdSalt::new("rail-nav")), "the nested key is not the root key"); + } + + /// The one-time copy must never overwrite state a 0.37 run has already stored. + #[test] + fn migration_does_not_replace_a_037_panel_state() { + let ctx = egui::Context::default(); + let old = egui::Id::unique("rail"); + let new = ctx.viewport_id().root_ui_id().with_salt(egui::IdSalt::new("rail")); + ctx.data_mut(|d| { + d.insert_persisted(old, state(287.0)); + d.insert_persisted(new, state(412.0)); + }); + let output = ctx.run_ui(egui::RawInput::default(), |ui| migrate_panel_state(ui)); + output.drop_without_applying_deltas(); + let kept = egui::PanelState::load(&ctx, new).expect("the 0.37 state"); + assert_eq!(kept.size().x, 412.0); + } +} diff --git a/crates/ui-egui/src/link_ui.rs b/crates/ui-egui/src/link_ui.rs index c714870c53..f7768912dd 100644 --- a/crates/ui-egui/src/link_ui.rs +++ b/crates/ui-egui/src/link_ui.rs @@ -7,6 +7,7 @@ use pdfcraft_engine::{Edit, LinkAction, LinkHighlight, LinkItem, LinkStyle}; use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; +use crate::page_pointer::PagePointer; use crate::theme::{self, Tokens}; use crate::{PdfCraftApp, widgets}; @@ -56,21 +57,23 @@ fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] { } /// Link tool input on one page. Returns `true` when the gesture belongs to the tool. +#[allow(clippy::too_many_arguments)] pub(crate) fn page_input( ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, + ptr: &PagePointer, page: usize, info: &DocInfo, links: &[LinkItem], view: &mut DocView, ) -> bool { - let pointer = ui.input(|i| i.pointer.hover_pos().or(i.pointer.interact_pos())); + let pointer = ptr.pos; let lv = &mut view.links; if let Some((dp, start)) = lv.drag && dp == page { - if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) { + if resp.drag_stopped() || !ptr.primary_down { lv.drag = None; let end = pointer.unwrap_or(start); let r = Rect::from_two_pos(start, end).intersect(xf.rect); @@ -104,7 +107,7 @@ pub(crate) fn page_input( return true; } if resp.drag_started() { - let origin = ui.input(|i| i.pointer.press_origin()).unwrap_or(p); + let origin = ptr.origin.unwrap_or(p); lv.drag = Some((page, origin)); return true; } @@ -112,7 +115,7 @@ pub(crate) fn page_input( } /// Outlines of the page's links (selected one bold) and the rectangle being drawn. -pub(crate) fn paint(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, links: &[LinkItem], view: &DocView) { +pub(crate) fn paint(painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, links: &[LinkItem], view: &DocView, pointer: Option) { for l in links.iter().filter(|l| l.page == page) { let r = screen_rect(xf, info, page, l.rect); let selected = view.links.selected == Some((page, l.index)); @@ -123,7 +126,7 @@ pub(crate) fn paint(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, page egui::StrokeKind::Outside, ); } - if let (Some((dp, start)), Some(p)) = (view.links.drag, ui.input(|i| i.pointer.hover_pos())) + if let (Some((dp, start)), Some(p)) = (view.links.drag, pointer) && dp == page { let r = Rect::from_two_pos(start, p); @@ -288,7 +291,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut LinkDraft, pages: usize, t: &Token ui.add_enabled_ui(d.web, |ui| { ui.horizontal(|ui| { ui.add_space(24.0); - ui.add(crate::widgets::line(&mut d.url).desired_width(360.0).hint_text("https://example.org")); + ui.add(crate::widgets::line(&mut d.url).desired_width(360.0).hint_text("https://example.org")).accessible_name(tl!("Link URL")); }); }); widgets::radio_value(ui, &mut d.web, false, tl!("Go to a page view")); diff --git a/crates/ui-egui/src/marks_ui.rs b/crates/ui-egui/src/marks_ui.rs index a075814aa2..02aa3ac672 100644 --- a/crates/ui-egui/src/marks_ui.rs +++ b/crates/ui-egui/src/marks_ui.rs @@ -236,8 +236,19 @@ impl Default for MarksDraft { const PAGE_FORMATS: [&str; 5] = ["<<1>>", "<<1 of n>>", "<<1/n>>", "<>", "<>"]; // New formats go last: `date_format` is an index into this list. -const DATE_FORMATS: [&str; 8] = - ["<>", "<>", "<>", "<>", "<>", "<>", "<>", "<>"]; +const DATE_FORMATS: [&str; 11] = [ + "<>", + "<>", + "<>", + "<>", + "<>", + "<>", + "<>", + "<>", + "<>", + "<>", + "<>", +]; const BOX_NAMES: [&str; 6] = ["Left Header Text", "Center Header Text", "Right Header Text", "Left Footer Text", "Center Footer Text", "Right Footer Text"]; diff --git a/crates/ui-egui/src/measure_ui.rs b/crates/ui-egui/src/measure_ui.rs index 7c675c694a..79670ad304 100644 --- a/crates/ui-egui/src/measure_ui.rs +++ b/crates/ui-egui/src/measure_ui.rs @@ -196,7 +196,7 @@ pub(crate) fn page_input( let v = info.user_to_view(p[0] as f32, p[1] as f32); xf.norm_to_screen(v[0] / xf.pw, v[1] / xf.ph) }; - let pos = ui.input(|i| i.pointer.hover_pos()).filter(|p| xf.rect.contains(*p)); + let pos = cx.ptr.within(xf.rect); let mut hover = None; let mut snap = None; if let Some(pos) = pos { @@ -353,6 +353,7 @@ fn finish(state: &mut MeasureView, pending: &mut Option, doc: &Document, p pub(crate) fn panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { egui::ScrollArea::vertical().id_salt("measure-panel").show(ui, |ui| panel_body(app, ui, t)); } +/// Draw the measurement panel: tool selection, scale calibration, snapping and the current reading. fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) { crate::widgets::begin_dialog(ui); let Some((index, id)) = app.active_ids() else { @@ -406,8 +407,8 @@ fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) { ui.label(tl!("Click the first point to begin.")); } ui.horizontal(|ui| { - ui.label(tl!("Label")); - ui.add(crate::widgets::line(&mut state.label).desired_width(130.0).char_limit(128)); + let label = ui.label(tl!("Label")); + ui.add(crate::widgets::line(&mut state.label).desired_width(130.0).char_limit(128)).labelled_by(label.id); }); ui.separator(); ui.add(widgets::checkbox(&mut state.snap_enabled, tl!("Snap to drawing"))); @@ -429,7 +430,7 @@ fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) { ui.horizontal(|ui| { ui.label(tl!("Drawing scale")).on_hover_text(tl!("Applying a scale switches units to m / mm.")); let typed = ui.add(crate::widgets::line(&mut state.ratio_text).hint_text("1:100").desired_width(56.0).char_limit(16)); - typed.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, true, tl!("Scale"))); + typed.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TextInput, true, tl!("Scale"))); state.ratio_dirty |= typed.changed(); if typed.lost_focus() && std::mem::take(&mut state.ratio_dirty) { match parse_ratio(&state.ratio_text) { @@ -459,8 +460,8 @@ fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) { ui.add(egui::DragValue::new(&mut state.real_distance).range(0.000001..=1e12)); }); ui.horizontal(|ui| { - ui.label(tl!("Unit")); - ui.add(crate::widgets::line(&mut state.unit).desired_width(64.0).char_limit(12)); + let unit = ui.label(tl!("Unit")); + ui.add(crate::widgets::line(&mut state.unit).desired_width(64.0).char_limit(12)).labelled_by(unit.id); ui.add(egui::DragValue::new(&mut state.precision).range(0..=6).prefix(format!("{} ", tl!("Decimals")))); }); // A hand edit of the fields leaves the 1:N field showing the old ratio: say the new one. @@ -480,8 +481,8 @@ fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) { } } ui.horizontal(|ui| { - ui.label(tl!("Viewport")); - ui.add(crate::widgets::line(&mut state.name).desired_width(130.0).char_limit(128)); + let viewport = ui.label(tl!("Viewport")); + ui.add(crate::widgets::line(&mut state.name).desired_width(130.0).char_limit(128)).labelled_by(viewport.id); }); let apply = ui.add_enabled(doc.allows_annotation(), egui::Button::new(tl!("Apply scale"))).clicked(); if let Some(error) = &state.error { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs new file mode 100644 index 0000000000..786840b650 --- /dev/null +++ b/crates/ui-egui/src/menus.rs @@ -0,0 +1,791 @@ +//! The menus: one definition ([`PdfCraftApp::menu_bar`]) for the native macOS menu bar and the +//! in-window bar on Windows, Linux and the web, laid out for each platform by craft-menu. +//! +//! Items run registry commands (`pdfcraft_engine::commands`) by id, with the registry's label and +//! shortcut, so a menu click and its shortcut run the same command. A few items have menu-only +//! ids: the zoom keys the document view handles itself, the side panel choices, the clipboard +//! keys, the recent files and the open documents. [`PdfCraftApp::run_menu_command`] runs every +//! one of them, once per click, whichever bar it came from. + +use craft_keymap::{Key, Mods}; +use craft_menu::{ + Builtin, Chord, CommandRef, Document, Gate, Item, ItemRole, ItemState, Labeled, LayoutOptions, Menu, MenuBar, MenuRole, Node, Target, +}; +use pdfcraft_engine::catalog::{Availability, TOOL_GROUPS}; +use pdfcraft_engine::commands::{self, Shortcut}; + +use crate::canvas::{Fit, PageLayout}; +use crate::theme::ThemePreference; +use crate::{PdfCraftApp, RightPanel}; + +/// The app's name in the menus the layouts write (About, Hide, Quit). +pub(crate) const APP_NAME: &str = "PdfCraft"; + +/// The top bar's width after the menus: the mode tabs, the search box and the buttons. +const ROW_AFTER_MENUS: f32 = 720.0; +/// The narrowest the menu bar gets: one "Menu" title. +const MIN_MENUS_WIDTH: f32 = 80.0; + +/// Menu-only commands. The registry's commands keep their own ids. +pub(crate) const CUT: &str = "edit.cut"; +pub(crate) const PASTE: &str = "edit.paste"; +pub(crate) const DELETE: &str = "edit.delete"; +pub(crate) const SELECT_ALL: &str = "edit.select_all"; +pub(crate) const NO_RECENT: &str = "file.no_recent"; +/// An open document in the Window menu, `{"index": n}` in the tab strip. +pub(crate) const DOCUMENT: &str = "window.document"; +/// A side panel, `{"panel": name}` ([`PANELS`]). +pub(crate) const PANEL: &str = "view.panel"; +const ACTUAL_SIZE: &str = "view.zoom.actual_size"; +const PAGE_LEVEL: &str = "view.zoom.page_level"; +const FIT_WIDTH: &str = "view.zoom.fit_width"; +const FIT_HEIGHT: &str = "view.zoom.fit_height"; +const ZOOM_IN: &str = "view.zoom.in"; +const ZOOM_OUT: &str = "view.zoom.out"; +const ROTATE_CW: &str = "view.rotate.cw"; +const ROTATE_CCW: &str = "view.rotate.ccw"; + +/// The menu-only items: id, English label and the shortcut shown (the keys stay with the view). +const EXTRA: &[(&str, &str, Option)] = &[ + (CUT, "Cut", Some(Shortcut::cmd("X"))), + (PASTE, "Paste", Some(Shortcut::cmd("V"))), + (DELETE, "Delete", Some(Shortcut { command: false, shift: false, mac_ctrl: false, key: "Delete" })), + (SELECT_ALL, "Select all", Some(crate::commands::SELECT_ALL)), + (NO_RECENT, "No recent files", None), + (ACTUAL_SIZE, "Actual size", Some(crate::commands::ACTUAL_SIZE)), + (PAGE_LEVEL, "Zoom to page level", Some(crate::commands::PAGE_LEVEL)), + (FIT_WIDTH, "Fit to width", Some(crate::commands::FIT_WIDTH)), + (FIT_HEIGHT, "Fit to height", None), + (ZOOM_IN, "Zoom in", Some(crate::commands::ZOOM_IN)), + (ZOOM_OUT, "Zoom out", Some(crate::commands::ZOOM_OUT)), + (ROTATE_CW, "Rotate view clockwise", Some(crate::commands::ROTATE_CW)), + (ROTATE_CCW, "Rotate view counterclockwise", Some(crate::commands::ROTATE_CCW)), +]; + +/// View ▸ Side panels: the panel, its id in [`PANEL`] commands and its English label. +const PANELS: &[(RightPanel, &str, &str)] = &[ + (RightPanel::Comments, "comments", "Comments"), + (RightPanel::Bookmarks, "bookmarks", "Bookmarks"), + (RightPanel::Pages, "pages", "Pages"), + (RightPanel::Fields, "fields", "Fields"), + (RightPanel::Layers, "layers", "Layers"), + (RightPanel::Attachments, "attachments", "Attachments"), + (RightPanel::Signatures, "signatures", "Signatures"), + (RightPanel::Accessibility, "accessibility", "Accessibility Checker"), + (RightPanel::Tags, "tags", "Tags"), + (RightPanel::Search, "search", "Search"), + (RightPanel::Compare, "compare", "Compare"), +]; + +/// The English titles of the menus and submenus, for the bar's mnemonics in languages whose +/// labels have no ASCII letter. +const TITLES: &[&str] = &[ + "File", + "Edit", + "View", + "Pages", + "Sign", + "Window", + "Help", + "Open Recent", + "Create", + "Export", + "Protect", + "Zoom", + "Rotate view", + "Page navigation", + "Page display", + "Side panels", + "Display theme", + "Tools", + "Fill & Sign", +]; + +/// Separator. +const SEP: &str = "---"; + +const FILE: &[&str] = &[ + "create.blank", + "file.open", + "file.open_recent", + "file.pin_folder", + SEP, + "page.combine", + SEP, + "file.save", + "file.save_as", + "file.revert", + SEP, + "file.close", + "file.close_all", + SEP, + "optimize.reduce", + "optimize.advanced", + SEP, + "file.properties", + "file.word_count", + SEP, + "print.dialog", +]; +const CREATE: &[&str] = &["create.file", "create.multiple", "create.multiple_separate", "create.images", "create.clipboard"]; +const EXPORT: &[&str] = &["export.docx", "export.pptx", "export.rtf", "export.html", "export.text", SEP, "export.image", "export.all_images"]; +const PROTECT: &[&str] = &["protect.password", "protect.remove", "protect.unlock", SEP, "protect.properties"]; +const EDIT: &[&str] = &[ + "edit.undo", + "edit.redo", + SEP, + CUT, + "edit.copy", + PASTE, + DELETE, + SEP, + SELECT_ALL, + SEP, + "edit.find", + "edit.advanced_search", + SEP, + "edit.column_select", + "edit.snapshot", + SEP, + "edit.attach", + "form.clear", + SEP, + "app.preferences", +]; +const ZOOM: &[&str] = &[ACTUAL_SIZE, PAGE_LEVEL, FIT_WIDTH, FIT_HEIGHT, "view.fit_visible", SEP, ZOOM_IN, ZOOM_OUT, SEP, "view.marquee_zoom"]; +const NAVIGATION: &[&str] = &["view.focus_page_input", SEP, "view.previous_view", "view.next_view"]; +const DISPLAY: &[&str] = &[ + "view.layout.single", + "view.layout.two_up", + SEP, + "view.continuous", + "view.page_gaps", + "view.layout.cover", + SEP, + "view.fit_width_scrolling", + "view.fit_one_page", +]; +const THEMES: &[&str] = &["view.theme.system", "view.theme.light", "view.theme.dark"]; +const PANES: &[&str] = &["view.side_panel", "view.tools_pane"]; +const PANE_COMMANDS: &[&str] = &["comment.list", "form.fields", "ui.dock.reset"]; +const MODES: &[&str] = &["view.full_screen", "view.read_mode"]; +const PAGES: &[&str] = &[ + "page.organize", + SEP, + "page.insert_blank", + "page.insert", + "page.replace", + "page.extract", + "page.split", + "page.duplicate", + "page.delete", + SEP, + "page.rotate", + "page.rotate_ccw", + "page.rotate_dialog", + "page.reverse", + SEP, + "page.crop", + "page.boxes", + "page.number", + SEP, + "bookmark.add", + "bookmark.from_structure", +]; +/// Sign ▸ Fill & Sign, with the short labels of the Fill & Sign toolbar. +const FILL: &[(&str, &str)] = &[ + ("sign.fill.text", "Add text"), + ("sign.fill.check", "Checkmark"), + ("sign.fill.cross", "Cross"), + ("sign.fill.dot", "Dot"), + ("sign.fill.line", "Line"), + ("sign.fill.date", "Date"), + ("sign.fill.signature", "Signature"), + ("sign.fill.initials", "Initials"), +]; +const SIGN: &[&str] = &[ + SEP, + "sign.fill.signature.change", + "sign.fill.initials.change", + "sign.fill.signature.remove", + "sign.fill.initials.remove", + SEP, + "sign.digital", + "sign.certify", + "sign.certify_invisible", + "form.add.signature", + SEP, + "sign.validate", + "sign.panel", +]; +const WINDOW: &[&str] = &["view.next_tab", "view.previous_tab"]; +const HELP: &[&str] = + &["help.shortcuts", SEP, "help.app_page", "help.github", "help.website", "help.discord", SEP, "help.check_updates", SEP, "help.about"]; + +/// A registry shortcut as a craft-keymap chord: `command` is ⌘ (Ctrl elsewhere) and `mac_ctrl` +/// the Control key on macOS. `None` for a key craft-keymap doesn't name. +pub(crate) fn chord(s: Shortcut) -> Option { + let key = Key::parse(s.key)?; + Some(Chord::new(Mods { cmd: s.command, ctrl: s.mac_ctrl, alt: false, shift: s.shift }, key)) +} + +fn extra(id: &str) -> Option<&'static (&'static str, &'static str, Option)> { + EXTRA.iter().find(|(e, ..)| *e == id) +} + +/// The English label of an item's command, for the bar's mnemonics. +fn english_label(command: &CommandRef) -> Option { + if let Some(spec) = commands::command(&command.id) { + return Some(spec.label.to_owned()); + } + if let Some((_, label, _)) = extra(&command.id) { + return Some((*label).to_owned()); + } + FILL.iter().find(|(id, _)| *id == command.id).map(|(_, label)| (*label).to_owned()) +} + +fn english(labeled: Labeled<'_>) -> Option { + match labeled { + Labeled::Command(command) => english_label(command), + Labeled::Title(shown) => TITLES.iter().find(|t| tl!(t) == shown).map(|t| (*t).to_owned()), + } +} + +fn translate(english: &'static str) -> String { + tl!(english).to_owned() +} + +/// The layouts' own labels (About, Quit, Window…) through the app's translations. +pub(crate) fn layout_options() -> LayoutOptions<'static> { + LayoutOptions { label: &translate, ..LayoutOptions::new(APP_NAME) } +} + +impl PdfCraftApp { + /// The app's menus for this frame, before the platform layout: every item's label, shortcut, + /// enabled and checked state as the app is now. A modal dialog doesn't grey anything here; + /// that is the [`Gate`]'s job ([`PdfCraftApp::menu_gate`]). + pub fn menu_bar(&self, ctx: &egui::Context) -> MenuBar { + let mut used: Vec<&str> = Vec::new(); + let list = |ids: &[&'static str], used: &mut Vec<&'static str>| -> Vec { + ids.iter() + .map(|&id| { + if id == SEP { + return Node::Separator; + } + used.push(id); + self.menu_item(id, None) + }) + .collect() + }; + let file = { + let mut nodes = Vec::new(); + for node in list(FILE, &mut used) { + // Open Recent is the live recent list; Create, Export and Protect follow Combine, + // Optimize and Close. + let recent = matches!(&node, Node::Item(it) if it.command.id == "file.open_recent"); + if recent { + nodes.push(self.recent_menu()); + continue; + } + let after = match &node { + Node::Item(it) => it.command.id.clone(), + _ => String::new(), + }; + nodes.push(node); + match after.as_str() { + "page.combine" => nodes.push(Node::submenu(tl!("Create"), list(CREATE, &mut used))), + "optimize.advanced" => { + nodes.push(Node::submenu(tl!("Export"), list(EXPORT, &mut used))); + nodes.push(Node::Separator); + nodes.push(Node::submenu(tl!("Protect"), list(PROTECT, &mut used))); + } + _ => {} + } + } + nodes + }; + // Edit ▸ Pages follows Select All. + let mut edit = Vec::new(); + for node in list(EDIT, &mut used) { + let select_all = matches!(&node, Node::Item(it) if it.command.id == SELECT_ALL); + edit.push(node); + if select_all { + edit.push(Node::submenu(tl!("Pages"), list(PAGES, &mut used))); + } + } + let mut sign = vec![Node::submenu( + tl!("Fill & Sign"), + FILL.iter() + .map(|&(id, label)| { + used.push(id); + self.menu_item(id, Some(tl!(label))) + }) + .collect(), + )]; + sign.extend(list(SIGN, &mut used)); + let window = list(WINDOW, &mut used); + let help = list(HELP, &mut used); + let mut view = vec![ + Node::submenu(tl!("Zoom"), list(ZOOM, &mut used)), + Node::submenu(tl!("Rotate view"), list(&[ROTATE_CW, ROTATE_CCW], &mut used)), + Node::submenu(tl!("Page navigation"), list(NAVIGATION, &mut used)), + Node::submenu(tl!("Page display"), list(DISPLAY, &mut used)), + Node::Separator, + ]; + view.extend(list(PANES, &mut used)); + view.push(Node::submenu( + tl!("Side panels"), + PANELS + .iter() + .map(|(_, name, label)| { + Item::new(CommandRef::with_params(PANEL, serde_json::json!({ "panel": name })), tl!(label)).radio(false).into() + }) + .collect(), + )); + view.extend(list(PANE_COMMANDS, &mut used)); + view.push(Node::Separator); + view.extend(list(MODES, &mut used)); + view.push(Node::submenu(tl!("Display theme"), list(THEMES, &mut used))); + view.push(Node::Separator); + view.push(Node::submenu(tl!("Tools"), self.tools_menu(&used))); + view.extend(list(&["view.palette"], &mut used)); + let mut bar = MenuBar { + menus: vec![ + Menu::new(tl!("File"), craft_menu::tidy(file)).role(MenuRole::File), + Menu::new(tl!("Edit"), craft_menu::tidy(edit)).role(MenuRole::Edit), + Menu::new(tl!("View"), craft_menu::tidy(view)).role(MenuRole::View), + Menu::new(tl!("Sign"), craft_menu::tidy(sign)), + Menu::new(tl!("Window"), window).role(MenuRole::Window), + Menu::new(tl!("Help"), craft_menu::tidy(help)).role(MenuRole::Help), + ], + documents: self.menu_documents(), + }; + bar.tag_item(&CommandRef::new("help.about"), ItemRole::About); + bar.tag_item(&CommandRef::new("app.preferences"), ItemRole::Settings); + craft_menu::apply_state(&mut bar, |command| self.menu_state(ctx, command)); + bar + } + + /// One item: a registry command with its live label (Undo names the edit) and shortcut, or a + /// menu-only one. Radio and check items get their kind here, their state from + /// [`PdfCraftApp::menu_state`]. + fn menu_item(&self, id: &'static str, label: Option<&str>) -> Node { + let (english, shortcut) = match commands::command(id) { + Some(spec) => { + let current = commands::current_label(spec, &self.session, self.active_ids().map(|(_, doc)| doc)); + (crate::i18n::menu_label(id, ¤t), spec.shortcut.or((id == "edit.copy").then_some(crate::commands::COPY))) + } + None => match extra(id) { + Some((_, label, shortcut)) => (tl!(label).to_owned(), *shortcut), + None => (id.to_owned(), None), + }, + }; + let mut item = Item::new(id, label.map_or(english, str::to_owned)); + if let Some(c) = shortcut.and_then(chord) { + item = item.shortcut(c); + } + if matches!(id, "view.layout.single" | "view.layout.two_up") || THEMES.contains(&id) { + item = item.radio(false); + } + item.into() + } + + /// File ▸ Open Recent: the recent files, newest first, then Clear Recent Files. + fn recent_menu(&self) -> Node { + let mut items: Vec = self + .recent + .iter() + .enumerate() + .map(|(index, r)| Item::new(CommandRef::with_params("file.open_recent", serde_json::json!({ "index": index })), r.name.clone()).into()) + .collect(); + if items.is_empty() { + items.push(self.menu_item(NO_RECENT, None)); + } else { + items.push(Node::Separator); + items.push(self.menu_item("file.clear_recent", Some(tl!("Clear Recent Files")))); + } + let label = commands::command("file.open_recent").map_or("Open Recent", |c| c.label); + Node::submenu(crate::i18n::menu_label("file.open_recent", label), items) + } + + /// View ▸ Tools: the All tools groups, each a submenu of its ready tools that no other menu + /// lists, sections apart. + fn tools_menu(&self, used: &[&str]) -> Vec { + TOOL_GROUPS + .iter() + .filter(|g| g.availability == Availability::Ready) + .map(|group| { + let mut nodes = Vec::new(); + let mut seen: Vec<&str> = Vec::new(); + for section in group.sections { + nodes.push(Node::Separator); + for tool in section.items.iter().filter(|t| t.availability == Availability::Ready) { + let actions: &[&'static str] = if tool.actions.is_empty() { std::slice::from_ref(&tool.command) } else { tool.actions }; + for &id in actions { + if commands::command(id).is_none() || used.contains(&id) || seen.contains(&id) { + continue; + } + seen.push(id); + let label = if tool.actions.is_empty() { Some(tl!(tool.label)) } else { None }; + nodes.push(self.menu_item(id, label)); + } + } + } + Node::submenu(tl!(group.label), nodes) + }) + .collect() + } + + /// The open documents, in tab order, for the Window menu. + fn menu_documents(&self) -> Vec { + (0..self.views.len()) + .filter_map(|index| { + let name = crate::canvas::tab_label(self, index)?; + Some(Document::new(CommandRef::with_params(DOCUMENT, serde_json::json!({ "index": index })), name).active(self.active == Some(index))) + }) + .collect() + } + + /// What may run from the menus now: a dialog, a prompt or the command palette holds the + /// window. Edit's text items stay with a focused text field (craft-menu's gate). + pub fn menu_gate(&self, ctx: &egui::Context) -> Gate { + Gate { modal: self.modal_open() || self.palette_open, recording_shortcut: false, text_editing: ctx.text_edit_focused() } + } + + /// Whether a menu item's command can run now, and its checked or selected state. + pub fn menu_state(&self, ctx: &egui::Context, command: &CommandRef) -> ItemState { + let view = self.active.and_then(|i| self.views.get(i)); + let organize = view.is_some_and(|v| v.organize); + let has_doc = view.is_some(); + let enabled_id = |id: &str| commands::command(id).is_some_and(|s| self.command_enabled(s)); + let index = param_index(command); + let text = ctx.text_edit_focused() && matches!(command.id.as_str(), "edit.undo" | "edit.redo" | "edit.copy" | CUT | PASTE | SELECT_ALL); + let enabled = text + || match command.id.as_str() { + NO_RECENT => false, + "file.open_recent" => index.is_none_or(|i| i < self.recent.len()), + "file.clear_recent" => !self.recent.is_empty(), + DOCUMENT => index.is_some_and(|i| i < self.views.len()), + // macOS ⌘W closes the window when no document is open (#869). + "file.close" if !has_doc && crate::commands::mac_shortcuts(ctx) && !cfg!(target_arch = "wasm32") => true, + "edit.copy" if organize => enabled_id("page.copy"), + "edit.copy" if view.is_some_and(|v| v.picked_image.is_some()) => { + self.active_ids().and_then(|(_, id)| self.session.get(id)).is_some_and(|d| d.allows_copying()) + } + CUT => organize && enabled_id("page.cut"), + PASTE => organize && self.page_clipboard.is_some() && enabled_id("page.paste"), + SELECT_ALL | DELETE => has_doc, + ACTUAL_SIZE | PAGE_LEVEL | FIT_WIDTH | FIT_HEIGHT | ZOOM_IN | ZOOM_OUT | ROTATE_CW | ROTATE_CCW => has_doc, + PANEL => panel(command).is_some(), + id => enabled_id(id), + }; + let checked = match command.id.as_str() { + "view.layout.single" => Some(view.is_some_and(|v| v.layout == PageLayout::Single)), + "view.layout.two_up" => Some(view.is_some_and(|v| v.layout == PageLayout::TwoUp)), + "view.continuous" => Some(view.is_some_and(|v| v.continuous())), + "view.page_gaps" => Some(view.is_some_and(|v| v.show_page_gaps)), + "view.layout.cover" => Some(view.is_some_and(|v| v.cover)), + "view.full_screen" => Some(self.full_screen), + "view.read_mode" => Some(self.mode == crate::Mode::Read), + "view.side_panel" => Some(self.right.is_some()), + "view.tools_pane" => Some(self.left_open), + "view.theme.system" => Some(self.theme_preference == ThemePreference::System), + "view.theme.light" => Some(self.theme_preference == ThemePreference::Light), + "view.theme.dark" => Some(self.theme_preference == ThemePreference::Dark), + PANEL => Some(panel(command).is_some_and(|p| self.right == Some(p))), + _ => None, + }; + ItemState { enabled, checked } + } + + /// Run a command chosen in either menu bar. Registry commands go through + /// [`PdfCraftApp::dispatch_command`], as their shortcuts do; the clipboard items act as + /// their keys would (⌘C is a Copy event, Delete a key press), so every place that handles the + /// key handles the click. A command that can't run now (a dialog opened since the menu was + /// drawn) does nothing. + pub fn run_menu_command(&mut self, ctx: &egui::Context, command: &CommandRef) { + // Both menu bars run Quit and Minimize themselves; Hide and Hide Others are AppKit's. + if Builtin::of(command).is_some() { + return; + } + if !self.menu_gate(ctx).allows_command(command) || !self.menu_state(ctx, command).enabled { + return; + } + let index = param_index(command); + let key = |key: egui::Key, modifiers: egui::Modifiers| { + [true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }) + }; + match command.id.as_str() { + "file.open_recent" if index.is_some() => { + if let Some(path) = index.and_then(|i| self.recent.get(i)).map(|r| r.path.clone()) { + self.open_recent(&path); + } + } + DOCUMENT => { + if let Some(i) = index.filter(|&i| i < self.views.len()) { + self.active = Some(i); + } + } + "edit.copy" => self.menu_input.push(egui::Event::Copy), + CUT => self.menu_input.push(egui::Event::Cut), + PASTE => ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaste), + SELECT_ALL => self.menu_input.extend(key(egui::Key::A, egui::Modifiers::COMMAND)), + DELETE => self.menu_input.extend(key(egui::Key::Delete, egui::Modifiers::NONE)), + PANEL => self.choose_right_panel(panel(command)), + ACTUAL_SIZE | PAGE_LEVEL | FIT_WIDTH | FIT_HEIGHT | ZOOM_IN | ZOOM_OUT | ROTATE_CW | ROTATE_CCW => { + let Some(v) = self.active.and_then(|i| self.views.get_mut(i)) else { return }; + match command.id.as_str() { + ACTUAL_SIZE => v.set_zoom(1.0), + PAGE_LEVEL => v.fit = Fit::Page, + FIT_WIDTH => v.fit = Fit::Width, + FIT_HEIGHT => { + v.fit = Fit::Height; + v.goto = Some((v.current, 0.0)); + } + ZOOM_IN => v.zoom_step(true), + ZOOM_OUT => v.zoom_step(false), + ROTATE_CW => v.rotate_view(true), + _ => v.rotate_view(false), + } + } + id => { + if let Some(spec) = commands::command(id) { + self.dispatch_command(ctx, spec.id); + } + } + } + ctx.request_repaint(); + } + + /// The menu bar drawn in the window (Windows, Linux, the web, and macOS when the native one + /// couldn't be installed), where the top bar starts. Runs the commands chosen. + pub(crate) fn menu_bar_ui(&mut self, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + let bar = craft_menu::layout(&self.menu_bar(&ctx), Target::current(), &layout_options()).bar; + let gate = self.menu_gate(&ctx); + // The mode tabs, search and buttons follow the bar in the same row: it takes its titles' + // width, less when the row is short, where the menus that don't fit move into its "⋯". + let fit = (ui.available_width() - ROW_AFTER_MENUS).max(MIN_MENUS_WIDTH); + let chosen = craft_menu::MenuBarUi::new(&bar) + .gate(gate) + .label(&translate) + .english(&english) + .id(egui::Id::unique("pdfcraft-menu-bar")) + .shrink_to_fit(fit) + .show(ui); + for command in chosen { + self.run_menu_command(&ctx, &command); + } + } + + /// Install the native menu bar (macOS), once, from the eframe app creator. `false` when + /// AppKit refused it: the in-window bar stays. + #[cfg(target_os = "macos")] + pub fn install_native_menu(&mut self, ctx: &egui::Context) -> bool { + crate::i18n::set_current(crate::i18n::Lang::from_pref(&self.language)); + let bar = craft_menu::mac_layout(&self.menu_bar(ctx), &layout_options()).bar; + // The Window menu lists the open documents itself, so AppKit's window list stays off. + self.native_menu = craft_menu::mac::install_with(ctx, &bar, craft_menu::mac::MacOptions::default().window_list(false)); + self.native_menu.is_some() + } + + /// The native menu's clicks since the last frame, run like in-window ones. + pub(crate) fn native_menu_clicks(&mut self, ctx: &egui::Context) { + let gate = self.menu_gate(ctx); + let Some(native) = self.native_menu.as_mut() else { return }; + native.set_gate(gate); + for command in native.take_clicks(ctx) { + self.run_menu_command(ctx, &command); + } + } + + /// Bring the native menu in step with the app, after this frame's changes. + pub(crate) fn sync_native_menu(&mut self, ctx: &egui::Context) { + if self.native_menu.is_none() { + return; + } + let layout = craft_menu::mac_layout(&self.menu_bar(ctx), &layout_options()); + let gate = self.menu_gate(ctx); + if let Some(native) = self.native_menu.as_mut() { + native.set_gate(gate); + for clash in native.sync(layout) { + log::info!("menu shortcut {:?} kept by {} over {} ({})", clash.shortcut, clash.command.id, clash.system, clash.resolution); + } + } + } +} + +/// A command's `{"index": n}` parameter. +fn param_index(command: &CommandRef) -> Option { + command.params.get("index").and_then(serde_json::Value::as_u64).and_then(|i| usize::try_from(i).ok()) +} + +/// The side panel a [`PANEL`] command names. +fn panel(command: &CommandRef) -> Option { + let name = command.params.get("panel").and_then(serde_json::Value::as_str)?; + PANELS.iter().find(|(_, n, _)| *n == name).map(|(p, ..)| *p) +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::rc::Rc; + + use craft_menu::{Backend, Dispatch, Event, ItemKind, NativeMenu}; + + use super::*; + + const PDF: &[u8] = include_bytes!("../tests/data/form.pdf"); + + fn app(docs: &[&str]) -> (PdfCraftApp, egui::Context) { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + for name in docs { + app.open_bytes(name, None, PDF.to_vec()).unwrap(); + } + (app, egui::Context::default()) + } + + fn linux(app: &PdfCraftApp, ctx: &egui::Context) -> MenuBar { + craft_menu::layout(&app.menu_bar(ctx), Target::Linux, &layout_options()).bar + } + + fn item<'b>(bar: &'b MenuBar, command: &CommandRef) -> &'b Item { + bar.find(command).unwrap_or_else(|| panic!("{} is in the menus", command.key())) + } + + #[test] + fn every_menu_command_is_listed_once_with_its_shortcut() { + let (app, ctx) = app(&["a.pdf"]); + let bar = linux(&app, &ctx); + let items = bar.items(); + let mut keys: Vec = items.iter().map(|it| it.command.key()).collect(); + keys.sort(); + let count = keys.len(); + keys.dedup(); + assert_eq!(keys.len(), count, "no command is listed twice"); + for spec in commands::COMMANDS.iter().filter(|s| s.menu.is_some()) { + if spec.id == "file.open_recent" { + continue; // A submenu of the recent files. + } + let it = item(&bar, &CommandRef::new(spec.id)); + let shortcut = spec.shortcut.or((spec.id == "edit.copy").then_some(crate::commands::COPY)); + assert_eq!(it.shortcut, shortcut.and_then(chord), "{} has its registry shortcut", spec.id); + } + // Every item runs a registry command or one of the menu's own. + for it in items { + let id = it.command.id.as_str(); + assert!( + commands::command(id).is_some() || extra(id).is_some() || [PANEL, DOCUMENT].contains(&id) || Builtin::of(&it.command).is_some(), + "{id} runs something" + ); + } + } + + #[test] + fn check_and_radio_items_follow_the_app() { + let (mut app, ctx) = app(&["a.pdf"]); + let kind = |app: &PdfCraftApp, id: &str| item(&linux(app, &ctx), &CommandRef::new(id)).kind; + assert_eq!(kind(&app, "view.layout.single"), ItemKind::Radio(true)); + assert_eq!(kind(&app, "view.layout.two_up"), ItemKind::Radio(false)); + let continuous = app.views[0].continuous(); + assert_eq!(kind(&app, "view.continuous"), ItemKind::Check(continuous)); + app.run_menu_command(&ctx, &CommandRef::new("view.layout.two_up")); + app.run_menu_command(&ctx, &CommandRef::new("view.continuous")); + assert_eq!(kind(&app, "view.layout.single"), ItemKind::Radio(false)); + assert_eq!(kind(&app, "view.layout.two_up"), ItemKind::Radio(true)); + assert_eq!(kind(&app, "view.continuous"), ItemKind::Check(!continuous)); + let left = app.left_open; + assert_eq!(kind(&app, "view.tools_pane"), ItemKind::Check(left)); + app.run_menu_command(&ctx, &CommandRef::new("view.tools_pane")); + assert_eq!(kind(&app, "view.tools_pane"), ItemKind::Check(!left)); + let bookmarks = CommandRef::with_params(PANEL, serde_json::json!({ "panel": "bookmarks" })); + app.run_menu_command(&ctx, &bookmarks); + assert_eq!(app.right, Some(RightPanel::Bookmarks)); + assert_eq!(item(&linux(&app, &ctx), &bookmarks).kind, ItemKind::Radio(true)); + assert_eq!(kind(&app, "view.side_panel"), ItemKind::Check(true)); + } + + #[test] + fn a_dialog_greys_document_commands_and_drops_their_clicks() { + let (mut app, ctx) = app(&["a.pdf"]); + let rotate = CommandRef::new("page.rotate"); + assert!(app.menu_gate(&ctx).allows_command(&rotate)); + app.dialog = Some(crate::Dialog::About); + assert!(!app.menu_gate(&ctx).allows_command(&rotate), "the gate greys the menus under a dialog"); + let doc = app.views[0].id; + let before = app.session.get(doc).unwrap().info.pages[0].rotation; + app.run_menu_command(&ctx, &rotate); + assert_eq!(app.session.get(doc).unwrap().info.pages[0].rotation, before, "a click that arrives anyway does nothing"); + // Quit still asks the window to close, which asks about unsaved changes. + assert!(app.menu_gate(&ctx).allows_command(&Builtin::Quit.command())); + } + + #[test] + fn recent_files_and_open_documents_are_listed_once_each() { + let (mut app, ctx) = app(&["a.pdf", "b.pdf"]); + app.recent = ["one.pdf", "two.pdf"].map(|n| crate::RecentFile { name: n.into(), path: format!("/nowhere/{n}"), pages: 1, size: 0 }).into(); + let bar = linux(&app, &ctx); + for (i, name) in ["one.pdf", "two.pdf"].into_iter().enumerate() { + assert_eq!(item(&bar, &CommandRef::with_params("file.open_recent", serde_json::json!({ "index": i }))).label, name); + } + let window = bar.menu(MenuRole::Window).unwrap(); + let documents: Vec<&str> = window + .children + .iter() + .filter_map(|n| match n { + Node::Item(it) if it.command.id == DOCUMENT => Some(it.label.as_str()), + _ => None, + }) + .collect(); + assert_eq!(documents, ["a.pdf", "b.pdf"], "each open document once, in tab order"); + app.run_menu_command(&ctx, &CommandRef::with_params(DOCUMENT, serde_json::json!({ "index": 0 }))); + assert_eq!(app.active, Some(0)); + // The macOS layout lists them once too. + let mac = craft_menu::mac_layout(&app.menu_bar(&ctx), &layout_options()).bar; + let listed = mac.items().iter().filter(|it| it.command.id == DOCUMENT).count(); + assert_eq!(listed, 2); + app.recent.clear(); + let bar = linux(&app, &ctx); + assert!(!item(&bar, &CommandRef::new(NO_RECENT)).enabled, "an empty list shows a greyed placeholder"); + assert!(bar.find(&CommandRef::new("file.clear_recent")).is_none()); + } + + /// A native backend that reports what the test queues, through the driver's dispatch as the + /// macOS backend does. + #[derive(Clone, Default)] + struct FakeMenu(Rc, Vec)>>); + + impl FakeMenu { + fn click(&self, command: &CommandRef) { + let mut state = self.0.borrow_mut(); + let event = state.0.as_ref().map(|d| d.chosen(command, None, false, None)); + state.1.extend(event); + } + } + + impl Backend for FakeMenu { + fn sync(&mut self, _bar: &MenuBar) {} + fn drain(&mut self) -> Vec { + std::mem::take(&mut self.0.borrow_mut().1) + } + fn attach(&mut self, dispatch: Dispatch) { + self.0.borrow_mut().0 = Some(dispatch); + } + } + + #[test] + fn a_native_menu_click_runs_its_command_once() { + let (mut app, ctx) = app(&["a.pdf"]); + let fake = FakeMenu::default(); + app.native_menu = Some(NativeMenu::new(Box::new(fake.clone()))); + let doc = app.views[0].id; + let before = app.session.get(doc).unwrap().info.pages[0].rotation; + fake.click(&CommandRef::new("page.rotate")); + let mut raw = egui::RawInput::default(); + app.prepare_raw_input(&ctx, &mut raw); + app.native_menu_clicks(&ctx); + app.native_menu_clicks(&ctx); + assert_eq!(app.session.get(doc).unwrap().info.pages[0].rotation, (before + 90) % 360, "rotated once"); + } +} diff --git a/crates/ui-egui/src/motion.rs b/crates/ui-egui/src/motion.rs index 934fc06188..d95d205b0f 100644 --- a/crates/ui-egui/src/motion.rs +++ b/crates/ui-egui/src/motion.rs @@ -88,11 +88,19 @@ pub struct Motion { /// egui's own animation time and scroll animation, kept while they are changed here. egui_times: Option<(f32, ScrollAnimation)>, wheel: WheelReader, + /// This frame's wheel motion, read on its first pass and reused on later passes. + /// + /// egui empties `RawInput` between the passes of a multi-pass frame, so reading the wheel + /// again in a later pass sees nothing. Only the first pass is handed the motion (a scroll + /// area keeps the offset it moved to, a view keeps the zoom it set); later passes are handed + /// no scroll and a unit zoom, explicitly. + frame: Option<(u64, WheelMotion)>, } impl Default for Motion { + /// Start motion handling with animations enabled and no desktop preference sampled yet. fn default() -> Self { - Self { reduced: false, source: None, asked_at: f64::NEG_INFINITY, egui_times: None, wheel: WheelReader::default() } + Self { reduced: false, source: None, asked_at: f64::NEG_INFINITY, egui_times: None, wheel: WheelReader::default(), frame: None } } } @@ -114,13 +122,36 @@ impl Motion { if animate { // A gesture begun while easing is egui's to finish. self.wheel = WheelReader::default(); + self.frame = None; return; } let options = ctx.options(|o| o.input_options); - let motion = ctx.input(|i| self.wheel.read(&i.events, i.viewport_rect().height(), &options)); - ctx.input_mut(|i| i.smooth_scroll_delta = motion.scroll); + // One frame may run several passes (`request_discard`). Only the first pass has this + // frame's events, and only it may hand the wheel on: a scroll area applies the delta to + // its persisted offset, and a view multiplies the zoom into its persisted zoom, so + // replaying either would apply it twice. egui also rebuilds `smooth_scroll_delta` and + // its zoom factor from its retained wheel state every pass, so later passes must be + // given zero scroll and a unit zoom explicitly. A missing zoom entry would not do: it + // would fall back to egui's rebuilt factor. + let frame = ctx.cumulative_frame_nr(); + let first_pass = match self.frame { + Some((read_in, _)) => read_in != frame, + None => true, + }; + let motion = match self.frame { + Some((read_in, motion)) if read_in == frame => motion, + _ => { + let motion = ctx.input(|i| self.wheel.read(&i.events, i.viewport_rect().height(), &options)); + self.frame = Some((frame, motion)); + motion + } + }; + let (scroll, zoom) = if first_pass { (motion.scroll, motion.zoom) } else { (Vec2::ZERO, 1.0) }; + ctx.input_mut(|i| i.smooth_scroll_delta = scroll); let pass = ctx.cumulative_pass_nr(); - ctx.data_mut(|d| d.insert_temp(zoom_id(), (pass, motion.zoom))); + ctx.data_mut(|d| d.insert_temp(zoom_id(), (pass, zoom))); + // Existing kinetic scrolling remains owned by egui; gating wheel input does not + // suppress a scroll area's per-pass advancement of retained velocity. } /// egui's animations, and its scrolling to a place, take no time unless the window is to @@ -148,8 +179,9 @@ pub(crate) fn animated(ctx: &egui::Context) -> bool { ctx.global_style().animation_time > 0.0 } +/// Return the stable egui id holding the last wheel-zoom input state. fn zoom_id() -> egui::Id { - egui::Id::new("pdfcraft-motion-wheel-zoom") + egui::Id::unique("pdfcraft-motion-wheel-zoom") } /// This pass's zoom factor from a pinch or ⌘/Ctrl with the wheel (1.0 for none): egui's @@ -194,7 +226,7 @@ impl WheelReader { let mut modifiers = self.touch.unwrap_or_default(); for event in events { match event { - Event::MouseWheel { unit, delta: step, phase, modifiers: latest } => match phase { + Event::MouseWheel { unit, delta: step, phase, modifiers: latest, .. } => match phase { TouchPhase::Start => { self.touch = Some(*latest); modifiers = *latest; @@ -256,7 +288,7 @@ mod tests { use std::rc::Rc; fn wheel(unit: MouseWheelUnit, delta: Vec2, phase: TouchPhase, modifiers: Modifiers) -> Event { - Event::MouseWheel { unit, delta, phase, modifiers } + Event::MouseWheel { unit, delta, phase, modifiers, source: egui::MouseWheelSource::Unknown } } fn notch(dy: f32, modifiers: Modifiers) -> Event { @@ -507,6 +539,104 @@ mod tests { }); } + /// With animation effects off, a real scroll area moves by one wheel step whether the frame + /// runs one pass or two (a `request_discard` from a sizing pass): the scroll area already + /// applied and stored the step in the first pass, so a later pass must not apply it again, + /// and must not replay the frame's raw wheel events either. + #[test] + fn two_pass_scroll_area_applies_one_wheel_step_like_a_single_pass() { + fn run(two_passes: bool) -> (Vec, Vec) { + let ctx = egui::Context::default(); + let mut motion = Motion { reduced: true, ..Default::default() }; + let mut offsets = Vec::new(); + let mut wheel_events = Vec::new(); + let raw = egui::RawInput { + time: Some(0.1), + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(300.0, 300.0))), + events: vec![egui::Event::PointerMoved(egui::pos2(50.0, 50.0)), notch(-1.0, Modifiers::NONE)], + ..Default::default() + }; + let output = ctx.run_ui(raw, |ui| { + motion.begin_pass(ui.ctx(), AnimationEffects::System); + wheel_events.push(ui.input(|i| i.events.iter().filter(|e| matches!(e, egui::Event::MouseWheel { .. })).count())); + // Taller than the 300-point viewport, so one step stays far from the scroll + // limit and a doubled step could not be hidden by clamping. + let out = egui::ScrollArea::vertical().id_salt("motion-two-pass-scroll").auto_shrink([false, false]).show(ui, |ui| { + ui.set_min_size(egui::vec2(100.0, 3000.0)); + }); + offsets.push(out.state.unclamped_offset()); + if two_passes && ui.ctx().current_pass_index() == 0 { + ui.ctx().request_discard("motion two-pass scroll regression"); + } + }); + output.drop_without_applying_deltas(); + (offsets, wheel_events) + } + + let (single, single_events) = run(false); + let (two, two_events) = run(true); + + assert_eq!(single.len(), 1, "one frame, one pass"); + assert_eq!(two.len(), 2, "the frame really ran two passes"); + assert_eq!(single_events, vec![1], "one wheel event reaches the first pass"); + assert_eq!(two_events, vec![1, 0], "raw wheel events are not replayed in the later pass"); + + let step = single[0].y; + assert!(step > 0.0, "the wheel step scrolled: {single:?}"); + assert!((two[0].y - step).abs() < 0.01, "the first pass applies the whole step: single={step}, two-pass={two:?}"); + assert!((two[1].y - step).abs() < 0.01, "the presented pass must not apply the same step again: single={step}, two-pass={two:?}"); + } + + /// With animation effects off, ctrl/⌘-wheel and pinch zoom reach a view's persisted zoom + /// once, whether the frame runs one pass or two (a `request_discard` from a sizing pass): + /// multiplying an accumulator by each pass's factor gives the single step either way. A + /// later pass gets a unit factor explicitly, and a frame with no wheel input resets to it. + #[test] + fn two_pass_zoom_applies_one_step_like_a_single_pass() { + /// One frame's `zoom_delta` readings, one per pass. + fn frame(ctx: &egui::Context, motion: &mut Motion, time: f64, events: Vec, two_passes: bool) -> Vec { + let raw = egui::RawInput { + time: Some(time), + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(300.0, 300.0))), + events, + ..Default::default() + }; + let mut zooms = Vec::new(); + let output = ctx.run_ui(raw, |ui| { + motion.begin_pass(ui.ctx(), AnimationEffects::System); + zooms.push(zoom_delta(ui.ctx())); + if two_passes && ui.ctx().current_pass_index() == 0 { + ui.ctx().request_discard("motion two-pass zoom regression"); + } + }); + output.drop_without_applying_deltas(); + zooms + } + + let line = egui::InputOptions::default().line_scroll_speed; + let ctrl_step = (egui::InputOptions::default().scroll_zoom_speed * line).exp(); + for (name, events, step) in [("ctrl-wheel", vec![notch(1.0, Modifiers::COMMAND)], ctrl_step), ("pinch", vec![Event::Zoom(1.5)], 1.5)] { + assert!((step - 1.0).abs() > 0.01, "{name}: the factor is nontrivial: {step}"); + let single = { + let ctx = egui::Context::default(); + let mut motion = Motion { reduced: true, ..Default::default() }; + frame(&ctx, &mut motion, 0.1, events.clone(), false) + }; + let ctx = egui::Context::default(); + let mut motion = Motion { reduced: true, ..Default::default() }; + let two = frame(&ctx, &mut motion, 0.1, events, true); + assert_eq!(single.len(), 1, "{name}: one frame, one pass: {single:?}"); + assert_eq!(two.len(), 2, "{name}: the frame really ran two passes: {two:?}"); + let product = |zooms: &[f32]| zooms.iter().product::(); + assert!((product(&single) - step).abs() < 1e-5, "{name}: one pass applies the step: {single:?}"); + assert!((product(&two) - step).abs() < 1e-5, "{name}: two passes apply it once: {two:?}"); + assert!((two[1] - 1.0).abs() < 1e-6, "{name}: a later pass zooms by nothing: {two:?}"); + // The next frame's wheel reading starts over; the two-pass frame's factor is gone. + let after = frame(&ctx, &mut motion, 0.1 + 1.0 / 60.0, Vec::new(), false); + assert_eq!(after, vec![1.0], "{name}: the cached factor does not survive the frame"); + } + } + #[test] fn the_system_is_asked_now_and_then_and_no_answer_changes_nothing() { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/object_ui.rs b/crates/ui-egui/src/object_ui.rs index b68fe352ae..d495116c6c 100644 --- a/crates/ui-egui/src/object_ui.rs +++ b/crates/ui-egui/src/object_ui.rs @@ -8,6 +8,7 @@ use pdfcraft_engine::{Document, Edit, EditableObject, MAX_MOVE_OBJECTS, ObjectKi use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; +use crate::page_pointer::PagePointer; const BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); @@ -35,6 +36,12 @@ struct Gesture { } impl ObjectSelection { + /// Clear any object-selection gesture and the page it was blocking. + pub(crate) fn cancel_pointer_gesture(&mut self) { + self.gesture = None; + self.blocked_page = None; + } + pub fn count(&self) -> usize { self.selected.len() } @@ -156,18 +163,24 @@ fn leave_single(view: &mut DocView) { /// Returns true while group selection owns the interaction. Ordinary clicks still reach the /// single-object text/resize tools. A text draft is finished or discarded through its editor. -pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, page: usize, doc: &Document, view: &mut DocView) -> bool { +pub(crate) fn page_input( + ui: &egui::Ui, + resp: &egui::Response, + xf: &PageXform, + ptr: &PagePointer, + page: usize, + doc: &Document, + view: &mut DocView, +) -> bool { if view.objects.input_blocked { return true; } if view.content.draft.is_some() || view.line_editor.as_ref().is_some_and(|e| e.has_changes()) { let blocked = view.objects.blocked_page == Some(page); - let modifier_press = ui.input(|i| i.pointer.primary_pressed()) - && modified(ui) - && ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| xf.rect.contains(p) && ui.clip_rect().contains(p)); + let modifier_press = ptr.pressed().is_some() && modified(ui) && ptr.pos.is_some_and(|p| xf.rect.contains(p) && ui.clip_rect().contains(p)); if blocked || modifier_press { view.objects.hold_editor = true; - view.objects.blocked_page = ui.input(|i| i.pointer.primary_down()).then_some(page); + view.objects.blocked_page = ptr.primary_down.then_some(page); if modifier_press { view.objects.notice = Some(tl!("Finish or discard the text being edited before selecting several objects.").into()); } @@ -176,14 +189,24 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, p return false; } let mut selection = std::mem::take(&mut view.objects); - let result = input(&mut selection, ui, resp, xf, page, doc, view); + let result = input(&mut selection, ui, resp, xf, ptr, page, doc, view); view.objects = selection; result } #[allow(clippy::too_many_arguments)] -fn input(s: &mut ObjectSelection, ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, page: usize, doc: &Document, view: &mut DocView) -> bool { - let (pointer, pressed, down) = ui.input(|i| (i.pointer.interact_pos(), i.pointer.primary_pressed(), i.pointer.primary_down())); +fn input( + s: &mut ObjectSelection, + ui: &egui::Ui, + resp: &egui::Response, + xf: &PageXform, + ptr: &PagePointer, + page: usize, + doc: &Document, + view: &mut DocView, +) -> bool { + let pointer = ptr.pos; + let (pressed, down) = (ptr.pressed().is_some(), ptr.primary_down); let info = &doc.info; if s.page == Some(page) && let Some(mut gesture) = s.gesture diff --git a/crates/ui-egui/src/optimize_ui.rs b/crates/ui-egui/src/optimize_ui.rs index 6662bf9974..d46d7ae591 100644 --- a/crates/ui-egui/src/optimize_ui.rs +++ b/crates/ui-egui/src/optimize_ui.rs @@ -7,7 +7,7 @@ use std::sync::{Arc, Mutex}; -use egui::{Align, Layout}; +use egui::{Align, Layout, RichText}; use pdfcraft_engine::optimize::{Compatibility, Compression, ImageSettings, Preset, QUALITIES, Settings}; use pdfcraft_engine::optimizer::{OptimizeStage, Optimized}; use pdfcraft_engine::{DocId, EditError, Hidden, OptimizeReport}; @@ -267,9 +267,19 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut OptimizeDraft, t: &Tokens) -> (boo /// Audit Space Usage: how the file's bytes are spent now, by kind of content (not what /// optimizing would save). pub(crate) fn audit_body(ui: &mut egui::Ui, rows: &[pdfcraft_engine::optimize::SpaceUse], t: &Tokens) -> bool { - use egui::{RichText, TextWrapMode, WidgetText}; ui.label(RichText::new(tl!("Space Audit")).font(crate::theme::semibold(18.0))); ui.add_space(4.0); + audit_table(ui, rows, t); + ui.add_space(12.0); + let mut ok = false; + ui.horizontal(|ui| ui.with_layout(Layout::right_to_left(Align::Center), |ui| ok = widgets::pill_button(ui, tl!("OK"), true).clicked())); + ok +} + +/// The audit's explanation and its table: bytes and share of the file per kind of content. +/// Shared by the Optimizer's Audit Space Usage and Properties ▸ Space Audit. +pub(crate) fn audit_table(ui: &mut egui::Ui, rows: &[pdfcraft_engine::optimize::SpaceUse], t: &Tokens) { + use egui::{TextWrapMode, WidgetText}; ui.label(RichText::new(tl!("What takes up the space in this file, by kind of content.")).color(t.text_muted)); ui.add_space(8.0); // Every cell is laid out first so each column can be exactly as wide as its widest cell. A @@ -300,7 +310,9 @@ pub(crate) fn audit_body(ui: &mut egui::Ui, rows: &[pdfcraft_engine::optimize::S let numbers = width(1) + width(2) + 2.0 * gap; let widths = [width(0).max(ui.available_width() - numbers), width(1), width(2)]; let height = table.iter().flatten().map(|g| g.size().y).fold(ui.spacing().interact_size.y, f32::max); - egui::Grid::new("space-audit").num_columns(3).striped(true).spacing([gap, 4.0]).show(ui, |ui| { + // No minimum column width: egui's default widened a narrow Bytes column past `widths` and + // pushed the table beyond the dialog's padding. + egui::Grid::new("space-audit").num_columns(3).striped(true).spacing([gap, 4.0]).min_col_width(0.0).show(ui, |ui| { for row in table { for (column, (cell, width)) in row.into_iter().zip(widths).enumerate() { // Numbers line up on their last digit. @@ -311,13 +323,25 @@ pub(crate) fn audit_body(ui: &mut egui::Ui, rows: &[pdfcraft_engine::optimize::S ui.end_row(); } }); - ui.add_space(12.0); - let mut ok = false; - ui.horizontal(|ui| ui.with_layout(Layout::right_to_left(Align::Center), |ui| ok = widgets::pill_button(ui, tl!("OK"), true).clicked())); - ok } impl PdfCraftApp { + /// Measure the active document's space use, unless `space_audit` already describes it as + /// it is now. With no document the audit is empty. + pub(crate) fn refresh_space_audit(&mut self) { + let Some((_, id)) = self.active_ids() else { + self.space_audit.clear(); + self.space_audit_of = None; + return; + }; + let Some(doc) = self.session.get(id) else { return }; + let key = (id, doc.edit_generation()); + if self.space_audit_of != Some(key) { + self.space_audit = doc.audit_space(); + self.space_audit_of = Some(key); + } + } + /// Optimize PDF with the dialog's choices on a worker thread; the copy is saved when done. pub fn optimize_with_draft(&mut self) { let (settings, discard) = (self.optimize_draft.settings.clone(), self.optimize_draft.discard.clone()); diff --git a/crates/ui-egui/src/organize_panel.rs b/crates/ui-egui/src/organize_panel.rs index b02d118012..338f64bc14 100644 --- a/crates/ui-egui/src/organize_panel.rs +++ b/crates/ui-egui/src/organize_panel.rs @@ -195,7 +195,7 @@ enum Availability { fn row(ui: &mut egui::Ui, t: &Tokens, icon: &str, label: &str, availability: Availability, menu: bool) -> egui::Response { let ready = availability == Availability::Ready && ui.is_enabled(); let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), if ready { Sense::click() } else { Sense::hover() }); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ready, label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ready, label)); let press = theme::Press::track(ui, &resp); if ready { press.wash(ui, rect, 6, false); diff --git a/crates/ui-egui/src/page_pointer.rs b/crates/ui-egui/src/page_pointer.rs new file mode 100644 index 0000000000..a6a318c1fd --- /dev/null +++ b/crates/ui-egui/src/page_pointer.rs @@ -0,0 +1,419 @@ +//! The pointer as the pages see it, read once per frame from the page view's content widget. +//! +//! egui knows what is drawn over the pages (a dialog, a menu, the palette, the quick bar, the +//! chrome over a page scrolled under it) and which widget holds a drag (a panel's tab carried +//! over them), and keeps a press with the widget it began on until release. egui's raw pointer +//! state knows none of that, and its hover position is gone on the frame +//! a touch or a Windows pen lifts: egui-winit sends `PointerGone` with every touch release, and +//! winit reports Windows pen input as touch. So page modules read [`PagePointer`], never egui's +//! pointer positions or button states; a test below keeps it that way. +//! +//! Dialogs are drawn after the pages, so on the frame one opens egui has not met its layer yet: +//! the reading also takes whether one is open. + +use egui::{Event, PointerButton, Pos2, Rect}; + +/// A primary-button press or release, and where it happened. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum Edge { + Press(Pos2), + Release(Pos2), +} + +/// How many of a frame's presses and releases are kept. A frame holds one or two (a click, a +/// double-click in a slow frame), but one after a stall can hold any number. +const MAX_EDGES: usize = 8; + +/// One frame's pointer, as the pages see it. +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct PagePointer { + /// Where the pointer is, while the pages are what egui has under it and no other widget holds + /// a drag (a panel's tab carried over them), or while they hold a press (until the frame of + /// its release, past a page's edge included). With a dialog open only a drag the pages + /// already have keeps it. It is egui's `interact_pos`, which still has the position on the + /// frame a touch lifts. + pub pos: Option, + /// Where the press the pages hold began, also on the frame of its release when the press + /// came in that same frame (egui has forgotten it by then). `None` for a press that began on + /// something else. + pub origin: Option, + /// This frame's primary presses and releases of a press the pages hold, in order. + pub edges: Vec, + /// The primary button is down, wherever it was pressed: a gesture ends once it is up. + pub primary_down: bool, + /// Some button is down, wherever it was pressed. + pub any_down: bool, +} + +impl PagePointer { + /// The pointer for `resp`, the content widget that holds every page, allocated in `ui`, while + /// a dialog is open over the window or not (`modal`). + pub(crate) fn read(ui: &egui::Ui, resp: &egui::Response, modal: bool) -> Self { + let (at, origin, edges, primary_down, any_down) = + ui.input(|i| (i.pointer.interact_pos(), i.pointer.press_origin(), edges(&i.events), i.pointer.primary_down(), i.pointer.any_down())); + // egui's own answer to "do the pages hold a press?": from the press on until the frame + // of its click, release or drag stop. A drag can outlive egui's press while the + // pointer is outside the window. With a dialog open only a drag counts, up to the frame + // it stops: on the frame one opens egui still gives the pages a press that lands under + // it, and its click. + let held = resp.dragged() || resp.drag_stopped() || (!modal && resp.interact_pointer_pos().is_some()); + // `contains_pointer` alone also holds a few points under the edge of an Area over the + // pages (egui's interact radius), so the top layer there must be the pages' own. A + // dialog covers them from the frame it opens, before egui knows its layer. + let layer = ui.layer_id(); + let on_top = + |p: &Pos2| !modal && !dragged_elsewhere(ui, resp) && resp.contains_pointer() && ui.ctx().layer_id_at(*p).is_none_or(|l| l == layer); + let pos = at.filter(|p| held || on_top(p)); + if !held { + return Self { pos, origin: None, edges: Vec::new(), primary_down, any_down }; + } + Self { pos, origin: origin.or_else(|| first_press(&edges)), edges, primary_down, any_down } + } + + /// Where the pointer is, when it is inside `r`. + pub(crate) fn within(&self, r: Rect) -> Option { + self.pos.filter(|p| r.contains(*p)) + } + + /// Where this frame's first primary press was. + pub(crate) fn pressed(&self) -> Option { + first_press(&self.edges) + } +} + +/// Whether egui gives the drag to a widget that is not the pages' (a panel's tab, a floating panel's +/// grip): egui hovers nothing else until it stops, though it still reports the pages under the +/// pointer. A widget on the pages (a form field's editor) keeps them. +fn dragged_elsewhere(ui: &egui::Ui, resp: &egui::Response) -> bool { + let ctx = ui.ctx(); + ctx.dragged_id().is_some_and(|id| { + id != resp.id && !ctx.read_response(id).is_some_and(|r| r.layer_id == resp.layer_id && resp.interact_rect.contains_rect(r.interact_rect)) + }) +} + +fn first_press(edges: &[Edge]) -> Option { + edges.iter().find_map(|e| match e { + Edge::Press(p) => Some(*p), + Edge::Release(_) => None, + }) +} + +/// The first [`MAX_EDGES`] primary presses and releases among `events`, in order. +fn edges(events: &[Event]) -> Vec { + events + .iter() + .filter_map(|e| match e { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, .. } if pos.is_finite() => { + Some(if *pressed { Edge::Press(*pos) } else { Edge::Release(*pos) }) + } + _ => None, + }) + .take(MAX_EDGES) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::{Context, Modifiers, Sense, TouchDeviceId, TouchId, TouchPhase, pos2, vec2}; + + /// The pages' widget: x 0..200, y 0..200 of a 400×400 screen. + const PAGES: Rect = Rect { min: Pos2::ZERO, max: Pos2 { x: 200.0, y: 200.0 } }; + /// An Area drawn over the pages' lower right corner. + const OVER: Rect = Rect { min: Pos2 { x: 120.0, y: 120.0 }, max: Pos2 { x: 260.0, y: 260.0 } }; + + fn button(pos: Pos2, button: PointerButton, pressed: bool) -> Event { + Event::PointerButton { pos, button, pressed, modifiers: Modifiers::NONE } + } + + fn touch(phase: TouchPhase, pos: Pos2) -> Event { + Event::Touch { device_id: TouchDeviceId(1), id: TouchId(1), phase, pos, force: None } + } + + /// Runs one frame of the pages' widget (and the Area over it, with `area`) and returns what + /// the pages see. + fn frame(ctx: &Context, events: Vec, area: bool) -> PagePointer { + frame_with(ctx, events, area, false) + } + + /// [`frame`], with a dialog open (`modal`) whose layer egui has not met. + fn frame_with(ctx: &Context, events: Vec, area: bool, modal: bool) -> PagePointer { + let mut seen = PagePointer::default(); + let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 400.0))), events, ..Default::default() }; + let mut output = ctx.run_ui(input, |ui| { + let (_, resp) = ui.allocate_exact_size(PAGES.size(), Sense::click_and_drag()); + seen = PagePointer::read(ui, &resp, modal); + if area { + egui::Area::new(egui::Id::unique("over")).fixed_pos(OVER.min).show(ui.ctx(), |ui| { + ui.allocate_exact_size(OVER.size(), Sense::hover()); + }); + } + }); + // An input-only test has no renderer to take the font texture. + output.textures_delta.clear(); + seen + } + + #[test] + fn a_touch_tap_keeps_its_position_on_the_frame_it_lifts() { + let ctx = Context::default(); + let p = pos2(50.0, 60.0); + frame(&ctx, vec![], false); + let down = frame(&ctx, vec![touch(TouchPhase::Start, p), Event::PointerMoved(p), button(p, PointerButton::Primary, true)], false); + assert_eq!((down.pos, down.origin, down.pressed()), (Some(p), Some(p), Some(p))); + // egui-winit ends a touch with the release and `PointerGone` in one frame. + let up = frame(&ctx, vec![touch(TouchPhase::End, p), button(p, PointerButton::Primary, false), Event::PointerGone], false); + assert_eq!(ctx.input(|i| i.pointer.hover_pos()), None, "egui's hover position is gone"); + assert_eq!(up.pos, Some(p), "the pages still know where the finger lifted"); + assert_eq!(up.edges, [Edge::Release(p)]); + assert_eq!(frame(&ctx, vec![], false), PagePointer::default(), "and forget it the frame after"); + } + + #[test] + fn a_press_and_release_in_one_frame_keep_the_press() { + let ctx = Context::default(); + let p = pos2(50.0, 60.0); + frame(&ctx, vec![Event::PointerMoved(p)], false); + let click = frame(&ctx, vec![button(p, PointerButton::Primary, true), button(p, PointerButton::Primary, false)], false); + assert_eq!(ctx.input(|i| i.pointer.press_origin()), None, "egui has forgotten the press"); + assert_eq!(click.origin, Some(p)); + assert_eq!(click.edges, [Edge::Press(p), Edge::Release(p)]); + } + + #[test] + fn something_drawn_over_the_pages_hides_the_pointer() { + let ctx = Context::default(); + // The Area and the widget rects egui hit-tests against come from the frame before. + frame(&ctx, vec![], true); + frame(&ctx, vec![], true); + let beside = pos2(100.0, 100.0); + assert_eq!(frame(&ctx, vec![Event::PointerMoved(beside)], true).pos, Some(beside)); + // Inside the Area, and just inside its edge where egui's interact radius still finds the + // pages' widget. + for p in [pos2(160.0, 160.0), pos2(122.0, 150.0)] { + assert_eq!(frame(&ctx, vec![Event::PointerMoved(p)], true).pos, None, "{p:?}"); + } + // Off the pages altogether. + assert_eq!(frame(&ctx, vec![Event::PointerMoved(pos2(300.0, 50.0))], true).pos, None); + // A press on the Area is not the pages' press, though the button is down all the same. + let p = pos2(160.0, 160.0); + let press = frame(&ctx, vec![button(p, PointerButton::Primary, true)], true); + assert_eq!(press, PagePointer { primary_down: true, any_down: true, ..PagePointer::default() }); + } + + #[test] + fn a_press_on_the_pages_keeps_the_pointer_until_its_release() { + let ctx = Context::default(); + frame(&ctx, vec![], true); + frame(&ctx, vec![], true); + let start = pos2(40.0, 40.0); + frame(&ctx, vec![Event::PointerMoved(start), button(start, PointerButton::Primary, true)], true); + // Dragged over the Area and off the pages: still theirs. + for p in [pos2(160.0, 160.0), pos2(300.0, 50.0)] { + let held = frame(&ctx, vec![Event::PointerMoved(p)], true); + assert_eq!((held.pos, held.origin, held.primary_down), (Some(p), Some(start), true), "{p:?}"); + } + let end = pos2(300.0, 60.0); + let up = frame(&ctx, vec![Event::PointerMoved(end), button(end, PointerButton::Primary, false)], true); + assert_eq!((up.pos, up.edges.as_slice()), (Some(end), [Edge::Release(end)].as_slice()), "the release is the pages' too"); + assert!(!up.primary_down && !up.any_down, "and the button is up"); + assert_eq!(frame(&ctx, vec![], true).pos, None, "then off the pages, the pointer is not theirs"); + } + + #[test] + fn a_dialog_hides_the_pointer_and_its_clicks_but_not_a_drag_the_pages_have() { + let ctx = Context::default(); + let p = pos2(50.0, 50.0); + frame(&ctx, vec![Event::PointerMoved(p)], false); + assert_eq!(frame(&ctx, vec![], false).pos, Some(p)); + assert_eq!(frame_with(&ctx, vec![], false, true).pos, None, "no pointer under a dialog, before egui knows its layer too"); + let click = frame_with(&ctx, vec![button(p, PointerButton::Primary, true), button(p, PointerButton::Primary, false)], false, true); + assert_eq!((click.pos, click.origin, click.edges.as_slice()), (None, None, [].as_slice()), "nor a click egui gives the pages then"); + // A drag the pages already have when a dialog opens goes on until its release. + frame(&ctx, vec![button(p, PointerButton::Primary, true)], false); + frame(&ctx, vec![Event::PointerMoved(pos2(80.0, 50.0))], false); + let q = pos2(110.0, 50.0); + let dragging = frame_with(&ctx, vec![Event::PointerMoved(q)], false, true); + assert_eq!((dragging.pos, dragging.origin), (Some(q), Some(p)), "a drag the pages have keeps its pointer under a dialog"); + let up = frame_with(&ctx, vec![button(q, PointerButton::Primary, false)], false, true); + assert_eq!(up.edges.as_slice(), [Edge::Release(q)].as_slice(), "and its release"); + } + + #[test] + fn a_secondary_press_is_some_button_down_but_no_primary_edge() { + let ctx = Context::default(); + let p = pos2(50.0, 60.0); + frame(&ctx, vec![Event::PointerMoved(p)], false); + let right = frame(&ctx, vec![button(p, PointerButton::Secondary, true)], false); + assert_eq!((right.pos, right.any_down, right.primary_down, right.edges.len()), (Some(p), true, false, 0)); + } + + #[test] + fn edges_are_primary_finite_in_order_and_capped() { + let p = pos2(1.0, 2.0); + let q = pos2(3.0, 4.0); + let events = vec![ + button(p, PointerButton::Secondary, true), + button(p, PointerButton::Primary, true), + Event::PointerMoved(q), + button(pos2(f32::NAN, 0.0), PointerButton::Primary, false), + button(q, PointerButton::Primary, false), + ]; + assert_eq!(edges(&events), [Edge::Press(p), Edge::Release(q)]); + let many: Vec = (0..100).map(|i| button(p, PointerButton::Primary, i % 2 == 0)).collect(); + assert_eq!(edges(&many).len(), MAX_EDGES); + assert_eq!(first_press(&[Edge::Release(q), Edge::Press(p), Edge::Press(q)]), Some(p)); + } + + /// The page modules: the per-page chain in `canvas::document_area` and what it calls. + const PAGE_MODULES: [&str; 18] = [ + "canvas.rs", + "comments.rs", + "forms_ui.rs", + "prepare.rs", + "fill_sign.rs", + "sign_ui.rs", + "crop.rs", + "zoom_snap.rs", + "camera_ui.rs", + "measure_ui.rs", + "redact_ui.rs", + "link_ui.rs", + "content_ui.rs", + "edit_text_ui.rs", + "object_ui.rs", + "image_pick.rs", + "signature_drag.rs", + "calc_order_ui.rs", + ]; + + /// egui's raw pointer state: where the pointer is, and which buttons are down or went down. + const RAW_READS: [&str; 21] = [ + "hover_pos()", + "interact_pos()", + "latest_pos()", + "press_origin()", + "pointer_hover_pos()", + "pointer_interact_pos()", + "pointer_latest_pos()", + "primary_down()", + "primary_pressed()", + "primary_released()", + "primary_clicked()", + "any_down()", + "any_pressed()", + "any_released()", + "any_click()", + // These take the button, so they match up to the parenthesis. + "button_down(", + "button_pressed(", + "button_released(", + "button_clicked(", + "button_double_clicked(", + "button_triple_clicked(", + ]; + + /// Functions in the page modules that are not page input and read egui's pointer state + /// themselves: (file, function, why). + const NOT_PAGE_INPUT: [(&str, &str, &str); 11] = [ + ("canvas.rs", "zoom_anchor", "a pinch zooms the whole view, and is read before the pages' widget exists"), + ("canvas.rs", "drawing_edge_pointer", "scrolls the view at its edges, read before the pages' widget exists"), + ("canvas.rs", "glide_interrupted", "a press anywhere stops the touchpad's glide"), + ("canvas.rs", "presentation_click", "full screen's click to turn the page, read before the pages' widget exists"), + ("canvas.rs", "page_size_hint", "the page size hint sits in the view's corner, not on a page"), + ("canvas.rs", "organize_grid", "Organize Pages shows thumbnails, not pages"), + ("comments.rs", "keys", "keyboard handling after the pages: a drag cancelled with Escape ends once no button is down"), + ("forms_ui.rs", "overlay", "the field editor is an Area of its own over the page"), + ("content_ui.rs", "editor", "the added-text editor is an Area of its own over the page"), + ("edit_text_ui.rs", "overlay", "the paragraph editor is an Area of its own over the page"), + ("fill_sign.rs", "scan_tab", "the signature dialog's Scan tab"), + ]; + + /// The function a `fn` line declares, laid out as rustfmt does (`pub`, `const` and the like + /// before `fn`). + fn declares(line: &str) -> Option<&str> { + let mut rest = line; + loop { + let before = rest; + for prefix in ["pub(crate) ", "pub(super) ", "pub ", "const ", "async ", "unsafe "] { + rest = rest.strip_prefix(prefix).unwrap_or(rest); + } + if rest == before { + break; + } + } + let rest = rest.strip_prefix("fn ")?; + rest.find(|c: char| !(c.is_alphanumeric() || c == '_')).map(|end| &rest[..end]) + } + + /// Whether `line` calls `read`, and not a longer name that ends in it. + fn reads(line: &str, read: &str) -> bool { + line.match_indices(read).any(|(at, _)| !line[..at].ends_with(|c: char| c.is_alphanumeric() || c == '_')) + } + + /// Page modules read the pointer only through `PagePointer`. egui's raw state misses what is + /// drawn over the pages and where a touch lifted, so a raw read in a page module brings back + /// the bugs `PagePointer` fixed: a cursor or a press through a dialog, a tap that does nothing. + #[test] + fn page_modules_read_the_pointer_only_through_page_pointer() { + let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut problems = Vec::new(); + let mut needed = vec![false; NOT_PAGE_INPUT.len()]; + for file in PAGE_MODULES { + let source = std::fs::read_to_string(src.join(file)).expect("page module").replace("\r\n", "\n"); + // Tests drive egui's input themselves. + let code = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default(); + // The top-level function, or method of an `impl`, that each line is in. + let (mut function, mut in_impl) = (None, false); + for (n, line) in code.lines().enumerate() { + if line.starts_with("impl") { + (function, in_impl) = (None, true); + } else if line == "}" { + (function, in_impl) = (None, false); + } else if let Some(name) = declares(line).or_else(|| line.strip_prefix(" ").filter(|_| in_impl).and_then(declares)) { + function = Some(name); + } + if line.trim_start().starts_with("//") { + continue; + } + for read in RAW_READS.into_iter().filter(|read| reads(line, read)) { + match NOT_PAGE_INPUT.iter().position(|(f, name, _)| *f == file && function == Some(*name)) { + Some(k) => needed[k] = true, + None => problems.push(format!( + "src/{file}:{}: `{read}` reads egui's pointer state in a page module. Read `PagePointer` \ + (src/page_pointer.rs) instead: it knows what is drawn over the pages and where a touch \ + lifted. Not page input? Add the function to NOT_PAGE_INPUT there, with why.", + n + 1 + )), + } + } + } + } + for ((file, name, _), needed) in NOT_PAGE_INPUT.iter().zip(needed) { + if !needed { + problems.push(format!("src/{file}: fn {name} reads no pointer state any more: remove it from NOT_PAGE_INPUT.")); + } + } + // A module that takes the page pointer is a page module, so it is checked too. + let mut dirs = vec![src.clone()]; + while let Some(dir) = dirs.pop() { + for entry in std::fs::read_dir(dir).expect("source directory") { + let path = entry.expect("source entry").path(); + if path.is_dir() { + dirs.push(path); + continue; + } + if path.extension().is_none_or(|ext| ext != "rs") { + continue; + } + let file = + path.strip_prefix(&src).expect("under src").components().map(|c| c.as_os_str().to_string_lossy()).collect::>().join("/"); + let source = std::fs::read_to_string(&path).expect("source file"); + if file != "page_pointer.rs" && source.contains("PagePointer") && !PAGE_MODULES.contains(&file.as_str()) { + problems.push(format!("src/{file} takes the page pointer: add it to PAGE_MODULES.")); + } + } + } + assert!(problems.is_empty(), "{}", problems.join("\n")); + } +} diff --git a/crates/ui-egui/src/page_selection_ui.rs b/crates/ui-egui/src/page_selection_ui.rs index 0907232f7a..eb97b16b09 100644 --- a/crates/ui-egui/src/page_selection_ui.rs +++ b/crates/ui-egui/src/page_selection_ui.rs @@ -96,7 +96,7 @@ impl PageSelectionInput { .char_limit(16_384) .hint_text(current.saturating_add(1).to_string()), ); - field.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, ui.is_enabled(), label)); + field.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TextInput, ui.is_enabled(), label)); commit = ui.is_enabled() && !cancel && field.lost_focus(); let pick = crate::icons::button(ui, "chevron-down", 26.0, false, tl!("Select pages")); chosen = egui::Popup::menu(&pick).show(menu).and_then(|r| r.inner); diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs index 4f4197927a..99174a4eba 100644 --- a/crates/ui-egui/src/palette.rs +++ b/crates/ui-egui/src/palette.rs @@ -19,6 +19,11 @@ struct Hit { /// Where `needle` (folded with [`pdfcraft_render::text::fold_case`]) matches `hay`, for ranking. fn score(hay: &str, needle: &str) -> Option { + score_for_direction(hay, needle, crate::i18n::current().rtl()) +} + +/// Rank logical text, accepting reordered query words for right-to-left locales. +fn score_for_direction(hay: &str, needle: &str, rtl: bool) -> Option { if needle.is_empty() { return Some(0); } @@ -26,10 +31,9 @@ fn score(hay: &str, needle: &str) -> Option { if let Some(p) = h.find(needle) { return Some(p); } - // A right-to-left language's labels are in display order (their words run the other way) - // while the query is in typing order, so several words match one by one. - if crate::i18n::current().rtl() && needle.contains(char::is_whitespace) { - let first = needle.split_whitespace().map(|word| h.find(word)).try_fold(usize::MAX, |first, p| p.map(|p| first.min(p))); + // Match the all-tools search: RTL queries may name the same words in another order. + if rtl && needle.contains(char::is_whitespace) { + let first = needle.split_whitespace().try_fold(usize::MAX, |first, word| h.find(word).map(|at| first.min(at))); if let Some(first) = first { return Some(first); } @@ -39,8 +43,9 @@ fn score(hay: &str, needle: &str) -> Option { needle.chars().all(|c| it.any(|x| x == c)).then_some(100) } +/// Draw the command palette and dispatch the chosen command or tool group. pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { - let palette_rect_id = egui::Id::new("palette_rect"); + let palette_rect_id = egui::Id::unique("palette_rect"); if !app.palette_open { return; } @@ -111,8 +116,8 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { hits.truncate(12); let (down, up) = ctx.input(|i| (i.key_pressed(egui::Key::ArrowDown), i.key_pressed(egui::Key::ArrowUp))); let selected = ctx.data_mut(|data| { - let id = egui::Id::new("palette_selected"); - let query_id = egui::Id::new("palette_selected_query"); + let id = egui::Id::unique("palette_selected"); + let query_id = egui::Id::unique("palette_selected_query"); let previous_query = data.get_temp::(query_id); let selected = if previous_query.as_deref() == Some(&q) { data.get_temp::(id).unwrap_or(0) } else { 0 }.min(hits.len().saturating_sub(1)); @@ -143,7 +148,7 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { return; } let mut chosen: Option<(Option<&'static str>, Option<&'static str>)> = None; - let area_response = egui::Area::new(egui::Id::new("palette")) + let area_response = egui::Area::new(egui::Id::unique("palette")) .order(egui::Order::Foreground) .pivot(Align2::CENTER_TOP) .fixed_pos(egui::pos2(screen.center().x, screen.top() + 96.0)) @@ -152,13 +157,15 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { ui.set_width(560.0); ui.horizontal(|ui| { ui.add(icons::image("search", 18.0, t.text_muted)); - let r = ui.add( - egui::TextEdit::singleline(&mut app.palette_query) - .hint_text(tl!("Search tools and commands…")) - .frame(egui::Frame::NONE) - .font(theme::regular(15.0)) - .desired_width(f32::INFINITY), - ); + let r = ui + .add( + egui::TextEdit::singleline(&mut app.palette_query) + .hint_text(tl!("Search tools and commands…")) + .frame(egui::Frame::NONE) + .font(theme::regular(15.0)) + .desired_width(f32::INFINITY), + ) + .accessible_name(tl!("Search tools and commands")); // Enter runs the selected hit while the field is focused or relinquishing focus. if (r.has_focus() || r.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Enter)) @@ -181,7 +188,7 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { // Display is translated; matching above already considered both languages. let shown = crate::i18n::command_label(&h.label); let shown_detail = tl!(&h.detail).to_string(); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, h.ready, &shown)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, h.ready, &shown)); let fg = if h.ready { t.text } else { t.text_faint }; let detail_font = theme::regular(12.0); let detail_max = (rect.width() * 0.42).clamp(0.0, 220.0); @@ -226,9 +233,20 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { #[cfg(test)] mod search { - use super::score; + use super::{score, score_for_direction}; use pdfcraft_render::text::fold_case; + /// Word-order tolerance is RTL-only and still requires every requested word. + #[test] + fn rtl_palette_queries_accept_reordered_words_without_reversing_text() { + let label = "تنظيم الصفحات"; + assert_eq!(score_for_direction(label, &fold_case("الصفحات تنظيم"), true), Some(0)); + assert_eq!(score_for_direction(label, &fold_case("تنظيم الصفحات"), true), Some(0)); + assert_eq!(score_for_direction(label, &fold_case("الصفحات مفقود"), true), None); + assert_eq!(score_for_direction(label, &fold_case("الصفحات تنظيم"), false), None); + assert_eq!(score_for_direction("Organize Pages", &fold_case("Pages Organize"), false), None); + } + #[test] fn commands_match_in_any_case_with_turkish_i() { assert_eq!(score("İçe aktar", &fold_case("içe")), Some(0)); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index a7820c2d02..bcc4123f25 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -36,14 +36,14 @@ pub fn left_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { ); } -/// A panel's title row: Back (with `back` as its tooltip) when given, the title and Close. -/// Returns whether Back and Close were clicked. -pub(crate) fn panel_header(ui: &mut egui::Ui, t: &Tokens, title: &str, back: Option<&str>) -> (bool, bool) { +/// A panel's title row: a leading button (`lead`: its icon and tooltip) when given, the title +/// and Close. Returns whether the leading button and Close were clicked. +pub(crate) fn panel_header(ui: &mut egui::Ui, t: &Tokens, title: &str, lead: Option<(&str, &str)>) -> (bool, bool) { let mut go_back = false; let mut close = false; ui.horizontal(|ui| { - if let Some(tip) = back - && icons::button(ui, "chevron-left", 26.0, false, tip).clicked() + if let Some((icon, tip)) = lead + && icons::button(ui, icon, 26.0, false, tip).clicked() { go_back = true; } @@ -62,8 +62,14 @@ pub(crate) fn panel_header(ui: &mut egui::Ui, t: &Tokens, title: &str, back: Opt (go_back, close) } +/// The All tools sidebar: Expand (the full view) and Close, then the tool list, the first few +/// tools until View more shows them all. fn all_tools(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { - let (_, close) = panel_header(ui, t, "All tools", None); + // Labels distinct from the Combine grid's Expand and Collapse, which can show beside them. + let (expand, close) = panel_header(ui, t, "All tools", Some(("maximize-2", tl!("Expand all tools")))); + if expand { + app.expand_all_tools(); + } if close { app.left_open = false; } @@ -102,9 +108,10 @@ fn row_label(ui: &mut egui::Ui, text: &str, font: egui::FontId, color: Color32, elided } +/// Draw one tool-group row with its icon, label, badge and availability hint. fn tool_row(ui: &mut egui::Ui, t: &Tokens, g: &ToolGroup) -> egui::Response { let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!(g.label))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!(g.label))); let press = theme::Press::track(ui, &resp); press.wash(ui, rect, 6, false); let body = rect.translate(press.offset()); @@ -149,12 +156,13 @@ fn tool_row(ui: &mut egui::Ui, t: &Tokens, g: &ToolGroup) -> egui::Response { theme::hand(resp).on_hover_text(tip) } +/// Draw the detail view for one tool group, dispatching its commands and tool-specific panels. fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static ToolGroup) { if g.id == "edit" { edit_panel(app, ui, t, g); return; } - let (back, close) = panel_header(ui, t, g.label, Some(tl!("Back to all tools"))); + let (back, close) = panel_header(ui, t, g.label, Some(("chevron-left", tl!("Back to all tools")))); if back { app.left = LeftPanel::AllTools; app.enter_mode(crate::Mode::AllTools); @@ -209,7 +217,7 @@ fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static continue; } let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!(item.label))); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!(item.label))); let ready = item.availability == Availability::Ready; let press = theme::Press::track(ui, &resp); press.wash(ui, rect, 6, false); @@ -357,7 +365,7 @@ fn edit_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static widgets::section_title(ui, tl!("Add content")); let content: Vec<_> = g.sections.iter().filter(|s| s.title == "Add content").flat_map(|s| s.items.iter()).filter(|i| i.command != "edit.edit_text").collect(); - let more_id = egui::Id::new("edit_panel_more"); + let more_id = egui::Id::unique("edit_panel_more"); let more = ui.data(|d| d.get_temp::(more_id)).unwrap_or(false); let shown = if more { content.len() } else { EDIT_CONTENT_SHOWN.min(content.len()) }; for i in content.iter().take(shown) { @@ -430,7 +438,7 @@ fn actions_menu(app: &PdfCraftApp, ui: &egui::Ui, resp: &egui::Response, item: & /// item is muted and says when it ships when hovered. fn edit_row(ui: &mut egui::Ui, t: &Tokens, icon: &str, label: &str, availability: Availability) -> egui::Response { let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label)); let press = theme::Press::track(ui, &resp); press.wash(ui, rect, 6, false); let body = rect.translate(press.offset()); @@ -461,7 +469,7 @@ fn stamp_palette(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { for kind in StampKind::ALL.into_iter().filter(|k| k.group() == group) { let active = app.quick_tool == crate::QuickTool::Stamp(kind); let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 38.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, active, kind.label())); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, active, kind.label())); let press = theme::Press::track(ui, &resp); press.wash(ui, rect, 6, active); let body = rect.translate(press.offset()); @@ -648,6 +656,7 @@ pub(crate) enum Nav { Flash(usize, [f32; 4]), } +/// Draw the selected right-hand panel and apply its navigation, layer, bookmark or edit actions. pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let Some((index, id)) = app.active_ids() else { return }; @@ -699,11 +708,22 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { RightPanel::Search => ("Search", None), RightPanel::Compare => ("Compare", compare.as_ref().filter(|c| c.new == id).map(|c| c.result.changes.len())), }; + // Comments count like Acrobat (no replies); the replies go in small text below. + let replies = if panel == RightPanel::Comments { crate::comments_panel::reply_count(info) } else { 0 }; ui.horizontal(|ui| { - ui.label(egui::RichText::new(tl!(title)).font(theme::semibold(15.5))); - if let Some(c) = count { - ui.label(egui::RichText::new(c.to_string()).font(theme::medium(13.0)).color(t.text_faint)); - } + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 0.0; + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!(title)).font(theme::semibold(15.5))); + if let Some(c) = count { + ui.label(egui::RichText::new(c.to_string()).font(theme::medium(13.0)).color(t.text_faint)); + } + }); + if replies > 0 { + let text = crate::i18n::trn(crate::i18n::current(), replies as u64, "{n} reply", "{n} replies"); + ui.label(egui::RichText::new(text).font(theme::regular(11.0)).color(t.text_faint)); + } + }); if panel == RightPanel::Pages && view.selected.len() > 1 { let n = view.selected.len().to_string(); let picked = crate::i18n::fmt(tl!("{n} pages selected"), &[("n", &n)]); @@ -713,6 +733,11 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { if icons::button(ui, "x", 26.0, false, tl!("Close")).clicked() { close = true; } + if panel == RightPanel::Pages + && let Some(c) = page_tools(ui, !modal && view.panel_drag.is_none() && bm_editable) + { + panel_command = Some(c); + } // Right to left: close, "…", filter, search (Acrobat's order left to right). if panel == RightPanel::Comments { panel_command = crate::comments_panel::header_controls(ui, info, view, doc.comments_hidden()); @@ -765,7 +790,7 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { }); }); ui.add_space(6.0); - let search_id = egui::Id::new(("bookmark_search", id.0)); + let search_id = egui::Id::unique(("bookmark_search", id.0)); let mut bookmark_query = ui.data(|d| d.get_temp::(search_id)).unwrap_or_default(); if panel == RightPanel::Bookmarks && !info.outline.is_empty() { ui.horizontal(|ui| { @@ -841,7 +866,7 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { } for (li, l) in info.layers.iter().enumerate() { let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, l.visible, &l.name)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, l.visible, &l.name)); let press = theme::Press::track(ui, &resp); press.wash(ui, rect, 6, false); let body = rect.translate(press.offset()); @@ -896,42 +921,87 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { empty(ui, &t, "paperclip", "This document has no attachments."); } for (ai, a) in info.attachments.iter().enumerate() { - egui::Frame::NONE.inner_margin(egui::Margin::symmetric(4, 6)).show(ui, |ui| { - ui.horizontal(|ui| { - ui.add(icons::image("paperclip", 16.0, t.icon)); - ui.vertical(|ui| { - ui.set_width(ui.available_width() - 70.0); - ui.add(egui::Label::new(egui::RichText::new(&a.name).font(theme::medium(13.0))).truncate()); - let mut meta = a.size.map(human_size).unwrap_or_default(); - if let pdfcraft_render::AttachmentSource::Annotation { page, .. } = a.source { - let on_page = crate::i18n::fmt( - tl!("on page {label}"), - &[("label", info.pages.get(page).map(|p| p.label.as_str()).unwrap_or("?"))], + let is_pdf = a.name.to_lowercase().ends_with(".pdf"); + // Only an attachment of the document itself can be deleted here. + let deletable = match &a.source { + pdfcraft_render::AttachmentSource::Document { key } if attachable => Some(key), + _ => None, + }; + // The whole row answers a double-click and a right-click (#3466), below + // its buttons, which still take their own clicks. + let mut buttons: Vec = Vec::new(); + let row = ui.scope_builder(egui::UiBuilder::new().sense(Sense::click()), |ui| { + egui::Frame::NONE.inner_margin(egui::Margin::symmetric(4, 6)).show(ui, |ui| { + ui.horizontal(|ui| { + ui.add(icons::image("paperclip", 16.0, t.icon)); + ui.vertical(|ui| { + ui.set_width(ui.available_width() - 70.0); + // Not selectable, so a click on the name or the details reaches the row. + ui.add( + egui::Label::new(egui::RichText::new(&a.name).font(theme::medium(13.0))).truncate().selectable(false), ); - meta = format!("{meta} · {on_page}"); - } - if let Some(d) = &a.description { - meta = format!("{meta} · {d}"); - } - ui.add(egui::Label::new(egui::RichText::new(meta).color(t.text_faint).small()).truncate()); + let mut meta = a.size.map(human_size).unwrap_or_default(); + if let pdfcraft_render::AttachmentSource::Annotation { page, .. } = a.source { + let on_page = crate::i18n::fmt( + tl!("on page {label}"), + &[("label", info.pages.get(page).map(|p| p.label.as_str()).unwrap_or("?"))], + ); + meta = format!("{meta} · {on_page}"); + } + if let Some(d) = &a.description { + meta = format!("{meta} · {d}"); + } + ui.add( + egui::Label::new(egui::RichText::new(meta).color(t.text_faint).small()).truncate().selectable(false), + ); + }); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let save = icons::button(ui, "file-down", 28.0, false, tl!("Save attachment…")); + if save.clicked() { + attachment_action = Some((ai, false)); + } + buttons.push(save); + if is_pdf { + let open = icons::button(ui, "file-input", 28.0, false, tl!("Open in a new tab")); + if open.clicked() { + attachment_action = Some((ai, true)); + } + buttons.push(open); + } + if let Some(key) = deletable { + let delete = icons::button(ui, "trash-2", 28.0, false, tl!("Delete attachment")); + if delete.clicked() { + panel_edit = Some(pdfcraft_engine::Edit::DeleteAttachment { key: key.clone() }); + } + buttons.push(delete); + } + }); }); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if icons::button(ui, "file-down", 28.0, false, tl!("Save attachment…")).clicked() { - attachment_action = Some((ai, false)); - } - if a.name.to_lowercase().ends_with(".pdf") - && icons::button(ui, "file-input", 28.0, false, tl!("Open in a new tab")).clicked() - { - attachment_action = Some((ai, true)); - } - if let pdfcraft_render::AttachmentSource::Document { key } = &a.source - && attachable - && icons::button(ui, "trash-2", 28.0, false, tl!("Delete attachment")).clicked() - { - panel_edit = Some(pdfcraft_engine::Edit::DeleteAttachment { key: key.clone() }); - } - }); - }); + }) + }); + // A double-click opens a PDF in a new tab, as Acrobat opens an attachment; + // any other file is saved, since PdfCraft opens only PDFs. + if row.response.double_clicked() { + attachment_action = Some((ai, is_pdf)); + } + // A right-click on one of the row's buttons is a right-click on the row: + // the union keeps the row's id, so it is one menu either way. + let menu = buttons.into_iter().fold(row.response.clone(), |row, button| row | button); + menu.context_menu(|ui| { + if is_pdf && ui.button(tl!("Open in a new tab")).clicked() { + attachment_action = Some((ai, true)); + ui.close(); + } + if ui.button(tl!("Save attachment…")).clicked() { + attachment_action = Some((ai, false)); + ui.close(); + } + if let Some(key) = deletable + && ui.button(tl!("Delete attachment")).clicked() + { + panel_edit = Some(pdfcraft_engine::Edit::DeleteAttachment { key: key.clone() }); + ui.close(); + } }); } } @@ -1129,13 +1199,14 @@ fn title_format(item: &OutlineItem, depth: usize, t: &Tokens) -> egui::TextForma egui::TextFormat { font_id, color, italics: item.italic, ..Default::default() } } +/// Draw one bookmark row with expansion, inline rename and the editable context menu. fn outline_item(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, item: &OutlineItem, path: &[usize], siblings: usize, cx: &mut OutlineCtx<'_>) { if cx.searching && !cx.matches.contains(path) { return; } let depth = path.len() - 1; let indent = depth as f32 * 16.0; - let id = ui.id().with(("outline", path)); + let id = ui.scope_id().with(("outline", path)); if let Some(levels) = cx.expand { ui.data_mut(|d| d.insert_temp(id, depth < levels)); } @@ -1191,7 +1262,7 @@ fn outline_item(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, item: &OutlineIte let galley = ui.fonts_mut(|f| f.layout_job(job)); let h = (galley.size().y + 12.0).max(28.0); let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &item.title)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &item.title)); theme::Press::track(ui, &resp).wash(ui, rect, 6, false); let x0 = rect.left() + indent; if !item.children.is_empty() { @@ -1273,6 +1344,38 @@ fn outline_item(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, item: &OutlineIte } } +/// Page tools in the Pages panel's header (#4415): a menu, as Bookmark options is in its panel, +/// to insert a blank page or pages from a file, rotate, extract and delete. Each runs its +/// command on the selected pages (the current page when none is), so it does what Pages ▸ … +/// does; the items say so, apart from the thumbnails' own menu. `enabled` is false while the +/// document may not be assembled or a dialog is open. Returns the command a click asks for. +fn page_tools(ui: &mut egui::Ui, enabled: bool) -> Option<&'static str> { + let tools: [(&'static str, &str); 6] = [ + ("page.insert_blank", tl!("Insert a blank page after the selection")), + ("page.insert", tl!("Insert pages from a file after the selection…")), + ("page.rotate_ccw", tl!("Rotate the selected pages counterclockwise")), + ("page.rotate", tl!("Rotate the selected pages clockwise")), + ("page.extract", tl!("Extract the selected pages…")), + ("page.delete", tl!("Delete the selected pages")), + ]; + let mut picked = None; + ui.add_enabled_ui(enabled, |ui| { + let more = icons::button(ui, "ellipsis", 26.0, false, tl!("Page tools")); + egui::Popup::menu(&more).show(|ui| { + ui.set_min_width(260.0); + for (id, label) in tools { + let Some(spec) = pdfcraft_engine::commands::command(id) else { continue }; + let item = egui::Button::image_and_text(icons::image(spec.icon, 16.0, ui.visuals().text_color()), label); + if ui.add(item).clicked() { + picked = Some(id); + ui.close(); + } + } + }); + }); + picked +} + /// Size controls stay above the scroll area, so they remain reachable on long documents. fn thumbnail_size(ui: &mut egui::Ui, t: &Tokens, view: &mut crate::DocView, enabled: bool) { let before = view.thumbnail_zoom(); @@ -1379,7 +1482,7 @@ fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocVie } let info = crate::i18n::fmt(tl!("Page {label}"), &[("label", &p.label)]); let picked = view.selected.contains(&i); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, picked, info.clone())); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, picked, info.clone())); // The current page keeps its heavier border; picked pages share its fill. let selected = i == view.current; let press = theme::Press::track(ui, &resp); @@ -1623,7 +1726,7 @@ fn fields( FieldKind::Unknown => "square", }; let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, selected, &f.name)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, &f.name)); // A row scrolled out of view still takes clicks and keyboard focus, but isn't drawn: // laying out every row of a large form each frame took a tenth of a second a frame. let visible = ui.is_rect_visible(rect); @@ -1650,8 +1753,8 @@ fn fields( for (r, icon, earlier, tip) in [(up, "chevron-up", true, tl!("Earlier in tab order")), (down, "chevron-down", false, tl!("Later in tab order"))] { - let b = ui.interact(r, ui.id().with((&f.name, earlier)), Sense::click()); - b.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("{tip}: {}", f.name))); + let b = ui.interact(r, ui.scope_id().with((&f.name, earlier)), Sense::click()); + b.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, format!("{tip}: {}", f.name))); if visible { let bpress = theme::Press::track(ui, &b); bpress.wash(ui, r, 4, false); diff --git a/crates/ui-egui/src/prepare.rs b/crates/ui-egui/src/prepare.rs index 5833c38384..2635693676 100644 --- a/crates/ui-egui/src/prepare.rs +++ b/crates/ui-egui/src/prepare.rs @@ -12,6 +12,7 @@ use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; use crate::comments; +use crate::page_pointer::PagePointer; use crate::theme; use crate::widgets; @@ -150,6 +151,13 @@ pub struct PrepareView { } impl PrepareView { + /// Cancel a preparation marquee without injecting the terminal pointer release that Escape does. + pub(crate) fn cancel_pointer_gesture(&mut self) { + cancel_marquee(self); + // Unlike Escape, control cancellation never injects a terminal pointer release. + self.ignore_release = false; + } + /// Unique field names: selecting several widgets of one radio group still edits one field. pub fn names(&self) -> Vec { let mut names = Vec::new(); @@ -242,6 +250,7 @@ pub(crate) fn page_input( ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, + ptr: &PagePointer, page: usize, info: &DocInfo, form: &[FormField], @@ -250,7 +259,7 @@ pub(crate) fn page_input( view: &mut DocView, ) -> Outcome { let mut out = Outcome { consumed: false, properties: false, placed: false, table: None }; - let pointer = ui.input(|i| i.pointer.hover_pos().or(i.pointer.interact_pos())); + let pointer = ptr.pos; let prep = &mut view.prepare; if prep.marquee_before.is_some() && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { @@ -259,7 +268,7 @@ pub(crate) fn page_input( return out; } if prep.ignore_release { - if !ui.input(|i| i.pointer.primary_down()) { + if !ptr.primary_down { prep.ignore_release = false; } out.consumed = true; @@ -271,7 +280,7 @@ pub(crate) fn page_input( && gp == page { out.consumed = true; - if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) { + if resp.drag_stopped() || !ptr.primary_down { prep.grab = None; let end = pointer.unwrap_or(start); match grab { @@ -337,7 +346,7 @@ pub(crate) fn page_input( if let Some(tool) = tool { ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); if resp.drag_started() || resp.clicked() { - let origin = ui.input(|i| i.pointer.press_origin()).filter(|o| xf.rect.contains(*o)).unwrap_or(p); + let origin = ptr.origin.filter(|o| xf.rect.contains(*o)).unwrap_or(p); prep.grab = Some((page, Grab::Draw(origin), origin)); out.consumed = true; if resp.clicked() { @@ -393,7 +402,7 @@ pub(crate) fn page_input( ui.ctx().set_cursor_icon(egui::CursorIcon::Move); } if resp.drag_started() { - let origin = ui.input(|i| i.pointer.press_origin()).unwrap_or(p); + let origin = ptr.origin.unwrap_or(p); if let Some((_, left, top)) = selected_rect.and_then(|r| handles(r).into_iter().find(|(c, ..)| c.distance(origin) <= 6.0)) { prep.grab = Some((page, Grab::Corner(left, top), origin)); out.consumed = true; @@ -538,10 +547,19 @@ pub fn arrange(form: &[FormField], anchor: &(String, usize), others: &[(String, } /// Fields with their light-blue fill and name tags, the selection with its handles, and the -/// rectangle being drawn or dragged. -pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, form: &[FormField], view: &DocView) { +/// rectangle being drawn or dragged to the pages' `pointer`. +#[allow(clippy::too_many_arguments)] +pub(crate) fn paint_page( + ui: &egui::Ui, + painter: &egui::Painter, + xf: &PageXform, + page: usize, + info: &DocInfo, + form: &[FormField], + view: &DocView, + pointer: Option, +) { let prep = &view.prepare; - let pointer = ui.input(|i| i.pointer.hover_pos()); let grab = prep.grab.filter(|(gp, ..)| *gp == page); let zoom = xf.zoom().clamp(0.5, 3.0); for f in form { @@ -1360,6 +1378,7 @@ const SEPARATORS: [&str; 5] = ["1,234.56", "1234.56", "1.234,56", "1234,56", "1' const NEGATIVES: [&str; 4] = ["-1,234.01", "1,234.01 (red)", "(1,234.01)", "(1,234.01) (red)"]; const SPECIALS: [&str; 5] = ["Zip Code", "Zip Code + 4", "Phone Number", "Social Security Number", "Arbitrary Mask"]; +/// Draw the field-format editor for the selected category and its options. fn format_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) { let cat = category(&d.format); let mut picked = cat; @@ -1472,8 +1491,8 @@ fn format_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) { ui.selectable_value(p, pr.to_string(), pr); } }); - ui.label(tl!("Custom:")); - ui.add(crate::widgets::line(p).desired_width(120.0)); + let custom = ui.label(tl!("Custom:")); + ui.add(crate::widgets::line(p).desired_width(120.0)).labelled_by(custom.id); }); } Format::Special(n) => { @@ -1497,8 +1516,8 @@ fn format_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) { } }); ui.horizontal(|ui| { - ui.label(tl!("Mask:")); - ui.add(crate::widgets::line(m).desired_width(160.0)); + let mask = ui.label(tl!("Mask:")); + ui.add(crate::widgets::line(m).desired_width(160.0)).labelled_by(mask.id); }); ui.label(egui::RichText::new(tl!("9 digit, A letter, O letter or digit, X any character")).small().color(t.text_faint)); if idx < 4 { diff --git a/crates/ui-egui/src/print_ui.rs b/crates/ui-egui/src/print_ui.rs index fcc8d89d97..5bf8737fcc 100644 --- a/crates/ui-egui/src/print_ui.rs +++ b/crates/ui-egui/src/print_ui.rs @@ -300,7 +300,7 @@ pub(crate) struct PrintTasks { fn listed_printers() -> Vec { #[cfg(target_arch = "wasm32")] { - vec![spool::Printer { name: crate::web_print::BROWSER_PRINTER.to_string(), default: true, two_sided: None }] + vec![spool::Printer { name: crate::web_print::BROWSER_PRINTER.to_string(), default: true, two_sided: None, description: String::new() }] } #[cfg(not(target_arch = "wasm32"))] { @@ -894,15 +894,20 @@ fn driver_options_panel(ui: &mut egui::Ui, d: &mut PrintDraft, t: &Tokens) { } egui::Grid::new(("driver-group", n)).num_columns(2).spacing([10.0, 4.0]).show(ui, |ui| { for o in chunk { - ui.label(&o.label); let current = choices.get(&o.key).cloned().unwrap_or_else(|| o.default.clone()); let label_of = |c: &str| o.choices.iter().find(|(k, _)| k == c).map_or_else(|| c.to_string(), |(_, l)| l.clone()); let mut pick = current.clone(); - egui::ComboBox::from_id_salt(("driver-option", &o.key)).selected_text(label_of(¤t)).width(200.0).show_ui(ui, |ui| { - for (k, l) in &o.choices { - ui.selectable_value(&mut pick, k.clone(), l.as_str()); - } - }); + let option_label = ui.label(&o.label); + egui::ComboBox::from_id_salt(("driver-option", &o.key)) + .selected_text(label_of(¤t)) + .width(200.0) + .show_ui(ui, |ui| { + for (k, l) in &o.choices { + ui.selectable_value(&mut pick, k.clone(), l.as_str()); + } + }) + .response + .labelled_by(option_label.id); if pick != current { if pick == o.default { choices.remove(&o.key); @@ -918,13 +923,19 @@ fn driver_options_panel(ui: &mut egui::Ui, d: &mut PrintDraft, t: &Tokens) { }); } -fn combo(ui: &mut egui::Ui, id: &str, value: &mut T, choices: &[(T, &str)], width: f32) { +/// Draw a combo box whose accessible name does not depend on the selected value. +fn combo(ui: &mut egui::Ui, id: &str, name: &str, value: &mut T, choices: &[(T, &str)], width: f32) { let shown = choices.iter().find(|c| c.0 == *value).map_or("", |c| c.1); - egui::ComboBox::from_id_salt(id).selected_text(shown).width(width).show_ui(ui, |ui| { - for (v, label) in choices { - ui.selectable_value(value, *v, *label); - } - }); + egui::ComboBox::from_id_salt(id) + .selected_text(shown) + .width(width) + .show_ui(ui, |ui| { + for (v, label) in choices { + ui.selectable_value(value, *v, *label); + } + }) + .response + .accessible_name(name); } /// Draw the dialog. `thumb` gives a page's thumbnail texture when there is one. Returns @@ -948,7 +959,7 @@ pub(crate) fn body( ui.vertical(|ui| { ui.set_width(470.0); egui::Grid::new("print-top").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| { - ui.label(tl!("Printer:")); + let printer_caption = ui.label(tl!("Printer:")); let before = d.printer.clone(); // The browser's print dialog sets copies, two-sided printing and colour itself. let browser = d.printer.as_deref() == Some(crate::web_print::BROWSER_PRINTER); @@ -976,7 +987,9 @@ pub(crate) fn body( if ui.selectable_value(&mut d.printer, None, tl!("Save as PDF")).clicked() { d.printer_picked = true; } - }); + }) + .response + .labelled_by(printer_caption.id); let tip = if spool::HAS_PRINTER_PREFERENCES { tl!("Opens the printer driver's preferences. They are saved for every print from this computer; the copies, two-sided, grayscale and paper chosen here still apply.") } else { @@ -1017,10 +1030,10 @@ pub(crate) fn body( ); ui.end_row(); } - ui.label(tl!("Copies:")); + let copies_label = ui.label(tl!("Copies:")); ui.add_enabled_ui(!browser, |ui| { ui.horizontal(|ui| { - ui.add(egui::DragValue::new(&mut d.copies).range(1..=999)); + ui.add(egui::DragValue::new(&mut d.copies).range(1..=999)).labelled_by(copies_label.id); ui.add(widgets::checkbox(&mut d.collate, tl!("Collate"))); ui.add(widgets::checkbox(&mut d.grayscale, tl!("Print in grayscale"))); }); @@ -1034,6 +1047,7 @@ pub(crate) fn body( combo( ui, "duplex", + tl!("Two-sided:"), &mut d.duplex, &[ (None, tl!("Printer default")), @@ -1046,14 +1060,16 @@ pub(crate) fn body( }); ui.end_row(); } - ui.label(tl!("Paper:")); + let paper_label = ui.label(tl!("Paper:")); let paper_name = PAPERS.get(d.paper).map_or("US Letter", |p| p.0); let before = d.paper; egui::ComboBox::from_id_salt("paper").selected_text(tl!(paper_name)).width(260.0).show_ui(ui, |ui| { for (i, (name, _)) in PAPERS.iter().enumerate() { ui.selectable_value(&mut d.paper, i, tl!(name)); } - }); + }) + .response + .labelled_by(paper_label.id); // Label stock gets the label layout (cut, turned upright, fitted). if d.paper != before && d.paper == print::LABEL_PAPER { d.paper_before_label = Some(before); @@ -1104,11 +1120,11 @@ pub(crate) fn body( ui.horizontal(|ui| { widgets::radio_value(ui, &mut d.which, Which::All, tl!("All")); widgets::radio_value(ui, &mut d.which, Which::Current, tl!("Current page")); - widgets::radio_value(ui, &mut d.which, Which::Range, tl!("Pages")); + let pages = widgets::radio_value(ui, &mut d.which, Which::Range, tl!("Pages")); let r = ui.add_enabled( d.which == Which::Range, crate::widgets::line(&mut d.range).hint_text(format!("1-{}", sizes.len())).desired_width(110.0), - ); + ).labelled_by(pages.id); if r.gained_focus() { d.which = Which::Range; } @@ -1123,6 +1139,7 @@ pub(crate) fn body( combo( ui, "subset", + tl!("More options:"), &mut d.subset, &[(Subset::All, tl!("All pages in range")), (Subset::Odd, tl!("Odd pages only")), (Subset::Even, tl!("Even pages only"))], 150.0, @@ -1157,7 +1174,8 @@ pub(crate) fn body( if ui.add(widgets::radio(custom, tl!("Custom scale:"))).clicked() { d.size = SizeMode::Custom(d.custom_scale); } - ui.add_enabled(custom, egui::DragValue::new(&mut d.custom_scale).range(1.0..=1000.0).suffix(" %")); + ui.add_enabled(custom, egui::DragValue::new(&mut d.custom_scale).range(1.0..=1000.0).suffix(" %")) + .accessible_name(tl!("Custom scale")); }); } Handling::Label => { @@ -1170,21 +1188,22 @@ pub(crate) fn body( } Handling::Poster => { ui.horizontal(|ui| { - ui.label(tl!("Tile scale:")); - ui.add(egui::DragValue::new(&mut d.poster_scale).range(10.0..=1000.0).suffix(" %")); - ui.label(tl!("Overlap:")); - ui.add(egui::DragValue::new(&mut d.overlap).range(0.0..=144.0).suffix(" pt")); + let scale_label = ui.label(tl!("Tile scale:")); + ui.add(egui::DragValue::new(&mut d.poster_scale).range(10.0..=1000.0).suffix(" %")).labelled_by(scale_label.id); + let overlap_label = ui.label(tl!("Overlap:")); + ui.add(egui::DragValue::new(&mut d.overlap).range(0.0..=144.0).suffix(" pt")).labelled_by(overlap_label.id); ui.add(widgets::checkbox(&mut d.cut_marks, tl!("Cut marks"))); }); } Handling::Multiple => { ui.horizontal(|ui| { ui.label(tl!("Pages per sheet:")); - combo(ui, "per-sheet", &mut d.per_sheet, &[(2, "2"), (4, "4"), (6, "6"), (9, "9"), (16, "16")], 60.0); + combo(ui, "per-sheet", tl!("Pages per sheet:"), &mut d.per_sheet, &[(2, "2"), (4, "4"), (6, "6"), (9, "9"), (16, "16")], 60.0); ui.label(tl!("Page order:")); combo( ui, "order", + tl!("Page order:"), &mut d.order, &[ (PageOrder::Horizontal, tl!("Horizontal")), @@ -1212,6 +1231,7 @@ pub(crate) fn body( combo( ui, "booklet", + tl!("Booklet subset:"), &mut d.booklet_subset, &[ (BookletSubset::BothSides, tl!("Both sides")), @@ -1221,7 +1241,7 @@ pub(crate) fn body( 130.0, ); ui.label(tl!("Binding:")); - combo(ui, "binding", &mut d.binding, &[(Binding::Left, tl!("Left")), (Binding::Right, tl!("Right"))], 80.0); + combo(ui, "binding", tl!("Binding:"), &mut d.binding, &[(Binding::Left, tl!("Left")), (Binding::Right, tl!("Right"))], 80.0); }); } } @@ -1237,6 +1257,7 @@ pub(crate) fn body( combo( ui, "content", + tl!("Comments & Forms"), &mut d.content, &[ (Content::Document, tl!("Document")), @@ -1257,7 +1278,7 @@ pub(crate) fn body( ui.add_enabled_ui(d.as_image, |ui| { ui.label(tl!("Resolution:")); let mut dpi = d.dpi.round() as u32; - combo(ui, "print-dpi", &mut dpi, &[(150, "150 dpi"), (300, "300 dpi"), (600, "600 dpi")], 100.0); + combo(ui, "print-dpi", tl!("Resolution:"), &mut dpi, &[(150, "150 dpi"), (300, "300 dpi"), (600, "600 dpi")], 100.0); d.dpi = f64::from(dpi); }); }); diff --git a/crates/ui-egui/src/protect.rs b/crates/ui-egui/src/protect.rs index fb48c369de..959877de64 100644 --- a/crates/ui-egui/src/protect.rs +++ b/crates/ui-egui/src/protect.rs @@ -86,7 +86,7 @@ fn radio(ui: &mut egui::Ui, t: &Tokens, on: bool, label: &str) -> egui::Response } ui.painter().text(egui::pos2(rect.left() + 26.0, rect.center().y), egui::Align2::LEFT_CENTER, tl!(label), theme::regular(14.0), t.text); let info = tl!(label).to_string(); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, true, on, info.clone())); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, true, on, info.clone())); resp.on_hover_cursor(egui::CursorIcon::PointingHand) } diff --git a/crates/ui-egui/src/recovery.rs b/crates/ui-egui/src/recovery.rs index 128e576043..71759c4599 100644 --- a/crates/ui-egui/src/recovery.rs +++ b/crates/ui-egui/src/recovery.rs @@ -311,22 +311,32 @@ impl PdfCraftApp { } } - /// Autosave when the interval has passed (called every frame). - pub(crate) fn autosave_tick(&mut self, ctx: &egui::Context, now: f64) { + /// Autosave when the interval has passed (called by `logic`, which also runs while hidden). + /// + /// The deadline is monotonic (`web_time::Instant`): egui's input clock does not advance + /// during a hidden `run_logic`, so using it would stall autosave or restart its interval. + pub(crate) fn autosave_tick(&mut self, ctx: &egui::Context, now: web_time::Instant) { if self.recovery.is_none() { return; } - if now - self.last_autosave >= AUTOSAVE_SECS { - self.last_autosave = now; + // The first tick starts the interval, as the old zero clock did: it does not snapshot + // immediately just because no deadline had been recorded yet. + let Some(last) = self.last_autosave else { + self.last_autosave = Some(now); + if self.session.has_pending_autosave() { + ctx.request_repaint_after(std::time::Duration::from_secs_f64(AUTOSAVE_SECS)); + } + return; + }; + if now.duration_since(last).as_secs_f64() >= AUTOSAVE_SECS { + self.last_autosave = Some(now); self.autosave_now(); } // An idle native window has no input-driven frames. Wake it at the existing // autosave deadline, without keeping clean or already-snapshotted documents awake. if self.session.has_pending_autosave() { - let remaining = (self.last_autosave + AUTOSAVE_SECS - now).clamp(0.0, AUTOSAVE_SECS); - if let Ok(delay) = std::time::Duration::try_from_secs_f64(remaining) { - ctx.request_repaint_after(delay); - } + let next = self.last_autosave.unwrap_or(now) + std::time::Duration::from_secs_f64(AUTOSAVE_SECS); + ctx.request_repaint_after(next.saturating_duration_since(now)); } } @@ -469,8 +479,8 @@ mod idle_tests { assert_eq!(distinct.len(), 200); } - fn frame(app: &mut PdfCraftApp, ctx: &egui::Context, now: f64) -> Duration { - let mut out = ctx.run_ui(egui::RawInput { time: Some(now), ..Default::default() }, |ui| { + fn frame(app: &mut PdfCraftApp, ctx: &egui::Context, now: web_time::Instant) -> Duration { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { app.autosave_tick(ui.ctx(), now); }); // Headless timer test: acknowledge texture uploads without a GPU backend. @@ -478,6 +488,8 @@ mod idle_tests { out.viewport_output[&egui::ViewportId::ROOT].repaint_delay } + /// Check that an idle dirty document schedules a bounded write and autosaves without input, then stops + /// waking the window. #[test] fn idle_dirty_document_requests_its_deadline_and_writes_without_input() { let dir = TestDir::new(); @@ -487,22 +499,26 @@ mod idle_tests { let bytes = app.session.create_blank(200.0, 300.0, 1).unwrap(); let id = app.session.open_new("Untitled.pdf", bytes).unwrap(); let ctx = egui::Context::default(); + let base = web_time::Instant::now(); + frame(&mut app, &ctx, base); let mut delay = Duration::ZERO; for _ in 0..4 { - delay = frame(&mut app, &ctx, 15.0); + delay = frame(&mut app, &ctx, base + Duration::from_secs(15)); } assert!(delay > Duration::ZERO && delay <= Duration::from_secs(45), "idle deadline: {delay:?}"); assert!(store.list().is_empty()); - frame(&mut app, &ctx, 60.0); + frame(&mut app, &ctx, base + Duration::from_secs(60)); assert_eq!(store.list().len(), 1, "timer frame writes the snapshot without input"); assert!(app.session.get(id).unwrap().dirty, "autosave does not mark the document saved"); assert!(!app.session.has_pending_autosave()); for _ in 0..4 { - delay = frame(&mut app, &ctx, 61.0); + delay = frame(&mut app, &ctx, base + Duration::from_secs(61)); } assert_eq!(delay, Duration::MAX, "unchanged snapshot needs no periodic wake"); } + /// Check that an edit after autosave schedules the next deadline, keeps the old snapshot until then, and + /// replaces it on time. #[test] fn second_edit_after_autosave_schedules_next_deadline_and_updates_snapshot() { let dir = TestDir::new(); @@ -512,7 +528,9 @@ mod idle_tests { let bytes = app.session.create_blank(200.0, 300.0, 1).unwrap(); let id = app.session.open_new("Untitled.pdf", bytes).unwrap(); let ctx = egui::Context::default(); - frame(&mut app, &ctx, 60.0); + let base = web_time::Instant::now(); + frame(&mut app, &ctx, base); + frame(&mut app, &ctx, base + Duration::from_secs(60)); let entries = store.list(); assert_eq!(entries.len(), 1); let key = entries[0].key.clone(); @@ -520,20 +538,20 @@ mod idle_tests { assert!(!app.session.has_pending_autosave()); let mut delay = Duration::ZERO; for _ in 0..4 { - delay = frame(&mut app, &ctx, 61.0); + delay = frame(&mut app, &ctx, base + Duration::from_secs(61)); } assert_eq!(delay, Duration::MAX); app.session.apply(id, pdfcraft_engine::Edit::SetInfo { key: "Title".into(), value: "Second edit".into() }).unwrap(); assert!(app.session.has_pending_autosave()); for _ in 0..4 { - delay = frame(&mut app, &ctx, 75.0); + delay = frame(&mut app, &ctx, base + Duration::from_secs(75)); } assert!(delay > Duration::ZERO && delay <= Duration::from_secs(45), "second edit wakes by 120 seconds: {delay:?}"); assert_eq!(store.read(&key).unwrap(), first, "editing does not overwrite the snapshot before its deadline"); - frame(&mut app, &ctx, 119.0); + frame(&mut app, &ctx, base + Duration::from_secs(119)); assert_eq!(store.read(&key).unwrap(), first); - frame(&mut app, &ctx, 120.0); + frame(&mut app, &ctx, base + Duration::from_secs(120)); let entries = store.list(); assert_eq!(entries.len(), 1, "the same document replaces its existing snapshot"); assert_eq!(entries[0].key, key); @@ -545,11 +563,12 @@ mod idle_tests { assert!(app.session.get(id).unwrap().dirty); assert!(!app.session.has_pending_autosave()); for _ in 0..4 { - delay = frame(&mut app, &ctx, 121.0); + delay = frame(&mut app, &ctx, base + Duration::from_secs(121)); } assert_eq!(delay, Duration::MAX, "the second snapshot also lets the window sleep"); } + /// Check that a clean document and a document with recovery disabled never schedule an autosave. #[test] fn clean_documents_and_disabled_recovery_do_not_schedule_autosave() { for recovery_enabled in [false, true] { @@ -565,9 +584,10 @@ mod idle_tests { app.session.open_new("Unsaved.pdf", bytes).unwrap(); } let ctx = egui::Context::default(); + let base = web_time::Instant::now(); let mut delay = Duration::ZERO; for _ in 0..4 { - delay = frame(&mut app, &ctx, 15.0); + delay = frame(&mut app, &ctx, base + Duration::from_secs(15)); } assert_eq!(delay, Duration::MAX); } diff --git a/crates/ui-egui/src/redact_ui.rs b/crates/ui-egui/src/redact_ui.rs index 412fc95997..6a5bee7ea4 100644 --- a/crates/ui-egui/src/redact_ui.rs +++ b/crates/ui-egui/src/redact_ui.rs @@ -8,6 +8,7 @@ use pdfcraft_engine::{Edit, NewAnnotation, REDACT_PATTERNS, REDACTION_CODE_SETS, use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; +use crate::page_pointer::PagePointer; use crate::theme::Tokens; use crate::{PdfCraftApp, widgets}; @@ -234,20 +235,22 @@ fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] { /// Redact tool input on a page. Presses on text are left to text selection (the selection is /// marked when it ends, see [`after_text`]); presses elsewhere draw a box. Returns `true` when /// the gesture is a box. +#[allow(clippy::too_many_arguments)] pub(crate) fn page_input( ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, + ptr: &PagePointer, page: usize, info: &DocInfo, over_text: impl Fn(Pos2) -> bool, view: &mut DocView, ) -> bool { - let pointer = ui.input(|i| i.pointer.hover_pos().or(i.pointer.interact_pos())); + let pointer = ptr.pos; if let Some((dp, start)) = view.redact_drag && dp == page { - if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) { + if resp.drag_stopped() || !ptr.primary_down { view.redact_drag = None; let end = pointer.unwrap_or(start); let r = Rect::from_two_pos(start, end).intersect(xf.rect); @@ -263,7 +266,7 @@ pub(crate) fn page_input( ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); } if resp.drag_started() { - let origin = ui.input(|i| i.pointer.press_origin()).unwrap_or(p); + let origin = ptr.origin.unwrap_or(p); if !over_text(origin) { view.redact_drag = Some((page, origin)); return true; @@ -283,9 +286,9 @@ pub(crate) fn after_text(resp: &egui::Response, page: usize, info: &DocInfo, vie } } -/// The box being drawn. -pub(crate) fn paint(ui: &egui::Ui, painter: &egui::Painter, page: usize, view: &DocView) { - if let (Some((dp, start)), Some(p)) = (view.redact_drag, ui.input(|i| i.pointer.hover_pos())) +/// The box being drawn, to the pages' `pointer`. +pub(crate) fn paint(painter: &egui::Painter, page: usize, view: &DocView, pointer: Option) { + if let (Some((dp, start)), Some(p)) = (view.redact_drag, pointer) && dp == page { let r = Rect::from_two_pos(start, p); @@ -385,7 +388,7 @@ pub(crate) fn search_body(ui: &mut egui::Ui, d: &mut SearchDraft, t: &Tokens) -> widgets::radio_value(ui, &mut d.mode, SearchMode::Phrase, tl!("Single word or phrase")); let mut enter = false; ui.add_enabled_ui(d.mode == SearchMode::Phrase, |ui| { - let r = ui.add(crate::widgets::line(&mut d.text).hint_text(tl!("Text to find")).desired_width(380.0)); + let r = ui.add(crate::widgets::line(&mut d.text).hint_text(tl!("Text to find")).desired_width(380.0)).accessible_name(tl!("Text to find")); enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); }); ui.add_space(6.0); @@ -393,7 +396,8 @@ pub(crate) fn search_body(ui: &mut egui::Ui, d: &mut SearchDraft, t: &Tokens) -> let mut import = false; ui.add_enabled_ui(d.mode == SearchMode::Words, |ui| { egui::ScrollArea::vertical().id_salt("redact-words").max_height(120.0).show(ui, |ui| { - ui.add(egui::TextEdit::multiline(&mut d.words).hint_text(tl!("One word or phrase per line")).desired_width(380.0).desired_rows(4)); + ui.add(egui::TextEdit::multiline(&mut d.words).hint_text(tl!("One word or phrase per line")).desired_width(380.0).desired_rows(4)) + .accessible_name(tl!("Words or phrases to redact")); }); ui.horizontal(|ui| { import = ui.button(tl!("Import list…")).clicked(); diff --git a/crates/ui-egui/src/scroll_speed.rs b/crates/ui-egui/src/scroll_speed.rs index e5a2b453f4..ebb6a34c41 100644 --- a/crates/ui-egui/src/scroll_speed.rs +++ b/crates/ui-egui/src/scroll_speed.rs @@ -90,7 +90,13 @@ mod tests { fn wheel(unit: egui::MouseWheelUnit, dy: f32, command: bool) -> egui::Event { let modifiers = egui::Modifiers { command, ..egui::Modifiers::NONE }; - egui::Event::MouseWheel { unit, delta: egui::vec2(0.0, dy), phase: egui::TouchPhase::Move, modifiers } + egui::Event::MouseWheel { + unit, + delta: egui::vec2(0.0, dy), + phase: egui::TouchPhase::Move, + modifiers, + source: egui::MouseWheelSource::Unknown, + } } #[test] diff --git a/crates/ui-egui/src/search_ui.rs b/crates/ui-egui/src/search_ui.rs index 630ca898dc..85a9b46052 100644 --- a/crates/ui-egui/src/search_ui.rs +++ b/crates/ui-egui/src/search_ui.rs @@ -11,6 +11,7 @@ use crate::widgets; /// Glyphs of context shown on each side of a match. const CONTEXT: usize = 36; +/// Draw the Search panel with its query, options, progress and clickable result excerpts. pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, view: &mut DocView, pages: usize) { crate::widgets::begin_dialog(ui); let searched = view.texts.len() + view.text_failed.len(); @@ -18,7 +19,8 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, view: &mut DocView, pages: us find.in_panel = true; let l = ui.label(egui::RichText::new(tl!("What word or phrase would you like to search for?")).color(t.text_muted)); let r = ui - .add(crate::widgets::line(&mut find.query).id(egui::Id::new("search-panel-input")).hint_text(tl!("Search")).desired_width(f32::INFINITY)) + .add(crate::widgets::line(&mut find.query).id(egui::Id::unique("search-panel-input")).hint_text(tl!("Search")).desired_width(f32::INFINITY)) + .accessible_name(tl!("Search")) .labelled_by(l.id); if find.focus { r.request_focus(); diff --git a/crates/ui-egui/src/side_panels.rs b/crates/ui-egui/src/side_panels.rs new file mode 100644 index 0000000000..71c3baa648 --- /dev/null +++ b/crates/ui-egui/src/side_panels.rs @@ -0,0 +1,522 @@ +//! The side panels around the page: the Tools pane, the rail and the open side panel +//! ([`crate::RightPanel`]), drawn with craft-ui task panes ([`craft_ui::panes`]). +//! +//! On the right, outermost first: the rail, the Tools pane when Preferences ▸ Tools panel side is +//! Right, then the side panel, then the page. Panes are docked only; their widths are resizable +//! and saved with the preferences. + +use std::ops::RangeInclusive; + +use craft_ui::panes::{Edge, PaneContent, PaneHost, PaneLabels, PaneRequest, PaneState, SidePolicy}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::{Mode, PdfCraftApp}; + +/// Default width of the Tools pane. +pub const TOOLS_WIDTH: f32 = 272.0; +/// Default width of the side panel. +pub const SIDE_WIDTH: f32 = 330.0; +const TOOLS_RANGE: RangeInclusive = 240.0..=520.0; +const SIDE_RANGE: RangeInclusive = 260.0..=520.0; +/// Version of the saved `side_panels` value. +const VERSION: u64 = 1; +/// Deepest split tree the old layout migration walks; the old renderer allowed far fewer. +const MAX_DEPTH: usize = 32; + +/// The two panes [`PaneHost`] draws. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Pane { + Tools, + /// The open [`crate::RightPanel`]. + Side, +} + +impl Pane { + pub fn parse(id: &str) -> Option { + match id { + "tools" => Some(Self::Tools), + "side" => Some(Self::Side), + _ => None, + } + } + + fn range(self) -> RangeInclusive { + match self { + Self::Tools => TOOLS_RANGE, + Self::Side => SIDE_RANGE, + } + } +} + +/// Preferences ▸ Tools panel side. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ToolsSide { + #[default] + Left, + Right, +} + +/// The saved side panel layout. Which side panel is open stays in `PdfCraftApp::right` and the +/// Tools pane's open state in `PdfCraftApp::left_open`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SidePanels { + /// The one setting that places the Tools pane; [`SidePanels::tools_edge`] is its only reader + /// in the layout. + pub tools_side: ToolsSide, + pub tools_width: f32, + pub side_width: f32, +} + +impl Default for SidePanels { + fn default() -> Self { + Self { tools_side: ToolsSide::Left, tools_width: TOOLS_WIDTH, side_width: SIDE_WIDTH } + } +} + +/// A width from settings or automation: finite, within the pane's limits. +fn clamp_width(pane: Pane, width: f64) -> Option { + let range = pane.range(); + width.is_finite().then(|| (width as f32).clamp(*range.start(), *range.end())) +} + +/// What [`restore`] read from the saved preferences. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Restored { + pub panels: SidePanels, + /// `None`: not saved; keep the default. + pub tools_open: Option, + /// An old layout had the side panel closed. + pub side_closed: bool, +} + +impl SidePanels { + pub fn tools_on_right(&self) -> bool { + self.tools_side == ToolsSide::Right + } + + fn tools_edge(&self) -> Edge { + match self.tools_side { + ToolsSide::Left => Edge::Start, + ToolsSide::Right => Edge::End, + } + } + + pub fn width(&self, pane: Pane) -> f32 { + match pane { + Pane::Tools => self.tools_width, + Pane::Side => self.side_width, + } + } + + fn set_width(&mut self, pane: Pane, width: f32) { + let Some(width) = clamp_width(pane, f64::from(width)) else { return }; + match pane { + Pane::Tools => self.tools_width = width, + Pane::Side => self.side_width = width, + } + } + + /// The saved value (`side_panels` in the preferences). + pub fn to_json(&self, tools_open: bool) -> Value { + json!({ + "version": VERSION, + "tools_side": self.tools_side, + "tools_open": tools_open, + "tools_width": self.tools_width, + "side_width": self.side_width, + }) + } +} + +/// Reads the side panel layout from saved preferences: `side_panels`, else the old `docking` +/// value, else the defaults. Nothing in it is an error: a malformed or unknown value gives the +/// defaults. +pub fn restore(saved: &Value) -> Restored { + let defaults = Restored { panels: SidePanels::default(), tools_open: None, side_closed: false }; + if let Some(value) = saved.get("side_panels") { + if value.get("version").and_then(Value::as_u64) != Some(VERSION) { + return defaults; + } + let mut panels = SidePanels::default(); + if let Some(side) = value.get("tools_side").and_then(|v| serde_json::from_value::(v.clone()).ok()) { + panels.tools_side = side; + } + if let Some(width) = value.get("tools_width").and_then(Value::as_f64).and_then(|w| clamp_width(Pane::Tools, w)) { + panels.tools_width = width; + } + if let Some(width) = value.get("side_width").and_then(Value::as_f64).and_then(|w| clamp_width(Pane::Side, w)) { + panels.side_width = width; + } + return Restored { panels, tools_open: value.get("tools_open").and_then(Value::as_bool), side_closed: false }; + } + let Some(mut restored) = saved.get("docking").and_then(migrate) else { return defaults }; + if let Some(open) = saved.get("docking_left_open").and_then(Value::as_bool) { + restored.tools_open = Some(open); + } + restored +} + +/// Where the old docking tree held a panel. +struct Found { + /// The panel's group held only it: a tab group or stack of its own. + alone: bool, + /// The innermost split around the panel's own group. + split: Option, +} + +struct Split { + horizontal: bool, + /// The panel is in the split's second (right or bottom) child. + second: bool, + /// The split's fixed size, when it is the panel's side. + size: Option, +} + +/// The pixel size of `size` (`{"FixedFirst": w}` or `{"FixedSecond": w}`) when it fixes the side +/// the panel is on. +fn fixed_size(size: Option<&Value>, second: bool) -> Option { + let key = if second { "FixedSecond" } else { "FixedFirst" }; + size?.get(key)?.as_f64() +} + +fn group_ids(node: &Value) -> Option> { + if let Some(tabs) = node.get("Tabs") { + return Some(tabs.get("panels")?.as_array()?.iter().filter_map(Value::as_str).collect()); + } + let entries = node.get("Stack")?.get("entries")?.as_array()?; + Some(entries.iter().filter_map(|entry| entry.get("panel")?.as_str()).collect()) +} + +fn find(node: &Value, panel: &str, depth: usize) -> Option { + if depth > MAX_DEPTH { + return None; + } + if let Some(ids) = group_ids(node) { + return ids.contains(&panel).then(|| Found { alone: ids.iter().all(|id| *id == panel), split: None }); + } + let split = node.get("Split")?; + for (key, second) in [("first", false), ("second", true)] { + if let Some(mut found) = split.get(key).and_then(|child| find(child, panel, depth + 1)) { + // A panel sharing its group was placed by its group, not by a split. + if found.alone && found.split.is_none() { + let horizontal = split.get("axis").and_then(Value::as_str) == Some("Horizontal"); + found.split = Some(Split { horizontal, second, size: fixed_size(split.get("size"), second) }); + } + return Some(found); + } + } + None +} + +/// The old layout's saved place of a closed panel: whether it was on the right and its width. +fn hidden_place<'a>(old: &'a Value, panel: &str) -> Option<&'a Value> { + old.get("hidden")?.as_array()?.iter().find_map(|entry| { + let pair = entry.as_array()?; + (pair.first()?.as_str()? == panel).then(|| pair.get(1)).flatten() + }) +} + +fn floating_contains(old: &Value, panel: &str) -> bool { + old.get("layout").and_then(|l| l.get("floating")).and_then(Value::as_array).is_some_and(|groups| { + groups.iter().any(|group| group.get("panels").and_then(Value::as_array).is_some_and(|ids| ids.iter().any(|id| id.as_str() == Some(panel)))) + }) +} + +/// A panel of the old layout: on the right edge, open, and its width where the layout stated it +/// in points. A floating or tabbed panel counts as on the left, as the old Tools side reading did. +fn old_panel(old: &Value, panel: &str) -> (bool, bool, Option) { + let root = old.get("layout").and_then(|l| l.get("root")); + if let Some(found) = root.and_then(|root| find(root, panel, 0)) { + return match found.split { + Some(split) if split.horizontal => (split.second, true, split.size), + _ => (false, true, None), + }; + } + if floating_contains(old, panel) { + return (false, true, None); + } + let Some(place) = hidden_place(old, panel) else { return (false, false, None) }; + let right = place.get("placement").and_then(|p| p.get("Split")).and_then(Value::as_str) == Some("Right"); + let left = place.get("placement").and_then(|p| p.get("Split")).and_then(Value::as_str) == Some("Left"); + let size = if right || left { fixed_size(place.get("size"), right) } else { None }; + (right, false, size) +} + +/// Reads the old docking layout (craft-ui's `Workspace`) as plain JSON. `None` when it has no +/// document view, which the old layout always held. +fn migrate(old: &Value) -> Option { + find(old.get("layout")?.get("root")?, "canvas", 0)?; + let mut panels = SidePanels::default(); + let (tools_right, tools_open, tools_width) = old_panel(old, "tools"); + let (_, side_open, side_width) = old_panel(old, "inspector"); + if tools_right { + panels.tools_side = ToolsSide::Right; + } + if let Some(width) = tools_width.and_then(|w| clamp_width(Pane::Tools, w)) { + panels.tools_width = width; + } + if let Some(width) = side_width.and_then(|w| clamp_width(Pane::Side, w)) { + panels.side_width = width; + } + Some(Restored { panels, tools_open: Some(tools_open), side_closed: !side_open }) +} + +/// What `ui.panel` and `ui.state` report. +pub fn state(app: &PdfCraftApp) -> Value { + let panels = &app.side_panels; + json!({ + "tools_side": panels.tools_side, + "tools_open": app.left_open, + "tools_width": panels.tools_width, + "side_open": app.right.is_some(), + "side_panel": app.right.map(|r| format!("{r:?}")), + "side_width": panels.side_width, + }) +} + +/// `ui.panel {panel, open?, width?}` and `ui.dock.reset` (`reset`). +pub fn command(app: &mut PdfCraftApp, reset: bool, params: &Value) -> Result { + if reset { + app.side_panels = SidePanels::default(); + app.left_open = true; + return Ok(json!({ "side_panels": state(app) })); + } + let pane = Pane::parse(params.get("panel").and_then(Value::as_str).ok_or("panel must be \"tools\" or \"side\"")?) + .ok_or("panel must be \"tools\" or \"side\"")?; + let open = match params.get("open") { + None | Some(Value::Null) => None, + Some(value) => Some(value.as_bool().ok_or("open must be a boolean")?), + }; + let width = match params.get("width") { + None | Some(Value::Null) => None, + Some(value) => Some(value.as_f64().and_then(|w| clamp_width(pane, w)).ok_or("width must be a finite number")?), + }; + if let Some(width) = width { + app.side_panels.set_width(pane, width); + } + match (pane, open) { + (Pane::Tools, Some(open)) => app.left_open = open, + (Pane::Side, Some(true)) if app.right.is_none() => app.toggle_side_panel(), + (Pane::Side, Some(false)) if app.right.is_some() => app.choose_right_panel(None), + _ => {} + } + Ok(json!({ "side_panels": state(app) })) +} + +struct Content<'a> { + app: &'a mut PdfCraftApp, +} + +impl PaneContent for Content<'_> { + fn title(&self, pane: &Pane) -> String { + crate::i18n::t(match pane { + Pane::Tools => "Tools", + Pane::Side => "Inspector", + }) + .to_owned() + } + + fn body(&mut self, ui: &mut egui::Ui, pane: &Pane) { + match pane { + Pane::Tools => crate::panels::left_panel(self.app, ui), + Pane::Side => { + ui.data_mut(|data| data.insert_temp(egui::Id::unique("pdfcraft-inspector-body"), ui.max_rect())); + crate::panels::right_panel(self.app, ui); + } + } + } + + fn default_state(&self, pane: &Pane) -> PaneState { + state_of(&self.app.side_panels, *pane) + } + + fn size_range(&self, pane: &Pane) -> RangeInclusive { + pane.range() + } + + fn movable(&self, _pane: &Pane) -> bool { + false + } + + fn title_bar(&self, _pane: &Pane) -> bool { + // Both panels draw their own header and Close. + false + } +} + +fn state_of(panels: &SidePanels, pane: Pane) -> PaneState { + let edge = match pane { + Pane::Tools => panels.tools_edge(), + Pane::Side => Edge::End, + }; + PaneState::docked(edge, panels.width(pane)) +} + +/// The panes that show this frame, outermost first on a shared edge. +fn open_panes(app: &PdfCraftApp) -> Vec { + let mut open = Vec::new(); + // Read mode hides the tools, not the navigation panes. + if app.left_open && app.mode != Mode::Read { + open.push(Pane::Tools); + } + // Home and Combine files have no side panel; the choice is kept for the next document. + if app.active.is_some() && app.right.is_some() { + open.push(Pane::Side); + } + open +} + +/// Draws the rail and the open panes. Call after the top bars and before the central panel. +pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { + if let Some(panel) = app.right { + app.last_right = panel; + } + // The rail first: it is the outermost panel on the right. + if app.active.is_some() { + crate::chrome::right_rail(app, ui); + } + // The expanded All tools view covers the panes. + if app.mode == Mode::AllTools && app.all_tools_full { + return; + } + let open = open_panes(app); + if open.is_empty() { + return; + } + let t = crate::theme::Tokens::get(ui.ctx()); + let labels = PaneLabels { resize: crate::i18n::t("Resize panels").to_owned(), ..PaneLabels::default() }; + let panels = app.side_panels; + // Columns: both panes may share the right edge, Tools outermost, as `open` lists them. + let output = PaneHost::new(egui::Id::unique("pdfcraft-side-panels")) + .policy(SidePolicy::Columns) + .f6(false) + .labels(labels) + .frame(egui::Frame::NONE.fill(t.panel)) + .body_margin(egui::Margin::ZERO) + .show(ui, &open, |pane| Some(state_of(&panels, *pane)), &mut Content { app }); + for request in output.requests { + match request { + PaneRequest::Resize(pane, width) => app.side_panels.set_width(pane, width), + PaneRequest::Close(Pane::Tools) => app.left_open = false, + PaneRequest::Close(Pane::Side) => app.choose_right_panel(None), + _ => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tabs(panels: &[&str]) -> Value { + json!({"Tabs": {"panels": panels, "active": 0}}) + } + + fn split(size: Value, first: Value, second: Value) -> Value { + json!({"Split": {"axis": "Horizontal", "size": size, "first": first, "second": second}}) + } + + fn workspace(root: Value, floating: Value, hidden: Value) -> Value { + json!({"docking": {"layout": {"root": root, "floating": floating}, "hidden": hidden}}) + } + + /// The old default layout, as craft-ui 5c874d5 serialized it. + fn docked(tools: f64, inspector: f64) -> Value { + workspace( + split(json!({"FixedFirst": tools}), tabs(&["tools"]), split(json!({"FixedSecond": inspector}), tabs(&["canvas"]), tabs(&["inspector"]))), + json!([]), + json!([]), + ) + } + + #[test] + fn docked_default_keeps_widths() { + let restored = restore(&docked(300.0, 400.0)); + assert_eq!(restored.panels, SidePanels { tools_side: ToolsSide::Left, tools_width: 300.0, side_width: 400.0 }); + assert_eq!((restored.tools_open, restored.side_closed), (Some(true), false)); + } + + #[test] + fn tools_on_the_right_moves_right() { + // `set_tools_on_right` put Tools to the right of the Inspector. + let old = workspace( + split(json!({"FixedSecond": 330.0}), tabs(&["canvas"]), split(json!({"FixedSecond": 290.0}), tabs(&["inspector"]), tabs(&["tools"]))), + json!([]), + json!([]), + ); + let restored = restore(&old); + assert_eq!(restored.panels.tools_side, ToolsSide::Right); + assert_eq!(restored.panels.tools_width, 290.0); + // The Inspector's own split fixes Tools' width, not its. + assert_eq!(restored.panels.side_width, SIDE_WIDTH); + } + + #[test] + fn floated_tools_docks_left_and_stays_open() { + let old = workspace( + split(json!({"FixedSecond": 360.0}), tabs(&["canvas"]), tabs(&["inspector"])), + json!([{"panels": ["tools"], "active": 0, "rect": [10.0, 20.0, 300.0, 400.0]}]), + json!([]), + ); + let restored = restore(&old); + assert_eq!(restored.panels, SidePanels { tools_side: ToolsSide::Left, tools_width: TOOLS_WIDTH, side_width: 360.0 }); + assert_eq!((restored.tools_open, restored.side_closed), (Some(true), false)); + } + + #[test] + fn tools_tabbed_with_the_inspector_docks_both() { + let old = workspace(split(json!({"FixedSecond": 350.0}), tabs(&["canvas"]), tabs(&["inspector", "tools"])), json!([]), json!([])); + let restored = restore(&old); + assert_eq!(restored.panels.tools_side, ToolsSide::Left); + assert_eq!((restored.panels.tools_width, restored.panels.side_width), (TOOLS_WIDTH, SIDE_WIDTH)); + assert_eq!((restored.tools_open, restored.side_closed), (Some(true), false)); + } + + #[test] + fn a_hidden_inspector_stays_closed_and_hidden_tools_keeps_its_side() { + let old = workspace( + tabs(&["canvas"]), + json!([]), + json!([ + ["inspector", {"floating": null, "anchor": "canvas", "placement": {"Split": "Right"}, "size": {"FixedSecond": 380.0}, "stack": null, "sibling": ["canvas"]}], + ["tools", {"floating": null, "anchor": "canvas", "placement": {"Split": "Right"}, "size": {"FixedSecond": 250.0}, "stack": null, "sibling": ["canvas"]}] + ]), + ); + let restored = restore(&old); + assert_eq!(restored.panels, SidePanels { tools_side: ToolsSide::Right, tools_width: 250.0, side_width: 380.0 }); + assert_eq!((restored.tools_open, restored.side_closed), (Some(false), true)); + } + + #[test] + fn malformed_values_give_the_defaults() { + let defaults = Restored { panels: SidePanels::default(), tools_open: None, side_closed: false }; + for saved in [ + json!({"docking": "not a layout"}), + json!({"docking": {"layout": [1, 2]}}), + json!({"docking": {"layout": {"root": {"Split": {"axis": 3, "size": {"FixedFirst": "wide"}, "first": null}}}}}), + json!({"side_panels": {"version": 2, "tools_side": "right"}}), + json!({"side_panels": "right"}), + ] { + let restored = restore(&saved); + assert_eq!(restored, defaults, "{saved}"); + } + // Non-finite and out-of-range widths fall back or clamp; an unknown side keeps Left. + let restored = restore(&json!({"side_panels": {"version": 1, "tools_side": "up", "tools_width": 9000.0, "side_width": "x"}})); + assert_eq!(restored.panels, SidePanels { tools_side: ToolsSide::Left, tools_width: 520.0, side_width: SIDE_WIDTH }); + // Deep trees are cut off rather than walked. + let mut deep = tabs(&["tools"]); + for _ in 0..200 { + deep = split(json!({"Ratio": 0.5}), tabs(&["canvas"]), deep); + } + assert_eq!(restore(&workspace(deep, json!([]), json!([]))).panels.tools_side, ToolsSide::Left); + } + + #[test] + fn saved_value_round_trips() { + let panels = SidePanels { tools_side: ToolsSide::Right, tools_width: 300.0, side_width: 410.0 }; + let restored = restore(&json!({ "side_panels": panels.to_json(false) })); + assert_eq!(restored, Restored { panels, tools_open: Some(false), side_closed: false }); + } +} diff --git a/crates/ui-egui/src/sign_ui.rs b/crates/ui-egui/src/sign_ui.rs index 25f92530b2..8f6b2c4a0c 100644 --- a/crates/ui-egui/src/sign_ui.rs +++ b/crates/ui-egui/src/sign_ui.rs @@ -10,6 +10,7 @@ use pdfcraft_engine::{SignOptions, SignatureImage, SignatureInfo, SignatureStatu use crate::canvas::{DocView, PageXform, Snapshot, SnapshotKind}; use crate::fill_sign::SavedSig; +use crate::page_pointer::PagePointer; use crate::theme::{self, Tokens}; use crate::{PdfCraftApp, icons, widgets}; @@ -151,9 +152,16 @@ pub struct SignView { } /// Drag a signature rectangle on one page (Digitally sign, Certify (visible signature)). -pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, page: usize, info: &pdfcraft_render::DocInfo, view: &mut DocView) { - let pointer = ui.input(|i| i.pointer.hover_pos()); - let origin = ui.input(|i| i.pointer.press_origin()); +pub(crate) fn page_input( + ui: &egui::Ui, + resp: &egui::Response, + xf: &PageXform, + ptr: &PagePointer, + page: usize, + info: &pdfcraft_render::DocInfo, + view: &mut DocView, +) { + let (pointer, origin) = (ptr.pos, ptr.origin); if pointer.is_some_and(|p| xf.rect.contains(p)) { ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); } @@ -670,6 +678,7 @@ fn error(ui: &mut egui::Ui, err: &Option) { } } +/// Draw the digital-ID chooser, including unusable certificates and removal confirmation. fn choose(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { title(ui, "Sign with a Digital ID"); ui.label(tl!("Choose the digital ID that you want to use for signing:")); @@ -682,7 +691,7 @@ fn choose(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { let selected = d.selected == Some(i); let usable = e.unusable.is_none(); let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 52.0), egui::Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, usable, selected, &e.name)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, usable, selected, &e.name)); let fill = if selected { t.accent_soft } else if resp.hovered() && usable { diff --git a/crates/ui-egui/src/signature_drag.rs b/crates/ui-egui/src/signature_drag.rs index 788f333e68..e574d68ec4 100644 --- a/crates/ui-egui/src/signature_drag.rs +++ b/crates/ui-egui/src/signature_drag.rs @@ -186,8 +186,8 @@ impl SignatureDrag { { return; } - let (pointer, shift) = painter.ctx().input(|i| (i.pointer.hover_pos(), i.modifiers.shift)); - let rect = cx.adjusted_rect(a, cv.gesture.as_ref(), pointer, shift, pending); + let shift = painter.ctx().input(|i| i.modifiers.shift); + let rect = cx.adjusted_rect(a, cv.gesture.as_ref(), cx.ptr.pos, shift, pending); let painter = painter.with_clip_rect(painter.clip_rect().intersect(cx.xf.rect)); cx.xf.paint_image(&painter, background.id(), 0.0, 0.0, 1.0, 1.0); match &layers.front { diff --git a/crates/ui-egui/src/system_fonts.rs b/crates/ui-egui/src/system_fonts.rs index 383dc1e7a9..971683887a 100644 --- a/crates/ui-egui/src/system_fonts.rs +++ b/crates/ui-egui/src/system_fonts.rs @@ -503,6 +503,8 @@ mod tests { std::env::var_os("PDFCRAFT_SYSTEM_FONTS").is_some_and(|v| v == "0") } + /// Check that the first readable fallback candidate wins, so reversing the preference list reverses the + /// chosen face. #[test] fn the_first_readable_candidate_wins_not_the_last() { // List order *is* the preference, so a loader that scanned back-to-front would pick a @@ -517,9 +519,9 @@ mod tests { let size = |p: &Path| std::fs::metadata(p).expect("bundled face").len(); assert_ne!(size(&inter), size(&mono), "the two fixtures must be tellable apart"); let first = script_fallback(&[inter.clone(), mono.clone()], 'A').expect("Inter covers 'A'"); - assert_eq!(first.font.len() as u64, size(&inter), "the first readable candidate must win"); + assert_eq!(first.bytes().len() as u64, size(&inter), "the first readable candidate must win"); let reversed = script_fallback(&[mono.clone(), inter], 'A').expect("JetBrains Mono covers 'A'"); - assert_eq!(reversed.font.len() as u64, size(&mono), "reversing the list must reverse the pick"); + assert_eq!(reversed.bytes().len() as u64, size(&mono), "reversing the list must reverse the pick"); } #[test] @@ -663,7 +665,7 @@ mod tests { // them, as this list produces. let direct = script_fallback(&devanagari_candidates(), DEVANAGARI_PROBE).expect("the list above resolves"); let cached = devanagari_fallback().expect("devanagari_fallback() resolves where these faces are installed"); - assert_eq!(cached.font.len(), direct.font.len(), "devanagari_fallback() must load the face this list points at, not another script's"); + assert_eq!(cached.bytes().len(), direct.bytes().len(), "devanagari_fallback() must load the face this list points at, not another script's"); assert_eq!(cached.index, direct.index, "and the same face within that file"); // The advertised last resort is the biggest file on the list and reaches its target through // a compatibility symlink in `/Library/Fonts`, a folder installers and users may modify — so diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 442cd6d8e1..d9ba466b42 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -3,7 +3,7 @@ use std::sync::Arc; -use egui::epaint::text::{FontTweak, HintingTarget, SmoothHinting}; +use egui::epaint::text::{FallbackAlign, FontTweak, HintingTarget, SmoothHinting}; use egui::{Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, Rect, Response, Stroke, Vec2, Visuals}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] @@ -122,8 +122,9 @@ impl Tokens { } } + /// Return the cached theme tokens, falling back to the light palette before the first apply. pub fn get(ctx: &egui::Context) -> Self { - ctx.data(|d| d.get_temp::(egui::Id::new("pdfcraft-theme"))).unwrap_or_else(|| Self::for_kind(ThemeKind::Light)) + ctx.data(|d| d.get_temp::(egui::Id::unique("pdfcraft-theme"))).unwrap_or_else(|| Self::for_kind(ThemeKind::Light)) } pub fn dark(&self) -> bool { @@ -230,14 +231,34 @@ pub const SYSTEM_HEBREW_FALLBACK: &str = "system-hebrew-fallback"; /// The name of the installed Hangul (Korean) face [`installed_font_definitions`] may add. pub const SYSTEM_HANGUL_FALLBACK: &str = "system-hangul-fallback"; +/// Which installed face an interface language needs, given whether the build embeds a `Hans` +/// face. +/// +/// A bundled `Hans` face serves Simplified Chinese, so the installed face stays last there. It +/// cannot serve Traditional Chinese: a Traditional interface keeps an installed `Hant` face +/// before the Japanese faces even when a Simplified-only face is embedded, or Traditional-only +/// characters would fall through to a Japanese face first. +#[cfg(not(target_arch = "wasm32"))] +fn installed_fallback_need(cjk: CjkPreference, has_embedded_hans: bool) -> crate::system_fonts::Need { + use crate::system_fonts::Need; + match cjk { + CjkPreference::SimplifiedChinese if has_embedded_hans => Need::Other, + CjkPreference::SimplifiedChinese => Need::Hans, + CjkPreference::TraditionalChinese => Need::Hant, + CjkPreference::Default => Need::Other, + } +} + /// What [`install_fonts_for`] installs: [`font_definitions_for`], then, on desktop, one face /// already installed on this machine. It only draws characters no embedded face has. /// -/// - In a Chinese interface built without a `Hans` craft-fonts face (every release so far, -/// #826), it is a Chinese system face (Microsoft YaHei, PingFang, …), placed before the -/// Japanese craft-fonts faces: they lack hundreds of the catalog's characters (欢, 导, 签, …), -/// and one line drawn from two faces with different vertical metrics would not sit on one -/// baseline. +/// - In a Simplified Chinese interface built without an embedded `Hans` face (#826), it is a +/// Simplified Chinese system face (Microsoft YaHei, PingFang SC, …), placed before the Japanese +/// craft-fonts faces: they lack hundreds of the catalog's characters (欢, 导, 签, …), and one +/// line drawn from two faces with different vertical metrics would not sit on one baseline. +/// - A Traditional Chinese interface always uses an installed `Hant` face before the Japanese +/// faces, even when a Simplified-only `Hans` face is embedded: that face cannot supply +/// Traditional-only characters (說, 體, …). /// - Otherwise it is the last fallback of every family (an Arabic file name in a build without /// craft-fonts). /// @@ -268,12 +289,7 @@ pub fn installed_font_definitions(cjk: CjkPreference) -> FontDefinitions { stack.push(name.to_owned()); } } - let need = match cjk { - _ if !pdfcraft_fonts::ui_chinese_fonts().is_empty() => Need::Other, - CjkPreference::SimplifiedChinese => Need::Hans, - CjkPreference::TraditionalChinese => Need::Hant, - CjkPreference::Default => Need::Other, - }; + let need = installed_fallback_need(cjk, !pdfcraft_fonts::ui_chinese_fonts().is_empty()); if let Some(data) = fallback(need) { fonts.font_data.insert(SYSTEM_FALLBACK.to_owned(), data); let japanese: Vec = pdfcraft_fonts::ui_japanese_fonts().iter().map(|f| f.name()).collect(); @@ -323,37 +339,14 @@ fn primary_font_tweak(platform: FontPolicyPlatform, name: &str) -> FontTweak { tweak } -/// A face's vertical metrics in em, as egui reads them: skrifa's ascent, descent (negative) and -/// line gap at the default location. `None` when the face doesn't parse. -fn vertical_metrics(bytes: &[u8], index: u32) -> Option<[f32; 3]> { - use skrifa::MetadataProvider as _; - let font = skrifa::FontRef::from_index(bytes, index).ok()?; - let m = font.metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()); - let em = f32::from(m.units_per_em); - let metrics = [m.ascent / em, m.descent / em, m.leading / em]; - (em > 0.0 && metrics.iter().all(|v| v.is_finite())).then_some(metrics) -} - -/// How far below the primary face's baseline egui draws a fallback face's, in em of the font size -/// (negative: above). egui centres the fallback's line box (ascent − descent + gap) on the -/// primary's rather than lining up their baselines, so the baseline moves by half the difference -/// of ascent + descent, less half the difference of the line gaps (epaint `text_layout`). -fn baseline_drop(primary: [f32; 3], fallback: [f32; 3]) -> f32 { - let [pa, pd, pg] = primary; - let [a, d, g] = fallback; - ((a + d) - (pa + pd) + pg - g) / 2.0 -} - -/// `data` with its glyphs moved onto Inter's baseline (#1676). Without this, Chinese next to Latin -/// in one label sits higher or lower than the Latin by a few percent of the font size, depending on -/// the installed Chinese face (PingFang, Microsoft JhengHei, SimSun…). Inter is the first face of -/// every proportional family; the monospace family's JetBrains Mono differs from it by 0.004 em, -/// well under a pixel. A face that doesn't parse is left as it is (egui will skip it anyway). +/// `data` with its glyphs placed on the row's baseline (#1676). epaint 0.37 otherwise centres a +/// fallback face's line box on the primary's, so Chinese next to Latin in one label sits higher or +/// lower than the Latin by a few percent of the font size, depending on the installed or embedded +/// face (PingFang, Microsoft JhengHei, LXGW WenKai Lite…). `FallbackAlign::Baseline` makes epaint +/// position every face's glyphs from the primary face's ascent, which is exactly the existing +/// baseline-alignment intent without metric-derived offsets that go stale across egui versions. pub(crate) fn on_interface_baseline(mut data: FontData) -> FontData { - let primary = vertical_metrics(pdfcraft_fonts::INTER_REGULAR, 0); - if let (Some(primary), Some(fallback)) = (primary, vertical_metrics(&data.font, data.index)) { - data.tweak.y_offset_factor = -baseline_drop(primary, fallback); - } + data.tweak.fallback_align = FallbackAlign::Baseline; data } @@ -429,13 +422,15 @@ pub fn semibold(size: f32) -> FontId { FontId::new(size, FontFamily::Name("semibold".into())) } +/// Install the palette, widget styling, text sizes and right-to-left flag for the chosen theme. pub fn apply(ctx: &egui::Context, kind: ThemeKind) { // Keep the native window inherited. A concrete egui theme pins macOS's // NSWindow appearance and prevents winit from delivering ThemeChanged. // Our saved choice owns the palette; both egui branches receive it below. ctx.set_theme(egui::ThemePreference::System); + let rtl = crate::i18n::current().rtl(); let t = Tokens::for_kind(kind); - ctx.data_mut(|d| d.insert_temp(egui::Id::new("pdfcraft-theme"), t)); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique("pdfcraft-theme"), t)); let mut v = if t.dark() { Visuals::dark() } else { Visuals::light() }; v.panel_fill = t.panel; v.window_fill = t.card; @@ -477,6 +472,9 @@ pub fn apply(ctx: &egui::Context, kind: ThemeKind) { } ctx.set_visuals(v); ctx.global_style_mut(|s| { + // RTL languages mirror stock chrome (menus, combo boxes, scroll bars, headers). + // Labels still get their paragraph direction explicitly from `bidi::ui_direction`. + s.right_to_left = rtl; s.spacing.item_spacing = egui::vec2(8.0, 6.0); s.spacing.button_padding = egui::vec2(10.0, 5.0); s.spacing.menu_margin = egui::Margin::same(6); @@ -493,6 +491,30 @@ pub fn apply(ctx: &egui::Context, kind: ThemeKind) { let style = ctx.global_style(); ctx.set_style_of(egui::Theme::Light, style.clone()); ctx.set_style_of(egui::Theme::Dark, style); + ctx.data_mut(|d| d.insert_temp(direction_key(), rtl)); +} + +/// Return the stable egui id holding the current right-to-left style flag. +fn direction_key() -> egui::Id { + egui::Id::unique("pdfcraft-style-right-to-left") +} + +/// Keep stock egui chrome's direction in step with the drawing language, after a language +/// change as well as at startup. Setting a flag on both concrete themes is cheap; this avoids +/// rebuilding the whole visuals and font state every frame. +pub fn sync_direction(ctx: &egui::Context) { + let rtl = crate::i18n::current().rtl(); + let changed = ctx.data_mut(|d| { + if d.get_temp::(direction_key()) == Some(rtl) { + false + } else { + d.insert_temp(direction_key(), rtl); + true + } + }); + if changed { + ctx.all_styles_mut(|s| s.right_to_left = rtl); + } } /// WCAG 2 contrast ratio between two opaque colours. @@ -520,6 +542,21 @@ pub fn readable(color: Color32, background: Color32, text: Color32) -> Color32 { mod tests { use super::*; + /// Arabic and Kurdish switch stock chrome to mirror from the right; English switches it + /// back. This is the `Style::right_to_left` half of the explicit RTL policy. + #[test] + fn arabic_chrome_is_right_to_left_after_the_language_changes() { + let previous = crate::i18n::current(); + let ctx = egui::Context::default(); + crate::i18n::set_current(crate::i18n::Lang::from_code("ar").expect("registered language")); + apply(&ctx, ThemeKind::Light); + assert!(ctx.global_style().right_to_left); + crate::i18n::set_current(crate::i18n::Lang::EN); + sync_direction(&ctx); + assert!(!ctx.global_style().right_to_left); + crate::i18n::set_current(previous); + } + /// #3193: a bookmark's own colour is kept where it reads on the panel, and moved towards the /// theme's text colour where it doesn't (dark blue on the dark panel), never past it. #[test] @@ -641,18 +678,16 @@ mod tests { ) } - /// The baseline of each glyph of `text` laid out by egui at `size` points, in points from the - /// top of the row: its position plus its face's `y_offset_factor`, as epaint draws it. + /// The y position of each glyph of `text` laid out by egui at `size` points, in points from + /// the top of the row. fn baselines(fallback: FontData, size: f32, text: &str) -> Vec { - let factor = fallback.tweak.y_offset_factor; let mut defs = FontDefinitions::empty(); defs.font_data.insert("Inter".into(), Arc::new(FontData::from_static(pdfcraft_fonts::INTER_REGULAR))); defs.font_data.insert("fallback".into(), Arc::new(fallback)); defs.families.insert(FontFamily::Proportional, vec!["Inter".into(), "fallback".into()]); let mut fonts = Fonts::new(TextOptions::default(), defs); let g = galley(&mut fonts, FontFamily::Proportional, size, 1.0, text); - let inter_has = |c: char| c.is_ascii(); - g.rows[0].glyphs.iter().map(|gl| gl.pos.y + if inter_has(gl.chr) { 0.0 } else { size * factor }).collect() + g.rows[0].glyphs.iter().map(|gl| gl.pos.y).collect() } #[test] @@ -664,14 +699,23 @@ mod tests { // As egui lays it out by itself, DŽ sits ~4 pt off H's baseline: the bug (#1676). let raw = baselines(FontData::from_static(dancing), size, "HDŽ"); assert!((raw[1] - raw[0]).abs() > 3.0, "the test fonts must differ: {raw:?}"); - let drop = baseline_drop(vertical_metrics(pdfcraft_fonts::INTER_REGULAR, 0).unwrap(), vertical_metrics(dancing, 0).unwrap()); - assert!((raw[1] - raw[0] - size * drop).abs() <= 1.0, "baseline_drop predicts egui: {raw:?} vs {drop}"); - // On the interface baseline, to the pixel. + // `FallbackAlign::Baseline` puts every face on the same baseline, to the pixel. let aligned = baselines(on_interface_baseline(FontData::from_static(dancing)), size, "HDŽ"); assert!((aligned[1] - aligned[0]).abs() <= 1.0, "{aligned:?}"); - // A face that doesn't parse is left alone rather than guessed at. - assert_eq!(on_interface_baseline(FontData::from_static(b"not a font")).tweak, FontTweak::default()); - assert_eq!(vertical_metrics(b"", 0), None); + assert_eq!(on_interface_baseline(FontData::from_static(dancing)).tweak.fallback_align, FallbackAlign::Baseline); + } + + /// The policy only exists on native platforms (`installed_font_definitions` has no installed + /// faces on wasm), so the test must not use it in a wasm test build. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn traditional_chinese_keeps_an_installed_face_when_hans_is_embedded() { + use crate::system_fonts::Need; + assert_eq!(installed_fallback_need(CjkPreference::SimplifiedChinese, true), Need::Other); + assert_eq!(installed_fallback_need(CjkPreference::SimplifiedChinese, false), Need::Hans); + assert_eq!(installed_fallback_need(CjkPreference::TraditionalChinese, true), Need::Hant); + assert_eq!(installed_fallback_need(CjkPreference::TraditionalChinese, false), Need::Hant); + assert_eq!(installed_fallback_need(CjkPreference::Default, true), Need::Other); } #[test] diff --git a/crates/ui-egui/src/updates.rs b/crates/ui-egui/src/updates.rs index 4e7957e15e..fa59f9a89f 100644 --- a/crates/ui-egui/src/updates.rs +++ b/crates/ui-egui/src/updates.rs @@ -1,12 +1,8 @@ -//! Help ▸ Check for updates (issue #28): ask for the latest release and offer its download page. -//! -//! The desktop app supplies how to ask ([`PdfCraftApp::update_source`]), so this crate has no -//! network code; without a source (the web build, tests) the command opens the releases page. -//! PdfCraft never downloads or installs anything itself: the user downloads the new version. -//! It asks only when the user does: there is no check at start (the owner's decision). - -use std::sync::Arc; +//! Help ▸ Check for updates and the check at start, through `craft_update`: the flow itself is its +//! `Updater`; this module supplies the suite, the quit path and the status dialog. +#[cfg(not(target_arch = "wasm32"))] +use craft_update::updater::{Action, CheckSource, Status, Updater}; use egui::{Align, Layout}; use crate::{PdfCraftApp, theme, widgets}; @@ -14,108 +10,102 @@ use crate::{PdfCraftApp, theme, widgets}; /// Where every PdfCraft release is listed. pub const RELEASES_PAGE: &str = "https://github.com/storytold/pdfcraft/releases"; -/// The latest published release. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Release { - /// Its version tag, such as `v0.2.0`. - pub version: String, - /// Its page on [`RELEASES_PAGE`], where the downloads are. - pub url: String, -} +/// Stands in for the network in tests. +#[cfg(not(target_arch = "wasm32"))] +pub type UpdateSource = CheckSource; -/// Asks for the latest release (blocking; it runs on its own thread). -pub type UpdateSource = Arc Result + Send + Sync>; - -/// Whether release `latest` (a tag such as `v0.2.0`) is newer than version `current` (`0.1.1`). -/// Pre-release and build suffixes are ignored; a version that doesn't parse is never newer. -pub fn is_newer(latest: &str, current: &str) -> bool { - matches!((parse(latest), parse(current)), (Some(l), Some(c)) if l > c) -} - -fn parse(v: &str) -> Option<(u64, u64, u64)> { - let v = v.trim().trim_start_matches(['v', 'V']); - let core = v.split(['-', '+']).next()?; - let mut parts = core.split('.'); - let mut next = |required: bool| match parts.next() { - Some(p) => p.parse::().ok(), - None if required => None, - None => Some(0), - }; - let version = (next(true)?, next(false)?, next(false)?); - parts.next().is_none().then_some(version) -} - -/// Where a check is. -#[derive(Default)] -pub(crate) enum Check { - #[default] - Idle, - #[cfg(not(target_arch = "wasm32"))] - Running(std::sync::mpsc::Receiver>), - Done(Result), -} - -#[derive(Default)] pub(crate) struct Updates { - pub(crate) check: Check, + #[cfg(not(target_arch = "wasm32"))] + updater: Updater, /// The Updates dialog is showing. pub(crate) open: bool, + /// Ask for a newer release at start; the desktop app turns it on, so tests never reach the network. + #[cfg(not(target_arch = "wasm32"))] + startup_check: bool, + #[cfg(not(target_arch = "wasm32"))] + check_requested: bool, +} + +impl Default for Updates { + fn default() -> Self { + Self { + #[cfg(not(target_arch = "wasm32"))] + updater: Updater::new(&craft_update::suite::PDFCRAFT, env!("CARGO_PKG_VERSION")), + open: false, + #[cfg(not(target_arch = "wasm32"))] + startup_check: false, + #[cfg(not(target_arch = "wasm32"))] + check_requested: false, + } + } } impl PdfCraftApp { + /// Whether the app asks for a newer release on its first frame (off until the desktop app turns it on). + #[cfg(not(target_arch = "wasm32"))] + pub fn set_startup_update_check(&mut self, on: bool) { + self.updates.startup_check = on; + } + + /// Replaces the network with `source` (tests). + #[cfg(not(target_arch = "wasm32"))] + pub fn set_update_source(&mut self, source: UpdateSource) { + self.updates.updater = Updater::new(&craft_update::suite::PDFCRAFT, env!("CARGO_PKG_VERSION")).with_source(source); + } + /// Help ▸ Check for updates: ask for the latest release and show the outcome. pub fn check_for_updates(&mut self) { - let Some(source) = self.update_source.clone() else { - self.open_url(RELEASES_PAGE); - return; - }; #[cfg(not(target_arch = "wasm32"))] { self.updates.open = true; - if matches!(self.updates.check, Check::Running(_)) { - return; - } - let (tx, rx) = std::sync::mpsc::channel(); - let ctx = self.ctx.clone(); - std::thread::spawn(move || { - // The receiver may be gone (the app quit): nothing to report to then. - let _ = tx.send(source()); - if let Some(ctx) = ctx { - ctx.request_repaint(); - } - }); - self.updates.check = Check::Running(rx); + self.updates.check_requested = true; } #[cfg(target_arch = "wasm32")] { - let _ = source; self.open_url(RELEASES_PAGE); } } - /// Pick up a finished check (each frame). + /// Starts the checks and picks up their answers (each frame). pub(crate) fn poll_updates(&mut self) { #[cfg(not(target_arch = "wasm32"))] - if let Check::Running(rx) = &self.updates.check { - let result = match rx.try_recv() { - Ok(r) => r, - Err(std::sync::mpsc::TryRecvError::Empty) => return, - Err(std::sync::mpsc::TryRecvError::Disconnected) => Err("the update check stopped unexpectedly".into()), - }; - self.updates.check = Check::Done(result); + { + let Some(ctx) = self.ctx.clone() else { return }; + let updates = &mut self.updates; + if std::mem::take(&mut updates.check_requested) { + updates.updater.check_now(&ctx); + } else if updates.startup_check { + updates.updater.check_at_start(&ctx); + } + updates.updater.tick(&ctx); } } } -/// The Updates dialog. +/// The startup prompt, and the Updates dialog that answers Help ▸ Check for updates. pub(crate) fn dialog(app: &mut PdfCraftApp, ctx: &egui::Context) { + #[cfg(not(target_arch = "wasm32"))] + { + // The restart helper is scheduled before the app closes, so no update starts over unsaved work. + let clean = app.first_dirty().is_none(); + match app.updates.updater.show_if(ctx, clean) { + Action::None => {} + Action::OpenUrl(url) => app.open_url(&url), + Action::Close => { + app.request_quit(ctx); + } + } + // A requested check that found a release is answered by the prompt, not by this dialog. + if app.updates.open && !app.updates.check_requested && matches!(app.updates.updater.status(), Status::Idle) { + app.updates.open = false; + } + } if !app.updates.open { return; } let t = theme::Tokens::get(ctx); let current = env!("CARGO_PKG_VERSION"); let mut close = false; - let mut download: Option = None; let modal = crate::widgets::show_modal(ctx, "updates", |ui| { ui.set_width(420.0); ui.horizontal(|ui| { @@ -123,34 +113,25 @@ pub(crate) fn dialog(app: &mut PdfCraftApp, ctx: &egui::Context) { ui.label(egui::RichText::new(tl!("Check for updates")).font(theme::semibold(16.0))); }); ui.add_space(8.0); - match &app.updates.check { - Check::Idle => { - ui.label(tl!("No check has run yet.")); - } - #[cfg(not(target_arch = "wasm32"))] - Check::Running(_) => { + #[cfg(not(target_arch = "wasm32"))] + match app.updates.updater.status() { + Status::Idle => { ui.horizontal(|ui| { ui.spinner(); ui.label(tl!("Checking for a newer version…")); }); } - Check::Done(Ok(r)) if is_newer(&r.version, current) => { - let version = r.version.trim_start_matches(['v', 'V']); - ui.label(egui::RichText::new(crate::i18n::fmt(tl!("PdfCraft {v} is available."), &[("v", version)])).strong()); - ui.label( - egui::RichText::new(crate::i18n::fmt( - tl!("You have version {c}. Download the new version from its release page."), - &[("c", current)], - )) - .color(t.text_muted), - ); - download = Some(r.url.clone()); + Status::Checking => { + ui.horizontal(|ui| { + ui.spinner(); + ui.label(tl!("Checking for a newer version…")); + }); } - Check::Done(Ok(_)) => { + Status::UpToDate => { ui.label(crate::i18n::fmt(tl!("PdfCraft {c} is up to date."), &[("c", current)])); } - Check::Done(Err(e)) => { - ui.label(crate::i18n::fmt(tl!("Couldn't check for updates: {e}"), &[("e", &e.to_string())])); + Status::Failed(e) => { + ui.label(crate::i18n::fmt(tl!("Couldn't check for updates: {e}"), &[("e", e)])); ui.label( egui::RichText::new(crate::i18n::fmt( tl!("You have version {c}. All releases are listed at {page}."), @@ -160,32 +141,21 @@ pub(crate) fn dialog(app: &mut PdfCraftApp, ctx: &egui::Context) { ); } } + #[cfg(target_arch = "wasm32")] + let _ = (&t, current); ui.add_space(10.0); - ui.label( - egui::RichText::new(tl!("Asks GitHub for the latest release. Nothing is downloaded or installed automatically.")) - .color(t.text_muted) - .small(), - ); - ui.add_space(12.0); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if let Some(url) = download.take() { - let get = widgets::pill_button(ui, tl!("Download"), true).clicked(); - let later = widgets::pill_button(ui, tl!("Later"), false).clicked(); - close = get || later; - download = get.then_some(url); - } else if widgets::pill_button(ui, tl!("Close"), true).clicked() { + if widgets::pill_button(ui, tl!("Close"), true).clicked() { close = true; } }); }); if modal.should_close() { close = true; - download = None; } if close { app.updates.open = false; - if let Some(url) = download { - app.open_url(&url); - } + #[cfg(not(target_arch = "wasm32"))] + app.updates.updater.dismiss_status(); } } diff --git a/crates/ui-egui/src/wheel_boost.rs b/crates/ui-egui/src/wheel_boost.rs index b9d474565e..10ccbbbaa7 100644 --- a/crates/ui-egui/src/wheel_boost.rs +++ b/crates/ui-egui/src/wheel_boost.rs @@ -313,11 +313,23 @@ mod tests { } fn wheel(unit: egui::MouseWheelUnit, dx: f32, dy: f32) -> egui::Event { - egui::Event::MouseWheel { unit, delta: egui::vec2(dx, dy), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE } + egui::Event::MouseWheel { + unit, + delta: egui::vec2(dx, dy), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + } } fn shifted(unit: egui::MouseWheelUnit, dx: f32, dy: f32) -> egui::Event { - egui::Event::MouseWheel { unit, delta: egui::vec2(dx, dy), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::SHIFT } + egui::Event::MouseWheel { + unit, + delta: egui::vec2(dx, dy), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::SHIFT, + source: egui::MouseWheelSource::Unknown, + } } #[test] @@ -379,6 +391,7 @@ mod tests { assert_eq!(input.page, egui::vec2(-1.0, 0.0)); } + /// Check that NaN, zero, negative or infinite scales and non-wheel events add nothing. #[test] fn invalid_scales_and_events_contribute_nothing() { use egui::MouseWheelUnit::Line; @@ -386,8 +399,13 @@ mod tests { let input = accumulate(&[wheel(Line, 0.0, -1.0)], bad, false); assert_eq!(input.line, egui::Vec2::ZERO, "line scale {bad}"); } - let zoom = - egui::Event::MouseWheel { unit: Line, delta: egui::vec2(0.0, -1.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::COMMAND }; + let zoom = egui::Event::MouseWheel { + unit: Line, + delta: egui::vec2(0.0, -1.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::COMMAND, + source: egui::MouseWheelSource::Unknown, + }; let key = egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; let input = accumulate(&[zoom, key, wheel(Line, 0.0, f32::NAN)], 100.0, false); assert_eq!(input, FrameInput::default()); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 85af966806..23513cc973 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -7,6 +7,8 @@ use crate::{PdfCraftApp, icons}; /// Horizontal and vertical inset of a single-line field. const FIELD_MARGIN: egui::Margin = egui::Margin::symmetric(10, 6); +/// Where text starts inside a [`line`] or [`block`] field, so plain values beside fields can line up with it. +pub(crate) const FIELD_TEXT_INSET: f32 = FIELD_MARGIN.left as f32; /// Outer height of a single-line field, including its inset. const FIELD_MIN_HEIGHT: f32 = 30.0; /// Padding inside every modal. @@ -25,12 +27,12 @@ fn dialog_frame(ctx: &egui::Context) -> egui::Frame { /// A modal with the card fill, a 10-point corner and room around its contents. pub fn modal(ctx: &egui::Context, id: impl std::hash::Hash + std::fmt::Debug) -> egui::Modal { - egui::Modal::new(egui::Id::new(id)).frame(dialog_frame(ctx)) + egui::Modal::new(egui::Id::unique(id)).frame(dialog_frame(ctx)) } /// Show [`modal`], outline its controls, and remember the card rect for layout checks. pub fn show_modal(ctx: &egui::Context, id: &str, content: impl FnOnce(&mut egui::Ui) -> R) -> egui::ModalResponse { - let stored = egui::Id::new(id); + let stored = egui::Id::unique(id); modal(ctx, id).show(ctx, |ui| { begin_dialog(ui); let inner = content(ui); @@ -109,7 +111,7 @@ pub fn mode_tab(ui: &mut egui::Ui, label: &str, active: bool) -> Response { let font = if active { theme::semibold(13.5) } else { theme::medium(13.5) }; let w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_owned(), font.clone(), t.text).size().x); let (rect, resp) = ui.allocate_exact_size(vec2(w + 22.0, 48.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), active, label)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), active, label)); let press = theme::Press::track(ui, &resp); let well = rect.shrink2(vec2(2.0, 9.0)); if active { @@ -138,21 +140,26 @@ pub fn pill_button(ui: &mut egui::Ui, label: &str, primary: bool) -> Response { pub fn pill_button_marked(ui: &mut egui::Ui, label: &str, primary: bool, marked: bool) -> Response { let t = Tokens::get(ui.ctx()); let font = theme::medium(12.5); - let w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_owned(), font.clone(), t.text).size().x); + let text_color = if primary { Color32::WHITE } else { t.text }; + // Dialog buttons are translated UI text: an explicit RTL base keeps an Arabic label right + // to left even when its first strong character is Latin or a placeholder. + let label_galley = crate::bidi::layout_ui(ui, label.to_owned(), font, text_color); + let w = label_galley.size().x; let (rect, resp) = ui.allocate_exact_size(vec2(w + 26.0, 28.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), label)); let press = theme::Press::track(ui, &resp); - let (fill, stroke, text) = if primary { - (press.fill(t.accent, t.accent_text, t.accent.lerp_to_gamma(Color32::BLACK, 0.22)), Stroke::NONE, Color32::WHITE) + let (fill, stroke) = if primary { + (press.fill(t.accent, t.accent_text, t.accent.lerp_to_gamma(Color32::BLACK, 0.22)), Stroke::NONE) } else { - (press.fill(t.card, t.hover, t.pressed), Stroke::new(1.0, t.border), t.text) + (press.fill(t.card, t.hover, t.pressed), Stroke::new(1.0, t.border)) }; ui.painter().rect(rect, CornerRadius::same(14), fill, stroke, egui::StrokeKind::Inside); if marked { resp.request_focus(); ui.painter().rect_stroke(rect.expand(2.0), CornerRadius::same(16), Stroke::new(2.0, t.accent), egui::StrokeKind::Outside); } - ui.painter().text(rect.translate(press.offset()).center(), Align2::CENTER_CENTER, label, font, text); + let body = rect.translate(press.offset()); + ui.painter().galley(body.center() - label_galley.size() / 2.0, label_galley, text_color); theme::hand(resp) } @@ -165,14 +172,15 @@ pub fn ghost_button(ui: &mut egui::Ui, icon: &str, label: &str) -> Response { pub fn ghost_button_sized(ui: &mut egui::Ui, icon: &str, label: &str, height: f32) -> Response { let t = Tokens::get(ui.ctx()); let font = theme::medium(13.0); - let w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_owned(), font.clone(), t.text).size().x); + let label_galley = crate::bidi::layout_ui(ui, label.to_owned(), font, t.text); + let w = label_galley.size().x; let (rect, resp) = ui.allocate_exact_size(vec2(w + 38.0, height), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), label)); let press = theme::Press::track(ui, &resp); press.wash(ui, rect, 6, false); let body = rect.translate(press.offset()); icons::paint(ui, Rect::from_min_size(body.min + vec2(6.0, (height - 18.0) / 2.0), vec2(18.0, 18.0)), icon, 17.0, t.icon); - ui.painter().text(body.left_center() + vec2(30.0, 0.0), Align2::LEFT_CENTER, label, font, t.text); + ui.painter().galley(body.left_center() + vec2(30.0, -label_galley.size().y / 2.0), label_galley, t.text); theme::hand(resp) } @@ -180,7 +188,7 @@ pub fn ghost_button_sized(ui: &mut egui::Ui, icon: &str, label: &str, height: f3 pub fn search_box(ui: &mut egui::Ui, placeholder: &str, width: f32) -> Response { let t = Tokens::get(ui.ctx()); let (rect, resp) = ui.allocate_exact_size(vec2(width, 32.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), placeholder)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), placeholder)); let press = theme::Press::track(ui, &resp); let fill = press.fill(t.field, t.hover, t.pressed); let edge = t.border.lerp_to_gamma(t.text_muted, press.hover * 0.35); @@ -195,6 +203,7 @@ pub fn search_box(ui: &mut egui::Ui, placeholder: &str, width: f32) -> Response let text_left = (rect.left() + 34.0).min(text_right); let text_rect = Rect::from_min_max(egui::pos2(text_left, rect.top()), egui::pos2(text_right, rect.bottom())); let mut job = egui::text::LayoutJob::simple_singleline(placeholder.to_owned(), theme::regular(13.0), t.text_faint); + job.direction = crate::bidi::ui_direction(); job.wrap = egui::text::TextWrapping { max_width: text_rect.width(), max_rows: 1, break_anywhere: true, ..Default::default() }; let text = ui.fonts_mut(|f| f.layout_job(job)); let elided = text.elided; @@ -204,10 +213,6 @@ pub fn search_box(ui: &mut egui::Ui, placeholder: &str, width: f32) -> Response if elided { resp.on_hover_text(placeholder) } else { resp } } -pub fn menu_item(ui: &mut egui::Ui, label: &str, shortcut: &str) -> Response { - ui.add(egui::Button::new(label).shortcut_text(shortcut)) -} - /// Corner radius of a checkbox's box. With the widgets' radius (`Tokens::radius`, 6) egui's 14 px /// box is a circle, the shape of a radio button, and View ▸ Page display has both. const CHECKBOX_RADIUS: u8 = 3; @@ -278,11 +283,20 @@ fn toggle<'a>(widget: impl egui::Widget + 'a, radius: Option) -> impl egui:: } } +/// Draw a panel section heading with explicit paragraph direction and letter spacing. pub fn section_title(ui: &mut egui::Ui, text: &str) { let t = Tokens::get(ui.ctx()); ui.add_space(10.0); // Section titles across every panel go through here, so one translation point covers them. - ui.label(egui::RichText::new(crate::i18n::upper_label(text)).font(theme::semibold(10.5)).color(t.text_faint).extra_letter_spacing(0.6)); + // The paragraph direction is explicit: an Arabic title beginning with Latin or a placeholder + // must not inherit a left-to-right base from its first strong character. + let mut job = egui::text::LayoutJob::simple(crate::i18n::upper_label(text), theme::semibold(10.5), t.text_faint, ui.available_width().max(0.0)); + job.direction = crate::bidi::ui_direction(); + if let Some(section) = job.sections.first_mut() { + section.format.extra_letter_spacing = 0.6; + } + let galley = ui.painter().layout_job(job); + ui.add(egui::Label::new(egui::WidgetText::Galley(galley)).selectable(false)); ui.add_space(2.0); } @@ -290,12 +304,35 @@ pub fn section_title(ui: &mut egui::Ui, text: &str) { /// at most `max_width` wide: a long file name or path stays inside its row (#425). pub fn row_text(ui: &egui::Ui, pos: egui::Pos2, text: impl Into, font: egui::FontId, color: Color32, max_width: f32) { let mut job = egui::text::LayoutJob::simple_singleline(text.into(), font, color); + // A file name or path: an Arabic name keeps its extension at the visual end, as in the tab. + job.direction = egui::epaint::text::TextDirection::LeftToRight; job.wrap = egui::text::TextWrapping::truncate_at_width(max_width.max(0.0)); let galley = ui.painter().layout_job(job); let rect = Align2::LEFT_CENTER.anchor_size(pos, galley.size()); ui.painter().galley(rect.min, galley, color); } +/// A wrapping label for a file name or folder path, laid out explicitly left to right so an +/// Arabic name keeps `.pdf` at the end. The accessibility name is the logical text. +pub fn label_ltr(ui: &mut egui::Ui, text: impl Into, font: egui::FontId, color: Color32) -> Response { + let mut job = egui::text::LayoutJob::simple(text.into(), font, color, ui.available_width().max(0.0)); + job.direction = egui::epaint::text::TextDirection::LeftToRight; + let galley = ui.painter().layout_job(job); + ui.add(egui::Label::new(egui::WidgetText::Galley(galley)).selectable(false)) +} + +/// [`label_ltr`] for one table row: the name is cut with an ellipsis instead of wrapping onto a +/// second row. The logical text remains the accessibility name. +pub fn label_ltr_truncated(ui: &mut egui::Ui, text: impl Into, font: egui::FontId, color: Color32) -> Response { + let text = text.into(); + let width = ui.available_width().max(0.0); + let mut job = egui::text::LayoutJob::simple_singleline(text, font, color); + job.direction = egui::epaint::text::TextDirection::LeftToRight; + job.wrap = egui::text::TextWrapping::truncate_at_width(width); + let galley = ui.painter().layout_job(job); + ui.add(egui::Label::new(egui::WidgetText::Galley(galley)).selectable(false)) +} + /// Text wrapped to the available width, for text people write (comments, replies). A paragraph /// in a right-to-left script (Persian, Arabic, Hebrew) reads right to left and sits against the /// right edge ([`crate::bidi::wrap`]); text without one is an ordinary wrapping label. @@ -310,11 +347,11 @@ pub fn wrapped_text(ui: &mut egui::Ui, text: &str, font: egui::FontId, color: Co } let width = ui.available_width().max(1.0); let lines = wrapped_lines(ui, text, &font, width, truncate); - let galleys: Vec<_> = lines.iter().map(|line| (line.rtl, ui.painter().layout_no_wrap(line.text.clone(), font.clone(), color))).collect(); + let galleys: Vec<_> = lines.iter().map(|line| (line.rtl, wrapped_line_galley(ui, line, &font, color))).collect(); let height = galleys.iter().map(|(_, g)| g.size().y).sum(); let (rect, resp) = ui.allocate_exact_size(vec2(width, height), Sense::hover()); // Painted text only: the accessibility name keeps the logical order. - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, text)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, text)); let mut y = rect.top(); for (rtl, galley) in galleys { let x = if rtl { rect.right() - galley.size().x } else { rect.left() }; @@ -325,6 +362,15 @@ pub fn wrapped_text(ui: &mut egui::Ui, text: &str, font: egui::FontId, color: Co resp } +/// One wrapped line laid out with its own paragraph direction. A line beginning with a Latin +/// word inside a right-to-left comment is still part of that paragraph, so its base direction +/// must stay right to left; `Auto` would follow the Latin word instead. +fn wrapped_line_galley(ui: &egui::Ui, line: &crate::bidi::Line, font: &egui::FontId, color: Color32) -> std::sync::Arc { + let mut job = egui::text::LayoutJob::simple_singleline(line.text.clone(), font.clone(), color); + job.direction = if line.rtl { egui::epaint::text::TextDirection::RightToLeft } else { egui::epaint::text::TextDirection::LeftToRight }; + ui.painter().layout_job(job) +} + const ELLIPSIS: &str = "\u{2026}"; /// Wrapped right-to-left text kept between frames. Wrapping measures many candidate lines, so @@ -349,7 +395,7 @@ fn wrapped_lines(ui: &egui::Ui, text: &str, font: &egui::FontId, width: f32, tru let mut hasher = std::collections::hash_map::DefaultHasher::new(); (text, font, width.to_bits(), ui.ctx().pixels_per_point().to_bits(), truncate).hash(&mut hasher); let key = hasher.finish(); - let cache_id = egui::Id::new("pdfcraft-rtl-wrap-cache"); + let cache_id = egui::Id::unique("pdfcraft-rtl-wrap-cache"); if let Some(hit) = ui.ctx().data_mut(|d| d.get_temp_mut_or_default::(cache_id).lines.get(&key).cloned()) { return hit; } @@ -366,12 +412,9 @@ fn wrapped_lines(ui: &egui::Ui, text: &str, font: &egui::FontId, width: f32, tru if truncate && (lines.len() > 1 || cut) { lines.truncate(1); if let Some(line) = lines.first_mut() { - // The ellipsis goes where the line's reading ends: the left of a right-to-left line. - if line.rtl { - line.text.insert_str(0, ELLIPSIS); - } else { - line.text.push_str(ELLIPSIS); - } + // The ellipsis goes where the line's reading ends, in logical text: epaint places it + // on the left for a right-to-left line and on the right for a left-to-right one. + line.text.push_str(ELLIPSIS); } } let lines: std::sync::Arc<[crate::bidi::Line]> = lines.into(); @@ -398,9 +441,9 @@ pub fn toast(app: &mut PdfCraftApp, ctx: &egui::Context) { let t = Tokens::get(ctx); let screen = ctx.content_rect(); // Above the progress notice while a job runs. - let card = if app.progress_notice.is_some() { ctx.data(|d| d.get_temp::(egui::Id::new(PROGRESS_HEIGHT))).unwrap_or(0.0) } else { 0.0 }; + let card = if app.progress_notice.is_some() { ctx.data(|d| d.get_temp::(egui::Id::unique(PROGRESS_HEIGHT))).unwrap_or(0.0) } else { 0.0 }; let lift = if card > 0.0 { 28.0 + card + 10.0 } else { 28.0 }; - egui::Area::new(egui::Id::new("toast")) + egui::Area::new(egui::Id::unique("toast")) .order(egui::Order::Tooltip) .pivot(Align2::CENTER_BOTTOM) .fixed_pos(screen.center_bottom() - vec2(0.0, lift)) @@ -453,10 +496,10 @@ pub fn progress_notice(app: &PdfCraftApp, ctx: &egui::Context) -> bool { }; let fraction = if p.fraction.is_finite() { p.fraction.clamp(0.0, 1.0) } else { 0.0 }; let animated = crate::motion::animated(ctx); - let shown = ctx.animate_value_with_time(egui::Id::new("progress-notice-bar"), fraction, if animated { 0.3 } else { 0.0 }); + let shown = ctx.animate_value_with_time(egui::Id::unique("progress-notice-bar"), fraction, if animated { 0.3 } else { 0.0 }); let now = ctx.input(|i| i.time); let mut cancel = false; - let area = egui::Area::new(egui::Id::new("progress-notice")) + let area = egui::Area::new(egui::Id::unique("progress-notice")) .order(egui::Order::Tooltip) .pivot(Align2::CENTER_BOTTOM) .fixed_pos(screen.center_bottom() - vec2(0.0, 28.0)) @@ -474,7 +517,7 @@ pub fn progress_notice(app: &PdfCraftApp, ctx: &egui::Context) -> bool { let (track, resp) = ui.allocate_exact_size(vec2(width, 6.0), Sense::hover()); // The label above names the job; the bar carries the percentage. resp.widget_info(|| { - let mut info = egui::WidgetInfo::new(egui::WidgetType::ProgressIndicator); + let mut info = egui::WidgetInfo::new(egui::Role::ProgressIndicator); info.value = Some(f64::from(fraction) * 100.0); info }); @@ -504,7 +547,7 @@ pub fn progress_notice(app: &PdfCraftApp, ctx: &egui::Context) -> bool { } }); }); - ctx.data_mut(|d| d.insert_temp(egui::Id::new(PROGRESS_HEIGHT), area.response.rect.height())); + ctx.data_mut(|d| d.insert_temp(egui::Id::unique(PROGRESS_HEIGHT), area.response.rect.height())); ctx.request_repaint_after(std::time::Duration::from_millis(33)); cancel } @@ -553,10 +596,10 @@ pub fn split_pill(ui: &mut egui::Ui, icon: &str, label: &str, more: &str) -> (Re let w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_owned(), font.clone(), t.text).size().x); let (rect, _) = ui.allocate_exact_size(vec2(w + 46.0 + 28.0, 30.0), Sense::hover()); let (main_rect, more_rect) = rect.split_left_right_at_x(rect.right() - 28.0); - let main = ui.interact(main_rect, ui.id().with(("split-pill", label)), Sense::click()); - let arrow = ui.interact(more_rect, ui.id().with(("split-pill-more", label)), Sense::click()); - main.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label)); - arrow.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), more)); + let main = ui.interact(main_rect, ui.scope_id().with(("split-pill", label)), Sense::click()); + let arrow = ui.interact(more_rect, ui.scope_id().with(("split-pill-more", label)), Sense::click()); + main.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), label)); + arrow.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), more)); let radius = CornerRadius::same(15); ui.painter().rect(rect, radius, t.card, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); let left = CornerRadius { nw: 15, sw: 15, ne: 0, se: 0 }; @@ -584,7 +627,7 @@ pub fn icon_pill(ui: &mut egui::Ui, icon: &str, label: &str, primary: bool) -> R let font = theme::medium(12.5); let w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_owned(), font.clone(), t.text).size().x); let (rect, resp) = ui.allocate_exact_size(vec2(w + 46.0, 30.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), label)); let press = theme::Press::track(ui, &resp); let (fill, stroke, text) = if primary { (press.fill(t.accent, t.accent_text, t.accent.lerp_to_gamma(Color32::BLACK, 0.22)), Stroke::NONE, Color32::WHITE) @@ -708,7 +751,7 @@ mod tests { ); output.drop_without_applying_deltas(); } - ctx.memory(|memory| memory.area_rect(egui::Id::new("toast")).unwrap()) + ctx.memory(|memory| memory.area_rect(egui::Id::unique("toast")).unwrap()) } #[test] @@ -759,7 +802,7 @@ mod tests { let ctx = egui::Context::default(); ctx.set_fonts(theme::font_definitions()); let cache_len = - |ctx: &egui::Context| ctx.data_mut(|d| d.get_temp_mut_or_default::(egui::Id::new("pdfcraft-rtl-wrap-cache")).lines.len()); + |ctx: &egui::Context| ctx.data_mut(|d| d.get_temp_mut_or_default::(egui::Id::unique("pdfcraft-rtl-wrap-cache")).lines.len()); let persian = "این یک نظر طولانی به زبان فارسی است که باید در چند خط شکسته شود"; let long = persian.repeat(2_000); let mut first = None; @@ -773,7 +816,7 @@ mod tests { first = Some(lines); let collapsed = wrapped_lines(ui, &long, &theme::regular(13.0), 120.0, true); assert_eq!(collapsed.len(), 1); - assert!(collapsed[0].text.starts_with(ELLIPSIS), "{:?}", collapsed[0].text); + assert!(collapsed[0].text.ends_with(ELLIPSIS), "{:?}", collapsed[0].text); }) .drop_without_applying_deltas(); } @@ -787,6 +830,61 @@ mod tests { assert!(cache_len(&ctx) <= WRAP_CACHE_MAX, "bounded: {}", cache_len(&ctx)); } + /// Comment lines reach epaint in logical order. A mixed Persian/Latin comment preordered by + /// `bidi::wrap` would be reordered a second time by egui 0.37; the old display-order text is + /// distinctly different, so this fails if the preordering comes back. + #[test] + fn rtl_comment_lines_stay_logical_for_epaint() { + let mixed = "این نظر PDF درباره فایل است"; + let ctx = egui::Context::default(); + ctx.set_fonts(theme::font_definitions()); + ctx.run_ui(egui::RawInput::default(), |ui| { + let lines = wrapped_lines(ui, mixed, &theme::regular(13.0), 1000.0, false); + assert_eq!(lines.len(), 1); + assert_eq!(lines[0].text, mixed, "epaint needs reading order, not display order"); + assert!(lines[0].rtl); + // Truncation appends the ellipsis logically; the line still starts with its first word. + let cut = wrapped_lines(ui, mixed, &theme::regular(13.0), 60.0, true); + assert_eq!(cut.len(), 1); + assert!(cut[0].text.starts_with("این"), "{:?}", cut[0].text); + assert!(cut[0].text.ends_with(ELLIPSIS), "{:?}", cut[0].text); + // The galley keeps logical text, so inspect positioned glyphs to prove that the + // visible ellipsis is at the left-hand reading end after epaint's bidi layout. + let galley = ui.painter().layout_no_wrap(cut[0].text.clone(), theme::regular(13.0), Color32::WHITE); + let row = &galley.rows[0]; + let ellipsis_x = row.glyphs.iter().find(|glyph| glyph.chr == '\u{2026}').expect("ellipsis glyph").pos.x; + assert!( + row.glyphs.iter().filter(|glyph| glyph.chr != '\u{2026}' && !glyph.chr.is_whitespace()).all(|glyph| glyph.pos.x > ellipsis_x), + "RTL truncation must place the ellipsis to the left: {:?}", + row.glyphs + ); + }) + .drop_without_applying_deltas(); + } + + /// A wrapped line keeps its paragraph's direction. The second line here starts with a + /// Latin word, but belongs to a right-to-left comment: `Auto` would shape it left to right + /// and put `PDF` at the visual left. Assert the actual painted positions instead. + #[test] + fn wrapped_rtl_line_starting_with_latin_keeps_its_rtl_base() { + if pdfcraft_fonts::ui_arabic_fonts().is_empty() { + return; + } + let ctx = egui::Context::default(); + ctx.set_fonts(theme::font_definitions()); + ctx.run_ui(egui::RawInput::default(), |ui| { + let line = crate::bidi::Line { text: "PDF درباره فایل است".to_owned(), rtl: true }; + let galley = wrapped_line_galley(ui, &line, &theme::regular(13.0), Color32::WHITE); + assert_eq!(galley.job.direction, egui::epaint::text::TextDirection::RightToLeft); + let positions: Vec<_> = galley.rows.iter().flat_map(|row| row.glyphs.iter().map(|g| (g.chr, g.pos.x))).collect(); + let p = positions.iter().find(|(c, _)| *c == 'P').map(|(_, x)| *x).expect("a Latin P"); + let rightmost_arabic = positions.iter().filter(|(c, _)| crate::bidi::has_rtl(&c.to_string())).map(|(_, x)| *x).fold(f32::MIN, f32::max); + assert!(p > rightmost_arabic, "the line stays right to left: {positions:?}"); + }) + .drop_without_applying_deltas(); + } + + /// Check right-aligned wrapping and a single collapsed line whose logical text ends with an ellipsis. #[test] fn rtl_comment_wraps_against_the_right_edge() { let persian = "این یک نظر طولانی به زبان فارسی است که باید در چند خط شکسته شود"; @@ -803,7 +901,7 @@ mod tests { // Collapsed: one line, its ellipsis on the left where right-to-left reading ends. let collapsed = wrapped_shapes(persian, true); assert_eq!(collapsed.len(), 1); - assert!(collapsed[0].2.starts_with('\u{2026}'), "{collapsed:?}"); + assert!(collapsed[0].2.ends_with('\u{2026}'), "the galley retains logical text: {collapsed:?}"); // Text without right-to-left script is a plain label, as before. let latin = wrapped_shapes("A short note", false); assert_eq!(latin.len(), 1); diff --git a/crates/ui-egui/src/window_controls.rs b/crates/ui-egui/src/window_controls.rs index d02ea5dc8c..9965d46ec1 100644 --- a/crates/ui-egui/src/window_controls.rs +++ b/crates/ui-egui/src/window_controls.rs @@ -96,7 +96,7 @@ pub fn buttons(ui: &mut egui::Ui, buttons: &[Button]) { Button::Close => tl!("Close"), }; let (rect, response) = ui.allocate_exact_size(Vec2::splat(BUTTON), Sense::click()); - response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label)); let response = response.on_hover_text(label); if ui.is_rect_visible(rect) { paint(ui.painter(), rect, button, maximized, &t, &response); @@ -203,7 +203,7 @@ pub fn resize_edges(ctx: &egui::Context) { } let screen = ctx.content_rect(); let t = Tokens::get(ctx); - egui::Area::new(egui::Id::new("window_resize_edges")) + egui::Area::new(egui::Id::unique("window_resize_edges")) .order(egui::Order::Foreground) .fixed_pos(screen.min) .pivot(Align2::LEFT_TOP) @@ -214,7 +214,7 @@ pub fn resize_edges(ctx: &egui::Context) { if rect.width() <= 0.0 || rect.height() <= 0.0 { continue; } - let response = ui.interact(rect, ui.id().with(("edge", n)), Sense::drag()).on_hover_cursor(cursor(direction)); + let response = ui.interact(rect, ui.scope_id().with(("edge", n)), Sense::drag()).on_hover_cursor(cursor(direction)); // Sensing only drags, a drag starts on the press itself. if response.drag_started() { desktop_grab(ui.ctx(), egui::ViewportCommand::BeginResize(direction)); @@ -231,8 +231,9 @@ pub fn resize_edges(ctx: &egui::Context) { }); } +/// Return the stable egui id for desktop window-grab state. fn grab_id() -> egui::Id { - egui::Id::new("window_controls_desktop_grab") + egui::Id::unique("window_controls_desktop_grab") } /// Hand the pointer to the desktop to move or resize the window. On Wayland the compositor keeps @@ -248,20 +249,41 @@ pub fn desktop_grab(ctx: &egui::Context, command: egui::ViewportCommand) { /// After `desktop_grab`, add the primary button's release to the next input, unless it brings one. /// The release goes before the input's first press (the next resize or move, or a click), which -/// egui would otherwise end straight away; a release after that press is the press's own. +/// egui would otherwise end straight away; a release after that press is the press's own. It also +/// goes before the pointer leaving, as a button event would bring the gone pointer back; a release +/// the input brings after the pointer left is the grab's own, and moves there instead. pub fn release_after_desktop_grab(ctx: &egui::Context, raw: &mut egui::RawInput) { if ctx.data_mut(|d| d.remove_temp::<()>(grab_id())).is_none() { return; } - let first = raw.events.iter().enumerate().find_map(|(n, e)| match e { - egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed, .. } => Some((n, *pressed)), - _ => None, - }); - let at = match first { - Some((_, false)) => return, - Some((n, true)) => n, - None => raw.events.len(), - }; + let mut at = raw.events.len(); + let mut gone = None; + let mut brought = None; + for (n, e) in raw.events.iter().enumerate() { + match e { + egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed: false, .. } => { + brought = Some(n); + break; + } + egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed: true, .. } => { + at = n; + break; + } + egui::Event::PointerGone => { + gone.get_or_insert(n); + } + _ => {} + } + } + if let Some(n) = brought { + // `gone` was found before `n`, so both are in range. + if let Some(g) = gone { + let release = raw.events.remove(n); + raw.events.insert(g, release); + } + return; + } + let at = gone.unwrap_or(at); let (pos, modifiers) = ctx.input(|i| (i.pointer.interact_pos().unwrap_or_default(), i.modifiers)); raw.events.insert(at, egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers }); } @@ -389,4 +411,52 @@ mod tests { frame(vec![], Some(egui::ViewportCommand::StartDrag)); assert_eq!(frame(vec![button(true), button(false)], None), [false, true, false]); } + + #[test] + fn the_release_goes_before_the_pointer_leaving() { + let ctx = egui::Context::default(); + let frame = |events: Vec, grab: bool| { + let mut raw = egui::RawInput { events, ..Default::default() }; + super::release_after_desktop_grab(&ctx, &mut raw); + let order = raw.events.clone(); + ctx.run_ui(raw, |ui| { + if grab { + super::desktop_grab(ui.ctx(), egui::ViewportCommand::StartDrag); + } + }) + .textures_delta + .clear(); + order + }; + let release = |e: &egui::Event| matches!(e, egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed: false, .. }); + let press = egui::Event::PointerButton { + pos: Pos2::new(5.0, 5.0), + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }; + // On Wayland the input after a move brings only that the pointer left. + frame(vec![egui::Event::PointerMoved(Pos2::new(5.0, 5.0)), press], true); + let order = frame(vec![egui::Event::PointerGone], false); + assert!(matches!(order.as_slice(), [r, egui::Event::PointerGone] if release(r)), "{order:?}"); + assert_eq!(ctx.input(|i| i.pointer.latest_pos()), None, "the pointer stays gone"); + assert!(!ctx.input(|i| i.pointer.primary_down())); + + // A release after the pointer left is the one the desktop kept, not to be doubled. + frame(vec![], true); + let order = frame( + vec![ + egui::Event::PointerGone, + egui::Event::PointerButton { + pos: Pos2::new(5.0, 5.0), + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }, + ], + false, + ); + assert!(matches!(order.as_slice(), [r, egui::Event::PointerGone] if release(r)), "moved, not doubled: {order:?}"); + assert_eq!(ctx.input(|i| i.pointer.latest_pos()), None, "the pointer stays gone"); + } } diff --git a/crates/ui-egui/src/zoom_snap.rs b/crates/ui-egui/src/zoom_snap.rs index eb2cc86727..e0315763fa 100644 --- a/crates/ui-egui/src/zoom_snap.rs +++ b/crates/ui-egui/src/zoom_snap.rs @@ -5,6 +5,7 @@ use egui::{Color32, CornerRadius, Pos2, Rect, Stroke}; use pdfcraft_render::{RenderConfig, RenderRequest, RequestKind, Tile}; use crate::canvas::{DocView, PageXform}; +use crate::page_pointer::PagePointer; use crate::{PdfCraftApp, QuickTool}; /// A finished gesture: (page, the rectangle on screen, the same in page view points @@ -12,9 +13,8 @@ use crate::{PdfCraftApp, QuickTool}; pub type Marquee = (usize, Rect, [f32; 4]); /// Drag a rectangle on page `page` (Marquee Zoom, Snapshot). -pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, page: usize, view: &mut DocView) { - let pointer = ui.input(|i| i.pointer.hover_pos()); - let origin = ui.input(|i| i.pointer.press_origin()); +pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, page: usize, view: &mut DocView) { + let (pointer, origin) = (ptr.pos, ptr.origin); if pointer.is_some_and(|p| xf.rect.contains(p)) { ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); } diff --git a/crates/ui-egui/tests/it/actions.rs b/crates/ui-egui/tests/it/actions.rs index 8e5f37cf08..a78de3481a 100644 --- a/crates/ui-egui/tests/it/actions.rs +++ b/crates/ui-egui/tests/it/actions.rs @@ -1,12 +1,30 @@ //! Action Wizard in the real shell (egui_kittest): run a built-in action on files, create an //! action, and remember it. +use egui::accesskit::Role; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; use pdfcraft_engine::Session; use pdfcraft_engine::actions::Step; use pdfcraft_ui_egui::PdfCraftApp; +/// The Action Wizard's name, description and step-argument boxes are named by their labels. +#[test] +fn action_editor_inputs_have_accessible_names() { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app + }); + h.run_steps(2); + assert!(h.state_mut().execute("actions.wizard")); + h.run_steps(2); + h.get_by_label("New Action…").click(); + h.run_steps(2); + h.get_by_role_and_label(Role::TextInput, "Action name:"); + h.get_by_role_and_label(Role::TextInput, "Description:"); +} + #[test] fn run_and_create_actions() { let dir = std::env::temp_dir().join(format!("pdfcraft-actions-ui-{}", std::process::id())); diff --git a/crates/ui-egui/tests/it/attachment_row.rs b/crates/ui-egui/tests/it/attachment_row.rs new file mode 100644 index 0000000000..c52b2892e0 --- /dev/null +++ b/crates/ui-egui/tests/it/attachment_row.rs @@ -0,0 +1,190 @@ +//! An attachment's row in the Attachments panel answers a double-click and a right-click, as +//! in Acrobat (#3466), while its buttons keep taking their own clicks. + +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use pdfcraft_ui_egui::PdfCraftApp; + +/// A one-page PDF with no embedded files, to attach to. +const INNER: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// A one-page PDF with two embedded files: `inner.pdf` (a PDF) and `notes.txt`. +fn attachments_pdf() -> Vec { + let objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles << /Names [(inner.pdf) 5 0 R (notes.txt) 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 200 200] /Resources << >> >>".to_vec(), + [format!("<< /Type /EmbeddedFile /Length {} >>\nstream\n", INNER.len()).as_bytes(), INNER, b"\nendstream"].concat(), + b"<< /Type /Filespec /F (inner.pdf) /UF (inner.pdf) /EF << /F 4 0 R >> >>".to_vec(), + b"<< /Type /EmbeddedFile /Length 11 >>\nstream\nhello notes\nendstream".to_vec(), + b"<< /Type /Filespec /F (notes.txt) /UF (notes.txt) /EF << /F 6 0 R >> >>".to_vec(), + ]; + 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 +} + +/// The app with [`attachments_pdf`] open and the Attachments panel showing, at 60 frames a +/// second so two clicks a frame apart make a double-click. +fn harness() -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("doc.pdf", None, attachments_pdf()).unwrap(); + app.set_option("panel", "attachments").unwrap(); + app + }); + h.run_steps(4); + h +} + +/// A folder of its own for one test's saved file, removed afterwards. +struct Dir(std::path::PathBuf); + +impl Dir { + fn new(test: &str) -> Self { + let path = std::env::temp_dir().join(format!("pdfcraft-attachment-row-{test}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).unwrap(); + Self(path) + } +} + +impl Drop for Dir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// Two pointer clicks a frame apart on `label` (an accessibility click is never a double-click). +fn double_click(h: &mut Harness<'static, PdfCraftApp>, label: &str) { + let at = row_name(h, label); + h.hover_at(at); + h.run_steps(1); + for _ in 0..2 { + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(1); + } + h.run_steps(3); +} + +fn right_click(h: &mut Harness<'static, PdfCraftApp>, label: &str) { + let at = row_name(h, label); + h.hover_at(at); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); +} + +#[test] +fn double_clicking_a_pdf_attachment_opens_it_in_a_new_tab() { + let mut h = harness(); + assert_eq!(h.state().views.len(), 1); + double_click(&mut h, "inner.pdf"); + assert_eq!(h.state().views.len(), 2, "the attached PDF opens in a tab of its own"); +} + +#[test] +fn double_clicking_another_attachment_saves_it() { + let dir = Dir::new("double"); + let target = dir.0.join("saved notes.txt"); + let mut h = harness(); + h.state_mut().pick_override = Some(vec![target.to_string_lossy().into_owned()]); + double_click(&mut h, "notes.txt"); + assert_eq!(std::fs::read(&target).unwrap(), b"hello notes", "PdfCraft opens only PDFs, so it saves the rest"); + assert_eq!(h.state().views.len(), 1, "nothing opens"); +} + +#[test] +fn the_right_click_menu_opens_saves_and_deletes() { + let dir = Dir::new("menu"); + let target = dir.0.join("from menu.pdf"); + let mut h = harness(); + let count = |h: &Harness<'static, PdfCraftApp>, label: &str| h.query_all_by_label(label).count(); + // The rows have Save and Delete buttons each, and the PDF's row an Open button. + assert_eq!((count(&h, "Save attachment…"), count(&h, "Open in a new tab"), count(&h, "Delete attachment")), (2, 1, 2)); + // A text file's menu has Save and Delete, no Open. + right_click(&mut h, "notes.txt"); + assert_eq!((count(&h, "Save attachment…"), count(&h, "Open in a new tab"), count(&h, "Delete attachment")), (3, 1, 3)); + h.key_press(egui::Key::Escape); + h.run_steps(3); + // Scope actions to the menu: egui nests it under its row in the accessibility tree. + // Save writes the PDF, not the text file whose row comes last. + right_click(&mut h, "inner.pdf"); + assert_eq!(count(&h, "Open in a new tab"), 2, "the PDF's menu offers Open"); + h.state_mut().pick_override = Some(vec![target.to_string_lossy().into_owned()]); + h.get_by_role(egui::accesskit::Role::Menu).get_by_label("Save attachment…").click(); + h.run_steps(3); + assert_eq!(std::fs::read(&target).unwrap(), INNER, "Save from inner.pdf's menu"); + right_click(&mut h, "inner.pdf"); + h.get_by_role(egui::accesskit::Role::Menu).get_by_label("Open in a new tab").click(); + h.run_steps(3); + assert_eq!(h.state().views.len(), 2, "Open from the menu"); + h.state_mut().active = Some(0); + h.run_steps(3); + // Delete from inner.pdf's menu leaves notes.txt, whose row's button would delete itself. + right_click(&mut h, "inner.pdf"); + h.get_by_role(egui::accesskit::Role::Menu).get_by_label("Delete attachment").click(); + h.run_steps(3); + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert_eq!(doc.info.attachments.iter().map(|a| a.name.as_str()).collect::>(), ["notes.txt"], "Delete from the menu"); + assert!(doc.can_undo().is_some_and(|l| l.starts_with("Delete attachment")), "as one undoable edit"); +} + +#[test] +fn the_row_buttons_still_take_their_own_clicks() { + let dir = Dir::new("buttons"); + let target = dir.0.join("from button.txt"); + let mut h = harness(); + h.state_mut().pick_override = Some(vec![target.to_string_lossy().into_owned()]); + // The rows list inner.pdf first, then notes.txt: the second Save button is notes.txt's. + h.get_all_by_label("Save attachment…").nth(1).unwrap().click(); + h.run_steps(3); + assert_eq!(std::fs::read(&target).unwrap(), b"hello notes", "the Save button under the row's click area"); + h.get_by_label("Open in a new tab").click(); + h.run_steps(3); + assert_eq!(h.state().views.len(), 2, "the Open button"); +} + +/// CodeRabbit on #4513: a right-click on one of a row's buttons opens that row's menu too, as a +/// right-click anywhere on the row does. +#[test] +fn a_right_click_on_a_row_button_opens_the_row_menu() { + let mut h = harness(); + let count = |h: &Harness<'static, PdfCraftApp>, label: &str| h.query_all_by_label(label).count(); + // inner.pdf's row comes first: its Save button is the first one. + let at = h.get_all_by_label("Save attachment…").next().expect("inner.pdf's Save button").rect().center(); + h.hover_at(at); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + assert_eq!(count(&h, "Open in a new tab"), 2, "inner.pdf's menu, with Open, is open"); + assert_eq!(count(&h, "Save attachment…"), 3); +} + +/// Where the attachment named `label` shows in the panel: the lowest text with that name, below +/// a tab of the same name once the attachment is open. +fn row_name(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Pos2 { + h.get_all_by_label(label).map(|n| n.rect()).max_by(|a, b| a.top().total_cmp(&b.top())).expect("the attachment is listed").center() +} diff --git a/crates/ui-egui/tests/it/bookmarks.rs b/crates/ui-egui/tests/it/bookmarks.rs index be8c5151bb..415067ad33 100644 --- a/crates/ui-egui/tests/it/bookmarks.rs +++ b/crates/ui-egui/tests/it/bookmarks.rs @@ -101,7 +101,7 @@ fn new_rename_reorder_indent_delete_and_undo() { /// The side panel's width as egui keeps it from one frame to the next. fn panel_width(h: &Harness<'static, PdfCraftApp>) -> f32 { - h.ctx.data(|data| data.get_temp::(egui::Id::new("pdfcraft-inspector-body"))).expect("the inspector body is shown").width() + h.ctx.data(|data| data.get_temp::(egui::Id::unique("pdfcraft-inspector-body"))).expect("the inspector body is shown").width() } #[test] @@ -290,8 +290,8 @@ fn style_and_colour_from_the_bookmark_menu() { let submenu = |h: &mut Harness<'static, PdfCraftApp>, menu: &str, item: &str| { h.get_by_label("Intro").click_secondary(); h.run_steps(2); - // A submenu's button carries its arrow. - h.get_by_label(&format!("{menu} ⏵")).click(); + // The submenu arrow is painted as an atom; the accessible name is the menu title. + h.get_by_label(menu).click(); h.run_steps(2); h.get_by_label(item).click(); h.run_steps(3); diff --git a/crates/ui-egui/tests/it/combine_grid.rs b/crates/ui-egui/tests/it/combine_grid.rs index 4ed7b5778e..0339e0b6bb 100644 --- a/crates/ui-egui/tests/it/combine_grid.rs +++ b/crates/ui-egui/tests/it/combine_grid.rs @@ -180,6 +180,17 @@ fn ready(t: &CombineThumb) -> bool { matches!(t, CombineThumb::Ready { .. }) } +/// Wait for a modal opened by an action to be laid out and present in the accessibility tree. +/// A newly shown modal needs a pass of its own; the action's frame only sets its state. +fn wait_for_label(h: &mut Harness<'static, PdfCraftApp>, label: &str) { + for _ in 0..30 { + if h.query_by_label(label).is_some() { + return; + } + h.run_steps(1); + } +} + /// Press, move in steps and release, as a user drags. fn drag(h: &mut Harness<'static, PdfCraftApp>, from: Pos2, to: Pos2) { h.hover_at(from); @@ -519,6 +530,10 @@ fn combine_follows_the_order_shown_in_the_grid() { #[test] fn long_and_unusual_names_are_shortened_on_the_card_but_kept_whole() { + if crate::support::skip_without_glyphs("long_and_unusual_names_are_shortened_on_the_card_but_kept_whole", "en", "日本語のファイル名.pdf") + { + return; + } let long = "Überweisungsbestätigung für das dritte Quartal 2026 – Endfassung (unterschrieben).pdf"; let mut h = grid_of(vec![(long, fixture(1)), ("日本語のファイル名.pdf", fixture(1)), ("a&b 'd'.pdf", fixture(1))], vec2(1400.0, 900.0)); // The card's label is the whole name; only the drawing is shortened. @@ -691,6 +706,7 @@ fn a_card_dragged_to_the_bottom_edge_scrolls_the_grid_and_still_drops() { assert_eq!(order.get(at + 1), Some(&target_name), "dropped before {target_name}: {order:?}"); } +/// Check that right-clicking a locked card selects that card and offers Unlock for it. #[test] fn a_locked_card_offers_unlock_on_a_right_click() { let mut h = grid_of(vec![("one.pdf", fixture(1)), ("secret.pdf", protected("pw", "owner"))], vec2(1400.0, 900.0)); @@ -699,6 +715,7 @@ fn a_locked_card_offers_unlock_on_a_right_click() { // The menu's item comes after the toolbar's button of the same name. h.get_all_by_label("Unlock…").last().unwrap().click(); h.run_steps(2); + wait_for_label(&mut h, "Unlock secret.pdf"); h.get_by_label("Unlock secret.pdf"); assert_eq!(h.state().combine_selection(), [1], "the card right-clicked is the one unlocked"); } @@ -716,6 +733,7 @@ fn ctrl_wheel(h: &mut Harness<'static, PdfCraftApp>, at: Pos2, dy: f32) { delta: vec2(0.0, dy), phase: egui::TouchPhase::Move, modifiers: Modifiers::COMMAND, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(3); } @@ -833,6 +851,8 @@ fn ctrl_keys_and_ctrl_wheel_size_the_cards_and_leave_the_window_alone() { assert_eq!(h.state().combine_zoom, now); } +/// Check that wheel zoom keeps the card under the pointer fixed and never blanks its thumbnail while a +/// sharper render arrives. #[test] fn zooming_keeps_the_card_under_the_pointer_in_place_and_thumbnails_never_go_blank() { let files: Vec<(String, Vec)> = (0..40).map(|i| (format!("f{i:02}.pdf"), fixture(1))).collect(); @@ -845,6 +865,7 @@ fn zooming_keeps_the_card_under_the_pointer_in_place_and_thumbnails_never_go_bla delta: vec2(0.0, -300.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(6); // A card fully in view, below the toolbar, with its thumbnail drawn. @@ -866,6 +887,7 @@ fn zooming_keeps_the_card_under_the_pointer_in_place_and_thumbnails_never_go_bla delta: vec2(0.0, 120.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::COMMAND, + source: egui::MouseWheelSource::Unknown, }); // The wheel zooms smoothly over several frames; in every one the card stays put. let mut zooms = Vec::new(); @@ -976,6 +998,7 @@ fn scrolled_grid(dy: f32) -> (Harness<'static, PdfCraftApp>, f32) { delta: vec2(0.0, -dy), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(8); (h, top) @@ -1012,6 +1035,7 @@ fn zooming_from_the_toolbar_while_scrolled_keeps_the_top_cards_in_place() { } } +/// Check that zooming out at the bottom keeps the last card in place without leaving a gap below it. #[test] fn zooming_out_at_the_bottom_leaves_no_gap_below_the_last_card() { let (mut h, _) = scrolled_grid(20_000.0); @@ -1024,6 +1048,7 @@ fn zooming_out_at_the_bottom_leaves_no_gap_below_the_last_card() { delta: vec2(0.0, -20_000.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(8); let bottom = card(&h, "f39.pdf").bottom(); @@ -1598,13 +1623,16 @@ fn a_page_cards_magnifier_opens_the_preview_on_that_page() { h.get_by_label("Preview").click(); h.run_steps(2); assert_eq!(h.state().combine_preview().map(|p| (p.0, p.1)), Some(("b.pdf".to_string(), 3))); + wait_for_label(&mut h, "Page 3 of 3"); h.get_by_label("Page 3 of 3"); - // A page card's own actions: Collapse, Remove page, Preview (its file can't be removed from - // it); nothing is selected yet, so no "Remove b.pdf" anywhere. - h.get_by_label("Remove page"); + // The preview is modal: the card's actions are not drawn behind it. + assert!(h.query_by_label("Remove page").is_none(), "the preview covers the page card's actions"); h.key_press(Key::Escape); h.run_steps(2); + // A page card's own actions: Collapse, Remove page, Preview (its file can't be removed from + // it); nothing is selected yet, so no "Remove b.pdf" anywhere. hover_card(&mut h, &page_card(4, "b.pdf")); + h.get_by_label("Remove page"); h.get_by_label("Collapse"); assert!(h.query_by_label("Remove b.pdf").is_none()); // Space on a page card that has the keyboard: that page too. @@ -1615,6 +1643,7 @@ fn a_page_cards_magnifier_opens_the_preview_on_that_page() { h.key_press(Key::Space); h.run_steps(2); assert_eq!(h.state().combine_preview().map(|p| p.1), Some(0), "page 1, the second the file adds"); + wait_for_label(&mut h, "Page 2 of 3"); h.get_by_label("Page 2 of 3"); } @@ -1651,6 +1680,7 @@ fn collapsing_brings_the_files_card_into_view() { delta: vec2(0.0, -120.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(3); } diff --git a/crates/ui-egui/tests/it/commands.rs b/crates/ui-egui/tests/it/commands.rs index 6f10288612..1e3c36216f 100644 --- a/crates/ui-egui/tests/it/commands.rs +++ b/crates/ui-egui/tests/it/commands.rs @@ -293,19 +293,21 @@ fn palette_arrow_keys_select_results_and_enter_runs_the_selection() { assert_eq!(app.session.get(app.views[0].id).unwrap().info.pages[0].rotation, 270); } +/// Check that choosing Edit ▸ Pages ▸ Delete pages in the menu bar reduces the page count once. #[test] fn the_pages_menu_comes_from_the_registry() { let mut h = harness(); - h.get_by_label("Menu").click(); - h.run_steps(2); - h.get_by_label("Pages ⏵").hover(); + support::open_menu(&mut h, "Edit"); + support::menu_row(&h, "Pages").hover(); h.run_steps(3); - h.get_by_label_contains("Delete pages").click(); + support::menu_row(&h, "Delete pages").click(); h.run_steps(3); let app = h.state(); assert_eq!(app.session.get(app.views[0].id).unwrap().info.pages.len(), 2); } +/// Check that Open Recent lists a recorded file and opens it in a new active tab without replacing the +/// original. #[test] fn the_open_recent_menu_lists_files_and_opens_one() { let dir = std::env::temp_dir().join(format!("pdfcraft-recent-{}", std::process::id())); @@ -324,14 +326,11 @@ fn the_open_recent_menu_lists_files_and_opens_one() { } }); h.run_steps(4); - h.get_by_label("Menu").click(); - h.run_steps(2); - h.get_by_label("File ⏵").hover(); - h.run_steps(3); - h.get_by_label("Open Recent ⏵").hover(); + support::open_menu(&mut h, "File"); + support::menu_row(&h, "Open Recent").hover(); h.run_steps(3); let original = h.state().views[0].id; - h.get_by_label_contains("recent.pdf").click(); + support::menu_row(&h, "recent.pdf").click(); support::wait_until(&mut h, "recent document", |app| app.views.len() == 2 && app.password_prompt.is_none()); let app = h.state(); let active = app.active.unwrap(); @@ -341,13 +340,11 @@ fn the_open_recent_menu_lists_files_and_opens_one() { assert_eq!(app.session.get(original).unwrap().bytes.as_slice(), fixture(3).as_slice()); } +/// Check that Open Recent is disabled while no file has been opened. #[test] fn the_open_recent_menu_is_disabled_while_the_list_is_empty() { let mut h = harness(); - h.get_by_label("Menu").click(); - h.run_steps(2); - h.get_by_label("File ⏵").hover(); - h.run_steps(3); + support::open_menu(&mut h, "File"); assert!( h.query_by(|n| n.label().as_deref() == Some("Open Recent") && n.is_disabled()).is_some(), "Open Recent is disabled while no file has been opened" @@ -358,6 +355,7 @@ fn recent_file(name: &str) -> pdfcraft_ui_egui::RecentFile { pdfcraft_ui_egui::RecentFile { name: name.into(), path: format!("/nowhere/{name}"), pages: 1, size: 0 } } +/// Check that Clear Recent Files empties the list and disables Open Recent. #[test] fn clear_recent_files_at_the_foot_of_open_recent_empties_the_list() { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { @@ -368,19 +366,13 @@ fn clear_recent_files_at_the_foot_of_open_recent_empties_the_list() { app }); h.run_steps(4); - h.get_by_label("Menu").click(); - h.run_steps(2); - h.get_by_label("File ⏵").hover(); + support::open_menu(&mut h, "File"); + support::menu_row(&h, "Open Recent").hover(); h.run_steps(3); - h.get_by_label("Open Recent ⏵").hover(); - h.run_steps(3); - h.get_by_label("Clear Recent Files").click(); + support::menu_row(&h, "Clear Recent Files").click(); h.run_steps(3); assert!(h.state().recent.is_empty(), "the list is empty"); - h.get_by_label("Menu").click(); - h.run_steps(2); - h.get_by_label("File ⏵").hover(); - h.run_steps(3); + support::open_menu(&mut h, "File"); assert!(h.query_by(|n| n.label().as_deref() == Some("Open Recent") && n.is_disabled()).is_some(), "and Open Recent is disabled"); } @@ -453,13 +445,13 @@ fn localized_shortcuts_update_immediately_and_keep_command_ids_and_key_bindings( assert!(!doc.dirty); } -/// Menu ▸ View open, with egui told the app runs on `os`. +/// View ▸ Zoom open in the menu bar, with egui told the app runs on `os`. fn view_menu(os: OperatingSystem) -> Harness<'static, PdfCraftApp> { let mut h = harness(); h.ctx.set_os(os); - h.get_by_label("Menu").click(); h.run_steps(2); - h.get_by_label("View ⏵").hover(); + support::open_menu(&mut h, "View"); + support::menu_row(&h, "Zoom").hover(); h.run_steps(3); h } @@ -468,21 +460,14 @@ fn view_menu(os: OperatingSystem) -> Harness<'static, PdfCraftApp> { fn view_menu_and_tooltips_write_shortcuts_for_the_platform() { // On Linux, View ▸ Zoom and Page navigation showed ⌘1, ⌘[ … (hard-coded) while the // registered items under them showed Ctrl+K; tooltips showed ⌘ too. + // The menu bar writes its shortcuts for the build's platform (craft-menu tests that). let h = view_menu(OperatingSystem::Nix); - h.get_by_label("Actual size Ctrl+1"); - h.get_by_label("Zoom to page level Ctrl+0"); - h.get_by_label("Rotate view counterclockwise Ctrl+Shift+−"); - h.get_by_label("Previous view Ctrl+["); - h.get_by_label("Find tools and commands… Ctrl+K"); h.get_by_label("Zoom in (Ctrl++)"); h.get_by_label("Print (Ctrl+P)"); assert_eq!(h.query_all_by_label_contains("⌘").count(), 0, "no ⌘ outside macOS"); assert_eq!(h.query_all_by_label_contains("Fit visible").count(), 1, "View lists Fit visible once"); - h.get_by_label("Fit visible Ctrl+3"); let h = view_menu(OperatingSystem::Mac); - h.get_by_label("Actual size ⌘1"); - h.get_by_label("Fit visible ⌘3"); h.get_by_label("Rotate view clockwise (⇧⌘+)"); assert_eq!(h.query_all_by_label_contains("Ctrl+").count(), 0, "no Ctrl+ on macOS"); } @@ -530,6 +515,8 @@ fn the_about_dialog_shows_the_running_version() { h.get_by_label_contains(&format!("Version {}", env!("CARGO_PKG_VERSION"))); } +/// Check that the quit shortcut needs exact platform modifiers and that unsaved work opens the save prompt +/// instead of closing. #[test] fn quit_shortcut_requires_exact_modifiers_and_preserves_unsaved_work() { for (os, modifiers) in [ @@ -550,6 +537,9 @@ fn quit_shortcut_requires_exact_modifiers_and_preserves_unsaved_work() { let closes = h.output().viewport_output.values().any(|v| v.commands.contains(&egui::ViewportCommand::Close)); assert_eq!(closes, should_close, "{os:?}, extra={extra:?}, dirty={dirty}"); if dirty { + // The save prompt is a modal laid out after the key pass; let that pass settle + // before looking it up. + h.run_steps(2); h.get_by_label("Cancel").click(); h.run_steps(2); assert_eq!(h.state().views.len(), 1); @@ -613,6 +603,9 @@ fn palette_ime_idle_frames_and_reopen_do_not_interrupt_composition() { #[test] fn palette_ime_chinese_preedit_commit_and_cancel_remain_uninterrupted() { + if crate::support::skip_without_glyphs("palette_ime_chinese_preedit_commit_and_cancel_remain_uninterrupted", "en", "你") { + return; + } let mut h = harness(); let mut interruptions = Vec::new(); h.state_mut().run_command("view.palette"); diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index a273e3cd62..3a3169576c 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -1,6 +1,7 @@ //! Commenting in the real shell (egui_kittest): tools, gestures, selection, the composer and //! the Comments panel. +use egui::accesskit::Role; use egui::{Pos2, pos2}; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; @@ -542,7 +543,7 @@ fn selected_text_replace_action_opens_replacement_composer() { h.run_steps(3); h.get_by_label("Replace text").click(); h.run_steps(2); - h.get_by_label("Replacement text"); + h.get_by_role_and_label(Role::MultilineTextInput, "Replacement text"); assert!(h.state().views[0].selected_text().is_none()); } @@ -1052,7 +1053,7 @@ fn the_panel_posts_comments_and_replies() { /// The side panel's width as egui keeps it from one frame to the next. fn panel_width(h: &Harness<'static, PdfCraftApp>) -> f32 { - h.ctx.data(|data| data.get_temp::(egui::Id::new("pdfcraft-inspector-body"))).expect("the inspector body is shown").width() + h.ctx.data(|data| data.get_temp::(egui::Id::unique("pdfcraft-inspector-body"))).expect("the inspector body is shown").width() } #[test] @@ -1228,8 +1229,9 @@ fn the_panel_filters_and_sorts() { assert_eq!(h.query_all_by_label("Oval").count(), ovals + 1, "a group header was added"); } -// Contributor-original PDF. Two roots deliberately share /NM. Their replies are on -// another page, with dates opposite to source order. An unnamed root has no thread. +// Contributor-original PDF. Two roots deliberately share /NM; the replies (on another page, +// with dates opposite to source order) point at the first by reference. Nothing replies to the +// unnamed root. const REVIEW_FIXTURE: &[u8] = b"%PDF-1.7 1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj 2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj @@ -1250,7 +1252,7 @@ trailer << /Root 1 0 R >> %%EOF"; #[test] -fn the_panel_preserves_global_threads_source_order_and_unfiltered_page_counts() { +fn the_panel_preserves_threads_source_order_and_unfiltered_page_counts() { let mut h = harness_with_bytes(REVIEW_FIXTURE.to_vec(), |app| { app.set_option("panel", "comments").unwrap(); app.views[0].comments.search = Some("needle".into()); @@ -1272,14 +1274,16 @@ fn the_panel_preserves_global_threads_source_order_and_unfiltered_page_counts() h.state_mut().views[0].comments.hidden_authors.clear(); h.run_steps(2); - h.get_by_label("Duplicate root"); - assert_eq!(h.query_all_by_label("Needle first reply").count(), 2, "duplicate names share the same global thread"); + // The replies point at the first root: sharing its /NM doesn't give the other its thread + // (or a search match on that thread). + assert_eq!(h.query_all_by_label("Duplicate root").count(), 0); + assert_eq!(h.query_all_by_label("Needle first reply").count(), 1); h.state_mut().views[0].comments.search = Some("status-only token".into()); h.state_mut().views[0].comments.hidden_statuses.push("Rejected".into()); h.state_mut().views[0].comments.hidden_checks.push("Checked".into()); h.run_steps(2); h.get_by_label("First root"); - h.get_by_label("Duplicate root"); + assert_eq!(h.query_all_by_label("Duplicate root").count(), 0); h.state_mut().views[0].comments.hidden_statuses.push("Accepted".into()); h.run_steps(2); @@ -1293,7 +1297,167 @@ fn the_panel_preserves_global_threads_source_order_and_unfiltered_page_counts() h.state_mut().views[0].comments.hidden_checks.clear(); h.run_steps(2); h.get_by_label("Unnamed root"); - assert_eq!(h.query_all_by_label("Needle first reply").count(), 0, "an unnamed root has no replies"); + assert_eq!(h.query_all_by_label("Needle first reply").count(), 0, "nothing replies to the unnamed root"); +} + +// Contributor-original PDF, shaped like PyMuPDF output: every page's first comment is named +// `fitz-A0`, so only the `/IRT` reference tells which comment a reply belongs to. +const PER_PAGE_NAMES: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R] >> endobj +4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [6 0 R 7 0 R 8 0 R] >> endobj +5 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (fitz-A0) /T (Alpha) /Contents (Cover note) >> endobj +6 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (fitz-A0) /T (Beta) /Contents (Second page note) >> endobj +7 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (fitz-A1) /IRT 6 0 R /T (Gamma) /Contents (Reply on page two) >> endobj +8 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (fitz-A2) /IRT 6 0 R /T (Beta) /Contents (Answer on page two) >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +#[test] +fn replies_follow_irt_references_when_names_repeat_on_every_page() { + let h = harness_with_bytes(PER_PAGE_NAMES.to_vec(), |app| app.set_option("panel", "comments").unwrap()); + let reply = h.get_by_label("Reply on page two"); + assert!(reply.rect().top() > h.get_by_label("Second page note").rect().top(), "the reply sits under page 2's comment"); + assert!(h.get_by_label("Page 2").rect().top() < reply.rect().top(), "not under page 1's comment, which shares the /NM"); + h.get_by_label("Answer on page two"); + // The header counts the two comments, not their replies (Acrobat says 2, not 4), and gives + // the replies in small text under it. + assert_eq!(h.query_all_by_label("4").count(), 0); + h.get_by_label("2 replies"); +} + +#[test] +fn the_header_names_no_replies_when_there_are_none() { + let h = harness_with_bytes(PER_PAGE_NAMES.to_vec(), |app| { + app.set_option("panel", "comments").unwrap(); + app.views[0].comments.search = Some("cover".into()); + }); + h.get_by_label("Cover note"); + h.get_by_label("2 replies"); // The header counts the document, not the search. + let h = harness_with_bytes(SINGLE_COMMENT.to_vec(), |app| app.set_option("panel", "comments").unwrap()); + h.get_by_label("Only note"); + assert_eq!(h.query_all_by_label_contains("repl").count(), 0, "no \"0 replies\" line"); +} + +// Contributor-original PDF: one comment, no replies. +const SINGLE_COMMENT: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /NM (only) /T (Alpha) /Contents (Only note) >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +// Contributor-original PDF. Page 1: an unnamed comment with a reply and a reply to that reply, +// a Replace Text strikeout with its grouped caret, and a hidden status whose comment no page +// lists. Page 2: an `/IRT` to the annotation itself, to an annotation no page lists (object 11), +// to a pop-up, to a link and to a form field, and two annotations whose `/IRT`s point at each other. +const THREAD_EDGES: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 19 0 R] >> endobj +4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [10 0 R 12 0 R 13 0 R 14 0 R 15 0 R 16 0 R 17 0 R 18 0 R 20 0 R 21 0 R] >> endobj +5 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /T (Alpha) /Contents (Unnamed parent) >> endobj +6 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /IRT 5 0 R /T (Beta) /Contents (Direct reply) >> endobj +7 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /IRT 6 0 R /T (Gamma) /Contents (Reply to the reply) >> endobj +8 0 obj << /Type /Annot /Subtype /StrikeOut /Rect [60 100 160 112] /IT /StrikeOutTextEdit /T (Alpha) /Contents (Struck words) >> endobj +9 0 obj << /Type /Annot /Subtype /Caret /Rect [158 96 166 104] /IRT 8 0 R /RT /Group /T (Alpha) /Contents (Replacement words) >> endobj +10 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /IRT 10 0 R /T (Delta) /Contents (Points at itself) >> endobj +11 0 obj << /Type /Annot /Subtype /Text /Rect [20 120 40 140] /T (Nobody) /Contents (Listed on no page) >> endobj +12 0 obj << /Type /Annot /Subtype /Text /Rect [20 120 40 140] /IRT 11 0 R /T (Delta) /Contents (Points at an unlisted note) >> endobj +13 0 obj << /Type /Annot /Subtype /Popup /Rect [120 100 280 180] >> endobj +14 0 obj << /Type /Annot /Subtype /Text /Rect [20 80 40 100] /IRT 13 0 R /T (Delta) /Contents (Points at a pop-up) >> endobj +15 0 obj << /Type /Annot /Subtype /Link /Rect [200 20 260 40] /Border [0 0 0] /Dest [3 0 R /Fit] >> endobj +16 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (field) /Rect [200 60 260 80] >> endobj +17 0 obj << /Type /Annot /Subtype /Text /Rect [20 50 40 70] /IRT 15 0 R /T (Delta) /Contents (Points at a link) >> endobj +18 0 obj << /Type /Annot /Subtype /Text /Rect [20 20 40 40] /IRT 16 0 R /T (Delta) /Contents (Points at a form field) >> endobj +19 0 obj << /Type /Annot /Subtype /Text /Rect [240 20 260 40] /F 30 /IRT 11 0 R /T (Delta) /StateModel /Review /State /Accepted /Contents (Stray status) >> endobj +20 0 obj << /Type /Annot /Subtype /Text /Rect [60 20 80 40] /IRT 21 0 R /T (Delta) /Contents (Loop one) >> endobj +21 0 obj << /Type /Annot /Subtype /Text /Rect [90 20 110 40] /IRT 20 0 R /T (Delta) /Contents (Loop two) >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +#[test] +fn replies_that_lead_nowhere_stay_comments_and_deeper_replies_join_their_thread() { + let mut h = harness_with_bytes(THREAD_EDGES.to_vec(), |app| { + app.set_option("panel", "comments").unwrap(); + app.set_option("quick", "select").unwrap(); + }); + // An `/IRT` that reaches no listed comment leaves its annotation a comment of its own, + // rather than a reply shown nowhere. + // Links, form widgets and pop-ups are never parents: neither reader lists them as comments. + for orphan in + ["Points at itself", "Points at an unlisted note", "Points at a pop-up", "Points at a link", "Points at a form field", "Loop one", "Loop two"] + { + assert!(h.get_by_label(orphan).rect().top() > h.get_by_label("Page 2").rect().top(), "{orphan}"); + } + // A reply to a reply joins the thread of the comment it starts from, also when that comment + // has no /NM. + let (parent, direct, deeper) = (h.get_by_label("Unnamed parent"), h.get_by_label("Direct reply"), h.get_by_label("Reply to the reply")); + assert!(parent.rect().top() < direct.rect().top() && direct.rect().top() < deeper.rect().top()); + assert!(deeper.rect().top() < h.get_by_label("Struck words").rect().top(), "under its own thread"); + // Comments: the unnamed parent, the strikeout and the seven on page 2. Replies: the two + // replies, not the Replace Text caret grouped with its strikeout. + h.get_by_label("9"); + h.get_by_label("2 replies"); + // A status whose comment no page lists is not a comment: no card, and nothing to pick on the + // page where it sits. + assert_eq!(h.query_all_by_label("Stray status").count(), 0); + click_pt(&mut h, (250.0, 30.0)); + assert_eq!(h.state().views[0].comments.selected, None); +} + +/// The arrow keys move a comment, not a reply: also not a reply to a comment with no `/NM`. +#[test] +fn arrow_keys_leave_a_reply_to_an_unnamed_comment_where_it_is() { + let mut h = harness_with_bytes(THREAD_EDGES.to_vec(), |app| app.set_option("quick", "select").unwrap()); + let rect = |h: &Harness<'static, PdfCraftApp>, text: &str| comments(h).into_iter().find(|a| a.contents.as_deref() == Some(text)).unwrap().rect; + let (reply, parent) = (rect(&h, "Direct reply"), rect(&h, "Unnamed parent")); + h.state_mut().views[0].comments.selected = Some((0, 1)); + h.key_press(egui::Key::ArrowRight); + h.run_steps(3); + assert_eq!(rect(&h, "Direct reply"), reply, "the reply stays"); + h.state_mut().views[0].comments.selected = Some((0, 0)); + h.key_press(egui::Key::ArrowRight); + h.run_steps(3); + assert_ne!(rect(&h, "Unnamed parent"), parent, "its comment moves"); +} + +// Contributor-original PDF: a comment, a reply, and a checkmark and a status set on the reply. +const CHECKED_REPLY: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R 5 0 R 6 0 R 7 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Text /Rect [100 100 120 120] /T (Alpha) /Contents (Unchecked comment) >> endobj +5 0 obj << /Type /Annot /Subtype /Text /Rect [100 60 120 80] /IRT 4 0 R /T (Beta) /Contents (Checked reply) >> endobj +6 0 obj << /Type /Annot /Subtype /Text /Rect [100 60 120 80] /IRT 5 0 R /T (Beta) /StateModel /Marked /State /Marked >> endobj +7 0 obj << /Type /Annot /Subtype /Text /Rect [100 60 120 80] /IRT 5 0 R /T (Beta) /StateModel /Review /State /Accepted >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// A checkmark or status belongs to the annotation its `/IRT` names (ISO 32000-2 §12.5.6.2): one +/// set on a reply doesn't mark the comment, on its card or in the page's menu. +#[test] +fn a_checkmark_on_a_reply_does_not_mark_its_comment() { + let mut h = harness_with_bytes(CHECKED_REPLY.to_vec(), |app| { + app.set_option("panel", "comments").unwrap(); + app.set_option("quick", "select").unwrap(); + }); + click_pt(&mut h, (110.0, 110.0)); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment is selected"); + h.get_by_label("Checked reply"); + h.get_by_label("Mark with checkmark"); // The card's checkmark. + assert_eq!(h.query_all_by_label("Accepted").count(), 0, "no status badge"); + let p = at(&h, 110.0, 110.0); + h.hover_at(p); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); + h.get_by_label("Copy text"); // The page's menu is open. + assert_eq!(h.query_all_by_label("Remove checkmark").count(), 0); + assert_eq!(h.query_all_by_label("Mark with checkmark").count(), 2, "the card and the page's menu"); } #[test] @@ -2172,6 +2336,9 @@ fn markup_hover_harness(pdf: Vec, view_rotation: u16) -> Harness<'static, Pd app.open_bytes("markup.pdf", None, pdf).expect("opens synthetic markup"); app.set_option("left", "closed").unwrap(); app.set_option("panel", "none").unwrap(); + // Small enough to show the whole page in every rotation: a point hovered below the window + // is not over the page. + app.set_option("zoom", "100").unwrap(); app.views[0].rotation = view_rotation; app }); @@ -2193,7 +2360,9 @@ fn hover_markup_at(h: &mut Harness<'static, PdfCraftApp>, x: f32, y: f32) { pw: page.width, ph: page.height, }; - xf.user_rect(info, 0, [x, y, x, y]).center() + let p = xf.user_rect(info, 0, [x, y, x, y]).center(); + assert!(v.viewport_rect().contains(p), "user point ({x}, {y}) is on screen, at {p:?}"); + p }; h.hover_at(p); h.run_steps(3); diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index 0d7253707e..4a7927f692 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -335,6 +335,9 @@ fn a_picture_on_the_clipboard_is_pasted_onto_the_page() { /// written to the page as `?` (or lost). #[test] fn text_the_standard_fonts_cant_draw_stays_in_the_editor() { + if crate::support::skip_without_glyphs("text_the_standard_fonts_cant_draw_stays_in_the_editor", "en", "Hello 世界") { + return; + } let _gpu = gpu(); let mut h = harness(); assert!(h.state_mut().execute("edit.text")); @@ -372,6 +375,9 @@ fn text_the_standard_fonts_cant_draw_stays_in_the_editor() { /// is left blocking Save out of sight. #[test] fn a_kept_draft_goes_with_its_deleted_page() { + if crate::support::skip_without_glyphs("a_kept_draft_goes_with_its_deleted_page", "en", "日本語") { + return; + } let mut h = harness(); assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::InsertBlankPage { at: 1, width: 612.0, height: 792.0 })); h.run_steps(2); @@ -609,6 +615,39 @@ fn dragged_text_follows_the_pointer_before_release() { assert_ne!(added(&h)[0].content.rect(), rect, "releasing moves it"); } +/// #4464: what a dragged text box covered in its first place stays there; only the text follows +/// the pointer. The page has a red square under the text's first place and nothing elsewhere. +#[test] +fn dragged_text_leaves_what_it_covered_behind() { + let _gpu = gpu(); + let content = "1 0 0 rg 20 60 240 70 re f"; + let pdf = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /Contents 4 0 R >> endobj\n4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF", + content.len() + ); + let mut h = harness_bytes(pdf.into_bytes()); + let (rect, from, to, end) = drag_added_text(&mut h); + assert!(rect[0] > 20.0 && rect[2] < 260.0 && rect[1] > 60.0 && rect[3] < 130.0, "the text starts on the red square: {rect:?}"); + if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") { + h.render().unwrap().save(format!("{dir}/added-text-drag-over-red.png")).unwrap(); + } + let pixels = h.render().unwrap(); + let red = |r: egui::Rect| { + let (x0, y0, x1, y1) = (r.left().max(0.0) as u32, r.top().max(0.0) as u32, r.right() as u32, r.bottom() as u32); + (y0..y1.min(pixels.height())) + .flat_map(|y| (x0..x1.min(pixels.width())).map(move |x| (x, y))) + .filter(|(x, y)| matches!(pixels.get_pixel(*x, *y).0, [r, g, b, _] if r > 200 && g < 80 && b < 80)) + .count() + }; + let (old, new) = ink_at(&mut h, from, to); + assert!(new > 20 && old == 0, "the text follows the pointer ({old} old, {new} new)"); + assert!(red(from) > 100, "the red square stays where the text was ({} red pixels)", red(from)); + assert_eq!(red(to), 0, "and doesn't come along with the text"); + h.drop_at(end); + h.run_steps(3); + assert_ne!(added(&h)[0].content.rect(), rect, "releasing moves it"); +} + /// After release the text stays in its new place while the page is re-rendered with the move, /// instead of jumping back to where it was for those frames. #[test] diff --git a/crates/ui-egui/tests/it/control.rs b/crates/ui-egui/tests/it/control.rs index 7099ece40b..e2b09931ea 100644 --- a/crates/ui-egui/tests/it/control.rs +++ b/crates/ui-egui/tests/it/control.rs @@ -4,7 +4,7 @@ use std::sync::{Arc, Mutex}; use egui_kittest::Harness; -use egui_kittest::kittest::{NodeT, Queryable}; +use egui_kittest::kittest::Queryable; use pdfcraft_render::{PageRenderer, RenderRequest, RequestKind}; use pdfcraft_ui_egui::PdfCraftApp; use pdfcraft_ui_egui::control::{ControlClient, Reply}; @@ -88,6 +88,14 @@ fn ok(h: &mut Harness<'static, PdfCraftApp>, c: &ControlClient, method: &str, pa call(h, c, method, params).unwrap_or_else(|e| panic!("{method}: {e}")) } +/// Let the next real frames run after a control setting that changes the interface fonts or +/// opens a modal. `ui.set` answers in the input pass, and a font change discards the pass that +/// applies it, so the modal's labels are not in the accessibility tree until those frames have +/// been laid out. +fn settle(h: &mut Harness<'static, PdfCraftApp>) { + h.run_steps(3); +} + #[test] fn comment_pin_is_reported_and_persistent() { let (mut h, c) = harness(); @@ -162,6 +170,9 @@ fn control_resize_reports_observed_dimensions_and_rejects_invalid_sizes() { assert_eq!(state["documents"][0]["dirty"], false); } +/// Kittest calls only `App::logic` and `App::ui`, not eframe's `raw_input_hook`, so this checks +/// the widget tree and key handling but not the production focus-generation ordering. The +/// `production_*` tests above drive `prepare_raw_input` first and cover that ordering. #[test] fn control_focus_traverses_real_widgets_and_keyboard_activates() { let (mut h, c) = harness(); @@ -183,6 +194,55 @@ fn control_focus_traverses_real_widgets_and_keyboard_activates() { assert_eq!(state["documents"][0]["dirty"], false); } +/// A frame driven in production order: eframe's raw input hook runs before egui's input hooks, so +/// the control plugin sees the focus/dialog generation produced by the previous UI pass. The +/// kittest harness does not call `raw_input_hook`, which otherwise hides focus-change bugs. +fn production_step(h: &mut Harness<'static, PdfCraftApp>) { + let ctx = h.ctx.clone(); + let mut raw = std::mem::take(h.input_mut()); + h.state_mut().prepare_raw_input(&ctx, &mut raw); + *h.input_mut() = raw; + h.step(); +} + +fn call_production(h: &mut Harness<'static, PdfCraftApp>, c: &ControlClient, method: &str, params: Value) -> Reply { + let rx = c.send(method, params); + for _ in 0..30 { + production_step(h); + if let Ok(r) = rx.try_recv() { + return r; + } + } + panic!("{method}: no reply after 30 production-ordered frames"); +} + +#[test] +fn production_key_release_survives_the_focus_change_its_press_causes() { + let (mut h, c) = harness(); + ok(&mut h, &c, "ui.focus", json!({"label": "Convert"})); + let reply = call_production(&mut h, &c, "ui.key", json!({"key": "Tab"})); + reply.unwrap_or_else(|e| panic!("a Tab press that moves focus must not leave its release unapplied: {e}")); + let sign = ok(&mut h, &c, "ui.inspect", json!({"query": "E-Sign", "role": "Button"})); + assert!(sign["widgets"].as_array().unwrap().iter().any(|w| w["label"] == "E-Sign" && w["focused"] == true), "focus moved on the press: {sign}"); + assert!(!h.ctx.input(|i| i.keys_down.contains(&egui::Key::Tab)), "the release reached egui: no stuck Tab"); +} + +#[test] +fn production_drag_releases_the_button_after_the_press_moves_focus() { + let (mut h, c) = harness_pages(2); + h.state_mut().apply_edit(pdfcraft_engine::Edit::AddBookmark { parent: vec![], index: 0, title: "Target".into(), page: 0, left: None, top: None }); + ok(&mut h, &c, "ui.set", json!({"key": "panel", "value": "bookmarks"})); + let field = ok(&mut h, &c, "ui.inspect", json!({"query": "Search", "role": "TextInput"})); + let rect = field["widgets"][0]["rect"].as_array().expect("the search field has a rect"); + let (x0, y0, x1, y1) = (rect[0].as_f64().unwrap(), rect[1].as_f64().unwrap(), rect[2].as_f64().unwrap(), rect[3].as_f64().unwrap()); + let start = json!([x0 + 8.0, (y0 + y1) / 2.0]); + let end = json!([x1 - 8.0, (y0 + y1) / 2.0]); + call_production(&mut h, &c, "ui.drag", json!({"from": start, "to": end, "steps": 3})) + .unwrap_or_else(|e| panic!("the press's focus change must not cancel its moves and release: {e}")); + assert!(!h.ctx.input(|i| i.pointer.button_down(egui::PointerButton::Primary)), "the pointer button was released"); + assert_eq!(h.ctx.input(|i| i.modifiers), egui::Modifiers::NONE, "no modifier is left held"); +} + #[test] fn control_button_focus_preserves_document_undo_redo_shortcuts() { let (mut h, c) = harness_pages(2); @@ -209,7 +269,7 @@ fn control_text_focus_retains_text_undo_ownership() { ok(&mut h, &c, "ui.focus", json!({"id": widgets["widgets"][0]["id"]})); assert!(h.ctx.text_edit_focused()); ok(&mut h, &c, "ui.type", json!({"text": "target"})); - let search_id = egui::Id::new(("bookmark_search", h.state().views[0].id.0)); + let search_id = egui::Id::unique(("bookmark_search", h.state().views[0].id.0)); assert_eq!(h.ctx.data(|d| d.get_temp::(search_id)).as_deref(), Some("target")); ok(&mut h, &c, "ui.key", json!({"key": "Z", "modifiers": ["command"]})); assert_eq!(h.ctx.data(|d| d.get_temp::(search_id)).as_deref(), Some("")); @@ -420,16 +480,16 @@ fn lithuanian_inspector_title_updates_through_the_control_channel() { let (mut h, c) = harness(); ok(&mut h, &c, "ui.set", json!({ "key": "panel", "value": "comments" })); h.run_steps(2); - h.get_by_role_and_label(egui::accesskit::Role::Tab, "Inspector"); + h.get_by_label("Inspector"); let documents = ok(&mut h, &c, "ui.state", json!({}))["documents"].clone(); ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "lt" })); h.run_steps(2); - h.get_by_role_and_label(egui::accesskit::Role::Tab, "Inspektorius"); - assert!(h.query_by_role_and_label(egui::accesskit::Role::Tab, "Inspector").is_none()); + h.get_by_label("Inspektorius"); + assert!(h.query_by_label("Inspector").is_none()); assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["documents"], documents); ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "en" })); h.run_steps(2); - h.get_by_role_and_label(egui::accesskit::Role::Tab, "Inspector"); + h.get_by_label("Inspector"); } /// Switching the interface language changes labels only: documents, their dirty state and the @@ -444,6 +504,9 @@ fn language_switch_preserves_document_and_command_ids() { assert_eq!(documents[0]["dirty"], true); let commands = ok(&mut h, &c, "ui.commands", json!({})); for code in ["ja", "zh-hans", "fr", "de", "uk", "lt", "en"] { + if crate::support::skip_without_glyphs(&format!("language_switch_preserves_document_and_command_ids ({code})"), code, "") { + continue; + } ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": code })); h.run_steps(2); let state = ok(&mut h, &c, "ui.state", json!({})); @@ -452,12 +515,16 @@ fn language_switch_preserves_document_and_command_ids() { assert_eq!(ok(&mut h, &c, "ui.commands", json!({})), commands); let lang = i18n::Lang::from_code(code).unwrap(); - h.get_by_label(i18n::tr(lang, "Menu")).click(); + // A title without an ASCII letter shows its mnemonic after it ("ファイル(F)"). + let file = i18n::tr(lang, "File"); + h.get_by(|n| n.role() == egui::accesskit::Role::MenuItem && n.label().is_some_and(|l| l.starts_with(file))).click(); h.run_steps(2); - h.get_by_label(&format!("{} ⏵", i18n::tr(lang, "File"))).hover(); - h.run_steps(3); - h.get_by_label_contains(i18n::tr(lang, "Open…")); - ok(&mut h, &c, "ui.key", json!({ "key": "Escape" })); + // The mnemonic goes before an ellipsis: "開く(O)…". + let open = i18n::tr(lang, "Open…").trim_end_matches('…'); + let open_row = |n: &egui_kittest::kittest::AccessKitNode<'_>| { + n.role() == egui::accesskit::Role::MenuItem && n.label().is_some_and(|l| l.starts_with(open)) + }; + assert!(h.query_all_by(open_row).next().is_some(), "File lists Open… in {code}"); ok(&mut h, &c, "ui.key", json!({ "key": "Escape" })); h.run_steps(2); } @@ -716,6 +783,7 @@ fn autoscroll_cancels_on_click_wheel_focus_loss_and_pointer_exit() { delta: egui::vec2(0.0, -20.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(2); assert!(!h.state().views[0].auto_scrolling()); @@ -847,6 +915,9 @@ fn state_and_view_options() { #[test] fn japanese_controls_and_search_keep_command_ids() { + if crate::support::skip_without_glyphs("japanese_controls_and_search_keep_command_ids", "ja", "") { + return; + } let (mut h, c) = harness(); ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "ja" })); assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["language"], "ja"); @@ -854,6 +925,8 @@ fn japanese_controls_and_search_keep_command_ids() { assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Convert"); ok(&mut h, &c, "ui.command", json!({ "id": "app.preferences" })); + // The command sets the dialog; its content reaches the accessibility tree in the next pass. + h.run_steps(2); let prefs = ok(&mut h, &c, "ui.inspect", json!({ "query": "表示言語" })); assert!(prefs["count"].as_u64().unwrap() > 0, "{prefs}"); ok(&mut h, &c, "ui.click", json!({ "label": "OK" })); @@ -879,6 +952,7 @@ fn german_preferences_and_search_keep_command_ids() { ok(&mut h, &c, "ui.click", json!({ "label": "Konvertieren" })); assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Convert"); ok(&mut h, &c, "ui.command", json!({ "id": "app.preferences" })); + h.run_steps(2); let prefs = ok(&mut h, &c, "ui.inspect", json!({ "query": "Sprache der Oberfläche" })); assert!(prefs["count"].as_u64().unwrap() > 0, "{prefs}"); let selector = ok(&mut h, &c, "ui.inspect", json!({ "query": "Deutsch" })); @@ -908,6 +982,7 @@ fn ukrainian_preferences_and_search_keep_command_ids() { let (mut h, c) = harness(); ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "uk" })); ok(&mut h, &c, "ui.command", json!({ "id": "app.preferences" })); + h.run_steps(2); for label in ["Мова інтерфейсу", "Українська"] { let found = ok(&mut h, &c, "ui.inspect", json!({ "query": label })); assert!(found["count"].as_u64().unwrap() > 0, "{label}: {found}"); @@ -937,13 +1012,22 @@ fn ukrainian_preferences_and_search_keep_command_ids() { #[test] fn preferences_menu_and_shortcut_allow_switching_interface_languages() { + if crate::support::skip_without_glyphs("preferences_menu_and_shortcut_allow_switching_interface_languages", "ja", "") { + return; + } let (mut h, c) = harness(); ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "ja" })); - ok(&mut h, &c, "ui.click", json!({ "label": "メニュー" })); - ok(&mut h, &c, "ui.click", json!({ "label": "編集 ⏵" })); - let menu = ok(&mut h, &c, "ui.inspect", json!({ "query": "環境設定…" })); - let prefs = menu["widgets"].as_array().unwrap().iter().find(|w| w["clickable"] == true).expect("Preferences menu item"); - ok(&mut h, &c, "ui.click", json!({ "id": prefs["id"] })); + // The menu bar's Edit menu holds Preferences. Titles and rows carry their mnemonic + // ("編集(E)", "環境設定(P)…"), so match by the start of the label. + let menu_row = |text: &'static str| { + move |n: &egui_kittest::kittest::AccessKitNode<'_>| { + n.role() == egui::accesskit::Role::MenuItem && n.label().is_some_and(|l| l.starts_with(text)) + } + }; + h.get_by(menu_row("編集")).click(); + h.run_steps(2); + h.get_by(menu_row("環境設定")).click(); + h.run_steps(2); for (current, next, code) in [("日本語", "English", "en"), ("English", "Українська", "uk"), ("Українська", "日本語", "ja")] { let selector = ok(&mut h, &c, "ui.inspect", json!({ "query": current })); @@ -972,6 +1056,9 @@ fn preferences_menu_and_shortcut_allow_switching_interface_languages() { #[test] fn japanese_dialogs_errors_and_custom_action_names() { + if crate::support::skip_without_glyphs("japanese_dialogs_errors_and_custom_action_names", "ja", "") { + return; + } let (mut h, c) = harness(); ok(&mut h, &c, "ui.set", json!({"key": "language", "value": "ja"})); for (dialog, label) in [ @@ -1012,10 +1099,15 @@ fn japanese_dialogs_errors_and_custom_action_names() { #[test] fn simplified_chinese_about_tabs_and_credit_controls() { + if crate::support::skip_without_glyphs("simplified_chinese_about_tabs_and_credit_controls", "zh-hans", "") { + return; + } let (mut h, c) = harness(); let documents = ok(&mut h, &c, "ui.state", json!({}))["documents"].clone(); ok(&mut h, &c, "ui.set", json!({"key": "language", "value": "zh-hans"})); + settle(&mut h); ok(&mut h, &c, "ui.set", json!({"key": "dialog", "value": "about"})); + settle(&mut h); for label in ["关于", "贡献者", "模型"] { let found = ok(&mut h, &c, "ui.inspect", json!({"query": label})); assert!(found["widgets"].as_array().unwrap().iter().any(|w| w["label"] == label), "{found}"); @@ -1039,10 +1131,15 @@ fn simplified_chinese_about_tabs_and_credit_controls() { #[test] fn simplified_chinese_signature_prompts_and_errors_keep_document_state() { + if crate::support::skip_without_glyphs("simplified_chinese_signature_prompts_and_errors_keep_document_state", "zh-hans", "") { + return; + } let (mut h, c) = harness(); let documents = ok(&mut h, &c, "ui.state", json!({}))["documents"].clone(); ok(&mut h, &c, "ui.set", json!({"key": "language", "value": "zh-hans"})); + settle(&mut h); ok(&mut h, &c, "ui.set", json!({"key": "dialog", "value": "signature"})); + settle(&mut h); let typed = ok(&mut h, &c, "ui.inspect", json!({"query": "请输入您的签名。"})); assert!(typed["count"].as_u64().unwrap() > 0, "{typed}"); // The shell also has a Draw control; the modal's button is registered after the shell. @@ -1270,6 +1367,10 @@ fn screenshots_of_window_and_region() { let region = ok(&mut h, &c, "ui.screenshot", json!({ "region": [10, 20, 110, 70] })); assert_eq!((region["width"].as_f64().unwrap(), region["height"].as_f64().unwrap()), ((100.0 * ppp).round(), (50.0 * ppp).round())); assert!(call(&mut h, &c, "ui.screenshot", json!({ "region": [5, 5, 1, 1] })).is_err()); + // A region that cannot be inside the window is refused at admission, before a frame or slot + // is used, with an actionable message. + let outside = call(&mut h, &c, "ui.screenshot", json!({ "region": [1300, 0, 1500, 10] })).unwrap_err(); + assert!(outside.contains("inside the window"), "{outside}"); } #[test] @@ -1497,135 +1598,72 @@ fn a_plan_scale_picked_or_typed_applies_at_once() { } #[test] -fn docking_pointer_group_float_close_restore_and_document_commands() { - use pdfcraft_ui_egui::docking::Panel; +fn ui_panel_opens_closes_and_sizes_panes_and_survives_restore() { let (mut h, c) = harness(); ok(&mut h, &c, "ui.set", json!({"key":"panel", "value":"bookmarks"})); - h.run_steps(4); - // Tools docked alone has no tab strip (as Acrobat's tool pane), so it is grouped by command. - assert!(h.query_by_role_and_label(egui::accesskit::Role::Tab, "Tools").is_none()); - ok(&mut h, &c, "ui.dock", json!({"operation":"move", "panel":"inspector", "target":"tools", "zone":"center"})); - h.run_steps(4); - assert_eq!(h.state().docking.layout.location(&Panel::Inspector).unwrap().anchor, Some(Panel::Tools)); - // Grouped, Tools has its tab again. - h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tools"); - h.get_by_role_and_label(egui::accesskit::Role::Tab, "Inspector").click_button(egui::PointerButton::Secondary); h.run_steps(3); - h.get_by_label("Float panel").click(); - h.run_steps(5); - let rect = h.state().docking.layout.floating.iter().find(|g| g.panels.contains(&Panel::Inspector)).unwrap().rect; - h.get_by_role_and_label(egui::accesskit::Role::Tab, "Inspector").click_button(egui::PointerButton::Secondary); + let state = ok(&mut h, &c, "ui.panel", json!({"panel":"side", "width": 401.6})); + assert_eq!(state["side_panels"]["side_width"], 401.6f32 as f64); + // Widths are clamped to the panel's limits. + let state = ok(&mut h, &c, "ui.panel", json!({"panel":"tools", "width": 9000})); + assert_eq!(state["side_panels"]["tools_width"], 520.0); + ok(&mut h, &c, "ui.panel", json!({"panel":"tools", "width": 300, "open": false})); h.run_steps(3); - // Scope to the open context menu; the Tools body also has a close icon. - h.get_by_label("Float panel").parent().unwrap().get_by_label("Close panel").click(); - h.run_steps(4); - assert!(!h.state().docking.layout.contains(&Panel::Inspector)); - ok(&mut h, &c, "ui.set", json!({"key":"panel", "value":"bookmarks"})); - h.run_steps(4); - assert_eq!(h.state().docking.layout.floating.iter().find(|g| g.panels.contains(&Panel::Inspector)).unwrap().rect, rect); + assert!(!h.state().left_open); + ok(&mut h, &c, "ui.panel", json!({"panel":"side", "open": false})); + h.run_steps(3); + assert_eq!(h.state().right, None); + assert!(h.query_by_label("This document has no bookmarks.").is_none()); + // Reopening the side panel brings back the one open last. + ok(&mut h, &c, "ui.panel", json!({"panel":"side", "open": true})); + h.run_steps(3); + h.get_by_label("This document has no bookmarks."); + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["side_panels"]["side_panel"], "Bookmarks"); let saved = h.state().persist(); - let expected = serde_json::to_value(&h.state().docking).unwrap(); let (mut reloaded, client) = harness(); reloaded.state_mut().restore(&saved); - reloaded.run_steps(5); - assert_eq!(serde_json::to_value(&reloaded.state().docking).unwrap(), expected); - ok(&mut reloaded, &client, "ui.dock", json!({"operation":"move", "panel":"inspector", "target":"tools", "zone":"center"})); reloaded.run_steps(4); - ok(&mut reloaded, &client, "ui.command", json!({"id":"page.insert_blank"})); - assert_eq!(ok(&mut reloaded, &client, "ui.state", json!({}))["documents"][0]["pages"], 6); - ok(&mut reloaded, &client, "ui.command", json!({"id":"edit.undo"})); + let state = ok(&mut reloaded, &client, "ui.state", json!({}))["side_panels"].clone(); + assert_eq!( + state, + json!({"tools_side":"left", "tools_open":false, "tools_width":300.0, "side_open":true, "side_panel":"Bookmarks", "side_width":401.6f32 as f64}) + ); + // The ui.command form and the reset command. + ok(&mut reloaded, &client, "ui.command", json!({"id":"ui.panel", "params":{"panel":"tools", "open":true}})); + assert!(reloaded.state().left_open); + ok(&mut reloaded, &client, "ui.panel", json!({"panel":"tools", "width": 300})); + ok(&mut reloaded, &client, "ui.set", json!({"key":"tools-side", "value":"right"})); + ok(&mut reloaded, &client, "ui.command", json!({"id":"ui.dock.reset"})); + let state = ok(&mut reloaded, &client, "ui.state", json!({}))["side_panels"].clone(); + assert_eq!((state["tools_side"].clone(), state["tools_width"].clone(), state["side_width"].clone()), (json!("left"), json!(272.0), json!(330.0))); assert_eq!(ok(&mut reloaded, &client, "ui.state", json!({}))["documents"][0]["pages"], 5); } #[test] -fn invalid_docking_requests_are_atomic_and_do_not_change_documents() { +fn invalid_panel_requests_change_nothing() { let (mut h, c) = harness(); - let before = serde_json::to_value(&h.state().docking).unwrap(); + let before = ok(&mut h, &c, "ui.state", json!({}))["side_panels"].clone(); for params in [ - json!({"operation":"close", "panel":"canvas"}), - json!({"operation":"move", "panel":"tools", "target":"canvas", "zone":"center"}), - json!({"operation":"float", "panel":"tools", "rect":[0,0,-1,100]}), - json!({"operation":"move", "panel":"unknown", "target":"tools", "zone":"center"}), + json!({"panel":"canvas"}), + json!({"panel":"inspector", "open": true}), + json!({}), + json!({"panel":"tools", "open":"yes"}), + json!({"panel":"tools", "width":"wide"}), + json!({"panel":"side", "width": 300, "open": 1}), ] { - assert!(call(&mut h, &c, "ui.dock", params).is_err()); - assert_eq!(serde_json::to_value(&h.state().docking).unwrap(), before); + assert!(call(&mut h, &c, "ui.panel", params).is_err()); let state = ok(&mut h, &c, "ui.state", json!({})); + assert_eq!(state["side_panels"], before); assert_eq!(state["documents"][0]["pages"], 5); assert_eq!(state["documents"][0]["dirty"], false); } } +/// #3580 review: a panel a script opens (`app.execMenuItem("ShowHideBookmarks")`) is shown. #[test] -fn docking_renders_real_pdf_default_floating_and_redocked() { - for width in [900.0, 1400.0] { - for scale in [1.0, 2.0] { - let (mut h, c) = harness_pages_at(5, width, scale); - ok(&mut h, &c, "ui.resize", json!({"width":width,"height":900})); - ok(&mut h, &c, "ui.set", json!({"key":"panel","value":"bookmarks"})); - h.state_mut().apply_edit(pdfcraft_engine::Edit::AddBookmark { - parent: vec![], - index: 0, - title: "Overview".into(), - page: 0, - left: None, - top: None, - }); - h.state_mut().apply_edit(pdfcraft_engine::Edit::AddBookmark { - parent: vec![], - index: 1, - title: "Details".into(), - page: 2, - left: None, - top: None, - }); - for state in ["default", "floating", "redocked"] { - if state == "floating" { - ok(&mut h, &c, "ui.dock", json!({"operation":"float", "panel":"inspector", "rect":[160,100,330,480]})); - } else if state == "redocked" { - ok(&mut h, &c, "ui.dock", json!({"operation":"move", "panel":"inspector", "target":"tools", "zone":"center"})); - } - h.run_steps(5); - h.state().docking.validate().unwrap(); - if let Ok(directory) = std::env::var("CRAFT_UI_DOCKING_DIR") { - let _gpu = crate::gpu(); - std::fs::create_dir_all(&directory).unwrap(); - h.render().unwrap().save(format!("{directory}/pdf-{width}-{scale}-{state}.png")).unwrap(); - } - } - } - } -} - -#[test] -fn explicit_panel_requests_reveal_inactive_group_members() { - let (mut h, c) = harness(); - ok(&mut h, &c, "ui.set", json!({"key":"panel", "value":"bookmarks"})); - ok(&mut h, &c, "ui.dock", json!({"operation":"move", "panel":"inspector", "target":"tools", "zone":"center"})); - ok(&mut h, &c, "ui.dock", json!({"operation":"activate", "panel":"tools"})); - ok(&mut h, &c, "ui.command", json!({"id":"form.fields"})); - h.run_steps(4); - h.get_by_label("Fields"); - h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tools").click(); - h.run_steps(4); - // Ordinary frames must preserve the user's selected tab. - h.get_by_label("View more"); - ok(&mut h, &c, "ui.set", json!({"key":"panel", "value":"bookmarks"})); - h.run_steps(4); - h.get_by_label("This document has no bookmarks."); - ok(&mut h, &c, "ui.command", json!({"id":"edit.edit_text"})); - h.run_steps(4); - // The Edit panel's own row: the tool's name is on the floating toolbar too while it is on. - h.get_by_label("Header and footer"); -} - -/// #3580 review: a panel a script opens (`app.execMenuItem("ShowHideBookmarks")`) is shown even -/// when the inspector is a background tab of a custom docking layout. -#[test] -fn a_script_opened_panel_reveals_an_inactive_inspector() { +fn a_script_opened_panel_shows() { let (mut h, c) = harness(); ok(&mut h, &c, "ui.set", json!({"key":"panel", "value":"pages"})); - ok(&mut h, &c, "ui.dock", json!({"operation":"move", "panel":"inspector", "target":"tools", "zone":"center"})); - ok(&mut h, &c, "ui.dock", json!({"operation":"activate", "panel":"tools"})); h.run_steps(3); assert!(h.query_by_label("This document has no bookmarks.").is_none()); let id = h.state().active_ids().unwrap().1; @@ -1635,12 +1673,11 @@ fn a_script_opened_panel_reveals_an_inactive_inspector() { } #[test] -fn home_and_combine_suppress_inspector_without_forgetting_layout_or_choice() { - use pdfcraft_ui_egui::docking::Panel; +fn home_and_combine_hide_the_side_panel_without_forgetting_it() { let (mut h, c) = harness(); ok(&mut h, &c, "ui.set", json!({"key":"panel", "value":"bookmarks"})); - ok(&mut h, &c, "ui.dock", json!({"operation":"float", "panel":"inspector", "rect":[170,120,330,470]})); - let before = serde_json::to_value(&h.state().docking).unwrap(); + ok(&mut h, &c, "ui.panel", json!({"panel":"side", "width": 380})); + let before = ok(&mut h, &c, "ui.state", json!({}))["side_panels"].clone(); for combine in [false, true] { if combine { h.state_mut().open_combine_tab(); @@ -1648,62 +1685,41 @@ fn home_and_combine_suppress_inspector_without_forgetting_layout_or_choice() { h.state_mut().active = None; } h.run_steps(4); - assert!(h.query_by_role_and_label(egui::accesskit::Role::Tab, "Inspector").is_none()); - if let Ok(directory) = std::env::var("CRAFT_UI_DOCKING_DIR") { - let _gpu = crate::gpu(); - std::fs::create_dir_all(&directory).unwrap(); - let state = if combine { "combine" } else { "home" }; - h.render().unwrap().save(format!("{directory}/availability-{state}.png")).unwrap(); - } - assert!(h.state().docking.layout.contains(&Panel::Inspector)); + assert!(h.query_by_label("This document has no bookmarks.").is_none()); assert_eq!(h.state().right, Some(pdfcraft_ui_egui::RightPanel::Bookmarks)); - assert_eq!(serde_json::to_value(&h.state().docking).unwrap(), before); + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["side_panels"], before); h.state_mut().active = Some(0); h.state_mut().combine_tab.focused = false; h.run_steps(4); h.get_by_label("This document has no bookmarks."); - if let Ok(directory) = std::env::var("CRAFT_UI_DOCKING_DIR") { - let _gpu = crate::gpu(); - h.render().unwrap().save(format!("{directory}/availability-return.png")).unwrap(); - } - assert_eq!(serde_json::to_value(&h.state().docking).unwrap(), before); + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["side_panels"], before); } } #[test] -fn malformed_docking_settings_preserve_other_preferences() { +fn malformed_panel_settings_preserve_other_preferences() { let mut app = PdfCraftApp::new(); app.restore(r#"{"docking":{"layout":{"root":null,"floating":[]},"hidden":[]},"default_mode":"edit","flatten_fill_sign":true}"#); assert_eq!(app.default_mode, pdfcraft_ui_egui::Mode::Edit); - app.docking.validate().unwrap(); + assert_eq!(app.side_panels, pdfcraft_ui_egui::side_panels::SidePanels::default()); let saved: Value = serde_json::from_str(&app.persist()).unwrap(); assert_eq!(saved["default_mode"], "edit"); assert_eq!(saved["flatten_fill_sign"], true); + // The old value is dropped on the next save. + assert!(saved.get("docking").is_none()); + assert_eq!(saved["side_panels"]["version"], 1); } #[test] -fn rail_reveals_its_remembered_inactive_inspector_then_closes_the_visible_panel() { - use pdfcraft_ui_egui::docking::Panel; +fn rail_opens_a_panel_then_closes_the_open_one() { let (mut h, c) = harness(); - ok(&mut h, &c, "ui.set", json!({"key":"panel", "value":"bookmarks"})); - ok(&mut h, &c, "ui.dock", json!({"operation":"move", "panel":"inspector", "target":"tools", "zone":"center"})); - ok(&mut h, &c, "ui.dock", json!({"operation":"activate", "panel":"tools"})); - h.run_steps(3); let document = ok(&mut h, &c, "ui.state", json!({}))["documents"].clone(); - let mut preferences: Value = serde_json::from_str(&h.state().persist()).unwrap(); - preferences.as_object_mut().unwrap().remove("docking"); - assert!(h.query_by_label("This document has no bookmarks.").is_none()); h.get_by_role_and_label(egui::accesskit::Role::Button, "Bookmarks").click(); h.run_steps(4); h.get_by_label("This document has no bookmarks."); - let mut after: Value = serde_json::from_str(&h.state().persist()).unwrap(); - after.as_object_mut().unwrap().remove("docking"); - assert_eq!(after, preferences, "revealing a group member only changes layout activation"); - assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["documents"], document); h.get_by_role_and_label(egui::accesskit::Role::Button, "Bookmarks").click(); h.run_steps(4); assert_eq!(h.state().right, None); - assert!(!h.state().docking.layout.contains(&Panel::Inspector)); assert!(h.query_by_label("This document has no bookmarks.").is_none()); assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["documents"], document); } @@ -1726,6 +1742,9 @@ fn control_quit_retains_inactive_fill_sign_typing_after_cancel() { #[test] fn control_quit_retains_blocked_content_typing_after_cancel() { + if crate::support::skip_without_glyphs("control_quit_retains_blocked_content_typing_after_cancel", "en", "Keep 世界") { + return; + } let (mut h, c) = harness(); ok(&mut h, &c, "ui.command", json!({"id":"edit.text"})); h.run_steps(3); @@ -1851,7 +1870,13 @@ fn navigation_wheel(h: &mut Harness<'static, PdfCraftApp>, pos: egui::Pos2, delt vec![ egui::Event::ModifiersChanged(modifiers), egui::Event::PointerMoved(pos), - egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: egui::vec2(0.0, delta), modifiers, phase: egui::TouchPhase::Move }, + egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, delta), + modifiers, + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Unknown, + }, ], ); h.run_steps(20); @@ -1951,7 +1976,7 @@ fn page_size_shows_while_the_pointer_is_in_the_bottom_left_quadrant() { let (mut h, _c) = harness(); let area = h.state().views[0].viewport_rect(); let shown = |h: &Harness<'static, PdfCraftApp>| { - let layer = egui::LayerId::new(egui::Order::Foreground, egui::Id::new("page-size-hint")); + let layer = egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("page-size-hint")); h.ctx.memory(|m| m.areas().visible_last_frame(&layer)) }; let away = area.center() + egui::vec2(300.0, -200.0); @@ -2032,6 +2057,7 @@ fn routine_continuous_scrolling_does_not_add_navigation_history() { delta: egui::vec2(0.0, -1200.0), modifiers: egui::Modifiers::NONE, phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(8); assert!(h.state().views[0].current > 0, "the wheel really moved to a later page"); diff --git a/crates/ui-egui/tests/it/date_format.rs b/crates/ui-egui/tests/it/date_format.rs index 0606e021de..c2dadb28d3 100644 --- a/crates/ui-egui/tests/it/date_format.rs +++ b/crates/ui-egui/tests/it/date_format.rs @@ -133,6 +133,13 @@ fn month_names_follow_the_date_language_or_else_the_interface_language() { #[test] fn a_date_the_pdf_cannot_hold_is_refused_with_a_warning_first() { + if crate::support::skip_without_glyphs( + "a_date_the_pdf_cannot_hold_is_refused_with_a_warning_first", + "en", + "日曜日月曜日火曜日水曜日木曜日金曜日土曜日", + ) { + return; + } let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); diff --git a/crates/ui-egui/tests/it/dialog_chrome.rs b/crates/ui-egui/tests/it/dialog_chrome.rs index 57f549305d..47462fcf2b 100644 --- a/crates/ui-egui/tests/it/dialog_chrome.rs +++ b/crates/ui-egui/tests/it/dialog_chrome.rs @@ -6,11 +6,17 @@ use egui_kittest::kittest::Queryable; use pdfcraft_ui_egui::PdfCraftApp; fn fixture() -> Vec { - let objs = [ + fixture_with_info(None) +} + +/// A one-page file, with `info` as its document information dictionary when given. +fn fixture_with_info(info: Option<&str>) -> Vec { + let mut objs = vec![ "<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>", "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] >>", ]; + objs.extend(info); let mut out = b"%PDF-1.7\n".to_vec(); let mut offsets = Vec::new(); for (i, o) in objs.iter().enumerate() { @@ -22,12 +28,14 @@ fn fixture() -> Vec { 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()); + let info = if info.is_some() { " /Info 4 0 R" } else { "" }; + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R{info} >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); out } +/// Return the rectangle recorded for a dialog card, failing the test when the card is missing. fn card(h: &Harness<'_, PdfCraftApp>, id: &str) -> egui::Rect { - h.ctx.data(|d| d.get_temp(egui::Id::new(id).with("card"))).unwrap_or_else(|| panic!("missing dialog card {id}")) + h.ctx.data(|d| d.get_temp(egui::Id::unique(id).with("card"))).unwrap_or_else(|| panic!("missing dialog card {id}")) } fn protected() -> Vec { @@ -96,3 +104,157 @@ fn document_properties_long_filename_does_not_overflow_card() { let card = card(&h, "dialog"); assert!(card.width() <= 700.0, "dialog card should not blow out horizontally on long filename, width was: {}", card.width()); } + +#[test] +fn document_properties_values_line_up_and_long_fields_wrap() { + let info = "<< /Title (Showcase) /Subject (A specimen document exercising typography, world scripts, vector art, forms and annotations) /Creator (demo-pdf) >>"; + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("doc.pdf", None, fixture_with_info(Some(info))).unwrap(); + app.set_option("dialog", "properties").unwrap(); + app + }); + h.run_steps(4); + let card = card(&h, "dialog"); + let title = h.get_by_role_and_label(Role::TextInput, "Title").rect(); + // The fields reach the card's padding instead of stopping short of it. + assert!(card.right() - title.right() <= 24.0, "title field {title:?} in {card:?}"); + // Plain values start where the fields' text does. + for value in ["doc.pdf", "demo-pdf"] { + // The file name is also the document tab's label. + let v = h.query_all_by_label(value).map(|n| n.rect()).find(|r| card.contains_rect(*r)).unwrap_or_else(|| panic!("{value} in the dialog")); + assert!((v.left() - (title.left() + 10.0)).abs() <= 1.0, "{value} at {v:?}, title field {title:?}"); + } + // A long subject wraps onto a second line rather than running past the field's edge. + let subject = h.get_by_role_and_label(Role::MultilineTextInput, "Subject").rect(); + assert!(subject.height() > title.height() + 8.0, "subject field {subject:?}, title field {title:?}"); +} + +#[test] +fn space_audit_tab_measures_the_file_and_stays_in_the_card() { + use pdfcraft_ui_egui::{Dialog, PropsTab}; + let open = |dialog: Dialog| { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("doc.pdf", None, fixture()).unwrap(); + app.dialog = Some(dialog); + app + }); + h.run_steps(4); + h + }; + let fonts = card(&open(Dialog::Properties(PropsTab::Fonts)), "dialog"); + // The tab is reachable by name, like the other Properties tabs. + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("doc.pdf", None, fixture()).unwrap(); + app.set_option("dialog", "space-audit").unwrap(); + app + }); + h.run_steps(4); + assert_eq!(h.state().dialog, Some(Dialog::Properties(PropsTab::SpaceAudit))); + assert!(!h.state().space_audit.is_empty(), "the tab measures the file when it opens"); + h.get_by_label("Total"); + // Switching tabs keeps the dialog the same size, and the table stays inside its padding, + // in the tab and in the Optimizer's own Audit Space Usage dialog. + let tab = card(&h, "dialog"); + assert!((tab.width() - fonts.width()).abs() < 0.5, "space audit card {tab:?}, fonts card {fonts:?}"); + for h in [h, open(Dialog::AuditSpace)] { + let card = card(&h, "dialog"); + let pct = h.get_by_label("Percentage").rect(); + assert!(pct.right() <= card.right() - 16.0, "Percentage {pct:?} in {card:?}"); + } +} + +#[test] +fn space_audit_tab_measures_again_after_an_edit() { + use pdfcraft_engine::optimize::SpaceCategory; + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("doc.pdf", None, fixture()).unwrap(); + app.set_option("dialog", "space-audit").unwrap(); + app + }); + h.run_steps(4); + let bookmarks = + |h: &Harness<'_, PdfCraftApp>| h.state().space_audit.iter().filter(|r| r.category == SpaceCategory::Bookmarks).map(|r| r.bytes).sum::(); + let before = h.state().space_audit.clone(); + assert_eq!(bookmarks(&h), 0, "the fixture has no outline: {before:?}"); + // Further frames with the file unchanged keep the same measurement. + h.run_steps(2); + assert_eq!(h.state().space_audit, before); + // An edit while the tab is open is measured on the next frame. + h.state_mut().apply_edit(pdfcraft_engine::Edit::AddBookmark { + parent: vec![], + index: 0, + title: "Chapter".into(), + page: 0, + left: None, + top: None, + }); + h.run_steps(2); + assert!(bookmarks(&h) > 0, "the new outline is counted: {:?}", h.state().space_audit); + // The table shows the new row (the rail's Bookmarks button has the same label). + let card = card(&h, "dialog"); + assert!(h.query_all_by_label("Bookmarks").any(|n| card.contains_rect(n.rect())), "a Bookmarks row in {card:?}"); +} + +/// Check that a dialog's Close button follows the content when switching from a taller tab to a shorter one. +#[test] +fn dialog_buttons_follow_the_content_after_a_taller_tab() { + use pdfcraft_ui_egui::{Dialog, PropsTab}; + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("doc.pdf", None, fixture()).unwrap(); + app.dialog = Some(Dialog::Properties(PropsTab::InitialView)); + app + }); + h.run_steps(4); + for name in ["Navigation tab", "Page layout", "Magnification"] { + h.get_by_role_and_label(Role::ComboBox, name); + } + h.get_by_role_and_label(Role::SpinButton, "Open to page"); + h.state_mut().dialog = Some(Dialog::Properties(PropsTab::Description)); + h.run_steps(4); + // The card shrinks back to the shorter tab: Close sits under the last row, not halfway + // down the room the taller tab left behind. + let last = h.get_by_label("PDF producer").rect(); + let close = h.get_by_role_and_label(Role::Button, "Close").rect(); + assert!(close.top() - last.bottom() <= 40.0, "Close {close:?} under the last row {last:?}"); + let card = card(&h, "dialog"); + assert!(card.bottom() - close.bottom() <= 24.0, "Close {close:?} at the foot of {card:?}"); +} + +#[test] +fn page_hover_cursors_stay_off_while_a_dialog_is_open() { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("form.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap(); + // Small enough that the check box sits above the dialog card. + app.set_option("zoom", "100").unwrap(); + app + }); + for _ in 0..30 { + h.run_steps(2); + } + // The middle of the "agree" check box (/Rect [20 310 36 326] on a 300 × 400 page). + let page = h.state().views[0].page_screen_rect(0).expect("on screen"); + let k = page.width() / 300.0; + let check = page.min + egui::vec2(28.0 * k, (400.0 - 318.0) * k); + h.hover_at(check); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand, "the check box takes clicks"); + h.state_mut().set_option("dialog", "security").unwrap(); + h.run_steps(4); + let card = card(&h, "dialog"); + assert!(!card.contains(check), "the check box {check:?} is beside the dialog {card:?}, under its backdrop"); + h.hover_at(check + egui::vec2(1.0, 0.0)); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Default, "nothing under the dialog's backdrop can be clicked"); +} diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index a6523184a9..e5e104d674 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -1087,6 +1087,9 @@ fn save_prompt_fits_the_smallest_window_whatever_the_name() { let size = egui::vec2(820.0, 520.0); for name in names { let start: String = name.chars().take(10).collect(); + if support::skip_without_glyphs(&format!("save_prompt_fits_the_smallest_window_whatever_the_name ({start}…)"), "en", &name) { + continue; + } let mut h = Harness::builder().with_size(size).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); @@ -1368,11 +1371,8 @@ fn edit_menu_names_the_step_to_undo() { let mut h = harness(2, |app| { app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 }); }); - h.get_by_label("Menu").click(); - h.run_steps(2); - h.get_by_label("Edit ⏵").hover(); // submenus open on hover; their labels carry the arrow - h.run_steps(3); - h.get_by_label_contains("Undo Rotate page").click(); // the label includes the shortcut + support::open_menu(&mut h, "Edit"); + support::menu_row(&h, "Undo Rotate page").click(); h.run_steps(3); let app = h.state(); let doc = app.session.get(app.views[0].id).unwrap(); @@ -3573,6 +3573,9 @@ fn an_applied_paragraph_keeps_its_edited_look_until_the_page_is_redrawn() { /// as a form field does; the editor used to close with the error and the typing was lost. #[test] fn a_paragraph_whose_edit_fails_stays_open_with_its_typing() { + if crate::support::skip_without_glyphs("a_paragraph_whose_edit_fails_stays_open_with_its_typing", "en", "Top ก") { + return; + } let bytes = source_font_fixture_with_text("BT /F1 24 Tf 20 220 Td (Top) Tj ET BT /F2 24 Tf 20 120 Td (Bottom) Tj ET"); let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); @@ -3698,7 +3701,7 @@ fn editing_existing_text_in_place() { /// paragraph editor and retypes it as "Chapter One", leaving the editor open. fn edit_tab_with_an_open_paragraph(h: &mut Harness<'static, PdfCraftApp>) { use pdfcraft_ui_egui::{Mode, QuickTool}; - h.get_by_label("Edit").click(); + support::outside_menus(h, "Edit").click(); h.run_steps(3); assert_eq!((h.state().mode, h.state().quick_tool), (Mode::Edit, QuickTool::EditText)); open_the_first_line(h); @@ -6346,6 +6349,7 @@ fn a_paragraph_running_off_its_page_edits_without_covering_the_next_page() { delta: egui::vec2(0.0, -300.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(4); let scrolled = h.state().views[0].page_screen_rect(0).unwrap(); diff --git a/crates/ui-egui/tests/it/editor_taps.rs b/crates/ui-egui/tests/it/editor_taps.rs new file mode 100644 index 0000000000..26a6f1f3d3 --- /dev/null +++ b/crates/ui-egui/tests/it/editor_taps.rs @@ -0,0 +1,347 @@ +//! A finger tap, or a pen tap on Windows, on an editor open over the page acts as a mouse click +//! there. The field editors and the Edit text paragraph editor are drawn over the pages, outside +//! the page pointer, and close on a click away. On the frame egui reports a tap there is no +//! hover position (egui-winit sends `PointerGone` with every touch release), so they must judge +//! where the click landed by the click's own position. + +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use pdfcraft_ui_egui::PdfCraftApp; + +use crate::support::input::touch_tap; + +/// A PDF of `objects`, numbered from 1 with the catalog first. +fn pdf(objects: &[&str]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objects.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", objects.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", objects.len() + 1).as_bytes()); + out +} + +const PAGES: &str = "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>"; +const HELVETICA: &str = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"; + +/// A 300×400 page with a text field `name`, a combo box `country` and a list box `colour` +/// (Red, Green, Blue) 70 pt tall. +fn form_pdf() -> Vec { + pdf(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 0 R] /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >> >>", + PAGES, + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [40 340 260 360] /P 3 0 R /F 4 >>", + "<< /Type /Annot /Subtype /Widget /FT /Ch /Ff 131072 /T (country) /Opt [[(ca) (Canada)] [(fr) (France)] [(no) (Norway)]] /Rect [40 290 260 310] /P 3 0 R /F 4 >>", + "<< /Type /Annot /Subtype /Widget /FT /Ch /T (colour) /Opt [(Red) (Green) (Blue)] /Rect [40 150 200 220] /P 3 0 R /F 4 >>", + HELVETICA, + ]) +} + +/// A 300×400 page with one line of 12 pt text at x 40, y 300. +fn text_pdf() -> Vec { + let line = "BT /F1 12 Tf 40 300 Td (Signed and approved by the committee.) Tj ET"; + let content = format!("<< /Length {} >>\nstream\n{line}\nendstream", line.len() + 1); + pdf(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + PAGES, + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>", + content.as_str(), + HELVETICA, + ]) +} + +/// The app with `pdf` open at 150 % and its first frames run. +fn open(pdf: Vec) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("test.pdf", None, pdf).unwrap(); + app.set_option("left", "closed").unwrap(); + app.set_option("zoom", "150").unwrap(); + app + }); + for _ in 0..30 { + h.run_steps(2); + } + h +} + +/// A mouse click: move, press and release in frames of their own, and the pointer stays there. +/// (`drop_at` also sends `PointerGone` a frame later, as if the mouse left the window.) +fn mouse_click(h: &mut Harness<'_, PdfCraftApp>, p: egui::Pos2) { + h.hover_at(p); + h.run_steps(1); + h.drag_at(p); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(4); +} + +/// Where field `name`'s widget is on screen. +fn field(h: &Harness<'_, PdfCraftApp>, name: &str) -> egui::Rect { + widget(h, name, 0) +} + +/// Where widget `w` of field `name` is on screen. +fn widget(h: &Harness<'_, PdfCraftApp>, name: &str, w: usize) -> egui::Rect { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let f = doc.form.iter().find(|f| f.name == name).unwrap(); + pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, w).expect("on screen") +} + +fn value(h: &Harness<'_, PdfCraftApp>, name: &str) -> Vec { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone() +} + +/// The field whose editor or list is open. +fn focus(h: &Harness<'_, PdfCraftApp>) -> Option { + h.state().views[0].forms.focus.as_ref().map(|f| f.name.clone()) +} + +/// The Green row of the list box: the second, rows being 11.5 pt under a 2 pt inset. +fn green(h: &Harness<'_, PdfCraftApp>) -> egui::Pos2 { + let r = field(h, "colour"); + egui::pos2(r.center().x, r.min.y + 19.0 * r.height() / 70.0) +} + +// --------------------------------------------------------------------------------------------- +// Form fields. + +#[test] +fn mouse_click_opens_a_combo_box() { + // Control for the tap below. + let mut h = open(form_pdf()); + let p = field(&h, "country").center(); + mouse_click(&mut h, p); + assert_eq!(focus(&h).as_deref(), Some("country"), "a click opens the list of choices"); +} + +#[test] +fn touch_tap_opens_a_combo_box_and_picks_a_choice() { + let mut h = open(form_pdf()); + let p = field(&h, "country").center(); + touch_tap(&mut h, p); + assert_eq!(focus(&h).as_deref(), Some("country"), "a tap opens the list of choices, as a click does"); + let france = h.get_by_label("France").rect().center(); + touch_tap(&mut h, france); + assert_eq!(value(&h, "country"), ["fr"], "and a tap on a choice picks it"); +} + +#[test] +fn mouse_click_in_a_list_box_keeps_it_focused() { + // Control for the tap below. + let mut h = open(form_pdf()); + let p = green(&h); + mouse_click(&mut h, p); + assert_eq!((value(&h, "colour"), focus(&h).as_deref()), (vec!["Green".to_string()], Some("colour")), "a click picks the row"); + h.key_press(egui::Key::ArrowDown); + h.run_steps(3); + assert_eq!(value(&h, "colour"), ["Blue"], "and the list keeps the keyboard"); +} + +#[test] +fn touch_tap_in_a_list_box_keeps_it_focused() { + let mut h = open(form_pdf()); + let p = green(&h); + touch_tap(&mut h, p); + assert_eq!( + (value(&h, "colour"), focus(&h).as_deref()), + (vec!["Green".to_string()], Some("colour")), + "a tap picks the row and keeps the list focused, as a click does" + ); + h.key_press(egui::Key::ArrowDown); + h.run_steps(3); + assert_eq!(value(&h, "colour"), ["Blue"], "so the arrow keys go on from it"); +} + +#[test] +fn touch_tap_inside_an_open_text_field_keeps_typing_there() { + // A guard more than a probe: the text editor closes when egui takes its focus away, and + // egui judges that from the tap's own position. + let mut h = open(form_pdf()); + let r = field(&h, "name"); + touch_tap(&mut h, r.center()); + assert_eq!(focus(&h).as_deref(), Some("name"), "a tap opens the field for typing"); + touch_tap(&mut h, r.left_center() + egui::vec2(6.0, 0.0)); + assert_eq!(focus(&h).as_deref(), Some("name"), "a tap inside the open field keeps it open"); + h.event(egui::Event::Text("Ada".into())); + h.run_steps(1); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(value(&h, "name"), ["Ada"], "and the typing lands in it"); +} + +const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; + +/// Adds a date field `due` at x 40..160, y 240..260, its calendar under it. +fn add_date_field(h: &mut Harness<'_, PdfCraftApp>) { + h.state_mut().apply_edit(pdfcraft_engine::Edit::AddField { + page: 0, + rect: [40.0, 240.0, 160.0, 260.0], + kind: pdfcraft_engine::NewField::Date, + name: Some("due".into()), + }); + h.run_steps(4); +} + +/// Next month's calendar heading, and its 15th as the date field writes it. +fn next_month(h: &Harness<'_, PdfCraftApp>) -> (String, String) { + let (y, m, _) = h.state().session.today(); + let (ny, nm) = if m == 12 { (y + 1, 1) } else { (y, m + 1) }; + (format!("{} {ny}", MONTHS[(nm - 1) as usize]), format!("{nm:02}/15/{ny}")) +} + +#[test] +fn mouse_click_on_a_date_fields_calendar_keeps_it_open() { + // Control for the tap below. + let mut h = open(form_pdf()); + add_date_field(&mut h); + let (heading, picked) = next_month(&h); + let p = field(&h, "due").center(); + mouse_click(&mut h, p); + let next = h.get_by_label("›").rect().center(); + mouse_click(&mut h, next); + assert_eq!(focus(&h).as_deref(), Some("due"), "a click on the calendar's next month leaves the field open"); + h.get_by_label(&heading); + let day = h.get_by_label("15").rect().center(); + mouse_click(&mut h, day); + assert_eq!(value(&h, "due"), [picked], "and a click on a day picks it"); +} + +#[test] +fn touch_tap_on_a_date_fields_calendar_keeps_it_open() { + let mut h = open(form_pdf()); + add_date_field(&mut h); + let (heading, picked) = next_month(&h); + let p = field(&h, "due").center(); + touch_tap(&mut h, p); + let next = h.get_by_label("›").rect().center(); + touch_tap(&mut h, next); + assert_eq!(focus(&h).as_deref(), Some("due"), "a tap on the calendar's next month leaves the field open, as a click does"); + h.get_by_label(&heading); + let day = h.get_by_label("15").rect().center(); + touch_tap(&mut h, day); + assert_eq!(value(&h, "due"), [picked], "and a tap on a day picks it"); +} + +/// A 300×400 page with a check box `agree` and a pair of radio buttons `pick`, A and then B to +/// its right, each 20 pt square. A click focuses them, so the keys reach them (#2223). +fn toggles_pdf() -> Vec { + pdf(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] >> >>", + PAGES, + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R 7 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /V /Off /AS /Off /Rect [40 300 60 320] /P 3 0 R /F 4 /AP << /N << /Yes 8 0 R /Off 8 0 R >> >> >>", + "<< /FT /Btn /T (pick) /Ff 32768 /V /Off /Kids [6 0 R 7 0 R] >>", + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [40 240 60 260] /P 3 0 R /F 4 /AP << /N << /A 8 0 R /Off 8 0 R >> >> >>", + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [100 240 120 260] /P 3 0 R /F 4 /AP << /N << /B 8 0 R /Off 8 0 R >> >> >>", + "<< /Type /XObject /Subtype /Form /BBox [0 0 20 20] /Length 0 >>\nstream\n\nendstream", + ]) +} + +#[test] +fn mouse_click_on_a_focused_check_box_keeps_its_focus() { + // Control for the tap below. + let mut h = open(toggles_pdf()); + let p = field(&h, "agree").center(); + mouse_click(&mut h, p); + assert_eq!((value(&h, "agree"), focus(&h).as_deref()), (vec!["Yes".to_string()], Some("agree")), "a click ticks and focuses the box"); + mouse_click(&mut h, p); + assert_eq!(focus(&h).as_deref(), Some("agree"), "a click on the focused box keeps its focus"); + assert!(value(&h, "agree").is_empty(), "and clears it"); +} + +#[test] +fn touch_tap_on_a_focused_check_box_keeps_its_focus() { + let mut h = open(toggles_pdf()); + let p = field(&h, "agree").center(); + mouse_click(&mut h, p); + assert_eq!(focus(&h).as_deref(), Some("agree"), "a click focuses the box"); + touch_tap(&mut h, p); + assert_eq!(focus(&h).as_deref(), Some("agree"), "a tap on the focused box keeps its focus, as a click does"); + assert!(value(&h, "agree").is_empty(), "and clears it"); +} + +#[test] +fn mouse_click_on_a_radio_button_keeps_the_group_focused() { + // Control for the tap below. + let mut h = open(toggles_pdf()); + let (a, b) = (widget(&h, "pick", 0).center(), widget(&h, "pick", 1).center()); + mouse_click(&mut h, a); + assert_eq!((value(&h, "pick"), focus(&h).as_deref()), (vec!["A".to_string()], Some("pick")), "a click selects A and focuses the group"); + mouse_click(&mut h, b); + assert_eq!((value(&h, "pick"), focus(&h).as_deref()), (vec!["B".to_string()], Some("pick")), "a click on B selects it in the focused group"); + h.key_press(egui::Key::ArrowLeft); + h.run_steps(3); + assert_eq!(value(&h, "pick"), ["A"], "so the arrows move within the group"); +} + +#[test] +fn touch_tap_on_a_radio_button_keeps_the_group_focused() { + let mut h = open(toggles_pdf()); + let (a, b) = (widget(&h, "pick", 0).center(), widget(&h, "pick", 1).center()); + mouse_click(&mut h, a); + assert_eq!(focus(&h).as_deref(), Some("pick"), "a click focuses the group"); + touch_tap(&mut h, b); + assert_eq!(focus(&h).as_deref(), Some("pick"), "a tap on B keeps the group focused, as a click does"); + assert_eq!(value(&h, "pick"), ["B"], "and selects B"); + h.key_press(egui::Key::ArrowLeft); + h.run_steps(3); + assert_eq!(value(&h, "pick"), ["A"], "so the arrows move within the group"); +} + +// --------------------------------------------------------------------------------------------- +// Edit text: a click on the Format text panel keeps the paragraph open. These open the Line +// spacing list: a control that changes the paragraph on the click gives it the keyboard back in +// that same frame, which hides a click away. + +/// Opens the line of `text_pdf` for typing with Edit text. +fn edit_paragraph(h: &mut Harness<'_, PdfCraftApp>) { + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let r = h.state().views[0].page_screen_rect(0).expect("page on screen"); + let k = r.width() / 300.0; + let p = r.min + egui::vec2(60.0 * k, (400.0 - 304.0) * k); + mouse_click(h, p); + assert!(h.state().views[0].line_editor.is_some(), "the paragraph opens for typing"); + assert!(h.ctx.memory(|m| m.has_focus(egui::Id::unique("edit-text-line-input"))), "with the keyboard"); +} + +/// The open paragraph's line spacing, if a paragraph is open. +fn line_spacing(h: &Harness<'_, PdfCraftApp>) -> Option { + h.state().views[0].line_editor.as_ref().map(|e| e.extras.line_spacing) +} + +#[test] +fn mouse_click_on_format_text_keeps_the_paragraph_open() { + // Control for the tap below. + let mut h = open(text_pdf()); + edit_paragraph(&mut h); + let spacing = h.get_by_value("Auto").rect().center(); + mouse_click(&mut h, spacing); + assert_eq!(line_spacing(&h), Some(0.0), "a click opens the Line spacing list and leaves the paragraph open"); + let double = h.get_by_label("2").rect().center(); + mouse_click(&mut h, double); + assert_eq!(line_spacing(&h), Some(2.0), "to take the spacing picked"); +} + +#[test] +fn touch_tap_on_format_text_keeps_the_paragraph_open() { + let mut h = open(text_pdf()); + edit_paragraph(&mut h); + let spacing = h.get_by_value("Auto").rect().center(); + touch_tap(&mut h, spacing); + assert_eq!(line_spacing(&h), Some(0.0), "a tap opens the Line spacing list and leaves the paragraph open, as a click does"); + let double = h.get_by_label("2").rect().center(); + touch_tap(&mut h, double); + assert_eq!(line_spacing(&h), Some(2.0), "to take the spacing picked"); +} diff --git a/crates/ui-egui/tests/it/fonts.rs b/crates/ui-egui/tests/it/fonts.rs index 1e569230d8..d897f509bc 100644 --- a/crates/ui-egui/tests/it/fonts.rs +++ b/crates/ui-egui/tests/it/fonts.rs @@ -15,6 +15,19 @@ fn families() -> Vec { vec![FontId::proportional(13.0), FontId::monospace(13.0), theme::medium(13.0), theme::semibold(17.0)] } +/// The bundled faces, with no craft-fonts and no installed system face: 0.37 gates its emoji +/// fonts behind `monochrome_emoji_fonts`, which the app and this test crate explicitly enable. +/// Text symbols must keep working with `PDFCRAFT_SYSTEM_FONTS=0`. +#[test] +fn bundled_monochrome_symbols_have_glyphs() { + let mut fonts = Fonts::new(TextOptions::default(), theme::font_definitions()); + for id in families() { + for text in ["✔", "▲", "▼"] { + assert!(fonts.has_glyphs(&id, text), "{id:?} lacks {text} with default_fonts + monochrome_emoji_fonts"); + } + } +} + /// Lays `text` out in every interface family and returns each galley's width. fn layout_widths(fonts: &mut Fonts, text: &str) -> Vec { let mut view = fonts.with_pixels_per_point(2.0); @@ -280,42 +293,60 @@ fn catalog_han(catalog: &str) -> String { han.into_iter().collect() } -/// In a Chinese interface built without a `Hans` face (every release so far, #826), a Chinese -/// face installed on this machine goes before the Japanese faces, which lack hundreds of the -/// catalog's characters, and draws every Han character of both Chinese catalogs. +/// One Chinese preference against its catalog: the installed face must be in every family, +/// before the Japanese faces, and cover every Han character the catalog uses. +/// +/// Returns false when this machine has no installed face for the preference (or system fonts +/// are off); the caller reports that as a skip. A bundled `Hans` face is never allowed to +/// stand in for Traditional Chinese. +fn installed_chinese_face_covers(cjk: theme::CjkPreference, catalog: &str, name: &str) -> bool { + let defs = theme::installed_font_definitions(cjk); + if !defs.font_data.contains_key(theme::SYSTEM_FALLBACK) { + eprintln!("skipping {name}: no installed Chinese face for {cjk:?} (or PDFCRAFT_SYSTEM_FONTS=0)"); + return false; + } + for (family, stack) in &defs.families { + let system = stack.iter().position(|n| n == theme::SYSTEM_FALLBACK).expect("in every family"); + assert_eq!(stack.iter().filter(|n| *n == theme::SYSTEM_FALLBACK).count(), 1, "{family:?}"); + let own = stack.iter().position(|n| n.starts_with("Inter") || n == "JetBrainsMono").expect("the app's own font"); + assert!(own < system, "{family:?}: {stack:?}"); + if let Some(first_ja) = stack.iter().position(|n| n.starts_with("BIZ UDPGothic")) { + assert!(system < first_ja, "{family:?}: {stack:?}"); + } + } + let han = catalog_han(catalog); + assert!(han.contains('欢') || han.contains('說'), "{cjk:?}"); + let mut fonts = Fonts::new(TextOptions::default(), defs); + for id in families() { + let missing: String = han.chars().filter(|c| !fonts.has_glyphs(&id, &c.to_string())).collect(); + assert!(missing.is_empty(), "{cjk:?} {id:?} lacks {missing}"); + } + true +} + +/// In a Simplified Chinese interface built without a `Hans` face, a Simplified Chinese face +/// installed on this machine goes before the Japanese faces, which lack hundreds of the +/// catalog's characters. #[test] -fn chinese_ui_uses_an_installed_chinese_face() { +fn simplified_chinese_ui_uses_an_installed_hans_face_without_an_embedded_one() { if !pdfcraft_fonts::ui_chinese_fonts().is_empty() { - eprintln!("skipping chinese_ui_uses_an_installed_chinese_face: a Hans face is bundled"); + eprintln!("skipping simplified_chinese_ui_uses_an_installed_hans_face_without_an_embedded_one: a Hans face is bundled"); return; } - for (cjk, catalog) in [ - (theme::CjkPreference::SimplifiedChinese, include_str!("../../src/i18n/zh-hans.tsv")), - (theme::CjkPreference::TraditionalChinese, include_str!("../../src/i18n/zh-hant.tsv")), - ] { - let defs = theme::installed_font_definitions(cjk); - if !defs.font_data.contains_key(theme::SYSTEM_FALLBACK) { - eprintln!("skipping chinese_ui_uses_an_installed_chinese_face: no installed Chinese face (or PDFCRAFT_SYSTEM_FONTS=0)"); - return; - } - for (family, stack) in &defs.families { - let system = stack.iter().position(|n| n == theme::SYSTEM_FALLBACK).expect("in every family"); - assert_eq!(stack.iter().filter(|n| *n == theme::SYSTEM_FALLBACK).count(), 1, "{family:?}"); - let own = stack.iter().position(|n| n.starts_with("Inter") || n == "JetBrainsMono").expect("the app's own font"); - assert!(own < system, "{family:?}: {stack:?}"); - if let Some(first_ja) = stack.iter().position(|n| n.starts_with("BIZ UDPGothic")) { - assert!(system < first_ja, "{family:?}: {stack:?}"); - } - } - let han = catalog_han(catalog); - assert!(han.contains('欢') || han.contains('說'), "{cjk:?}"); - let mut fonts = Fonts::new(TextOptions::default(), defs); - for id in families() { - let missing: String = han.chars().filter(|c| !fonts.has_glyphs(&id, &c.to_string())).collect(); - assert!(missing.is_empty(), "{cjk:?} {id:?} lacks {missing}"); - } - } - // Other languages keep the installed face last, as before. + installed_chinese_face_covers(theme::CjkPreference::SimplifiedChinese, include_str!("../../src/i18n/zh-hans.tsv"), "Simplified"); +} + +/// A Traditional Chinese interface keeps its installed Traditional face before the Japanese +/// faces even when the build embeds a Simplified-only `Hans` face: that face cannot supply +/// Traditional-only characters, and Japanese-first fallback would otherwise win. +#[test] +fn traditional_chinese_ui_keeps_an_installed_hant_face_before_japanese() { + installed_chinese_face_covers(theme::CjkPreference::TraditionalChinese, include_str!("../../src/i18n/zh-hant.tsv"), "Traditional"); +} + +/// Other languages keep the installed face last. +#[test] +fn non_chinese_ui_keeps_the_installed_face_last() { let defs = theme::installed_font_definitions(theme::CjkPreference::Default); for stack in defs.families.values() { assert!(stack.iter().position(|n| n == theme::SYSTEM_FALLBACK).is_none_or(|i| i + 1 == stack.len()), "{stack:?}"); @@ -346,7 +377,7 @@ fn primary_ui_fonts_cover_latin_and_cyrillic_catalogs() { let defs = theme::font_definitions(); assert_eq!(&defs.families[&FontFamily::Monospace][..2], ["JetBrainsMono", "Inter"]); let fallback = &defs.font_data["Inter"]; - let fallback_font = skrifa::FontRef::from_index(&fallback.font, fallback.index).unwrap(); + let fallback_font = skrifa::FontRef::from_index(fallback.font.as_ref().as_ref(), fallback.index).unwrap(); let fallback_charmap = fallback_font.charmap(); for (code, catalog) in [ ("cs", include_str!("../../src/i18n/cs.tsv")), @@ -384,7 +415,7 @@ fn primary_ui_fonts_cover_latin_and_cyrillic_catalogs() { } for name in ["Inter", "Inter-Medium", "Inter-SemiBold", "JetBrainsMono"] { let data = &defs.font_data[name]; - let font = skrifa::FontRef::from_index(&data.font, data.index).unwrap(); + let font = skrifa::FontRef::from_index(data.font.as_ref().as_ref(), data.index).unwrap(); let charmap = font.charmap(); let mut missing: Vec = text.chars().filter(|c| charmap.map(*c).is_none() && (name != "JetBrainsMono" || fallback_charmap.map(*c).is_none())).collect(); diff --git a/crates/ui-egui/tests/it/forms.rs b/crates/ui-egui/tests/it/forms.rs index 436792553a..dcaa7510b5 100644 --- a/crates/ui-egui/tests/it/forms.rs +++ b/crates/ui-egui/tests/it/forms.rs @@ -64,7 +64,7 @@ fn hovering_a_field_shows_its_tooltip() { let mut h = harness(); assert!(h.query_by_label("Your name").is_none(), "no tooltip before the hover"); rest_on_field(&mut h, "name"); - assert!(h.query_by_label("Your name").is_some(), "the name field's /TU shows on hover"); + assert!(h.query_all_by_label("Your name").next().is_some(), "the name field's /TU shows on hover"); rest_on_field(&mut h, "city"); assert!(h.query_by_label("Your name").is_none(), "another field doesn't keep the tooltip"); // Nor shows one of its own: none has been shown since the pointer left `name` (well past the @@ -84,7 +84,7 @@ fn a_read_only_fields_tooltip_shows_on_hover() { let mut h = harness(); set_city(&mut h, pdfcraft_engine::FieldProps { tooltip: Some("Where you live".into()), read_only: Some(true), ..Default::default() }); rest_on_field(&mut h, "city"); - assert!(h.query_by_label("Where you live").is_some()); + assert!(h.query_all_by_label("Where you live").next().is_some()); } /// A `/TU` of only spaces shows no tooltip (no empty box under the pointer). @@ -404,7 +404,7 @@ fn dragging_over_another_fields_text_focuses_it_and_selects_from_the_press() { let doc = s.session.get(s.views[0].id).unwrap(); let f = doc.form.iter().find(|f| f.name == "name").unwrap(); let rect = pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen"); - (rect, egui::Id::new(("form-field", s.views[0].id.0, "name", 0usize))) + (rect, egui::Id::unique(("form-field", s.views[0].id.0, "name", 0usize))) }; // From inside the first word to past the end of the text. let (from, to) = (egui::pos2(rect.left() + 30.0, rect.center().y), egui::pos2(rect.right() - 8.0, rect.center().y)); diff --git a/crates/ui-egui/tests/it/links.rs b/crates/ui-egui/tests/it/links.rs index b08036b11d..f08e9d4356 100644 --- a/crates/ui-egui/tests/it/links.rs +++ b/crates/ui-egui/tests/it/links.rs @@ -42,6 +42,19 @@ fn home_screen_links() { } } +#[test] +fn home_community_card_closes_for_this_run() { + let mut h = harness(|_| {}); + h.get_by_label("Join the ArtCraft community"); + h.get_by_label("Close").click(); + h.run_steps(2); + assert!(h.state().community_card_closed); + assert!(h.query_by_label("Join the ArtCraft community").is_none()); + assert!(h.query_by_label("Join our Discord").is_none()); + // Not remembered: a fresh app shows the card again. + assert!(!PdfCraftApp::new().community_card_closed); +} + #[test] fn about_dialog_shows_the_brand_and_links() { let pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF"; diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index b49642fe04..a5c3192358 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -21,6 +21,7 @@ mod accessibility; mod actions; mod animate; mod attach_picker_target; +mod attachment_row; mod bookmark_position; mod bookmarks; mod bulk_fields; @@ -39,6 +40,7 @@ mod date_format; mod dialog_chrome; mod document_links; mod editing; +mod editor_taps; mod export; mod fill_sign; mod folders; @@ -57,6 +59,7 @@ mod pageboxes; mod panel_snapshots; mod pdf_links; mod pickers; +mod pointer; mod preferences_layout; mod prepare; mod print; @@ -65,6 +68,7 @@ mod reading_colors; mod recovery; mod redact; mod reload; +mod side_panels; mod signing; mod stamps; mod text_select; diff --git a/crates/ui-egui/tests/it/marks.rs b/crates/ui-egui/tests/it/marks.rs index f518dcd1c7..0e77cf2c23 100644 --- a/crates/ui-egui/tests/it/marks.rs +++ b/crates/ui-egui/tests/it/marks.rs @@ -1,5 +1,6 @@ //! Header & footer, watermark and background dialogs in the real shell (egui_kittest). +use crate::support; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; use pdfcraft_engine::MarkKind; @@ -127,7 +128,7 @@ fn backgrounds_and_watermarks_from_a_file() { let mut h = harness(); h.state_mut().execute("edit.background"); h.run_steps(2); - h.get_by_label("File").click(); + support::outside_menus(&h, "File").click(); h.run_steps(2); h.get_by_label("No file chosen"); assert!(h.query_by(|n| n.label().as_deref() == Some("OK") && n.is_disabled()).is_some(), "OK waits for a file"); diff --git a/crates/ui-egui/tests/it/panel_snapshots.rs b/crates/ui-egui/tests/it/panel_snapshots.rs index c9cc1d5901..97fba87777 100644 --- a/crates/ui-egui/tests/it/panel_snapshots.rs +++ b/crates/ui-egui/tests/it/panel_snapshots.rs @@ -87,12 +87,12 @@ fn harness(panel: &'static str) -> Harness<'static, PdfCraftApp> { h } -/// The side panel's contents: the dock node after the Inspector tab. +/// The side panel's contents: the node after the pane's own focus stop, named "Inspector". fn panel<'h>(h: &'h Harness<'static, PdfCraftApp>) -> Node<'h> { - let tab = h.get_by(|n| n.role() == Role::Tab && n.label().as_deref() == Some("Inspector")); - let dock = tab.parent().expect("the Inspector tab sits in a dock node"); - let mut after = dock.children().skip_while(|n| n.accesskit_node().id() != tab.accesskit_node().id()).skip(1); - after.next().expect("the panel follows its tab") + let stop = h.get_by(|n| n.role() != Role::Label && n.label().as_deref() == Some("Inspector")); + let pane = stop.parent().expect("the side panel's focus stop sits in its pane"); + let mut after = pane.children().skip_while(|n| n.accesskit_node().id() != stop.accesskit_node().id()).skip(1); + after.next().expect("the panel follows its focus stop") } /// One line per node that says something: layout containers are skipped (their children move up a diff --git a/crates/ui-egui/tests/it/pickers.rs b/crates/ui-egui/tests/it/pickers.rs index 6aa5515527..f9e46c5f2f 100644 --- a/crates/ui-egui/tests/it/pickers.rs +++ b/crates/ui-egui/tests/it/pickers.rs @@ -963,6 +963,9 @@ impl Drop for AttachmentDir { #[test] fn attachment_save_preserves_bytes_metadata_and_selected_destination() { + if crate::support::skip_without_glyphs("attachment_save_preserves_bytes_metadata_and_selected_destination", "en", "user chosen 資料.bin") { + return; + } let dir = AttachmentDir::new(); let chosen = dir.0.join("user chosen 資料.bin"); let original = attachment_fixture(); diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs new file mode 100644 index 0000000000..f2b6c0e907 --- /dev/null +++ b/crates/ui-egui/tests/it/pointer.rs @@ -0,0 +1,653 @@ +//! What the page's tools see of the pointer. A finger tap, a touch drag and a click whose press +//! and release share a frame act as a mouse click and drag do, and nothing drawn over the page (a +//! dialog, the palette, the quick bar, the chrome over a page scrolled under it) lets the page's +//! cursor, tips or presses through. +//! +//! The ignored tests pin live bugs that later steps of the page pointer router fix: one cursor +//! writer (step 2), one hit test (step 3), and press-time capture with cancel (step 4). + +use egui_kittest::Harness; +use pdfcraft_engine::StampKind; +use pdfcraft_ui_egui::comments::CommentTool; +use pdfcraft_ui_egui::{PdfCraftApp, QuickTool, UnlockPrompt}; + +use crate::support::input::{coalesced_click, double_click, one_frame, touch_drag, touch_tap}; + +/// A PDF of `objects`, numbered from 1 with the catalog first. +fn pdf(objects: &[&str]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objects.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", objects.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", objects.len() + 1).as_bytes()); + out +} + +fn stream(content: &str) -> String { + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len() + 1) +} + +const CATALOG: &str = "<< /Type /Catalog /Pages 2 0 R >>"; +const PAGES: &str = "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>"; +const HELVETICA: &str = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"; +/// One line of 12 pt Helvetica at x 40, y 300. +const LINE: &str = "BT /F1 12 Tf 40 300 Td (Signed and approved by the committee.) Tj ET"; + +/// One 300×400 page with a web link at x 50..250, y 300..350. +fn link_pdf() -> Vec { + pdf(&[ + CATALOG, + PAGES, + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Link /Rect [50 300 250 350] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >>", + ]) +} + +/// A 300×400 page with a paragraph at y≈300 and a link over its first words. +fn linked_text_pdf() -> Vec { + let text = stream("BT /F1 12 Tf 40 300 Td (Read the guide at example.org before you start.) Tj ET"); + pdf(&[ + CATALOG, + PAGES, + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 6 0 R >> >> /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Link /Rect [40 295 160 312] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >>", + text.as_str(), + HELVETICA, + ]) +} + +/// A 300×400 page with a line of text at y≈300 and a rectangle comment over it. +fn comment_on_text_pdf() -> Vec { + let text = stream(LINE); + pdf(&[ + CATALOG, + PAGES, + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 6 0 R >> >> /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Square /Rect [30 290 200 320] /C [1 0 0] /Border [0 0 2] /F 4 /T (Reviewer) >>", + text.as_str(), + HELVETICA, + ]) +} + +/// A 300×400 page with one line of text at x 40, y 300. +fn text_pdf() -> Vec { + let text = stream(LINE); + pdf(&[CATALOG, PAGES, "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>", text.as_str(), HELVETICA]) +} + +/// A 300×400 page with an unchecked check box `agree` at x 100..120, y 200..220. +fn checkbox_pdf() -> Vec { + pdf(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/ZaDb 0 Tf 0 g) /DR << /Font << /ZaDb 5 0 R >> >> >> >>", + PAGES, + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /V /Off /AS /Off /Rect [100 200 120 220] /P 3 0 R /F 4 /MK << /CA (4) >> /DA (/ZaDb 11 Tf 0 g) >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /ZapfDingbats >>", + ]) +} + +/// The app with `pdf` open at `zoom` percent and its first frames run. The left panel stays +/// open only where a test places the page relative to the window's chrome. +fn open(pdf: Vec, zoom: &'static str, left_open: bool) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("test.pdf", None, pdf).unwrap(); + if !left_open { + app.set_option("left", "closed").unwrap(); + } + app.set_option("zoom", zoom).unwrap(); + app + }); + for _ in 0..30 { + h.run_steps(2); + } + h +} + +fn stamp_tool(h: &mut Harness<'_, PdfCraftApp>) { + h.state_mut().quick_tool = QuickTool::Stamp(StampKind::DynApproved); + h.run_steps(4); +} + +fn page(h: &Harness<'_, PdfCraftApp>) -> egui::Rect { + h.state().views[0].page_screen_rect(0).expect("page on screen") +} + +/// The screen point at PDF (x, y) on the 300×400 page. +fn at(h: &Harness<'_, PdfCraftApp>, x: f32, y: f32) -> egui::Pos2 { + let r = page(h); + let k = r.width() / 300.0; + r.min + egui::vec2(x * k, (400.0 - y) * k) +} + +fn cursor_at(h: &mut Harness<'_, PdfCraftApp>, p: egui::Pos2) -> egui::CursorIcon { + h.hover_at(p); + h.run_steps(2); + h.output().platform_output.cursor_icon +} + +/// A mouse click: press and release in frames of their own, as `drag_at` and `drop_at` send them. +fn mouse_click(h: &mut Harness<'_, PdfCraftApp>, p: egui::Pos2) { + h.hover_at(p); + h.run_steps(1); + h.drag_at(p); + h.run_steps(1); + h.drop_at(p); + h.run_steps(4); +} + +/// A mouse drag from `from` by `by`, held over two frames on the way. +fn mouse_drag(h: &mut Harness<'_, PdfCraftApp>, from: egui::Pos2, by: egui::Vec2) { + h.hover_at(from); + h.run_steps(1); + h.drag_at(from); + h.run_steps(1); + h.hover_at(from + by); + h.run_steps(2); + h.drop_at(from + by); + for _ in 0..10 { + h.run_steps(2); + } +} + +fn stamps(h: &Harness<'_, PdfCraftApp>) -> usize { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().info.annotations.iter().filter(|a| a.subtype == "Stamp").count() +} + +fn square(h: &Harness<'_, PdfCraftApp>) -> Option<[f32; 4]> { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().info.annotations.iter().find(|a| a.subtype == "Square").map(|a| a.rect) +} + +fn agree(h: &Harness<'_, PdfCraftApp>) -> Vec { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "agree").unwrap().value.clone() +} + +/// Hovers `p` until `want` accepts the cursor there (text layers and paragraph boxes arrive from +/// worker threads), and returns the last cursor seen. +fn wait_for_cursor(h: &mut Harness<'_, PdfCraftApp>, p: egui::Pos2, want: impl Fn(egui::CursorIcon) -> bool) -> egui::CursorIcon { + let mut cursor = egui::CursorIcon::Default; + for _ in 0..50 { + cursor = cursor_at(h, p); + if want(cursor) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + cursor +} + +// --------------------------------------------------------------------------------------------- +// The cursor promises one thing and a press does another. + +#[test] +#[ignore = "step 2 of the page pointer router: one cursor writer"] +fn stamp_tool_over_a_link_shows_its_crosshair() { + let mut h = open(link_pdf(), "150", false); + stamp_tool(&mut h); + let on_link = at(&h, 150.0, 325.0); + let off_link = at(&h, 150.0, 150.0); + assert_eq!(cursor_at(&mut h, off_link), egui::CursorIcon::Crosshair, "the tool's cursor off the link"); + let cursor = cursor_at(&mut h, on_link); + mouse_click(&mut h, on_link); + assert_eq!((stamps(&h), h.state().pending_link.is_some()), (1, false), "a click places a stamp and does not follow the link"); + assert_eq!(cursor, egui::CursorIcon::Crosshair, "so the cursor is the stamp tool's, not the link hand"); +} + +#[test] +#[ignore = "step 2 of the page pointer router: one cursor writer"] +fn rectangle_tool_over_a_link_shows_its_crosshair() { + let mut h = open(link_pdf(), "150", false); + h.state_mut().quick_tool = QuickTool::Comment(CommentTool::Rectangle); + h.run_steps(4); + let off_link = at(&h, 150.0, 150.0); + let off = cursor_at(&mut h, off_link); + let on_link = at(&h, 150.0, 325.0); + let on = cursor_at(&mut h, on_link); + assert_eq!(on, off, "a drag over the link draws a rectangle, as it does off the link"); +} + +#[test] +#[ignore = "step 2 of the page pointer router: one cursor writer"] +fn edit_text_over_a_linked_word_offers_the_move_arrow() { + let mut h = open(linked_text_pdf(), "150", false); + h.state_mut().quick_tool = QuickTool::EditText; + for _ in 0..30 { + h.run_steps(2); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + // One paragraph, one line: a word outside the link, then a word inside it. + let plain = at(&h, 230.0, 303.0); + let linked = at(&h, 60.0, 303.0); + let off = wait_for_cursor(&mut h, plain, |c| c == egui::CursorIcon::Move); + let on = cursor_at(&mut h, linked); + let id = h.state().views[0].id; + let before = h.state().session.get(id).unwrap().text_blocks(0)[0].rect; + let k = page(&h).width() / 300.0; + mouse_drag(&mut h, linked, egui::vec2(20.0 * k, 0.0)); + let after = h.state().session.get(id).unwrap().text_blocks(0)[0].rect; + assert_eq!(off, egui::CursorIcon::Move, "a closed paragraph offers the four-way arrow (#1717)"); + assert!((after[0] - before[0] - 20.0).abs() < 1.5, "a drag from the linked word moves the paragraph: {before:?} → {after:?}"); + assert_eq!(on, off, "the linked words belong to the same paragraph, and a drag there moves it too"); +} + +#[test] +#[ignore = "step 2 of the page pointer router: one cursor writer"] +fn select_tool_over_a_comment_on_text_offers_the_move_arrow() { + let mut h = open(comment_on_text_pdf(), "150", false); + // Inside the rectangle where there is no text, and inside it over a word. + let blank = at(&h, 120.0, 315.0); + let on_text = at(&h, 60.0, 303.0); + let over_blank = cursor_at(&mut h, blank); + let over_text = wait_for_cursor(&mut h, on_text, |c| c != egui::CursorIcon::Default); + let before = square(&h).expect("the rectangle"); + let k = page(&h).width() / 300.0; + mouse_drag(&mut h, on_text, egui::vec2(20.0 * k, 0.0)); + let after = square(&h).expect("the rectangle"); + assert_eq!(over_blank, egui::CursorIcon::Move, "a movable comment offers the four-way arrow"); + assert!((after[0] - before[0] - 20.0).abs() < 1.5, "a drag from the text moves the rectangle: {before:?} → {after:?}"); + assert_eq!(over_text, egui::CursorIcon::Move, "so it offers the arrow where text lies under it too"); +} + +/// Narrows the paragraph by dragging its left edge 100 pt inward, starting `dx` screen points +/// left of the edge. Returns the cursor shown at the press point and the line count after. +fn narrow_from_left(dx: f32) -> (egui::CursorIcon, usize) { + let mut h = open(text_pdf(), "150", false); + assert!(h.state_mut().execute("edit.edit_text")); + for _ in 0..20 { + h.run_steps(2); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + let id = h.state().views[0].id; + let b = h.state().session.get(id).unwrap().text_blocks(0)[0].clone(); + let k = page(&h).width() / 300.0; + let grip = at(&h, b.rect[0] as f32, (b.rect[1] + b.rect[3]) as f32 / 2.0) - egui::vec2(dx, 0.0); + let cursor = cursor_at(&mut h, grip); + h.drag_at(grip); + h.run_steps(1); + for i in 1..=4 { + h.hover_at(grip + egui::vec2(25.0 * k * i as f32, 0.0)); + h.run_steps(1); + } + h.drop_at(grip + egui::vec2(100.0 * k, 0.0)); + for _ in 0..10 { + h.run_steps(2); + } + (cursor, h.state().session.get(id).unwrap().text_lines(0).len()) +} + +#[test] +#[ignore = "step 3 of the page pointer router: one hit test"] +fn a_paragraph_edge_resizes_wherever_it_shows_the_resize_cursor() { + let (inside_cursor, inside_lines) = narrow_from_left(1.0); + assert_eq!(inside_cursor, egui::CursorIcon::ResizeHorizontal); + assert!(inside_lines > 1, "control: a drag from the edge rewraps the paragraph"); + let (outside_cursor, outside_lines) = narrow_from_left(5.0); + assert_eq!(outside_cursor, egui::CursorIcon::ResizeHorizontal, "the resize cursor shows 5 pt out"); + assert!(outside_lines > 1, "so a drag from there rewraps it too"); +} + +// --------------------------------------------------------------------------------------------- +// Things drawn over the page keep the page's cursor, tips and presses off. + +/// Check that the command palette's frame keeps the default cursor over the page while the page beside it +/// still shows the stamp tool. +#[test] +fn stamp_cursor_stays_off_the_command_palette() { + let mut h = open(link_pdf(), "300", false); + stamp_tool(&mut h); + h.state_mut().palette_open = true; + h.run_steps(4); + let palette = h.ctx.memory(|m| m.area_rect(egui::Id::unique("palette"))).expect("palette shown"); + // On the palette's frame margin, where none of its widgets sets a cursor, over the page. + let p = egui::pos2(palette.left() + 4.0, palette.bottom() - 4.0); + assert!(page(&h).contains(p), "the palette must lie over the page here"); + assert_eq!(cursor_at(&mut h, p), egui::CursorIcon::Default); + // The page beside the palette still shows the tool. + let beside = egui::pos2(palette.left() + 4.0, palette.bottom() + 40.0); + assert!(page(&h).contains(beside)); + assert_eq!(cursor_at(&mut h, beside), egui::CursorIcon::Crosshair); +} + +/// A link beside the open palette shows no tip: tips draw above the palette. +#[test] +fn link_tip_stays_off_while_the_palette_is_open() { + use egui_kittest::kittest::Queryable; + let mut h = open(link_pdf(), "300", false); + h.state_mut().palette_open = true; + h.run_steps(4); + let palette = h.ctx.memory(|m| m.area_rect(egui::Id::unique("palette"))).expect("palette shown"); + let beside = [60.0, 100.0, 150.0, 200.0, 240.0] + .into_iter() + .flat_map(|x| [305.0, 325.0, 345.0].map(|y| at(&h, x, y))) + .find(|p| page(&h).contains(*p) && !palette.expand(40.0).contains(*p)) + .unwrap_or_else(|| panic!("no point of the link beside the palette {palette:?}, page {:?}", page(&h))); + h.hover_at(beside); + h.run_steps(3); + assert!(h.query_by_label("https://example.org/").is_none(), "no tip beside the palette"); + h.state_mut().palette_open = false; + h.hover_at(beside); + h.run_steps(3); + h.get_by_label("https://example.org/"); +} + +/// A panel's resize edge dragged over a link shows neither the link's tip nor its cursor, and the +/// link answers again once the edge is let go. +#[test] +fn a_dragged_panel_edge_passes_over_a_link_without_its_tip() { + use egui::accesskit::Role; + use egui_kittest::kittest::Queryable; + let mut h = open(link_pdf(), "150", false); + h.state_mut().set_option("panel", "comments").unwrap(); + for _ in 0..4 { + h.run_steps(2); + } + let on_link = at(&h, 100.0, 340.0); + assert_eq!(cursor_at(&mut h, on_link), egui::CursorIcon::PointingHand); + h.run_steps(2); + assert!(tip_shown(&h), "the link's tip shows over an uncovered page"); + // Away from the link, then the side panel's resize edge (the rightmost one) is picked up and + // carried over it. + h.hover_at(at(&h, 150.0, 100.0)); + h.run_steps(4); + let tab = h + .get_all_by(|n| n.role() == Role::Splitter && n.label().as_deref() == Some("Resize panels")) + .map(|n| n.rect().center()) + .max_by(|a, b| a.x.total_cmp(&b.x)) + .expect("the side panel has a resize edge") + // The pane's half of the edge: the page, drawn after the panes, takes the other half. + + egui::vec2(2.0, 0.0); + h.hover_at(tab); + h.run_steps(1); + h.drag_at(tab); + h.run_steps(1); + for i in 1..=8 { + h.hover_at(tab + (on_link - tab) * (i as f32 / 8.0)); + h.run_steps(1); + } + for _ in 0..4 { + h.run_steps(2); + assert!(h.ctx.dragged_id().is_some(), "the edge is still being dragged"); + assert!(!tip_shown(&h), "no link tip under a dragged panel edge"); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand); + } + // Back to the start and let go there, which leaves the width as it was. + for i in 1..=8 { + h.hover_at(on_link + (tab - on_link) * (i as f32 / 8.0)); + h.run_steps(1); + } + h.drop_at(tab); + h.run_steps(2); + assert!(h.ctx.dragged_id().is_none()); + assert_eq!(cursor_at(&mut h, on_link), egui::CursorIcon::PointingHand, "the link answers again"); + h.run_steps(2); + assert!(tip_shown(&h), "and shows its tip"); +} + +/// Send one wheel event over the page and let its scrolling settle. +fn wheel(h: &mut Harness<'_, PdfCraftApp>, delta: egui::Vec2) { + h.hover_at(egui::pos2(800.0, 500.0)); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta, + modifiers: egui::Modifiers::NONE, + phase: egui::TouchPhase::Move, + source: egui::MouseWheelSource::Unknown, + }); + for _ in 0..10 { + h.run_steps(2); + } +} + +/// Check that the quick bar's frame keeps the default cursor over the page while the page below it still +/// shows the stamp tool. +#[test] +fn stamp_cursor_stays_off_the_quick_bar() { + let mut h = open(link_pdf(), "400", true); + stamp_tool(&mut h); + // Bring the page under the quick bar's left end. + wheel(&mut h, egui::vec2(-200.0, 0.0)); + let bar = h.ctx.memory(|m| m.area_rect(egui::Id::unique("quick-bar"))).expect("quick bar shown"); + // Inside the bar's frame margin: no button there. + let p = egui::pos2(bar.left() + 3.0, bar.bottom() - 3.0); + assert!(page(&h).contains(p), "the quick bar must lie over the page here"); + assert_eq!(cursor_at(&mut h, p), egui::CursorIcon::Default); + let below = egui::pos2(bar.left() + 3.0, bar.bottom() + 20.0); + assert!(page(&h).contains(below)); + assert_eq!(cursor_at(&mut h, below), egui::CursorIcon::Crosshair); +} + +#[test] +fn stamp_cursor_stays_off_the_chrome_above_a_page_scrolled_under_it() { + let mut h = open(link_pdf(), "400", true); + stamp_tool(&mut h); + let top = page(&h).top(); + wheel(&mut h, egui::vec2(0.0, -300.0)); + let page = page(&h); + // Above where the page started (the viewport's top edge, give or take its margin): the + // toolbar, with the page scrolled up under it. Left of the link, which shows the hand. + let x = page.left() + 20.0; + let p = egui::pos2(x, top - 45.0); + assert!(page.contains(p), "the page must extend under the chrome here"); + // The toolbar keeps its own cursor (a button's hand here); the page's is gone. + assert_ne!(cursor_at(&mut h, p), egui::CursorIcon::Crosshair); + assert_eq!(cursor_at(&mut h, egui::pos2(x, top + 40.0)), egui::CursorIcon::Crosshair); +} + +/// Whether the page's hover tip was drawn in the last frame. +fn tip_shown(h: &Harness<'_, PdfCraftApp>) -> bool { + let tip = egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("canvas-hover")); + h.ctx.memory(|m| m.areas().is_visible(&tip)) +} + +/// Opens the Unlock prompt, a dialog with a backdrop over the whole window, from the next frame. +fn open_unlock_prompt(h: &mut Harness<'_, PdfCraftApp>) { + let doc = h.state().views[0].id; + h.state_mut().unlock_prompt = Some(UnlockPrompt { doc, name: "test.pdf".into(), input: String::new(), error: None, then: None }); +} + +/// Check that the link tip and pointing cursor disappear under the Unlock prompt. +#[test] +fn link_tip_stays_under_the_unlock_prompt() { + let mut h = open(link_pdf(), "300", true); + let page = page(&h); + let k = page.width() / 300.0; + // Near the link's top left corner, clear of the prompt's card in the middle of the window. + let on_link = page.min + egui::vec2(55.0 * k, (400.0 - 345.0) * k); + assert_eq!(cursor_at(&mut h, on_link), egui::CursorIcon::PointingHand); + h.run_steps(2); + assert!(tip_shown(&h), "the link's tip shows over an uncovered page"); + + open_unlock_prompt(&mut h); + h.hover_at(on_link + egui::vec2(1.0, 0.0)); + for _ in 0..4 { + h.run_steps(2); + } + let card = h.ctx.memory(|m| m.area_rect(egui::Id::unique("unlock"))); + assert!(card.is_none_or(|c| !c.contains(on_link)), "the point must be on the backdrop, not the card: {card:?}"); + assert!(!tip_shown(&h), "the link's tip must not float over the Unlock prompt"); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand); +} + +/// Check that moving onto a link's floating tip still reports the link's pointing-hand cursor. +#[test] +fn the_link_tip_lets_the_pointer_through_to_the_link() { + let mut h = open(link_pdf(), "150", false); + let p = at(&h, 100.0, 340.0); + assert_eq!(cursor_at(&mut h, p), egui::CursorIcon::PointingHand); + let tip = h.ctx.memory(|m| m.area_rect(egui::Id::unique("canvas-hover"))).expect("the link's tip"); + // A quick move lands on the tip, drawn beside where the pointer was, and still on the link. + let onto = tip.min + egui::vec2(6.0, 6.0); + assert!(egui::Rect::from_two_pos(at(&h, 50.0, 350.0), at(&h, 250.0, 300.0)).contains(onto), "the point must be on the link"); + h.hover_at(onto); + h.run_steps(1); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand, "the tip must not take the pointer from the link"); +} + +// Dialogs are drawn after the pages, so on the frame one opens egui does not know its layer yet +// and still gives the pages the pointer, and a press there. + +#[test] +fn the_link_tip_stays_under_a_dialog_on_the_frame_it_opens() { + let mut h = open(link_pdf(), "150", false); + let p = at(&h, 100.0, 340.0); + assert_eq!(cursor_at(&mut h, p), egui::CursorIcon::PointingHand); + h.run_steps(2); + assert!(tip_shown(&h), "the link's tip shows over an uncovered page"); + open_unlock_prompt(&mut h); + h.run_steps(1); + assert!(!tip_shown(&h), "the link's tip must not show over the Unlock prompt on the frame it opens"); +} + +#[test] +fn a_click_on_the_frame_a_dialog_opens_places_no_stamp() { + let mut h = open(link_pdf(), "150", false); + stamp_tool(&mut h); + let p = at(&h, 150.0, 150.0); + assert_eq!(cursor_at(&mut h, p), egui::CursorIcon::Crosshair); + open_unlock_prompt(&mut h); + let button = |pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + one_frame(&mut h, [button(true), button(false)]); + h.run_steps(4); + assert_eq!(stamps(&h), 0, "a click in the frame the Unlock prompt opens places no stamp under it"); +} + +#[test] +fn a_double_click_on_a_dialog_backdrop_leaves_page_text_alone() { + let mut h = open(text_pdf(), "150", false); + let word = at(&h, 50.0, 303.0); + // The I-beam shows the page's text layer has arrived. + assert_eq!(wait_for_cursor(&mut h, word, |c| c == egui::CursorIcon::Text), egui::CursorIcon::Text, "the page text is live"); + // Control: without a dialog, a double-click there selects the word. + double_click(&mut h, word); + h.run_steps(4); + assert!(h.state().views[0].selected_text().is_some_and(|t| !t.is_empty()), "control: a double-click selects the word"); + // A click on blank page clears it again. + h.run_steps(30); + let p = at(&h, 150.0, 150.0); + mouse_click(&mut h, p); + h.run_steps(30); + assert!(h.state().views[0].selected_text().is_none_or(|t| t.is_empty()), "cleared before the dialog opens"); + h.state_mut().set_option("dialog", "shortcuts").unwrap(); + h.run_steps(6); + assert!(h.state().dialog.is_some(), "a modal dialog is open"); + // On the dialog's backdrop, where the page's first word lies underneath. + double_click(&mut h, word); + h.run_steps(4); + let selected = h.state().views[0].selected_text(); + assert!(selected.as_deref().is_none_or(str::is_empty), "a double-click on a dialog selects nothing on the page under it: {selected:?}"); +} + +// --------------------------------------------------------------------------------------------- +// Taps, touch drags and clicks within one frame. On the frame a touch (or a Windows pen) lifts, +// egui-winit has sent `PointerGone`, so there is no hover position; egui still reports the click. + +#[test] +fn mouse_click_follows_a_link() { + // Control for the tap below. + let mut h = open(link_pdf(), "150", false); + let p = at(&h, 150.0, 325.0); + mouse_click(&mut h, p); + assert!(h.state().pending_link.is_some(), "a click on a link asks to open it"); +} + +#[test] +fn touch_tap_follows_a_link() { + let mut h = open(link_pdf(), "150", false); + let p = at(&h, 150.0, 325.0); + touch_tap(&mut h, p); + assert!(h.state().pending_link.is_some(), "a tap on a link asks to open it, as a click does"); +} + +#[test] +fn coalesced_click_follows_a_link() { + let mut h = open(link_pdf(), "150", false); + let p = at(&h, 150.0, 325.0); + coalesced_click(&mut h, p); + assert!(h.state().pending_link.is_some(), "a click whose press and release share a frame follows the link"); +} + +#[test] +fn touch_tap_places_a_stamp() { + let mut h = open(link_pdf(), "150", false); + stamp_tool(&mut h); + let p = at(&h, 150.0, 150.0); + touch_tap(&mut h, p); + assert_eq!(stamps(&h), 1, "a tap with the stamp tool places the stamp"); +} + +#[test] +fn coalesced_click_places_a_stamp() { + let mut h = open(link_pdf(), "150", false); + stamp_tool(&mut h); + let p = at(&h, 150.0, 150.0); + coalesced_click(&mut h, p); + assert_eq!(stamps(&h), 1, "a click whose press and release share a frame places the stamp"); +} + +#[test] +fn mouse_click_ticks_a_check_box() { + // Control for the tap below. + let mut h = open(checkbox_pdf(), "150", false); + let p = at(&h, 110.0, 210.0); + mouse_click(&mut h, p); + assert_eq!(agree(&h), ["Yes"], "a click ticks the box"); +} + +#[test] +fn touch_tap_ticks_a_check_box() { + let mut h = open(checkbox_pdf(), "150", false); + let p = at(&h, 110.0, 210.0); + touch_tap(&mut h, p); + assert_eq!(agree(&h), ["Yes"], "a tap ticks the box, as a click does"); +} + +#[test] +fn touch_drag_moves_a_comment_and_leaves_no_gesture_behind() { + let mut h = open(comment_on_text_pdf(), "150", false); + let k = page(&h).width() / 300.0; + let before = square(&h); + // A finger drags the rectangle 30 pt right, from a blank spot inside it. + let from = at(&h, 120.0, 315.0); + touch_drag(&mut h, from, from + egui::vec2(30.0 * k, 0.0)); + let after_touch = square(&h); + assert_ne!(after_touch, before, "the touch drag moves the rectangle"); + // Later, a mouse drag on empty page far below it. + let p = at(&h, 150.0, 100.0); + mouse_drag(&mut h, p, egui::vec2(10.0 * k, 10.0 * k)); + assert_eq!(square(&h), after_touch, "an unrelated drag elsewhere leaves the rectangle where the touch put it"); +} + +// --------------------------------------------------------------------------------------------- +// Ending a gesture. + +#[test] +#[ignore = "step 4 of the page pointer router: press-time capture and cancel"] +fn escape_cancels_a_signature_rectangle() { + let mut h = open(link_pdf(), "150", false); + h.state_mut().quick_tool = QuickTool::SignArea { certify: false }; + h.run_steps(4); + let (a, b) = (at(&h, 60.0, 200.0), at(&h, 200.0, 120.0)); + h.hover_at(a); + h.run_steps(1); + h.drag_at(a); + h.run_steps(1); + h.hover_at(b); + h.run_steps(2); + // Escape while the button is still held. + h.key_press(egui::Key::Escape); + h.run_steps(4); + let s = h.state(); + assert_eq!(s.quick_tool, QuickTool::SignArea { certify: false }, "Escape abandons the rectangle; the tool stays"); + assert!(s.dialog.is_none(), "and no signing dialog opens: {:?}", s.dialog); +} diff --git a/crates/ui-egui/tests/it/prepare.rs b/crates/ui-egui/tests/it/prepare.rs index 936b847d82..8d565ab696 100644 --- a/crates/ui-egui/tests/it/prepare.rs +++ b/crates/ui-egui/tests/it/prepare.rs @@ -813,16 +813,16 @@ fn actions_tab_shows_a_chain_and_keeps_it_when_the_first_action_is_replaced() { }; open(&mut h); h.get_by_label("Run a JavaScript, then 1 more action"); - // The first Edit is the menu bar's; the rows' follow (Mouse Up, On Blur). - assert_eq!(h.get_all_by_label("Edit").count(), 3); - h.get_all_by_label("Edit").nth(1).unwrap().click(); + // The menu bar's Edit menu and the Edit tab come first; the rows' follow (Mouse Up, On Blur). + assert_eq!(h.get_all_by_label("Edit").count(), 4); + h.get_all_by_label("Edit").nth(2).unwrap().click(); h.run_steps(2); assert_eq!(h.state().field_props.as_ref().unwrap().editing, Some(FieldTrigger::MouseUp)); // While one script is open, no other Edit can overwrite it. - assert!(h.get_all_by_label("Edit").skip(1).all(|n| n.accesskit_node().is_disabled())); + assert!(h.get_all_by_label("Edit").skip(2).all(|n| n.accesskit_node().is_disabled())); h.get_by_label("Discard").click(); h.run_steps(2); - assert!(h.get_all_by_label("Edit").skip(1).all(|n| !n.accesskit_node().is_disabled())); + assert!(h.get_all_by_label("Edit").skip(2).all(|n| !n.accesskit_node().is_disabled())); // Replace the Mouse Up script with a link: the chained link stays after it. { let d = h.state_mut().field_props.as_mut().unwrap(); @@ -1028,9 +1028,7 @@ fn escape_in_a_name_field_leaves_the_field_not_the_table() { let mut h = harness(); draw_table(&mut h); // The app's own text boxes (zoom, page number) are inputs too: take the one in the window. - let window = h.get_by_label("Text Field Table").rect(); - let field = - h.query_all(by().role(Role::TextInput)).map(|n| n.rect()).find(|r| window.contains(r.center())).expect("the first column's name field"); + let field = h.get_by_role_and_label(Role::TextInput, "Column 1").rect(); h.hover_at(field.center()); h.drag_at(field.center()); h.run_steps(1); diff --git a/crates/ui-egui/tests/it/print.rs b/crates/ui-egui/tests/it/print.rs index 21aeca1286..81025e756f 100644 --- a/crates/ui-egui/tests/it/print.rs +++ b/crates/ui-egui/tests/it/print.rs @@ -23,6 +23,15 @@ fn harness_with_settings(settings: Option) -> Harness<'static, PdfCraftA h } +/// The page-range field is labelled by the Pages radio button rather than floating unnamed. +#[test] +fn the_page_range_field_is_named_for_screen_readers() { + let mut h = harness(); + assert!(h.state_mut().execute("print.dialog")); + h.run_steps(3); + h.get_by_role_and_label(Role::TextInput, "Pages"); +} + #[test] fn current_print_page_saves_the_viewed_page_when_labels_restart() { // Five distinct painted pages, labelled i, ii, iii, 1, 2. @@ -62,7 +71,7 @@ fn current_print_page_saves_the_viewed_page_when_labels_restart() { h.run_steps(3); assert!(h.state_mut().execute("print.dialog")); h.run_steps(3); - h.get_by_label("Current page").click(); + h.get_by_role_and_label(Role::RadioButton, "Current page").click(); h.run_steps(3); assert_eq!(h.state().print_draft.current_page, current); h.get_by_label("Sheet 1 of 1"); @@ -278,8 +287,8 @@ fn printers_listed_in_the_background_preselect_the_default_unless_one_was_picked h.state_mut().printer_source = Some(Arc::new(move || { let _open = wait.lock(); vec![ - Printer { name: "Office".into(), default: false, two_sided: None }, - Printer { name: "Front Desk".into(), default: true, two_sided: None }, + Printer { name: "Office".into(), default: false, two_sided: None, description: String::new() }, + Printer { name: "Front Desk".into(), default: true, two_sided: None, description: String::new() }, ] })); assert!(h.state_mut().execute("print.dialog")); @@ -335,7 +344,10 @@ fn the_picked_printer_is_kept_for_the_next_print_while_it_is_listed() { let source = listed.clone(); h.state_mut().printer_source = Some(Arc::new(move || { let names = source.lock().map(|n| n.clone()).unwrap_or_default(); - names.into_iter().map(|name| Printer { name: name.into(), default: name == "Front Desk", two_sided: None }).collect() + names + .into_iter() + .map(|name| Printer { name: name.into(), default: name == "Front Desk", two_sided: None, description: String::new() }) + .collect() })); let open = |h: &mut Harness<'static, PdfCraftApp>| { h.state_mut().dialog = None; @@ -422,8 +434,8 @@ fn a_printer_without_two_sided_printing_points_to_its_properties() { let mut h = harness(); h.state_mut().printer_source = Some(std::sync::Arc::new(|| { vec![ - Printer { name: "Office PCL".into(), default: true, two_sided: Some(true) }, - Printer { name: "Office PS".into(), default: false, two_sided: Some(false) }, + Printer { name: "Office PCL".into(), default: true, two_sided: Some(true), description: String::new() }, + Printer { name: "Office PS".into(), default: false, two_sided: Some(false), description: String::new() }, ] })); assert!(h.state_mut().execute("print.dialog")); @@ -885,10 +897,10 @@ fn a_booklet_decides_two_sided_itself() { h.run_steps(3); h.state_mut().print_draft.duplex = Some(Duplex::LongEdge); h.run_steps(1); - assert!(h.query_by_label("Two-sided:").is_some()); + assert!(h.query_by_role_and_label(Role::ComboBox, "Two-sided:").is_some()); h.get_by_label("Booklet").click(); h.run_steps(2); - assert!(h.query_by_label("Two-sided:").is_none(), "hidden while Booklet is chosen"); + assert!(h.query_by_role_and_label(Role::ComboBox, "Two-sided:").is_none(), "hidden while Booklet is chosen"); let d = &h.state().print_draft; assert_eq!(d.duplex, Some(Duplex::LongEdge), "the Two-sided choice is kept"); assert_eq!(d.job("x").duplex, Some(Duplex::ShortEdge), "both sides flip on the short edge"); @@ -898,6 +910,6 @@ fn a_booklet_decides_two_sided_itself() { } h.get_by_label("Size").click(); h.run_steps(2); - assert!(h.query_by_label("Two-sided:").is_some()); + assert!(h.query_by_role_and_label(Role::ComboBox, "Two-sided:").is_some()); assert_eq!(h.state().print_draft.job("x").duplex, Some(Duplex::LongEdge), "back to the choice"); } diff --git a/crates/ui-egui/tests/it/recovery.rs b/crates/ui-egui/tests/it/recovery.rs index 3e3f3a593c..2d8258eeda 100644 --- a/crates/ui-egui/tests/it/recovery.rs +++ b/crates/ui-egui/tests/it/recovery.rs @@ -173,6 +173,8 @@ fn encrypted(bytes: Vec, password: &str) -> Vec { pdfcraft_cos::write_full(&doc, &Default::default()).unwrap() } +/// Check that an encrypted document is autosaved without plaintext and recovered only after the password is +/// entered. #[test] fn encrypted_documents_are_autosaved_encrypted_and_recovered_with_the_password() { let bytes = encrypted(fixture(2), "pw"); @@ -186,6 +188,13 @@ fn encrypted_documents_are_autosaved_encrypted_and_recovered_with_the_password() h.get_by_label_contains("password-protected"); h.get_by_label("Recover").click(); support::wait_until(&mut h, "recovery password prompt", |app| app.password_prompt.is_some()); + // The prompt is laid out in the pass after the one that set it. + for _ in 0..10 { + if h.query_by_role(Role::PasswordInput).is_some() { + break; + } + h.run_steps(1); + } let field = h.get_by_role(Role::PasswordInput); field.focus(); field.type_text("pw"); @@ -320,6 +329,8 @@ fn missing_corrupt_or_mismatched_metadata_recovers_the_snapshot_without_an_origi } } +/// Check that an encrypted snapshot without metadata still asks for its password and is recovered without +/// rewriting its bytes. #[test] fn an_encrypted_snapshot_without_metadata_still_requires_its_password() { let s = store("encrypted-orphan"); @@ -330,6 +341,12 @@ fn an_encrypted_snapshot_without_metadata_still_requires_its_password() { let mut h = harness(s.clone()); h.get_by_label("Recover").click(); support::wait_until(&mut h, "orphan recovery password prompt", |app| app.password_prompt.is_some()); + for _ in 0..10 { + if h.query_by_role(Role::PasswordInput).is_some() { + break; + } + h.run_steps(1); + } assert!(h.state().views.is_empty()); let field = h.get_by_role(Role::PasswordInput); field.focus(); diff --git a/crates/ui-egui/tests/it/side_panels.rs b/crates/ui-egui/tests/it/side_panels.rs new file mode 100644 index 0000000000..2436d41b61 --- /dev/null +++ b/crates/ui-egui/tests/it/side_panels.rs @@ -0,0 +1,133 @@ +//! Side panels: the Tools pane, the rail and the open side panel. + +use egui::accesskit::Role; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use pdfcraft_ui_egui::PdfCraftApp; + +const FIXTURE: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 400] >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +const WIDTH: f32 = 1400.0; +const NO_BOOKMARKS: &str = "This document has no bookmarks."; + +fn harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(WIDTH, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("doc.pdf", None, FIXTURE.to_vec()).unwrap(); + setup(&mut app); + app + }); + h.run_steps(6); + h +} + +fn rail(h: &Harness<'static, PdfCraftApp>) -> egui::Rect { + h.get_by_role_and_label(Role::Button, "Bookmarks").rect() +} + +fn side(h: &Harness<'static, PdfCraftApp>) -> egui::Rect { + h.get_by_label(NO_BOOKMARKS).rect() +} + +/// The Tools pane's All tools header button. +fn tools(h: &Harness<'static, PdfCraftApp>) -> egui::Rect { + h.get_by_label("Expand all tools").rect() +} + +#[test] +fn rail_is_outermost_then_tools_then_the_side_panel_then_the_page() { + // Tools on the left: the page between Tools and the side panel, the rail at the right edge. + let h = harness(|app| app.set_option("panel", "bookmarks").unwrap()); + let (r, s, t) = (rail(&h), side(&h), tools(&h)); + assert!(r.right() > WIDTH - 12.0, "{r:?}"); + assert!(s.right() < r.left() && s.left() > WIDTH - 48.0 - 340.0, "{s:?} {r:?}"); + assert!(t.left() < 48.0, "{t:?}"); + // Tools on the right: rail, Tools, side panel, page. + let h = harness(|app| { + app.set_option("tools-side", "right").unwrap(); + app.set_option("panel", "bookmarks").unwrap(); + }); + let (r, s, t) = (rail(&h), side(&h), tools(&h)); + assert!(r.right() > WIDTH - 12.0, "{r:?}"); + assert!(t.right() < r.left() && t.left() > WIDTH - 48.0 - 280.0, "{t:?} {r:?}"); + assert!(s.right() < t.left() && s.left() > WIDTH - 48.0 - 280.0 - 340.0, "{s:?} {t:?}"); +} + +#[test] +fn read_mode_shows_the_side_panel_without_tools() { + let mut h = harness(|app| app.set_option("panel", "bookmarks").unwrap()); + h.state_mut().set_option("mode", "read").unwrap(); + h.run_steps(4); + assert!(h.query_by_label("Expand all tools").is_none()); + assert!(side(&h).right() < rail(&h).left() && side(&h).left() > WIDTH / 2.0); + // The rail closes and opens it there too. + h.get_by_role_and_label(Role::Button, "Bookmarks").click(); + h.run_steps(4); + assert!(h.query_by_label(NO_BOOKMARKS).is_none()); + h.get_by_role_and_label(Role::Button, "Bookmarks").click(); + h.run_steps(4); + assert!(side(&h).right() < rail(&h).left()); +} + +#[test] +fn a_dragged_width_is_remembered_across_restarts() { + let mut h = harness(|app| app.set_option("panel", "bookmarks").unwrap()); + let before = h.state().side_panels.side_width; + let side_left = side(&h).left(); + // The side panel's resize edge: the one right of the page, left of the panel's text. + let edge = h + .get_all_by_label("Resize panels") + .map(|n| n.rect().center()) + .filter(|c| c.x < side_left && c.x > WIDTH / 2.0) + .min_by(|a, b| (side_left - a.x).total_cmp(&(side_left - b.x))) + .expect("the side panel has a resize edge") + // The pane's half of the edge: the page, drawn after the panes, takes the other half. + + egui::vec2(2.0, 0.0); + h.hover_at(edge); + h.run_steps(1); + h.drag_at(edge); + h.run_steps(1); + for i in 1..=4 { + h.hover_at(edge - egui::vec2(10.0 * i as f32, 0.0)); + h.run_steps(1); + } + h.drop_at(edge - egui::vec2(40.0, 0.0)); + h.run_steps(3); + let after = h.state().side_panels.side_width; + // The pointer was pressed 2 pt inside the panel, and the edge follows the pointer. + assert!((after - (before + 40.0 - 2.0)).abs() <= 1.0, "{before} -> {after}"); + let saved = h.state().persist(); + let mut restored = PdfCraftApp::new(); + restored.restore(&saved); + assert_eq!(restored.side_panels.side_width, after); +} + +#[test] +fn f4_toggles_the_side_panel_and_shift_f4_the_tools_panel() { + let mut h = harness(|app| app.set_option("panel", "bookmarks").unwrap()); + h.get_by_label(NO_BOOKMARKS); + h.key_press(egui::Key::F4); + h.run_steps(3); + assert_eq!(h.state().right, None); + assert!(h.query_by_label(NO_BOOKMARKS).is_none()); + // F4 again reopens the panel that was open, not the default. + h.key_press(egui::Key::F4); + h.run_steps(3); + h.get_by_label(NO_BOOKMARKS); + assert!(h.state().left_open); + h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::F4); + h.run_steps(3); + assert!(!h.state().left_open); + assert!(h.query_by_label("Expand all tools").is_none()); + // Shift+F4 leaves the side panel alone. + h.get_by_label(NO_BOOKMARKS); + h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::F4); + h.run_steps(3); + h.get_by_label("Expand all tools"); +} diff --git a/crates/ui-egui/tests/it/signing.rs b/crates/ui-egui/tests/it/signing.rs index 0050f82b19..62d97d4e58 100644 --- a/crates/ui-egui/tests/it/signing.rs +++ b/crates/ui-egui/tests/it/signing.rs @@ -1,5 +1,6 @@ //! Use a certificate in the real shell: draw a signature, create a digital ID, sign, validate. +use crate::support; use egui::{Pos2, pos2}; use egui_kittest::Harness; use egui_kittest::kittest::{NodeT, Queryable}; @@ -128,14 +129,14 @@ fn drawing_a_signature_creating_an_id_signing_and_trusting() { field(&h, "Digital ID password").click(); h.run_steps(1); field(&h, "Digital ID password").type_text("nope"); - h.get_by_label("Sign").click(); + support::outside_menus(&h, "Sign").click(); h.run_steps(3); h.get_by_label("The password is incorrect."); h.state_mut().sign_draft.as_mut().unwrap().password = "secret12".into(); field(&h, "Reason").click(); h.run_steps(1); field(&h, "Reason").type_text("I approve"); - h.get_by_label("Sign").click(); + support::outside_menus(&h, "Sign").click(); h.run_steps(6); let s = h.state(); @@ -275,7 +276,7 @@ fn certifying_without_a_visible_signature() { h.get_by_label("Certify as \"Test Signer EC\""); h.get_by_label("Permitted actions after certifying"); h.state_mut().sign_draft.as_mut().unwrap().password = "test".into(); - h.get_by_label("Sign").click(); + support::outside_menus(&h, "Sign").click(); h.run_steps(6); let s = h.state(); assert_eq!(s.sign_draft.as_ref().and_then(|d| d.error.clone()), None); @@ -361,7 +362,7 @@ fn clicking_an_empty_signature_field_signs_it() { d.password = "test".into(); } h.run_steps(2); - h.get_by_label("Sign").click(); + support::outside_menus(&h, "Sign").click(); h.run_steps(6); let s = h.state(); let sig = s.session.get(s.views[1].id).unwrap().signatures.iter().find(|x| x.field == "Approver").cloned().unwrap(); @@ -398,7 +399,7 @@ fn the_fill_and_sign_signature_or_an_image_shows_in_a_digital_signature() { h.get_by_label("Fill & Sign signature").click(); h.run_steps(2); assert_eq!(h.state().sign_draft.as_ref().unwrap().graphic, SignGraphic::FillSign); - h.get_by_label("Sign").click(); + support::outside_menus(&h, "Sign").click(); h.run_steps(6); let s = h.state(); assert_eq!(s.sign_draft.as_ref().and_then(|d| d.error.clone()), None); @@ -418,7 +419,7 @@ fn the_fill_and_sign_signature_or_an_image_shows_in_a_digital_signature() { h.state_mut().sign_draft.as_mut().unwrap().graphic = SignGraphic::Image(image.clone()); h.run_steps(2); h.get_by_label("Choose image…"); - h.get_by_label("Sign").click(); + support::outside_menus(&h, "Sign").click(); h.run_steps(6); let s = h.state(); assert_eq!(s.sign_draft.as_ref().and_then(|d| d.error.clone()), None); @@ -534,7 +535,7 @@ fn windows_store_identity_does_not_ask_for_a_file_password() { h.get_by_label("Sign as \"Store signer\""); assert_eq!(h.query_all_by_label("Digital ID password").count(), 0); h.get_by_label("The key is in the Windows certificate store, which may ask to allow PdfCraft to use it."); - h.get_by_label("Sign").click(); + support::outside_menus(&h, "Sign").click(); h.run_steps(3); assert_eq!(h.state().dialog, Some(Dialog::Sign)); assert!(h.state().sign_draft.as_ref().unwrap().error.as_ref().unwrap().contains("Windows certificate store")); @@ -679,7 +680,7 @@ fn signing_preserves_destination_opening_attributes() { let d = h.state_mut().sign_draft.as_mut().unwrap(); (d.selected, d.step, d.password) = (Some(entry), SignStep::SignAs, "test".into()); h.run_steps(2); - h.get_by_label("Sign").click(); + support::outside_menus(&h, "Sign").click(); h.run_steps(6); assert!(h.state().session.get(h.state().views[0].id).unwrap().signatures.iter().any(|s| s.signed)); assert_eq!(xattr::get(&dest, "com.apple.LaunchServices.OpenWith").unwrap(), Some(b"existing binding".to_vec())); diff --git a/crates/ui-egui/tests/it/support/input.rs b/crates/ui-egui/tests/it/support/input.rs new file mode 100644 index 0000000000..0030c463d4 --- /dev/null +++ b/crates/ui-egui/tests/it/support/input.rs @@ -0,0 +1,65 @@ +//! Pointer input in the frames the platform delivers it in. +//! +//! `Harness::step` runs one frame for every queued event, each 0.25 s after the last, so input +//! sent through `Harness::event` (or `drag_at` and `drop_at`) never shares a frame. Real input +//! does: egui-winit turns each touch (and on Windows each pen stroke, which winit reports as +//! touch) into a `Touch` event plus pointer events in one frame, and sends `PointerGone` with +//! every release, so `hover_pos()` is `None` on the frame egui reports the tap. A slow frame +//! can also hold a whole click. These helpers push each frame's events into +//! `Harness::input_mut` and step once. +use egui::{Event, Modifiers, PointerButton, Pos2, TouchDeviceId, TouchId, TouchPhase}; +use egui_kittest::Harness; + +/// Runs one frame with all of `events` in it, in order. +pub fn one_frame(h: &mut Harness<'_, S>, events: impl IntoIterator) { + h.input_mut().events.extend(events); + h.step(); +} + +fn button(pos: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE } +} + +fn touch(phase: TouchPhase, pos: Pos2) -> Event { + Event::Touch { device_id: TouchDeviceId(1), id: TouchId(1), phase, pos, force: None } +} + +/// A finger tap at `p`, or a pen tap on Windows: the touch starts (touch, move, press in one +/// frame), and a frame later it ends (touch, release, `PointerGone` in one frame). +pub fn touch_tap(h: &mut Harness<'_, S>, p: Pos2) { + one_frame(h, [touch(TouchPhase::Start, p), Event::PointerMoved(p), button(p, true)]); + h.run_steps(1); + one_frame(h, [touch(TouchPhase::End, p), button(p, false), Event::PointerGone]); + h.run_steps(4); +} + +/// A finger drag from `a` to `b` in six moves, one frame each, ending as a touch does: release +/// and `PointerGone` in one frame. +pub fn touch_drag(h: &mut Harness<'_, S>, a: Pos2, b: Pos2) { + one_frame(h, [touch(TouchPhase::Start, a), Event::PointerMoved(a), button(a, true)]); + for i in 1..=6 { + let p = a + (b - a) * (i as f32 / 6.0); + one_frame(h, [touch(TouchPhase::Move, p), Event::PointerMoved(p)]); + } + one_frame(h, [touch(TouchPhase::End, b), button(b, false), Event::PointerGone]); + h.run_steps(4); +} + +/// A mouse click at `p` whose press and release land in one slow frame (#2100). +pub fn coalesced_click(h: &mut Harness<'_, S>, p: Pos2) { + h.hover_at(p); + h.run_steps(2); + one_frame(h, [button(p, true), button(p, false)]); + h.run_steps(4); +} + +/// A double-click at `p`: two clicks, each a press and release in one frame, in consecutive +/// frames (0.25 s apart, within egui's 0.3 s double-click delay). +pub fn double_click(h: &mut Harness<'_, S>, p: Pos2) { + h.hover_at(p); + h.run_steps(1); + for _ in 0..2 { + one_frame(h, [button(p, true), button(p, false)]); + } + h.run_steps(2); +} diff --git a/crates/ui-egui/tests/it/support/mod.rs b/crates/ui-egui/tests/it/support/mod.rs index 2e1e11ffb2..f419bb3296 100644 --- a/crates/ui-egui/tests/it/support/mod.rs +++ b/crates/ui-egui/tests/it/support/mod.rs @@ -1,6 +1,14 @@ -//! State-based waits for native preparation, which completes between UI frames. +//! State-based waits for native preparation, which completes between UI frames, and pointer +//! input in the frames the platform delivers it in ([`input`]), and the skip for tests whose text +//! the interface fonts cannot draw ([`skip_without_glyphs`]). +use std::collections::BTreeSet; + +use egui::FontId; +use egui::epaint::text::{Fonts, TextOptions}; use egui_kittest::Harness; -use pdfcraft_ui_egui::PdfCraftApp; +use pdfcraft_ui_egui::{PdfCraftApp, i18n, theme}; + +pub mod input; /// Step frames until `ready` holds or 30 seconds pass, and say whether it held. For background work /// (a worker-thread open, page text arriving) that a test then asserts on itself: a frame count is @@ -42,3 +50,56 @@ pub fn wait_until(h: &mut Harness<'static, PdfCraftApp>, what: &str, ready: impl h.state().toast ); } + +/// Open the in-window menu bar's `title` menu (File, Edit, View…). +pub fn open_menu(h: &mut Harness<'static, PdfCraftApp>, title: &str) { + use egui_kittest::kittest::Queryable; + h.get_by(|n| n.role() == egui::accesskit::Role::MenuItem && n.label().as_deref() == Some(title)).click(); + h.run_steps(2); +} + +/// The menu row labelled `label` in an open menu. +pub fn menu_row<'h>(h: &'h Harness<'static, PdfCraftApp>, label: &str) -> egui_kittest::Node<'h> { + use egui_kittest::kittest::Queryable; + let label = label.to_owned(); + h.get_by(move |n| n.role() == egui::accesskit::Role::MenuItem && n.label().as_deref() == Some(label.as_str())) +} + +/// The widget labelled `label` outside the menu bar, whose titles (File, Edit, Sign…) may share it. +pub fn outside_menus<'h>(h: &'h Harness<'static, PdfCraftApp>, label: &str) -> egui_kittest::Node<'h> { + use egui_kittest::kittest::Queryable; + let label = label.to_owned(); + h.get_by(move |n| n.role() != egui::accesskit::Role::MenuItem && n.label().as_deref() == Some(label.as_str())) +} + +/// Whether the test `name` must skip because the interface fonts lack a glyph for a character it +/// draws: one in `text` or in the catalog of the interface `language`. egui 0.37 test harnesses +/// panic on a missing glyph, and Japanese, Chinese and Thai faces come only from the optional +/// craft-fonts build input or a face installed on this machine. Prints the skip line. +/// +/// The fonts are those the app installs for `language` and for the default CJK preference: a +/// switch to Chinese installs its fonts for the next frame, so the first frame in Chinese draws +/// with the fonts of the language before it. +/// +/// A build that requires craft-fonts (`CRAFT_FONTS_REQUIRED=1`, as the CI gate sets) never skips: +/// a missing glyph there is a failure. +pub fn skip_without_glyphs(name: &str, language: &str, text: &str) -> bool { + if std::env::var("CRAFT_FONTS_REQUIRED").as_deref() == Ok("1") { + return false; + } + let catalog = i18n::LANGUAGES.iter().find(|l| l.code == language).map_or("", |l| l.source); + let translations = catalog.lines().filter(|l| !l.starts_with('#')).filter_map(|l| l.split('\t').nth(2)); + let chars: BTreeSet = std::iter::once(text).chain(translations).flat_map(str::chars).filter(|c| !c.is_control()).collect(); + let cjk = theme::CjkPreference::for_language(language); + let missing = [theme::CjkPreference::Default, cjk].into_iter().find_map(|cjk| { + let defs = theme::installed_font_definitions(cjk); + let families: Vec = defs.families.keys().map(|f| FontId::new(13.0, f.clone())).collect(); + let mut fonts = Fonts::new(TextOptions::default(), defs); + chars.iter().copied().find(|c| families.iter().any(|id| !fonts.has_glyphs(id, &c.to_string()))) + }); + let Some(missing) = missing else { + return false; + }; + eprintln!("skipping {name}: no interface font draws {missing:?} (set CRAFT_FONTS_DIR to a craft-fonts checkout with a face for it)"); + true +} diff --git a/crates/ui-egui/tests/it/text_select.rs b/crates/ui-egui/tests/it/text_select.rs index 2b63044160..e480f1c598 100644 --- a/crates/ui-egui/tests/it/text_select.rs +++ b/crates/ui-egui/tests/it/text_select.rs @@ -189,6 +189,7 @@ fn copied(h: &Harness<'static, PdfCraftApp>, want: &str) -> bool { h.output().platform_output.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(t) if t == want)) } +/// Check that both the Edit menu and the context menu copy the selected page text. #[test] fn selected_text_copy_edit_and_context_menus() { use egui_kittest::kittest::Queryable; @@ -197,11 +198,8 @@ fn selected_text_copy_edit_and_context_menus() { click(&mut h, at); h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::Hand; h.run_steps(2); // Hide floating Copy so this test can only reach menu entries. - h.get_by_label("Menu").click(); - h.run_steps(2); - h.get_by_label("Edit ⏵").hover(); - h.run_steps(3); - h.get_by_label_contains("Copy").click(); + crate::support::open_menu(&mut h, "Edit"); + crate::support::menu_row(&h, "Copy").click(); let mut did_copy = false; for _ in 0..4 { h.step(); @@ -228,15 +226,13 @@ fn selected_text_copy_edit_and_context_menus() { #[test] fn escape_closes_an_open_menu_before_it_clears_the_selection() { - // #3535: one Escape closed the main menu and dropped the selection with it. - use egui_kittest::kittest::Queryable; + // #3535: one Escape closed the menu and dropped the selection with it. let (mut h, at) = harness(|_| {}); click(&mut h, at); click(&mut h, at); h.run_steps(2); assert_eq!(h.state().views[0].selected_text().as_deref(), Some("quick")); - h.get_by_label("Menu").click(); - h.run_steps(2); + crate::support::open_menu(&mut h, "View"); assert!(egui::Popup::is_any_open(&h.ctx), "the menu is open"); h.key_press(egui::Key::Escape); h.run_steps(2); @@ -336,6 +332,7 @@ fn selected_text_copy_preserves_unicode_and_page_clipboard() { assert_eq!(h.state().page_clipboard.as_ref().map(|c| c.pages.clone()), Some(vec![0])); } +/// Check that a focused search input owns Copy and the selected page text does not also consume it. #[test] fn selected_text_copy_keeps_focused_input_clipboard_ownership() { let (mut h, at) = harness(|_| {}); @@ -344,7 +341,7 @@ fn selected_text_copy_keeps_focused_input_clipboard_ownership() { h.state_mut().views[0].open_find(); h.state_mut().views[0].find.as_mut().unwrap().query = "widget café".into(); h.run_steps(3); - h.ctx.memory_mut(|m| m.request_focus(egui::Id::new("find-input"))); + h.ctx.memory_mut(|m| m.request_focus(egui::Id::unique("find-input"))); h.run_steps(2); h.key_press_modifiers(Modifiers::COMMAND, egui::Key::A); h.run_steps(2); @@ -394,7 +391,13 @@ fn press_and_move(h: &mut Harness<'static, PdfCraftApp>, from: Pos2, to: Pos2) { /// One wheel event, with time to scroll (egui spreads a wheel line over a few frames). fn wheel(h: &mut Harness<'static, PdfCraftApp>, unit: MouseWheelUnit, dy: f32) { - h.event(Event::MouseWheel { unit, delta: egui::vec2(0.0, dy), phase: TouchPhase::Move, modifiers: Modifiers::NONE }); + h.event(Event::MouseWheel { + unit, + delta: egui::vec2(0.0, dy), + phase: TouchPhase::Move, + modifiers: Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + }); h.run_steps(10); } diff --git a/crates/ui-egui/tests/it/theme.rs b/crates/ui-egui/tests/it/theme.rs index 1559838dd0..a02c5c05ca 100644 --- a/crates/ui-egui/tests/it/theme.rs +++ b/crates/ui-egui/tests/it/theme.rs @@ -36,12 +36,9 @@ fn click_toolbar(h: &mut Harness<'static, PdfCraftApp>, label: &str) { } fn open_view_menu(h: &mut Harness<'static, PdfCraftApp>) { - h.get_by_label("Menu").click(); - h.run_steps(2); - h.get_by_label("View ⏵").hover(); - h.run_steps(3); + crate::support::open_menu(h, "View"); assert!(h.query_by_label("Switch light / dark theme").is_none()); - h.get_by_label("Display theme ⏵").hover(); + crate::support::menu_row(h, "Display theme").hover(); h.run_steps(3); for choice in ["Use system setting", "Light gray", "Dark gray"] { h.get_by_label(choice); diff --git a/crates/ui-egui/tests/it/trust_signer.rs b/crates/ui-egui/tests/it/trust_signer.rs index 27bd2f3c9e..c7a2ad739f 100644 --- a/crates/ui-egui/tests/it/trust_signer.rs +++ b/crates/ui-egui/tests/it/trust_signer.rs @@ -3,6 +3,7 @@ //! certificate whose CMS carries its issuer chain gives a signature whose `chain.last()` (the //! self-signed root) differs from its `certificate` (the signer); the click must trust the signer. +use crate::support; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; use pdfcraft_engine::SignatureStatus; @@ -58,7 +59,7 @@ fn one_click_trust_stores_the_displayed_signer_not_the_embedded_root() { d.password = "test".into(); } h.run_steps(2); - h.get_by_label("Sign").click(); + support::outside_menus(&h, "Sign").click(); h.run_steps(6); // The signature validates as Unknown and carries a two-certificate chain whose top (the root) diff --git a/crates/ui-egui/tests/it/ui.rs b/crates/ui-egui/tests/it/ui.rs index c15a5c869b..cd5ce8dbb1 100644 --- a/crates/ui-egui/tests/it/ui.rs +++ b/crates/ui-egui/tests/it/ui.rs @@ -49,14 +49,14 @@ fn tools_panel_side_preference_moves_and_persists() { h.get_by_label("Tools panel side"); h.get_by_label("Right").click(); h.run_steps(3); - assert!(h.state().docking.tools_on_right()); + assert!(h.state().side_panels.tools_on_right()); let saved = h.state().persist(); let mut restored = PdfCraftApp::new(); restored.restore(&saved); - assert!(restored.docking.tools_on_right()); + assert!(restored.side_panels.tools_on_right()); h.get_by_label("Left").click(); h.run_steps(3); - assert!(!h.state().docking.tools_on_right()); + assert!(!h.state().side_panels.tools_on_right()); } /// Every piece of text painted on the last frame: its whole text, whether it was cut with "…", @@ -125,6 +125,170 @@ fn every_catalog_tool_is_listed_after_view_more() { } } +#[test] +fn expanded_all_tools_lists_every_tool_by_category_and_searches() { + let mut h = harness(|_| {}); + h.get_by_label("Expand all tools").click(); + h.run_steps(3); + assert!(h.state().all_tools_full); + for c in pdfcraft_engine::catalog::TOOL_CATEGORIES { + h.get_by_label(c.label); + } + for g in pdfcraft_engine::catalog::TOOL_GROUPS { + assert!(h.query_all_by_label(g.label).count() >= 1, "tool {} missing from the expanded view", g.label); + } + // An item name finds its tool; the other categories drop out. + h.state_mut().all_tools_query = "rotate".into(); + h.run_steps(3); + assert_eq!(h.query_all_by_label("Organize pages").count(), 1, "only the expanded view's tile; the sidebar is gone"); + assert!(h.query_by_label("Forms & Signatures").is_none()); + h.state_mut().all_tools_query = "no such tool".into(); + h.run_steps(3); + h.get_by_label("No tools match your search."); + // Choosing a tool goes back to the workspace with that tool's panel. + h.state_mut().all_tools_query = "organize".into(); + h.run_steps(3); + h.get_by_label("Organize pages").click(); + h.run_steps(3); + assert!(!h.state().all_tools_full); + assert_eq!(h.state().left, pdfcraft_ui_egui::LeftPanel::Tool("organize")); + h.get_by_label("Back to all tools"); +} + +#[test] +fn collapse_and_escape_close_the_expanded_all_tools() { + let mut h = harness(|app| app.set_option("tools", "full").unwrap()); + h.get_by_label("Collapse all tools").click(); + h.run_steps(3); + assert!(!h.state().all_tools_full); + h.get_by_label("View more"); + h.get_by_label("Expand all tools").click(); + h.run_steps(3); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert!(!h.state().all_tools_full); +} + +#[test] +fn escape_in_a_dialog_over_the_expanded_all_tools_leaves_the_view_open() { + let mut h = harness(|app| app.set_option("tools", "full").unwrap()); + h.state_mut().dialog = Some(pdfcraft_ui_egui::Dialog::About); + h.run_steps(3); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert!(h.state().all_tools_full, "Escape belonged to the dialog"); +} + +#[test] +fn escape_in_the_command_palette_over_the_expanded_all_tools_leaves_the_view_open() { + let mut h = harness(|app| app.set_option("tools", "full").unwrap()); + h.state_mut().palette_open = true; + h.run_steps(3); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert!(!h.state().palette_open, "Escape closed the palette"); + assert!(h.state().all_tools_full, "and only the palette"); +} + +#[test] +fn another_workspace_from_the_mode_option_puts_the_expanded_all_tools_away() { + let mut app = PdfCraftApp::new(); + app.set_option("tools", "full").unwrap(); + app.set_option("mode", "all").unwrap(); + assert!(app.all_tools_full, "All tools itself keeps the view"); + app.set_option("mode", "read").unwrap(); + app.set_option("mode", "all").unwrap(); + assert!(!app.all_tools_full, "coming back shows the sidebar, as the mode bar does"); +} + +#[test] +fn opening_a_document_puts_the_expanded_all_tools_away() { + use pdfcraft_ui_egui::Mode; + // All tools as the default workspace: no workspace change, but the document must show. + let mut app = PdfCraftApp::new(); + app.set_option("tools", "full").unwrap(); + app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens"); + assert!(!app.all_tools_full, "the document shows, not the catalogue over it"); + // Read as the default workspace: coming back to All tools later shows the sidebar. + let mut app = PdfCraftApp::new(); + app.set_option("default-mode", "read").unwrap(); + app.set_option("tools", "full").unwrap(); + app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens"); + assert_eq!(app.mode, Mode::Read); + assert!(!app.all_tools_full); +} + +#[test] +fn choosing_a_tool_puts_the_expanded_all_tools_away() { + use pdfcraft_ui_egui::LeftPanel; + let mut h = harness(|app| app.set_option("tools", "full").unwrap()); + h.state_mut().set_option("tool", "organize").unwrap(); + h.run_steps(3); + assert!(!h.state().all_tools_full, "the tool's panel shows, not the catalogue"); + assert_eq!(h.state().left, LeftPanel::Tool("organize")); +} + +#[test] +fn another_tab_coming_forward_puts_the_expanded_all_tools_away() { + // Home, through the control option. + let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); + h.state_mut().set_option("tools", "full").unwrap(); + h.run_steps(3); + assert!(h.state().all_tools_full, "the document's own tab keeps it"); + h.state_mut().set_option("home", "").unwrap(); + h.run_steps(3); + assert!(!h.state().all_tools_full); + h.get_by_label_contains("Welcome to PdfCraft"); + // Another document's tab. + let mut h = harness(|app| { + app.open_bytes("one.pdf", None, FIXTURE.to_vec()).expect("fixture opens"); + app.open_bytes("two.pdf", None, FIXTURE.to_vec()).expect("fixture opens"); + }); + h.state_mut().set_option("tools", "full").unwrap(); + h.run_steps(3); + h.state_mut().active = Some(0); + h.run_steps(3); + assert!(!h.state().all_tools_full, "one.pdf shows, not the catalogue over it"); +} + +#[test] +fn going_home_with_no_document_puts_the_expanded_all_tools_away() { + // Home is already the tab in front, so only the option itself can put the view away. + let mut h = harness(|app| app.set_option("tools", "full").unwrap()); + h.state_mut().set_option("home", "").unwrap(); + h.run_steps(3); + assert!(!h.state().all_tools_full); + h.get_by_label_contains("Welcome to PdfCraft"); +} + +#[test] +fn expanding_all_tools_from_read_mode_restores_the_right_panel() { + use pdfcraft_ui_egui::{Mode, RightPanel}; + let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); + h.state_mut().right = Some(RightPanel::Bookmarks); + h.state_mut().execute("view.read_mode"); + h.run_steps(3); + assert_eq!((h.state().mode, h.state().right), (Mode::Read, None), "Read mode hides the panel"); + h.state_mut().set_option("tools", "full").unwrap(); + h.run_steps(3); + assert!(h.state().all_tools_full); + assert_eq!((h.state().mode, h.state().right), (Mode::AllTools, Some(RightPanel::Bookmarks))); +} + +#[test] +fn expanding_all_tools_from_edit_puts_edit_text_down() { + use pdfcraft_ui_egui::{LeftPanel, Mode, QuickTool}; + let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); + support::outside_menus(&h, "Edit").click(); + h.run_steps(3); + assert_eq!(h.state().quick_tool, QuickTool::EditText); + h.state_mut().set_option("tools", "full").unwrap(); + h.run_steps(3); + assert!(h.state().all_tools_full); + assert_eq!((h.state().mode, h.state().quick_tool), (Mode::AllTools, QuickTool::Select)); + assert_eq!(h.state().left, LeftPanel::AllTools, "collapsing goes back to the All tools list"); +} + #[test] fn opening_a_pdf_shows_comments_and_bookmarks() { let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); @@ -432,6 +596,7 @@ fn overflowing_tabs_can_be_reached_by_scrolling() { delta: egui::vec2(-500.0, 0.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(8); } @@ -554,6 +719,7 @@ fn narrow_tab_strips_accept_vertical_wheel_scrolling() { delta: egui::vec2(0.0, -500.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(8); } @@ -1474,7 +1640,7 @@ fn the_edit_tab_turns_on_edit_text_and_images() { use pdfcraft_ui_egui::{Mode, QuickTool}; let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); assert_eq!(h.state().quick_tool, QuickTool::Select); - h.get_by_label("Edit").click(); + support::outside_menus(&h, "Edit").click(); h.run_steps(3); assert_eq!(h.state().mode, Mode::Edit); assert_eq!(h.state().quick_tool, QuickTool::EditText); @@ -1483,7 +1649,7 @@ fn the_edit_tab_turns_on_edit_text_and_images() { assert_eq!(h.state().mode, Mode::AllTools); assert_eq!(h.state().quick_tool, QuickTool::Select); // Another tool picked inside Edit is not taken away by leaving it. - h.get_by_label("Edit").click(); + support::outside_menus(&h, "Edit").click(); h.run_steps(3); h.state_mut().quick_tool = QuickTool::Hand; h.get_by_label("Convert").click(); @@ -1497,7 +1663,7 @@ fn the_edit_tab_turns_on_edit_text_and_images() { fn organize_pages_from_the_edit_panel_opens_its_panel() { use pdfcraft_ui_egui::LeftPanel; let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); - h.get_by_label("Edit").click(); + support::outside_menus(&h, "Edit").click(); h.run_steps(3); assert_eq!(h.state().left, LeftPanel::Tool("edit")); assert!( @@ -1515,7 +1681,7 @@ fn organize_pages_from_the_edit_panel_opens_its_panel() { #[test] fn the_edit_panel_keeps_the_add_update_remove_menu() { let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); - h.get_by_label("Edit").click(); + support::outside_menus(&h, "Edit").click(); h.run_steps(3); h.get_by_label("Watermark").click(); h.run_steps(3); @@ -1553,6 +1719,7 @@ fn short_window_rail_buttons_scroll_clear_of_page_navigation() { delta: egui::vec2(0.0, -200.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(4); } diff --git a/crates/ui-egui/tests/it/updates.rs b/crates/ui-egui/tests/it/updates.rs index a8ecb31c56..d2eaa7c660 100644 --- a/crates/ui-egui/tests/it/updates.rs +++ b/crates/ui-egui/tests/it/updates.rs @@ -5,18 +5,28 @@ use std::sync::Arc; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; use pdfcraft_ui_egui::PdfCraftApp; -use pdfcraft_ui_egui::updates::{Release, UpdateSource, is_newer}; +use pdfcraft_ui_egui::updates::UpdateSource; -fn source(answer: Result<&str, &str>) -> UpdateSource { - let answer = answer.map(str::to_string).map_err(str::to_string); - Arc::new(move || answer.clone().map(|v| Release { url: format!("https://github.com/storytold/pdfcraft/releases/tag/{v}"), version: v })) +fn release(tag: &str) -> craft_update::Release { + craft_update::Release { + tag: tag.to_string(), + version: craft_update::parse_version(tag), + html_url: format!("https://github.com/storytold/pdfcraft/releases/tag/{tag}"), + ..Default::default() + } } -fn harness(answer: Result<&str, &str>) -> Harness<'static, PdfCraftApp> { +/// `Ok(Some(tag))` is a newer release, `Ok(None)` is up to date. +fn source(answer: Result, &str>) -> UpdateSource { + let answer = answer.map(|tag| tag.map(release)).map_err(str::to_string); + Arc::new(move || answer.clone()) +} + +fn harness(answer: Result, &str>) -> Harness<'static, PdfCraftApp> { Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); - app.update_source = Some(source(answer)); + app.set_update_source(source(answer)); app }) } @@ -37,34 +47,21 @@ fn settle(h: &mut Harness<'static, PdfCraftApp>) { h.run_steps(2); } -#[test] -fn versions_compare_by_number() { - assert!(is_newer("v0.2.0", "0.1.1")); - assert!(is_newer("v0.1.10", "0.1.9")); - assert!(is_newer("1", "0.9.9")); - assert!(!is_newer("v0.1.1", "0.1.1")); - assert!(!is_newer("v0.1.0", "0.1.1")); - assert!(!is_newer("v0.1.1-beta.2", "0.1.1"), "suffixes are ignored"); - assert!(!is_newer("nightly", "0.1.1"), "a tag that isn't a version is never newer"); - assert!(!is_newer("v1.2.3.4", "0.1.1")); - assert!(!is_newer("v99999999999999999999.0.0", "0.1.1"), "out of range"); -} - #[test] fn a_newer_release_is_offered_for_download() { - let mut h = harness(Ok("v99.0.0")); + let mut h = harness(Ok(Some("v99.0.0"))); h.state_mut().execute("help.check_updates"); settle(&mut h); h.get_by_label_contains("PdfCraft 99.0.0 is available"); - h.get_by_label("Download"); - h.get_by_label("Later").click(); + h.get_by_label("DOWNLOAD"); + h.get_by_label("LATER").click(); h.run_steps(3); assert!(h.query_by_label_contains("is available").is_none(), "Later closes the dialog"); } #[test] fn an_up_to_date_or_failed_check_says_so() { - let mut h = harness(Ok(concat!("v", env!("CARGO_PKG_VERSION")))); + let mut h = harness(Ok(None)); h.state_mut().execute("help.check_updates"); settle(&mut h); h.get_by_label_contains("is up to date"); @@ -78,7 +75,7 @@ fn an_up_to_date_or_failed_check_says_so() { #[test] fn nothing_is_asked_until_the_user_checks() { - // No check at start (the owner's decision): the source is only called from the command. + // The check at start is opt-in (the desktop app turns it on): the source is only called from the command. let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let counted = calls.clone(); let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(move |_cc| { @@ -87,9 +84,9 @@ fn nothing_is_asked_until_the_user_checks() { // Settings from a build that had the startup option are ignored. app.restore(r#"{"check_updates_at_start": true}"#); let counted = counted.clone(); - app.update_source = Some(Arc::new(move || { + app.set_update_source(Arc::new(move || { counted.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - Ok(Release { version: "v99.0.0".into(), url: "https://github.com/storytold/pdfcraft/releases/tag/v99.0.0".into() }) + Ok(Some(release("v99.0.0"))) })); app }); @@ -101,3 +98,23 @@ fn nothing_is_asked_until_the_user_checks() { assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); h.get_by_label_contains("PdfCraft 99.0.0 is available"); } + +#[test] +fn the_check_at_start_offers_the_prompt_and_later_dismisses_it() { + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.set_update_source(source(Ok(Some("v99.0.0")))); + app.set_startup_update_check(true); + app + }); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while h.query_by_label_contains("is available").is_none() { + h.run_steps(2); + assert!(std::time::Instant::now() < deadline, "timed out waiting for the startup prompt"); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + h.get_by_label("LATER").click(); + h.run_steps(3); + assert!(h.query_by_label_contains("is available").is_none(), "Later closes the prompt"); +} diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index b825968ff9..a83b4cb756 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -70,7 +70,7 @@ fn mixed_harness(options: &'static [(&'static str, &'static str)]) -> Harness<'s /// A vertical wheel event; negative `dy` scrolls down. fn wheel(unit: MouseWheelUnit, dy: f32, phase: TouchPhase) -> egui::Event { - egui::Event::MouseWheel { unit, delta: egui::vec2(0.0, dy), phase, modifiers: Modifiers::NONE } + egui::Event::MouseWheel { unit, delta: egui::vec2(0.0, dy), phase, modifiers: Modifiers::NONE, source: egui::MouseWheelSource::Unknown } } #[cfg(target_os = "linux")] @@ -710,7 +710,13 @@ fn frames_after_a_notch(system_reduced: bool, animation_effects: &str, zooming: h.run_steps(2); let before = rect(&h, 0).expect("page 1 on screen").min.y; let (delta, modifiers) = if zooming { (egui::vec2(0.0, 1.0), Modifiers::COMMAND) } else { (egui::vec2(0.0, -1.0), Modifiers::NONE) }; - h.event(egui::Event::MouseWheel { unit: MouseWheelUnit::Line, delta, phase: TouchPhase::Move, modifiers }); + h.event(egui::Event::MouseWheel { + unit: MouseWheelUnit::Line, + delta, + phase: TouchPhase::Move, + modifiers, + source: egui::MouseWheelSource::Unknown, + }); (0..40) .map(|_| { h.step(); @@ -1027,9 +1033,8 @@ fn view_menu_page_display_uses_the_same_commands() { use egui_kittest::kittest::Queryable; let mut h = harness(&[]); h.run_steps(4); - h.get_by_label("Menu").click(); - h.run_steps(2); - h.get_by_label("View ⏵").hover(); + crate::support::open_menu(&mut h, "View"); + crate::support::menu_row(&h, "Page display").hover(); h.run_steps(3); h.get_by_label("Single page").click(); h.run_steps(4); @@ -1468,6 +1473,52 @@ fn required_radio_buttons_get_a_round_red_border() { assert!(!stroke(at(50.3, 339.7)), "the widget's corner, outside the circle, is not: {:?}", at(50.3, 339.7)); } +/// Two required check boxes: the first laid out for a round XFA check button (`/PCXfaRound`), the +/// second an ordinary square one. +const ROUND_CHECKS: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] >> >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 2 /T (round) /V /Off /AS /Off /Rect [50 300 90 340] /PCXfaRound true /P 3 0 R >> endobj +5 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 2 /T (square) /V /Off /AS /Off /Rect [150 300 190 340] /P 3 0 R >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// With fields highlighted, a round XFA check box gets a round border, as a radio button does, +/// while an ordinary check box keeps a square one. +#[test] +fn round_check_boxes_get_a_round_highlight() { + let _gpu = gpu(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("checks.pdf", None, ROUND_CHECKS.to_vec()).expect("opens"); + app.set_option("left", "closed").unwrap(); + app.set_option("panel", "none").unwrap(); + app.set_option("fields", "on").unwrap(); + app + }); + crate::support::settle(&mut h); + let r = rect(&h, 0).expect("page 1"); + let ppp = h.ctx.pixels_per_point(); + let img = h.render().expect("renders"); + let at = |x: f32, y: f32| { + let p = egui::pos2(r.min.x + r.width() * (x / 300.0), r.min.y + r.height() * (1.0 - y / 400.0)); + *img.get_pixel((p.x * ppp) as u32, (p.y * ppp) as u32) + }; + // The red of the required border, or its antialiased edge over the field tint. + let stroke = |p: image::Rgba| p[0] > 180 && p[2] < 140 && i16::from(p[0]) - i16::from(p[2]) > 80; + assert!([0.05_f32, 0.2].into_iter().any(|dx| stroke(at(50.0 + dx, 320.0))), "the round box's left edge is red: {:?}", at(50.2, 320.0)); + // Near the top of the left edge: outside the circle, on a square's border. + assert!(!stroke(at(50.25, 339.0)), "the round box's corner, outside the circle, is not: {:?}", at(50.25, 339.0)); + assert!( + [0.1_f32, 0.25, 0.4].into_iter().any(|dx| stroke(at(150.0 + dx, 339.0))), + "the square box's is: {:?} {:?}", + at(150.1, 339.0), + at(150.25, 339.0) + ); +} + /// Acrobat's Highlight existing fields is a preference, not a per-document choice: turned off /// once, the next document (and the next session) opens with fields plain. New settings start /// with it on. @@ -1959,17 +2010,14 @@ fn long_view_menu_fits_a_short_window() { app }); h.run_steps(8); - h.get_by_label("Menu").click(); + crate::support::open_menu(&mut h, "View"); h.run_steps(2); - h.get_by_label("View ⏵").hover(); - h.run_steps(4); - let before = h.get_by_label("Side panels ⏵").rect().bottom(); - h.get_by_label("Fit to height ").hover(); + crate::support::menu_row(&h, "Rotate view").hover(); h.event(wheel(MouseWheelUnit::Point, -1000.0, TouchPhase::Move)); h.run_steps(6); - let after = h.get_by_label("Side panels ⏵").rect().bottom(); - assert!(after < before && after <= 400.0, "menu did not scroll into view: {before} -> {after}"); - h.get_by_label("Side panels ⏵").hover(); + let after = crate::support::menu_row(&h, "Side panels").rect().bottom(); + assert!(after <= 400.0, "the menu scrolls Side panels into view: {after}"); + crate::support::menu_row(&h, "Side panels").hover(); h.run_steps(4); h.get_all_by_label("Bookmarks").last().unwrap().click(); h.run_steps(4); @@ -2111,3 +2159,101 @@ fn full_screen_clicks_turn_pages_whatever_tool_was_on() { assert!(!h.state().full_screen); assert_eq!(h.state().quick_tool, QuickTool::Hand, "the tool from before is back"); } + +/// #4415: the Pages panel's Page tools menu. Each tool acts on the selected pages (the current +/// page when none is): rotate, delete and insert a blank page here, as Pages ▸ … does. +#[test] +fn the_pages_panel_tools_act_on_the_selected_pages() { + let mut h = harness(&[("panel", "pages")]); + let id = h.state().views[0].id; + let rotations = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(id).unwrap().info.pages.iter().map(|p| p.rotation).collect::>(); + // The panel's Page tools menu, on the right-hand side of the window. + let tool = |h: &mut Harness<'static, PdfCraftApp>, label: &str| { + h.get_all_by_label("Page tools").find(|n| n.rect().left() > 1000.0).expect("Page tools in the Pages panel").click(); + h.run_steps(2); + h.get_by_label(label).click(); + h.run_steps(3); + }; + h.state_mut().views[0].select_pages(&[1, 2]); + h.run_steps(2); + tool(&mut h, "Rotate the selected pages clockwise"); + assert_eq!(rotations(&h), [0, 90, 90], "pages 2 and 3, the selection"); + tool(&mut h, "Rotate the selected pages counterclockwise"); + assert_eq!(rotations(&h), [0, 0, 0]); + h.state_mut().views[0].select_pages(&[0]); + h.run_steps(2); + tool(&mut h, "Delete the selected pages"); + assert_eq!(h.state().session.get(id).unwrap().info.pages.len(), 2, "page 1 deleted"); + tool(&mut h, "Insert a blank page after the selection"); + assert_eq!(h.state().session.get(id).unwrap().info.pages.len(), 3); + h.state_mut().execute("edit.undo"); + h.run_steps(2); + assert_eq!(h.state().session.get(id).unwrap().info.pages.len(), 2, "each is one undo step"); +} + +/// CodeRabbit on #4583: the Page tools menu's file items work from the menu too. Insert pages +/// from a file puts the picked file's pages after the selection; Extract the selected pages +/// opens Extract for the selection and writes it. +#[test] +fn the_pages_panel_tools_insert_from_a_file_and_extract() { + let dir = std::env::temp_dir().join(format!("pdfcraft-page-tools-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let more = dir.join("more.pdf"); + std::fs::write(&more, PAGES).unwrap(); + let mut h = harness(&[("panel", "pages")]); + let id = h.state().views[0].id; + let count = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(id).unwrap().info.pages.len(); + let tool = |h: &mut Harness<'static, PdfCraftApp>, label: &str| { + h.get_all_by_label("Page tools").find(|n| n.rect().left() > 1000.0).expect("Page tools in the Pages panel").click(); + h.run_steps(2); + h.get_by_label(label).click(); + h.run_steps(3); + }; + assert_eq!(count(&h), 3); + h.state_mut().views[0].select_pages(&[0]); + h.state_mut().pick_override = Some(vec![more.to_string_lossy().into_owned()]); + tool(&mut h, "Insert pages from a file after the selection…"); + h.run_steps(3); + assert_eq!(count(&h), 6, "the picked file's three pages are inserted"); + + let out = dir.join("out"); + std::fs::create_dir_all(&out).unwrap(); + h.state_mut().export_dir_override = Some(out.to_string_lossy().into_owned()); + h.state_mut().views[0].select_pages(&[1, 2]); + tool(&mut h, "Extract the selected pages…"); + assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::Extract), "Extract opens for the selection"); + h.state_mut().extract_draft = pdfcraft_ui_egui::ExtractDraft { separate: true, ..Default::default() }; + h.get_all_by_label("Extract").last().unwrap().click(); + h.run_steps(3); + assert_eq!(std::fs::read_dir(&out).unwrap().count(), 2, "the two selected pages, each a file"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// The Pages panel's Page tools are greyed out on a document whose security doesn't allow +/// page changes: the menu doesn't open, and nothing changes. +#[test] +fn the_pages_panel_tools_are_off_without_page_editing_permission() { + use egui_kittest::kittest::NodeT; + let mut h = harness(&[("panel", "pages")]); + { + let app = h.state_mut(); + app.apply_edit(pdfcraft_engine::Edit::Protect(pdfcraft_engine::Protection { + permissions_password: Some("owner".into()), + changes: pdfcraft_engine::Changes::None, + ..Default::default() + })); + let bytes = app.session.save_bytes(app.views[0].id).unwrap(); + app.open_bytes("restricted.pdf", None, bytes.as_ref().clone()).unwrap(); + } + h.run_steps(6); + let i = h.state().active.unwrap(); + let id = h.state().views[i].id; + assert!(!h.state().session.get(id).unwrap().allows_assembly()); + let tools = h.get_all_by_label("Page tools").find(|n| n.rect().left() > 1000.0).expect("Page tools"); + assert!(tools.accesskit_node().is_disabled(), "Page tools is greyed out"); + tools.click(); + h.run_steps(3); + assert!(h.query_by_label("Delete the selected pages").is_none(), "its menu doesn't open"); + assert_eq!(h.state().session.get(id).unwrap().info.pages.len(), 3); +} diff --git a/crates/ui-egui/tests/it/viewer.rs b/crates/ui-egui/tests/it/viewer.rs index 7795e05134..f2a12b69aa 100644 --- a/crates/ui-egui/tests/it/viewer.rs +++ b/crates/ui-egui/tests/it/viewer.rs @@ -154,7 +154,7 @@ fn find_shortcuts_advance_and_retreat_matches() { assert_eq!(n, 15); // Surrender text edit focus so canvas shortcut handler receives key events. h.state_mut().views[0].find.as_mut().unwrap().focus = false; - h.ctx.memory_mut(|m| m.surrender_focus(egui::Id::new("find-input"))); + h.ctx.memory_mut(|m| m.surrender_focus(egui::Id::unique("find-input"))); h.run_steps(2); let start = h.state().views[0].find.as_ref().unwrap().current.unwrap(); // ⌘G advances to next match. @@ -201,6 +201,141 @@ fn find_shortcuts_work_from_the_find_field() { assert!(h.ctx.egui_wants_keyboard_input(), "and keeps it"); } +/// Open the find bar on the first document, search for the single uppercase "PAGE" on each +/// page, and wait until every text layer has answered. Returns the document's view index. +fn find_one_match_per_page(h: &mut Harness<'static, PdfCraftApp>) -> usize { + h.state_mut().active = Some(0); + h.run_steps(2); + let view = h.state().active.expect("a document is shown"); + { + let v = &mut h.state_mut().views[view]; + v.open_find(); + let f = v.find.as_mut().expect("the find bar is open"); + f.query = "PAGE".into(); + f.case_sensitive = true; + v.rerun_find(); + } + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + while std::time::Instant::now() < deadline { + h.run_steps(2); + std::thread::sleep(std::time::Duration::from_millis(5)); + if h.state().views[view].find.as_ref().is_some_and(|f| f.matches.len() >= 5) { + break; + } + } + assert_eq!(h.state().views[view].find.as_ref().unwrap().matches.len(), 5, "one PAGE on each of five pages"); + view +} + +/// Automatic following ends when the reader scrolls inside the viewed page, not only when the +/// page changes. This drives the production document area with a real wheel event. +#[test] +fn wheel_scrolling_ends_automatic_search_following_on_the_same_page() { + let mut h = harness(); + let view = find_one_match_per_page(&mut h); + h.run_steps(4); + { + let f = h.state().views[view].find.as_ref().unwrap(); + assert_eq!((f.auto_page, f.follow_ended), (Some(0), false), "the automatic choice is following page 1"); + } + let center = h.state().views[view].viewport_rect().center(); + h.event(egui::Event::PointerMoved(center)); + h.run_steps(1); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -20.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + }); + h.run_steps(12); + let v = &h.state().views[view]; + assert_eq!(v.current, 0, "a small wheel scroll stays within page 1"); + let f = v.find.as_ref().unwrap(); + assert!(f.follow_ended, "the reader's wheel ends automatic following"); + assert_eq!((f.auto_from, f.auto_page), (None, None)); +} + +/// A programmatic search flash is not reader scrolling, even while an unrelated drag is held. +/// Holding a text selection on the page keeps a pointer drag active; the flash still needs to +/// move the viewport, and automatic following must survive so a late layer can still follow it. +#[test] +fn a_search_flash_preserves_following_while_an_unrelated_drag_is_held() { + let mut h = harness(); + let view = find_one_match_per_page(&mut h); + h.run_steps(4); + let start = h.state().views[view].page_screen_rect(0).expect("page 1 is on screen").center(); + h.event(egui::Event::PointerMoved(start)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + // Move enough to turn the press into a text-selection drag. That drag owns the pointer but + // does not scroll the document; only the document scroll area scrolls it. + let held = start + egui::vec2(24.0, 0.0); + h.event(egui::Event::PointerMoved(held)); + h.run_steps(2); + assert!(h.ctx.dragged_id().is_some(), "an unrelated page drag is held"); + assert!(h.state().views[view].page_screen_rect(1).is_none(), "page 2 starts off screen"); + + // A flash to page 2 visibly moves the viewport from under the held drag. + { + let f = h.state_mut().views[view].find.as_mut().unwrap(); + f.query = "Page 2".into(); + f.case_sensitive = true; + } + h.state_mut().views[view].rerun_find(); + h.run_steps(3); + let v = &h.state().views[view]; + assert_eq!(v.current, 1, "the automatic choice moved to page 2"); + let f = v.find.as_ref().unwrap(); + assert!(!f.follow_ended, "a flash's own jump is not the reader scrolling"); + assert_eq!(f.auto_page, Some(1)); + assert!(v.page_screen_rect(1).is_some(), "page 2 is visibly on screen after the flash"); + + // Releasing the unrelated drag leaves the automatic follow in place. + h.event(egui::Event::PointerButton { pos: held, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(2); + assert!(!h.state().views[view].find.as_ref().unwrap().follow_ended, "the page drag never ended following"); +} + +/// The document scroll bar is its own scroll surface: a track click moves the offset with no +/// pointer motion, and dragging from that press keeps moving it. Both are the reader scrolling, +/// so automatic following ends. A global "any drag is scrolling" check would miss the click. +#[test] +fn scroll_bar_track_click_and_drag_end_automatic_search_following() { + let mut h = harness(); + let view = find_one_match_per_page(&mut h); + h.run_steps(4); + let vp = h.state().views[view].viewport_rect(); + // Floating bars sit in the outer few points of the document area (see theme). Aim 75% down + // the vertical track: below the handle at this scroll position, and clear of the corner + // where the horizontal bar's track overlaps. + let track = egui::pos2(vp.right() - 4.0, vp.top() + vp.height() * 0.75); + let before = h.state().views[view].page_screen_rect(0).expect("page 1 is on screen").top(); + h.event(egui::Event::PointerMoved(track)); + h.run_steps(2); + h.event(egui::Event::PointerButton { pos: track, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(2); + { + let v = &h.state().views[view]; + let f = v.find.as_ref().unwrap(); + assert!(f.follow_ended, "a track click that jumps the view ends automatic following"); + assert_eq!((f.auto_from, f.auto_page), (None, None)); + let after = v.page_screen_rect(0).map(|r| r.top()); + assert!(after.is_none_or(|top| top < before - 100.0), "the track click moved the viewport ({before} -> {after:?})"); + } + + // Keep dragging from the press: the scroll bar follows the pointer, still reader scrolling. + let dragged = egui::pos2(vp.right() - 4.0, vp.top() + vp.height() * 0.4); + h.event(egui::Event::PointerMoved(dragged)); + h.run_steps(2); + let v = &h.state().views[view]; + assert!(v.find.as_ref().unwrap().follow_ended, "the track drag is the reader scrolling"); + assert!(v.page_screen_rect(0).is_none_or(|r| r.top() < before - 100.0), "dragging the track kept moving the viewport"); + h.event(egui::Event::PointerButton { pos: dragged, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(2); +} + #[test] fn marking_while_searching_does_not_jitter_or_reset_search_state() { let mut h = harness(); @@ -339,7 +474,7 @@ fn page_input_focused(h: &Harness<'static, PdfCraftApp>) -> bool { let focused = h.ctx.memory(|m| m.focused()); let view = &h.state().views[h.state().active.unwrap()]; // Also identifies the original editor when running this test-only patch on the baseline. - focused == Some(egui::Id::new(("page-input", view.id.0))) || focused == Some(egui::Id::new("page-input")) + focused == Some(egui::Id::unique(("page-input", view.id.0))) || focused == Some(egui::Id::unique("page-input")) } /// Exercise the real navigation editor, including its focus and Enter handling. @@ -967,7 +1102,7 @@ fn wrapped_revision_buttons_stay_above_binding_and_open_the_saved_revision() { let left = h.get_by_label("Left Edge").rect(); let right = h.get_by_label("Right Edge").rect(); // Properties requests a fixed width. Measure its card in each viewport. - let card = h.ctx.data(|d| d.get_temp::(egui::Id::new("dialog").with("card"))).expect("Properties is shown"); + let card = h.ctx.data(|d| d.get_temp::(egui::Id::unique("dialog").with("card"))).expect("Properties is shown"); for (n, rect) in &buttons { for (label, below) in [("Binding", binding), ("Left Edge", left), ("Right Edge", right)] { assert!(rect.bottom() <= below.top(), "revision {n} overlaps {label} at viewport width {width}: {rect:?}, {below:?}"); @@ -1556,7 +1691,13 @@ fn the_scroll_speed_preference_scales_mouse_wheel_notches() { let top = |h: &Harness<'static, PdfCraftApp>| h.state().views[i].page_screen_rect(0).expect("page 1 on screen").min.y; let before = top(&h); h.event(egui::Event::PointerMoved(h.state().views[i].viewport_rect().center())); - h.event(egui::Event::MouseWheel { unit, delta: egui::vec2(0.0, -dy), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE }); + h.event(egui::Event::MouseWheel { + unit, + delta: egui::vec2(0.0, -dy), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + }); // egui spreads a wheel notch over a few frames. h.run_steps(30); before - top(&h) @@ -1857,6 +1998,7 @@ fn mouse_wheel_scrolls_while_selecting_text() { delta: egui::vec2(0.0, -3.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(20); let after = h.state().views[view].page_screen_rect(0).unwrap().top(); @@ -1896,7 +2038,13 @@ fn selection_wheel_delta(unit: egui::MouseWheelUnit, amount: f32, held: bool, an } h.run_steps(2); let before = h.state().views[view].page_screen_rect(0).unwrap().top(); - h.event(egui::Event::MouseWheel { unit, delta: egui::vec2(0.0, -amount), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE }); + h.event(egui::Event::MouseWheel { + unit, + delta: egui::vec2(0.0, -amount), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, + }); h.run_steps(40); before - h.state().views[view].page_screen_rect(0).unwrap().top() } @@ -1930,7 +2078,13 @@ fn selection_control_wheel_still_zooms() { for modifiers in [egui::Modifiers::CTRL, egui::Modifiers::COMMAND] { let (mut h, view, _) = selection_wheel_harness(); let before = h.state().views[view].page_screen_rect(0).unwrap().width(); - h.event(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: egui::vec2(0.0, 1.0), phase: egui::TouchPhase::Move, modifiers }); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, 1.0), + phase: egui::TouchPhase::Move, + modifiers, + source: egui::MouseWheelSource::Unknown, + }); h.run_steps(40); let after = h.state().views[view].page_screen_rect(0).unwrap().width(); assert!(after > before + 1.0, "control wheel should zoom: {before} -> {after}"); @@ -1948,6 +2102,7 @@ fn selection_wheel_outside_document_does_not_scroll() { delta: egui::vec2(0.0, -1.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(40); let after = h.state().views[view].page_screen_rect(0).unwrap().top(); @@ -1975,6 +2130,7 @@ fn selection_wheel_does_not_scroll_behind_preferences() { delta: egui::vec2(0.0, -1.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(40); let after = h.state().views[view].page_screen_rect(0).unwrap().top(); @@ -1991,6 +2147,7 @@ fn selection_mouse_wheel_resumes_after_point_scrolling() { delta: egui::vec2(0.0, -30.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(delay); let before = h.state().views[view].page_screen_rect(0).unwrap().top(); @@ -2002,6 +2159,7 @@ fn selection_mouse_wheel_resumes_after_point_scrolling() { delta: egui::vec2(0.0, -1.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Unknown, }); h.run_steps(40); let after = h.state().views[view].page_screen_rect(0).unwrap().top(); @@ -2087,3 +2245,26 @@ fn go_to_page_brings_the_page_into_view_horizontally() { let (r, vp) = (v.page_screen_rect(2).expect("page 3 on screen"), v.viewport_rect()); assert!(r.left() >= vp.left() && r.right() <= vp.right(), "page 3 at {r:?}, viewport {vp:?}"); } + +/// #4614: the accessible window is named like the native one, and follows it when the active +/// document changes, so a screen reader can find the window by its title. +#[test] +fn the_accessible_window_has_the_window_title_as_its_name() { + use egui_kittest::kittest::NodeT as _; + let mut h = harness(); + h.run_steps(2); + let name = |h: &Harness<'static, PdfCraftApp>| h.root().accesskit_node().label().map(|l| l.to_string()); + assert_eq!(name(&h).as_deref(), Some("b.pdf — PdfCraft")); + { + use pdfcraft_engine::Edit; + let s = h.state_mut(); + let id = s.views[s.active.unwrap()].id; + s.session.apply(id, Edit::SetInfo { key: "Title".into(), value: "Quarterly report".into() }).unwrap(); + let mut v = s.session.get(id).unwrap().initial_view(); + v.display_title = true; + s.session.apply(id, Edit::SetInitialView(Box::new(v))).unwrap(); + } + h.run_steps(2); + assert_eq!(name(&h).as_deref(), Some("Quarterly report — PdfCraft")); + assert_eq!(name(&h), Some(h.state().window_title.clone())); +} diff --git a/crates/ui-egui/tests/perf.rs b/crates/ui-egui/tests/perf.rs index 61d3965bdc..892f7b766a 100644 --- a/crates/ui-egui/tests/perf.rs +++ b/crates/ui-egui/tests/perf.rs @@ -94,7 +94,7 @@ fn pdf(objs: Vec) -> Vec { out } -/// Named roots exercise thread lookups even when none of the comments has replies. +/// Named roots, none with replies: the panel still looks up every root's thread. fn named_comments(n: usize) -> Vec { let annots: Vec = (0..n).map(|i| format!("{} 0 R", i + 4)).collect(); let mut objs = vec![ diff --git a/crates/ui-egui/tests/snapshots/panel_comments.txt b/crates/ui-egui/tests/snapshots/panel_comments.txt index c5bed58730..ca9e50db8c 100644 --- a/crates/ui-egui/tests/snapshots/panel_comments.txt +++ b/crates/ui-egui/tests/snapshots/panel_comments.txt @@ -4,11 +4,11 @@ Button "Close" [off] Button "More options" [off] Button "Filter comments" [off] Button "Search comments" [off] -TextInput +TextInput "Add a comment" Button "Collapse" [off] Label = "Page 1" Label = "1" Label = "Reviewer" Label = "2026-01-01 12:00" Label = "Check this heading" -Unknown +Unknown "Note — click to show it on the page" diff --git a/crates/ui-egui/tests/snapshots/panel_pages.txt b/crates/ui-egui/tests/snapshots/panel_pages.txt index 4e746f1006..7f432057d9 100644 --- a/crates/ui-egui/tests/snapshots/panel_pages.txt +++ b/crates/ui-egui/tests/snapshots/panel_pages.txt @@ -1,6 +1,7 @@ Label = "Pages" Label = "2" Button "Close" [off] +Button "Page tools" [off] Label = "Thumbnail size" Label = "100%" Button "Smaller thumbnails" [off] diff --git a/crates/xfa/Cargo.toml b/crates/xfa/Cargo.toml index dcec1e75bc..d917018057 100644 --- a/crates/xfa/Cargo.toml +++ b/crates/xfa/Cargo.toml @@ -14,7 +14,7 @@ workspace = true pdfcraft-cos = { workspace = true } pdfcraft-fonts = { workspace = true } roxmltree = "0.20" -base64 = "0.22" +base64 = "0.23" png = "0.18" sha2 = "0.11" image = { version = "0.25", default-features = false, features = ["bmp", "gif", "tiff"] } diff --git a/crates/xfa/README.md b/crates/xfa/README.md index 23a6ef0d0e..2d08a74bb2 100644 --- a/crates/xfa/README.md +++ b/crates/xfa/README.md @@ -38,8 +38,11 @@ that warning and keeps the existing layout and accepted widget values. XObjects, and widgets: text (multi-line, max length, alignment), date (Acrobat's `AFDate` format and keystroke actions from the picture clause), check boxes and radio groups (from `exclGroup`), push buttons with Reset / Print / Save As / URI actions recognised from their - scripts. Appearance streams are left empty for the forms layer to generate. Each field keeps - its SOM path in `/PCSom`; the AcroForm gets `/PCXfaLayout` so a saved form is not laid out + scripts, and signature fields, each named by its SOM path (`form1[0].page1[0].sign[0]`, as + fields under one for each step above it) because other viewers lay a dynamic form out again + and find a signature by that name. A box whose path, or a part of it, the file already gives + another field keeps its own name, and a new layout keeps a signature a generated box holds. Appearance streams are left empty for the forms layer to + generate. Each field keeps its SOM path in `/PCSom`; the AcroForm gets `/PCXfaLayout` so a saved form is not laid out twice. The XFA packets stay, so Adobe's viewers keep rendering the form from them. - **Data** (`data`): the `datasets` packet. On layout, a field takes its value from the data @@ -82,6 +85,7 @@ be measured exponentially often in their nesting. A measurement budget backs thi | `/PCSom` | generated fields | the field's SOM path, so values go back to the data | | `/PCXfaLayout` | AcroForm | what laying out produced, so a saved form is not laid out again | | `/PCXfaOverrides` | AcroForm | `<< /Presence << /som (hidden) … >> /Access << /som (readOnly) … >> >>`: presence and access scripts set, by SOM path (instance 0 of a repeating subform stands for all); applied to the template before every layout, so undo, save and reopen keep them. Adobe's viewers draw from the XFA packets and don't read it; at most 10 000 entries are read or written | + | `/PCXfaNode` | generated fields above a signature field | `true`: the field names a step of the signature field's SOM path; a new layout replaces it with the fields it holds | | `/PCXfaClick` | generated push buttons | `true`: the template has a click script for this button. Such buttons carry no `/A`: XFA JavaScript is not Acrobat JavaScript, so it is never written as a `/S /JavaScript` action other viewers would run. PdfCraft finds the script by the field's `/PCSom` | ## API sketch diff --git a/crates/xfa/src/data.rs b/crates/xfa/src/data.rs index 95f59a44b9..eac2f20bf4 100644 --- a/crates/xfa/src/data.rs +++ b/crates/xfa/src/data.rs @@ -12,7 +12,7 @@ use std::collections::HashMap; use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; use crate::XfaError; -use crate::model::{Field, Node, SomIndices, Template}; +use crate::model::{Field, Node, SomIndices, Template, Ui}; use crate::packets::{Encoding, encode}; const XFA_DATA_NS: &str = "http://www.xfa.org/schema/xfa-data/1.0/"; @@ -1630,11 +1630,11 @@ pub fn write_data_ops(doc: &mut Document, ops: &[DataOp], reuse: Option) } /// Put `bytes` in the document as a datasets stream: in place of `old` when that is the stream -/// `reuse`, else as a new object. +/// `reuse` or already an unsaved overlay stream of `doc`, else as a new object. fn put_stream(doc: &mut Document, old: Option<&Object>, reuse: Option, bytes: &[u8]) -> ObjRef { let stream = Object::Stream(Stream::flate(Dict::new(), bytes)); - match (old.and_then(Object::as_ref), reuse) { - (Some(r), Some(keep)) if r == keep => { + match old.and_then(Object::as_ref) { + Some(r) if reuse == Some(r) || doc.is_edited(r.num) => { doc.set(r, stream); r } @@ -1794,3 +1794,210 @@ pub fn is_on(text: &str, on: &str) -> bool { } !matches!(t.to_ascii_lowercase().as_str(), "" | "0" | "off" | "false" | "no") && on.trim().is_empty() } + +/// The datasets stream's object reference in `doc` (the `datasets` item in the `/XFA` array, or +/// the single `/XFA` stream), when `doc` has one. +fn datasets_stream_ref(doc: &Document) -> Option { + let root = doc.root()?; + let catalog = doc.get(root); + let acro = catalog.as_dict()?.get(b"AcroForm").map(|a| doc.resolve(a))?; + let xfa = acro.as_dict()?.get(b"XFA")?; + match &*doc.resolve(xfa) { + Object::Array(items) => { + let at = (0..items.len()) + .step_by(2) + .find(|&i| items.get(i).and_then(|n| n.as_string()).is_some_and(|s| s.to_text() == "datasets") && i + 1 < items.len())?; + items.get(at + 1)?.as_ref() + } + Object::Stream(_) => xfa.as_ref(), + _ => None, + } +} + +/// The SOM path and default value of every AcroForm text field with the Password flag (bit 14, +/// `1 << 13`) in `doc`, for static XFA forms whose password fields set the flag in the AcroForm +/// dictionary. +fn acro_password_fields(doc: &Document) -> Vec<(String, String)> { + const PASSWORD_FLAG: i64 = 1 << 13; + let Some(root) = doc.root() else { return Vec::new() }; + let catalog = doc.get(root); + let Some(acro) = catalog.as_dict().and_then(|c| c.get(b"AcroForm")).map(|a| doc.resolve(a)) else { return Vec::new() }; + let Some(top) = acro.as_dict().and_then(|a| a.get(b"Fields")).map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()) else { + return Vec::new(); + }; + struct AcroWalk<'a> { + doc: &'a Document, + seen: std::collections::HashSet, + out: Vec<(String, String)>, + } + impl AcroWalk<'_> { + fn walk( + &mut self, + items: &[Object], + parent_name: &str, + inherited_ft: Option<&[u8]>, + inherited_ff: i64, + inherited_dv: Option<&str>, + depth: usize, + ) { + if depth > 32 { + return; + } + for item in items { + let Some(r) = item.as_ref() else { continue }; + if !self.seen.insert(r) { + continue; + } + let obj = self.doc.get(r); + let Some(d) = obj.as_dict() else { continue }; + let ft = d.name(b"FT").or(inherited_ft); + let ff = d.int(b"Ff").unwrap_or(inherited_ff); + let dv_own = text_of(self.doc, d, b"DV"); + let dv = dv_own.as_deref().or(inherited_dv); + let t = text_of(self.doc, d, b"T"); + let full_name = match (&t, parent_name.is_empty()) { + (Some(seg), true) => seg.clone(), + (Some(seg), false) => format!("{parent_name}.{seg}"), + (None, _) => parent_name.to_string(), + }; + let kids = d.get(b"Kids").map(|k| self.doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); + let has_named_kid = kids.iter().filter_map(Object::as_ref).any(|kr| self.doc.get(kr).as_dict().is_some_and(|kd| kd.contains(b"T"))); + if !has_named_kid && ft == Some(b"Tx") && (ff & PASSWORD_FLAG) != 0 { + let som = text_of(self.doc, d, crate::pdf::SOM_KEY).unwrap_or_else(|| full_name.clone()); + if !som.is_empty() { + self.out.push((som, dv.unwrap_or_default().to_string())); + } + } + if !kids.is_empty() { + self.walk(&kids, &full_name, ft, ff, dv, depth + 1); + } + } + } + } + let mut w = AcroWalk { doc, seen: std::collections::HashSet::new(), out: Vec::new() }; + w.walk(&top, "", None, 0, None, 0); + w.out +} + +/// The `Want` entries needed to restore password fields in `doc`'s datasets packet to their +/// defaults (or empty when they have no default). +fn password_dataset_wants(doc: &Document) -> Vec { + if datasets_stream_ref(doc).is_none() { + return Vec::new(); + } + let Ok(Some(packet)) = datasets_packet(doc) else { return Vec::new() }; + let Some(data) = parse_datasets(&packet) else { return Vec::new() }; + with_template(doc, None, |tpl| { + let mut candidates: Vec<(String, DataPath, String)> = Vec::new(); + let mut seen = std::collections::HashSet::new(); + if let Some(t) = tpl { + struct Walk<'a> { + tpl: &'a Template, + data: &'a DataNode, + nodes: usize, + seen: &'a mut std::collections::HashSet, + out: &'a mut Vec<(String, DataPath, String)>, + } + impl Walk<'_> { + fn child_som(parent: &str, name: Option<&str>, index: usize) -> String { + match name { + Some(n) if !parent.is_empty() => format!("{parent}.{n}[{index}]"), + Some(n) => format!("{n}[{index}]"), + None => parent.to_string(), + } + } + fn walk(&mut self, children: &[Node], parent_som: &str, depth: usize) { + if depth > MAX_DEPTH || self.nodes >= crate::script::MAX_FORM_NODES { + return; + } + let sibs = SomIndices::of(children); + for (i, n) in children.iter().enumerate() { + if self.nodes >= crate::script::MAX_FORM_NODES { + break; + } + self.nodes += 1; + let sib = sibs.at(i); + match n { + Node::Subform(sf) => { + let from_data = match &sf.common.name { + Some(name) if sf.occur.max != Some(1) => self.data.count(&data_path(self.tpl, parent_som), name), + _ => 0, + }; + let instances = crate::layout::instance_count(&sf.occur, from_data).max(from_data); + for k in 0..instances { + if k > 0 { + if self.nodes >= crate::script::MAX_FORM_NODES { + break; + } + self.nodes += 1; + } + let som = Self::child_som(parent_som, sf.common.name.as_deref(), sib + k); + self.walk(&sf.children, &som, depth + 1); + } + } + Node::Area(a) => { + let som = Self::child_som(parent_som, a.common.name.as_deref(), sib); + self.walk(&a.children, &som, depth + 1); + } + Node::Field(f) if f.ui == Ui::PasswordEdit => { + let som = Self::child_som(parent_som, f.common.name.as_deref().or(Some("field")), sib); + let path = data_path(self.tpl, &som); + if !path.is_empty() && self.seen.insert(path.clone()) { + let default = f.value.plain().map(|v| f.saved_value(v.trim())).unwrap_or_default(); + self.out.push((som, path, default)); + } + } + _ => {} + } + } + } + } + let root = Node::Subform(Box::new(t.root.clone())); + let mut w = Walk { tpl: t, data: &data, nodes: 0, seen: &mut seen, out: &mut candidates }; + w.walk(std::slice::from_ref(&root), "", 0); + if let Some(ps) = &t.root.page_set { + for (i, area) in ps.areas.iter().enumerate() { + let som = area.name.clone().map(|n| format!("{n}[0]")).unwrap_or_else(|| format!("#pageArea[{i}]")); + w.walk(&area.items, &som, 1); + } + } + } + for (som, default) in acro_password_fields(doc) { + let path = path_of(tpl, &som); + if !path.is_empty() && seen.insert(path.clone()) { + candidates.push((som, path, default)); + } + } + candidates + .into_iter() + .filter_map(|(som, path, default)| { + let cur = data.value_at(&path)?; + (!cur.is_empty() && cur != default).then_some(Want { som, path, text: default, ensure: false }) + }) + .collect() + }) +} + +/// Whether any XFA password field (`` or an AcroForm text field with the Password +/// flag) holds a value in `doc`'s datasets packet beyond its default (#3944). +pub fn has_password_values(doc: &Document) -> bool { + !password_dataset_wants(doc).is_empty() +} + +/// Clear typed password values from `doc`'s XFA datasets packet (restoring a template default +/// where the field has one, else emptying the element), replacing the datasets stream in place +/// (#3944). Returns how many password fields were cleared in the datasets. +pub fn clear_password_values(doc: &mut Document) -> Result { + let wants = password_dataset_wants(doc); + if wants.is_empty() { + return Ok(0); + } + let count = wants.len(); + let reuse = datasets_stream_ref(doc); + let report = rewrite(doc, &[Op::Values(&wants)], reuse)?; + if !report.written || !password_dataset_wants(doc).is_empty() { + let reason = report.warnings.first().map_or("the XFA datasets password values could not be cleared", String::as_str); + return Err(XfaError::Malformed(reason.to_string())); + } + Ok(count) +} diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs index b76b3efa1a..8f6e0f7ee8 100644 --- a/crates/xfa/src/fixtures.rs +++ b/crates/xfa/src/fixtures.rs @@ -144,6 +144,43 @@ pub fn static_shell(data: &str) -> Vec { assemble(objs) } +/// A static XFA form of `n` text fields `form1[0].page1[0].f[0]` on one page, whose values +/// (`v`) are only in its datasets, as when another viewer filled it in. The first field is +/// read-only. +pub fn static_shell_many(n: usize) -> Vec { + let data: String = (0..n).map(|i| format!("v{i}")).collect(); + let xdp = format!( + "{data}" + ); + // Objects 1–7, then the widgets from 8. + let kids: Vec = (0..n).map(|i| format!("{} 0 R", i + 8)).collect(); + let kids = kids.join(" "); + let mut objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R /AcroForm << /XFA 5 0 R /Fields [6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> >> >> >> >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Annots [{kids}] >>").into_bytes(), + b"<< /Length 0 >>\nstream\n\nendstream".to_vec(), + format!("<< /Length {} >>\nstream\n{xdp}\nendstream", xdp.len()).into_bytes(), + b"<< /T (form1[0]) /Kids [7 0 R] >>".to_vec(), + format!("<< /T (page1[0]) /Parent 6 0 R /Kids [{kids}] >>").into_bytes(), + ]; + for i in 0..n { + let y = 760 - (i % 50) * 14; + let x = 36 + (i / 50) * 110; + // The first field is read-only: its data value is not taken. + let flags = if i == 0 { " /Ff 1" } else { "" }; + objs.push( + format!( + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (f{i}[0]){flags} /Parent 7 0 R /Rect [{x} {y} {} {}] /P 3 0 R /DA (/Helv 9 Tf 0 g) >>", + x + 100, + y + 12 + ) + .into_bytes(), + ); + } + assemble(objs) +} + /// A PDF file holding `objs` as objects 1, 2, … in order, with a cross-reference table and a /// trailer whose root is object 1. fn assemble(objs: Vec>) -> Vec { diff --git a/crates/xfa/src/lib.rs b/crates/xfa/src/lib.rs index 7fe372d53a..7e63218c90 100644 --- a/crates/xfa/src/lib.rs +++ b/crates/xfa/src/lib.rs @@ -92,14 +92,14 @@ pub(crate) fn xml_nesting_depth(text: &str) -> usize { pub use data::{DataImport, ImportNode, export_data, import_data, parse_data_file, write_data_image}; pub use data::{ - DataNode, DataOp, DataPath, DatasetsWrite, FieldData, FieldDatum, add_data_instances, build_data, data_path, iso_to_pattern, parse_datasets, - pattern_to_iso, read_values, read_values_with, remove_data_instance, som_to_path, write_data_ops, write_data_value, write_datasets, - write_datasets_reusing, write_datasets_with, + DataNode, DataOp, DataPath, DatasetsWrite, FieldData, FieldDatum, add_data_instances, build_data, clear_password_values, data_path, + has_password_values, iso_to_pattern, parse_datasets, pattern_to_iso, read_values, read_values_with, remove_data_instance, som_to_path, + write_data_ops, write_data_value, write_datasets, write_datasets_reusing, write_datasets_with, }; pub use layout::{Action, BorderShape, Form, Item, MAX_PAGES, Page, Widget, WidgetKind, formatted_value, layout}; pub use packets::{Encoding, Packets, decode as decode_packet, encode as encode_packet, read_packets}; pub use parse::{measure, parse}; -pub use pdf::{CLICK_KEY, LAYOUT_KEY, SOM_KEY, Stripped, XfaShape, existing_layout, strip, xfa_shape}; +pub use pdf::{CLICK_KEY, LAYOUT_KEY, NODE_KEY, SOM_KEY, Stripped, XfaShape, existing_layout, strip, xfa_shape}; pub use picture::typed_date; pub use script::{ FormNode, LiveForm, MAX_FORM_NODES, NodeKind, OVERRIDES_KEY, Overrides, ScriptEvent, apply_overrides, data_of, field_grows_with_text, diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index 8e930c8f46..23e21fc331 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -2,7 +2,7 @@ //! streams (standard-14 fonts, WinAnsi), image XObjects and AcroForm widgets. Widget appearance //! streams are left empty here; the forms layer regenerates them from the field dictionaries. -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; use pdfcraft_fonts::{literal, win_ansi}; @@ -21,6 +21,10 @@ pub const LAYOUT_KEY: &[u8] = b"PCXfaLayout"; /// PDF action (the script is XFA's, not Acrobat JavaScript); PdfCraft runs the script by the /// field's SOM path. pub const CLICK_KEY: &[u8] = b"PCXfaClick"; +/// Private key on the fields generated to name a signature field by its SOM path, one for each +/// step of the path above it (`form1[0]`, then `page1[0]`): a new layout replaces them with the +/// fields they hold. +pub const NODE_KEY: &[u8] = b"PCXfaNode"; /// Width and height of a JPEG from its first frame header (SOF0–SOF15, not the DNL/JPG/DAC/DHT /// markers). `None` for anything else. @@ -44,7 +48,7 @@ fn hex32(h: &[u8; 32]) -> Vec { fn parse_hex32(s: &[u8]) -> Option<[u8; 32]> { let mut out = [0u8; 32]; - if s.len() != 64 { + if s.len() != 64 || !s.iter().all(u8::is_ascii_hexdigit) { return None; } for (o, pair) in out.iter_mut().zip(s.as_chunks::<2>().0) { @@ -264,6 +268,11 @@ struct Emitter<'a> { warnings: Vec, /// The signatures the file holds, by the SOM path of the field signed. signed: HashMap, + /// The full names of the fields the file came with, which a generated name must not repeat. + taken: HashSet, + /// The fields above the signature fields named by their SOM paths, by the path they name: + /// each one's object and the fields it holds. + nodes: HashMap)>, } /// A signature field of the file that has been signed. @@ -279,13 +288,16 @@ const MAX_FIELDS_SEARCHED: usize = 20_000; /// Deepest field nesting followed. const MAX_FIELD_DEPTH: usize = 32; -/// The signed signature fields the document came with, by full name. Signing applications name -/// a dynamic form's fields by their SOM path (`form1[0].page1[0].sign[0]`), which is how a -/// laid-out signature box finds its signature. Fields of an earlier layout pass are not read. -fn signed_fields(doc: &Document, catalog: &Dict) -> HashMap { +/// The fields the document came with: the full name of each, and the signed signature fields by +/// SOM path. Signing applications name a dynamic form's fields by their SOM path +/// (`form1[0].page1[0].sign[0]`), which is how a laid-out signature box finds its signature. A +/// field an earlier layout pass generated gives no name, but a signature on it is kept, by its +/// `/PCSom`, so a new layout keeps the box signed. +fn existing_fields(doc: &Document, catalog: &Dict) -> (HashMap, HashSet) { let mut out = HashMap::new(); - let Some(acro) = catalog.get(b"AcroForm").map(|a| doc.resolve(a)) else { return out }; - let Some(fields) = acro.as_dict().and_then(|a| a.get(b"Fields")).map(|f| doc.resolve(f)) else { return out }; + let mut names = HashSet::new(); + let Some(acro) = catalog.get(b"AcroForm").map(|a| doc.resolve(a)) else { return (out, names) }; + let Some(fields) = acro.as_dict().and_then(|a| a.get(b"Fields")).map(|f| doc.resolve(f)) else { return (out, names) }; let mut stack: Vec<(Object, String, bool, usize)> = fields.as_array().into_iter().flatten().map(|f| (f.clone(), String::new(), false, 0)).collect(); let mut seen = 0usize; @@ -296,7 +308,22 @@ fn signed_fields(doc: &Document, catalog: &Dict) -> HashMap { } let field = doc.resolve(&field); let Some(d) = field.as_dict() else { continue }; + if d.contains(NODE_KEY) { + // Generated to name a signature field: only what it holds is looked at. + if depth < MAX_FIELD_DEPTH { + let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); + stack.extend(kids.into_iter().map(|k| (k, String::new(), false, depth + 1))); + } + continue; + } if d.contains(SOM_KEY) { + let som = d.get(SOM_KEY).map(|s| doc.resolve(s)).and_then(|s| s.as_string().map(|s| s.to_text())); + if d.name(b"FT") == Some(b"Sig") + && let Some(som) = som + && let Some(value) = d.get(b"V").filter(|v| doc.resolve(v).as_dict().is_some()) + { + out.entry(som).or_insert(Signed { value: value.clone(), appearance: d.get(b"AP").cloned() }); + } continue; } let name = match d.get(b"T").map(|t| doc.resolve(t)).and_then(|t| t.as_string().map(|t| t.to_text())) { @@ -304,6 +331,9 @@ fn signed_fields(doc: &Document, catalog: &Dict) -> HashMap { Some(t) => format!("{prefix}.{t}"), None => prefix, }; + if !name.is_empty() { + names.insert(name.clone()); + } let is_sig = match d.name(b"FT") { Some(ft) => ft == b"Sig", None => parent_is_sig, @@ -318,7 +348,7 @@ fn signed_fields(doc: &Document, catalog: &Dict) -> HashMap { stack.extend(kids.into_iter().map(|k| (k, name.clone(), is_sig, depth + 1))); } } - out + (out, names) } impl Emitter<'_> { @@ -363,6 +393,9 @@ impl Emitter<'_> { ff |= 1; } let mut top_level = true; + // The SOM path of the generated field above this one, for a signature field named by its + // SOM path. + let mut under: Option = None; match &w.kind { WidgetKind::Text | WidgetKind::Date(_) | WidgetKind::Password => { d.set(b"FT".to_vec(), Object::name("Tx")); @@ -464,6 +497,16 @@ impl Emitter<'_> { } ff |= 1; } + if let Some((above, last)) = self.som_steps(&w.som) + && let Some(parent) = self.node(&above) + { + // Named by its SOM path, as fields under one for each step above it: other + // viewers lay a dynamic form out again and find a signature by that name. + d.set(b"T".to_vec(), Object::String(PdfString::text(&last))); + d.set(b"Parent".to_vec(), Object::Ref(parent)); + top_level = false; + under = Some(above.join(".")); + } } WidgetKind::Image => { // A push button with nothing drawn: the forms layer treats one so marked as an @@ -574,6 +617,12 @@ impl Emitter<'_> { if top_level { self.fields.push(r); self.field_count += 1; + } else if let Some(path) = under { + if let Some((_, kids)) = self.nodes.get_mut(&path) { + kids.push(r); + } + self.taken.insert(w.som.clone()); + self.field_count += 1; } else if let WidgetKind::Radio { group, .. } = &w.kind && let Some((_, kids)) = self.radio_groups.get_mut(group) { @@ -582,6 +631,60 @@ impl Emitter<'_> { Ok(Some(r)) } + /// The steps of SOM path `som` above the last, and the last, when a field can be named by the + /// path: two to [`MAX_FIELD_DEPTH`] steps, none empty, and no field of the file or of this + /// pass already named by the path or by a part of it. `None` keeps the field's own name. + fn som_steps(&self, som: &str) -> Option<(Vec, String)> { + let steps: Vec<&str> = som.split('.').collect(); + if steps.len() < 2 || steps.len() > MAX_FIELD_DEPTH || steps.iter().any(|s| s.is_empty()) { + return None; + } + for i in 1..=steps.len() { + let path = steps.get(..i)?.join("."); + // A part already generated by this pass is shared; one the file has is not touched. + let generated = i < steps.len() && self.nodes.contains_key(&path); + if !generated && self.taken.contains(&path) { + return None; + } + } + let (last, above) = steps.split_last()?; + Some((above.iter().map(|s| s.to_string()).collect(), last.to_string())) + } + + /// The generated field named by the SOM path of `steps`, made (with those above it) when this + /// pass has none yet; the topmost goes into the AcroForm's fields. `None` for no steps. + fn node(&mut self, steps: &[String]) -> Option { + let mut parent: Option<(ObjRef, String)> = None; + for step in steps { + let path = match &parent { + Some((_, above)) => format!("{above}.{step}"), + None => step.clone(), + }; + let r = match self.nodes.get(&path) { + Some((r, _)) => *r, + None => { + let mut d = Dict::new(); + d.set(b"T".to_vec(), Object::String(PdfString::text(step))); + d.set(b"Kids".to_vec(), Object::Array(Vec::new())); + d.set(NODE_KEY.to_vec(), Object::Bool(true)); + if let Some((p, _)) = &parent { + d.set(b"Parent".to_vec(), Object::Ref(*p)); + } + let r = self.doc.add(Object::Dict(d)); + match parent.as_ref().and_then(|(_, above)| self.nodes.get_mut(above)) { + Some((_, kids)) => kids.push(r), + None => self.fields.push(r), + } + self.nodes.insert(path.clone(), (r, Vec::new())); + self.taken.insert(path.clone()); + r + } + }; + parent = Some((r, path)); + } + parent.map(|(r, _)| r) + } + /// An image XObject for a picture: JPEGs as they are (`DCTDecode`), PNGs, GIFs, BMPs and TIFFs /// decoded to samples (`FlateDecode`, with a soft mask for transparency). `None`, with a warning, /// for anything else. @@ -908,6 +1011,9 @@ fn earlier_images(doc: &Document, pages: ObjRef) -> HashMap<[u8; 32], ObjRef> { out } +/// Write laid-out `form` into `doc`: its pages replace the document's, its widgets join the +/// AcroForm's fields (a box the file holds a signature for keeps it), and the AcroForm is marked +/// laid out. What was written, with any warnings, comes back. pub fn write_form(doc: &mut Document, form: &Form) -> Result { let catalog_ref = doc.root().ok_or_else(|| XfaError::Malformed("the document has no catalog".into()))?; let mut catalog = doc.get(catalog_ref).as_dict().cloned().ok_or_else(|| XfaError::Malformed("the catalog is not a dictionary".into()))?; @@ -925,7 +1031,7 @@ pub fn write_form(doc: &mut Document, form: &Form) -> Result }; // Pictures embedded by earlier passes, from the pages this pass replaces. let images = earlier_images(doc, pages_ref); - let signed = signed_fields(doc, &catalog); + let (signed, taken) = existing_fields(doc, &catalog); let mut em = Emitter { doc, images, @@ -935,7 +1041,17 @@ pub fn write_form(doc: &mut Document, form: &Form) -> Result field_count: 0, warnings: Vec::new(), signed, + taken, + nodes: HashMap::new(), }; + // Every name a field of this pass takes at the top, wherever and in whatever order it is + // written: a signature field named by its SOM path must not repeat one. + for w in form.pages.iter().flat_map(|p| &p.items).filter_map(|i| if let Item::Widget(w) = i { Some(w) } else { None }) { + em.taken.insert(match &w.kind { + WidgetKind::Radio { group, .. } => group.clone(), + _ => w.name.clone(), + }); + } let mut kids = Vec::new(); for page in &form.pages { kids.push(Object::Ref(em.page(page, pages_ref)?)); @@ -950,6 +1066,10 @@ pub fn write_form(doc: &mut Document, form: &Form) -> Result } })?; } + let nodes: Vec<(ObjRef, Vec)> = em.nodes.values().cloned().collect(); + for (node, kids) in nodes { + em.doc.update_dict(node, |d| d.set(b"Kids".to_vec(), Object::Array(kids.iter().map(|r| Object::Ref(*r)).collect())))?; + } let Emitter { doc, fields, field_count, warnings, .. } = em; let count = kids.len(); doc.update_dict(pages_ref, |d| { @@ -1174,7 +1294,7 @@ pub fn strip(doc: &mut Document) -> Result { out.image_fields += 1; } let private: Vec> = - [SOM_KEY, CLICK_KEY, IMAGE_FIELD_KEY, b"PCItems".as_slice()].iter().filter(|k| d.contains(k)).map(|k| k.to_vec()).collect(); + [SOM_KEY, CLICK_KEY, IMAGE_FIELD_KEY, NODE_KEY, b"PCItems".as_slice()].iter().filter(|k| d.contains(k)).map(|k| k.to_vec()).collect(); if !private.is_empty() { out.field_keys += private.len(); doc.update_dict(r, |d| { @@ -1203,6 +1323,7 @@ pub fn strip(doc: &mut Document) -> Result { for r in streams { doc.free(r); } + doc.require_full_save(); Ok(out) } @@ -1293,3 +1414,20 @@ pub fn existing_layout(doc: &Document) -> Option { pub fn pdf_rect(r: Rect, page_height: f64) -> [f64; 4] { [r.x, page_height - r.bottom(), r.right(), page_height - r.y] } + +#[cfg(test)] +mod tests { + use super::parse_hex32; + + #[test] + fn parse_hex32_rejects_non_hex_digits() { + for prefix in ["+f", "-f", " f"] { + let mut s = prefix.as_bytes().to_vec(); + s.resize(64, b'0'); + assert!(parse_hex32(&s).is_none(), "{prefix:?}"); + } + let mut ok = b"Ff".to_vec(); + ok.resize(64, b'0'); + assert_eq!(parse_hex32(&ok).map(|h| h[0]), Some(0xff)); + } +} diff --git a/crates/xfa/src/script.rs b/crates/xfa/src/script.rs index d330c2a647..6946aa9b1c 100644 --- a/crates/xfa/src/script.rs +++ b/crates/xfa/src/script.rs @@ -586,19 +586,61 @@ pub fn rerender(doc: &mut Document, tpl: &Template) -> Result if form.pages.is_empty() { return Err(XfaError::Malformed("the template laid out to no pages".into())); } - // The AcroForm keeps only the fields PdfCraft did not generate. + // The fields an earlier pass generated give way to the new layout's, after it is written: it + // keeps a signature one of them holds. The AcroForm keeps every field PdfCraft did not generate. let Some(root) = doc.root() else { return Err(XfaError::NotXfa) }; - if let Some((acro_ref, mut acro)) = acroform(doc) { - let fields = acro.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default(); - let kept: Vec = - fields.into_iter().filter(|f| f.as_ref().is_none_or(|r| doc.get(r).as_dict().is_none_or(|d| !d.contains(crate::pdf::SOM_KEY)))).collect(); + let top = |doc: &Document| -> Vec { + acroform(doc).and_then(|(_, acro)| acro.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned())).unwrap_or_default() + }; + let generated: std::collections::HashSet = top(doc) + .iter() + .filter_map(Object::as_ref) + .filter(|r| doc.get(*r).as_dict().is_some_and(|d| d.contains(crate::pdf::SOM_KEY) || d.contains(crate::pdf::NODE_KEY))) + .collect(); + let written = crate::pdf::write_form(doc, &form)?; + if !generated.is_empty() + && let Some((acro_ref, mut acro)) = acroform(doc) + { + let kept: Vec = top(doc).into_iter().filter(|f| f.as_ref().is_none_or(|r| !generated.contains(&r))).collect(); acro.set(b"Fields".to_vec(), Object::Array(kept)); match acro_ref { Some(r) => doc.set(r, Object::Dict(acro)), None => doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(acro)))?, } + // A generated field edited in this overlay is freed with its appearance and widget kids, + // so a replaced password widget's value or masked appearance cannot stay in the overlay. + // Signature fields, and the fields that name them by their SOM path, stay: the new + // layout's signature field took their value and appearance. + let free_ap = |doc: &mut Document, d: &Dict| { + if let Some(ap) = d.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()) + && let Some(n) = ap.get(b"N").and_then(Object::as_ref) + && doc.is_edited(n.num) + { + doc.free(n); + } + }; + let signature = |d: &Dict| d.name(b"FT") == Some(b"Sig".as_slice()) || d.contains(crate::pdf::NODE_KEY); + for r in generated { + let Some(d) = doc.get(r).as_dict().cloned().filter(|d| !signature(d)) else { continue }; + free_ap(doc, &d); + if let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) { + for k in kids.iter().filter_map(Object::as_ref) { + if let Some(kd) = doc.get(k).as_dict().cloned() { + if signature(&kd) { + continue; + } + free_ap(doc, &kd); + } + if doc.is_edited(k.num) { + doc.free(k); + } + } + } + if doc.is_edited(r.num) { + doc.free(r); + } + } } - let written = crate::pdf::write_form(doc, &form)?; let mut warnings = form.warnings; warnings.extend(written.warnings); if written.usage_rights { diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 3175882f89..39a1c93483 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -1508,6 +1508,8 @@ fn top_fields(doc: &Document) -> Vec<(String, pdfcraft_cos::Dict)> { .collect() } +/// Choice lists, a password, a signature and pictures in the template become their kinds of +/// widget, and their AcroForm fields and image XObjects once written. #[test] fn choice_lists_signatures_passwords_and_pictures_become_fields_and_images() { let form = layout_xml(&fields_template("")).unwrap(); @@ -1570,7 +1572,11 @@ fn choice_lists_signatures_passwords_and_pictures_become_fields_and_images() { assert_eq!(opt[0].as_string().unwrap().to_text(), "English", "same shown text and value: a plain string"); assert_eq!(field("other").int(b"Ff"), Some((1 << 17) | (1 << 18)), "an editable combo box"); assert_eq!(field("pin").int(b"Ff"), Some(1 << 13), "a password field"); - assert_eq!(field("sign").name(b"FT"), Some(b"Sig".as_slice())); + // The signature field is named by its SOM path, under a generated field for each step above it. + assert!(fields.iter().all(|(name, _)| name != "sign")); + let named = full_names(&doc); + let sign = named.iter().find(|(name, _)| name == "form[0].page1[0].sign[0]").map(|(_, d)| d).expect("named by its SOM path"); + assert_eq!(sign.name(b"FT"), Some(b"Sig".as_slice())); let streams: Vec = doc .object_numbers() .into_iter() @@ -3009,6 +3015,213 @@ fn a_zero_caption_reserve_sizes_the_caption_as_none_does() { assert!((x_of("withZero") - x_of("without")).abs() < 0.01, "as without a reserve: {} {}", x_of("withZero"), x_of("without")); } +/// The terminal fields by full name, down the field tree. +fn full_names(doc: &Document) -> Vec<(String, Dict)> { + let root = doc.get(doc.root().unwrap()); + let acro = doc.resolve(root.as_dict().unwrap().get(b"AcroForm").unwrap()); + let mut todo: Vec<(Object, String)> = + doc.resolve(acro.as_dict().unwrap().get(b"Fields").unwrap()).as_array().unwrap().iter().map(|f| (f.clone(), String::new())).collect(); + let mut out = Vec::new(); + while let Some((f, prefix)) = todo.pop() { + let d = doc.resolve(&f).as_dict().unwrap().clone(); + let t = d.get(b"T").map(|t| doc.resolve(t)).and_then(|t| t.as_string().map(|t| t.to_text())); + let name = match t { + Some(t) if prefix.is_empty() => t, + Some(t) => format!("{prefix}.{t}"), + None => prefix, + }; + match d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) { + Some(kids) if kids.iter().any(|k| doc.resolve(k).as_dict().is_some_and(|k| k.contains(b"T"))) => { + todo.extend(kids.into_iter().map(|k| (k, name.clone()))); + } + _ => out.push((name, d)), + } + } + out +} + +/// A laid-out signature field is named by its SOM path, under a generated field for each step +/// above it, as other viewers lay a dynamic form out again and find its signature by that name. +/// Boxes in one subform share those fields; a new layout replaces them rather than adding more; +/// taking the XFA out leaves them as ordinary fields. +#[test] +fn signature_fields_are_named_by_their_som_path() { + let xdp = r##" + + + +"##; + let mut doc = Document::open(Arc::new(shell(xdp))).unwrap(); + let report = render_into(&mut doc).unwrap(); + assert_eq!(report.fields, 4, "name and the three signature fields"); + let doc = Document::open(Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap())).unwrap(); + let check = |doc: &Document| { + let top: Vec = pdfcraft_forms_free_fields(doc).into_iter().map(|(name, _)| name).collect(); + assert_eq!(top.iter().filter(|n| *n == "form[0]").count(), 1, "one field for the root subform: {top:?}"); + assert!(top.iter().any(|n| n == "name"), "other fields keep their names: {top:?}"); + let named = full_names(doc); + for som in ["form[0].body[0].sign[0]", "form[0].body[0].other[0]", "form[0].end[0].last[0]"] { + let (_, d) = named.iter().find(|(name, _)| name == som).unwrap_or_else(|| panic!("{som} in {named:?}")); + assert_eq!(d.name(b"FT"), Some(b"Sig".as_slice())); + assert_eq!(fields_by_som(doc).get(som).map(String::as_str), Some(som), "the data finds it by its SOM path"); + } + assert_eq!(named.len(), 4, "{named:?}"); + }; + check(&doc); + let body = |doc: &Document| { + let named = full_names(doc); + let parent = |som: &str| named.iter().find(|(name, _)| name == som).and_then(|(_, d)| d.get(b"Parent").and_then(Object::as_ref)); + (parent("form[0].body[0].sign[0]"), parent("form[0].body[0].other[0]")) + }; + let (a, b) = body(&doc); + assert!(a.is_some() && a == b, "the two boxes of one subform share its field"); + // Laid out again: the same fields, not a second set. + let mut doc = doc; + let tpl = crate::script::template_of(&doc).unwrap(); + rerender(&mut doc, &tpl).unwrap(); + check(&doc); + // The XFA taken out: ordinary fields with the same names, without the private key. + strip(&mut doc).unwrap(); + let named = full_names(&doc); + assert!(named.iter().any(|(name, _)| name == "form[0].end[0].last[0]"), "{named:?}"); + let top = pdfcraft_forms_free_fields(&doc); + let node = top.iter().find(|(name, _)| name == "form[0]").map(|(_, d)| d.clone()).expect("the root subform's field"); + assert!(!node.contains(NODE_KEY)); +} + +/// The terminal field named `full`, down the field tree. +fn field_ref(doc: &Document, full: &str) -> Option { + let root = doc.get(doc.root()?); + let acro = doc.resolve(root.as_dict()?.get(b"AcroForm")?); + let mut todo: Vec<(pdfcraft_cos::ObjRef, String)> = + doc.resolve(acro.as_dict()?.get(b"Fields")?).as_array()?.iter().filter_map(Object::as_ref).map(|r| (r, String::new())).collect(); + while let Some((r, prefix)) = todo.pop() { + let d = doc.get(r).as_dict()?.clone(); + let t = d.get(b"T").map(|t| doc.resolve(t)).and_then(|t| t.as_string().map(|t| t.to_text())).unwrap_or_default(); + let name = if prefix.is_empty() { t } else { format!("{prefix}.{t}") }; + if name == full { + return Some(r); + } + let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); + todo.extend(kids.iter().filter_map(Object::as_ref).map(|k| (k, name.clone()))); + } + None +} + +/// A box signed on a laid-out form stays signed when the form is laid out again (as after a +/// script adds a row): the new layout's field, named by the same SOM path, holds the same +/// signature and is locked, and the old field is gone from the AcroForm. +#[test] +fn a_signature_on_a_laid_out_box_survives_a_new_layout() { + let mut doc = Document::open(Arc::new(shell(&fields_template("")))).unwrap(); + render_into(&mut doc).unwrap(); + let som = "form[0].page1[0].sign[0]"; + let old = field_ref(&doc, som).expect("the box"); + // Signed as signing does it: a signature dictionary as the field's value, and a picture. + let mut sig = Dict::new(); + sig.set(b"Type".to_vec(), Object::name("Sig")); + let sig = doc.add(Object::Dict(sig)); + let picture = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(Dict::new(), b"0 0 1 rg 0 0 10 10 re f".to_vec()))); + doc.update_dict(old, |d| { + d.set(b"V".to_vec(), Object::Ref(sig)); + let mut ap = Dict::new(); + ap.set(b"N".to_vec(), Object::Ref(picture)); + d.set(b"AP".to_vec(), Object::Dict(ap)); + }) + .unwrap(); + let tpl = crate::script::template_of(&doc).unwrap(); + rerender(&mut doc, &tpl).unwrap(); + let new = field_ref(&doc, som).expect("still named by its SOM path"); + assert_ne!(new, old, "a field of the new layout"); + let d = doc.get(new).as_dict().unwrap().clone(); + assert_eq!(d.get(b"V").and_then(Object::as_ref), Some(sig), "the same signature"); + let shown = d.get(b"AP").map(|ap| doc.resolve(ap)).and_then(|ap| ap.as_dict().and_then(|ap| ap.get(b"N").and_then(Object::as_ref))); + assert_eq!(shown, Some(picture), "and the same picture"); + assert!(matches!(&*doc.get(picture), Object::Stream(_)), "which is still in the file"); + assert!(matches!(&*doc.get(sig), Object::Dict(_)), "as is the signature"); + assert_eq!(d.int(b"Ff").unwrap_or(0) & 1, 1, "a signed box is not signed again"); + let top = pdfcraft_forms_free_fields(&doc); + assert_eq!(top.iter().filter(|(name, _)| name == "form[0]").count(), 1, "the old fields are gone"); +} + +/// A box right in the form's root subform, signed and then laid out again, keeps its signature +/// and picture in the file: its field sits just under the one naming it, and the old fields that +/// give way to the new layout's (freed when this session made them) take nothing it still uses. +#[test] +fn a_signed_box_in_the_root_subform_keeps_its_picture_through_a_new_layout() { + let xdp = r#" +"#; + let mut doc = Document::open(Arc::new(shell(xdp))).unwrap(); + render_into(&mut doc).unwrap(); + let som = "form[0].sign[0]"; + let old = field_ref(&doc, som).expect("the box"); + let sig = doc.add(Object::Dict(Dict::new())); + let picture = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(Dict::new(), b"0 0 1 rg 0 0 10 10 re f".to_vec()))); + doc.update_dict(old, |d| { + d.set(b"V".to_vec(), Object::Ref(sig)); + let mut ap = Dict::new(); + ap.set(b"N".to_vec(), Object::Ref(picture)); + d.set(b"AP".to_vec(), Object::Dict(ap)); + }) + .unwrap(); + let tpl = crate::script::template_of(&doc).unwrap(); + rerender(&mut doc, &tpl).unwrap(); + let new = field_ref(&doc, som).expect("still named by its SOM path"); + let d = doc.get(new).as_dict().unwrap().clone(); + let shown = d.get(b"AP").map(|ap| doc.resolve(ap)).and_then(|ap| ap.as_dict().and_then(|ap| ap.get(b"N").and_then(Object::as_ref))); + assert_eq!(shown, Some(picture)); + assert!(matches!(&*doc.get(picture), Object::Stream(_)), "the picture is still in the file"); + assert!(matches!(&*doc.get(sig), Object::Dict(_)), "and the signature"); +} + +/// A field of the same pass keeps a signature field named by its SOM path from repeating its +/// name, written before the box or after it: with a text field named `form[0]` (as a hostile +/// template may name one), the box keeps its own name and no two fields share one. +#[test] +fn a_signature_field_does_not_repeat_a_name_another_field_has() { + let text = r#""#; + let sign = r#""#; + for (first, second) in [(text, sign), (sign, text)] { + let xdp = format!( + r##" + + + +"## + ); + let mut doc = Document::open(Arc::new(shell(&xdp))).unwrap(); + render_into(&mut doc).unwrap(); + let mut top: Vec = pdfcraft_forms_free_fields(&doc).into_iter().map(|(name, _)| name).collect(); + assert!(top.iter().any(|n| n == "sign"), "the box keeps its own name: {top:?}"); + top.sort(); + let len = top.len(); + top.dedup(); + assert_eq!(top.len(), len, "no name twice: {top:?}"); + } +} + +/// A signature box the file already holds a signature for shows that signature, the file's own +/// objects, and is locked; the other box stays unsigned. #[test] fn a_signed_signature_box_shows_the_signature_the_file_holds() { let mut doc = Document::open(Arc::new(crate::fixtures::signed_shell())).unwrap(); @@ -3017,7 +3230,10 @@ fn a_signed_signature_box_shows_the_signature_the_file_holds() { let acro = doc.resolve(catalog.as_dict().unwrap().get(b"AcroForm").unwrap()); let fields = doc.resolve(acro.as_dict().unwrap().get(b"Fields").unwrap()).as_array().unwrap().clone(); let laid: Vec<_> = fields.iter().map(|f| doc.resolve(f)).filter(|f| f.as_dict().unwrap().contains(SOM_KEY)).collect(); + // The file has a field named `form[0]` already: the unsigned box keeps its own name rather + // than repeat that one. let (signed, unsigned) = (laid[0].as_dict().unwrap(), laid[1].as_dict().unwrap()); + assert_eq!(unsigned.get(b"T").and_then(|t| t.as_string()).map(|t| t.to_text()).as_deref(), Some("other")); // The signature dictionary and the picture are the file's own objects, not copies. let value = doc.resolve(signed.get(b"V").unwrap()); assert_eq!(value.as_dict().unwrap().name(b"Type"), Some(&b"Sig"[..])); @@ -3350,6 +3566,7 @@ fn stripping_takes_the_xfa_out_and_leaves_an_ordinary_document() { ur.set(b"UR3".to_vec(), Object::Dict(pdfcraft_cos::Dict::new())); doc.update_dict(root, |d| d.set(b"Perms".to_vec(), Object::Dict(ur))).unwrap(); let r = strip(&mut doc).unwrap(); + assert!(doc.full_save_required()); assert_eq!(r.packets, vec!["template".to_string(), "datasets".to_string()]); assert_eq!(r.unbound_data, Some(0)); assert_eq!((r.script_buttons, r.image_fields), (0, 1), "{r:?}"); @@ -3435,6 +3652,16 @@ fn stripping_takes_the_xfa_out_and_leaves_an_ordinary_document() { assert!(started.elapsed() < std::time::Duration::from_secs(5), "{:?}", started.elapsed()); } +#[test] +fn strip_marks_document_as_requiring_full_save() { + use crate::pdf::strip; + let mut doc = Document::open(Arc::new(shell(&fields_template("
JP")))).unwrap(); + render_into(&mut doc).unwrap(); + assert!(!doc.full_save_required()); + strip(&mut doc).unwrap(); + assert!(doc.full_save_required(), "stripping XFA packets must require a full save to prevent data exposure in incremental saves"); +} + #[test] fn som_indices_count_earlier_same_named_siblings() { let draw = |name: Option<&str>| { @@ -3780,3 +4007,69 @@ fn faces_measure_text_at_their_standard_14_widths() { let condensed = text::Face { h_scale: 0.8, ..face(text::Family::Times, true, false) }; assert!((condensed.width(sample) - 0.8 * Std14::TimesBold.text_width(sample, 10.0)).abs() < 1e-9); } + +/// #3944: clearing XFA password values removes values typed into `` fields from the +/// datasets packet (including across repeating subforms), restores a field's template default +/// `…` when it has one, and replaces the datasets stream in place so +/// the typed password is not left in an unreferenced stream in the overlay. +#[test] +fn clear_password_values_removes_typed_passwords_from_datasets_in_place() { + let xdp = r#" + + +
Adahunter29999firstsecond
+
+
"#; + let mut doc = Document::open(Arc::new(shell(xdp))).unwrap(); + render_into(&mut doc).unwrap(); + assert!(has_password_values(&doc)); + let cleared = clear_password_values(&mut doc).unwrap(); + assert_eq!(cleared, 4); + assert!(!has_password_values(&doc), "only default and non-password values remain"); + assert_eq!(clear_password_values(&mut doc).unwrap(), 0); + + let packets = read_packets(&doc).unwrap().unwrap(); + assert!(packets.xdp.contains("Ada"), "non-password fields stay: {}", packets.xdp); + assert!(packets.xdp.contains("0000"), "template default restored: {}", packets.xdp); + for secret in ["hunter2", "9999", "first", "second"] { + assert!(!packets.xdp.contains(secret), "{secret} still in datasets: {}", packets.xdp); + } + let written = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let reopened = Document::open(Arc::new(written)).unwrap(); + for num in reopened.object_numbers() { + if let Object::Stream(st) = &*reopened.get(pdfcraft_cos::ObjRef::new(num, 0)) { + let decoded = st.decoded().unwrap_or_default(); + let text = String::from_utf8_lossy(&decoded); + for secret in ["hunter2", "9999", "first", "second"] { + assert!(!text.contains(secret), "object {num} still holds {secret}: {text}"); + } + } + } +} + +/// #3944: a static XFA form whose AcroForm field sets the Password flag (`/Ff 8192`) also has its +/// typed value cleared from the datasets packet. +#[test] +fn clear_password_values_clears_static_xfa_password_fields() { + let pdf = String::from_utf8(static_shell("hunter2")) + .unwrap() + .replace("/T (name[0])", "/T (name[0]) /Ff 8192"); + let mut doc = Document::open(Arc::new(pdf.into_bytes())).unwrap(); + assert!(has_password_values(&doc)); + assert_eq!(clear_password_values(&mut doc).unwrap(), 1); + assert!(!has_password_values(&doc)); + let packets = read_packets(&doc).unwrap().unwrap(); + assert!(!packets.xdp.contains("hunter2"), "{}", packets.xdp); +} diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index 6131c9c96b..9c3d9ab017 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -1126,6 +1126,24 @@ fn place(doc: &mut Document, page_ref: ObjRef, mut d: Dict) -> Result Result<(), DataError> { + let mut p = Dict::new(); + p.set(b"Type".to_vec(), Object::name("Annot")); + p.set(b"Subtype".to_vec(), Object::name("Popup")); + p.set(b"Rect".to_vec(), arr(rect)); + p.set(b"Parent".to_vec(), Object::Ref(parent)); + p.set(b"Open".to_vec(), Object::Bool(open)); + let pr = doc.add(Object::Dict(p)); + doc.update_dict(parent, |d| d.set(b"Popup".to_vec(), Object::Ref(pr)))?; + doc.update_dict(page, |pd| { + if let Some(Object::Array(a)) = pd.get_mut(b"Annots") { + a.push(Object::Ref(pr)); + } + })?; + Ok(()) +} + fn import_xfdf(doc: &mut Document, text: &str) -> Result { check_form_xml_depth(text)?; let xml = parse_form_xml(text).map_err(|e| DataError::Malformed(e.to_string()))?; @@ -1157,19 +1175,7 @@ fn import_xfdf(doc: &mut Document, text: &str) -> Result { if let Some(pop) = node.children().find(|c| c.has_tag_name("popup")) && let Some(rect) = pop.attribute("rect").map(csv).filter(|r| r.len() == 4) { - let mut p = Dict::new(); - p.set(b"Type".to_vec(), Object::name("Annot")); - p.set(b"Subtype".to_vec(), Object::name("Popup")); - p.set(b"Rect".to_vec(), arr(&rect)); - p.set(b"Parent".to_vec(), Object::Ref(r)); - p.set(b"Open".to_vec(), Object::Bool(pop.attribute("open") == Some("yes"))); - let pr = doc.add(Object::Dict(p)); - doc.update_dict(r, |d| d.set(b"Popup".to_vec(), Object::Ref(pr)))?; - doc.update_dict(page, |pd| { - if let Some(Object::Array(a)) = pd.get_mut(b"Annots") { - a.push(Object::Ref(pr)); - } - })?; + add_popup(doc, page, r, &rect, pop.attribute("open") == Some("yes"))?; } report.comments += 1; } @@ -1276,7 +1282,11 @@ fn walk_xfdf(node: roxmltree::Node, prefix: &str, out: &mut Vec<(String, Vec` is an empty field (the export writes one that way), so it + // clears the field like FDF's `/V ()`; a field with kids only names their parent. A + // leaf holding only `` is not read yet, so it leaves the field alone. + let names_kids_or_rich = f.children().any(|c| c.has_tag_name("field") || c.has_tag_name("value-richtext")); + if !vals.is_empty() || !names_kids_or_rich { out.push((full.clone(), vals)); } walk_xfdf(f, &full, out, depth + 1)?; @@ -1445,6 +1455,11 @@ fn import_fdf(doc: &mut Document, bytes: &[u8]) -> Result { // The parent is another comment: link it below rather than copying it in. let irt = src.get(b"IRT").cloned(); src.remove(b"IRT"); + // The pop-up is an annotation of its own (/Popup shall be an indirect reference, ISO + // 32000-2 Table 172): copied in, it would become a direct dictionary. A new one is + // placed on the page below, as the XFDF import does. + let popup = src.get(b"Popup").map(|p| fdf.resolve(p)).and_then(|p| p.as_dict().cloned()); + src.remove(b"Popup"); let mut path: Vec = a.as_ref().into_iter().collect(); let Object::Dict(mut d) = deep(&fdf, &Object::Dict(src), 0, &mut path, &mut budget)? else { continue }; let Some(page) = d.get(b"Page").and_then(Object::as_int).and_then(|p| usize::try_from(p).ok()).and_then(|p| pages.get(p).copied()) else { @@ -1457,6 +1472,10 @@ fn import_fdf(doc: &mut Document, bytes: &[u8]) -> Result { d.set(b"P".to_vec(), Object::Ref(page)); let r = place(doc, page, d)?; later.remove(&r); + if let Some(rect) = popup.as_ref().and_then(|p| rect_strict(&fdf, p.get(b"Rect"))) { + let open = popup.as_ref().and_then(|p| p.get(b"Open")).is_some_and(|o| matches!(&*fdf.resolve(o), Object::Bool(true))); + add_popup(doc, page, r, &rect, open)?; + } if let Some(fr) = a.as_ref() { placed.insert(fr, r); } diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 17c48c281b..2bb54f9232 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -1933,3 +1933,84 @@ fn annots_of(doc: &Document) -> Vec { .filter(|d| d.get(b"Subtype") != Some(&Object::name("Widget"))) .collect() } + +/// An empty text field exports as a `` with no ``, and the import skipped such a +/// field, so importing the data left the target's old text in place. FDF, XML, CSV and text +/// carry the same empty value and clear the field. +#[test] +fn an_empty_field_in_xfdf_clears_the_field_it_is_imported_into() { + let src = one_field(); + assert_eq!(values(&src), vec![("Name".to_string(), Vec::::new())]); + let exports = [ + ("xfdf", export_xfdf(&src, false, true, "form.pdf").into_bytes()), + ("fdf", export_fdf(&src, false, true, "form.pdf").unwrap()), + ("xml", export_data(&src, Format::Xml).into_bytes()), + ]; + for (format, data) in exports { + let mut dst = one_field(); + set_value(&mut dst, "Name", &FieldValue::Text("Ada".into())).unwrap(); + let report = import(&mut dst, &data); + assert_eq!(values(&dst), values(&src), "{format}: {report:?}: {}", String::from_utf8_lossy(&data)); + } +} + +/// A leaf holding only `` is not read by the import, so it must leave the field +/// as it was rather than clear it like an empty ``. +#[test] +fn a_richtext_only_field_in_xfdf_leaves_the_field_alone() { + let data = br#" +

Bo

"#; + let mut dst = one_field(); + set_value(&mut dst, "Name", &FieldValue::Text("Ada".into())).unwrap(); + let report = import(&mut dst, data); + assert_eq!(values(&dst), vec![("Name".to_string(), vec!["Ada".to_string()])], "{report:?}"); +} + +/// An FDF comment's pop-up is an annotation of its own (`/Popup` → a `/Subtype /Popup` object +/// with `/Parent`). The import copied the pop-up's dictionary into the comment as a direct value, +/// which ISO 32000-2 Table 172 says shall be an indirect reference to an annotation on the page. +/// The import now places a pop-up of its own, as the XFDF import does, keeping its place and +/// open state. +#[test] +fn an_fdf_comment_popup_is_not_copied_in_as_a_direct_dictionary() { + let fdf = fdf_with( + "/Annots [2 0 R 3 0 R]", + &[ + "<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /NM (n) /Page 0 /Contents (note) /Popup 3 0 R >>".into(), + "<< /Type /Annot /Subtype /Popup /Rect [40 10 140 60] /Parent 2 0 R /Page 0 /Open true >>".into(), + ], + ); + let mut dst = blank(); + assert_eq!(import(&mut dst, &fdf).unwrap().comments, 1); + let note = find_by_name(&dst, "n").unwrap(); + let popup = dst.get(note).as_dict().unwrap().get(b"Popup").cloned(); + let page = pdfcraft_model::pages(&dst)[0].dict.get(b"Annots").map(|a| dst.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap(); + let pr = popup.as_ref().and_then(Object::as_ref).filter(|r| page.contains(&Object::Ref(*r))); + let pr = pr.unwrap_or_else(|| panic!("/Popup must be a reference to an annotation on the page: {popup:?}")); + let pd = dst.get(pr).as_dict().cloned().unwrap(); + assert_eq!(pd.name(b"Subtype"), Some(b"Popup".as_slice())); + assert_eq!(pd.get(b"Parent"), Some(&Object::Ref(note))); + assert_eq!(pd.get(b"Open"), Some(&Object::Bool(true))); + let rect: Vec = pd.get(b"Rect").and_then(Object::as_array).unwrap().iter().filter_map(Object::as_f64).collect(); + assert_eq!(rect, [40.0, 10.0, 140.0, 60.0]); +} + +/// A pop-up without a usable `/Rect` cannot be placed: the comment still imports, with no +/// `/Popup` at all (neither the FDF's dictionary copied in nor a pop-up on the page). +#[test] +fn an_fdf_comment_popup_without_a_rect_is_dropped() { + let fdf = fdf_with( + "/Annots [2 0 R 3 0 R]", + &[ + "<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /NM (n) /Page 0 /Contents (note) /Popup 3 0 R >>".into(), + "<< /Type /Annot /Subtype /Popup /Parent 2 0 R /Page 0 /Open true >>".into(), + ], + ); + let mut dst = blank(); + assert_eq!(import(&mut dst, &fdf).unwrap().comments, 1); + let note = find_by_name(&dst, "n").unwrap(); + assert!(dst.get(note).as_dict().unwrap().get(b"Popup").is_none()); + let page = pdfcraft_model::pages(&dst)[0].dict.get(b"Annots").map(|a| dst.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap(); + let popups = page.iter().filter(|a| dst.resolve(a).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Popup".as_slice()))).count(); + assert_eq!(popups, 0); +} diff --git a/deny.toml b/deny.toml index ed9c26c206..f6c2028bbd 100644 --- a/deny.toml +++ b/deny.toml @@ -34,7 +34,13 @@ unknown-registry = "deny" unknown-git = "deny" # Shared UI components and spooler; Cargo.toml pins the immutable revision. craft-print pins # storytold's winprint fork (Windows printing, #1107) to an immutable revision of its own. -allow-git = ["https://github.com/storytold/craft-libs", "https://github.com/storytold/winprint.rs"] +allow-git = [ + "https://github.com/storytold/craft-libs", + "https://github.com/storytold/winprint.rs", + # The reviewed egui 0.37 and winit 0.30.13 fork revisions (immutable pins in Cargo.toml). + "https://github.com/storytold/egui", + "https://github.com/storytold/winit", +] [advisories] yanked = "warn" diff --git a/docs/editing-images.md b/docs/editing-images.md index 67d3613bf1..4ebafd115f 100644 --- a/docs/editing-images.md +++ b/docs/editing-images.md @@ -38,7 +38,9 @@ the full placement by the same change; resizing keeps the opposite corner fixed, horizontal-only or vertical-only corner drags, and grabbing a grip slightly off-centre does not jump. A selected corner keeps control of its drag even over a text label; the drag previews one frame and applies one undoable edit on release. Exclusive crops and their holes move -with the selected image; shared clips stay fixed without moving neighbouring text or artwork. +with the selected image, also when the picture sits inside a clip it shares with its section +(a report drawing each picture as `q re W n cm Do Q` within a table's clip): the picture's own +clip scales with it, and shared clips stay fixed without moving neighbouring text or artwork. `image_hit` applies the same path and hole checks headlessly. `page_images` retains `rect` as the full placement rectangle used by `image_edit` and adds `visible_rect` for selection (null diff --git a/docs/egui-037-migration-plan.md b/docs/egui-037-migration-plan.md new file mode 100644 index 0000000000..2871a57f7c --- /dev/null +++ b/docs/egui-037-migration-plan.md @@ -0,0 +1,812 @@ +# Current integration — 2026-10-25 + +The local integration includes main `d764a8ebca2dc289841b34ea3048f613a9869bbb` and pins craft-ui, craft-quit and craft-print to published `42e30cd5b3f4b114270dd053fccbc33bedc47f68`. The egui, winit and independent craft-docpath pins are unchanged. Its `Cargo.lock` SHA-256 is `3e0c10576c8584eb1f45a27908177e7c65bb83f8e855bf3a5429edd4e7268c80`. + +Main replaced the floating docking workspace with fixed shared side panes in `crates/ui-egui/src/side_panels.rs`. `PaneHost` owns the Tools and inspector columns; `Rail` owns the fixed navigation rail and footer. The integration retains the 0.37 ID, text alignment, capture, IME and accessibility adaptations. The former `docking.rs`, `rail-nav` panel and read-mode inspector panel no longer exist. Floating windows, tab groups and drag/redock operations from the earlier design are not capabilities of this integrated UI. Their historical acceptance items below do not establish current behavior. + +Focused scroll, zoom and RTL palette regressions passed. Removing the production fixes while retaining the tests reproduced double scrolling, double zooming and failed reordered-word RTL search; both source files were then restored byte-for-byte. Independent source review accepted the integration. Full combined-tree CI and the separate web WASM check passed with required external oracles: 5,190 passing test executions, zero failures and 22 ignored across 89 summaries. The first attempt stopped at the storage reservation guard; the successful rerun used an increased reservation. The 5,190 result comes from managed run `egui037-pdf-combined-ci-20261025-1340` and qualifies the integration of main `d764a8ebca2dc289841b34ea3048f613a9869bbb` with `Cargo.lock` SHA-256 `3e0c10576c8584eb1f45a27908177e7c65bb83f8e855bf3a5429edd4e7268c80`, published as `fbfad2039c650a02b39ceee5c3f0e4d6fd4b3f74`. The older 5,183-pass result covered commit `9032aeb35f7abe7777f66d4b835fd461a865c516`, integrating main `71b47b51`; it is a separate checkpoint and is not evidence for the current tree. Both runs recorded 22 ignored executions across 89 summaries. + +Remaining acceptance includes native/browser GUI, native IME, packaging and paired performance. Shared-pane F6 cycling and explicit RTL edge mirroring are not enabled. New side-pane command labels still need catalog translations; English fallback remains. Current panel checks must exercise Tools/inspector visibility, Read mode, resizing and persistence, rail selection, F4/Shift+F4, capture and accessible Pane/Splitter labels, rather than the removed floating/tab-group workflows. + +The qualification tables and original porting protocol below are historical records. Their old pins, lock hashes and docking design describe those checkpoints only; this section supersedes them for the current integration. + +> Historical records begin below this notice. Their source identities, lockfile hashes and test counts describe earlier checkpoints only; they do not qualify the current `d764a8eb` integration. The current integration and its 5,190-pass qualification are recorded above. + +# PdfCraft — egui 0.37 porting plan + +Status update (2026-10-25): Move PdfCraft to the pinned egui 0.37 and winit forks, preserving main `418ac6a2`, including suppression of page link hover while dragging a panel, page-pointer ownership, editor gestures and navigation/security fixes. Adapt UI lifecycle, capture and IME reset handling while preserving logical RTL text. Restore document-properties filename alignment and connect Initial View controls to their accessible labels. Validation: all ten CI stages and the separate web-shell WASM check passed. Focused pointer regressions plus CI recorded 5,130 passing test executions, zero failures and 28 ignored across 143 summaries. The dragged-panel link regression passes on egui 0.37; independent review accepted preservation of the upstream fix. The alignment and accessible-name regressions failed before their fixes and pass afterward; independent reviews accepted the integration and both fixes. Pins and lockfile remain unchanged. Native/browser GUI, native IME, paired app performance and final platform/package acceptance remain open. Human review and human merge are required. + +The original planning record below is historical. + +Status update (2026-10-25): Move PdfCraft to the pinned egui 0.37 and winit forks, preserving main `24edde65`, including page-pointer ownership, editor gestures and navigation/security fixes. Adapt UI lifecycle, capture and IME reset handling while preserving logical RTL text. Restore document-properties filename alignment and connect Initial View controls to their accessible labels. Validation: all ten CI stages and the separate web-shell WASM check passed. Focused dialog regressions plus CI recorded 5,111 passing test executions, zero failures and 22 ignored across 143 summaries. The alignment and accessible-name regressions failed before their fixes and pass afterward; independent reviews accepted the integration and both fixes. Pins and lockfile remain unchanged. Native/browser GUI, native IME, paired app performance and final platform/package acceptance remain open. Human review and human merge are required. + +The original planning record below is historical. + +Status: implemented locally against `a2ec27b4775f53c40cad3456936d9406611e3a8d`; not published. Production code, manifests, vendor provenance and tests in this checkout carry the migration. The 2026-10-25 qualification section below supersedes the historical external-config and missing-IME statements; the 2026-10-24 amendment records the earlier local state. The manifests pin egui `6d518fc799681571963842f95b55a6b68431b15a`, winit `a517b3846ee8457d84ee4975110bfda6797c44dc`, and craft-ui/craft-quit/craft-print `25da1314d443171b4adeaad2cd9454b273f35c6f` directly by immutable git revision, while `craft-docpath` remains at `82a24b006eefa036b43c09a67fb9a3fbd17ffe63`; `Cargo.lock` is `f5cee0acb795240d2071116230316fd4e4b6bfe4e4314c82b0a4ca5459739f80`, and the target-main observation is `a7801dc84e74dd12757aecbb6be324dce7a33939`. The repository quality gates passed as recorded below; publication still requires fresh human approval. This revision also incorporates the independent adversarial review of the prior plan (`f781bf52d03d59cd2fb4539771c7a0d196e2f49e64c48635e72c224c9bdcdcd0`). + +## Historical qualification state — 2026-10-25 + +The managed run `egui037-pdfcraft-reviewed-ui-ci-20261025-0541` passed the first nine CI stages, including formatting, strict lint, workspace tests, WASM compilation, assets and dependency checks. It recorded 5,025 passing test executions, zero failures and 16 ignored across 198 summaries (including four focused regression executions). The final parity stage found one stale test reference after the Arabic logical-order test was renamed; the reference was corrected without changing executable source. Formatting and parity then passed in `egui037-pdfcraft-parity-final-20261025-0555`. An intervening submission `0553` was refused by the storage guard before execution and is not validation evidence. Final documentation/attribution corrections were checked by formatting, assets generation/validation and parity in `egui037-pdfcraft-final-doc-assets-20261025-0571`. + +The page-text editor keeps the prior four-by-two inset under egui 0.37, and print tests locate the Two-sided control by ComboBox role. Both narrow/wide text image regressions and booklet regressions passed before the full suite. Canonical dependency/source guards retain the vendored security and rendering provenance. The repository WASM gate checks the library layers. The separate web application shell also passed `cargo check --locked --target wasm32-unknown-unknown -p pdfcraft-web` in `egui037-pdfcraft-web-shell-check-20261025-0575`. Native GUI and IME/PRIMARY, browser interaction, packaged WASM size, real-world old settings and paired app performance remain open; these checks do not establish cross-platform acceptance. + +This section supersedes every historical statement below that the egui, winit or +craft-libs revisions resolve through `local-integration-config.toml`, that the +checked-in manifest does not pin them, or that E21b/P7b surrounding-text support is +absent from the candidate. Those statements are planning-era history; the current +tree does not use an external Cargo config. + +| Item | Current value | +|---|---| +| egui family | `6d518fc799681571963842f95b55a6b68431b15a` (storytold/egui) | +| winit | `a517b3846ee8457d84ee4975110bfda6797c44dc` (storytold/winit) | +| craft-ui / craft-quit / craft-print | `25da1314d443171b4adeaad2cd9454b273f35c6f` (storytold/craft-libs) | +| `Cargo.lock` SHA-256 | `f5cee0acb795240d2071116230316fd4e4b6bfe4e4314c82b0a4ca5459739f80` | +| Target-main observation | `a7801dc84e74dd12757aecbb6be324dce7a33939` | + +`Cargo.toml` pins those revisions directly and `Cargo.lock` resolves them to the +same immutable git sources; no `local-integration-config.toml` is read. The +retained `vendor/winit` and `vendor/egui-wgpu` copies remain provenance for the +patch text and source guards, not runtime path patches. + +The pinned egui `6d518fc` tree contains the `TextEdit` outgoing +surrounding-text/`IMEOutput` producer and egui-winit calls +`set_ime_surrounding_text`. PdfCraft adds no custom adapter. Native platform +execution and the outgoing-text tests remain unqualified. + +## 1. Baseline and scope + +| Item | Value | +|---|---| +| PdfCraft source | current owned checkout `a2ec27b4775f53c40cad3456936d9406611e3a8d`; the original planning baseline was `0dc77fe4ed8810b9e9f25b9dd050afae662c062a` (`docs/egui-037-plan-20261022`, tree `ebb7259286988cf937ade02ab08db67064b04084`) | +| PdfCraft lockfile | current `Cargo.lock` is the direct-pin qualification lock (SHA-256 `f5cee0acb795240d2071116230316fd4e4b6bfe4e4314c82b0a4ca5459739f80`); it is not publication evidence. The planning baseline lock SHA-256 was `eaee6973f7c1ba79f5bfc1bfa3da50ef457d13affeb51a3d50d27437804d4cf7`. | +| Planning-baseline egui family | `egui`, `eframe`, `egui-winit`, `egui_extras`, `egui_kittest` 0.36.2 from crates.io; `egui-wgpu` 0.36.2 vendored | +| Planning-baseline winit | 0.30.13 vendored; upstream tag commit `e9809ef54b18499bb4f2cac945719ecc2a61061b` | +| Planning-baseline craft-libs pins | `craft-ui` `5c874d51eeb3de70e735c8ce87a59a7dedd47675`, `craft-quit` `f58bc1a3df490992d633393d0898af0588ce882c`, `craft-print` `666c358065261a2816b376fa3bb10538dbf53040`, `craft-docpath` `82a24b006eefa036b43c09a67fb9a3fbd17ffe63` | +| Planning-era shared-libs integration base | `3832c47ef68a0e6214f2c1e85f3526b6adcb686e`; an ancestor of the current craft-libs pin `25da1314d443171b4adeaad2cd9454b273f35c6f` | +| Historical planning observation of craft-libs PR 199 | OPEN / CONFLICTING at the then-inspected head `b68de8c5f4f1c060904d211a7b1320aa0f7bce78`; API-port ancestor `8614e4a04fae516fba16c22b31ff1008cb6be10b`; audit merge `ec8840af8c5fda695cf195cdd05e5ae078415c07` | +| Target egui | 0.37.0, upstream release commit `841a328ba2dd8f6996beb7189ebb7fc00e668a2a` | +| Planning-era reviewed egui candidate | `aiongg/craft/0.37` HEAD `a4f9a17f64a6bae61350fb85804c9df0d9865916`; the current pin is `6d518fc799681571963842f95b55a6b68431b15a` | +| Planning-era reviewed winit candidate | `craft/0.30.13-lang` HEAD `8b35388bd719e8cc86a95971ba0bc026c9a8f058`; it lacks W15P PRIMARY, which the current pin `a517b3846ee8457d84ee4975110bfda6797c44dc` contains | +| Shared rollout contract | `craft-libs/docs/egui-037-suite-rollout.md`, current reviewed content identified by the publication/review ledger; record its landed commit before implementation | +| Shared library plan | `craft-libs/docs/egui-037-migration-plan.md`, current reviewed content identified by the publication/review ledger; record its landed commit before implementation | +| Shared egui plan | `egui/docs/egui-037-migration-plan.md`, current reviewed content identified by the publication/review ledger; record its landed commit before implementation | + +The following paragraph records the pre-merge planning assessment; PR 199 subsequently merged, and the current pinned shared-library candidate includes its integration and later main work. At planning time, PR 199 was an input to the shared-library work, not a branch to merge. Its head and `ec8840a` are stale relative to current main: they omit `craft-platform` and revert security, settings, docking and printing fixes. Only the API-port hunks from `8614e4a` and the README change from `1764bca` are reused, applied onto `3832c47`; the final library revision must be reviewed and published separately. + +This plan covers only PdfCraft. It does not adopt, reset or migrate any other checkout, job, VM or shared target. Historical diagnostics retained in the private task evidence for `egui037-audit-20261022` are pointers only; they are not acceptance evidence. + +Entry points: + +- Native: `apps/pdfcraft/src/main.rs::main` calls `eframe::run_native`; `app_creator` (`main.rs:365`) builds `PdfCraftApp` and wires storage, control, recovery and GPU state. +- Web: `apps/pdfcraft-web/src/main.rs::main` uses `eframe::WebRunner::start` with `WebOptions { renderer: Glow }`, `?file=`, `?host=parent` and the before-unload warning. +- Headless: `crates/ui-egui/examples/shot.rs` uses `egui_kittest::Harness::build_eframe` plus `Harness::render`; `apps/pdfcraft-cli` has no egui/eframe dependency. +- Tests: `crates/ui-egui/tests/it/main.rs` links the integrated UI tests; `apps/pdfcraft/tests/it/main.rs` links twelve winit source guards; `crates/ui-egui/src/control.rs` has the in-crate control/pointer probes; `crates/ui-egui/tests/perf.rs` is the existing timing harness. + +Targets to keep green: + +- `apps/pdfcraft` (native binary; wgpu plus glow fallback, accesskit, persistence, links, x11/wayland, native clipboard). +- `apps/pdfcraft-web` (wasm32, eframe glow; no egui-winit clipboard feature is needed or available there). +- `apps/pdfcraft-cli` (no egui dependency; unchanged). +- `crates/ui-egui` (library, unit tests, `tests/it` integrated binary, `tests/perf.rs`, `examples/shot.rs`). +- `xtask` gates (`fmt`, clippy, test, layers, wasm, assets, deny, parity). + +## 2. Actual lifecycle, features and compatibility surface + +### 2.1 App boundary + +`PdfCraftApp` is declared in `crates/ui-egui/src/lib.rs`; the eframe implementation is `impl eframe::App for PdfCraftApp`: + +- `raw_input_hook` applies line-scroll speed and injects the OS drag pointer position before egui reads the frame. +- `save` repairs minimized-window storage through `window_state::WindowState::keep`. +- `logic` owns host-side work: context capture, window repair, i18n selection, font installation, UI scale, drag/drop completion, inbox/open queues, OS events, control-channel requests, quit guard, autosave, file watching, update/print/export/OCR/optimize/action polls, deferred shortcuts, per-view input blocking, pending edits and picker completion. +- `ui` owns per-pass layout: title command, full-screen or normal panels, tab strip, mode bar, right rail, docking or read-mode central panel, palette, dialogs, progress, toasts, then `finish_render_frame`. +- `finish_render_frame` suspends hidden tabs' render pools and commits the visible/compare views' frame demand. + +`crates/ui-egui/src/canvas.rs` is a second lifecycle: `DocView::begin_render_frame` resets demand and the frame queue; `DocView::finish_render_frame` builds the render queue, feeds the pool and calls `DocView::receive` for a bounded upload batch. + +### 2.2 Lifecycle truth: `logic` is not a frame clock + +At the pinned 0.37 source, both native eframe (`crates/eframe/src/native/epi_integration.rs::update`) and web (`crates/eframe/src/web/app_runner.rs::logic`) call `app.logic` and then `app.ui` inside the `Context::run_ui` closure. A discarded layout reruns both. Hidden or occluded windows can call `App::logic` through `Context::run_logic`, which updates only raw viewport/window-focus information; events, input time, layout and normal pass state remain from the previous UI pass. Input therefore belongs only in a known UI phase (`App::ui` or an explicit method it calls), never in background work that reads `ctx.input` under the assumption that the window is hidden. + +Two corrections are binding: + +- `ViewportInfo::visible()` describes minimized/occluded state; it does not prove that the current callback is logic-only. A hidden UI paint can be requested for screenshots or injected input. +- After a normally completed `run_ui`, `current_pass_index()` is zero even after multiple passes: `ContextImpl::end_pass` takes the viewport output with `mem::take`, and `run_dyn` restores the count only immediately before the next UI pass. A first-pass guard does not by itself skip ordinary hidden `run_logic` calls. It is still not a frame clock and cannot supply fresh input. + +No `run_ui_with_logic`/E22 scheduler API is adopted. There is no stable one-frame boundary inside `run_ui`, and both hosts already run logic per pass. Queues keyed by consumed identity, command/generation identity and monotonic deadlines provide the required behavior. + +### 2.3 Hidden work and current input + +- `App::logic` may run repeatedly in one visible frame and may run alone while hidden. Autosave, recovery, playback/audio, open completion, file watching, polls and undo application stay there, but use monotonic elapsed time (`std::time::Instant`, `web_time::Instant` on wasm), not `ctx.input(|i| i.time)`. +- Fresh wheel, pointer, keyboard and IME input is handled in `App::ui` before panels. `Motion::begin_pass` moves from `logic` into `ui` (before any widget reads wheel input) or is guarded by a fresh-UI-pass token; it must never consume the same `ctx.input` event twice. +- The grid-drop deadline and control-adjacent timers use monotonic deadlines too. +- UI-produced commands may commit promptly from the UI. The requirement is exactly-once consumption, not "never commit on a discarded pass": egui cannot know at commit time whether a later widget will request a discard. Screenshot completion and render queues may be cancelled without side effects; an edit/undo transaction may not be duplicated when the pass is replayed. + +### 2.4 Feature and cfg inventory + +| cfg / feature | Where it matters | +|---|---| +| `target_arch = "wasm32"` | `apps/pdfcraft-web`; `ui-egui` disables `file_watch`, `pickers`, `system_fonts`; `fill_sign` image inbox and host bridge; no native window and no egui-winit clipboard | +| `windows` | hidden-maximized restore, DPI change, DWM frame, IME cursor, auto language, measurement units, DX12 presentation (`apps/pdfcraft/src/main.rs`, `motion.rs`, `single_instance.rs`, winit patches W1/W3/W4/W18/W19) | +| `target_os = "macos"` | Apple events, traffic lights/full-size content view, ⌘Q menu (`craft-quit`), title-bar drag, IME commit/discard/emoji/Korean (W6–W10, W12, W22–W24, W26) | +| `target_os = "linux"` | GNOME/Wayland frame decision, compositor GPU selection, Wayland regular clipboard and W15P PRIMARY (W15/W15P/E4), X11 DPI/IME (W5/W11/W20/W21), Wayland scroll phases (W13) | +| `unix, not macos` | single-instance Unix socket, Wayland/X11 detection, fontconfig directory parsing | +| `not unix` / other | Windows and wasm branches above; no silent fallback to a native-only API | +| Native eframe features | `default_fonts`, `wgpu`, `glow`, `accesskit`, `persistence`, `links`, `wayland`, `x11`, **`clipboard`** | +| `ui-egui` eframe features | `default_fonts`, `wgpu`, `accesskit`, `persistence`, `links`, **`clipboard`** for native consumer/test parity | +| Web eframe features | `default_fonts`, `glow`, `persistence`, `accesskit`; no winit clipboard path | +| egui features | `bytemuck` in `ui-egui`; `default_fonts` explicit; **`monochrome_emoji_fonts`** in every egui-consuming target that draws PdfCraft UI text | +| eframe `system_fonts` | stays off; `NativeOptions::system_font_fallback = false` | + +`cargo xtask wasm` checks crates below L8; `cargo check -p pdfcraft-web --target wasm32-unknown-unknown` remains required for the actual web shell. A CI feature-identity assertion must show `egui-winit/clipboard` enabled for native roots after the migration, and `cargo deny`/`cargo metadata` must show exactly the approved fork sources. + +### 2.5 Control channel and screenshots + +`crates/ui-egui/src/control.rs` is the user-observable automation surface. Today it installs `ControlPlugin`, stores the AccessKit tree and injects queued input in hooks, handles requests in `Control::tick`, receives screenshots as `egui::Event::Screenshot`, sends `ViewportCommand::Screenshot(UserData::new(tag))`, crops and PNG-encodes in `screenshot_png`, and times out after 5 seconds. Its tests live both in `src/control.rs` cfg-test modules and `crates/ui-egui/tests/it/control.rs` (65 tests, linked by `tests/it/main.rs`), including discard handling and `screenshots_of_window_and_region`. PR 1 updates both sets. + +egui 0.37 removes `Event::Screenshot` and changes `ViewportCommand::Screenshot` to a one-shot `ScreenshotCallback`. A pending screenshot command itself asks the integration to paint a hidden window; `RequestPaintWhileHidden` is additionally required when injected input must be applied without a screenshot. + +`Shared::passes` is a pass counter, not a frame counter. The migration renames the unit to accepted frames and increments it in `output_hook` only when `output.platform_output.request_discard_reasons.is_empty()`; an intermediate discarded pass does not count. A request made before a forced discard is not answered before the intended number of accepted frames. + +### 2.6 Panels, docking and window chrome (planning baseline) + +PdfCraft uses the 0.37 `Panel` API already in its 0.36 form: + +- `chrome::tab_strip`: `Panel::top("tab_strip")`; +- `chrome::mode_bar`: `Panel::top("mode_bar")`; +- `chrome::right_rail`: `Panel::right("rail")` and `Panel::bottom("rail-nav")`; +- read-mode inspector: `Panel::right("read_right_panel")`; +- central panel in `lib.rs`. + +0.36 stores `PanelState` under the panel `Id` passed to the constructor. 0.37 derives it as `parent_ui.scope_id().with_salt(IdSalt::new(id_salt))`; the panel child `Ui` is also salted with the new panel ID. Every named panel key therefore changes, and in-panel widget/focus/scroll state may reset. This is not left to implementation: §6.2 migrates the persisted keys in the first compatibility PR and tests the upgrade from a 0.36 fixture. + +Docking is `crates/ui-egui/src/docking.rs`; `DockArea::new(egui::Id::unique("pdfcraft-docking"))` wraps `craft_ui::docking`. Persistence is PdfCraft's own `Workspace` JSON, not egui panel memory. `DockArea::direction` is a new opt-in stock-compatible API planned in craft-libs CL-3; the explicit RTL adoption in PR 4 waits for that reviewed revision. Its visual policy preserves saved physical splits, rectangles and logical tab order. + +### 2.7 Window frame, decorations and restoration + +`apps/pdfcraft/src/main.rs` owns native options, app creation and GPU setup. `apps/pdfcraft/src/decorations.rs` decides whether GNOME Wayland leaves the frame to the app. `crates/ui-egui/src/window_controls.rs` draws the app frame and resize edges, and `window_state.rs` repairs a 64×64 restored window. + +`craft-window` owns `Frame`, `Layout` and `Button`; `window_controls.rs` currently has distinct duplicate types and its own GNOME `button-layout` parser. The migration makes `window_controls` re-export `craft_window::{Button, Layout}` and removes the duplicate parser. `decorations::app_drawn` uses `craft_window::Choice::probe(Prefix::new("PDFCRAFT"))`; `PDFCRAFT_TITLEBAR` accepts `app`, `system` and `desktop` (the last two force the desktop frame), matching craft-window. PdfCraft still applies `with_decorations(false)` only when it will draw its own header bar, and the runtime “Use system title bar” preference still toggles `ViewportCommand::Decorations`; craft-window decides the startup frame, not the runtime preference. The probe is called once at startup and never from `App::ui`. + +### 2.8 Fonts and language + +`theme::install_fonts_for` calls `ctx.set_fonts(installed_font_definitions(cjk))`; `installed_font_definitions` appends one installed face plus optional script fallbacks. `theme::on_interface_baseline` computes `FontTweak::y_offset_factor` from primary and fallback metrics. `system_fonts::fallback` is the single-face, last-resort policy; `PDFCRAFT_SYSTEM_FONTS=0` disables it. + +At the planning baseline, the i18n layer preordered right-to-left translations at catalog load (`i18n::LangInfo.rtl`, `catalog.map_translations(display_rtl)`), and `bidi::display_rtl`, `bidi::visual`, `bidi::wrap` and `bidi::visual_auto` preordered runs because egui 0.36 does not. The current migration removed catalog preordering; `display_rtl` and the other preordering helpers remain test-only. The fork's E5/E12/E13/E15/E16/E17/E20 own reordering now. Keeping `display_rtl` in production alongside E5 would double-reorder text. + +### 2.9 Clipboard, IME and input + +Text copy/paste goes through egui events; images and file paste use `arboard` directly. Native eframe 0.37 no longer enables `egui-winit/clipboard` implicitly when `default-features = false`; without the explicit feature, text cut/copy/paste loses OS integration and the Wayland clipboard/PRIMARY path is compiled out. `arboard` image/file code can mask that loss in a superficial check. + +PdfCraft uses ordinary `egui::TextEdit` for its dialogs, forms, comments composer and text editor; it has no custom IME editor producer requiring an app-side surrounding-text adapter. The pinned egui `6d518fc` contains the TextEdit producer/`IMEOutput` surrounding-text path, and egui-winit calls `set_ime_surrounding_text`; native platform execution and outgoing-text tests remain unqualified. + +## 3. Dependency, lockfile and vendor audit + +### 3.1 Manifests + +| Manifest | egui/eframe facts and required change | +|---|---| +| `Cargo.toml` workspace | egui/eframe/egui_extras/egui_kittest 0.36; `[patch.crates-io]` contains `winit = vendor/winit` and `egui-wgpu = vendor/egui-wgpu`. Replace window-stack path patches with approved immutable fork revisions; retain PDF vendors | +| `crates/ui-egui/Cargo.toml` | eframe features `default_fonts`, `wgpu`, `accesskit`, `persistence`, `links`; add `clipboard`; egui features gain `monochrome_emoji_fonts`; egui_kittest dev-dep `eframe`, `wgpu` | +| `apps/pdfcraft/Cargo.toml` | eframe features `default_fonts`, `wgpu`, `glow`, `accesskit`, `wayland`, `x11`, `persistence`, `links`; add `clipboard`; Linux dev-dep winit 0.30 for the scroll-policy test | +| `apps/pdfcraft-web/Cargo.toml` | eframe features `default_fonts`, `glow`, `persistence`, `accesskit`; no clipboard feature or egui-winit dependency | +| `apps/pdfcraft-cli/Cargo.toml` | no egui/eframe; unaffected | + +egui 0.37 no longer enables `default_fonts` by default. PdfCraft explicitly enables it in its own features. `monochrome_emoji_fonts` is chosen (not replacing `✔`/`▲`/`▼` with new SVG glyphs): the fonts are already OFL/MIT, attributed, and preserve existing text symbols deterministically with `PDFCRAFT_SYSTEM_FONTS=0`. The painted submenu arrow is not a font glyph after 0.37 and needs no emoji font. + +### 3.2 Lockfile duplicates and family resolution + +At planning time the lockfile contained one egui family at 0.36.2; the current qualification lockfile resolves the family at 0.37.0 (see `Current qualification state — 2026-10-25`). Several `vello_common`/`vello_cpu` minors still come through the PDF renderer and epaint. The target state must prove one egui family, one epaint family and one winit: + +- `egui`, `epaint`, `emath`, `ecolor`, `eframe`, `egui-winit`, `egui-wgpu`, `egui_extras`, `egui_kittest` all at 0.37.0 from one source. +- `winit` at 0.30.13 with the craft language patches, from one source. +- `wgpu` remains 30.x; `egui-wgpu` matches the resolved wgpu. +- `accesskit` moves 0.24.x → 0.25.x; `accesskit_winit` 0.32.x → 0.34.x; epaint moves to `vello_cpu` 0.2.0 while hayro remains on 0.0.8. E11 is not required for PdfCraft because these versions cannot unify (verify with `cargo tree -e features -i vello_cpu`). +- Verify `cargo metadata` package versions and sources, not tag names. + +### 3.3 Vendored winit provenance and compatibility + +`vendor/winit` is 0.30.13 (`vendor/winit/.cargo_vcs_info.json` = `e9809ef5…`) and carries **W1–W13**, not W1–W15. It has no language APIs. The planning-era candidate winit fork `8b35388b` carried W1–W15 plus W17–W27 but not W15P; W16 is not in this cut. W14 (`c488d2e9`, macOS cancelable termination plus `dpi` from crates.io) and W15 (`cf73a29c`, `b05ce129`, per-seat Wayland data device and regular clipboard) are in the fork, not the current vendor. + +W14 remains opt-in in the shared winit revision and is **not enabled** by PdfCraft; its existing termination path stays `craft-quit`. Validate that `dpi` resolves once with the git source. W15 regular clipboard is required by E4. In the planning-era candidate `8b35388b`, the **W15P** PRIMARY implementation was a prerequisite added by the shared winit work, using SCTK 0.19.2's `PrimarySelectionManagerState`/device/source/offer on the existing connection and event queue, and had to land before egui P2b. The current pin `a517b384` contains W15P (`PATCHES.md` W15P row; `src/platform_impl/linux/wayland/clipboard.rs`) and W27; native evidence remains open. No second regular `wl_data_device` is acceptable. + +The egui fork requires companion winit APIs on their target platforms. Integrate and review the series in the approved public storytold fork, compare every current PdfCraft vendor delta, transfer behavioral tests, then replace the path patch with the final immutable git revision. Record provenance and old/new source hashes in migration evidence. Keep the original vendor until equivalence is established; do not publish an interim personal-fork branch dependency. + +### 3.4 Source-guard migration and exact inventory + +`apps/pdfcraft/tests/it/main.rs` links twelve winit guard modules. All twelve must be transferred to the winit fork (or replaced by equivalent fork-owned immutable tests), and only behavioral/ABI tests remain in PdfCraft: + +`winit_macos_emoji_picker`, `winit_macos_ime_commit`, `winit_macos_ime_discard`, `winit_macos_korean_first_key`, `winit_macos_korean_space`, `winit_macos_title_bar_drag` (W12), `winit_wayland_scroll` (W13), `winit_wayland_text_input` (W2), `winit_windows_hidden_maximized` (W4), `winit_windows_ime_cursor` (W3), `winit_x11_dpi` (W11), `winit_x11_ime_focus` (W5). + +PdfCraft keeps the retained `vendor/winit` and `vendor/egui-wgpu` copies as provenance and may read them only for provenance assertions (W1 patch text, base hashes, the W1–W13/E2+E3 rows), never as proof of the code the app runs. The resolved graph is checked from `Cargo.lock`: `vendor_graph.rs` requires the exact canonical storytold git URL, 40-character revision and fragment for the two crates it inspects, `winit` and `egui-wgpu`, and rejects path sources. W17 covers W2, W3, W5–W10, W12 and W13; add W1, W4, W11 and the egui-wgpu behavioral coverage to the same central evidence map. The W15/W15P guard belongs on the winit fork or a behavior test, never on `vendor/winit`. + +### 3.5 Vendored egui-wgpu provenance + +`vendor/egui-wgpu` is 0.36.2 (`49682f8b…`) with E2+E3. The target is 0.37.0 plus the same two patches. The reviewed fork's E2+E3 commit is `9dd799ab` and adds a `surface_fit` unit test; PdfCraft also has stronger guards: + +- `apps/pdfcraft/src/main.rs::a_window_larger_than_the_gpu_limit_gets_a_surface_within_it` +- `apps/pdfcraft/src/main.rs::surface_fit_never_empties_or_overflows_a_surface` +- `apps/pdfcraft/src/main.rs::vendor_provenance_egui_wgpu_surface_fit_identity` +- `apps/pdfcraft/src/main.rs::a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic` +- `apps/pdfcraft/src/main.rs::vendor_provenance_egui_wgpu_catch_errors_identity` +- `apps/pdfcraft/src/main.rs::pipeline_creation_error_is_returned_and_device_remains_usable` + +Integrate E2+E3 and the behavioral regressions into the central 0.37 fork, then consume the approved revision. Replace the source-path/source-absence assertions with immutable package identity checks. `surface_texture_limits` and `configure_gpu` remain: the first raises the enabled 2D limit, the second preserves adapter selection, DX12 presentation and indirect-validation behavior. + +### 3.6 craft-libs: port only, and account for craft-print/GDI/rgb drift + +PR 199's egui port changes `craft-ui` roles, `Id::unique`/`Ui::scope_id`, `egui_extras`/`egui_kittest` 0.37, and the docking fixture's `⋮` → `…`. Reuse those API hunks on current main; do not import the stale tree. PdfCraft needs `craft-ui` for docking/icons/tabs/layout, `craft-window` for the frame decision, and no wholesale move of its PDF-specific print UI into `craft_ui::printing`. + +Bumping all four craft-libs crates from their four current pins listed in section 1 to a revision based on `3832c47` carries more than a new field: + +- `craft-print` adds the unconditional `craft-winprint-gdi` dependency (`storytold/winprint.rs`, revision `417ac3c4…`), a public `rgb` re-export of the GDI transport, `lpstat -l` description parsing and `Printer.description`. +- Audit `craft-winprint-gdi` licensing/target cfg/`cargo deny`/Windows build, and the non-Windows `rgb::PrintError::Unsupported` behavior. +- `Printer` now has `description`; update every literal in the repository. At baseline there are 19 `Printer { … }` literals across `crates/print` and UI tests, not 13. This is a mechanical compile fix, with the craft-print tests and Windows native checks re-run. +- Keep PdfCraft's own `print_ui.rs` sheet preview and options. `description` is not automatically displayed; a product change to show it is separate. +- The current main already includes intervening security bounds, settings merge, docking permissions and linear printer-name collision checks; preserve them. The shared-library plan's CL-1 diff gate remains mandatory. + +### 3.7 Publication and duplicate-resolution rules + +- Pin immutable revisions. A branch name is not a pin. +- `[patch.crates-io]` in the egui or winit repository does not propagate into PdfCraft. PdfCraft's root must patch every resolved egui-workspace package, including `egui_glow` for GL, `epaint_default_fonts`, the used core/render/test crates, and winit. +- Use approved public immutable storytold fork revisions after vendor equivalence review. Never add a machine-local absolute path patch or publish another vendor fork to bypass dependency publication. +- Add a CI assertion that `cargo metadata` resolves exactly one package ID for each family crate and one winit, and that no `path` dependency points outside the repository. + +### 3.8 Vendors outside the UI port + +`vendor/hayro*`, `vendor/lopdf` and `vendor/vello_common` are renderer/text-engine vendors. Keep their revisions, patches and tests. Inspect vello package identities and feature unification; incompatible versions may legitimately coexist. Do not force unrelated major versions to unify or edit PDF renderer vendors merely to make `cargo tree -d` empty. + +## 4. egui 0.37 breaking changes that touch PdfCraft + +| Change | PdfCraft use | Required action | +|---|---|---| +| `default_fonts` no longer a default feature | explicit in ui-egui, pdfcraft, web, craft-ui dev-dep | keep explicit | +| `monochrome_emoji_fonts` gates emoji faces | `✔`, `▲`, `▼` and previously `⏵` text | enable the feature; add `egui-icons.ttf` attribution; keep painted submenu arrows out of font coverage | +| eframe `clipboard` becomes explicit | native eframe with `default-features = false` | enable in native app and native UI tests; web needs none; add feature-identity check | +| `WidgetType` deprecated alias for `Role` | 52 references, 56 `widget_info` closures | map roles explicitly | +| `Id::new` deprecated → `Id::unique` | 74 references | semantic rename | +| `Ui::id()` deprecated → `Ui::scope_id()` | 20 references | mechanical rename; `scope_id()` is the value `id()` returned, not a new inherited ID | +| `UiBuilder::id` deprecated → `scope_id` | `canvas.rs:2607`; not `chrome.rs:697` | rename only the UiBuilder call | +| `Panel` constructors take `AsIdSalt`; panel keys derive from parent scope | 5 panels | migrate 0.36 `PanelState` keys in PR 1 and test the upgrade | +| `Ui::shrink_clip_rect` for panels | all panel content | test clip intersection and overlays | +| `ViewportCommand::Screenshot` callback; `Event::Screenshot` removed | `control.rs` | bounded weak-slot callback; screenshot command itself paints a hidden window | +| `Event::MouseWheel` gains `source: MouseWheelSource` | 41 references | add source; use `Unknown`/`Wheel`/`Trackpad`/`Momentum` only | +| `ScrollStyle.kinetic` / rubber-banding | `fling.rs`, `wheel_pager.rs`, `canvas.rs` | keep PdfCraft fling as document owner; leave egui kinetic for ordinary ScrollAreas; prove one curve | +| `NativeOptions.system_font_fallback` and `system_fonts` feature | not enabled today | set false and guard | +| kittest panics on missing glyphs and unnamed inputs | 204 harness constructions in `tests/it` | fix names/glyphs in PR 1; no global disabling | +| `Event::PasteImage`, `MiddleClickPaste`, PRIMARY | image paste via arboard | adopt the W15P PRIMARY contract; no app-level filtering of the event | +| `Response::accessible_name`, `Ui::label_inputs_by`, `Ui::name_inputs` | custom widgets and text fields | use for accessible names | +| Submenu arrow is a painted `arrow_atom` | test labels `"File ⏵"`, `"View ⏵"`, etc. | update labels/accessible names and keep an arrow-painted assertion | + +## 5. Fork patch audit: E1–E21 and E21b (planning-era baseline) + +Base: `0.37.0` = `841a328b`. The E1–E21 audit below was made against the planning-era fork head `a4f9a17f`; the current qualification pin is `6d518fc` (see `Current qualification state — 2026-10-25`), and the E21b row records the later pin. The patch table in the fork's `PATCHES.md` is provenance; each row was re-audited against the source at `a4f9a17f` and PdfCraft's needs, so rows naming `a4f9a17f` are planning-era history. + +| # | Fork commits | Crate | Upstream status at 0.37.0 | PdfCraft need | PdfCraft tests / removal condition | +|---|---|---|---|---|---| +| E1 | `8f3abf06` | eframe | not upstream; `ViewportHooks` absent | none today: PdfCraft opens one root viewport; `--new-window` is a separate process | compile only; keep for craft-tablet. Remove when upstream adds per-viewport hooks or craft-tablet no longer needs it | +| E2+E3 | `9dd799ab` | egui-wgpu | not upstream; #8361 open | required: `Surface::configure` panic and start-up errors | six `apps/pdfcraft/src/main.rs` surface/GPU guards plus fork-owned behavioral tests; remove when upstream fixes #8361 and the start-up error path | +| E4 | `66bacf23` | egui-winit | upstream #8657 draft; 0.37 still uses smithay | required on Hyprland and every Wayland compositor serving one data device | W15 regular clipboard plus W15P PRIMARY; native Wayland single-data-device test; remove when upstream has the behavior | +| E5 | `0e27d87e`, `777977b7`, `f7e89e23`, `5b2b8e19` | epaint, egui | #8577 open, #8698 reviewed; not in 0.37 | required for Arabic, Kurdish, Hebrew and mixed-direction text | bidi shaping, caret, selection, mark and no-default-fonts tests; remove only when upstream merges #8577 plus fixes | +| E6 | `0abfabcf` | egui | not proposed | required: caret moves must not kill IME composition | text-edit IME focus test; remove when upstream has equivalent | +| E7 | `0670c58a` | egui-winit | not upstream | required: shortcuts on Russian/Arabic/Hebrew Windows layouts | native Windows layout test plus source guard; remove when upstream | +| E8 | `1b03c10f` | egui | not upstream | required: Thai/Vietnamese/Arabic marks above and below the row | TextEdit pixel/ink test; remove when upstream | +| E9 | `b2f5cafc`, `e1341a3d`, `5105330d`, `75a5b3d3` | egui | not upstream | required for Arabic/Kurdish chrome (menus, scroll bars, combo boxes, headers) | RTL chrome geometry tests, no double-mirroring; remove when upstream | +| E10 | `dc90c13e`, `c8e573f7`, `34b2c0d4` | eframe web | not upstream; 0.37 has no `web_keys.rs` | required for the web build on non-Latin layouts and named keys | web build + browser keyboard test; remove when upstream | +| E11 | `92e61afd` | epaint | #8641 draft; not in 0.37 | not required by PdfCraft: epaint 0.2 vello_cpu cannot unify with hayro 0.0.8 | verify with `cargo tree -e features -i vello_cpu`; keep because other suite apps enable multithreading; remove when upstream flushes | +| E12 | `e955bb18`, `485c0b89`, `0185887c`, `7015ac23`, `4067b2c4`, `620e070e` | epaint, egui | not upstream; follow-up to #8577 | required for paragraph direction, RTL alignment, ellipsis at the visual end, UAX #9 L1 and linear reordering | direct direction/ellipsis/L1 tests; remove when upstream | +| E13 | `5e4da7f4` | epaint, egui | not upstream | required for caret/hit-test/Delete/Backspace by grapheme cluster | emoji, combining accent, Devanagari cluster tests; remove when upstream | +| E14 | `cbd77549`, `b3722ac0` | epaint, egui | not upstream | required: fallback faces on the primary baseline and rows growing to fit | replace PdfCraft's manual `y_offset_factor`; baseline/row-height tests; remove when upstream | +| E15 | `f1cae609` | epaint, egui | not upstream | required: `LineBreaker` trait, grapheme-safe emergency breaks, `LayoutJob::language` | CJK/Thai/Hindi line breaking tests; remove when upstream | +| E16 | `b3ef9771`, `bed6dfe3` | epaint, egui | not upstream | required: visual cursor movement with affinity, Home/End in reading order, password mask per cluster, neutral paragraph direction | bidi cursor tests and password-mask test; remove when upstream | +| E17 | `520435df` | egui | not upstream | required: `Context::set_language`, `UiBuilder::language`, AccessKit root language, one text run per bidi level | AccessKit language and text-run tests; remove when upstream | +| E18 | `89de8f3c` | eframe | fork-only | required as the bridge from PdfCraft's language to E17 and web screen reader/IME `lang` | `Frame::set_ui_language` test; remove when upstream | +| E19 | `d64047bf`, `642b74f7`, `baeb4515` | egui-winit, egui | fork-only until winit has the APIs | required for Latin shortcut letters on non-Latin layouts | needs W23/W25; app shortcut test; remove when upstream | +| E20 | `23955535` | egui | fork-only | required so widget text uses the `Ui` language for shaping and line breaking | language/line-break test; remove when upstream | +| E21 | `a70470b0` | egui-winit, egui | not proposed | required for macOS press-and-hold/dictation and Wayland IME replacing exact bytes | needs W26/W27; byte-count IME test; remove when upstream | +| E21b/P7b | `6d518fc799681571963842f95b55a6b68431b15a` | egui-winit, egui | present in the pinned fork; native execution unqualified | bounded outgoing surrounding-text payload, TextEdit producer and Wayland W27 adapter are present in the pinned tree | logical/byte-boundary/password/focus/over-limit tests; native Wayland trace remains open | + +### 5.1 Audit notes and traps + +- **E2+E3 are not one patch.** The surface fit and the error scopes protect different failures. +- **E4 + W15P.** Regular clipboard uses the winit data device (W15). PRIMARY is a distinct protocol object on the same connection/event queue and per-seat lifecycle (W15P), using SCTK's PRIMARY objects. It must not bind a second regular `wl_data_device`. Preserve password exclusion and no unchanged-selection reclaim. No “filter `MiddleClickPaste`” substitute is acceptable. +- **E21b is present in the pinned tree.** The planning-era candidate lacked the producer; pinned egui `6d518fc` contains the TextEdit producer/`IMEOutput` path and egui-winit calls `set_ime_surrounding_text`. PdfCraft uses ordinary TextEdit, so it needs no custom model adapter; native platform execution and outgoing-text tests remain open. +- **E5/E12/E13/E16 are a dependency chain.** Do not cherry-pick E12 without E5 or E13/E16 without E5. +- **E14 supersedes PdfCraft's baseline hack but not its Windows hinting.** Keep JetBrains Mono `HintingTarget::Smooth`. +- **E15/E20 change line breaking.** Native wrapping must be the only wrapper once active. +- **E17/E20 do not automatically force base direction.** PdfCraft sets `TextDirection::RightToLeft` where the UI locale requires it. +- **E19 is useless without W23/W25; E21 is useless without W26/W27.** +- **E11 is not required by PdfCraft.** + +### 5.2 winit patch provenance and compatibility + +Base: `v0.30.13` = `e9809ef54b18499bb4f2cac945719ecc2a61061b`. + +The retained vendor carries W1–W13. The planning-era reviewed language branch `8b35388b` carried W1–W15 plus W17–W27 but not W15P. W16 is not in this cut. The current pin `a517b384` contains W15P PRIMARY and W27 (`PATCHES.md`); native evidence remains open. + +| Group | Patches | Purpose | Upstream / removal | +|---|---|---|---| +| Windows DPI/frame/IME | W1 `b357a332`, W3 `fd104a4c`, W4 `3fffb7ce`, W18 `894bf2d7`, W19 `fa2e1b3d` | mixed-DPI suggested rect, Korean IME cursor, hidden maximized restore, hidden system caret, IME-consumed key presses | W1 merged on master; W3/W4 open; W18/W19 to be proposed | +| Wayland | W2 `13ca693b`, W13 `5506b1a9`, W15 `cf73a29c`+`b05ce129`, W15P (present in the pinned fork), W27 `f40c76a2` | text input owned by the seat, scroll phase policy, per-seat data device and regular clipboard, PRIMARY on the same connection, text-input-v3 incoming delete plus the outgoing surrounding-text API that E21b sends | W2 merged; W13 to be proposed; W15 #4729 open; W15P present in the pinned fork; W27 has master equivalents | +| X11 | W5 `283370ce`, W11 `4df8554b`, W20 `019c95f1`, W21 `23bb19a7` | focused replacement IME context, server DPI fallback, one input context per window, quiet IME spot | W5 open; W11/W20/W21 to be proposed; W20 supersedes W5 | +| macOS | W6 `a82b90fb`, W7 `9b90d5bd`, W8 `6a87addd`, W9 `9500f25d`, W10 `4a7dd791`, W12 `7facf001`, W14 `c488d2e9`, W22 `0816871e`, W23 `c8cd8aa1`, W24 `4b5bc5a0`, W26 `2b9cd1cf` | NSTextInputClient commit flow, discard marked text, emoji picker, Korean space/first key, title-bar drag, cancelable termination, IME ranges, ⌘-layout names, reset, byte-count delete | W14 is opt-in and not enabled by PdfCraft; others open/to be proposed | +| Language/tests | W17 `2be27382`, W25 `eb4007da` | regression source guards; `platform::latin_layout::KeyEventExtLatinLayout` | tests go with their patches; W25 required by E19 | + +Compatibility rules: + +- The egui fork's `egui-winit` calls `winit::platform::latin_layout::KeyEventExtLatinLayout`, `KeyEventExtMacOS::key_with_command`, `WindowExtMacOS::take_ime_delete_surrounding` and `WindowExtWayland::take_ime_delete_surrounding`; E21b also calls `WindowExtWayland::set_ime_surrounding_text`. +- W14 remains compiled in the shared fork but not opted into by PdfCraft; `craft-quit` remains its terminate path. +- Re-run winit policy/unit tests in the selected fork and app-facing regressions. Native IME/compositor acceptance still requires actual workflows. + +## 6. Migration design + +### 6.1 Frame boundary and exactly-once side effects + +Keep the existing `eframe::App` hooks. Adding a private phase type is allowed only if implementation proves it simplifies real state ownership; do not add a scheduler API or rerun the whole app through a blanket `Context` bridge. + +1. `logic` remains the owner of queues, autosave, recovery, file watching, open completion, background polls, control request admission, undo application and other host state. Replayed passes must not re-enqueue an action, duplicate a command or add a second undo step. +2. UI-produced editing commands may apply in the UI, as they do today (`chrome.rs`, `canvas.rs`, `dialogs.rs` call `execute`/`apply_edit`). Preserve promptness. The rule is exactly-once by command/response ID or document generation, not “zero commits on discard”. A discarded pass may repeat layout; it must not repeat the edit. +3. Inventory every UI-side `execute`/`apply_edit`/undo path and add a forced-late-discard test using a real toolbar action. A per-pass pending-command queue keyed by `Response::id`/command identity is preferred over moving all UI side effects into logic. +4. `DocView::begin_render_frame` may collect per-pass demand. Render/upload commits use document/render generation, not `!ctx.will_discard()` as the correctness boundary. A late discard may discard demand work but must not lose an already-produced first-pass texture. +5. `Control::tick` remains in `logic` for queue servicing. It sends `ViewportCommand::RequestPaintWhileHidden` while a screenshot or injected input waits. Injected input is consumed once; a replay does not replay the click. +6. Autosave, recovery, grid-drop timeouts and playback use monotonic time. `Motion::begin_pass` is called from `ui` before panels; no logic-only call consumes `ctx.input` or smooth-scroll state. +7. `Frame::set_ui_language` runs after language resolution, once per pass only when the resolved language changes; it does not read stale input from `logic`. +8. Never infer a logic-only phase from `visible() == Some(false)`; hidden UI paint is a known UI phase and must handle current input. + +### 6.2 Panels, docking and persistence + +Current integration note: the shared side panes and rail described at the top supersede the former docking and panel layout below. Retain historical requirements as context, not as claims of implemented capabilities. + + +The first compatibility PR implements an actual one-time panel-key migration. For each of `"tab_strip"`, `"mode_bar"`, `"rail"`, `"rail-nav"` and `"read_right_panel"`: + +- old key (0.36): `Id::new(salt)`; +- new key (0.37 root panel): `ctx.viewport_id().root_ui_id().with_salt(IdSalt::new(salt))` (equivalently `parent_ui.scope_id().with_salt(IdSalt::new(salt))` for the root). +- Before the first 0.37 UI pass, if the new key has no persisted `PanelState` and the old key does, read the public `PanelState` and insert it under the new key. Keep the old key untouched for rollback. Do not reset a width that can be migrated. + +Test with a synthetic 0.36 memory fixture: write the old key with a known outer width, run the 0.37 app, and assert the panel width survives within 1 point. Dump the old/new IDs for one panel and one child widget. A one-time reset of transient in-panel focus/scroll state is explicitly accepted only if the upgrade test demonstrates it; persisted widths are not reset. PdfCraft's own `docking::Workspace` layout is not touched. + +Further tests: + +- Panel order: tab strip → mode bar → right rail → central panel. Read-mode inspector must not reorder the rail or duplicate a panel ID. +- A panel's content clip is a subset of the panel rect and parent clip; popups/comment overlays use `Area`/`Window` layers. +- Floating docking, tab detach, group move and split resize survive restart from `Workspace` JSON, not egui memory. +- `DockArea::new(Id::unique("pdfcraft-docking"))`; keep the same salt for craft-ui state. If RTL is selected, pass the explicit direction; persisted split/rectangles/logical tab order stay unchanged. + +### 6.3 Viewports + +PdfCraft has no deferred or immediate child viewports. Keep `ViewportId::ROOT` as the only production viewport. `window_state` and tests already use it. E1 `ViewportHooks` remains a suite feature, not a PdfCraft requirement. Do not add popout document windows in this port. + +### 6.4 Screenshot callback correlation + +Replace `ViewportCommand::Screenshot(UserData)` plus `Event::Screenshot` with a bounded, cancelable request record: + +- one completion slot per admitted request, owned by that request; the callback holds only a weak reference to the slot; +- callback stores at most one `Arc` and wakes the owning root viewport after releasing the slot lock; it never encodes PNG, writes a file, runs commands, or strongly captures `Context` in a cycle; +- `Context::request_screenshot` or `ViewportCommand::Screenshot` with the one-shot callback; `finish_pending` takes each completed slot exactly once and replies on the app thread; +- cancellation, timeout and viewport close drop/cancel the slot so a late callback cannot produce a file or second reply; +- admission defaults are 4 requests, 128 MiB/image and 256 MiB aggregate reserved + in-flight/completed image bytes. These cap pending requests and aggregate image bytes. Reserve checked bytes before issuing a capture, using the allowed surface dimensions; revalidate dimensions before issuance after resize/DPI changes. Release reservations on completion, cancellation, oversize/stale rejection and shutdown; tests assert retained bytes return to zero; +- store issuance-time target-UI logical viewport rect, `pixels_per_point`, requested region + and geometry epoch in the slot. Reject/retry any resize/DPI/zoom/surface epoch change + before save, even a change back. Callback arguments contain pixels only. The callback image may be smaller because E2 surface fitting reduced it; derive x/y image scale from the returned dimensions and recorded logical bounds, verify aspect/rounding, and do not use `ctx.pixels_per_point()` at completion. A resize/DPI generation mismatch retries within the deadline or returns a defined error; +- PR 1 waits for the approved craft-ui CL-2F completion-slot helper and adopts it directly; no temporary app-local implementation. Preserve PdfCraft request/path/region/reply policy in `control.rs`. + +Hidden-window tests are separate: + +- a hidden window with queued injected input needs `RequestPaintWhileHidden` to apply it; without the command the input times out; +- a pending screenshot command itself requests a paint. A request still queued in app + logic, or awaiting settling before UI issuance, needs `RequestPaintWhileHidden` to reach + that point; test admission and already-issued capture separately; +- keep late-callback, cancellation, two-region/out-of-order, close, admission overflow and DPI-change tests. Assert exact reply counts, dimensions/pixels, bounded memory and eventual queue drain. + +The dependency boundary also requires P2b's bounded asynchronous W15/W15P transfer API +(total deadline, cumulative byte cap, bounded workers/queue, completion wake and cancellation), +and P7's emitted-key identity paired by physical key for repeat/release. PdfCraft adds slow/trickle, +oversized/canceled paste and event-loop responsiveness checks, plus ordinary held-key/Ctrl+Z +layout-switch, release-modifier-first and focus-loss cases. Do not copy those internals locally; +consume the reviewed revisions and assert app-visible behavior. Clipboard shortcut keydown may +be intercepted before egui sees a Key event, so Ctrl+C/Cmd+V alone is not a stuck-key test. + +### 6.5 Fonts and glyph coverage + +Keep PdfCraft's single-face, last-resort system-font policy. Do not enable eframe's `system_fonts` provider silently. Set `NativeOptions.system_font_fallback = false` and keep a source/feature guard that `eframe/system_fonts` stays off. + +Glyph decision: enable `egui/monochrome_emoji_fonts` in `pdfcraft-ui-egui`, the native app and the web dependency graph. This keeps the existing `✔`, `▲` and `▼` text symbols and the OFL/MIT attributed Noto Emoji/emoji-icon fonts deterministic with `PDFCRAFT_SYSTEM_FONTS=0`; do not replace them with new SVG assets in this migration. The new painted submenu arrow is an atom, not text, and is removed from glyph expectations. + +Attribution and assets in the first green PR: + +- treat the 0.37 font change as a deliberate fixture change: `⋮` (U+22EE) was never covered by the bundled faces, and the craft-libs test fixture moves to `…`; do not restore a tofu-drawing fixture. Production `…`/`−` remain covered; +- update all four `[[bundled]]` `epaint_default_fonts` entries from 0.36.2 to 0.37.0; +- add `egui-icons.ttf`, SHA-256 `122806555c73df7cef55aa1d945289ae27fbc4dbbd31abbb4204ee0cda829cc8`, recorded as the MIT-licensed `emoji-icon-font` subset by John Slegers, with its exact usage; +- run `cargo xtask assets --write` and then `cargo xtask assets`. + +Adopt E14 for fallbacks: set `FontTweak::fallback_align = FallbackAlign::Baseline` for script fallbacks; remove metric-derived `y_offset_factor`; keep Windows JetBrains Mono hinting. Add a glyph-coverage test for every UI literal and catalog translation with `MissingGlyphPolicy::Panic` (or `Fonts::has_glyphs`) for embedded-only, craft-fonts, each script fallback, and `PDFCRAFT_SYSTEM_FONTS=0/1`. A missing glyph is a failure, not a snapshot update. + +### 6.6 Language and RTL + +Current integration note: the shared side panes and rail described at the top supersede the former docking and panel layout below. Retain historical requirements as context, not as claims of implemented capabilities. + + +End state: + +- `logic` resolves the language and calls `frame.set_ui_language(Some(code))` (E18), setting `Context::language` (E17). +- Translated UI text stays logical in catalogs. Remove `catalog.map_translations(display_rtl)`, `bidi::display_rtl` and the matching label preordering. Rely on E5/E12. +- Use explicit `TextDirection::RightToLeft` for `ar`/`ckb` labels whose first strong character may be Latin or a placeholder; use `LeftToRight` for file/path labels where the extension must stay at the end; use `Auto` for document-authored text. A small local direction helper is used only where multiple call sites need it; no new fork-only API. +- Replace `bidi::wrap`/`widgets::wrapped_text` with native `Label`/`LayoutJob` wrapping once E15 is active. A remaining custom wrapper must measure with the same direction/language it paints. +- Set `Style::right_to_left` for `ar`/`ckb` after auditing manual `Layout::right_to_left` call sites. E9 mirrors chrome, not every manually positioned control. Pass an explicit direction to `craft_ui::DockArea`; do not reverse saved workspace order. +- Keep `bidi::visual` only for painter paths that cannot use a `LayoutJob`; never combine it with native bidi on the same text. +- AccessKit labels and copied text stay logical; only painting is reordered. + +### 6.7 Input, IME and clipboard + +- Add `source` to every `Event::MouseWheel` constructor. The only variants are `Unknown`, `Wheel`, `Trackpad`, `Momentum`. On Wayland, Windows, X11 and web, winit reports `Unknown`; macOS reports `Wheel`, `Trackpad` or `Momentum`. +- PdfCraft's `fling.rs` remains the document-motion owner. On non-macOS platforms it keeps the existing `MouseWheelUnit::Point` + `TouchPhase::End` heuristic, explicitly including `Unknown`; never use the invented `Finger` or `WheelTilt` egui variants. On macOS its custom fling stays off because the OS sends `Momentum`; test that egui kinetic does not add a second document glide. Ordinary widget `ScrollArea`s keep the egui kinetic default; wheel deltas do not create coast velocity, only pointer-drag velocity and momentum do. +- E10/E19/W23/W25: verify shortcuts on non-Latin layouts through synthetic `RawInput` and native Windows/macOS/Linux tests. +- E21/W26/W27: test incoming `DeleteSurroundingBytes` and outgoing E21b surrounding text after delete/commit, deletion-only batches, preedit exclusion, reversed selection, UTF-8 boundaries, long/over-limit selections, field/window switching, password clearing and late serials. +- Text copy/paste stays on egui events; keep `arboard` for image/file clipboard. Enable eframe `clipboard` for native builds. PRIMARY uses W15P: keyboard-only focus, serial freshness, owner/seat changes, empty/absent offers, self-paste, canceled/slow transfers, password exclusion and no unchanged-selection reclaim. Native evidence on wlroots/KWin/Mutter. No second regular data device. + +### 6.8 Accessibility + +Current integration note: the shared side panes and rail described at the top supersede the former docking and panel layout below. Retain historical requirements as context, not as claims of implemented capabilities. + + +- Map every `WidgetInfo` role explicitly (`Role::TextInput`, `Role::CheckBox`, etc.). +- Add accessible names to every input widget. 0.37 kittest fails unnamed inputs; use `labelled_by`, `Ui::label_inputs_by`, `Ui::name_inputs` or `Response::accessible_name`. +- Keep logical labels in AccessKit. Do not set preordered visual strings. +- `ControlPlugin` copies the AccessKit tree; `ui.inspect` must keep stable widget IDs across panel migration. Test roles/labels for text field, slider, tab and custom button. +- Panel resize handles expose `Role::Splitter`. + +### 6.9 Rendering and GPU + +- Keep `surface_texture_limits` and `configure_gpu`. E2+E3 remain required. +- Do **not** skip `DocView::receive` uploads on `will_discard`. A first-pass texture is carried into the final merged `TexturesDelta`; skipping it can lose a page. Use generation identity to avoid re-scheduling renders and avoid duplicate whole-image deltas. +- Assert the final merged `FullOutput.textures_delta.set` contains one whole delta per texture generation; a forced-late-discard test must show the page after the next accepted pass. +- Keep `RESULTS_PER_FRAME`/`UPLOAD_BYTES_PER_FRAME`, hidden-tab suspension, `PDFCRAFT_RENDERER=wgpu|gl`, OpenGL retry and software-renderer notice. +- Screenshot readback is callback-driven; never assume it is ready in the same input frame. + +### 6.10 Commands, undo and persistence + +No command ID, tool ID, `parity/` entry, undo transaction or saved-settings key changes in this port. Preserve `commands.rs` registry IDs, `Session` document generations and undo steps, `Workspace` JSON, `window_state` storage key `window`, and `persist()` keys `pdfcraft`/legacy `printcraft`. The only persistence migration is egui's panel `PanelState` keys in §6.2. + +## 7. PR stages and rollback + +All publication is gated by explicit human approval. No push or PR is authorized by this plan. + +### Stage 0 — harness and baseline (before production change) + +- Land only this document and a test-only measurement driver/harness; no production behavior changes. +- The harness must run on the unmodified baseline `a2ec27b` and, where its adapter permits, stock 0.36.2, stock 0.37.0 and the fork. +- Record baseline frame/idle/RSS samples, `Context::tessellate` with 120 ASCII labels (#8731), startup phase split (instance/adapter/device → pipeline creation → first frame; #8736), and font install/discovery with `PDFCRAFT_SYSTEM_FONTS=0/1` and provider on/off (#8512). +- Record `cargo metadata`, lock hash, vendor hashes, hardware, renderer/driver, DPI, fixtures and actual commands. A baseline may not be measured after production changes. + +### Shared fork prerequisites (external to PdfCraft) + +The implementation is blocked until the shared plans publish approved immutable revisions containing: + +- the reviewed winit W1–W15, W17–W27 and **W15P** PRIMARY path (all present in the current pin `a517b384`); +- the reviewed egui E1–E21 and **E21b/P7b** outgoing surrounding-text integration; +- the craft-libs API-port hunks on current main, with `craft-window` and `craft-print` drift reviewed. + +PdfCraft does not vendor a private copy or bypass missing publication with a path patch. + +### PR 1 — atomic first-green compatibility change + +Depends on: Stage 0 and the complete approved egui/winit revisions (P2b bounded W15/W15P, P7 key release pairing, P7b IME lifecycle, P7c selection fix), plus craft-libs CL-1/CL-2F. PR 4 RTL docking additionally waits for CL-3. + +Scope (one reviewable change set; commits may split by file only if every commit is green): + +- Pin the egui 0.37 family and winit 0.30.13 fork revision; update `Cargo.lock`. +- Enable native eframe `clipboard` in `apps/pdfcraft` and `crates/ui-egui`; add the feature-identity assertion. State that web has no egui-winit clipboard. +- Enable `monochrome_emoji_fonts`; update `ATTRIBUTION.toml` bundled versions and add `egui-icons.ttf`; run assets write/check. +- Add storytold egui/winit git URLs to `deny.toml` `allow-git`; keep `unknown-git = "deny"`. +- Rename `WidgetType`/`Id::new`/`Ui::id`/`UiBuilder::id`; map roles; fix all kittest accessible-name and missing-glyph failures (204 harnesses). +- Update painted submenu labels: `"File ⏵"` → `"File"`, etc.; update AccessKit/name expectations across `commands.rs`, `theme.rs`, `editing.rs`, `control.rs`, `text_select.rs`, `view.rs`; assert the arrow is painted. +- Add `MouseWheel.source` with the real variants; migrate the screenshot path to the bounded callback; add panel-key migration. +- Port from craft-libs current main using only the reviewed API hunks and `craft-window`; add `craft-window` type reuse and retain `PDFCRAFT_TITLEBAR`/runtime preference semantics. Add `Printer.description` to every literal (19 at baseline) and audit craft-print/GDI/rgb drift. +- Transfer winit/egui-wgpu source guards centrally; replace PdfCraft source-path and `source =` assertions with immutable git graph checks. +- Remove catalog preordering and `display_rtl` as the fork becomes the sole UI bidi owner; + include the focused logical-text/selection regression in this first fork pin. +- Do not defer names, glyphs, attribution, required bidi adaptation or `deny` to PR 3. + +Tests: fmt, clippy `-D warnings`, `cargo test -p pdfcraft-ui-egui`, `cargo test -p pdfcraft`, wasm check, `cargo xtask layers/assets/deny/parity`, native feature/graph assertions, source-guard replacements. Snapshot changes must be reviewed, never auto-accepted. + +Rollback: revert PR 1; keep the 0.36 lock and vendor directories in history. Do not leave a 0.37 manifest with a 0.36 lock. + +### PR 2 — lifecycle, control and render commit + +Depends on: PR 1. + +Scope: + +- Replace `ctx.input` clocks in autosave/recovery/grid/control timers with monotonic deadlines. +- Move `motion.begin_pass` into UI before panels; remove stale event reads from logic-only iterations. +- Complete exactly-once command/edit/undo handling with a real forced-late-discard test; remove any “no commit on discard” assumption. +- Finish screenshot slot stress: hidden injected input, screenshot command paint, cancel/late/out-of-order/DPI, aggregate reservations. +- Rename `Pending::Frames`/`Shared::passes` to accepted completed frames; increment only when `request_discard_reasons` is empty; add a forced-discard timing test. +- Add generation-safe texture tests; do not gate uploads on `will_discard`. + +Rollback: revert PR 2; PR 1 retains a complete 0.37 callback and basic regression coverage. + +### PR 3 — language, fonts, RTL and accessibility completion + +Depends on: PR 2. + +Scope: + +- `Frame::set_ui_language`; AccessKit language. +- Expand native wrapping and explicit `TextDirection` tests; catalog preordering and + `display_rtl` removal already landed in PR 1 and must not be restored independently. +- E14 fallback alignment; glyph coverage; `system_font_fallback = false`. +- `Style::right_to_left` after manual-layout audit; craft-ui `DockArea::direction`. +- Role/name tests already required by PR 1 are strengthened, not introduced here. + +Rollback: revert PR 3; PR 2 remains. If native bidi proves wrong, revert the whole 0.37 family to the 0.36 fork tag `767b1cad` with the old preordering. + +### PR 4 — platform window, packaging and documentation + +Depends on: PR 1 (can run in parallel with PR 2/3 after rebase). + +Scope: + +- The type re-export/parser replacement lands in PR 1; PR 4 completes extended native policy tests for `craft-window`: `window_controls` re-exports `craft_window::{Button, Layout}`; `decorations` uses `Choice::probe(Prefix::new("PDFCRAFT"))`; remove duplicate parser; keep runtime `Use system title bar` toggle. +- Native Linux/Windows GUI checks, web build, packaging checks and clipboard/PRIMARY/IME workflows. +- Update `vendor/README.md`/`SOURCE.json` only for retained vendors; document removed window-stack copies and exact replacement revisions. +- Update `docs/development.md` and version statements. + +Rollback: revert PR 4; PR 1–3 remain. + +### PR 5 — performance evidence and final gates + +Depends on: all. + +Scope: run the Stage 0 harness on the candidate; add paired release results, stock-0.37 control, peak RSS, idle CPU/repaint, texture counters and startup/font/tessellation measurements; update `ROADMAP.md`/status docs. No behavior change. + +DAG: + +``` +Shared forks published -> Stage 0 -> PR1 -> PR2 -> PR3 -> PR5 + \-> PR4 -> PR5 +``` + +PR4 may be rebased alongside PR2/PR3, but do not land two PRs that both rewrite `lib.rs` without a rebase review. + +## 8. Test plan with user-observable assertions + +### 8.1 Existing suites to keep green + +- `crates/ui-egui/tests/it` (one binary): accessibility, actions, panels, controls, comments, content, editing, export, fonts, forms, links, pickers, print, recovery, signing, text selection, viewer, XFA. +- `crates/ui-egui/tests/perf.rs`: collapsed comments, 500-page scrolling and panel budget tests. +- `apps/pdfcraft/tests/it`: twelve winit guards (transferred/replaced), and the Wayland scroll-policy compile test. +- `apps/pdfcraft/src/main.rs`: GPU/surface/adapter guards. +- `examples/shot.rs`: headless screenshot of the real shell. +- `crates/ui-egui/src/control.rs`: in-crate control, pointer and multi-pass probes. + +Include `crates/ui-egui/tests/it/control.rs`, its discarded-pass requests and `screenshots_of_window_and_region` in PR 1 migration and the existing-suite gate. + +### 8.2 New or strengthened assertions + +| Area | Test | Assertion | +|---|---|---| +| Lifecycle | `logic_runs_per_pass_but_one_action_commits_once` | Native/web-shaped driver runs logic + UI twice in one frame; one click yields one edit/undo step | +| Hidden logic | `hidden_logic_advances_monotonic_deadlines` | Repeated `run_logic` with advancing wall clock and no new events autosaves once and changes no scroll/zoom | +| Hidden input | `hidden_injected_input_needs_a_ui_pass` | Queued click times out without `RequestPaintWhileHidden`; applies exactly once with it | +| E22 | `completed_multipass_resets_pass_index` | After completed `run_ui`, `current_pass_index()` is zero; a hidden call still runs; no first-pass guard is used as a scheduler | +| Panels | `upgrade_from_036_panel_memory` | Old `Id::new(salt)` width migrates to root-scope key; width within 1 pt; ID dump recorded | +| Panels | `panel_widget_id_stays_stable` | A widget in a panel keeps the same `scope_id`-derived interaction ID across consecutive 0.37 frames; `unique_id` is not substituted for state | +| Panels | `panel_clip_is_intersected_with_parent` | Child clip no larger than panel/parent; overlay uses `Area` and remains visible | +| Docking | `dock_float_detach_and_split_survive_restart` | Same `Workspace` JSON round-trips; panel bodies once; Canvas cannot float/share tabs | +| Screenshots | `two_screenshot_requests_keep_their_slots` | Two regions complete out of order with own dimensions/colours; each reply once | +| Screenshots | `hidden_screenshot_command_paints_itself` | A pending screenshot paints a hidden window; no false `RequestPaintWhileHidden` requirement | +| Screenshots | `late_screenshot_is_dropped_after_cancel` | Timeout/close/cancel produces no second reply/file; reservations return to zero | +| Screenshots | `dpi_change_uses_capture_geometry` | Request-time ppp/rect correlate the image; mismatch retries or errors, never crops with new scale | +| Multi-pass | `discard_does_not_duplicate_edit_or_undo` | One toolbar action with `max_passes=2`: one edit, one undo step, one generation change | +| Texture | `discard_keeps_first_pass_texture_and_deduplicates` | Final merged delta has one whole image per generation; page visible next accepted pass; no upload skip | +| Control | `after_frames_counts_accepted_frames` | Forced discard does not answer early; answers after intended accepted frames | +| Input | `unknown_wheel_source_keeps_wayland_fling` | `Unknown` + Point + End glides on Linux; `Trackpad`/`Momentum` policy covered; no `Finger`/`WheelTilt` | +| Input | `one_finger_lift_glides_once` | Offset after 0.5 s follows `fling.rs`; egui kinetic adds no second document curve | +| Fonts | `every_ui_string_has_all_glyphs` | All `tl!` literals and catalogs pass `MissingGlyphPolicy::Panic` for embedded + craft-fonts + system-font-off | +| Fonts | `fallback_rows_share_the_baseline` | Fallback baseline equals primary; row grows; `y_offset_factor` not the mechanism | +| Language | `arabic_ui_labels_use_an_explicit_paragraph_direction` | With an Arabic face, the label's paragraph direction is `RightToLeft` and its Latin run sits right of the Arabic; English stays `Auto` | +| Language | `arabic_catalog_stays_logical` | Catalog and command lookups stay in logical order and placeholders keep their positions | +| Language | `a_tab_with_an_arabic_file_name_keeps_its_logical_accessible_name` | AccessKit label is logical; paint is visual only | +| Editing | `grapheme_caret_and_delete_are_cluster_safe` | Emoji ZWJ/combining accent move/delete as one cluster | +| IME | `delete_surrounding_bytes_replaces_exactly` | Exact reported bytes deleted; one undo step | +| IME | `outgoing_surrounding_text_is_fresh` | After delete+commit, preedit exclusion, cursor/selection changes, long/over-limit/password/focus cases; E21b payload present and bounded | +| Accessibility | `all_input_widgets_have_names` | Every text input role has non-empty label; no global check disable | +| Accessibility | `panel_tree_roles_and_order` | Tab strip, mode bar, rail and inspector once; expected roles/order | +| Menu | `submenu_arrow_is_painted_not_named` | `"File"` etc. accessible labels; arrow atom visible; no `"File ⏵"` dependency | +| Clipboard | `native_egui_winit_clipboard_is_enabled` | Native graph enables `egui-winit/clipboard`; web does not require it | +| Clipboard | `wayland_text_clipboard_uses_one_data_device` | Regular clipboard works on Hyprland without a second regular device | +| PRIMARY | `primary_selection_uses_w15p` | Password excluded, unchanged selection not reclaimed, serial/seat/offer/self-paste/cancel cases; single data device | +| Selection | `textedit_selection_to_toolbar` | Select a subrange in the paragraph TextEdit, click Bold/colour/underline/spacing in Format and assert only that byte range receives `RangeStyle`, with one undo. Repeat via keyboard/focus surrender. `LineEditor.selection` is populated from cursor_range and used by `LineEditor::style` in `apply_line_format`; right-rail-before-central draw order may protect a path but is not proof. Final fork P7c owns #8763 fix (#8764 remains open/unmerged). #8748 is closed/unmerged and root-only PdfCraft has no child case | +| Web | `web_named_keys_and_non_latin_shortcuts` | Named keys not typed as text; `Ctrl+S` works on Russian layout | +| GPU | existing surface tests | 8600×3423 at 250% fitted to 8192; bad surface errors; pipeline error returns `CreateRenderer` | + +### 8.3 Deliberate broken-behavior probes + +Before accepting each test, temporarily revert the fix and confirm the named test fails: + +- remove `surface_fit` → surface-fit test fails; +- remove `catch_errors` → GPU setup error test fails/panics; +- re-add `display_rtl` to catalog lookups → `i18n::tests::arabic_catalog_stays_logical` fails; +- switch E21 back to character counts or remove E21b payload → IME tests fail; +- enqueue the same UI command on a replayed pass → exactly-once test fails; +- remove the hidden UI paint command for injected input → hidden-input test times out; +- remove an accessible name, or restore `"File ⏵"` as the only label → accessibility/menu tests fail; +- skip uploads on discard → texture/page test fails; +- count passes instead of accepted frames → control timing test answers early. + +## 9. Performance protocol and thresholds + +### 9.1 Measurement identity + +Record for every run: + +- exact source SHA, lockfile SHA-256, `cargo metadata` package IDs and sources; +- features (`CRAFT_FONTS_DIR` set/unset, `PDFCRAFT_SYSTEM_FONTS=0/1`, `PDFCRAFT_RENDERER=wgpu/gl`, `monochrome_emoji_fonts`, `clipboard`); +- fixture hash (`tests/perf.rs::big(500)`, `named_comments(10_000)`, panel fixture); +- OS/kernel, CPU, RAM, GPU/adapter/driver/backend, DPI/scale, window size (1400×900), renderer, profile; +- software adapter (llvmpipe/WARP) versus real GPU; +- host load and concurrent builds. + +Baseline is measured **before** any production change, on `a2ec27b`, in its own checkout/target/evidence. Candidate runs use the same machine, boot, fixture and environment. The first report and every investigated regression include stock-0.36 and stock-0.37 controls where the harness can run them, with all three identities recorded. + +### 9.2 Harness-first method + +The Stage 0 harness must exist on the baseline before PR 1. It has three layers: + +1. **Frame/idle/memory**: open the 500-page fixture and named-comment fixture; warm up, then alternating paired runs. +2. **Targeted engine measurements**: `Context::tessellate` of 120 ASCII labels for #8731; startup phases (instance/adapter/device → pipeline creation → first frame) for #8736 on WARP and a real GPU; font install/discovery with `PDFCRAFT_SYSTEM_FONTS=0/1` and provider enabled/disabled in an isolated fixture for #8512. +3. **Operations**: open + first frame, zoom, page scroll, dock/float, save/reopen. + +Method: + +- Build/run only through `maintenance/task.py run/status/finish` with a dedicated task, source and evidence directory; no direct cargo fallback. Submit each build/test directly; do not nest a multi-resource driver. +- Use release binaries and separate processes for baseline/candidate. +- At least 20 warm-up frames, 1,000 measured frames per revision for frame distributions; 10 warmups/30 timed iterations for steady operations; 20 fresh processes for startup. Record every raw sample; report median/p95/p99 and sample counts. +- Alternate baseline/candidate order; use paired runs, separate allocations, no concurrent builds or noisy workloads. +- Measure idle after settling for 5 s: zero repaints after 3 s, process CPU from `/proc//stat` or platform equivalent. +- Peak RSS from `getrusage(RUSAGE_SELF).ru_maxrss` or `/usr/bin/time -v`. +- Texture/allocation counters from `FullOutput.textures_delta` and `DocView::receive` upload count/bytes; add test-only counters if needed. +- Never infer UI performance from compile duration; never auto-update a golden or snapshot. + +### 9.3 Predefined thresholds + +Relative to the paired baseline on the same machine: + +| Metric | Acceptable candidate | +|---|---| +| Frame median | ≤ baseline median × 1.05 | +| Frame p95 | ≤ baseline p95 × 1.10 | +| Frame p99 | ≤ baseline p99 × 1.15, with at least 1,000 observations | +| Peak RSS | ≤ baseline × 1.10 | +| Idle CPU | ≤ 1% of one core; zero repaints after 3 s | +| Texture bytes/frame | ≤ baseline × 1.05 + 1 MiB | +| Open + first frame | median ≤ baseline × 1.05; p95 ≤ baseline × 1.10 | +| Operation latency | median ≤ baseline × 1.05; p95 ≤ baseline × 1.10 | + +Noise policy: if baseline median varies by more than 3% across repetitions, discard that run and repeat on a quiet machine; never relax a threshold to make a candidate pass. If a candidate fails, re-run once, then profile before deciding. Existing debug `BUDGET = 40 ms` smoke gates remain; stricter existing budgets win. Use the suite's 0.05 ms floor only for sub-millisecond measurements. + +## 10. Quality gates and platform matrix + +Required for every PR: + +- `cargo fmt --check` +- `cargo clippy -p pdfcraft-ui-egui -p pdfcraft -p pdfcraft-web --all-targets -- -D warnings` +- `cargo test -p pdfcraft-ui-egui` +- `cargo test -p pdfcraft` +- `cargo check -p pdfcraft-web --target wasm32-unknown-unknown` +- `cargo xtask layers` +- `cargo xtask assets` +- `cargo xtask deny` +- `cargo xtask parity` +- `cargo xtask ci` in CI +- `cargo tree -d`/`cargo metadata` duplicate/source assertions +- native feature assertion for `egui-winit/clipboard` and `monochrome_emoji_fonts` +- source-guard evidence from the immutable winit/egui-wgpu revisions + +Platform rows: + +| Platform | Required evidence | +|---|---| +| Linux X11 | real GUI: panels, docking, control screenshot, clipboard, IME (fcitx5/IBus), wheel/zoom | +| Linux Wayland GNOME | app-drawn frame, window controls, resize edges, panel clipping, PRIMARY | +| Linux Wayland KDE/Sway | server-side frame, same layout and shortcuts, regular clipboard | +| Linux Wayland Hyprland | text clipboard/PRIMARY single-device, screenshot control | +| Windows 11 | real GPU and WARP, mixed-DPI drag, hidden maximized restore, >8192 surface, Korean IME, screenshot, craft-print/GDI build | +| macOS | traffic lights/title-bar drag, ⌘Q save prompt via craft-quit (W14 not opted in), press-and-hold/dictation IME, emoji picker, Korean space | +| FreeBSD | CI or explicitly unresolved; no GUI claim without a real session | +| Web (Chromium/Firefox) | `trunk build`, open a PDF, Arabic UI, shortcuts on a non-Latin layout, image paste, no named-key text leakage | + +Headless `egui_kittest` is not proof of native focus, IME, file pickers, clipboard/PRIMARY or GPU presentation. A row is either backed by real evidence or listed as unresolved. Record actual selected test counts and failures; zero selected tests are not acceptance. + +## 11. Risks and migration traps + +1. **Double bidi.** Adopting E5 while keeping `display_rtl`/`bidi::visual` reverses Arabic/Kurdish twice. Remove preordering in the same language PR. +2. **Panel keys.** 0.37 derives panel IDs from the parent scope; widths reset unless the first compatibility PR migrates `PanelState`. Child widget state may reset once and is measured. +3. **`Ui::scope_id` is the old `Ui::id`.** Rename mechanically; do not switch existing `ui.id().with(...)` state to `unique_id()`. +4. **Multi-pass side effects.** `logic` and `ui` run per pass; edits/undo/render queues must be exactly-once. Do not impose “no commit on discard”. +5. **Hidden windows and stale input.** `run_logic` leaves input events/time stale. Current input belongs in the UI; background work uses monotonic deadlines. +6. **E22 pass-index fallacy.** After a completed `run_ui`, `current_pass_index()` is zero; a first-pass guard does not skip ordinary hidden logic. No E22 scheduler API. +7. **Screenshot assumptions.** A screenshot command itself paints a hidden window; injected input needs `RequestPaintWhileHidden`. Callback completion may be off-thread and has no built-in DPI metadata. +8. **Texture uploads.** Skipping uploads on `will_discard` can lose first-pass textures. Use generation identity and the final merged delta. +9. **Kinetic scroll.** Use real `MouseWheelSource` variants; `Unknown` is normal on Wayland/Windows/X11/web. Keep one document glide. +10. **Font provider policy.** eframe's system-font provider can read many installed faces, including proprietary ones; keep it off and preserve `PDFCRAFT_SYSTEM_FONTS=0`. +11. **Default-font glyph loss.** 0.37 gates emoji fonts; enable `monochrome_emoji_fonts`, add `egui-icons.ttf`, and keep the kittest glyph panic. +12. **Kittest stricter accessibility.** Unnamed inputs fail; fix labels rather than disabling checks. +13. **`WidgetType::TextEdit` does not exist as a `Role`.** It is `Role::TextInput`; `Checkbox` is `Role::CheckBox`. +14. **craft-print drift.** The bump adds `craft-winprint-gdi`, `rgb`, `lpstat -l` descriptions and `Printer.description`; audit all, fix 19 literals and preserve current main's security fixes. +15. **Winit source guards.** Twelve app modules read old vendor paths; move them centrally and use exact git identities. +16. **Snapshot churn is not acceptance.** Review every diff and keep semantic pixel tests. +17. **PRIMARY.** W15P is required and is present in the current winit pin; filtering `MiddleClickPaste` does not avoid the protocol risk and is not the selected design. +18. **IME surrounding text.** The planning-era candidate lacked E21b/P7b; pinned `6d518fc` contains the producer. Native Wayland execution remains unqualified, so do not describe W27 as complete integration. + +## 12. Resolved decisions and external gates + +Decisions made by this plan: + +- **E11:** not required by PdfCraft; epaint's `vello_cpu` 0.2.0 cannot unify with hayro's 0.0.8. Verify with `cargo tree -e features -i vello_cpu`. +- **Kinetic scrolling:** PdfCraft's `fling.rs` owns document motion; ordinary widget `ScrollArea`s keep egui's kinetic default. Use `Unknown`/`Wheel`/`Trackpad`/`Momentum`; macOS custom fling is off. +- **Panels:** migrate persisted `PanelState` keys in PR 1; transient in-panel focus/scroll may reset once only if the upgrade test proves it. +- **PRIMARY:** adopt W15P on the existing winit connection/event queue; no app-level filtering and no second regular data device. +- **RTL:** remove catalog preordering; explicit `TextDirection` for UI labels; `Style::right_to_left` after manual-layout audit; explicit craft-ui dock direction; saved workspace order unchanged. +- **Fonts:** `monochrome_emoji_fonts` on; no broad system provider. +- **Window frame:** `craft_window::Choice` at startup; `PDFCRAFT_TITLEBAR=app|system|desktop`; `window_controls` uses craft-window types; runtime preference remains a `ViewportCommand::Decorations` toggle. +- **Screenshot helper:** PR 1 waits for and directly adopts the reviewed craft-ui helper after CL-2F; no local duplicate. +- **Performance:** harness and baseline precede production; stock-0.37 control accompanies first baseline and regressions. + +External gates that cannot be decided from this host: + +- final approved immutable storytold egui/winit/craft-libs revisions, including W15P and E21b/P7b; +- native Wayland PRIMARY/IME evidence on wlroots/KWin/Mutter; +- Windows real-GPU/WARP and native GUI evidence; macOS and FreeBSD hardware evidence; +- browser runtime and non-Latin keyboard evidence; +- explicit human approval of every branch/tag push, PR or publication. + +## 13. Limitations of this plan + +- No build, test, clippy, fmt, wasm, GUI or performance run was performed. The current tree was read only. +- No compiler diagnostics were generated for this baseline. Historical audit diagnostics are stale. +- Upstream status comes from the provided `upstream-discussions.json` snapshot and local refs; it is not a live GitHub query. #8763 and #8764 are an open issue/PR, not release fixes. #8748 is closed and N/A because PdfCraft uses only `ViewportId::ROOT`. +- No native Wayland compositor, macOS or Windows session was available; PRIMARY, IME, focus and real-GPU behavior remain unverified. +- No performance baseline has been measured. The thresholds in §9 are predeclared relative limits; the absolute baseline must be recorded before implementation. +- The exact publication revisions for egui, winit and craft-libs remain external prerequisites, not user choices. +- Only `docs/egui-037-migration-plan.md` was written. No production file, manifest, lockfile, vendor tree or test was changed. + +## 14. Source index used for this plan + +PdfCraft: + +- `Cargo.toml`, `Cargo.lock`, `deny.toml`, `ATTRIBUTION.toml`, `xtask/src/assets.rs`. +- `apps/pdfcraft/Cargo.toml`, `apps/pdfcraft-web/Cargo.toml`, `apps/pdfcraft/src/main.rs`, `apps/pdfcraft/src/decorations.rs`, `apps/pdfcraft/tests/it/*.rs`. +- `crates/ui-egui/Cargo.toml`, `crates/ui-egui/src/lib.rs`, `control.rs`, `canvas.rs`, `chrome.rs`, `window_controls.rs`, `window_state.rs`, `theme.rs`, `system_fonts.rs`, `motion.rs`, `recovery.rs`, `fling.rs`, `wheel_pager.rs`, `wheel_boost.rs`, `scroll_speed.rs`, `bidi.rs`, `widgets.rs`, `print_ui.rs`. +- `crates/print/src/tests.rs`, `crates/ui-egui/tests/it/*.rs`, `crates/ui-egui/tests/perf.rs`, `crates/ui-egui/examples/shot.rs`. +- `vendor/README.md`, `vendor/winit`, `vendor/egui-wgpu`, retained `vendor/hayro*`, `vendor/lopdf`, `vendor/vello_common`. + +Target/reference source: + +- egui 0.37 `841a328b`, planning-era candidate `a4f9a17f` (current pin `6d518fc`): `Context::run_ui`/`run_logic`/`end_pass`/`current_pass_index`, `Ui::scope_id`/`unique_id`, `UiBuilder`, `PanelState`/`Panel::id`, `ScreenshotCallback`, `MouseWheelSource`, `ScrollStyle`/`ScrollArea`, `menu::SubmenuButton::arrow_atom`, `epaint_default_fonts`. +- winit vendor 0.30.13, planning-era candidate `8b35388b` and current pin `a517b384`: `PATCHES.md`, `platform/wayland.rs::set_ime_surrounding_text`, per-seat clipboard/data-device APIs. +- craft-libs `3832c47` (planning-era; ancestor of the current `25da131` pin), PR 199 `b68de8c`/`8614e4a`/`ec8840a`: `crates/ui`, `crates/window`, `crates/print`, `docs/egui-037-suite-rollout.md`. +- Shared evidence: `lifecycle-source-audit.md`, `ime-surrounding-gap.md`, `upstream-discussions.json`. + + +## Local implementation update — 2026-10-23 + +The user clarified that reviewed plans remain local and implementation proceeds before publication. The earlier requirement to publish/land this plan first is superseded; pushes and PR publication still require explicit approval. + +The owned checkout now HEADs at `a2ec27b4775f53c40cad3456936d9406611e3a8d`; `57e531ccca0047597809748b5cb4276336d6051a` and the earlier baseline SHAs above remain historical review evidence, and both refs remain reachable. Current integration uses this checkout and its qualification lock; paired measurements must record the exact source and lock they used. + +Preserve document-folder native dialogs, colour popup close policy, panel-header spacing, bookmark contrast, and org_drag cancellation after document changes. Preserve new integration regressions and generate fresh baseline geometry. + +The complete static delta review and exact old/new object identities are retained in the task evidence: `canonical-diffs-refresh-20261023.json` and `overnight-consumer-review-20261023.md`. This amendment claims no new build, native GUI or performance acceptance. + +## Local implementation amendment — 2026-10-24 + +**Historical note (superseded by the 2026-10-25 qualification state).** The migration is implemented locally but remains unpublished. At the time of this amendment, `egui`/`eframe`/`egui-wgpu` 0.37.0, the reviewed winit worktree and the shared craft-libs helpers resolved through `local-integration-config.toml`; the checked-in `Cargo.toml`/`Cargo.lock` did not yet pin them. The current tree pins egui `6d518fc`, winit `a517b38` and craft-libs `25da131` (craft-ui, craft-quit and craft-print) directly, while `craft-docpath` remains at `82a24b006eefa036b43c09a67fb9a3fbd17ffe63` (see the top section). The repository quality gates passed as recorded below; publication still requires fresh human approval. No push, PR or release is authorized by this amendment. + +The font contract now includes the native-only Simplified Chinese face: the pinned craft-fonts input supplies `fonts/simplified-chinese-manifest.txt` (LXGW WenKai Lite, OFL-1.1), `crates/fonts/build.rs` embeds it on native builds only under a 16 MiB cap, and a `CRAFT_FONTS_REQUIRED` build fails rather than skipping an absent or over-cap required Chinese face. Traditional Chinese is served by an installed `Hant` face placed before the Japanese faces even when LXGW is embedded. CI, release and FreeBSD keep an immutable craft-fonts commit pin; the current candidate is recorded in the task evidence and the pin must be updated only to a published central commit. + +Fallback faces now use egui 0.37's `FontTweak::fallback_align = FallbackAlign::Baseline` for the existing one-baseline intent (the metric-derived `y_offset_factor` compensation is gone). Control-channel synthetic input that times out after a press keeps one bounded release batch, so a key, pointer button or modifier cannot stay held when the window resumes. Accepted-frame counting remains one count per presented frame, verified at `max_passes` above two. diff --git a/docs/form-performance.md b/docs/form-performance.md index c00adf0913..1ef72dfc6d 100644 --- a/docs/form-performance.md +++ b/docs/form-performance.md @@ -8,8 +8,9 @@ not fresh measurements on upstream main. No private document is needed for the b - `crates/ui-egui/src/forms_ui.rs` queues `Edit::SetFieldValue` for a text commit or checkbox toggle. `crates/automation/src/forms.rs::form_fill` uses the same edit API. -- `crates/engine/src/lib.rs::Session::apply` runs edits on a copy, preserving rollback and undo. - `run_edit` calls `pdfcraft_forms::set_value_with` with an engine `JsRunner`. +- `crates/engine/src/session.rs::Session::apply` runs edits on a copy, preserving rollback and + undo. `run_edit` (`crates/engine/src/edit.rs`) calls `pdfcraft_forms::set_value_with` with an + engine `JsRunner`. - `crates/forms/src/lib.rs` validates and writes the edited value, then runs one calculation pass. `/CO` precedes calculated fields omitted from `/CO`. Each script sees prior writes. There is no dependency graph or dependency-based filtering: dynamic references and script @@ -265,6 +266,8 @@ checks, not hosted CI or a new performance speedup claim. ## Revision history +- 2026-10-25: point the architecture notes at `session.rs` and `edit.rs`, where `Session::apply` + and `run_edit` moved when the engine's `lib.rs` was split. - 2026-10-19: refresh PR #3318 onto upstream `e397efa8`, retain upstream roadmap additions and all performance changes, and repeat focused, scoped, benchmark and headless validation. diff --git a/docs/gaps.md b/docs/gaps.md index 4349df5922..56ec3ae748 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where PdfCraft falls short of Acrobat Pro -> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (gap 6 #3673: Character encoding CJK mapping routes corrected; no readiness re-estimate) +> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (gaps 5 and 14 current state: native release Hans face via LXGW WenKai Lite; Arabic logical catalog, explicit RTL direction and mirrored stock chrome, with native/browser/IME workflows unqualified; no readiness re-estimate) > **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-19 · **Change:** minor (gap 2: composite fonts with `/ToUnicode /Identity-H` read as Unicode, hayro-interpret patch 44; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-17 · **Last updated:** 2026-10-17 · **Change:** minor (gap 6: #2662 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) @@ -37,7 +37,7 @@ update the numbers. |---|---|---|---|---| | 2 | Rendering is borrowed and its fidelity unmeasured | feature, spec | 110–190 | [pdf-spec-parity.md](pdf-spec-parity.md) | | 3 | Editing existing text is fragile on real files | feature | 100–180 | [target-app-parity.md](target-app-parity.md) | -| 5 | Chinese UI shows missing-glyph boxes in releases | localization | 6–12 | [localization-parity.md](localization-parity.md) | +| 5 | Chinese UI lacks a web glyph face | localization | 6–12 | [localization-parity.md](localization-parity.md) | | 6 | Open-issue backlog of wrong results in shipped features | stability | 45–80 | this file | | 7 | OCR reads only unaccented Latin | feature | 40–75 | [file-format-parity.md](file-format-parity.md) | | 8 | Office export weak, Office import absent | file format | 55–100 | [file-format-parity.md](file-format-parity.md) | @@ -46,7 +46,7 @@ update the numbers. | 11 | Accessibility: checker only, no tagging tools | feature | 40–70 | [target-app-parity.md](target-app-parity.md) | | 12 | No measured fidelity harness against Acrobat | quality | (in 2) | [target-app-parity.md](target-app-parity.md) | | 13 | Performance budgets and lazy loading of very large files | performance | 25–45 | this file | -| 14 | Arabic not mirrored; no Hindi, Indonesian, Korean | localization | 25–45 | [localization-parity.md](localization-parity.md) | +| 14 | Arabic RTL unqualified end to end; no Hindi, Indonesian, Korean | localization | 25–45 | [localization-parity.md](localization-parity.md) | | 15 | Forms: JavaScript object model, actions, submit, XFA depth | feature | 40–75 | [pdf-spec-parity.md](pdf-spec-parity.md) | | 16 | UI precision gaps (multi-select move, rulers, accelerators, windows) | UI/UX | 30–60 | [ui-parity.md](ui-parity.md) | | 17 | Certificate security and other protect gaps | feature, spec | 15–30 | [pdf-spec-parity.md](pdf-spec-parity.md) | @@ -117,18 +117,19 @@ update the numbers. how a document looks. - **Estimate:** 100–180 h (the longest feature pole, M7). **Doc:** [target-app-parity.md](target-app-parity.md). -### 5. Chinese UI shows missing-glyph boxes in releases +### 5. Chinese UI lacks a web glyph face -- **Missing:** a Simplified Chinese (`Hans`) UI face in release builds and a working fallback. +- **Missing:** a Simplified Chinese (`Hans`) UI face in the web build and a working fallback. Native release builds embed LXGW WenKai Lite from the pinned craft-fonts native Simplified Chinese manifest. - **Evidence:** [#826](https://github.com/storytold/pdfcraft/issues/826), [#688](https://github.com/storytold/pdfcraft/issues/688), [#728](https://github.com/storytold/pdfcraft/issues/728), [#758](https://github.com/storytold/pdfcraft/issues/758), - [#689](https://github.com/storytold/pdfcraft/issues/689) (Flatpak). craft-fonts has Noto Sans - CJK SC, but at 16.4 MB it is too large to embed whole; the interface needs a cut of it - (storytold/craft-libs#213). -- **Impact:** the second-largest language group sees a broken interface although the catalog is 97% - translated. + [#689](https://github.com/storytold/pdfcraft/issues/689) (Flatpak) predate the native face; the + web build still embeds only BIZ UDPGothic Regular plus the small Arabic, Hebrew, Thai and Devanagari faces. + craft-fonts has Noto Sans CJK SC, but at 16.4 MB it is too large to embed whole; a web face needs + a cut of it (storytold/craft-libs#213). +- **Impact:** the second-largest language group sees a broken interface in the web build although + the catalog is 97% translated. - **Estimate:** 6–12 h (cut CJK faces through craft-fonts-build plus the fallback chain). **Doc:** [localization-parity.md](localization-parity.md). @@ -305,14 +306,18 @@ update the numbers. to edit. - **Estimate:** 25–45 h. **Doc:** this file. -### 14. Arabic not mirrored; no Hindi, Indonesian, Korean +### 14. Arabic RTL unqualified end to end; no Hindi, Indonesian, Korean -- **Missing:** a right-to-left layout (panels, menus, alignment), bidirectional text in the text - layout itself (wrapping, typed text); catalogs for Hindi, Indonesian and Korean (Acrobat ships - Korean; it ships Arabic in its Middle East edition; it ships none of Hindi or Indonesian). +- **Missing:** end-to-end qualification of right-to-left interface workflows on native platforms + and in the browser (typed RTL text, IME composition, clipboard/selection and complex multi-line + bidirectional text); catalogs for Hindi, Indonesian and Korean (Acrobat ships Korean; it ships + Arabic in its Middle East edition; it ships none of Hindi or Indonesian). The logical catalog, + explicit right-to-left label direction and mirrored stock chrome are implemented and + unit-tested; complex RTL workflows are not yet accepted. Vietnamese ships as a catalog (`vi`, 100% of the current localizable union) but stays `partial` like other non-English languages (no translated help; engine/OS details stay English). -- **Evidence:** [localization.md](localization.md) §Arabic; `misc.rtl-ui` planned. +- **Evidence:** [localization.md](localization.md) §Arabic; `misc.rtl-ui` planned (end-to-end + qualification pending). - **Estimate:** 25–45 h (Arabic RTL 20–35, three catalogs 8–12 plus review). **Doc:** [localization-parity.md](localization-parity.md). @@ -398,6 +403,7 @@ update the numbers. | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | Gaps 5 and 14 current state: native release builds embed LXGW WenKai Lite for Simplified Chinese, while the web build has no Hans face; Arabic catalogs stay logical with explicit RTL label direction and mirrored stock chrome, while native/browser RTL input, IME and complex workflows remain unqualified. Estimates unchanged. | | 2026-10-19 | minor | Gap 2: a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (WSCAD) reads its codes as Unicode, so such pages have text to select, find and edit (hayro-interpret patch 44, `view.render-cid-fonts`). Estimates unchanged. | | 2026-10-15 | minor | Gap 21: the side-by-side view had already shipped; the Compare panel filters text changes and visual differences. Estimate 10–20 h → 8–16 h | | 2026-10-20 | minor | Gap 6 #3673: Character encoding accepts Identity-H/V with Adobe CJK collections, or a predefined non-Identity CMap; negative and indirect-reference regressions and a read-only automation test passed after failing before the fix. Per-glyph validation and embedded-font extraction remain separate. Scores and estimates unchanged. | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index e6754d1cee..fee2ba3e7b 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-21 · **Change:** minor (Brazilian Portuguese now covers the 2,674-key catalog union; twelve-language mean still rounds to ≈68%; Spanish 100% of the current key union) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (Persian (`fa`, 2,785 entries) added right-to-left like Arabic and Kurdish; Arabic current state corrected: catalogs stay logical, RTL labels take an explicit direction and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native release Hans face via LXGW WenKai Lite; release-pinned craft-fonts still has no Telugu face. Current union 2,798 keys. Scores and estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) Per-language status of PdfCraft's interface against Acrobat Pro's. How translations work and how to add a language: [localization.md](localization.md). Summary in [ROADMAP.md](../ROADMAP.md). @@ -13,7 +13,7 @@ add a language: [localization.md](localization.md). Summary in [ROADMAP.md](../R **That union is a moving target**: every change that adds a string widens it, so a count is only comparable with others measured on the same date, and a catalog that reached 100% falls behind without losing a single translation. Every figure here therefore carries its date. The current - union is **2,674** keys as of 2026-10-20, against the **2,143-key snapshot** of 2026-10-10; earlier + union is **2,798** keys as of 2026-10-25, against the **2,143-key snapshot** of 2026-10-10; earlier revisions quote the union of their own date (see the revision history), not this one. The twelve-language percentage table below still quotes the 2026-10-10 snapshot for every language except Spanish, Vietnamese, Lithuanian and Brazilian Portuguese, which are measured against the current union. English is @@ -34,22 +34,22 @@ add a language: [localization.md](localization.md). Summary in [ROADMAP.md](../R | Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | Acrobat ships it | To `full` (h) | |---|---|---|---|---|---|---|---|---| | English | `en` | built in (100%) | all; README and docs | Latin | yes | **full** | yes | 0 | -| Simplified Chinese | `zh-hans` | 2,088 (97.4%) | dialogs and tooltips through `tl!`; no help | CJK text and IME work, but **release builds have no Hans face**: many characters show as boxes (#826, #688, #728, #758; Flatpak #689) | no | partial | yes | 6–12 | -| Spanish | `es` | 2,674 (100% of current key union) | dialogs and tooltips; no help | Latin | no | partial | yes | 2–4 | -| Hindi | `hi` | 0 | none | Devanagari not tested (egui shapes Telugu conjuncts, so shaping is likely workable); no Devanagari face in craft-fonts | no | none | no | 10–18 | -| Arabic | `ar` | 2,143 (100%) | dialogs and tooltips; no help | Letters joined and lines reordered at load, but **layout not mirrored**, wrapped lines break at the wrong end, typed text runs left to right (`localization.md` §Arabic) | no | partial | yes (Middle East edition) | 25–45 | +| Simplified Chinese | `zh-hans` | 2,088 (97.4%) | dialogs and tooltips through `tl!`; no help | CJK text and IME work; native craft-fonts builds embed the **LXGW WenKai Lite** Hans face, while the web build has no Hans face beyond BIZ UDPGothic, and missing-glyph reports (#826, #688, #728, #758; Flatpak #689) predate the native face | no | partial | yes | 6–12 | +| Spanish | `es` | 2,798 (100% of current key union) | dialogs and tooltips; no help | Latin | no | partial | yes | 2–4 | +| Hindi | `hi` | 0 | none | Devanagari not tested (egui shapes Telugu conjuncts, so shaping is likely workable); the pinned craft-fonts manifest has Noto Sans Devanagari Regular; rendering remains unqualified | no | none | no | 10–18 | +| Arabic | `ar` | 2,143 (100%) | dialogs and tooltips; no help | Logical-order catalog; egui 0.37's epaint reorders with UAX #9, Arabic, Kurdish and Persian labels use an explicit right-to-left paragraph direction, and stock chrome is mirrored. Native/browser RTL input and IME, and complex bidirectional workflows, are not yet qualified (`localization.md` §Arabic) | no | partial | yes (Middle East edition) | 25–45 | | French | `fr` | 2,128 (99.3%) | dialogs and tooltips; no help | Latin | no | partial | yes | 1–2 | -| Portuguese (Brazil) | `pt-br` | 2,674 (100% of current union) | dialogs and tooltips; no help | Latin | community (#288) | partial | yes | 1–2 | +| Portuguese (Brazil) | `pt-br` | 2,798 (100% of current union) | dialogs and tooltips; no help | Latin | community (#288) | partial | yes | 1–2 | | Indonesian | `id` | 0 | none | Latin | no | none | no | 3–5 | | Japanese | `ja` | 2,123 (99.1%) | dialogs and tooltips; no help | BIZ UDPGothic from craft-fonts; IME patched on macOS, Windows, X11, Wayland; vertical text renders but can't be edited | no | partial | yes | 4–8 | | German | `de` | 2,128 (99.3%) | dialogs and tooltips; no help | Latin | community, in progress (#828: terminology, clipping) | partial | yes | 2–4 | | Korean | `ko` | 0 | none | Hangul IME patched (Microsoft and Apple Korean, `vendor/README.md` winit 3, 6, 9, 10); no Hangul UI face in craft-fonts | no | none | yes | 6–10 | -| Vietnamese | `vi` | 2,674 (100% of current key union) | dialogs and tooltips; no help | Latin with stacked diacritics; bundled Inter covers them (fonts test) | community | partial | no | 2–4 | +| Vietnamese | `vi` | 2,798 (100% of current key union) | dialogs and tooltips; no help | Latin with stacked diacritics; bundled Inter covers them (fonts test) | community | partial | no | 2–4 | **Localization dimension ≈ 68%**: the mean over the twelve, counting a catalog's percentage but only 60% for Chinese and Arabic until their script problems are fixed, and 0 for Hindi, Indonesian and Korean (still missing). Vietnamese moved from 0% to 100% in that mean (59% → 68%). **≈ 55–110 h** -to bring all twelve to `full`, of which ≈ 35–60 h is engineering (Hans face, RTL layout, bidi text) +to bring all twelve to `full`, of which ≈ 35–60 h is engineering (remaining RTL qualification, a web Hans face, bidi text) and the rest catalogs and fixes; native-speaker review needs humans. No language is `full` except English: every catalog leaves engine error details in English, and @@ -63,31 +63,34 @@ help/docs polish (catalog and script work are already done), not missing UI stri | Traditional Chinese | `zh-hant` | 2,016 (94.1%) | partial (same Hans/Hant face caveat) | yes | | Russian | `ru` | 2,043 (95.3%) | partial | yes | | Bulgarian | `bg` | 2,094 (97.7%) | partial | no | -| Czech | `cs` | 2,674 entries (every key of the other catalogs, the engine's sentences, weekdays and certificate fields) | partial | yes | +| Czech | `cs` | 2,794 entries (all but four keys of the current union; the engine's sentences, weekdays and certificate fields) | partial | yes | | Telugu | `te` | 2,058 (96.0%) | partial (the release-pinned craft-fonts has no Telugu face yet) | no | | Hungarian | `hu` | 2,126 (99.2%) | partial | yes | | Ukrainian | `uk` | 2,142 (100%) | partial | yes | -| Italian | `it` | 2,528 entries (every key of the other catalogs that the code looks up) | partial | yes | -| Kazakh | `kk` | 2,327 entries | partial | no | -| Polish | `pl` | 2,287 entries | partial | yes | -| Slovenian | `sl` | 2,324 entries | partial | no | -| Greek | `el` | 2,341 entries | partial | no | -| Turkish | `tr` | 2,313 entries | partial | yes | -| Lithuanian | `lt` | 2,674 (100% of current key union) | partial; community native-speaker review ([#1401](https://github.com/storytold/pdfcraft/pull/1401)) | no | -| Finnish | `fi` | 2,674 entries | partial; AI-assisted, native proofreading pending | yes | -| Albanian | `sq` | 2,344 entries | partial; not yet reviewed by a native speaker | no | -| Catalan | `ca` | 2,592 entries | partial: no translated help, engine error details in English; native-speaker review by the contributor | no | -| Romanian | `ro` | 2,673 entries (every bundled key) | partial; AI-assisted, not yet reviewed line by line by a native speaker | no | -| Norwegian Bokmål | `nb` | 2,674 entries | partial; native-speaker terminology review by the contributor ([#1836](https://github.com/storytold/pdfcraft/pull/1836)), not yet line by line | yes | +| Italian | `it` | 2,736 entries (all but 62 keys of the current union) | partial | yes | +| Kazakh | `kk` | 2,539 entries | partial | no | +| Polish | `pl` | 2,611 entries | partial | yes | +| Slovenian | `sl` | 2,536 entries | partial | no | +| Greek | `el` | 2,545 entries | partial | no | +| Turkish | `tr` | 2,525 entries | partial | yes | +| Lithuanian | `lt` | 2,798 (100% of current key union) | partial; community native-speaker review ([#1401](https://github.com/storytold/pdfcraft/pull/1401)) | no | +| Finnish | `fi` | 2,798 entries (every current key) | partial; AI-assisted, native proofreading pending | yes | +| Albanian | `sq` | 2,524 entries | partial; not yet reviewed by a native speaker | no | +| Catalan | `ca` | 2,798 entries | partial: no translated help, engine error details in English; native-speaker review by the contributor | no | +| Romanian | `ro` | 2,798 entries (every current key) | partial; AI-assisted, not yet reviewed line by line by a native speaker | no | +| Norwegian Bokmål | `nb` | 2,707 entries | partial; native-speaker terminology review by the contributor ([#1836](https://github.com/storytold/pdfcraft/pull/1836)), not yet line by line | yes | +| Persian | `fa` | 2,785 entries (all but 13 keys of the current 2,798-key union) | partial; AI-assisted, not yet reviewed line by line by a native speaker | no | -**19 other languages** ship (33 catalogs plus English = **34** interface languages in Preferences). -The entry counts without percentages were measured on 2026-10-12–14 (Norwegian Bokmål on 2026-10-18); they are not percentages of the older 2,143-key snapshot above. Their presence does not imply a detailed linguistic or runtime review. +**20 other languages** ship (34 catalogs plus English = **35** interface languages in Preferences). +The entry counts without percentages are as of 2026-10-25; they are not percentages of the older 2,143-key snapshot above. Their presence does not imply a detailed linguistic or runtime review. Acrobat languages PdfCraft lacks: Danish, Dutch, Swedish, Hebrew. Vietnamese is shipped here but not by Acrobat. ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | Arabic current state corrected: catalogs stay logical, Arabic/Kurdish labels take an explicit RTL direction, and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native Simplified Chinese uses LXGW WenKai Lite; the web build still has no Hans face and the release-pinned craft-fonts still has no Telugu face. Scores and estimates unchanged | +| 2026-10-25 | minor | Added Persian (`fa`, 2,785 entries, all but 13 of the current 2,798-key union); right-to-left like Arabic and Kurdish, with logical entries reordered by egui 0.37's epaint; AI-assisted, not yet native-reviewed | | 2026-10-21 | minor | Brazilian Portuguese (`pt-br`) now covers all 2,674 distinct keys in the current catalog union; no help or engine/OS error-detail localization; twelve-language mean still rounds to ≈ 68% | | 2026-10-20 | minor | Spanish: the 143 keys the other catalogs had and `es.tsv` was missing; `es` now covers the whole 2,674-key union, like `lt` and `vi`. Restated Lithuanian against the current union (its 2,585 was measured on 2026-10-13) and said plainly in *How this was measured* that the union moves, so counts only compare within a date | | 2026-10-19 | minor | Removed the 2026-10-13 status line and the 14-language summary that concurrent merges left beside the current ones; the summary keeps the Italian and Norwegian Bokmål measurement dates | diff --git a/docs/localization.md b/docs/localization.md index 90c669892d..e2ce23ef54 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -1,10 +1,10 @@ # Interface language -Choose **Menu > Edit > Preferences…** (Command-comma on macOS, Ctrl-comma elsewhere) **> Interface language** and select **Auto**, **English**, **日本語**, **简体中文**, **繁體中文**, **Čeština**, **Português (Brasil)**, **Deutsch**, **Español**, **Français**, **Русский**, **Қазақша**, **Български**, **తెలుగు**, **Magyar**, **Українська**, **Italiano**, **العربية**, **کوردیی ناوەندی**, **Polski**, **Slovenščina**, **Ελληνικά**, **Türkçe**, **Lietuvių**, **Tiếng Việt**, **Suomi**, **Shqip**, **Català**, **Română** or **Norsk bokmål**. The change applies immediately and persists between launches. Command ids, document contents and file names are unchanged. +Choose **Menu > Edit > Preferences…** (Command-comma on macOS, Ctrl-comma elsewhere) **> Interface language** and select **Auto**, **English**, **日本語**, **简体中文**, **繁體中文**, **Čeština**, **Português (Brasil)**, **Deutsch**, **Español**, **Français**, **Русский**, **Қазақша**, **Български**, **తెలుగు**, **Magyar**, **Українська**, **Italiano**, **العربية**, **کوردیی ناوەندی**, **فارسی**, **Polski**, **Slovenščina**, **Ελληνικά**, **Türkçe**, **Lietuvių**, **Tiếng Việt**, **Suomi**, **Shqip**, **Català**, **Română** or **Norsk bokmål**. The change applies immediately and persists between launches. Command ids, document contents and file names are unchanged. Displayed keyboard shortcuts use the same active interface language. For example, German shows `Strg+K` and `Strg+Umschalt+S` in the toolbar, menus, command palette, tooltips and Keyboard shortcuts dialog; macOS keeps its modifier symbols (`⌘`, `⇧`, `⌃`). Key names have their own `key` catalog context, so the Delete key can read `Entf` while the command remains `Löschen`. A missing key-name entry falls back to the English key name. Actual bindings and machine-facing command ids remain stable. -**Auto** (the default) follows the system language: `LC_ALL`, `LC_MESSAGES` or `LANG`, then the preferred-languages list on macOS. Any Portuguese locale (`pt_BR`, `pt_PT`) uses the Brazilian catalog, any French locale (`fr`, `fr_FR`, `fr_CA`, `fr_BE`) the French one, any German locale (`de`, `de_DE`, `de_AT`, `de_CH`) the German one, and Chinese locales for Taiwan, Hong Kong and Macau (`zh_TW`, `zh_HK`, `zh_MO`, `zh-Hant`) the Traditional Chinese one; other Chinese locales (`zh`, `zh_CN`, `zh_SG`, `zh-Hans`) the Simplified Chinese one. Telugu locales (`te`, `te_IN`) use the Telugu catalog, Bulgarian locales (`bg`, `bg_BG`) the Bulgarian one, and Hungarian locales (`hu`, `hu_HU`) the Hungarian one, and Ukrainian locales (`uk`, `uk_UA`) the Ukrainian one, and Italian locales (`it`, `it_IT`, `it_CH`) the Italian one, and Arabic locales (`ar`, `ar_MA`, `ar_EG`, `ar_SA`, …) the Arabic one, and Turkish locales (`tr`, `tr_TR`) the Turkish one, and Slovenian locales (`sl`, `sl_SI`) the Slovenian one, Vietnamese locales (`vi`, `vi_VN`) the Vietnamese one, Albanian locales (`sq`, `sq_AL`, `sq_XK`, …) the Albanian one, Catalan locales (`ca`, `ca_ES`, `ca_AD`, `ca_ES@valencia`, …) the Catalan one, and Romanian locales (`ro`, `ro_RO`, `ro-RO`, `ro_MD`) the Romanian one. Norwegian Bokmål locales (`nb`, `nb_NO`) use the Bokmål catalog, and so do general Norwegian (`no`, `no_NO`) and Nynorsk (`nn`, `nn_NO`), which have no catalog of their own. A system language without a catalog shows English. On Windows, **Auto** uses the Windows display languages, in preference order, when no supported locale is selected by those environment variables. Neutral `C`/`POSIX` locale values (including `C.UTF-8`) inherited from a launcher do not override the Windows display language; explicitly named supported locales such as `fr_FR.UTF-8` still do. The query asks Windows directly (`GetUserPreferredUILanguages`, through the `sys-locale` crate) without starting a process, and is cached for the process. If detection fails or the display language has no catalog, PdfCraft shows English. +**Auto** (the default) follows the system language: `LC_ALL`, `LC_MESSAGES` or `LANG`, then the preferred-languages list on macOS. Any Portuguese locale (`pt_BR`, `pt_PT`) uses the Brazilian catalog, any French locale (`fr`, `fr_FR`, `fr_CA`, `fr_BE`) the French one, any German locale (`de`, `de_DE`, `de_AT`, `de_CH`) the German one, and Chinese locales for Taiwan, Hong Kong and Macau (`zh_TW`, `zh_HK`, `zh_MO`, `zh-Hant`) the Traditional Chinese one; other Chinese locales (`zh`, `zh_CN`, `zh_SG`, `zh-Hans`) the Simplified Chinese one. Telugu locales (`te`, `te_IN`) use the Telugu catalog, Bulgarian locales (`bg`, `bg_BG`) the Bulgarian one, and Hungarian locales (`hu`, `hu_HU`) the Hungarian one, and Ukrainian locales (`uk`, `uk_UA`) the Ukrainian one, and Italian locales (`it`, `it_IT`, `it_CH`) the Italian one, and Arabic locales (`ar`, `ar_MA`, `ar_EG`, `ar_SA`, …) the Arabic one, and Turkish locales (`tr`, `tr_TR`) the Turkish one, and Slovenian locales (`sl`, `sl_SI`) the Slovenian one, Vietnamese locales (`vi`, `vi_VN`) the Vietnamese one, Albanian locales (`sq`, `sq_AL`, `sq_XK`, …) the Albanian one, Catalan locales (`ca`, `ca_ES`, `ca_AD`, `ca_ES@valencia`, …) the Catalan one, and Romanian locales (`ro`, `ro_RO`, `ro-RO`, `ro_MD`) the Romanian one, and Persian locales (`fa`, `fa_IR`, `fa_IR.UTF-8`, `fa-AF`, ...) the Persian one. Norwegian Bokmål locales (`nb`, `nb_NO`) use the Bokmål catalog, and so do general Norwegian (`no`, `no_NO`) and Nynorsk (`nn`, `nn_NO`), which have no catalog of their own. A system language without a catalog shows English. On Windows, **Auto** uses the Windows display languages, in preference order, when no supported locale is selected by those environment variables. Neutral `C`/`POSIX` locale values (including `C.UTF-8`) inherited from a launcher do not override the Windows display language; explicitly named supported locales such as `fr_FR.UTF-8` still do. The query asks Windows directly (`GetUserPreferredUILanguages`, through the `sys-locale` crate) without starting a process, and is cached for the process. If detection fails or the display language has no catalog, PdfCraft shows English. The control channel exposes the setting through `ui.set`: @@ -12,56 +12,51 @@ The control channel exposes the setting through `ui.set`: {"method":"ui.set","params":{"key":"language","value":"ja"}} ``` -The value is `auto` or a language code (`en`, `ja`, `zh-hans`, `zh-hant`, `cs`, `pt-br`, `de`, `es`, `fr`, `ru`, `kk`, `bg`, `te`, `hu`, `uk`, `it`, `ar`, `ckb`, `pl`, `sl`, `el`, `tr`, `lt`, `vi`, `fi`, `sq`, `ca`, `ro` or `nb`), in any case. `ui.state` reports `language` as written in the preferences (`auto`, `en`, `ja`, `zh-hans`, `zh-hant`, `cs`, `pt-br`, `de`, `es`, `fr`, `ru`, `kk`, `bg`, `te`, `hu`, `uk`, `it`, `ar`, `ckb`, `pl`, `sl`, `el`, `tr`, `lt`, `vi`, `fi`, `sq`, `ca`, `ro` or `nb`). Unknown values return an error without changing the current setting. Preferences saved before this setting existed follow the system language. +The value is `auto` or a language code (`en`, `ja`, `zh-hans`, `zh-hant`, `cs`, `pt-br`, `de`, `es`, `fr`, `ru`, `kk`, `bg`, `te`, `hu`, `uk`, `it`, `ar`, `ckb`, `pl`, `sl`, `el`, `tr`, `lt`, `vi`, `fi`, `sq`, `ca`, `ro`, `fa` or `nb`), in any case. `ui.state` reports `language` as written in the preferences (`auto`, `en`, `ja`, `zh-hans`, `zh-hant`, `cs`, `pt-br`, `de`, `es`, `fr`, `ru`, `kk`, `bg`, `te`, `hu`, `uk`, `it`, `ar`, `ckb`, `pl`, `sl`, `el`, `tr`, `lt`, `vi`, `fi`, `sq`, `ca`, `ro`, `fa` or `nb`). Unknown values return an error without changing the current setting. Preferences saved before this setting existed follow the system language. -Dialogs, panels, menus, notices and history labels go through the catalog (`tl!`). Most shipped catalogs (Japanese, Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese, Arabic, Kurdish, Slovenian, Kazakh, Polish, Greek, Turkish, Lithuanian, Vietnamese, Romanian, Norwegian Bokmål and others) cover the current localizable surface (about 2,000–2,700 entries each), and Czech (2,674 entries) covers the same surface. Untranslated labels use English. Error details that come from the engine or the operating system are shown as they are, inside a translated frame ("操作「…」に失敗しました: …"). The command palette matches the translated label, the English label and the command id. Vertical Japanese PDF rendering is an existing viewer feature; this change does not add vertical text editing. Document contents, filenames, CLI/MCP/control protocol strings and external help stay outside the catalog. Per-language coverage, script support and what each language still needs are in [localization-parity.md](localization-parity.md). +Dialogs, panels, menus, notices and history labels go through the catalog (`tl!`). Most shipped catalogs carry most of the current localizable surface (roughly 2,400–2,800 entries each); Czech (2,794 entries) has all but four keys, and Finnish, Vietnamese, Catalan, Brazilian Portuguese, Spanish, Lithuanian and Romanian have all 2,798. Persian has 2,785. Untranslated labels use English. Error details that come from the engine or the operating system are shown as they are, inside a translated frame ("操作「…」に失敗しました: …"). The command palette matches the translated label, the English label and the command id. Vertical Japanese PDF rendering is an existing viewer feature; this change does not add vertical text editing. Document contents, filenames, CLI/MCP/control protocol strings and external help stay outside the catalog. Per-language coverage, script support and what each language still needs are in [localization-parity.md](localization-parity.md). Whole sentences that the layers below word — what the accessibility checker found, what the PDF/A verifier reports, what a signature's details say, what is wrong with the pages to print, what an edit is called in the history — are translated too, because those layers hand the sentence over apart from the document's own words. See [Sentences the engine words](#sentences-the-engine-words). -The Ukrainian catalog has 2,047 entries, written from the meaning of the English labels using Ukrainian computing vocabulary. Commands use infinitives; fixed-count messages use count-neutral wording. Plural-aware messages use the three [CLDR Ukrainian integer forms](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#uk): `1 сторінка`, `2 сторінки`, `5 сторінок`; 11–14 take the third form, while 21 and 22 return to the first and second. Calendar dates use genitive month names (`9 жовтня 2026`), while headings use standalone names (`Жовтень 2026`). Home recommendation cards grow to fit translated text; the toolbar search hint truncates before the shortcut, with the full label available on hover. +The Ukrainian catalog has 2,530 entries, written from the meaning of the English labels using Ukrainian computing vocabulary. Commands use infinitives; fixed-count messages use count-neutral wording. Plural-aware messages use the three [CLDR Ukrainian integer forms](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#uk): `1 сторінка`, `2 сторінки`, `5 сторінок`; 11–14 take the third form, while 21 and 22 return to the first and second. Calendar dates use genitive month names (`9 жовтня 2026`), while headings use standalone names (`Жовтень 2026`). Home recommendation cards grow to fit translated text; the toolbar search hint truncates before the shortcut, with the full label available on hover. -The Lithuanian catalog has 2,585 entries and three integer plural forms. Calendar headings use standalone month names; dates use genitive forms. Calendar, inspector and password-dialog UI regressions check dynamic labels and preserve document values. A native Lithuanian-speaking contributor has reviewed the translation. +The Lithuanian catalog has 2,798 entries and three integer plural forms. Calendar headings use standalone month names; dates use genitive forms. Calendar, inspector and password-dialog UI regressions check dynamic labels and preserve document values. A native Lithuanian-speaking contributor has reviewed the translation. -The Vietnamese catalog has 2,674 entries and one plural form (`plural_none`), covering the current catalog-key union. Preferences theme `Appearance` uses context `theme` → `Giao diện`; object/comment/signature appearance stays plain `Diện mạo`. Form fields use `Trường`; redaction apply wording states permanent removal. Calendar months are numeric (`Tháng 1`…`12` / `tháng 1`…`12`). A native Vietnamese-speaking contributor has reviewed the translation. Status stays `partial`: no translated help, and engine/OS details stay English inside a translated frame. +The Vietnamese catalog has 2,798 entries and one plural form (`plural_none`), covering the current catalog-key union. Preferences theme `Appearance` uses context `theme` → `Giao diện`; object/comment/signature appearance stays plain `Diện mạo`. Form fields use `Trường`; redaction apply wording states permanent removal. Calendar months are numeric (`Tháng 1`…`12` / `tháng 1`…`12`). A native Vietnamese-speaking contributor has reviewed the translation. Status stays `partial`: no translated help, and engine/OS details stay English inside a translated frame. -The Finnish catalog has 2,762 entries: every key of the other bundled catalogs, written from the English labels in ordinary Finnish computing vocabulary and sentence case. Finnish has the English one/other plural rule, with the partitive singular after any number but 1 (`1 sivu`, `2 sivua`, `0 sivua`). Calendar headings use the nominative month (`Lokakuu 2026`); dates use the partitive (`14. lokakuuta 2026`), and Finnish is also a date language for month and weekday names. Any `fi-*` locale selects it; `sv-FI` keeps English until PdfCraft has a Swedish catalog. The bundled Latin faces cover `ä ö å š ž`. The translation is AI-assisted (Claude Code) and has not yet been proofread line by line by a native speaker. +The Finnish catalog has 2,798 entries: every key of the other bundled catalogs, written from the English labels in ordinary Finnish computing vocabulary and sentence case. Finnish has the English one/other plural rule, with the partitive singular after any number but 1 (`1 sivu`, `2 sivua`, `0 sivua`). Calendar headings use the nominative month (`Lokakuu 2026`); dates use the partitive (`14. lokakuuta 2026`), and Finnish is also a date language for month and weekday names. Any `fi-*` locale selects it; `sv-FI` keeps English until PdfCraft has a Swedish catalog. The bundled Latin faces cover `ä ö å š ž`. The translation is AI-assisted (Claude Code) and has not yet been proofread line by line by a native speaker. -The Catalan catalog has 2,592 entries, written from the meaning of the English labels in standard Catalan computing vocabulary. Commands use the second-person imperative (`Desa`, `Obre`, `Cancel·la`) and notices use impersonal or informal wording. Catalan has two plural forms like English (1 is singular; 0 and every other count take the plural: `1 pàgina`, `3 pàgines`). Every `ca-*` locale, including Valencian (`ca_ES@valencia`), uses this catalog. The interpunct of `l·l` is U+00B7, which the bundled Latin faces cover (tested in `tests/it/fonts.rs`). The date picker has Catalan month and weekday names; dates are day-first like French (`dddd d mmmm yyyy`), but after a day the month carries its own preposition, elided before a vowel (`7 de març`, `7 d'abril`, `8 d'octubre`), as Czech and Russian change theirs through `months_with_day`; the year takes `\de` from the pattern (`d mmmm \de yyyy`). A native Catalan speaker (the contributor) has reviewed the translation. +The Catalan catalog has 2,798 entries, written from the meaning of the English labels in standard Catalan computing vocabulary. Commands use the second-person imperative (`Desa`, `Obre`, `Cancel·la`) and notices use impersonal or informal wording. Catalan has two plural forms like English (1 is singular; 0 and every other count take the plural: `1 pàgina`, `3 pàgines`). Every `ca-*` locale, including Valencian (`ca_ES@valencia`), uses this catalog. The interpunct of `l·l` is U+00B7, which the bundled Latin faces cover (tested in `tests/it/fonts.rs`). The date picker has Catalan month and weekday names; dates are day-first like French (`dddd d mmmm yyyy`), but after a day the month carries its own preposition, elided before a vowel (`7 de març`, `7 d'abril`, `8 d'octubre`), as Czech and Russian change theirs through `months_with_day`; the year takes `\de` from the pattern (`d mmmm \de yyyy`). A native Catalan speaker (the contributor) has reviewed the translation. -The Romanian catalog (`ro`, Română) has 2,673 entries and three plural forms (`plural_ro`, CLDR `ro`): `one` for 1, `few` for 0 and counts whose last two digits are 1–19 ("2 pagini", "101 pagini"), `other` for the rest, written with "de" ("20 de pagini"). Commands use the noun form common in Romanian software ("Salvare", "Deschidere…"); messages address the user formally. Redaction is "Anonimizare" ("redactare" means editing in Romanian), a watermark "Filigran". Calendar month names are lowercase inside dates (`{day} {month} {y}`). The bundled Inter faces cover ă, â, î, ș, ț (comma below). Not yet reviewed by a second native speaker; status `partial`: no translated help, and engine/OS details stay English inside a translated frame. +The Romanian catalog (`ro`, Română) has 2,798 entries and three plural forms (`plural_ro`, CLDR `ro`): `one` for 1, `few` for 0 and counts whose last two digits are 1–19 ("2 pagini", "101 pagini"), `other` for the rest, written with "de" ("20 de pagini"). Commands use the noun form common in Romanian software ("Salvare", "Deschidere…"); messages address the user formally. Redaction is "Anonimizare" ("redactare" means editing in Romanian), a watermark "Filigran". Calendar month names are lowercase inside dates (`{day} {month} {y}`). The bundled Inter faces cover ă, â, î, ș, ț (comma below). Not yet reviewed by a second native speaker; status `partial`: no translated help, and engine/OS details stay English inside a translated frame. -The Norwegian Bokmål catalog covers every key the other bundled catalogs have, with the English one/other plural rule, Norwegian key names (`Mellomrom`; Shift keeps its name) and month and weekday names for Fill & Sign dates. It was written from the meaning of the English labels; a native-speaking contributor reviewed its terminology (key names, Opacity, Redact, Highlight, Fill & Sign), not yet every line. +The Norwegian Bokmål catalog has 2,707 entries, all but 91 of the current key union, with the English one/other plural rule, Norwegian key names (`Mellomrom`; Shift keeps its name) and month and weekday names for Fill & Sign dates. It was written from the meaning of the English labels; a native-speaking contributor reviewed its terminology (key names, Opacity, Redact, Highlight, Fill & Sign), not yet every line. -Edit text reads Hebrew lines in typing order and writes them back in drawing order (`crates/edit/src/bidi.rs`); letters the page's own font lacks are drawn with the craft-fonts `Hebr` face (Noto Sans Hebrew) as Type 3 glyphs, and a build without one says so. Hebrew points (niqqud) can't be written yet in Edit text. Add text (#128) draws Hebrew with the same face through the shaped path Arabic and Thai use: right to left with numbers and Latin in place, points kept on their letter, and typing order for copy and search. The craft-fonts revision pinned for CI and releases has no `Hebr` face yet, so until one is added releases report that error instead of drawing the missing letters, and refuse Hebrew in Add text. +Edit text reads Hebrew lines in typing order and writes them back in drawing order (`crates/edit/src/bidi.rs`); letters the page's own font lacks are drawn with the craft-fonts `Hebr` face (Noto Sans Hebrew) as Type 3 glyphs, and a build without one says so. Hebrew points (niqqud) can't be written yet in Edit text. Add text (#128) draws Hebrew with the same face through the shaped path Arabic and Thai use: right to left with numbers and Latin in place, points kept on their letter, and typing order for copy and search. The craft-fonts revision pinned for CI and releases includes the `Hebr` face, so releases draw the missing letters and accept Hebrew in Add text. -Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes (`CRAFT_FONTS_DIR`; see the README). A build made without it has no Japanese face, so Japanese labels show replacement boxes. Chinese is drawn with the same craft-fonts CJK faces (in Simplified Chinese the `Hans` faces come first, so a line never mixes faces with different baselines), so a character none of them has shows a replacement box (and in a build without craft-fonts every Chinese character does, on the web and on a desktop machine with no installed Chinese face). Until the interface embeds a `Hans` face, the desktop app draws a Chinese interface with a Chinese face already installed on the machine (Microsoft YaHei or JhengHei on Windows, PingFang, Hiragino Sans GB or STHeiti on macOS, WenQuanYi or Droid Sans Fallback on Linux), placed before the Japanese faces so a line keeps one face (#826). The web build has no installed fonts to fall back on. Russian, Bulgarian, Czech, Brazilian Portuguese, German, Spanish, French, Hungarian, Italian, Slovenian, Turkish, Lithuanian, Norwegian, Finnish, Albanian, Catalan and Romanian need only the bundled Latin faces (tested in `tests/it/fonts.rs`). Slovenian has four plural forms: one (1, 101, 201…), two (2, 102…), few (3, 4, 103…) and other (everything else, including 0, 5–20 and 21+), so `@plural` entries list four forms (`1 stran`, `2 strani`, `3 strani`, `5 strani`). Hungarian uses the same one/other plural rule as English: 1 is singular, and every other count — including 0 — is the other form. After a numeral the noun stays singular (`1 oldal`, `2 oldal`). Telugu is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, kept in the web build too), whose conjuncts egui shapes; a build without one shows Telugu labels as replacement boxes, and the release-pinned craft-fonts input has no Telugu face yet. +Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes (`CRAFT_FONTS_DIR`; see the README). A build made without it has no Japanese face, so Japanese labels show replacement boxes. Chinese uses the pinned craft-fonts input too. On native builds, `fonts/simplified-chinese-manifest.txt` adds LXGW WenKai Lite as a `Hans` face (at most 16 MiB, never embedded on the web); Simplified Chinese puts the `Hans` faces first, so a line never mixes faces with different baselines. Traditional Chinese has no embedded `Hant` face: the desktop app places a Traditional Chinese face already installed on the machine (Microsoft JhengHei on Windows, PingFang TC on macOS, a Noto CJK TC or WenQuanYi Zen Hei on Linux) before the Japanese faces, even when LXGW is bundled, because a Simplified-only face cannot supply Traditional-only characters. Without an embedded `Hans` face, Simplified Chinese likewise uses an installed Simplified Chinese face (Microsoft YaHei, PingFang SC, …) placed before Japanese (#826). The web build has no installed fonts to fall back on, so it shows a replacement box for characters the Japanese face lacks. Russian, Bulgarian, Czech, Brazilian Portuguese, German, Spanish, French, Hungarian, Italian, Slovenian, Turkish, Lithuanian, Norwegian, Finnish, Albanian, Catalan and Romanian need only the bundled Latin faces (tested in `tests/it/fonts.rs`). Slovenian has four plural forms: one (1, 101, 201…), two (2, 102…), few (3, 4, 103…) and other (everything else, including 0, 5–20 and 21+), so `@plural` entries list four forms (`1 stran`, `2 strani`, `3 strani`, `5 strani`). Hungarian uses the same one/other plural rule as English: 1 is singular, and every other count — including 0 — is the other form. After a numeral the noun stays singular (`1 oldal`, `2 oldal`). A build supplied with a craft-fonts `Telu` face draws Telugu and retains that face in the web payload; egui shapes its conjuncts. The release-pinned craft-fonts input has no Telugu face, so its Telugu labels show replacement boxes. -The Brazilian Portuguese catalog has 2,674 entries and covers the current union of catalog keys. It began with the ~1,826-entry gist André Cardoso posted in [#288](https://github.com/storytold/pdfcraft/issues/288), with labels added since and the existing wording reviewed. Portuguese, like French, treats 0 and 1 as singular (`1 página`, `2 páginas`). +The Brazilian Portuguese catalog has 2,798 entries and covers the current union of catalog keys. It began with the ~1,826-entry gist André Cardoso posted in [#288](https://github.com/storytold/pdfcraft/issues/288), with labels added since and the existing wording reviewed. Portuguese, like French, treats 0 and 1 as singular (`1 página`, `2 páginas`). The Albanian catalog (`sq.tsv`, interface name **Shqip**) is written in Standard Albanian with the imperative for commands (`Ruaj`, `Hap`, `Fshi`). Albanian uses two plural forms like English: 1 is singular and every other count, including 0, is plural (`1 faqe`, `2 faqe`). Month and weekday names are lowercase (`mars`, `e shtunë`), and the letters `ë` and `ç` are covered by the bundled Latin faces. It has not been reviewed by a native Albanian speaker yet. ## Arabic and right-to-left text -The Arabic catalog has 2,137 entries in Modern Standard Arabic with Western digits. Counts use the six [CLDR Arabic forms](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#ar) (`0`, `1`, `2`, `3–10`, `11–99`, the rest), and dates use the Arabic names of the Gregorian months. +The Arabic catalog has 2,783 entries in Modern Standard Arabic with Western digits. Counts use the six [CLDR Arabic forms](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#ar) (`0`, `1`, `2`, `3–10`, `11–99`, the rest), and dates use the Arabic names of the Gregorian months. -egui joins the letters of an Arabic word and runs them right to left, but it places the words of a line left to right in the order they are written. So catalog entries are written in logical (typing) order, and `i18n` puts each one into display order when the catalog loads (`bidi::display_rtl`): the line reads right to left, Latin words and numbers inside it read left to right, `{name}` placeholders and keyboard shortcuts stay whole, spaces at either end stay where the code concatenates, and a short caption keeps its colon on the right, beside the widget it captions. Lookups (`tl!`, `tr`, `trn`) therefore return display-order text for Arabic; `i18n::fmt` inserts values as they are. The command search matches a query of several Arabic words word by word. +egui 0.37's epaint shapes every line with the Unicode bidirectional algorithm (UAX #9). Catalog entries stay in logical (typing) order and are never preordered: `tl!`, `tr` and `trn` return logical text, `bidi::display_rtl` and `bidi::visual` exist only for the tests that prove the old preordering is gone, and epaint joins and reorders Arabic on every line. Interface labels use an explicit `TextDirection::RightToLeft` in an Arabic, Kurdish or Persian interface, so a label whose first strong character is Latin or a `{placeholder}` still reads right to left. File and path labels use `TextDirection::LeftToRight`, so an Arabic file name keeps `.pdf` at its visual end; document-authored text uses `Auto`. Stock chrome is mirrored through `Style::right_to_left` (menus, combo boxes, scroll bars, headers), and `i18n::fmt` inserts values in one pass. The command search matches a query of several Arabic words word by word. -What Arabic does not have yet: +Wrapped paragraphs break between words in logical order and epaint reorders each resulting line, so a wrapped label reads in the right direction from the first line down. Text typed into a field is shaped and reordered while editing, and the stored value stays logical. -- **The layout is not mirrored.** Panels, menus, toolbars and alignment are as in the left-to-right languages, with Arabic labels in them. -- **A wrapped paragraph breaks at the wrong end.** A label too long for its line continues on the next line with its *first* words, so wrapped descriptions and tooltips read from the last line up. Single-line labels are right. -- **Text you type into a field** (search, comments, form values) has joined letters but its words run left to right while you edit it. What is stored is correct. -- Labels built by joining translated pieces in code (a list such as `A · B · C`) keep the code's left-to-right order. +Central Kurdish (Sorani, `ckb`) takes the same path: its catalog is written in logical order and `rtl: true` selects the explicit right-to-left direction; epaint reorders it, so it behaves as Arabic does above. Counts use the two [CLDR ckb forms](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#ckb) (`one`, `other`); Sorani keeps the noun singular after a numeral, so the two `@plural` forms usually read the same. Dates use the Kurdish names of the Gregorian months. The `Arab` face (Noto Sans Arabic) covers the Kurdish-only letters (`ێ ڵ ڕ ۆ ە ڤ ژ چ گ پ`). -These need bidirectional support in the text layout itself, not in the catalog. +The Persian catalog (`fa.tsv`, interface name **فارسی**) has 2,785 entries, covering 2,785 of the current 2,798-key union; the 13 keys added after it was written still show English. It is written in contemporary software Persian from the meaning of the English labels. Counts use the two [CLDR `fa`](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#fa) forms (`one` for 0 and 1, `other` for every other count), so `@plural` entries list two forms. It is right to left like Arabic and Kurdish: `rtl: true` selects the explicit right-to-left direction, the entries stay in logical order and epaint reorders each line, and the stock chrome is mirrored as it is for Arabic. Compound words use the zero-width non-joiner (نیم‌فاصله, U+200C), the Persian comma `،` and question mark `؟`, and Persian `ی`/`ک` rather than the Arabic `ي`/`ك`. Dates use the Persian names of the Gregorian months. The `Arab` face (Noto Sans Arabic) covers Persian letters, so it needs no Japanese face. The translation is AI-assisted and has not yet been proofread line by line by a native speaker; status `partial`: no translated help, and engine/OS error details stay English inside a translated frame. -Central Kurdish (Sorani, `ckb`) takes the same path: its catalog is written in logical order and `rtl: true` puts every entry into display order at load (`bidi::display_rtl`), so it behaves as Arabic does above and has the same four limits. Counts use the two [CLDR ckb forms](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#ckb) (`one`, `other`); Sorani keeps the noun singular after a numeral, so the two `@plural` forms usually read the same. Dates use the Kurdish names of the Gregorian months. The `Arab` face (Noto Sans Arabic) covers the Kurdish-only letters (`ێ ڵ ڕ ۆ ە ڤ ژ چ گ پ`). +Arabic glyphs come from the craft-fonts `Arab` face (Noto Sans Arabic, kept in the web build too). A build without craft-fonts shows Arabic as replacement boxes on the web; on desktop it falls back to one installed font (below). The craft-fonts revision pinned for CI and releases (`.github/workflows/`) has the Arabic and Hebrew faces. Its Noto Sans CJK SC face is 16.4 MB and is left out by the general CJK cap; native builds take the separately pinned Simplified Chinese face (LXGW WenKai Lite) from `fonts/simplified-chinese-manifest.txt`, and the web build embeds only BIZ UDPGothic Regular plus the small Arabic, Hebrew, Thai and Devanagari faces. -Arabic glyphs come from the craft-fonts `Arab` face (Noto Sans Arabic, kept in the web build too). A build without craft-fonts shows Arabic as replacement boxes on the web; on desktop it falls back to one installed font (below). The craft-fonts revision pinned for CI and releases (`.github/workflows/`) has the Arabic face. That revision also holds Noto Sans CJK SC, which `crates/fonts/build.rs` leaves out for size (16.4 MB whole), so Chinese still has no embedded face of its own (desktop builds fall back to an installed Chinese face, above). +File names and document titles use the craft-fonts `Arab` face (Noto Sans Arabic) for Arabic and the `Hebr` face (Noto Sans Hebrew) for Hebrew when those faces are included in the build input; on desktop, a character no embedded face has is drawn with one font already installed on the machine (`PDFCRAFT_SYSTEM_FONTS=0` turns this off). epaint reorders each line with UAX #9, while file and path labels keep an explicit left-to-right base so the extension stays at the visual end; the tab strip, the recent-files list and Properties show such names in display order. -File names and document titles in Arabic or Hebrew are drawn with a craft-fonts `Arab` face when the build input has one; on desktop, a character no embedded face has is drawn with one font already installed on the machine (`PDFCRAFT_SYSTEM_FONTS=0` turns this off). The tab strip, the recent-files list and Properties show such names in display order; other places (bookmarks, comments, search results) have the glyphs but may order mixed-direction text wrongly. - -Catalog coverage does not guarantee glyph coverage: the craft-fonts revision pinned for CI and releases has no `Hans` face small enough to embed (desktop builds embed a `Hans` face of at most 4 MiB from `CRAFT_FONTS_DIR`), and the web build embeds only BIZ UDPGothic Regular. Chinese font delivery remains separate work, as discussed in [#112](https://github.com/storytold/pdfcraft/pull/112) and [#140](https://github.com/storytold/pdfcraft/pull/140). +Catalog coverage does not guarantee glyph coverage. Native builds embed LXGW WenKai Lite as the Simplified Chinese face from the pinned craft-fonts input (at most 16 MiB), while the web build embeds only BIZ UDPGothic Regular plus the small Arabic, Hebrew, Thai and Devanagari faces, so Chinese characters none of those faces has show replacement boxes on the web. Traditional Chinese has no embedded face and uses a `Hant` face installed on the machine, placed before the Japanese faces even when LXGW is bundled. German labels keep nouns capitalized, including tool names inserted into notices. The certificate viewer uses the `certificate` context for `Subject` (the certificate holder), while document properties keep the plain translation (the topic). Search hints reserve room for the shortcut, and Home cards limit their title and description to the available rows; hovering shows the full text when it is shortened. @@ -96,3 +91,5 @@ Error details that come from the operating system, and faults rather than mistak 3. Add an `ATTRIBUTION.toml` entry for the catalog (`kind = "translation"`, no `sha256`), then run `cargo xtask assets --write` (it regenerates `ATTRIBUTION.md` only) and `cargo xtask assets`. The Preferences dropdown, the system-language match, the catalog tests (format, duplicates, placeholders, plural forms, command ids) and the translation gaps report then pick it up. + +The egui 0.37 migration pins craft-fonts `b076417b5880bd4154bd22f00695b1d28c6bea83`. That manifest supplies Arabic, Hebrew, Thai and Devanagari faces selected for the web payload, but contains no Telugu face. The build code accepts a `Telu` row when a future input provides one; it does not establish Telugu glyph coverage with the current pin. diff --git a/docs/releasing.md b/docs/releasing.md index ec2e5853b3..496ef4a672 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -148,7 +148,18 @@ neither the MSI nor the portable zip needs the Visual C++ redistributable. - The portable zip holds both executables, the README, the licences, the OFL licence of each embedded craft-fonts family, and `portable.txt`. That marker beside `pdfcraft.exe` keeps the settings, logs, crash recovery and new digital IDs in `PdfCraftData\` next to the exe instead of - `%APPDATA%` and `%LOCALAPPDATA%` (`crates/ui-egui/src/portable.rs`). + `%APPDATA%` and `%LOCALAPPDATA%` (`crates/ui-egui/src/portable.rs`). The MSI carries the same + OFL notices; `-SkipBuild` requires `CRAFT_FONTS_DIR` so they can be staged, or + `-BuiltWithoutCraftFonts` for binaries built without that input, and `test-msi.ps1` checks that + the compiled MSI has OFL notice rows when expected (and none for a no-font build). + Before either package is created, `font-notices.ps1` validates every declared OFL notice in + `fonts/manifest.txt` and the native `fonts/simplified-chinese-manifest.txt` when present + (required when `CRAFT_FONTS_REQUIRED` is set). Missing or empty family notices fail packaging; + one family's notice cannot cover another. The check conservatively includes declared faces + omitted by build-time size caps. `test-font-notices.ps1` exercises these failures in CI. + The supplied checkout must match the existing binaries when using `-SkipBuild`. + That presence check does not establish byte-for-byte payload or installation coverage; + final package qualification still checks all notices against the staged files. - **Signing:** `packaging/windows/sign.ps1` signs both executables and the MSI with `signtool` (SHA-256, RFC 3161 timestamp), from a `.pfx` (`WINDOWS_CERTIFICATE`) or Azure Trusted Signing (`AZURE_*`), whichever is configured. It is the one place to change when signing changes. diff --git a/docs/roadmap.md b/docs/roadmap.md index 6b079d12f1..40fa711c24 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # PdfCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M8, #3205 follow-up: labelled Italian international phone numbers with a slash match whole; estimates unchanged) +> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged) @@ -135,15 +135,15 @@ In order. Each item names its entry in [gaps.md](gaps.md). Encrypted output that Acrobat opens shipped with #836 (#774). 2. **Fix the crash and wrong-result reports** filed against shipped features (gap 6). The first batch (#799–#821, including the panic in #816) is fixed. -3. **A Chinese UI face in releases** (gap 5, #826). +3. **A Chinese UI face in the web build** (gap 5, #826). 4. **A fidelity harness against Acrobat, then the renderer decision** (gaps 12 and 2). Owner decision needed: our own M2 devices, or #841's proposal as the bridge. 5. **Editing existing content** (gap 3, M7): fonts, subsets, reflow, CJK and RTL. 6. **Pro workflows:** OCR languages (gap 7), Office export/import (gap 8), Preflight and PDF/A/X/UA (gap 9), signatures with tokens, timestamp servers and online LTV (gap 10), accessibility tagging (gap 11). -7. **1.0 polish (M14):** performance budgets and lazy loading (gap 13), right-to-left layout and the - missing languages (gap 14), keyboard-only operation, a screen-reader audit. +7. **1.0 polish (M14):** performance budgets and lazy loading (gap 13), end-to-end right-to-left + qualification and the missing languages (gap 14), keyboard-only operation, a screen-reader audit. Decided against for now (owner, 2026-10-05): self-installing updates and an update check at start. Help ▸ Check for updates is manual only. @@ -177,7 +177,7 @@ total matches the parity estimate. | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Organize opens directly into the page overview, with checkbox selection, an editable page-range selection (#1092), and per-page rotate/delete controls; Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Extract and Split preserve shared Type 3 glyph resource graphs, retaining conservative pruning limits. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Renderer-creation errors return to the existing OpenGL startup fallback; a real-GPU regression covers errors and device reuse. The underlying x86 DX12 shader compiler failure remains unresolved; see `windows-renderer-startup.md`. With Windows' Animation effects turned off, the wheel scrolls and zooms without easing and egui's animations take no time (`motion.rs`; macOS, Linux and the browser aren't asked yet); Preferences ▸ Animation effects (Use system setting, On, Off) decides it on every platform. | | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web | -| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: the Comments panel groups replies by name and counts all page roots once per frame (#2669, item 5); stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles; Ctrl+Enter submission for highlight comment text and replies, with Shift+Enter newlines (reconciled with upstream main for PR #1457 on 2026-10-14). Missing: custom stamps, replace-text proposals, summary layouts with the page | +| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: replies thread by their `/IRT` reference rather than the parent's `/NM`, so files that repeat names per page (PyMuPDF) thread correctly, and the Comments header counts comments without their replies, as Acrobat does, with the reply count under it; the Comments panel groups replies and counts all page roots once per frame (#2669, item 5); stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles; Ctrl+Enter submission for highlight comment text and replies, with Shift+Enter newlines (reconciled with upstream main for PR #1457 on 2026-10-14). Missing: custom stamps, replace-text proposals, summary layouts with the page | | M4 | Engine, history, save, organize | 100–180 | 95% | 8–15 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize, Replace pages, bookmarks from tagged headings. Missing: page transitions, recovery on the web M4: Insert-from-file preserves source bookmarks; integration tests retained with current main. | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: stamps placed on a rotated page read upright (#1398); polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 12–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export (partial imports preserve existing reply and review-status links; nested FDF export uses lazy array owners and annotation positions, with retained entries budgeted before cloning under copied-graph limits and source `/Page` and `/Pages` dictionaries replaced by null at every copy depth); flatten; Fill & Sign with adjustable text/date size; agent tools.
Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. A refused restyle or resize of a text box another program drew leaves that comment unchanged (#1327). Make Current Properties Default on an arrow updates arrows, not plain lines (#1302). Flattening keeps a visible comment it cannot draw (#1317). Missing: custom stamps, replace-text proposals, summary layouts with the page | @@ -199,7 +199,7 @@ total matches the parity estimate. | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Character encoding accepts Identity-H/V with Adobe CJK collections and predefined CJK CMaps independently (#3673); per-glyph validation and embedded-font extraction remain separate.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA
Done since: CLI info, text and render use dynamic XFA layout pages, with synthetic count/text/pixel and copy-permission regressions (#1645).
Reading colours: pending page backgrounds follow the configured colour (#2059). | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. | -| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian and Arabic (logical-order catalog put into display order; layout not yet mirrored); the 2,674-entry Brazilian Portuguese catalog covers the current key union; Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Norwegian Bokmål adds a catalog and date names (terminology reviewed by a native speaker). Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Candidate #2383 clamps GPU requests to supported limits while retaining the adapter texture extent; synthetic V3D/downlevel and Windows GPU-required tests pass, but Raspberry Pi startup is unverified. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation Linux/Unix UI fallback fonts also use Fontconfig directories, including Nix store paths, with bounded discovery and the existing candidate allowlist; relative includes and digit-prefixed config filtering have isolated regressions. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation XDG desktops use the configuration base for preferences with non-overwriting migration and original-data preservation (#1395). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. Long main-menu lists scroll in short windows (#3373). Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation. Candidate #3558: bounded-worker macOS per-file PDFCraft association recovery after desktop open/save/sign; metadata-only watcher handling and upstream #3042 shared-writer preservation; recovery parser limits do not narrow save limits. Native workflow qualified on macOS 27.0.1; other versions and physical SMB/FUSE volumes remain unqualified. Estimates unchanged. Missing: a right-to-left layout, a Chinese UI face in releases, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation. | +| M14 | 1.0 polish: performance, localization, installers | 120–250 | 15% | 80–150 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`. Catalogs of ≈ 2,000–2,140 entries for Japanese, Simplified and Traditional Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian and Arabic (logical catalog; explicit right-to-left labels and mirrored stock chrome; end-to-end RTL workflows unqualified); the 2,674-entry Brazilian Portuguese catalog covers the current key union; Czech covers the menus. Italian has 2,528 entries: every key of the other catalogs that the code looks up. Signed and notarized macOS builds, signed Windows installers (x64, x86, ARM64), AppImage, deb, rpm, Flatpak and FreeBSD packages. #307 (bounded large-PDF memory): shared resource indexes and parser metadata, lazy inspection, bounded caches; a 9,156-page file inspects in ≈ 1.8 s at ≈ 500 MiB instead of exceeding 10 GiB. Edits let go of the previous working file before writing the next (a 1 GiB file peaks at two copies while editing, not three; gap 13). Shortcut display uses localized modifier/key names across menus, toolbar, palette, Help and tooltips (#496). Lithuanian adds a 2,578-entry catalog and date names, reviewed by a native-speaking contributor. Norwegian Bokmål adds a catalog and date names (terminology reviewed by a native speaker). Validated candidate (macOS raster probe; Windows runtime unverified): the Windows policy changes only embedded JetBrains Mono; horizontal alpha-edge energy per ink improves 2.78%, 3.87% and 1.59% at 100%, 125% and 150%, and is down 0.79% at 200%. Inter is pixel-identical; layout hashes, glyph coverage and the 8 MiB atlas are unchanged. Timing and RSS ranges overlap, with no improvement claimed. Estimates unchanged. Candidate #2383 clamps GPU requests to supported limits while retaining the adapter texture extent; synthetic V3D/downlevel and Windows GPU-required tests pass, but Raspberry Pi startup is unverified. Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation Linux/Unix UI fallback fonts also use Fontconfig directories, including Nix store paths, with bounded discovery and the existing candidate allowlist; relative includes and digit-prefixed config filtering have isolated regressions. Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation XDG desktops use the configuration base for preferences with non-overwriting migration and original-data preservation (#1395). Traditional Chinese accessibility statuses, rule semantics and Taiwan form/table terms have catalog regressions. Long main-menu lists scroll in short windows (#3373). Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean, file-backed input and global memory budgets, performance budgets, keyboard-only operation. Candidate #3558: bounded-worker macOS per-file PDFCraft association recovery after desktop open/save/sign; metadata-only watcher handling and upstream #3042 shared-writer preservation; recovery parser limits do not narrow save limits. Native workflow qualified on macOS 27.0.1; other versions and physical SMB/FUSE volumes remain unqualified. Estimates unchanged. Missing: end-to-end RTL workflow qualification, a Chinese web UI face, Hindi/Indonesian/Korean/Vietnamese, file-backed input and global memory budgets, performance budgets, keyboard-only operation. | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers | | | **Total** | **2,085–3,840** | **≈ 49% ready** | **≈ 770–1,400** | Area view of the same work: [target-app-parity.md](target-app-parity.md#by-feature-area) | @@ -208,7 +208,7 @@ total matches the parity estimate. | Rank | Next milestone | Closes | Estimate (h) | |---|---|---|---| | 0 | Convert and share: Create PDF from Office, Word export keeps layout | part of gap 8 | 32–55 | -| 1 | Backlog and platform: crash and wrong-result reports fixed, Chinese UI face (web printing done) | gaps 5, 6 | 50–90 | +| 1 | Backlog and platform: crash and wrong-result reports fixed, Chinese web UI face (web printing done) | gaps 5, 6 | 50–90 | | 2 | Fidelity harness and renderer decision (M2) | gaps 2, 12 | 110–190 | | 3 | Editing existing content (M7) | gap 3 | 100–180 | | 4 | Pro conversion: OCR languages and clean-up, Office export/import (M10) | gaps 7, 8 | 95–175 | @@ -242,6 +242,8 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st ## Revision history +**2026-10-25 · minor:** M14 localization current state: Arabic catalogs stay logical, Arabic/Kurdish labels take an explicit RTL direction, and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native Simplified Chinese uses LXGW WenKai Lite; the web build has no Hans face. Estimates unchanged. + **2026-10-23 · minor:** M11: image downsampling uses physical resolution after page-local `/UserUnit` scaling, including shared nested forms. Synthetic measurement and optimization regressions cover the change. Estimates unchanged. @@ -252,6 +254,7 @@ Estimates unchanged. | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | M3, #3970 follow-up: the button release given back after a window move or resize goes before the pointer leaving, so hover doesn't stick. Estimates unchanged. | | 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. | | 2026-10-23 | minor | M10: Word table cells use the grid columns they span, including cells after a merged cell; synthetic export regression and CLI reproduction. Estimates unchanged. | | 2026-10-23 | minor | M10: covered table slots retain their text in paragraph flow across Office exporters; emitted superscripts remain in their cells. Synthetic source and CLI regressions; estimates unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b019bdf3d6..6c7920e33c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # PdfCraft parity with Adobe Acrobat Pro -> **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M12 #3673: Character encoding CJK mapping routes corrected; scores and estimates unchanged) +> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (localization current state: Arabic logical catalog, explicit RTL direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite. Scores and estimates unchanged) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (localization ≈59% → ≈68%; weighted readiness 49.25 → 49.7 ≈ 50%) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) @@ -183,7 +183,7 @@ features total and are shown for orientation; the others add to it. | File formats and conversion | ≈ 40% | 12% | 120–220 | within features | PDF read/write strong, encrypted output included (#774 fixed); export Word is layout-poor (#773); no Excel/PowerPoint export, no Office/HTML/PostScript import | [file-format-parity.md](file-format-parity.md) | | PDF specification and standards | ≈ 60% | 8% | 150–260 | within features | ISO 32000 syntax, encryption, annotations and AcroForm strong; rendering and fonts borrowed from `hayro`; PDF 2.0 extras, linearization, public-key security absent; PDF/A partial, PDF/X/UA none | [pdf-spec-parity.md](pdf-spec-parity.md) | | Hardware | ≈ 35% | 4% | 25–50 | additional | Page raster is CPU only (Acrobat has GPU rendering); Windows CNG and macOS Keychain keys sign, no PKCS #11; no scanners; web printing goes through the browser's print dialog as pictures, not vectors | [hardware-parity.md](hardware-parity.md) | -| Localization | ≈ 68% over the 12 key languages | 5% | 35–60 | additional | 22 catalogs + English (23 interface languages); Chinese UI shows tofu in releases (#826, #688); Arabic not mirrored; no Hindi, Indonesian, Korean | [localization-parity.md](localization-parity.md) | +| Localization | ≈ 68% over the 12 key languages | 5% | 35–60 | additional | 22 catalogs + English (23 interface languages); native craft-fonts builds embed a Simplified Chinese face (LXGW WenKai Lite) but the web build has none; Arabic labels stay logical with explicit RTL direction and mirrored stock chrome, while native/browser/IME workflows are unqualified; no Hindi, Indonesian, Korean | [localization-parity.md](localization-parity.md) | | Performance | ≈ 40% | 6% | 25–45 | additional | 500-page smooth scrolling and large-file memory work (#307) done; no performance budgets (`misc.performance-budgets`), no lazy loading of GB files; blurry text at 1080p/1440p (#730) | [gaps.md](gaps.md) | | Stability | ≈ 50% | 8% | 45–80 | additional | Never-crash rules and nightly fuzzing; 245 open issues on 2026-10-10, many of them `fix(...)` reports of wrong results in shipped features (the #799–#821 batch is fixed) | [gaps.md](gaps.md) | | Platforms | ≈ 70% | 5% | 15–30 | additional | macOS (signed, notarized), Windows x64/x86/ARM64 (signed), Linux (AppImage, deb, rpm, Flatpak), FreeBSD, web (WASM). Acrobat has no Linux, FreeBSD or full web editor; we lack web OCR and web recovery | [hardware-parity.md](hardware-parity.md) | @@ -249,7 +249,7 @@ stage set to pre-alpha; see the revision history.) | Beta requirement | Hours | |---|---| | An interop suite that opens our outputs in other readers (encrypted output already opens in Acrobat, #774, #836; hours not re-estimated) | 8–15 | -| Rendering: a fidelity harness against Acrobat captures (local, clean-room) and fixing what it finds; settle the renderer (own M2 devices, or #841's proposal); Chinese UI faces in releases | 80–140 | +| Rendering: a fidelity harness against Acrobat captures (local, clean-room) and fixing what it finds; settle the renderer (own M2 devices, or #841's proposal); a Chinese web UI face | 80–140 | | Editing existing text robust on real files: subset fonts, CJK, RTL, rotated text | 70–120 | | Printing on Windows and the web | 15–30 | | OCR in the major languages (accents, Cyrillic, CJK), deskew and rotation | 35–60 | @@ -294,6 +294,7 @@ licensed OCR or AI model. | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | Localization current state: Arabic catalogs stay logical, Arabic/Kurdish labels take an explicit RTL direction, and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native Simplified Chinese uses LXGW WenKai Lite; the web build has no Hans face. Scores and estimates unchanged. | | 2026-10-20 | minor | M12 #3673: the Character encoding rule accepts Identity-H/V fonts with Adobe CJK collections, or a predefined non-Identity CMap, while rejecting unknown names and foreign registries. Synthetic checker and read-only automation regressions passed after failing before the fix; per-glyph mapping validation and embedded-font extraction remain separate. Breadth, readiness and hours unchanged. | | 2026-10-12 | minor | Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged. | | 2026-10-11 | minor | Web printing shipped (gap 4): Hardware, Platforms and L Print text updated. Text only: scores, readiness and hours not re-judged | diff --git a/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml b/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml index b3b9c53849..8ae41ae976 100644 --- a/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml @@ -48,6 +48,7 @@ modules: build-commands: - cargo --offline build --release --locked -p pdfcraft - install -Dm755 target/release/pdfcraft /app/bin/pdfcraft + - install -Dm644 NOTICE /app/share/doc/pdfcraft/NOTICE - install -Dm644 packaging/linux/ai.storyteller.pdfcraft.desktop /app/share/applications/ai.storyteller.pdfcraft.desktop - install -Dm644 packaging/linux/ai.storyteller.pdfcraft.mime.xml /app/share/mime/packages/ai.storyteller.pdfcraft.xml - mkdir -p /app/share/icons && cp -R assets/app-icon/hicolor /app/share/icons/ diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index df292455e8..fd486c392b 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -153,7 +153,6 @@ if has appimage; then fi APPDIR="$WORK/PdfCraft.AppDir" cp -R "$STAGE" "$APPDIR" - mv "$APPDIR/usr/share/doc" "$WORK/doc-unused" ln -s usr/bin/pdfcraft "$APPDIR/AppRun" cp "$HERE/$APP_ID.desktop" "$APPDIR/$APP_ID.desktop" cp "$ROOT/assets/app-icon/hicolor/256x256/apps/$APP_ID.png" "$APPDIR/$APP_ID.png" diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index 2e2ffc83f5..50701e0c16 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -103,6 +103,8 @@ mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" # Executable and icon carry the display name (CFBundleExecutable / CFBundleIconFile). cp "$WORK/bin/pdfcraft" "$APP/Contents/MacOS/PdfCraft" cp "$ROOT/assets/app-icon/pdfcraft.icns" "$APP/Contents/Resources/PdfCraft.icns" +# Required embedded-font notices are independent of optional font inputs. +cp "$ROOT/NOTICE" "$APP/Contents/Resources/NOTICE" # The standard About panel (PdfCraft menu > About PdfCraft) shows Credits.rtf under the version: # the website and Discord links the in-app About dialog has (#1287). cp "$HERE/Credits.rtf" "$APP/Contents/Resources/Credits.rtf" diff --git a/packaging/windows/font-notices.ps1 b/packaging/windows/font-notices.ps1 new file mode 100644 index 0000000000..6d8fdc5a1e --- /dev/null +++ b/packaging/windows/font-notices.ps1 @@ -0,0 +1,48 @@ +# Validate one non-comment manifest row before adding its family notice. +function Get-CraftFontNoticeRow([string] $rootPath, [string] $manifest, [string] $line) { + $fields = @($line -split ' \| ' | ForEach-Object { $_.Trim() }) + if ($fields.Count -lt 6 -or $fields[4] -ne 'OFL-1.1') { throw "Missing OFL metadata in ${manifest}: $line" } + # Manifest notice paths must name a family-local file, never escape the font input. + $relative = $fields[5].Replace('\', '/') + if ($relative -notmatch '^fonts/([a-zA-Z0-9_-]+)/OFL\.txt$') { throw "Invalid font notice path: $relative" } + $family = $Matches[1] + $fontPath = $fields[2].Replace('\', '/') + if ($fontPath -notmatch ('^fonts/' + [regex]::Escape($family) + '/[^/]+$')) { + throw "Font and notice must belong to the same family directory: $fontPath / $relative" + } + $notice = Join-Path $rootPath $relative + if (-not (Test-Path -LiteralPath $notice -PathType Leaf) -or (Get-Item -LiteralPath $notice).Length -eq 0) { + throw "Missing or empty OFL notice for $($fields[0]): $notice" + } + return @{ Family = $family; Notice = Get-Item -LiteralPath $notice } +} + +# Validate every declared native font notice before either Windows package is created. +# Checking all declared faces is deliberately conservative: size-capped faces may not be embedded. +function Get-CraftFontNotices([string] $FontRoot, [bool] $RequireChinese = $false) { + $rootPath = (Resolve-Path -LiteralPath $FontRoot).Path + $notices = @{} + foreach ($name in 'manifest.txt', 'simplified-chinese-manifest.txt') { + $manifest = Join-Path $rootPath "fonts/$name" + if (-not (Test-Path -LiteralPath $manifest -PathType Leaf)) { + if ($name -eq 'manifest.txt' -or $RequireChinese) { throw "Missing font manifest: $manifest" } + continue + } + foreach ($line in Get-Content -LiteralPath $manifest) { + $line = $line.Trim() + if (-not $line -or $line.StartsWith('#')) { continue } + $entry = Get-CraftFontNoticeRow $rootPath $manifest $line + $notices[$entry.Family] = $entry.Notice + } + } + if ($notices.Count -eq 0) { throw "No declared font notices in $rootPath" } + foreach ($family in @($notices.Keys | Sort-Object)) { $notices[$family] } +} + +# MSI FileName may be short|long. Compare the long names with the staged inventory. +function Assert-CraftFontNoticeNames([string[]] $Expected, [string[]] $Actual) { + $names = @($Actual | ForEach-Object { ($_ -split '\|')[-1] }) + foreach ($name in $Expected) { + if ($name -notin $names) { throw "The MSI is missing staged font notice: $name" } + } +} diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index 524280b971..dccc21f559 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -16,16 +16,30 @@ .EXAMPLE pwsh packaging/windows/package.ps1 -Arch x64 - pwsh packaging/windows/package.ps1 -Arch x86 -SkipBuild + pwsh packaging/windows/package.ps1 -Arch x86 -SkipBuild # set CRAFT_FONTS_DIR, or -BuiltWithoutCraftFonts pwsh packaging/windows/package.ps1 -Arch arm64 # cross-compiled; needs the MSVC ARM64 build tools #> param( [ValidateSet('x64', 'x86', 'arm64')] [string] $Arch = 'x64', - [switch] $SkipBuild + [switch] $SkipBuild, + # -SkipBuild only: the existing binaries were built without CRAFT_FONTS_DIR, so they embed no + # craft-fonts faces. Without CRAFT_FONTS_DIR this assertion is required: package.ps1 cannot + # otherwise know whether the binaries need the fonts' OFL notices. + [switch] $BuiltWithoutCraftFonts ) $ErrorActionPreference = 'Stop' $Root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +if ($BuiltWithoutCraftFonts -and -not $SkipBuild) { + throw '-BuiltWithoutCraftFonts describes an existing build made without CRAFT_FONTS_DIR; use it only with -SkipBuild.' +} +if ($BuiltWithoutCraftFonts -and $env:CRAFT_FONTS_DIR) { + throw '-BuiltWithoutCraftFonts cannot be combined with CRAFT_FONTS_DIR.' +} +if ($SkipBuild -and -not $env:CRAFT_FONTS_DIR -and -not $BuiltWithoutCraftFonts) { + throw 'package.ps1 -SkipBuild needs CRAFT_FONTS_DIR to stage the OFL notices of embedded craft-fonts, or -BuiltWithoutCraftFonts when the binaries were built without it.' +} + function Invoke-Native([string] $What, [scriptblock] $Block) { Write-Output "==> $What" & $Block @@ -108,17 +122,38 @@ if (-not (Test-Path (Join-Path $Models 'ATTRIBUTION.txt')) -or -not (Get-ChildIt throw "no OCR models in $Models after cargo xtask models" } +# ---- font notices for the MSI ------------------------------------------------------------------ +# The portable ZIP already carries these notices; stage the complete files before MSI creation too. +$Docs = Join-Path $Stage 'font-licences' +New-Item -ItemType Directory -Force -Path $Docs | Out-Null +$HasCraftFontLicences = 0 +if ($env:CRAFT_FONTS_DIR) { + . (Join-Path $PSScriptRoot 'font-notices.ps1') + # Validate both native manifests before copying anything. One family's notice cannot + # stand in for another's; retain complete files, including copyright and reserved names. + $fontNotices = @(Get-CraftFontNotices $env:CRAFT_FONTS_DIR (Test-Path Env:CRAFT_FONTS_REQUIRED)) + foreach ($ofl in $fontNotices) { + Copy-Item -LiteralPath $ofl.FullName -Destination (Join-Path $Docs "OFL-$($ofl.Directory.Name).txt") + $HasCraftFontLicences = 1 + } +} +# ---- end font notices for the MSI -------------------------------------------------------------- + # ---- MSI --------------------------------------------------------------------------------------- $Msi = Join-Path $Dist "pdfcraft-$Version-windows-$Arch.msi" Invoke-Native 'wix build' { wix build (Join-Path $PSScriptRoot 'pdfcraft.wxs') -arch $Arch ` (Join-Path $PSScriptRoot 'installer-ui.wxs') ` -d "Version=$MsiVersion" -d "BinDir=$Stage" -d "ModelsDir=$Models" -d "IconPath=$(Join-Path $Root 'assets\app-icon\pdfcraft.ico')" ` + -d "DocsDir=$Docs" -d "HasCraftFontLicences=$HasCraftFontLicences" ` -o $Msi } # Inspect the built MSI, not just the XML, before signing/publishing it. In a child process, so # its Windows Installer database handle is gone before signtool opens the MSI. -Invoke-Native 'test-msi' { & (Get-Process -Id $PID).Path -NoProfile -File (Join-Path $PSScriptRoot 'test-msi.ps1') $Msi } +Invoke-Native 'test-msi' { + & (Get-Process -Id $PID).Path -NoProfile -File (Join-Path $PSScriptRoot 'test-msi.ps1') $Msi ` + -CraftFontLicences $HasCraftFontLicences -ExpectedFontNoticesDirectory $Docs +} # wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets. Remove-Item -Force -ErrorAction SilentlyContinue ([IO.Path]::ChangeExtension($Msi, '.wixpdb')) & (Join-Path $PSScriptRoot 'sign.ps1') $Msi @@ -136,13 +171,10 @@ foreach ($f in 'README.md', 'LICENSE', 'LICENSE-MIT', 'LICENSE-APACHE') { $p = Join-Path $Root $f if (Test-Path $p) { Copy-Item $p $Portable } } -# Builds made with craft-fonts (CRAFT_FONTS_DIR, set for every release) embed its fonts: ship their -# licences, fonts\\OFL.txt -> OFL-.txt. -if ($env:CRAFT_FONTS_DIR) { - Get-ChildItem -Path (Join-Path $env:CRAFT_FONTS_DIR 'fonts') -Directory -ErrorAction SilentlyContinue | ForEach-Object { - $ofl = Join-Path $_.FullName 'OFL.txt' - if (Test-Path $ofl) { Copy-Item $ofl (Join-Path $Portable "OFL-$($_.Name).txt") } - } +# Builds made with craft-fonts (CRAFT_FONTS_DIR, set for every release) embed its fonts: ship the +# same verified notices the MSI carries, fonts\\OFL.txt -> OFL-.txt. +if ($HasCraftFontLicences -eq 1) { + Copy-Item (Join-Path $Docs 'OFL-*.txt') $Portable } # portable.txt beside pdfcraft.exe switches on portable mode: settings, logs, recovery files and new # digital IDs go to PdfCraftData\ next to the exe instead of %APPDATA% (#157; see diff --git a/packaging/windows/pdfcraft.wxs b/packaging/windows/pdfcraft.wxs index d96f2cd78d..65e76f254b 100644 --- a/packaging/windows/pdfcraft.wxs +++ b/packaging/windows/pdfcraft.wxs @@ -12,6 +12,9 @@ PdfCraft arrive on its command line. --> + + + + + + + + + + + diff --git a/packaging/windows/test-font-notices.ps1 b/packaging/windows/test-font-notices.ps1 new file mode 100644 index 0000000000..4d2c81b08b --- /dev/null +++ b/packaging/windows/test-font-notices.ps1 @@ -0,0 +1,43 @@ +param([string] $CraftFontsRoot) +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'font-notices.ps1') +$fixture = Join-Path ([IO.Path]::GetTempPath()) ('pdfcraft-font-notices-' + [guid]::NewGuid()) +function Assert-Rejected([scriptblock] $Action, [string] $Message) { + $rejected = $false + try { & $Action | Out-Null } catch { $rejected = $true } + if (-not $rejected) { throw "Expected rejection: $Message" } +} +try { + foreach ($family in 'core', 'chinese') { + New-Item -ItemType Directory -Path (Join-Path $fixture "fonts/$family") -Force | Out-Null + Set-Content -LiteralPath (Join-Path $fixture "fonts/$family/OFL.txt") -Value 'Synthetic notice fixture' + } + $core = 'Core | Regular | fonts/core/face.ttf | Latn | OFL-1.1 | fonts/core/OFL.txt' + $chinese = 'Chinese | Regular | fonts/chinese/face.ttf | Hans | OFL-1.1 | fonts/chinese/OFL.txt' + Set-Content (Join-Path $fixture 'fonts/manifest.txt') @('# comment', $core, $core) + Set-Content (Join-Path $fixture 'fonts/simplified-chinese-manifest.txt') $chinese + if (@(Get-CraftFontNotices $fixture $true).Count -ne 2) { throw 'Expected both unique family notices' } + Set-Content (Join-Path $fixture 'fonts/simplified-chinese-manifest.txt') ($chinese.Replace('fonts/chinese/OFL.txt', 'fonts/core/OFL.txt')) + Assert-Rejected { Get-CraftFontNotices $fixture $true } 'another family cannot supply this notice' + Set-Content (Join-Path $fixture 'fonts/simplified-chinese-manifest.txt') $chinese + Remove-Item (Join-Path $fixture 'fonts/chinese/OFL.txt') + Assert-Rejected { Get-CraftFontNotices $fixture $true } 'one remaining OFL cannot cover the Chinese family' + Set-Content (Join-Path $fixture 'fonts/chinese/OFL.txt') '' -NoNewline + Assert-Rejected { Get-CraftFontNotices $fixture $true } 'empty family notice' + Set-Content (Join-Path $fixture 'fonts/simplified-chinese-manifest.txt') ($chinese.Replace('fonts/chinese/OFL.txt', '../OFL.txt')) + Assert-Rejected { Get-CraftFontNotices $fixture $true } 'notice path escapes font input' + Remove-Item (Join-Path $fixture 'fonts/simplified-chinese-manifest.txt') + Assert-Rejected { Get-CraftFontNotices $fixture $true } 'required Chinese manifest missing' + if (@(Get-CraftFontNotices $fixture).Count -ne 1) { throw 'Optional Chinese manifest may be absent' } + Set-Content (Join-Path $fixture 'fonts/manifest.txt') 'malformed' + Assert-Rejected { Get-CraftFontNotices $fixture } 'malformed metadata' + Assert-CraftFontNoticeNames @('OFL-core.txt', 'OFL-chinese.txt') @('SHORT.TXT|OFL-core.txt', 'OFL-chinese.txt') + Assert-Rejected { Assert-CraftFontNoticeNames @('OFL-core.txt', 'OFL-chinese.txt') @('OFL-core.txt') } 'MSI lost one staged notice' + if ($CraftFontsRoot) { + $actual = @(Get-CraftFontNotices $CraftFontsRoot $true) + Write-Output "Verified $($actual.Count) notices from the actual native manifests" + } + Write-Output 'Font notice regression checks passed' +} finally { + Remove-Item -LiteralPath $fixture -Recurse -Force +} diff --git a/packaging/windows/test-msi.ps1 b/packaging/windows/test-msi.ps1 index 45e1147fa5..24a4c6d75a 100644 --- a/packaging/windows/test-msi.ps1 +++ b/packaging/windows/test-msi.ps1 @@ -4,7 +4,13 @@ .EXAMPLE pwsh packaging/windows/test-msi.ps1 dist/release/pdfcraft-0.2.1-windows-x64.msi #> -param([Parameter(Mandatory)] [string] $Path) +param( + [Parameter(Mandatory)] [string] $Path, + # 1 when package.ps1 staged craft-fonts OFL notices, 0 when the binaries embed no craft-fonts. + # -1 (the default) skips the check when the MSI is inspected by hand. + [int] $CraftFontLicences = -1, + [string] $ExpectedFontNoticesDirectory +) $ErrorActionPreference = 'Stop' $Installer = New-Object -ComObject WindowsInstaller.Installer $Database = $Installer.OpenDatabase((Resolve-Path -LiteralPath $Path).Path, 0) @@ -69,6 +75,30 @@ foreach ($sequence in @('InstallUISequence', 'InstallExecuteSequence')) { } $manufacturer = Read-Row 'SELECT `Value` FROM `Property` WHERE `Property` = ''Manufacturer''' 1 Assert-Equal $manufacturer[0] 'Learning Machines LLC' 'MSI manufacturer' + +# Embedded craft-fonts must ship their OFL notices; a build without them must not claim any. +# Check the compiled File table, not only the WiX source, so a staging or component-group mistake +# cannot silently omit the licences. +$fontNotices = @() +$fileView = $Database.OpenView('SELECT `FileName` FROM `File`') +try { + [void] $fileView.Execute() + while ($record = $fileView.Fetch()) { + $name = $record.StringData(1) + if ($name -like '*OFL-*') { $fontNotices += $name } + } +} finally { [void] $fileView.Close() } +if ($CraftFontLicences -eq 1 -and $fontNotices.Count -eq 0) { + throw 'the MSI carries no craft-font OFL notices' +} +if ($CraftFontLicences -eq 0 -and $fontNotices.Count -gt 0) { + throw "the MSI carries craft-font notices that packaging did not stage: $($fontNotices -join ', ')" +} +if ($ExpectedFontNoticesDirectory) { + . (Join-Path $PSScriptRoot 'font-notices.ps1') + $expected = @(Get-ChildItem -LiteralPath $ExpectedFontNoticesDirectory -File -Filter 'OFL-*.txt' | ForEach-Object { $_.Name }) + Assert-CraftFontNoticeNames $expected $fontNotices +} $status = Read-Row 'SELECT `Text` FROM `Control` WHERE `Dialog_` = ''InstallProgress'' AND `Control` = ''Status''' 1 Assert-Equal $status[0] 'Please wait while setup completes.' 'Persistent progress message' Assert-NoRow 'SELECT `Event` FROM `EventMapping` WHERE `Dialog_` = ''InstallProgress'' AND `Control_` = ''Status''' 'progress text subscription' diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index d38b467663..9200d6271c 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -758,8 +758,8 @@ milestone = "M2" status = "partial" commands = [] tools = [] -evidence = ["crates/render/src/raster.rs::soft_mask_without_group_colour_space_is_applied", "crates/render/src/raster.rs::a_multiply_highlight_inside_a_transparency_group_keeps_the_text"] -notes = "via hayro bootstrap. A transparency group that is neither isolated nor knockout and is entered at full opacity, with no soft mask and Normal blending, is drawn straight onto the page, so a blend mode inside it (the Multiply of a highlight appearance written by other editors) sees the page content (#1968). Missing: a non-isolated group that needs a layer (partial opacity, a soft mask or a blend mode on the group itself) still composites its elements against a blank backdrop, and knockout groups are drawn as ordinary ones" +evidence = ["crates/render/src/raster.rs::soft_mask_without_group_colour_space_is_applied", "crates/render/src/raster.rs::a_multiply_highlight_inside_a_transparency_group_keeps_the_text", "crates/render/tests/it/luminosity_group_space.rs::black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully"] +notes = "via hayro bootstrap. A luminosity soft mask whose group is in DeviceCMYK reads its flat CMYK colours and backdrop as their gray, 1 - min(1, 0.3 c + 0.59 m + 0.11 y + k) (ISO 32000-2 §11.5.3), so 100% K masks fully; CMYK images, shadings and tiling patterns inside such a mask still go through the display profile. A transparency group that is neither isolated nor knockout and is entered at full opacity, with no soft mask and Normal blending, is drawn straight onto the page, so a blend mode inside it (the Multiply of a highlight appearance written by other editors) sees the page content (#1968). Missing: a non-isolated group that needs a layer (partial opacity, a soft mask or a blend mode on the group itself) still composites its elements against a blank backdrop, and knockout groups are drawn as ordinary ones" [[feature]] id = "view.render-knockout" @@ -818,8 +818,8 @@ milestone = "M2" status = "shipped" commands = [] tools = [] -evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/render/src/raster.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source", "crates/engine/src/tests.rs::fields_left_to_the_viewer_are_displayed_from_their_values", "crates/forms/src/tests.rs::need_appearances_draws_every_text_and_choice_field_in_the_display_copy", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::check_boxes_without_an_appearance_show_their_caption_symbol", "crates/forms/src/tests.rs::push_buttons_without_an_appearance_show_their_caption_and_frame", "crates/render/src/raster.rs::invisible_unknown_annotations_and_popups_paint_nothing", "crates/print/src/tests.rs::invisible_annotations_of_unknown_types_do_not_print", "crates/engine/src/tests.rs::stamps_without_appearances_draw_the_stamp_they_name", "crates/automation/tests/it/automation.rs::page_render_draws_stamps_without_an_appearance"] -notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved; a stamp from another program draws the stamp its /Name names (the fourteen standard names of ISO 32000-2 Table 184, Draft when there is none) or, for any other name, a grey frame with both diagonals; text and choice fields are drawn from their values when the form sets /NeedAppearances (every one) or a widget has no /N (#3470), also display-only and cached per widget (a field edit on a 1000-field /NeedAppearances form takes ~13 ms against ~7 ms on a form that needs none) (a read-only document still gets the renderer's Highlight fallback only); check boxes and radio buttons with no appearance for their /AS are drawn display-only from /MK (background, border, and when on the /MK /CA ZapfDingbats symbol in the /DA colour, a check or a dot by default), and push buttons with none from /MK and /BS with their caption; Link borders without /AP and printing are not covered yet; an annotation of a non-standard type with the Invisible flag (ISO 32000-2 Table 167) is neither drawn nor printed, and a popup's own /AP is never painted on the page (its parent's text shows in the popup window, §12.5.6.14)" +evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/render/src/raster.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source", "crates/engine/src/tests.rs::fields_left_to_the_viewer_are_displayed_from_their_values", "crates/forms/src/tests.rs::need_appearances_draws_every_text_and_choice_field_in_the_display_copy", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::check_boxes_without_an_appearance_show_their_caption_symbol", "crates/forms/src/tests.rs::push_buttons_without_an_appearance_show_their_caption_and_frame", "crates/forms/src/tests.rs::need_appearances_draws_check_boxes_and_radio_buttons_from_their_captions", "crates/engine/src/tests.rs::need_appearances_buttons_are_displayed_from_their_captions", "crates/render/src/raster.rs::invisible_unknown_annotations_and_popups_paint_nothing", "crates/print/src/tests.rs::invisible_annotations_of_unknown_types_do_not_print", "crates/engine/src/tests.rs::stamps_without_appearances_draw_the_stamp_they_name", "crates/automation/tests/it/automation.rs::page_render_draws_stamps_without_an_appearance"] +notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved; a stamp from another program draws the stamp its /Name names (the fourteen standard names of ISO 32000-2 Table 184, Draft when there is none) or, for any other name, a grey frame with both diagonals; text and choice fields are drawn from their values when the form sets /NeedAppearances (every one) or a widget has no /N (#3470), also display-only and cached per widget (a field edit on a 1000-field /NeedAppearances form takes ~13 ms against ~7 ms on a form that needs none) (a read-only document still gets the renderer's Highlight fallback only); check boxes and radio buttons with no appearance for their /AS are drawn display-only from /MK (background, border, and when on the /MK /CA ZapfDingbats symbol in the /DA colour, a check or a dot by default), and when the form sets /NeedAppearances so is every one that has an /MK, over the appearance the file carries (ISO 32000-2 Table 224; one without /MK keeps its own), and push buttons with none from /MK and /BS with their caption; Link borders without /AP and printing are not covered yet; an annotation of a non-standard type with the Invisible flag (ISO 32000-2 Table 167) is neither drawn nor printed, and a popup's own /AP is never painted on the page (its parent's text shows in the popup window, §12.5.6.14)" [[feature]] id = "view.render-error-isolation" @@ -1274,8 +1274,8 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/editing.rs::resized_pages_panel_thumbnails_reorder_multiple_pages_and_undo", "crates/ui-egui/tests/it/viewer.rs::pages_panel_thumbnail_zoom_is_bounded_independent_and_survives_reload", "crates/ui-egui/tests/it/viewer.rs::pages_panel_thumbnail_size_controls_show_more_pages_and_keep_the_selection", "crates/ui-egui/tests/it/viewer.rs::pages_panel_thumbnail_zoom_keeps_a_far_current_page_visible", "crates/ui-egui/tests/it/control.rs::thumbnail_size_is_settable_and_inspectable_through_the_control_channel", "crates/ui-egui/tests/it/ui.rs::theme_and_view_options_apply", "crates/ui-egui/tests/it/viewer.rs::going_to_a_far_page_scrolls_its_thumbnail_into_view", "crates/ui-egui/tests/it/viewer.rs::going_to_a_page_in_view_leaves_the_pages_panel_still", "crates/ui-egui/tests/it/viewer.rs::a_page_change_while_thumbnails_are_dragged_does_not_scroll_the_pages_panel"] -notes = "Thumbnail size: 50–150% with a slider and buttons above the scroll area (#3808), independent of document and Organize zoom. Accessible through ui.set thumbnail-zoom and ui.state active.thumbnail_zoom; retained on file reload, not persisted between sessions. The panel follows the current page (#2781): a page whose thumbnail is out of sight scrolls it into view, as little as it takes; a thumbnail already in view, or clicked, leaves the panel still, and a page change while the user scrolls the panel or drags thumbnails does not move it." +evidence = ["crates/ui-egui/tests/it/editing.rs::resized_pages_panel_thumbnails_reorder_multiple_pages_and_undo", "crates/ui-egui/tests/it/viewer.rs::pages_panel_thumbnail_zoom_is_bounded_independent_and_survives_reload", "crates/ui-egui/tests/it/viewer.rs::pages_panel_thumbnail_size_controls_show_more_pages_and_keep_the_selection", "crates/ui-egui/tests/it/viewer.rs::pages_panel_thumbnail_zoom_keeps_a_far_current_page_visible", "crates/ui-egui/tests/it/control.rs::thumbnail_size_is_settable_and_inspectable_through_the_control_channel", "crates/ui-egui/tests/it/ui.rs::theme_and_view_options_apply", "crates/ui-egui/tests/it/viewer.rs::going_to_a_far_page_scrolls_its_thumbnail_into_view", "crates/ui-egui/tests/it/viewer.rs::going_to_a_page_in_view_leaves_the_pages_panel_still", "crates/ui-egui/tests/it/viewer.rs::a_page_change_while_thumbnails_are_dragged_does_not_scroll_the_pages_panel", "crates/ui-egui/tests/it/view.rs::the_pages_panel_tools_act_on_the_selected_pages", "crates/ui-egui/tests/it/view.rs::the_pages_panel_tools_insert_from_a_file_and_extract", "crates/ui-egui/tests/it/view.rs::the_pages_panel_tools_are_off_without_page_editing_permission"] +notes = "Page tools menu in the panel's header (#4415): insert a blank page or pages from a file after the selection, rotate either way, extract and delete, each on the selected pages (the current one when none is), greyed out where the document's security withholds page changes. Thumbnail size: 50–150% with a slider and buttons above the scroll area (#3808), independent of document and Organize zoom. Accessible through ui.set thumbnail-zoom and ui.state active.thumbnail_zoom; retained on file reload, not persisted between sessions. The panel follows the current page (#2781): a page whose thumbnail is out of sight scrolls it into view, as little as it takes; a thumbnail already in view, or clicked, leaves the panel still, and a page change while the user scrolls the panel or drags thumbnails does not move it." [[feature]] id = "view.thumbnail-context-ops" @@ -1322,8 +1322,8 @@ milestone = "M3" status = "shipped" commands = [] tools = ["doc_info"] -evidence = ["crates/render/src/inspect.rs::attachments_of_both_kinds_are_listed_and_extracted"] -notes = "" +evidence = ["crates/render/src/inspect.rs::attachments_of_both_kinds_are_listed_and_extracted", "crates/ui-egui/tests/it/attachment_row.rs::the_right_click_menu_opens_saves_and_deletes", "crates/ui-egui/tests/it/attachment_row.rs::double_clicking_a_pdf_attachment_opens_it_in_a_new_tab"] +notes = "Each row has Save, Open in a new tab (PDFs) and Delete (the document's own attachments) buttons, the same items on a right-click, and a double-click that opens a PDF in a new tab or saves any other file (#3466). Not yet: Acrobat's Search attachments, and opening a non-PDF attachment in another program (it is saved instead)." [[feature]] id = "view.layers-panel" @@ -1778,8 +1778,8 @@ milestone = "M3" status = "partial" commands = ["help.check_updates"] tools = [] -evidence = ["crates/ui-egui/tests/it/updates.rs::a_newer_release_is_offered_for_download", "crates/ui-egui/tests/it/updates.rs::nothing_is_asked_until_the_user_checks", "apps/pdfcraft/src/updates.rs::answers_are_read_and_only_our_release_pages_are_offered"] -notes = "asks GitHub Releases on request and offers the release page. By the owner's decision there is no check at start and no in-place install (Acrobat has both)" +evidence = ["crates/ui-egui/tests/it/updates.rs::a_newer_release_is_offered_for_download", "crates/ui-egui/tests/it/updates.rs::an_up_to_date_or_failed_check_says_so", "crates/ui-egui/tests/it/updates.rs::the_check_at_start_offers_the_prompt_and_later_dismisses_it", "crates/ui-egui/tests/it/updates.rs::nothing_is_asked_until_the_user_checks"] +notes = "asks GitHub Releases through craft-update at start (desktop app only) and on request, offers the release page, and updates and restarts in place where the install type allows it; an install waits while documents are unsaved" [[feature]] id = "view.single-key-accelerators" @@ -2153,8 +2153,8 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text"] tools = ["image_edit", "image_hit"] -evidence = ["crates/edit/src/tests.rs::private_image_crop_with_intervening_state_resizes_across_streams", "crates/automation/tests/it/automation.rs::private_crops_with_nested_state_resize_save_reopen_through_tools", "crates/edit/src/tests.rs::form_artwork_edits_only_the_selected_placement", "crates/ui-egui/tests/it/editing.rs::editing_existing_form_artwork_on_the_page", "crates/automation/tests/it/automation.rs::editing_form_artwork_through_tools", "crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/it/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/it/automation.rs::editing_page_images_through_tools", "crates/edit/src/tests.rs::curved_image_clips_have_analytic_bounds_and_holes_do_not_capture_points", "crates/edit/src/tests.rs::cropped_image_resizing_moves_private_clips_and_keeps_shared_ones", "crates/ui-egui/tests/it/editing.rs::cropped_image_corner_resize_keeps_opposite_corner_and_scales_crop", "crates/edit/src/tests.rs::image_selection_bounds_respect_clips_and_restore_graphics_state", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools"] -notes = "existing raster images and grouped Form artwork: select, drag to move, drag a corner to resize keeping proportions; Delete removes one placement. Forms include their nested content as a group; individual internal elements and inline images are not supported. See docs/editing-images.md; added images too Since #1116: raster images and grouped Forms move/resize/turn as placements; selected grips own the gesture and opposite anchor, with no PDF writes during movement. Curved path frames use analytic Bezier extrema and hit testing evaluates actual nonzero/even-odd fill rules, excluding holes; intersections can retain a loose frame. Selection and resize use the visible (clipped) frame; exclusive image clips move with their pixels across nested states, intervening operators and content streams; shared clips stay fixed. Inline images and Form internals remain unsupported; see docs/editing-images.md" +evidence = ["crates/edit/src/tests.rs::private_image_crop_with_intervening_state_resizes_across_streams", "crates/automation/tests/it/automation.rs::private_crops_with_nested_state_resize_save_reopen_through_tools", "crates/edit/src/tests.rs::form_artwork_edits_only_the_selected_placement", "crates/ui-egui/tests/it/editing.rs::editing_existing_form_artwork_on_the_page", "crates/automation/tests/it/automation.rs::editing_form_artwork_through_tools", "crates/edit/src/tests.rs::page_images_move_turn_replace_and_delete", "crates/ui-egui/tests/it/editing.rs::editing_existing_images_on_the_page", "crates/automation/tests/it/automation.rs::editing_page_images_through_tools", "crates/edit/src/tests.rs::curved_image_clips_have_analytic_bounds_and_holes_do_not_capture_points", "crates/edit/src/tests.rs::cropped_image_resizing_moves_private_clips_and_keeps_shared_ones", "crates/ui-egui/tests/it/editing.rs::cropped_image_corner_resize_keeps_opposite_corner_and_scales_crop", "crates/edit/src/tests.rs::image_selection_bounds_respect_clips_and_restore_graphics_state", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::resizing_an_image_under_a_shared_clip_moves_its_own_clip"] +notes = "existing raster images and grouped Form artwork: select, drag to move, drag a corner to resize keeping proportions; Delete removes one placement. Forms include their nested content as a group; individual internal elements and inline images are not supported. See docs/editing-images.md; added images too Since #1116: raster images and grouped Forms move/resize/turn as placements; selected grips own the gesture and opposite anchor, with no PDF writes during movement. Curved path frames use analytic Bezier extrema and hit testing evaluates actual nonzero/even-odd fill rules, excluding holes; intersections can retain a loose frame. Selection and resize use the visible (clipped) frame; exclusive image clips move with their pixels across nested states, intervening operators and content streams, also inside a clip the picture shares with other content (its own clip scales, the shared one stays); shared clips stay fixed. Inline images and Form internals remain unsupported; see docs/editing-images.md" [[feature]] id = "edit.image-extract" @@ -2448,7 +2448,7 @@ status = "shipped" commands = ["edit.header_footer"] tools = ["doc_header_footer"] evidence = ["crates/edit/src/tests.rs::tokens_expand", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove"] -notes = "<<1>>, <>, <<1 of n>>, <>, dates, Bates" +notes = "<<1>>, <>, <<1 of n>>, <>, dates (US, ISO, day-first with slashes and the dotted <>, <>, <>, <>), Bates. Month names are English only" [[feature]] id = "edit.header-footer-ranges" @@ -3471,8 +3471,8 @@ milestone = "M3" status = "shipped" commands = ["file.properties"] tools = ["doc_info"] -evidence = ["crates/ui-egui/tests/it/editing.rs::document_properties_edit_is_one_undoable_step", "crates/ui-egui/tests/it/editing.rs::cancelling_properties_discards_the_draft", "crates/organize/src/tests.rs::document_info_falls_back_to_the_xmp_packet", "crates/engine/src/tests.rs::document_information_stated_only_in_xmp_is_shown"] -notes = "Title, Author, Subject and Keywords (and Creator, Producer in doc_info) come from /Info, else from the XMP packet (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) for files that state them only there; edits write both. Missing: a UTF-16 XMP packet isn't read, and /Info kept only in an older revision's trailer isn't looked up" +evidence = ["crates/ui-egui/tests/it/editing.rs::document_properties_edit_is_one_undoable_step", "crates/ui-egui/tests/it/editing.rs::cancelling_properties_discards_the_draft", "crates/organize/src/tests.rs::document_info_falls_back_to_the_xmp_packet", "crates/engine/src/tests.rs::document_information_stated_only_in_xmp_is_shown", "crates/ui-egui/tests/it/dialog_chrome.rs::document_properties_values_line_up_and_long_fields_wrap", "crates/ui-egui/tests/it/dialog_chrome.rs::space_audit_tab_measures_the_file_and_stays_in_the_card"] +notes = "Description, Initial View, Security, Fonts, Custom (organize.custom-properties) and Advanced, plus a Space Audit tab Acrobat does not have (its audit is only in the PDF Optimizer); the audit runs when the tab opens and again after an edit. Title, Author, Subject and Keywords (and Creator, Producer in doc_info) come from /Info, else from the XMP packet (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) for files that state them only there; edits write both. Missing: a UTF-16 XMP packet isn't read, and /Info kept only in an older revision's trailer isn't looked up" [[feature]] id = "organize.edit-metadata" @@ -3507,8 +3507,8 @@ milestone = "M4" status = "shipped" commands = ["file.properties"] tools = ["doc_initial_view"] -evidence = ["crates/organize/src/view.rs::initial_view_resolves_indirect_boolean_preferences", "crates/ui-egui/tests/it/ui.rs::a_document_asking_for_full_screen_is_asked_about_first", "crates/organize/src/tests.rs::initial_view_round_trips_and_keeps_scripts", "crates/ui-egui/tests/it/viewer.rs::initial_view_is_edited_and_honoured_on_open", "crates/automation/tests/it/automation.rs::initial_view_through_tools", "crates/render/src/inspect.rs::a_go_to_action_opens_at_its_destination_explicit_or_named", "crates/ui-egui/tests/it/open_action.rs::an_xyz_open_action_puts_its_point_at_the_window_top_left", "crates/ui-egui/tests/it/open_action.rs::a_fith_open_action_puts_its_top_at_the_window_top", "crates/ui-egui/tests/it/open_action.rs::a_named_open_destination_opens_its_page", "crates/ui-egui/tests/it/open_action.rs::a_full_screen_document_shows_its_non_full_screen_panel_outside_full_screen", "crates/organize/src/tests.rs::an_unchanged_opening_destination_keeps_its_position_and_name", "crates/automation/tests/it/automation.rs::doc_info_reports_the_open_destination"] -notes = "navigation panel, layout, magnification, page, window and interface options; honoured on open; script open actions kept. Open in Full Screen mode (/PageMode /FullScreen) asks first (Full Screen / Not now), as Acrobat does, instead of taking the screen. Window and interface options also read indirect boolean ViewerPreferences values. Missing: Acrobat's Full Screen preference to turn that alert off and its \"remember my choice for this document\" (misc.prefs-full-screen). On open the /OpenAction destination places the view as ISO 32000-2 Table 149 says (the /XYZ point at the window's top-left, /FitH and /FitV top or left at the edge), and a named destination (name or string, /Names /Dests tree or catalog /Dests, §12.3.2.4) resolves; Always use my default zoom keeps only the page. A full-screen document shows its /NonFullScreenPageMode panel outside full screen (Table 147). doc_info reports the opening destination. Document Properties keeps an opening action whose page and magnification are left alone, but still edits only those two, and shows page 1 for a named destination" +evidence = ["crates/organize/src/view.rs::initial_view_resolves_indirect_boolean_preferences", "crates/ui-egui/tests/it/ui.rs::a_document_asking_for_full_screen_is_asked_about_first", "crates/organize/src/tests.rs::initial_view_round_trips_and_keeps_scripts", "crates/ui-egui/tests/it/viewer.rs::initial_view_is_edited_and_honoured_on_open", "crates/automation/tests/it/automation.rs::initial_view_through_tools", "crates/render/src/inspect.rs::a_go_to_action_opens_at_its_destination_explicit_or_named", "crates/ui-egui/tests/it/open_action.rs::an_xyz_open_action_puts_its_point_at_the_window_top_left", "crates/ui-egui/tests/it/open_action.rs::a_fith_open_action_puts_its_top_at_the_window_top", "crates/ui-egui/tests/it/open_action.rs::a_named_open_destination_opens_its_page", "crates/ui-egui/tests/it/open_action.rs::a_full_screen_document_shows_its_non_full_screen_panel_outside_full_screen", "crates/organize/src/tests.rs::an_unchanged_opening_destination_keeps_its_position_and_name", "crates/automation/tests/it/automation.rs::doc_info_reports_the_open_destination", "crates/organize/src/tests.rs::choosing_page_one_replaces_a_named_opening_destination", "crates/organize/src/tests.rs::a_named_opening_destination_is_kept_while_it_still_holds"] +notes = "navigation panel, layout, magnification, page, window and interface options; honoured on open; script open actions kept. Open in Full Screen mode (/PageMode /FullScreen) asks first (Full Screen / Not now), as Acrobat does, instead of taking the screen. Window and interface options also read indirect boolean ViewerPreferences values. Missing: Acrobat's Full Screen preference to turn that alert off and its \"remember my choice for this document\" (misc.prefs-full-screen). On open the /OpenAction destination places the view as ISO 32000-2 Table 149 says (the /XYZ point at the window's top-left, /FitH and /FitV top or left at the edge), and a named destination (name or string, /Names /Dests tree or catalog /Dests, §12.3.2.4) resolves; Always use my default zoom keeps only the page. A full-screen document shows its /NonFullScreenPageMode panel outside full screen (Table 147). doc_info reports the opening destination. Document Properties keeps an opening action whose page and magnification are left alone, but still edits only those two; it shows a named destination's page and magnification, and choosing another page or magnification replaces the name with an explicit destination, except that page 1 at the default magnification removes /OpenAction so the first page opens. A name that resolves nowhere reads as page 1 and is kept until another page is chosen" [[feature]] id = "organize.custom-properties" @@ -3583,8 +3583,8 @@ milestone = "M3" status = "shipped" commands = ["comment.list"] tools = ["comment_list"] -evidence = ["crates/ui-egui/tests/it/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/it/comments.rs::the_panel_posts_comments_and_replies", "crates/ui-egui/tests/it/comments.rs::page_groups_collapse_and_expand"] -notes = "cards grouped by page with replies, status badges, selection, reply box and an Add a comment box; page groups fold and unfold from their chevron or with Collapse/Expand all pages in the panel's \"…\" menu (a comment selected on a folded page opens its group). Groups by author, type or colour don't fold yet" +evidence = ["crates/ui-egui/tests/it/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/it/comments.rs::the_panel_posts_comments_and_replies", "crates/ui-egui/tests/it/comments.rs::page_groups_collapse_and_expand", "crates/ui-egui/tests/it/comments.rs::replies_follow_irt_references_when_names_repeat_on_every_page", "crates/engine/src/tests.rs::replies_thread_by_reference_when_names_repeat_across_pages", "crates/automation/tests/it/automation.rs::replies_thread_by_reference_when_ids_repeat_across_pages"] +notes = "cards grouped by page with replies (threaded by the `/IRT` reference, so files whose `/NM` repeats per page, as PyMuPDF writes them, thread correctly; the header counts comments without replies, as Acrobat does), status badges, selection, reply box and an Add a comment box; page groups fold and unfold from their chevron or with Collapse/Expand all pages in the panel's \"…\" menu (a comment selected on a folded page opens its group). Groups by author, type or colour don't fold yet" [[feature]] id = "comment.appearance-generation" @@ -4463,8 +4463,8 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/view.rs::highlight_fields_tints_the_field_area", "crates/ui-egui/tests/it/view.rs::required_radio_buttons_get_a_round_red_border", "crates/ui-egui/tests/it/view.rs::field_highlighting_is_on_by_default_and_remembered", "crates/ui-egui/tests/it/view.rs::stored_field_highlight_choice_wins_over_the_default", "crates/ui-egui/tests/it/view.rs::highlight_fields_leaves_read_only_fields_alone", "crates/ui-egui/tests/it/editing.rs::a_secured_form_shows_its_form_notice_and_its_security"] -notes = "only fields that can be filled are marked (a read-only field is left as drawn, so a form's locked boxes don't look fillable); offered on secured forms too; on by default for new settings (a stored choice is kept) and remembered for the next document and session; toggled from the form bar and from Preferences > Appearance, where it sets the default for documents opened afterwards; required fields get a red border (round for radio buttons); the tint is drawn over the field, where Acrobat paints the highlight colour behind it" +evidence = ["crates/ui-egui/tests/it/view.rs::highlight_fields_tints_the_field_area", "crates/ui-egui/tests/it/view.rs::required_radio_buttons_get_a_round_red_border", "crates/ui-egui/tests/it/view.rs::field_highlighting_is_on_by_default_and_remembered", "crates/ui-egui/tests/it/view.rs::stored_field_highlight_choice_wins_over_the_default", "crates/ui-egui/tests/it/view.rs::highlight_fields_leaves_read_only_fields_alone", "crates/ui-egui/tests/it/editing.rs::a_secured_form_shows_its_form_notice_and_its_security", "crates/ui-egui/tests/it/view.rs::round_check_boxes_get_a_round_highlight"] +notes = "only fields that can be filled are marked (a read-only field is left as drawn, so a form's locked boxes don't look fillable); offered on secured forms too; on by default for new settings (a stored choice is kept) and remembered for the next document and session; toggled from the form bar and from Preferences > Appearance, where it sets the default for documents opened afterwards; required fields get a red border (the tint and border round for radio buttons and for check boxes laid out for a round XFA check button); the tint is drawn over the field, where Acrobat paints the highlight colour behind it" [[feature]] id = "form.field-appearance-display" @@ -5296,8 +5296,8 @@ milestone = "M12" status = "partial" commands = ["file.open"] tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"] -evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/it/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen", "crates/xfa/src/tests.rs::laying_a_form_out_takes_off_the_usage_rights_it_voids", "crates/engine/src/tests.rs::a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::a_rule_between_two_pieces_of_a_link_keeps_them_apart", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools", "crates/xfa/src/tests.rs::rounded_corners_make_a_border_one_rounded_shape", "crates/xfa/src/tests.rs::a_rounded_box_cut_by_a_page_break_keeps_its_corners_on_each_page", "crates/xfa/src/tests.rs::rounded_boxes_are_written_as_curved_paths", "crates/xfa/src/tests.rs::a_flowed_subform_is_drawn_with_its_border_under_its_content", "crates/xfa/src/tests.rs::a_check_box_sits_where_its_margins_caption_and_alignment_put_it", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_takes_the_rest_of_the_field", "crates/xfa/src/tests.rs::a_radio_button_sits_where_a_check_box_would", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_is_measured_as_it_is_drawn"] -notes = "Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms (a page break between their children gives each page's part a closed border, the margins repeated at the cut, as measured on a synthetic form in another viewer, and a child's own page break breaks them the same way; one holding an item that doesn't fit on its page, cut across pages or placed past the content area's bottom, is drawn without its border, with a layout warning), captions, rounded and inverted border corners, rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes and radio groups (each box placed by its field's margins, caption and paragraph alignment as measured on two synthetic forms in another viewer: with no caption at the right of the content area, beside a reserved caption where the alignment puts it, and a caption without a reserve taking the rest of the field; a check box's own margin larger than the box draws a bigger box there, which isn't copied), drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; usage rights the form carried (/Perms /UR3) are taken off with a note, as the layout voids them and a form with void usage rights opens with every field locked; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; text is measured at the exact standard-14 widths of the face it is drawn in (bold, italic and Times included), not in the template's own typefaces, and Acrobat fidelity is eyeballed on real forms, not measured (#60)" +evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/it/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen", "crates/xfa/src/tests.rs::laying_a_form_out_takes_off_the_usage_rights_it_voids", "crates/engine/src/tests.rs::a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::a_rule_between_two_pieces_of_a_link_keeps_them_apart", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools", "crates/xfa/src/tests.rs::rounded_corners_make_a_border_one_rounded_shape", "crates/xfa/src/tests.rs::a_rounded_box_cut_by_a_page_break_keeps_its_corners_on_each_page", "crates/xfa/src/tests.rs::rounded_boxes_are_written_as_curved_paths", "crates/xfa/src/tests.rs::a_flowed_subform_is_drawn_with_its_border_under_its_content", "crates/xfa/src/tests.rs::a_check_box_sits_where_its_margins_caption_and_alignment_put_it", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_takes_the_rest_of_the_field", "crates/xfa/src/tests.rs::a_radio_button_sits_where_a_check_box_would", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_is_measured_as_it_is_drawn", "crates/xfa/src/tests.rs::signature_fields_are_named_by_their_som_path", "crates/xfa/src/tests.rs::a_signature_on_a_laid_out_box_survives_a_new_layout"] +notes = "Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms (a page break between their children gives each page's part a closed border, the margins repeated at the cut, as measured on a synthetic form in another viewer, and a child's own page break breaks them the same way; one holding an item that doesn't fit on its page, cut across pages or placed past the content area's bottom, is drawn without its border, with a layout warning), captions, rounded and inverted border corners, rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes and radio groups (each box placed by its field's margins, caption and paragraph alignment as measured on two synthetic forms in another viewer: with no caption at the right of the content area, beside a reserved caption where the alignment puts it, and a caption without a reserve taking the rest of the field; a check box's own margin larger than the box draws a bigger box there, which isn't copied), drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields (each named by its SOM path, under a generated field for each step above it, so that another viewer, which lays the form out again, finds a signature made here, as seen on a synthetic form, and a box signed here keeps its signature, and that name, when a script lays the form out again; a box whose path, or a part of it, the file already gives another field keeps its own name, and its signature isn't found there yet), image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; usage rights the form carried (/Perms /UR3) are taken off with a note, as the layout voids them and a form with void usage rights opens with every field locked; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; text is measured at the exact standard-14 widths of the face it is drawn in (bold, italic and Times included), not in the template's own typefaces, and Acrobat fidelity is eyeballed on real forms, not measured (#60)" [[feature]] id = "form.xfa-formcalc" @@ -5793,7 +5793,7 @@ status = "shipped" commands = ["redact.apply"] tools = ["redact_apply"] evidence = ["crates/redact/src/tests.rs::vectors_are_removed_or_clipped_and_inline_images_go"] -notes = "covered paths removed, others clipped; shadings clipped" +notes = "intersecting paths removed to avoid leaking geometry behind clipping; shadings clipped" [[feature]] id = "protect.redact-annots-fields" @@ -7052,8 +7052,8 @@ milestone = "M10" status = "partial" commands = ["export.docx"] tools = ["doc_export_office"] -evidence = ["crates/export/src/lib.rs::word_table_cell_widths_follow_the_columns_they_span", "crates/export/src/lib.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/edit/src/tests.rs::reading_keeps_dotted_leader_entries_whole_and_in_order", "crates/engine/src/tests.rs::exporting_office_keeps_dotted_leader_entries_as_paragraphs", "crates/export/src/lib.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/lib.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/lib.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/lib.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/it/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/lib.rs::docx_is_a_word_package", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/lib.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/lib.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/lib.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/lib.rs::docx_keeps_a_shared_page_background", "crates/edit/src/tests.rs::a_page_painted_first_with_a_full_page_fill_has_that_background", "crates/engine/src/tests.rs::exporting_to_word_keeps_the_page_background"] -notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells (cell widths sum the grid columns they occupy, including cells after spans, verified in the XML; a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, the page colour (a full-page opaque fill painted first, such as Edit a PDF ▸ Background) as the document background when every page shares one non-white colour (Word has no per-page background, so differing colours are dropped; #2992; checked in the XML only, not yet opened in Word), and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; dotted-leader contents entries keep the title, dots and page number together in separate paragraphs (#1757); the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. Not yet: exact font matching, vector rules, arbitrary paint-order/transparency, colour per run within a paragraph, general exact layout fidelity." +evidence = ["crates/export/src/lib.rs::word_table_cell_widths_follow_the_columns_they_span", "crates/export/src/lib.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/edit/src/tests.rs::reading_keeps_dotted_leader_entries_whole_and_in_order", "crates/engine/src/tests.rs::exporting_office_keeps_dotted_leader_entries_as_paragraphs", "crates/export/src/lib.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/lib.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/lib.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/lib.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/it/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/lib.rs::docx_is_a_word_package", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/lib.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/lib.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/lib.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/lib.rs::docx_keeps_a_shared_page_background", "crates/edit/src/tests.rs::a_page_painted_first_with_a_full_page_fill_has_that_background", "crates/engine/src/tests.rs::exporting_to_word_keeps_the_page_background", "crates/export/src/lib.rs::an_italic_word_stays_in_its_sentence", "crates/export/src/lib.rs::a_sentence_keeps_its_reading_order", "crates/export/src/lib.rs::runs_keep_the_spaces_the_source_had_between_them", "crates/export/src/lib.rs::a_paragraph_is_a_heading_only_when_its_text_is", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_an_italic_word_inside_its_sentence"] +notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells (cell widths sum the grid columns they occupy, including cells after spans, verified in the XML; a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, the page colour (a full-page opaque fill painted first, such as Edit a PDF ▸ Background) as the document background when every page shares one non-white colour (Word has no per-page background, so differing colours are dropped; #2992; checked in the XML only, not yet opened in Word), and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; dotted-leader contents entries keep the title, dots and page number together in separate paragraphs (#1757); the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. A block that continues another on its line (an italic case name, a link, a superscript) is a run in its own size, bold/italic and colour inside that paragraph, keeping the source's word space between them, rather than a paragraph of its own; a lead-in in bold no longer makes its whole line a heading (#4158). Not yet: a style change that wraps onto the next line still splits its paragraph there (the reading blocks keep style boundaries, #4158), exact font matching, vector rules, arbitrary paint-order/transparency, general exact layout fidelity." [[feature]] id = "create.export-rtf" @@ -7065,7 +7065,7 @@ status = "shipped" commands = ["export.rtf"] tools = ["doc_export_office"] evidence = ["crates/export/src/lib.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/lib.rs::rtf_escapes_and_sizes", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf"] -notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Paragraphs with sizes, bold, italic, text colour (colortbl), Unicode; page breaks. Images are left out." +notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Paragraphs with sizes, bold, italic, text colour (colortbl), Unicode; a block that continues another on its line is a formatted group inside that paragraph (#4158), though a style change that wraps onto the next line still splits it; page breaks. Images are left out." [[feature]] id = "create.export-xlsx" @@ -7673,7 +7673,7 @@ status = "shipped" commands = ["optimize.advanced"] tools = ["doc_audit_space"] evidence = ["crates/optimize/tests/optimize.rs::space_audit_shares_the_file_out_by_kind", "crates/optimize/tests/optimize.rs::space_audit_counts_what_fields_and_comments_draw_with_by_kind", "crates/optimize/tests/optimize.rs::space_audit_survives_deeply_nested_forms", "crates/automation/tests/it/automation.rs::auditing_space_through_tools", "crates/ui-egui/tests/it/viewer.rs::optimizer_audits_space_usage", "crates/ui-egui/tests/it/viewer.rs::space_audit_numbers_line_up_under_their_headers"] -notes = "PDF Optimizer ▸ Audit space usage: what the file's bytes are spent on now (not what optimizing would save), in 16 categories (images, content streams, fonts, X Object forms, structure, bookmarks, thumbnails, comments, forms, named destinations, colour spaces, shadings, patterns, graphics states, embedded files, overhead) with bytes and percentages. Resources are counted by kind wherever they are used, so the fonts and images form fields and comments draw with count as fonts and images. Still missing: sizes are each object as it would be written uncompressed, so objects packed in compressed object streams count larger than they take on disk (overhead, the rest of the file, shrinks to match)" +notes = "PDF Optimizer ▸ Audit space usage, and Document Properties ▸ Space Audit: what the file's bytes are spent on now (not what optimizing would save), in 16 categories (images, content streams, fonts, X Object forms, structure, bookmarks, thumbnails, comments, forms, named destinations, colour spaces, shadings, patterns, graphics states, embedded files, overhead) with bytes and percentages. Resources are counted by kind wherever they are used, so the fonts and images form fields and comments draw with count as fonts and images. Still missing: sizes are each object as it would be written uncompressed, so objects packed in compressed object streams count larger than they take on disk (overhead, the rest of the file, shrinks to match)" [[feature]] id = "optimize.optimizer-presets" @@ -8484,8 +8484,8 @@ milestone = "M3" status = "partial" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/control.rs::inspect_and_click_by_label_and_id"] -notes = "AccessKit labels on all custom widgets; full audit in M14.3" +evidence = ["crates/ui-egui/tests/it/control.rs::inspect_and_click_by_label_and_id", "crates/ui-egui/tests/it/viewer.rs::the_accessible_window_has_the_window_title_as_its_name"] +notes = "AccessKit labels on all custom widgets; the accessible window is named with the window title and follows it (#4614); full audit in M14.3" [[feature]] id = "a11y.keyboard-only" @@ -10012,8 +10012,8 @@ milestone = "M14" status = "partial" commands = ["app.preferences"] tools = [] -evidence = ["crates/ui-egui/src/commands.rs::localized_shortcut_labels_follow_language_and_platform", "crates/ui-egui/src/i18n/mod.rs::keyboard_key_names_have_a_separate_context_and_english_fallback", "crates/ui-egui/tests/it/commands.rs::localized_shortcuts_update_immediately_and_keep_command_ids_and_key_bindings", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_resolves_chinese_scripts", "crates/ui-egui/src/i18n/mod.rs::neutral_locale_does_not_override_display_language", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_query_returns_a_locale_tag", "crates/ui-egui/src/i18n/mod.rs::language_persists_and_invalid_input_keeps_current_language", "crates/ui-egui/src/i18n/mod.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/mod.rs::arabic_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::arabic_lookups_are_in_display_order", "crates/ui-egui/src/i18n/mod.rs::arabic_plurals_follow_the_six_cldr_forms", "crates/ui-egui/src/i18n/mod.rs::arabic_labels_have_glyphs", "crates/ui-egui/src/bidi.rs::rtl_labels_keep_placeholders_whole", "crates/ui-egui/tests/it/control.rs::simplified_chinese_signature_prompts_and_errors_keep_document_state", "crates/ui-egui/tests/it/control.rs::language_switch_preserves_document_and_command_ids", "crates/ui-egui/tests/it/control.rs::japanese_controls_and_search_keep_command_ids", "crates/ui-egui/tests/it/control.rs::japanese_dialogs_errors_and_custom_action_names", "crates/ui-egui/src/i18n/mod.rs::french_is_registered", "crates/ui-egui/src/widgets.rs::search_placeholder_never_overlaps_the_shortcut", "crates/ui-egui/src/home.rs::translated_card_description_leaves_room_for_the_action", "crates/ui-egui/tests/it/control.rs::german_preferences_and_search_keep_command_ids", "crates/ui-egui/src/i18n/mod.rs::telugu_labels_have_glyphs", "crates/ui-egui/src/i18n/mod.rs::hungarian_is_registered", "crates/ui-egui/src/i18n/mod.rs::ukrainian_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::ukrainian_integer_plurals_cover_teens_and_large_counts", "crates/ui-egui/src/i18n/mod.rs::spanish_keeps_the_holes_in_what_the_engine_says", "crates/print/src/tests.rs::wordings_are_said", "crates/ui-egui/src/i18n/mod.rs::norwegian_bokmal_is_registered_and_persists"] -notes = "Shortcut modifiers and key names use a dedicated catalog context across toolbar, menus, palette, Help and tooltips, with English fallback and macOS symbols preserved. Preferences switches Auto (system language)/English/Japanese/Simplified Chinese/Traditional Chinese/Czech/Brazilian Portuguese/German/Spanish/French/Telugu/Hungarian/Ukrainian/Italian/Arabic/Norwegian Bokmål and persists; ui.set language auto|en|ja|zh-hans|zh-hant|cs|pt-br|de|es|fr|te|hu|uk|it|ar|nb and ui.state expose it. TSV catalogs per language (same system as PhotoCraft); every dialog, panel and notice goes through tl!. Japanese, Traditional Chinese, Simplified Chinese, German, Spanish, French and Hungarian cover commands, dialogs, panels, shortcuts and history labels; Brazilian Portuguese covers the current union of catalog keys, including signature and accessibility messages; Czech remains limited to menus. French locales (fr, fr_FR, fr_CA, fr_BE) select the French catalog, German locales (de, de_DE, de_AT, de_CH) the German one, Hungarian locales (hu, hu_HU) the Hungarian one, and Norwegian locales (nb, nb_NO, and no/nn until a Nynorsk catalog exists) the Bokmål one. German search hints reserve shortcut space, Home cards bound translated text, certificate Subject has its own context, and inserted tool names keep their capitalization. The sentences the layers below word for themselves (accessibility findings and the saved report, PDF/A problems, signature details, page-range mistakes, edit labels) are handed over apart from the document's own values and go through the catalog too, through said(&i18n::words); Spanish, Italian and Brazilian Portuguese translate them, Polish the signature details and the changes after signing; strings absent from other catalogs fall back to English. OS error details, and faults rather than mistakes, stay as they are. Auto detects the Windows display language after explicitly named supported locale overrides; neutral C/POSIX launcher locales are skipped on Windows, with Chinese script/region mapping and English fallback. Arabic (ar and every ar-* locale) covers commands, dialogs and panels with the six CLDR plural forms; its labels are put into display order when the catalog loads, because egui joins Arabic letters but lays words out left to right. Missing: a right-to-left layout (Arabic labels sit in the left-to-right layout, a wrapped Arabic paragraph breaks at the wrong end, and text typed into a field is not reordered), Czech dialogs, Chinese and Telugu font delivery in releases (the pinned craft-fonts revision has an Arabic face; its Noto Sans CJK SC is too large to embed whole), the engine's sentences in the other catalogs, a full localization audit." +evidence = ["crates/ui-egui/src/commands.rs::localized_shortcut_labels_follow_language_and_platform", "crates/ui-egui/src/i18n/mod.rs::keyboard_key_names_have_a_separate_context_and_english_fallback", "crates/ui-egui/tests/it/commands.rs::localized_shortcuts_update_immediately_and_keep_command_ids_and_key_bindings", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_resolves_chinese_scripts", "crates/ui-egui/src/i18n/mod.rs::neutral_locale_does_not_override_display_language", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_query_returns_a_locale_tag", "crates/ui-egui/src/i18n/mod.rs::language_persists_and_invalid_input_keeps_current_language", "crates/ui-egui/src/i18n/mod.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/mod.rs::arabic_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::arabic_catalog_stays_logical", "crates/ui-egui/src/i18n/mod.rs::arabic_plurals_follow_the_six_cldr_forms", "crates/ui-egui/src/i18n/mod.rs::arabic_labels_have_glyphs", "crates/ui-egui/src/bidi.rs::arabic_ui_labels_use_an_explicit_paragraph_direction", "crates/ui-egui/src/theme.rs::arabic_chrome_is_right_to_left_after_the_language_changes", "crates/ui-egui/tests/it/control.rs::simplified_chinese_signature_prompts_and_errors_keep_document_state", "crates/ui-egui/tests/it/control.rs::language_switch_preserves_document_and_command_ids", "crates/ui-egui/tests/it/control.rs::japanese_controls_and_search_keep_command_ids", "crates/ui-egui/tests/it/control.rs::japanese_dialogs_errors_and_custom_action_names", "crates/ui-egui/src/i18n/mod.rs::french_is_registered", "crates/ui-egui/src/widgets.rs::search_placeholder_never_overlaps_the_shortcut", "crates/ui-egui/src/home.rs::translated_card_description_leaves_room_for_the_action", "crates/ui-egui/tests/it/control.rs::german_preferences_and_search_keep_command_ids", "crates/ui-egui/src/i18n/mod.rs::telugu_labels_have_glyphs", "crates/ui-egui/src/i18n/mod.rs::hungarian_is_registered", "crates/ui-egui/src/i18n/mod.rs::ukrainian_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::ukrainian_integer_plurals_cover_teens_and_large_counts", "crates/ui-egui/src/i18n/mod.rs::spanish_keeps_the_holes_in_what_the_engine_says", "crates/print/src/tests.rs::wordings_are_said", "crates/ui-egui/src/i18n/mod.rs::norwegian_bokmal_is_registered_and_persists"] +notes = "Shortcut modifiers and key names use a dedicated catalog context across toolbar, menus, palette, Help and tooltips, with English fallback and macOS symbols preserved. Preferences switches Auto (system language)/English/Japanese/Simplified Chinese/Traditional Chinese/Czech/Brazilian Portuguese/German/Spanish/French/Telugu/Hungarian/Ukrainian/Italian/Arabic/Norwegian Bokmål and persists; ui.set language auto|en|ja|zh-hans|zh-hant|cs|pt-br|de|es|fr|te|hu|uk|it|ar|nb and ui.state expose it. TSV catalogs per language (same system as PhotoCraft); every dialog, panel and notice goes through tl!. Japanese, Traditional Chinese, Simplified Chinese, German, Spanish, French and Hungarian cover commands, dialogs, panels, shortcuts and history labels; Brazilian Portuguese covers the current union of catalog keys, including signature and accessibility messages; Czech remains limited to menus. French locales (fr, fr_FR, fr_CA, fr_BE) select the French catalog, German locales (de, de_DE, de_AT, de_CH) the German one, Hungarian locales (hu, hu_HU) the Hungarian one, and Norwegian locales (nb, nb_NO, and no/nn until a Nynorsk catalog exists) the Bokmål one. German search hints reserve shortcut space, Home cards bound translated text, certificate Subject has its own context, and inserted tool names keep their capitalization. The sentences the layers below word for themselves (accessibility findings and the saved report, PDF/A problems, signature details, page-range mistakes, edit labels) are handed over apart from the document's own values and go through the catalog too, through said(&i18n::words); Spanish, Italian and Brazilian Portuguese translate them, Polish the signature details and the changes after signing; strings absent from other catalogs fall back to English. OS error details, and faults rather than mistakes, stay as they are. Auto detects the Windows display language after explicitly named supported locale overrides; neutral C/POSIX launcher locales are skipped on Windows, with Chinese script/region mapping and English fallback. Arabic (ar and every ar-* locale) covers commands, dialogs and panels with the six CLDR plural forms. Catalogs stay logical and egui 0.37's epaint applies UAX #9 reordering; Arabic and Kurdish labels take an explicit right-to-left paragraph direction and stock chrome is mirrored through Style::right_to_left. Missing: native and browser end-to-end RTL input, IME and complex bidirectional workflows are not yet qualified; Czech dialogs, Chinese and Telugu font delivery (native release builds embed LXGW WenKai Lite for Simplified Chinese, while the web build has no Chinese face beyond BIZ UDPGothic and the release-pinned craft-fonts has no Telugu face), the engine's sentences in the other catalogs, a full localization audit." [[feature]] id = "misc.prefs-reading" @@ -10145,7 +10145,7 @@ status = "partial" commands = [] tools = [] evidence = ["crates/ui-egui/src/i18n/mod.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/mod.rs::date_languages_are_the_interface_languages", "crates/ui-egui/src/i18n/mod.rs::language_persists_and_invalid_input_keeps_current_language"] -notes = "The UI offers 22 non-English catalogs plus English fallback (entry counts vary by language), and the selected language persists. Partial because the languages are not all usable yet: Chinese shows tofu in releases (#826, #688), the release-pinned craft-fonts has no Telugu face, Arabic is not mirrored (misc.rtl-ui), and strings missing from a catalog fall back to English (localization-parity.md). The language can be set through the UI control channel (`set language`) but not through an automation tool, so pdfcraft-cli run and MCP cannot change it." +notes = "The UI offers 22 non-English catalogs plus English fallback (entry counts vary by language), and the selected language persists. Partial because the languages are not all usable yet: Simplified Chinese has a native release LXGW WenKai Lite face but the web build has no Hans face (#826, #688), the release-pinned craft-fonts has no Telugu face, Arabic labels stay logical with explicit right-to-left direction and mirrored stock chrome while native/browser/IME RTL workflows are not yet qualified (misc.rtl-ui), and strings missing from a catalog fall back to English (localization-parity.md). The language can be set through the UI control channel (`set language`) but not through an automation tool, so pdfcraft-cli run and MCP cannot change it." [[feature]] id = "misc.rtl-ui" @@ -10157,7 +10157,7 @@ status = "planned" commands = [] tools = [] evidence = [] -notes = "Only a first step exists: Arabic and Hebrew file names and paths in the tab strip, the recent-files list and Properties have glyphs (a craft-fonts Arab face, or an installed font on desktop) and are shown in display order (crates/ui-egui/src/bidi.rs). Missing: multi-line text (bookmarks, comments, search results), text input, a mirrored layout and a right-to-left interface language." +notes = "Implemented and unit-tested: Arabic and Hebrew file names and paths in the tab strip, the recent-files list and Properties have glyphs (a craft-fonts Arab face, or an installed font on desktop), keep logical text and are laid out left to right so the extension stays at the visual end; Arabic and Kurdish interface labels use an explicit right-to-left paragraph direction and stock chrome mirrors through Style::right_to_left (crates/ui-egui/src/bidi.rs, theme.rs); catalogs are never preordered. Still not qualified: native and browser end-to-end RTL input, IME composition, clipboard/selection and complex multi-line RTL workflows. Status stays planned until those workflows are accepted." [[feature]] id = "misc.installers" diff --git a/vendor/README.md b/vendor/README.md index 812d7df10e..20fb0c75a8 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -1,5 +1,27 @@ # Vendored crates +## Window-stack provenance after the egui 0.37 migration + +The checked-in, resolved Cargo graph pins `winit` to storytold/winit +`a517b3846ee8457d84ee4975110bfda6797c44dc` and the egui crates to storytold/egui +`6d518fc799681571963842f95b55a6b68431b15a`. Neither `vendor/winit` nor +`vendor/egui-wgpu` is a root `[patch.crates-io]` path entry. The retained copies +record the app's earlier patches; their source-reading tests establish provenance, +while the resolved forks and their behavioral tests establish runtime behavior. + +The retained winit copy has upstream base `e9809ef54b18499bb4f2cac945719ecc2a61061b`, +as recorded in `vendor/winit/.cargo_vcs_info.json`. Its thirteen patch rows below +match the fork's `PATCHES.md` entries W1–W13 commit for commit (W1 `b357a332`, +W2 `13ca693b`, W3 `fd104a4c`, W4 `3fffb7ce`, W5 `283370ce`, W6 `a82b90fb`, +W7 `9b90d5bd`, W8 `6a87addd`, W9 `9500f25d`, W10 `4a7dd791`, W11 `4df8554b`, +W12 `7facf001`, W13 `5506b1a9`). The reviewed winit fork additionally carries +W14–W27 (W14 is opt-in and is not enabled by PdfCraft). `vendor/egui-wgpu` carries +E2 (`surface_fit`) and E3 (`catch_errors`); the egui-wgpu 0.37 fork retains both, +including its `surface_fit` tests exercised by PdfCraft's behavioral tests. + +Keep both copies while their provenance tests remain in use. Renderer and security +vendors below retain their independent path patches and licenses. + Permissively licensed crates we carry small patches for. Each directory keeps the upstream licence files unchanged. Every patch is marked `PdfCraft patch:` in the source, is covered by a PdfCraft test, and should be proposed upstream. Remove the vendored copy once upstream releases @@ -17,17 +39,24 @@ Other resource categories retain their existing parent-chain lookup. | Crate | Version | Licence | Patches | Test | |---|---|---|---|---| -| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`) | +| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it (`7 Tr` since follows 64); (43) Lab colour spaces honour their own `/WhitePoint`: L\*a\*b\* goes to XYZ relative to that white, is Bradford-adapted to D65 and converted to sRGB (ISO 32000-2 §8.6.5.4) instead of through `LAB.icc` as if every white were D50 (a D65 blue came out 45 levels too red; the profile is removed); components normalised to [0, 1] (`manual_scale`, an `/Indexed` palette over Lab) map onto L\* 0..100 and the a\*/b\* `/Range`; (44) a Type 0 font whose `/ToUnicode` is the name `/Identity-H` or `/Identity-V` (not a CMap stream; WSCAD writes it) reads each code as its Unicode value, as Acrobat shows it (`Type0Font::identity_to_unicode`); the identity CMap the name resolved to maps codes to CIDs only, so such pages had no text to select or find; (46) an image XObject's group applies the fill opacity, so the fill alpha is reset to 1 before a stencil mask takes the fill colour as its paint, and a pattern paint drops the opacity it captured at `scn` (`Pattern::without_opacity`) (it was applied twice: a mask at `ca 0.5` was drawn at a quarter, #2975); (47) `path_as_rect`: a five-element path is a rectangle only when its four points are distinct, each step from one to the next runs along a single axis, and the last returns to the first. The length test admits one MoveTo and four LineTo as well as one MoveTo, three LineTo and a ClosePath, and asking only that every corner of the bounding box was visited says nothing about the order or the steps between, so a five-point polyline whose ends reached the corners was painted as that box -- a fourth side the path does not have -- and a clip of that shape was discarded; the coordinates are compared exactly, since the matrix is applied after this check and a `cm` of 10,000 turns `is_nearly_equal`'s 256th of a unit into tens of device units (#3327); (48) an `/ICCBased` colour space is cached by what its profile is read from (`IccProfileKey`): the stream object it names, looked up without reading the stream, and then the stream's dictionary and its own bytes (decrypted, not decoded), so a byte-for-byte copy of a profile in another object is read once, and a profile stream written directly in its array, which reports the object holding the array as every other stream written there does, is told apart by its bytes. hayro keyed it by the stream dictionary's bytes alone, so two different profiles with the same `/N` and `/Length` shared whichever was read first, and colours in the second were drawn through the first. `InterpreterCache::icc_profile_reads` counts the profiles read; (49) `OpenTypeFontBlob::new` also accepts a `post` table version 1.0, 2.0 or 3.0 written with its 16-bit halves swapped (`00 00 00 03`), as ScanSoft PDF Create! embeds every font; such fonts were refused and their text drawn with a substitute (Hebrew came out garbled or missing, #2946); `glyph_names` reads such a table from a copy with the version written correctly, so a 2.0 table's names serve `/Differences`. Any other unknown version is still refused (pdf.js 9462); (50) `CffFontBlob::glyph_index_by_name` looks a glyph name up in a name to glyph map built on first use (shared between clones) instead of searching the whole charset, with a string lookup per glyph, for every character code of a font; the first glyph with a name still wins; (51) at most 4096 graphics states saved at once, holding at most 64 MiB together (each `q` copies the whole state, dash array and clip list included, so short operators multiplied one large state); a `q` past either limit is counted but not copied, and its `Q` still removes the clips added since (from storytold/craft-libs#239); (52) a `/W2` run whose first CID is past `MAX_CID` stops there, as in `/W` (7), checked before the run's metrics are read: a first CID of 4294967295 inserted one entry and then overflowed, discarding the whole `/W2` table (from storytold/craft-libs#254); (55) a Separation or DeviceN tint transform that cannot be read keeps its colour space, and one that cannot be read or evaluated paints the alternate space's initial colour, as evaluation failures already did; that fallback, and a `cs`/`CS` colour space that cannot be read (drawn in DeviceGray as before), are now reported once per content stream as `InterpreterWarning::ColorFallback` (`RenderWarning::ColourFallback` in `pdfcraft-render`) instead of silently (#4260); (56) an annotation whose Invisible flag (bit 1) is set and whose `/Subtype` is none of the standard types of ISO 32000-2 Table 171 (`is_standard_annotation_type`; no `/Subtype` counts as unknown) is not drawn (Table 167), and a `/Popup` annotation's appearance is never painted on the page (§12.5.6.14: a popup has no appearance stream of its own; the viewer shows it as a window for its parent annotation). Both were drawn from their `/AP`; (57) a simple font drawn with a standard-font stand-in because the document names a font it doesn't embed (`StandardKind` with `fallback`) narrows a glyph to a narrower `/Widths` advance but no longer widens it to a wider one: the advance already places the next glyph (ISO 32000-2 §9.2.4; /Widths in Table 109), and widening drew a non-embedded `Arial,Bold` with 600-unit widths as a heavy fixed-pitch face whose `i` and `l` were solid blocks twice their width; an exact standard-14 font still matches its standard metrics both ways; (58) the fallback request for a Type 0 font that is not embedded (`FallbackFontQuery::with_descriptor`) takes its serif, italic and small-cap flags, `/FontWeight`, `/FontFamily` and `/FontStretch` from the descendant CIDFont's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1; §9.8): it read them from the Type 0 dictionary, which has none, so every such request asked for a regular sans-serif face and no resolver could tell a Song or Mincho font from a Hei or Gothic one, or see `/FontWeight 700`; (59) the NoRotate flag (`/F` bit 5) is honoured: the appearance, fitted to `/Rect`, is turned against the page's `/Rotate` about the rectangle's upper-left corner, which stays where it is, so it reads upright as the page is displayed (ISO 32000-2 §12.5.3). Sticky notes, which are saved NoRotate, were drawn turned with the page (#2583). Form fields and links stay in `/Rect`, where PdfCraft takes their click targets from it; (64) text in a Type 3 font follows ISO 32000-2 §9.3.6 in the clipping modes: under `7 Tr` it is not rendered (reported to the device as clipping text, `GlyphDrawMode::Clip`, so extraction keeps it), and in modes 4 to 7 a Type 3 glyph adds nothing to the clipping path (`clip_glyph`), so text shown only in a Type 3 font leaves no clip at `ET`. Since (42) `7 Tr` painted the glyphs in the fill colour (black by default), so a fill meant to follow showed them wherever it didn't cover them; 4 to 6 still render filled; (66) a Separation colour space whose colourant is the special name `All` paints every colourant at once (ISO 32000-2 §8.6.6.4): on screen and in an RGB print raster its tint t is the gray 1 − t, complemented because the device is additive, whatever its alternate space and tint transform say (hayro drew it through the tint transform, so a `/DeviceCMYK` transform giving cyan drew registration marks light cyan); `/None` still paints nothing, any other name still goes through its alternate, and a DeviceN space naming `All` keeps its alternate, since the clause gives the name that meaning in a Separation space; (63) a luminosity soft mask whose group colour space is `/DeviceCMYK` (by name, not an ICC-based or calibrated space) reads a `/DeviceCMYK` fill or stroke colour drawn in it, and its `/BC`, as their gray `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)` (ISO 32000-2 §10.4.2.3, §11.5.3: device colour converts to gray with no colour calibration; `Context::luminosity_in_device_cmyk`, `color::device_cmyk_to_gray`): it went through the CMYK display profile, where 100% K is a dark grey, so a 100% K mask let about an eighth of the masked paint through. CMYK images, shadings and tiling patterns inside such a mask, and CMYK on the page, still use the profile | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on`; (18) `raster::tests::page_contents_and_nested_forms_share_one_budget`, `raster::tests::content_painted_again_and_again_keeps_drawing`, `raster::tests::sampled_functions_with_billions_of_samples_are_refused`; (19) `raster::tests::mesh_shadings_far_off_the_page_render`, `raster::tests::many_overlapping_mesh_triangles_finish`, `raster::tests::mesh_shadings_with_many_patches_or_no_rows_finish`; (20) `raster::tests::function_limits_calculator_arithmetic_and_depth`, `raster::tests::function_limits_public_construction_work_and_depth`, `raster::tests::function_limits_stitching_cycles_are_refused_and_shared_children_work`, `raster::tests::function_limits_invalid_calculator_keeps_the_page_renderable`; (21) `raster::tests::cids_a_substitute_lacks_are_not_drawn_as_other_glyphs`; (22) `raster::tests::variation_sequences_draw_their_kanji_in_a_substitute`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (24) `raster::tests::tiles_match_full_render`, `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` (both decode through the cache), `raster::tests::decoded_images_with_identical_dictionaries_keep_their_own_pixels`, `raster::tests::decoded_images_under_a_transfer_function_are_not_shared`; (25) `text::tests::actual_text_replaces_the_glyphs_it_marks_in_every_writing_direction`, `text::tests::actual_text_follows_the_pdf_rules_for_marked_content`; (26) `raster::tests::accented_letters_the_standard_stand_ins_lack_are_built_from_their_parts`, `raster::tests::comma_below_letters_in_standard_fonts_draw_a_comma_under_the_letter`; (27) `text::tests::truetype_without_an_encoding_reads_its_codes_as_win_ansi`; (28) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`, `raster::tests::images_converted_through_a_table_keep_every_samples_colour`, `raster::tests::a_malformed_tint_for_an_unused_sample_keeps_the_used_colours`; (29) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (30) `text::tests::the_annex_d2_alias_codes_read_as_an_ordinary_hyphen_and_space`; (31) `raster::tests::shadings_with_large_sampled_functions_paint_quickly`, `raster::tests::sampled_functions_interpolate_their_grid_as_before`; (32) `raster::tests::decoded_images_serve_every_size_they_are_drawn_at`; (33) `raster::tests::patch_meshes_keep_their_corner_colours`; (34) `raster::tests::spot_colour_images_run_their_tint_transform_once_per_sample_value`; (35) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (36) `raster::tests::mesh_triangles_sharing_an_edge_leave_no_seam`; (38) `raster::tests::default_colour_spaces::device_rgb_operators_draw_in_default_rgb`, `raster::tests::default_colour_spaces::device_cmyk_operators_draw_in_default_cmyk`, `raster::tests::default_colour_spaces::device_gray_operators_draw_in_default_gray`, `raster::tests::default_colour_spaces::device_images_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::device_shadings_draw_in_the_default_colour_spaces`, `raster::tests::default_colour_spaces::an_image_drawn_with_and_without_a_default_keeps_both_colours`, `raster::tests::default_colour_spaces::forms_inherit_the_default_colour_spaces_they_do_not_override`, `raster::tests::default_colour_spaces::unusable_defaults_leave_device_colour_alone`; (39) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (40) `raster::tests::visibility_expressions_decide_what_a_membership_dictionary_shows`; (41) `raster::tests::a_multiply_highlight_inside_a_transparency_group_keeps_the_text`; (42) `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`; (43) `raster::tests::lab_honours_the_space_white_point`, `raster::tests::indexed_palettes_over_lab_keep_their_colours`; (44) `text::tests::identity_to_unicode_names_give_text`; (46) `raster::tests::stencil_masks_are_drawn_at_the_fill_opacity_once`; (47) `raster::tests::a_five_point_polyline_is_not_painted_as_its_bounding_box`, `raster::tests::a_path_that_visits_every_corner_out_of_order_is_not_a_rectangle`, `raster::tests::a_rectangle_is_still_painted_as_a_rectangle`; (48) `raster::tests::icc_profile_cache::icc_profiles_with_identical_dictionaries_draw_their_own_colours`, `raster::tests::icc_profile_cache::icc_profiles_used_again_are_read_once`; (49) `raster::tests::a_font_whose_post_version_has_swapped_halves_is_still_used`; (50) `font::blob::tests::glyph_names_resolve_to_the_first_glyph_with_that_name`; (51) `context::saved_state_tests::repeated_saves_stay_bounded_and_balanced`; (52) `font::cid::tests::w2_run_past_the_largest_cid_keeps_the_rest_of_the_table` (hayro-interpret); (55) `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns`, `raster::tests::devicen_inks::a_tint_transform_that_cannot_be_read_falls_back_and_warns`; (56) `raster::tests::invisible_unknown_annotations_and_popups_paint_nothing`; (57) `substitute_widths::a_substitute_keeps_its_glyph_shapes_at_wider_advances`, `substitute_widths::a_substitute_still_narrows_glyphs_to_narrower_advances` (pdfcraft-render `tests/it`); (58) `cid_fallback_query::a_cid_font_fallback_request_carries_its_descendants_style`, `cid_fallback_query::a_cid_font_without_a_descriptor_asks_for_the_default_style` (pdfcraft-render integration tests); (59) `raster::tests::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages` (hayro-interpret); (64) `type3_text_clip::{clip_only_type3_text_is_not_rendered_and_leaves_no_clip, invisible_type3_text_is_not_rendered, other_modes_render_type3_glyphs_filled_and_never_clip, only_outline_glyphs_shape_a_clip_shared_with_type3_text, self_nesting_type3_text_in_a_clip_mode_stays_bounded}` (`cargo test -p pdfcraft-render --test it`), `raster::tests::rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled`, `text::tests::clipping_mode_type3_text_is_kept_by_the_ocr_filter`; (66) `separation_all::{separation_all_fills_paint_the_complemented_tint_as_gray, separation_all_strokes_paint_the_complemented_tint_as_gray, separation_all_images_paint_each_sample_as_the_complemented_gray, separation_all_shadings_paint_the_complemented_gray, separation_none_paints_nothing, a_named_separation_still_paints_through_its_alternate, a_device_n_space_naming_all_still_paints_through_its_alternate}` (`cargo test -p pdfcraft-render --test it`); (63) `color::device_cmyk_gray_tests::{each_ink_darkens_by_its_iso_weight, ink_past_full_coverage_is_black_and_bad_components_read_as_zero}` (hayro-interpret), `it::luminosity_group_space::{black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully, each_process_ink_in_a_device_cmyk_group_masks_by_its_weight, a_device_cmyk_backdrop_colour_masks_by_its_gray, gray_and_rgb_groups_mask_as_before, device_cmyk_on_the_page_keeps_its_display_colour}` (pdfcraft-render) | | hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group; under reading colours its background is painted inside it as the initial backdrop, so a Multiply highlight blends with the dark background instead of staying an opaque block, and otherwise the background stays outside, so a blend mode over an empty part of the page keeps its colour instead of blending with the white paper (#2970); (19) `set_paint` samples a mesh shading's texture over its triangles alone (`EncodedShadingPattern::extent`, padded rather than repeated) and skips a mesh with none on the canvas: a BricsCAD plan's 310 one-triangle `sh` each covered the whole tile, 2 s per 1024 px tile at any zoom (the watchdog gave the page up on a busy machine), now about 70 ms; (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349); (22) knockout groups: while one is innermost, fills, strokes, glyph outlines and shadings are drawn with `Compose::Copy` (weighted by coverage), so each element replaces what earlier elements of the group drew instead of compositing over it; an image or nested group, drawn into a layer of its own, first has what is under its shape cleared (`knockout_image` for an unmasked image's rectangle, only when `draw_image` will draw it, decoded at the size it is drawn; a knockout erase pass that draws a nested group's content with every paint clearing instead of painting, a shading fill's clear clipped to the shading's `/BBox` as its paint is) and then composites over it (#2971); (23) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (25) a tiling cell is drawn at the scale that makes one `/XStep` (`/YStep`) exactly the cell pixmap's whole number of pixels (`tiling_cell_pixels`, at least 1 and at most the cap), so the repeated pixmap puts cells at whole multiples of the step at any device scale; drawing at the unrounded scale repeated the cell up to half a pixel off at every step, and the error grew with distance from the pattern's origin (ISO 32000-2 8.7.3.1) | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (19) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path`; (22) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (25) `tiling_cell_placement::{far_cells_of_a_fill_sit_at_whole_steps, far_cells_sit_at_whole_steps_under_a_turned_scaled_matrix, far_cells_of_a_stroke_sit_at_whole_steps, a_cell_pixmap_spans_exactly_one_step}` (`cargo test -p pdfcraft-render --test it`) | | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate`; (4) `segment::tests::test_referred_to_count_is_capped` (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`) | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order; (6) `find_latest_trailer`: a rebuild takes a `/Type /XRef` stream dictionary as the trailer when the file has no `trailer` keyword (the latest in the file wins, as a later `trailer` would), so a concatenated or truncated file keeps its /Info and /ID instead of failing to load; a candidate whose /Root is the catalog beats one whose /Root is another dictionary, and an in-file /Root must match its generation (#2365) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace`; (6) `inspect::tests::rebuilt_file_keeps_the_info_of_its_cross_reference_stream`, `inspect::tests::rebuilt_file_prefers_a_trailer_whose_root_is_the_catalog` | | hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); an EI followed by a delimiter (EI%..., EI[...) ends the image like one followed by white space; only a regular character after EI skips the candidate; (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW, RunLength and ASCII85 never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it.; (17) CCITT `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11), not zero rows: scanned Group 4 pages written that way rendered blank; (18) The streaming Flate decoder (`lzw_flate::flate::fallback`) copies a back-reference in runs of up to its distance (`extend_from_within`), not byte by byte. The copy reads only bytes already in the output, and a copy whose source does not exist still writes nothing, so the bytes are those a byte-by-byte copy writes. Inflating the A1 sample's content stream (278.6 MB) takes about 14% less time (0.66 s to 0.56 s); the output is identical; (19) CCITT `/Rows` below the image's `/Height` decodes to the height: a plotter driver wrote `/Rows` equal to `/Columns`, so only the first rows were decoded and the rest drawn black (#3308) (20) `stream` followed by spaces or tabs and then the line break (`stream \r\n`, as Microsoft Reporting Services writes it) starts the data after the line break; the content stream was taken to start at the space and the page rendered blank.; (21) a JP2 colour specification naming an enumerated colour space the decoder refuses (CIEJab in a veraPDF fixture, YCbCr, PhotoYCC, YPbPr, bilevel…) no longer fails the image: it is decoded again from a copy whose `colr` names the space the decoder assumes without one, greyscale, sRGB or CMYK by component count, as MuPDF and Poppler draw it (`without_refused_enumerated_colour`; the page drew blank, #3306); (22) an object stream's index of `objnum offset` pairs is parsed once per stream and kept beside its decoded data (`ObjectStreamIndexes`, `XRef::with_object_stream_indexes`), and an offset past `usize` is malformed instead of wrapping: `XRef::get_with` parsed the whole index again for every object it fetched from the stream, so reading all of a stream of N objects took N² steps (a Firefox page whose 65,542 objects share one stream took 37 s to draw and 40 s to extract its text); (23) the inline-image end-of-data look-ahead no longer rejects a candidate `EI` because a byte above 0x7F lies between it and the next one: when the candidate is the end, those bytes are the content stream that follows the image, and its strings carry such bytes (WinAnsi `(Grüße) Tj`, the two-byte codes of any Type 0 font), so the operator check that follows decides alone, bounded as in (4) (a scanned page whose OCR text held an accented word and a later capital "EI" — EINGANG, BEI, KLEIN — drew and extracted nothing after the image: 40 of 275 pages across five real documents); (24) `xref::prev_offset`: a trailer `/Prev` written as a reference `N G R` is read as the offset that object holds when the section's own entries place it (iText's `indirectPrev.pdf`; each section's entries are read before its `Prev` is followed), and otherwise as offset N (tiff2pdf; JHOVE's PDF-HUL-157 files, #2366), as `pdfcraft-cos` and other readers take it, so the earlier sections' pages are seen instead of one; (25) direct arrays and dictionaries share a reader-local nesting cap of 128, counted while skipping and while parsing a dictionary (its lenient repair reads, rather than skips, an object found where a key belongs, so `<< << …` recursed through that read); deeper objects return `None` instead of overflowing the stack (including unused trailer values, trailer entries, content operands and marked-content properties); a chain of 65 direct stitching functions now stops at this cap, before the 64-function guard; (26) an xref stream subsection must be backed by its decoded data: all-zero `/W` widths (an entry reads no bytes) and a declared count longer than the data are refused before any entry is inserted, where a tiny stream with a large `/Size` or `/Index` count inserted that many entries (from storytold/craft-libs#239); a subsection running past object number `i32::MAX` is refused instead of overflowing or turning negative; (27) an xref stream's type field is read big-endian at its full `/W[0]` width, where only its first byte was read and wider fields shifted every later entry, and `/W[0]` over 8 bytes is refused like `/W[1]` (from storytold/craft-libs#241); (28) a content instruction keeps up to 256 operands (`MAX_OPERANDS`) instead of 10, and past that drops the oldest instead of ending the content stream: the eleventh operand of a twelve-ink DeviceN `scn` (ISO 32000-2 §8.6.6.5 allows any number of components) blanked the rest of the page (#4260) | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`, `content::tests::inline_image_ei_may_be_followed_by_a_delimiter`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed`; (17) `raster::tests::group4_fax_with_rows_zero_decodes_to_the_image_height`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height`; (20) `raster::tests::space_before_the_line_break_after_stream_still_renders`; (21) `raster::tests::a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn`, `filter::jpx::tests::refused_enumerated_colour_spaces_become_the_component_counts_default`, `filter::jpx::tests::what_the_decoder_handles_or_cant_be_read_is_left_alone` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (22) `raster::tests::objects_in_one_large_object_stream_are_read_in_linear_time`; (23) `text::tests::text_after_an_inline_image_survives_a_non_ascii_byte_before_a_later_ei`, `content::tests::an_inline_image_ends_at_its_own_ei_when_later_text_holds_a_non_ascii_byte` (hayro-syntax); (24) `xref::tests::prev_as_reference_reaches_the_earlier_section`, `xref::tests::prev_as_reference_to_an_object_holding_the_offset_reads_that_offset`, `pdfcraft-cos` `document::tests::a_prev_written_as_a_reference_still_reaches_the_earlier_sections`, `document::tests::a_prev_reference_to_an_object_holding_the_offset_reads_that_offset`; (25) `raster::tests::deeply_nested_direct_objects_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_content_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_the_trailer_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_as_marked_content_properties_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_are_read_up_to_the_nesting_cap`, `raster::tests::nesting_at_the_cap_fits_a_512_kib_stack` (native only), `raster::tests::function_limits_depth_guard_refuses_a_chain_of_references`; (26) `xref::tests::xref_stream_entries_need_backing_bytes`; (27) `xref::tests::xref_stream_type_field_uses_the_full_w0_width`, `xref::tests::xref_stream_loader_rejects_oversized_w0` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (28) `content::ops::tests::scn_with_twelve_components_keeps_them_all`, `content::ops::tests::operands_past_the_cap_keep_the_latest_in_order` (hayro-syntax), `raster::tests::devicen_inks::a_twelve_ink_devicen_colour_paints`, `raster::tests::devicen_inks::a_four_ink_devicen_colour_paints`, `raster::tests::devicen_inks::an_operator_with_hundreds_of_operands_does_not_end_the_content` | | winit | 0.30.13 | Apache-2.0 | (1) on Windows 11 (build 22000 and later), `WM_DPICHANGED` applies the rect Windows suggests. It used to nudge the window onto the monitor `MonitorFromWindow` names, which during a drag is still the monitor being left; the nudge sent another `WM_DPICHANGED`, so a window dragged to a monitor with another scale factor grew and stayed on the wrong monitor at the wrong scale (#324). Backport of winit master 488c036a ([winit#4341](https://github.com/rust-windowing/winit/pull/4341)), which no 0.30 release has; Windows 10 keeps the old adjustment, as on winit master. (2) on Wayland, a seat's text input is destroyed when the seat goes away, not whenever it loses a capability: losing any capability (a touch screen or tablet unplugged, a remote-desktop session ending) destroyed it, and only an added capability recreated it, so IME stopped working until restart. Port of [winit#4747](https://github.com/rust-windowing/winit/pull/4747); (3) on Windows, an IME composition reports its cursor from `GCS_CURSORPOS` only when `WM_IME_COMPOSITION` says it is set, and at the end of the composition otherwise: the Microsoft Korean IME never sets it, so the cursor read as 0 (egui then drew no caret while composing; it now draws it after the syllable). Port of [winit#4746](https://github.com/rust-windowing/winit/pull/4746); (4) on Windows, a hidden window is not maximized until it is shown, and a hidden window shown while maximized gets one `ShowWindow(SW_MAXIMIZE)`: `SW_MAXIMIZE` shows a hidden window, so PdfCraft, which eframe keeps hidden until its first frame is drawn, appeared unpainted (white) when it restored a maximized window, and could lose its focus. Port of [winit#4587](https://github.com/rust-windowing/winit/pull/4587) (the later, focus-safe form of [winit#4575](https://github.com/rust-windowing/winit/pull/4575)); (5) on X11, when allowing or disallowing IME replaces the focused window's input context (egui does it each time a text field gains or loses focus), the new context is focused: no focus event follows, so it stayed unfocused until the window lost and regained focus. Port of [winit#4727](https://github.com/rust-windowing/winit/pull/4727); (6) on macOS, `insertText` commits through IME when a composition was started (`pending_commit`, set by `setMarkedText`) rather than when marked text is left: input methods that end a composition with `unmarkText` before `insertText` lost the text, and Apple Korean dropped the key typed after a syllable (`한` then `5` or `;`); other `insertText` calls stay `KeyboardInput`; a commit outside a key press (a candidate clicked with the mouse, dictation) clears the marked text and leaves no key to swallow, as in [winit#4748](https://github.com/rust-windowing/winit/pull/4748) (without it the next key, such as Backspace, was lost). `validAttributesForMarkedText` lists the underline and marked-clause attributes, without which some input methods don't compose. Port of [winit#4650](https://github.com/rust-windowing/winit/pull/4650), the 0.30 backport of [winit#4651](https://github.com/rust-windowing/winit/pull/4651) (merged on winit master), with the underline attribute spelled as AppKit's `NSUnderlineStyleAttributeName` (`NSUnderline`); (7) on macOS, disallowing IME with a composition in progress (focus leaving a text field) tells the input method to discard its marked text (`discardMarkedText`): it kept composing it once IME was allowed again, so the abandoned syllable came back (`하`, then `가` typed in the next field, became `학`, then `하` + `가`; Japanese `か` then `さ` became `傘`). Port of [winit#4745](https://github.com/rust-windowing/winit/pull/4745); (8) on macOS, text inserted outside a key press without a composition (the emoji picker) is reported as `Ime::Commit`; it was dropped. Port of the emoji-picker part of [winit#4749](https://github.com/rust-windowing/winit/pull/4749) (0.30 backport of [winit#4748](https://github.com/rust-windowing/winit/pull/4748)); its `selectedRange` change for dictation is left out because it switches on press-and-hold for every held key (an accent menu instead of key repeat, and the chosen accent added after the repeated letters); (9) on macOS, a key whose text was committed through IME isn't also sent as `KeyboardInput` unless AppKit forwards it as a command (Enter): Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`, `insertText(" ")`, `setMarkedText("")`, and the last call reset winit's state, so the space arrived twice. Same goal as [winit#4478](https://github.com/rust-windowing/winit/pull/4478), whose changes don't reach this call order (macOS 14); to be proposed upstream there; (10) on macOS, when the first key in a fresh app reaches Apple Korean's `insertText` as a raw compatibility jamo instead of starting a composition, that key is interpreted once more, synchronously, before it is sent as `KeyboardInput` (g k s typed ㅎㅏㄴ instead of 한); only an unmodified, non-repeated first key whose one callback inserts that same jamo, with IME enabled and nothing composed, focus and input source unchanged, is retried (`ime_startup.rs`). Port of [winit#4744](https://github.com/rust-windowing/winit/pull/4744), with its policy tests.; (11) on X11 without `Xft.dpi`, the scale factor comes from the X screen's configured DPI (its size in pixels and millimetres) before the output's physical density from RandR: Xorg can deliberately run a dense panel at 96 DPI, and only winit applications then looked zoomed; `Xft.dpi` still wins, `WINIT_X11_SCALE_FACTOR` still overrides, and a screen reporting no size keeps the RandR fallback. PdfCraft patch (#1150, replacing the launcher pin proposed in #908). (12) on macOS, `WinitView` answers `mouseDownCanMoveWindow` with the window's `movable_by_window_background` attribute (NO unless asked): NSView answers YES for a view that isn't opaque, so with the transparent title bar and full-size content view PdfCraft uses, AppKit moved the window for any drag that began in the 28 pt title-bar band before the app saw it, and document tabs (which live in that band) could not be dragged to reorder them. The empty strip still moves the window through `ViewportCommand::StartDrag`. PdfCraft patch. The published crate's asset files (`docs/res/*.svg`, `examples/data/*.png`) are left out, so its `window` example doesn't build; (13) on Wayland, pixel-only wheel, wheel-tilt and continuous-source scroll events report `Moved` rather than starting an unterminated finger gesture, so egui follows modifier release instead of keeping Ctrl zoom latched (#991). Finger gestures and unknown sources retain their existing phase policy | (1) `pdfcraft` `tests::winit_carries_the_windows_11_monitor_scale_fix` keeps the patch in the build; the drag itself needs two monitors with different scale factors on Windows 11; (2) `pdfcraft` `tests/winit_wayland_text_input.rs`; the capability change itself needs a Wayland session and an input method; (3) `pdfcraft` `tests/winit_windows_ime_cursor.rs`; composing itself needs the Microsoft Korean IME; (4) `pdfcraft` `tests/winit_windows_hidden_maximized.rs`; the start-up itself needs Windows and a window that was closed maximized; (5) `pdfcraft` `tests/winit_x11_ime_focus.rs`; switching fields itself needs an X11 session and an input method (IBus, fcitx5); (6) `pdfcraft` `tests/winit_macos_ime_commit.rs`; composing itself needs macOS and an input method; (7) `pdfcraft` `tests/winit_macos_ime_discard.rs`; composing itself needs macOS and an input method; (8) `pdfcraft` `tests/winit_macos_emoji_picker.rs`; the picker itself needs macOS; (9) `pdfcraft` `tests/winit_macos_korean_space.rs`; composing itself needs macOS and Apple Korean; (10) `pdfcraft` `tests/winit_macos_korean_first_key.rs` and `ime_startup.rs`'s own tests (run them with `rustc --test`); typing itself needs macOS and Apple Korean; (11) `pdfcraft` `tests/winit_x11_dpi.rs` keeps the patch in the build, and `util::randr` tests the policy (run them in the vendored crate); the scale itself needs an X11 session; (12) `pdfcraft` `tests/winit_macos_title_bar_drag.rs` keeps the patch in the build; the drag itself needs macOS and a pointer; (13) `pdfcraft` `tests/winit_wayland_scroll.rs` compiles the production phase policy and exercises modifier release through egui; live COSMIC confirmation remains open | -| egui-wgpu | 0.36.2 | MIT OR Apache-2.0 | (1) `winit::surface_fit`: a window's surface is configured within the device's `max_texture_dimension_2d`, both sides shrunk by one factor that egui then draws at (`pixels_per_point × render_scale`), so the whole window is drawn into the surface (on DX12 it is stretched over the window and lines up with the pointer; wgpu's GL backend copies it unscaled into a corner); the longer side becomes exactly the limit; the depth and MSAA textures follow, and a recreated surface starts again from the window's own size. egui-wgpu configured the window's size as is, and `Surface::configure` panics beyond the limit: a window restored at another monitor's scale (opening an Outlook attachment on a mixed-DPI laptop asked an 8192-pixel device for 8600 × 3423, #577) or stretched across monitors closed PdfCraft, before its window appeared when it happened at start-up ([emilk/egui#8361](https://github.com/emilk/egui/issues/8361)). The published crate leaves out egui's licence files; `LICENSE-APACHE` and `LICENSE-MIT` are from the 0.36.2 tag; (2) `winit::catch_errors`: a new window's surface is configured inside wgpu error scopes (validation, out of memory, internal), and a failure is returned as `WgpuError::ConfigureSurface` (for the root window at start-up eframe returns it as `Error::Wgpu`, which PdfCraft retries with OpenGL); nothing is kept for the window. wgpu's default handler panicked instead, and eframe configures the root surface before the app is created, so a driver that can't configure one closed PdfCraft at launch (a 2015 Intel GPU through wgpu's GL backend timed out waiting for the GPU to go idle, `GpuWaitTimeout`, #519). Unchanged, so still a panic: an error in a later reconfigure (resize, a lost surface), a device lost during the first configure (wgpu reports it only to a callback), and a window shown at zero size (first configured on resize) | (1) `pdfcraft` `tests::a_window_larger_than_the_gpu_limit_gets_a_surface_within_it`, `tests::surface_fit_never_empties_or_overflows_a_surface`, and `tests::egui_wgpu_carries_the_surface_size_fit`, which keeps the patch in the build and in the paths that size a surface; (2) `pdfcraft` `tests::a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic` (a real device: an error inside `catch_errors` is returned, not a panic) and `tests::egui_wgpu_returns_a_surface_it_cannot_configure_as_an_error`, which keeps it around `add_surface`'s configure | +| egui-wgpu | 0.36.2 | MIT OR Apache-2.0 | (1) `winit::surface_fit`: a window's surface is configured within the device's `max_texture_dimension_2d`, both sides shrunk by one factor that egui then draws at (`pixels_per_point × render_scale`), so the whole window is drawn into the surface (on DX12 it is stretched over the window and lines up with the pointer; wgpu's GL backend copies it unscaled into a corner); the longer side becomes exactly the limit; the depth and MSAA textures follow, and a recreated surface starts again from the window's own size. egui-wgpu configured the window's size as is, and `Surface::configure` panics beyond the limit: a window restored at another monitor's scale (opening an Outlook attachment on a mixed-DPI laptop asked an 8192-pixel device for 8600 × 3423, #577) or stretched across monitors closed PdfCraft, before its window appeared when it happened at start-up ([emilk/egui#8361](https://github.com/emilk/egui/issues/8361)). The published crate leaves out egui's licence files; `LICENSE-APACHE` and `LICENSE-MIT` are from the 0.36.2 tag; (2) `winit::catch_errors`: a new window's surface is configured inside wgpu error scopes (validation, out of memory, internal), and a failure is returned as `WgpuError::ConfigureSurface` (for the root window at start-up eframe returns it as `Error::Wgpu`, which PdfCraft retries with OpenGL); nothing is kept for the window. wgpu's default handler panicked instead, and eframe configures the root surface before the app is created, so a driver that can't configure one closed PdfCraft at launch (a 2015 Intel GPU through wgpu's GL backend timed out waiting for the GPU to go idle, `GpuWaitTimeout`, #519). Unchanged, so still a panic: an error in a later reconfigure (resize, a lost surface), a device lost during the first configure (wgpu reports it only to a callback), and a window shown at zero size (first configured on resize) | (1) `pdfcraft` `tests::a_window_larger_than_the_gpu_limit_gets_a_surface_within_it`, `tests::surface_fit_never_empties_or_overflows_a_surface`, and `tests::vendor_provenance_egui_wgpu_surface_fit_identity`, which keeps the patch in the build and in the paths that size a surface; (2) `pdfcraft` `tests::a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic` (a real device: an error inside `catch_errors` is returned, not a panic) and `tests::vendor_provenance_egui_wgpu_catch_errors_identity`, which keeps it around `add_surface`'s configure | | vello_common | 0.0.8 | Apache-2.0 OR MIT | (1) `clip::intersect` (the clip step every clipped path passes through) starts its row walk at the first row both paths have strips in, not the earlier of the two first rows. The walk visited every row from the top of the clip for each path (on the A1 sample's full-page canvas, about half its strip rows per stroke), and rows where one path has no strips produce no output, so the strips are unchanged (vello_common 0.1 starts at the later row too). Its cost grew with the page, and a full-page render of the A1 sample was about 30% faster; the output is byte-identical | (1) `clip::tests::intersect_walks_only_rows_both_paths_have_strips` counts the rows the walk visits (`cargo test --manifest-path vendor/vello_common/Cargo.toml`) and fails without the patch; the render crate's pixel tests run through the patched crate | | hayro-cmap | 0.1.0 | Apache-2.0 OR MIT | (1) malformed parts of a CMap (bad bfchar/bfrange entries, unresolvable `usecmap`, unparseable tail) are skipped instead of discarding the whole map, so search and redaction find text the edit layer can read (#3177); (2) a binary CMap's `usecmap` counts against the 16-level nesting limit as a text one does, where it passed its own depth and a self-referencing binary CMap recursed until the stack overflowed (from storytold/craft-libs#254) | (1) `hayro-cmap::tests::bad_bfchar_entry_is_skipped`, `unresolvable_usecmap_is_skipped`; (2) `tests::self_referencing_binary_usecmap_stops_at_the_nesting_limit` | | hayro-postscript | 0.1.0 | Apache-2.0 OR MIT | (1) the token scanner's `object::read` skips `<<` and `>>` in a loop; it called itself once per delimiter, so a CMap stream holding a long run of `<` or `>` overflowed the stack and aborted the process (from storytold/craft-libs#263) | (1) `hayro-postscript::object::tests::long_open_dict_run_does_not_overflow`, `long_close_dict_run_does_not_overflow`, `dict_delimiters_are_skipped`, `nested_dict_delimiters_are_skipped` | +`hayro-postscript` is the crates.io 0.1.0 package plus patch (1). The local Cargo +registry cache metadata for that package (`.cargo_vcs_info.json`) records upstream +VCS commit `a2354aebaf86f5ab13e5fd6b6b0521306488bf9d`. The registry copy and this vendored tree's +source files differ only in `src/object.rs` (the patch above); the vendored tree +omits the registry dotfiles `.cargo_vcs_info.json` and `.cargo-ok`, so the upstream +commit is recorded here. + Renderer startup errors: `egui-wgpu::RenderState::create` also wraps `Renderer::new` in `winit::catch_errors` when the winit feature is enabled. A rejected shader or render pipeline is returned as `WgpuError::CreateRenderer`, allowing PdfCraft's existing startup OpenGL retry. diff --git a/vendor/hayro-interpret/src/color.rs b/vendor/hayro-interpret/src/color.rs index a8e7d84c16..531e3b01c9 100644 --- a/vendor/hayro-interpret/src/color.rs +++ b/vendor/hayro-interpret/src/color.rs @@ -391,6 +391,13 @@ impl ColorSpace { matches!(self.0.as_ref(), ColorSpaceType::DeviceGray) } + // PdfCraft patch (63). + /// Whether this is `/DeviceCMYK` itself, not an ICC-based or default colour space that + /// stands in for it. + pub(crate) fn is_device_cmyk(&self) -> bool { + matches!(self.0.as_ref(), ColorSpaceType::DeviceCmyk) + } + /// Get the number of components of the color space. pub(crate) fn num_components(&self) -> u8 { match self.0.as_ref() { @@ -1342,6 +1349,18 @@ fn f32_to_u8(val: f32) -> u8 { (val * 255.0 + 0.5) as u8 } +// PdfCraft patch (63). +/// The `/DeviceCMYK` to `/DeviceGray` conversion of ISO 32000-2 §10.4.2.3, +/// `gray = 1.0 - min(1.0, 0.3 c + 0.59 m + 0.11 y + k)`, which §11.5.3 also gives for the +/// luminosity of a soft mask whose group is in `/DeviceCMYK`. A missing component counts as +/// zero, and each component is clamped to `[0, 1]` first (`NaN` and infinities read as zero). +pub(crate) fn device_cmyk_to_gray(cmyk: &[f32]) -> f32 { + let component = |i: usize| clamp_component(cmyk.get(i).copied().unwrap_or(0.0), 0.0, 1.0); + let ink = 0.3 * component(0) + 0.59 * component(1) + 0.11 * component(2) + component(3); + // Every component is in [0, 1], so `ink` is not negative and the result is in [0, 1]. + 1.0 - ink.min(1.0) +} + /// Clamp an input-derived colour component to `[lo, hi]` without panicking on /// NaN or on a reversed range. Non-finite values map to zero. fn clamp_component(value: f32, lo: f32, hi: f32) -> f32 { @@ -1450,3 +1469,36 @@ pub(crate) trait ToRgb { )) } } + +#[cfg(test)] +mod device_cmyk_gray_tests { + use super::device_cmyk_to_gray; + + fn assert_close(cmyk: &[f32], expected: f32) { + let got = device_cmyk_to_gray(cmyk); + assert!( + (got - expected).abs() < 1e-5, + "{cmyk:?}: gray {got}, expected {expected}" + ); + } + + #[test] + fn each_ink_darkens_by_its_iso_weight() { + assert_close(&[0.0, 0.0, 0.0, 0.0], 1.0); + assert_close(&[0.0, 0.0, 0.0, 1.0], 0.0); + assert_close(&[0.0, 0.0, 0.0, 0.5], 0.5); + assert_close(&[1.0, 0.0, 0.0, 0.0], 0.7); + assert_close(&[0.0, 1.0, 0.0, 0.0], 0.41); + assert_close(&[0.0, 0.0, 1.0, 0.0], 0.89); + assert_close(&[0.5, 0.5, 0.5, 0.25], 0.25); + } + + #[test] + fn ink_past_full_coverage_is_black_and_bad_components_read_as_zero() { + assert_close(&[1.0, 1.0, 1.0, 1.0], 0.0); + assert_close(&[0.0, 0.0, 0.0, 1.5], 0.0); + assert_close(&[2.0, -1.0, f32::NAN, f32::INFINITY], 0.7); + assert_close(&[], 1.0); + assert_close(&[0.0, 0.0], 1.0); + } +} diff --git a/vendor/hayro-interpret/src/context.rs b/vendor/hayro-interpret/src/context.rs index 1b1655dcd4..ff178b25b9 100644 --- a/vendor/hayro-interpret/src/context.rs +++ b/vendor/hayro-interpret/src/context.rs @@ -173,6 +173,11 @@ pub struct Context<'a> { /// a limit was reached, the clip count to return to at its `Q`. saved_bytes: usize, skipped_saves: Vec, + // PdfCraft patch (63). + /// This is the content of a luminosity soft mask whose group colour space is `/DeviceCMYK`, + /// so a `/DeviceCMYK` fill or stroke colour paints as its gray (ISO 32000-2 §11.5.3, + /// §10.4.2.3) rather than through the CMYK profile used for display. + pub(crate) luminosity_in_device_cmyk: bool, } /// PdfCraft patch: most graphics states saved at once. Every `q` copies the current state, dash @@ -251,6 +256,7 @@ impl<'a> Context<'a> { nesting_depth, saved_bytes: 0, skipped_saves: Vec::new(), + luminosity_in_device_cmyk: false, } } diff --git a/vendor/hayro-interpret/src/interpret/path.rs b/vendor/hayro-interpret/src/interpret/path.rs index c1fd47f395..a08a60e3bd 100644 --- a/vendor/hayro-interpret/src/interpret/path.rs +++ b/vendor/hayro-interpret/src/interpret/path.rs @@ -1,4 +1,4 @@ -use crate::color::{Color, ColorSpace}; +use crate::color::{Color, ColorSpace, device_cmyk_to_gray}; use crate::context::{Context, path_as_rect}; use crate::device::Device; use crate::util::{BezPathExt, Float32Ext}; @@ -169,7 +169,13 @@ pub(crate) fn get_paint<'a>(context: &Context<'a>, is_stroke: bool) -> Paint<'a> Paint::Color(Color::new(ColorSpace::device_gray(), smallvec![0.0], 0.0)) } } else { - let color = Color::new(data.color_space, data.color, data.alpha); + // PdfCraft patch (63): see `Context::luminosity_in_device_cmyk`. + let color = if context.luminosity_in_device_cmyk && data.color_space.is_device_cmyk() { + let gray = device_cmyk_to_gray(&data.color); + Color::new(ColorSpace::device_gray(), smallvec![gray], data.alpha) + } else { + Color::new(data.color_space, data.color, data.alpha) + }; if let Some(tf) = &data.transfer_function { Paint::Color(Color::from_rgba(tf.apply(&color.to_rgba()))) diff --git a/vendor/hayro-interpret/src/soft_mask.rs b/vendor/hayro-interpret/src/soft_mask.rs index 4d97d565b5..1cd60a17e9 100644 --- a/vendor/hayro-interpret/src/soft_mask.rs +++ b/vendor/hayro-interpret/src/soft_mask.rs @@ -1,4 +1,4 @@ -use crate::color::{Color, ColorComponents, ColorSpace}; +use crate::color::{Color, ColorComponents, ColorSpace, DeviceFamily, device_cmyk_to_gray}; use crate::context::{Context, InterpreterCache}; use crate::device::Device; use crate::function::Function; @@ -59,6 +59,9 @@ struct Repr<'a> { transfer_function: Option, settings: InterpreterSettings, background: Color, + // PdfCraft patch (63). + /// A luminosity mask whose group colour space is `/DeviceCMYK`. + luminosity_in_device_cmyk: bool, xref: &'a XRef, nesting_depth: u32, } @@ -107,10 +110,17 @@ impl<'a> SoftMask<'a> { // PdfCraft patch: the group's /CS is optional (ISO 32000-2 §11.6.5.2; Chrome/Skia omit // it). Upstream returned None here, silently dropping the whole soft mask. Fall back to // DeviceRGB, the blending space our renderer composites in. - let cs = group + let cs_object = group .dict .get::>(GROUP) - .and_then(|g| g.get::>(CS)) + .and_then(|g| g.get::>(CS)); + // PdfCraft patch (63): the group is composited in `/DeviceCMYK` itself. An ICC-based + // or calibrated group space keeps its own conversion. + let group_in_device_cmyk = cs_object + .as_ref() + .and_then(DeviceFamily::of_object) + .is_some_and(|family| family == DeviceFamily::Cmyk); + let cs = cs_object .and_then(|o| ColorSpace::new(o, &context.interpreter_cache.object_cache)) .unwrap_or_else(ColorSpace::device_rgb); let transfer_function = dict @@ -121,7 +131,19 @@ impl<'a> SoftMask<'a> { LUMINOSITY => { let color = dict .get::(BC) - .map(|c| Color::new(cs, c, 1.0)) + .map(|c| { + // PdfCraft patch (63): a DeviceCMYK backdrop's luminosity is its gray + // (ISO 32000-2 §11.5.3), as for the group's content. + if group_in_device_cmyk && c.len() == 4 { + Color::new( + ColorSpace::device_gray(), + smallvec![device_cmyk_to_gray(&c)], + 1.0, + ) + } else { + Color::new(cs, c, 1.0) + } + }) .unwrap_or(Color::new(ColorSpace::device_gray(), smallvec![0.0], 1.0)); (MaskType::Luminosity, color) @@ -157,6 +179,7 @@ impl<'a> SoftMask<'a> { settings: context.settings.clone(), xref: context.xref, background, + luminosity_in_device_cmyk: group_in_device_cmyk && mask_type == MaskType::Luminosity, parent_resources, nesting_depth, }))) @@ -174,6 +197,7 @@ impl<'a> SoftMask<'a> { state, self.0.nesting_depth, ); + ctx.luminosity_in_device_cmyk = self.0.luminosity_in_device_cmyk; draw_form_xobject(&self.0.parent_resources, &self.0.group, &mut ctx, device); }