diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 7ca1124ce2..40626dc6ba 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,17 +286,17 @@ 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) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 9ca337e396..383272f3f7 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" @@ -3396,7 +3406,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 +3416,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 +3438,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 +3492,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 +3502,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 +3512,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 8b49c9521c..37f2487f8b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3934,7 +3934,7 @@ dependencies = [ [[package]] name = "pdfcraft" -version = "0.13.0" +version = "0.14.0" dependencies = [ "chrono", "craft-quit", @@ -3960,7 +3960,7 @@ dependencies = [ [[package]] name = "pdfcraft-a11y" -version = "0.13.0" +version = "0.14.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -3972,7 +3972,7 @@ dependencies = [ [[package]] name = "pdfcraft-annot" -version = "0.13.0" +version = "0.14.0" dependencies = [ "hayro-syntax", "pdfcraft-cos", @@ -3983,7 +3983,7 @@ dependencies = [ [[package]] name = "pdfcraft-audit" -version = "0.13.0" +version = "0.14.0" dependencies = [ "pdfcraft-content", "pdfcraft-cos", @@ -3994,7 +3994,7 @@ dependencies = [ [[package]] name = "pdfcraft-automation" -version = "0.13.0" +version = "0.14.0" dependencies = [ "base64 0.22.1", "flate2", @@ -4016,7 +4016,7 @@ dependencies = [ [[package]] name = "pdfcraft-cli" -version = "0.13.0" +version = "0.14.0" dependencies = [ "base64 0.22.1", "pdfcraft-audit", @@ -4034,11 +4034,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 +4046,7 @@ dependencies = [ [[package]] name = "pdfcraft-cos" -version = "0.13.0" +version = "0.14.0" dependencies = [ "chrono", "hayro-syntax", @@ -4061,7 +4061,7 @@ dependencies = [ [[package]] name = "pdfcraft-create" -version = "0.13.0" +version = "0.14.0" dependencies = [ "encoding_rs", "hayro-syntax", @@ -4077,7 +4077,7 @@ dependencies = [ [[package]] name = "pdfcraft-crypt" -version = "0.13.0" +version = "0.14.0" dependencies = [ "aes", "md-5", @@ -4089,7 +4089,7 @@ dependencies = [ [[package]] name = "pdfcraft-edit" -version = "0.13.0" +version = "0.14.0" dependencies = [ "hayro-syntax", "kurbo 0.13.1", @@ -4104,7 +4104,7 @@ dependencies = [ [[package]] name = "pdfcraft-engine" -version = "0.13.0" +version = "0.14.0" dependencies = [ "chrono", "craft-docpath", @@ -4145,14 +4145,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", @@ -4162,7 +4162,7 @@ dependencies = [ [[package]] name = "pdfcraft-fonts" -version = "0.13.0" +version = "0.14.0" dependencies = [ "harfrust", "pdfcraft-cos", @@ -4172,7 +4172,7 @@ dependencies = [ [[package]] name = "pdfcraft-forms" -version = "0.13.0" +version = "0.14.0" dependencies = [ "hayro-syntax", "pdfcraft-content", @@ -4185,11 +4185,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", @@ -4197,7 +4197,7 @@ dependencies = [ [[package]] name = "pdfcraft-measure" -version = "0.13.0" +version = "0.14.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4210,14 +4210,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", @@ -4227,7 +4227,7 @@ dependencies = [ [[package]] name = "pdfcraft-optimize" -version = "0.13.0" +version = "0.14.0" dependencies = [ "image", "pdfcraft-annot", @@ -4242,11 +4242,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", @@ -4257,7 +4257,7 @@ dependencies = [ [[package]] name = "pdfcraft-platform" -version = "0.13.0" +version = "0.14.0" dependencies = [ "async-channel", "futures-lite", @@ -4267,7 +4267,7 @@ dependencies = [ [[package]] name = "pdfcraft-preflight" -version = "0.13.0" +version = "0.14.0" dependencies = [ "flate2", "pdfcraft-content", @@ -4278,7 +4278,7 @@ dependencies = [ [[package]] name = "pdfcraft-print" -version = "0.13.0" +version = "0.14.0" dependencies = [ "craft-print", "pdfcraft-content", @@ -4290,7 +4290,7 @@ dependencies = [ [[package]] name = "pdfcraft-redact" -version = "0.13.0" +version = "0.14.0" dependencies = [ "pdfcraft-annot", "pdfcraft-content", @@ -4306,7 +4306,7 @@ dependencies = [ [[package]] name = "pdfcraft-render" -version = "0.13.0" +version = "0.14.0" dependencies = [ "bytemuck", "flate2", @@ -4325,7 +4325,7 @@ dependencies = [ [[package]] name = "pdfcraft-sign" -version = "0.13.0" +version = "0.14.0" dependencies = [ "aes", "aws-lc-rs", @@ -4361,11 +4361,11 @@ dependencies = [ [[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", @@ -4413,7 +4413,7 @@ dependencies = [ [[package]] name = "pdfcraft-web" -version = "0.13.0" +version = "0.14.0" dependencies = [ "eframe", "egui", @@ -4425,7 +4425,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfa" -version = "0.13.0" +version = "0.14.0" dependencies = [ "base64 0.22.1", "image", @@ -4439,7 +4439,7 @@ dependencies = [ [[package]] name = "pdfcraft-xfdf" -version = "0.13.0" +version = "0.14.0" dependencies = [ "pdfcraft-annot", "pdfcraft-cos", diff --git a/Cargo.toml b/Cargo.toml index e7acfd31f9..78c7a78c6f 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" @@ -21,39 +21,39 @@ craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1. # Whether a path stored in a document may be opened (GoToR link targets). craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } # internal -pdfcraft-geom = { path = "crates/geom", version = "0.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" diff --git a/ROADMAP.md b/ROADMAP.md index b16494387f..9fe97284ca 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -199,6 +199,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **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 +226,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. @@ -318,6 +320,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. 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/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 e94354021f..521600edb3 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 1bfcc77984..4b4ac8873b 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..9ea6f7f17b 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,15 @@ BT /F1 11 Tf 20 20 Td (Total due: 150.00) Tj ET"; assert!(rendered.iter().any(|c| matches!(c, Content::Png { .. }))); } +/// 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_within_their_budgets() { +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 +7320,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 +7346,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. @@ -297,6 +299,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}"), @@ -1038,8 +1044,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/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/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/lib.rs b/crates/engine/src/lib.rs index aa71d82fef..224318f1d7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -2461,6 +2461,8 @@ struct EditCtx { xfa_datasets: Option, /// A form script ran too long and was abandoned: the document's scripts go off. xfa_ran_away: bool, + /// What the XFA scripts' time limits are multiplied by ([`Session::with_xfa_time_scale`]). + xfa_time_scale: u32, /// 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()`. @@ -2495,6 +2497,7 @@ impl EditCtx { 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), @@ -2677,7 +2680,8 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> }, 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 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; @@ -3005,7 +3009,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> /// The comment list, read from the object graph (as `inspect` lists comments). fn comment_list(doc: &pdfcraft_cos::Document) -> Vec { - pdfcraft_annot::summaries(doc) + let mut list: Vec = pdfcraft_annot::summaries(doc) .into_iter() .map(|s| pdfcraft_render::Annotation { page: s.page, @@ -3015,6 +3019,8 @@ fn comment_list(doc: &pdfcraft_cos::Document) -> Vec Vec 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)) } @@ -3225,6 +3241,9 @@ pub struct Session { date_format: Option, /// Preferences ▸ Date format ▸ Language, when not following the interface language. date_language: Option, + /// What the XFA scripts' time limits are multiplied by (`None`: 1); see + /// [`Session::with_xfa_time_scale`]. + xfa_time_scale: Option, } /// Immutable session state needed while a document is prepared away from its owning thread. @@ -3233,6 +3252,7 @@ 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. @@ -3469,7 +3489,8 @@ fn image_content_type(bytes: &[u8]) -> Option<&'static str> { /// 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 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; @@ -3512,9 +3533,14 @@ fn xfa_after_data_change(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx, cal 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(); + // 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) = fields.iter().find(|f| f.name == name) else { continue }; + 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 @@ -3532,13 +3558,14 @@ fn xfa_values_from_datasets(doc: &mut pdfcraft_cos::Document) -> Result (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); + if let Some(v) = new { + updates.push((name, v)); } } - Ok(changed) + // 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()) } /// Validate the signature fields of `cos` (written as `bytes`). @@ -3557,6 +3584,17 @@ impl Session { 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 @@ -3629,7 +3667,7 @@ 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() } + 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. @@ -3758,7 +3796,13 @@ impl Session { 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 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; @@ -3942,6 +3986,7 @@ impl Session { 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(); @@ -3951,6 +3996,7 @@ impl Session { 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(); @@ -4048,7 +4094,13 @@ impl Session { 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 }; + 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)) @@ -4461,7 +4513,8 @@ impl Session { 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() }; + // 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. @@ -4511,11 +4564,15 @@ impl Session { // 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. + // 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; @@ -5120,17 +5177,18 @@ impl Session { 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(), - }; + // 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, @@ -5502,24 +5560,30 @@ pub fn comment_kind(a: &pdfcraft_render::Annotation) -> &str { /// 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(); + 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, all: &[pdfcraft_render::Annotation], parent: &str, depth: usize) { + 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 all.iter().filter(|r| r.state.is_none() && r.in_reply_to.as_deref() == Some(parent)) { + 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}"); } - if let Some(nm) = r.name.as_deref() { - write_replies(out, all, nm, depth + 1); - } + write_replies(out, children, r, depth + 1); } } let mut numbered: Vec<(usize, &pdfcraft_render::Annotation)> = Vec::new(); @@ -5558,9 +5622,7 @@ pub fn comment_summary(name: &str, all: &[pdfcraft_render::Annotation], sort: Su 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); - } + write_replies(&mut out, &children, a, 0); out.push('\n'); } out 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/tests.rs b/crates/engine/src/tests.rs index dc76159d14..097a934f8e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2284,6 +2284,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 +2730,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); @@ -3496,8 +3650,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] @@ -3720,6 +3878,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() { @@ -5354,6 +5584,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. @@ -5437,6 +5735,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
"; @@ -5456,7 +5756,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 @@ -5614,7 +5915,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(); @@ -5643,16 +5945,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; @@ -5665,9 +5970,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()]); @@ -5675,6 +5981,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. 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/forms/README.md b/crates/forms/README.md index 4aec223ee9..2c24ddc6cf 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 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..49fa21e76c 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))) } 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..ffd67899cc 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; diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 54bc137abd..903e8b6fcd 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,145 @@ fn comb_fields_show_their_cells() { let plain = saved_look(&doc, "d"); assert!(!plain.contains(" m ") && plain.contains("(A) Tj"), "{plain}"); } + +/// 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/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..fa39853a7f 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -1145,6 +1145,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![]); @@ -2673,6 +2708,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/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/src/all_tools_ui.rs b/crates/ui-egui/src/all_tools_ui.rs new file mode 100644 index 0000000000..c8c510a11c --- /dev/null +++ b/crates/ui-egui/src/all_tools_ui.rs @@ -0,0 +1,196 @@ +//! 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. +/// A right-to-left language's translations are in display order (their words run the other way) +/// while the query is in typing order, so there several words match one by one, 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::WidgetType::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 shown = crate::bidi::display_rtl("تنظيم الصفحات"); + assert_ne!(shown, "تنظيم الصفحات", "the catalogue reverses the words for display"); + assert!(label_hit("Organize pages", &shown, "تنظيم الصفحات", true)); + assert!(label_hit("Organize pages", &shown, "الصفحات", true), "one word"); + assert!(!label_hit("Organize pages", &shown, "تنظيم الملفات", true), "every word must be there"); + assert!(!label_hit("Organize pages", &shown, "تنظيم الصفحات", false), "left-to-right labels stay exact"); + } +} diff --git a/crates/ui-egui/src/calc_order_ui.rs b/crates/ui-egui/src/calc_order_ui.rs index 894fe17929..70a6dec2c0 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"); } @@ -724,10 +724,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 +735,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 +749,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..1cacc4e163 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); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a0e80e0aab..02bc000ed6 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}; @@ -2373,7 +2374,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()); @@ -2791,7 +2792,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 +2837,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 +2849,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 +2865,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 @@ -3006,7 +3005,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(); } @@ -3164,15 +3163,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; @@ -3261,11 +3251,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 +3413,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 +3429,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 +3453,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 +3474,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 +3504,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 +3524,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 +3532,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 +3549,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 +3585,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { ui, &resp, &xf, + &ptr, i, info, &added, @@ -3620,11 +3614,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 +3630,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 +3708,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 +3736,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 +3826,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 +3839,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 +3882,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,7 +4007,7 @@ 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 { @@ -4307,7 +4292,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::new("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); @@ -4729,6 +4716,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 @@ -5291,7 +5303,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 { @@ -5877,6 +5889,8 @@ mod tests { modified: None, name: None, in_reply_to: None, + parent: None, + grouped: false, rect: [0.0; 4], color: None, index: 0, 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..928d5cbf6b 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); @@ -1870,8 +1881,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 +1944,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 +1989,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() }))); @@ -2305,6 +2315,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 +2338,7 @@ mod tests { let cx = PageCx { page: 0, xf: &xf, + ptr: &PagePointer::default(), info: &info, tool: QuickTool::Select, prefs: &prefs, @@ -2543,6 +2556,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, diff --git a/crates/ui-egui/src/comments_panel.rs b/crates/ui-egui/src/comments_panel.rs index 6c13d78d33..627385b228 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 { @@ -157,25 +165,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())) @@ -304,8 +319,9 @@ 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); + above = 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); ui.label(egui::RichText::new(author).font(theme::semibold(12.5)).color(t.text)); if let Some(m) = &a.modified { @@ -376,11 +392,13 @@ 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| { @@ -393,8 +411,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); @@ -501,8 +523,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 +534,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 +553,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() diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 2ac624d3fc..0d86f7dd8f 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; @@ -117,6 +118,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 +134,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 +154,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 +162,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 +229,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 +252,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 +325,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 +345,7 @@ pub(crate) fn page_input( ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, + ptr: &PagePointer, page: usize, info: &DocInfo, added: &[Added], @@ -333,13 +355,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 +430,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 +472,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; @@ -935,19 +965,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 +1054,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/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..a6f2477572 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -10,6 +10,12 @@ 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); @@ -43,6 +49,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 +128,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,11 +140,14 @@ 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(), ); @@ -141,15 +155,23 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { 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()); 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(); } } @@ -500,7 +522,9 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { ui.end_row(); } } - }) + // Laid out above, outside the two-column grid. + PropsTab::SpaceAudit => {} + }); }); } Dialog::Split => { @@ -837,7 +861,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; @@ -1309,43 +1333,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 { diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 65c7bd780a..f67398d84f 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, @@ -497,6 +501,7 @@ fn crop_input( ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, + ptr: &PagePointer, page: usize, info: &DocInfo, view: &mut DocView, @@ -506,12 +511,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 +563,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 +669,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 +677,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 +805,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 +815,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 +859,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 +909,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 +920,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 +947,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 +1005,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 +1199,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 +1215,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 +1263,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 +1276,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 +1308,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 +1560,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; @@ -1700,11 +1708,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 +1748,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 +1815,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 4f25d6df21..9a13d570d9 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -160,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, @@ -509,7 +509,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; @@ -1615,6 +1615,7 @@ mod revert_draft_tests { calendar: None, calendar_rect: None, calendar_text: None, + had_focus: false, } } diff --git a/crates/ui-egui/src/fill_sign.rs b/crates/ui-egui/src/fill_sign.rs index 1684c80bea..862b63e72d 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)] @@ -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 { diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 0d81e547ca..8caa44ced2 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) { @@ -475,9 +480,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 +656,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; @@ -730,6 +746,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 +754,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)))) { @@ -785,9 +802,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,8 +836,7 @@ 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; } } @@ -859,8 +873,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..59b0a52bb3 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) }; @@ -29,26 +29,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) diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 35a301d6db..25ddac1d5f 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2766,6 +2766,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 إرفاق الملفات غير متاح على الويب بعد @@ -2807,3 +2815,4 @@ 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} رد diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 91b0b32527..a8af8ed655 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2452,6 +2452,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 Прикачването на файлове още не е налично в уеб версията @@ -2494,3 +2502,4 @@ animation effects Off Изключени # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Днес +@plural {n} reply|{n} replies {n} отговор|{n} отговора diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 2778dd3818..42b036d161 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2654,6 +2654,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 @@ -2792,3 +2800,4 @@ 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 diff --git a/crates/ui-egui/src/i18n/ckb.tsv b/crates/ui-egui/src/i18n/ckb.tsv index b851185cca..845f437845 100644 --- a/crates/ui-egui/src/i18n/ckb.tsv +++ b/crates/ui-egui/src/i18n/ckb.tsv @@ -2720,6 +2720,15 @@ 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} وەڵام diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 42b92f4df0..3cadb98e3b 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2563,6 +2563,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 @@ -2838,3 +2846,4 @@ 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í diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index ae3980ecfb..7b9dcc06f0 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2490,6 +2490,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 @@ -2532,3 +2540,4 @@ 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 diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index b3a694471b..dbe7969173 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2495,6 +2495,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 +2545,4 @@ animation effects Off Ανενεργά # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Σήμερα +@plural {n} reply|{n} replies {n} απάντηση|{n} απαντήσεις diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 507148a02d..951a3dd8c1 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2632,6 +2632,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 @@ -2854,3 +2862,4 @@ 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 diff --git a/crates/ui-egui/src/i18n/fa.tsv b/crates/ui-egui/src/i18n/fa.tsv new file mode 100644 index 0000000000..297d3084b9 --- /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, `rtl: true` in i18n/mod.rs puts every entry into display +# order when the catalog loads ([`crate::bidi::display_rtl`]); entries are written here in logical +# (typing) order. 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 cdda3c89f3..cd5a8040de 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2698,6 +2698,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. @@ -2801,3 +2809,4 @@ 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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 5e885dbbcd..ea6008039f 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2489,6 +2489,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 @@ -2797,3 +2805,4 @@ 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 diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index d33655bcd3..7f403caba1 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -2682,6 +2682,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 दबाएँ @@ -2768,3 +2776,4 @@ theme Appearance स्वरूप Attaching files is not available on the web yet वेब पर अभी फ़ाइलें संलग्न करना उपलब्ध नहीं है Delete attachment अनुलग्नक हटाएँ Delete attachment {key} अनुलग्नक {key} हटाएँ +@plural {n} reply|{n} replies {n} जवाब diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 07c0ee69cc..33ced9925d 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2488,6 +2488,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 @@ -2530,3 +2538,4 @@ 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 diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index e6542e4ca4..422fac2793 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2683,6 +2683,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 @@ -2769,3 +2777,4 @@ 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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 726407ccf6..60ecf2de3c 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2701,6 +2701,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 @@ -2763,3 +2771,4 @@ 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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index dd6dcf0d92..db338ebb11 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2489,6 +2489,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 版ではまだファイルを添付できません @@ -2531,3 +2539,4 @@ animation effects Off オフ # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today 今日 +@plural {n} reply|{n} replies {n} 件の返信 diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 53ba3699e2..ea0d0d0fb4 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -2487,6 +2487,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 Вебте файл тіркеу әзірге қолжетімсіз @@ -2529,3 +2537,4 @@ animation effects Off Өшірулі # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Бүгін +@plural {n} reply|{n} replies {n} жауап|{n} жауап diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index ba4821101a..a9ee5b4878 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2682,6 +2682,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를 누르세요 @@ -2768,3 +2776,4 @@ theme Appearance 모양 Attaching files is not available on the web yet 웹에서는 아직 파일을 첨부할 수 없습니다 Delete attachment 첨부 파일 삭제 Delete attachment {key} 첨부 파일 {key} 삭제 +@plural {n} reply|{n} replies 답글 {n}개 diff --git a/crates/ui-egui/src/i18n/lo.tsv b/crates/ui-egui/src/i18n/lo.tsv index 0084cdc2fe..3b702756bc 100644 --- a/crates/ui-egui/src/i18n/lo.tsv +++ b/crates/ui-egui/src/i18n/lo.tsv @@ -2436,6 +2436,15 @@ 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} ລາຍການ diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index 4c4201b5c9..058bbe9052 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -2739,6 +2739,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. @@ -2792,3 +2800,4 @@ 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ų diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 8f17a8acaf..15a27becbc 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -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). @@ -1672,7 +1676,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}"); @@ -1723,6 +1727,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 (single words in display order). + 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 +1831,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 5930aff85a..bc5fc98f58 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -2614,6 +2614,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… @@ -2707,3 +2715,4 @@ 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 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 0461ad8148..056ca7d403 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2465,6 +2465,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 @@ -2627,3 +2635,4 @@ 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 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 310e051e2d..1434c7c6e2 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2503,6 +2503,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 @@ -2816,3 +2824,4 @@ 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 diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index 85ac9e8617..7c739932e2 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -2654,6 +2654,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 @@ -2792,3 +2800,4 @@ 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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 4860d88780..bea98cd797 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2402,6 +2402,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 Прикрепление файлов в веб-версии пока недоступно @@ -2444,3 +2452,4 @@ animation effects Off Выкл # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Сегодня +@plural {n} reply|{n} replies {n} ответ|{n} ответа|{n} ответов diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index d3afeccbd1..ccbb42d57b 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2494,6 +2494,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 @@ -2536,3 +2544,4 @@ 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 diff --git a/crates/ui-egui/src/i18n/sq.tsv b/crates/ui-egui/src/i18n/sq.tsv index 6e8de9f682..a720a5333f 100644 --- a/crates/ui-egui/src/i18n/sq.tsv +++ b/crates/ui-egui/src/i18n/sq.tsv @@ -2420,6 +2420,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 @@ -2518,3 +2526,4 @@ 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 diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 7506bb725c..317d47f9b9 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -2398,6 +2398,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 వెబ్‌లో ఫైల్‌లను జోడించడం ఇంకా అందుబాటులో లేదు @@ -2440,3 +2448,4 @@ animation effects Off ఆఫ్ # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today ఈరోజు +@plural {n} reply|{n} replies {n} ప్రత్యుత్తరం|{n} ప్రత్యుత్తరాలు diff --git a/crates/ui-egui/src/i18n/th.tsv b/crates/ui-egui/src/i18n/th.tsv index 9aa0375d2d..92bb15f1ad 100644 --- a/crates/ui-egui/src/i18n/th.tsv +++ b/crates/ui-egui/src/i18n/th.tsv @@ -2686,6 +2686,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 ลักษณะที่ปรากฏ @@ -2694,3 +2702,4 @@ 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} รายการ diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index a640e501a0..bcdfe9a90e 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2485,6 +2485,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 @@ -2527,3 +2535,4 @@ 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 diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index f519504109..599b4b63af 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2511,6 +2511,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 Прикріплення файлів у веб-версії поки недоступне @@ -2553,3 +2561,4 @@ animation effects Off Вимкнено # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today Сьогодні +@plural {n} reply|{n} replies {n} відповідь|{n} відповіді|{n} відповідей diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index c5757a3e84..048f78f624 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2676,6 +2676,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. @@ -2798,3 +2806,4 @@ 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 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 4c33d8603e..7ea3ca941c 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2453,6 +2453,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 网页版尚不支持附加文件 @@ -2495,3 +2503,4 @@ animation effects Off 关 # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today 今天 +@plural {n} reply|{n} replies {n} 条回复 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index df79eae04f..b34cb787ca 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2439,6 +2439,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 網頁版尚不支援附加檔案 @@ -2481,3 +2489,4 @@ animation effects Off 關 # Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) Today 今天 +@plural {n} reply|{n} replies {n} 則回覆 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e50145f8c9..49b7bb81b5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -32,6 +32,7 @@ macro_rules! tl_ctx { mod a11y_ui; mod actions_ui; +mod all_tools_ui; mod appearance; mod calc_order_ui; pub mod canvas; @@ -101,6 +102,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; @@ -376,6 +378,7 @@ pub enum PropsTab { Fonts, Custom, Advanced, + SpaceAudit, } /// Document Properties ▸ Custom being edited: the properties as they will be after OK, in order, @@ -706,6 +709,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 +857,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). @@ -979,6 +990,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`): @@ -1127,6 +1140,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 +1224,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(), @@ -1252,6 +1269,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 +1611,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; @@ -2455,6 +2477,8 @@ impl PdfCraftApp { /// 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 +2513,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 +2529,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. @@ -2885,6 +2928,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 +2943,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 +2956,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), @@ -2940,7 +2993,11 @@ impl PdfCraftApp { _ => Some(Dialog::About), } } - ("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(); @@ -3615,6 +3672,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). @@ -3653,6 +3713,10 @@ impl eframe::App for PdfCraftApp { .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; + } let t = theme::Tokens::get(&ctx); egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.pasteboard)).show(ui, |ui| { if self.mode == Mode::Read { @@ -3664,6 +3728,8 @@ impl eframe::App for PdfCraftApp { None => canvas::document_area(self, index, ui), }, } + } else if self.mode == Mode::AllTools && self.all_tools_full { + all_tools_ui::show(self, ui); } else { docking::show(self, ui); } diff --git a/crates/ui-egui/src/link_ui.rs b/crates/ui-egui/src/link_ui.rs index c714870c53..446de39fc0 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); diff --git a/crates/ui-egui/src/marks_ui.rs b/crates/ui-egui/src/marks_ui.rs index fbca7bb08e..5340cef393 100644 --- a/crates/ui-egui/src/marks_ui.rs +++ b/crates/ui-egui/src/marks_ui.rs @@ -117,8 +117,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..00dad4bd5f 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 { diff --git a/crates/ui-egui/src/object_ui.rs b/crates/ui-egui/src/object_ui.rs index b68fe352ae..8d7d64e47c 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); @@ -156,18 +157,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 +183,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/page_pointer.rs b/crates/ui-egui/src/page_pointer.rs new file mode 100644 index 0000000000..ebb81ab5fa --- /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::new("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/panels.rs b/crates/ui-egui/src/panels.rs index a7820c2d02..f043a1e999 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; } @@ -154,7 +160,7 @@ fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static 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); @@ -699,11 +705,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)]); @@ -896,42 +913,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(); + } }); } } diff --git a/crates/ui-egui/src/prepare.rs b/crates/ui-egui/src/prepare.rs index 5833c38384..9f58f5ea5e 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; @@ -242,6 +243,7 @@ pub(crate) fn page_input( ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, + ptr: &PagePointer, page: usize, info: &DocInfo, form: &[FormField], @@ -250,7 +252,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 +261,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 +273,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 +339,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 +395,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 +540,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 { diff --git a/crates/ui-egui/src/redact_ui.rs b/crates/ui-egui/src/redact_ui.rs index 412fc95997..8a48b3f690 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); diff --git a/crates/ui-egui/src/sign_ui.rs b/crates/ui-egui/src/sign_ui.rs index 25f92530b2..6d09115b74 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); } 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/widgets.rs b/crates/ui-egui/src/widgets.rs index 85af966806..2d1f639965 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. diff --git a/crates/ui-egui/src/window_controls.rs b/crates/ui-egui/src/window_controls.rs index d02ea5dc8c..70df814222 100644 --- a/crates/ui-egui/src/window_controls.rs +++ b/crates/ui-egui/src/window_controls.rs @@ -248,20 +248,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 +410,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/attachment_row.rs b/crates/ui-egui/tests/it/attachment_row.rs new file mode 100644 index 0000000000..1a66723bb9 --- /dev/null +++ b/crates/ui-egui/tests/it/attachment_row.rs @@ -0,0 +1,189 @@ +//! 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); + // The PDF's menu: 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_all_by_label("Save attachment…").last().unwrap().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_all_by_label("Open in a new tab").last().unwrap().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_all_by_label("Delete attachment").last().unwrap().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/comments.rs b/crates/ui-egui/tests/it/comments.rs index a273e3cd62..d516330dd7 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -1228,8 +1228,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 +1251,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 +1273,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 +1296,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 +2335,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 +2359,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..0353a7d2b3 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -609,6 +609,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/dialog_chrome.rs b/crates/ui-egui/tests/it/dialog_chrome.rs index 57f549305d..d33ea7baac 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,7 +28,8 @@ 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 } @@ -96,3 +103,152 @@ 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:?}"); +} + +#[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); + 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/editor_taps.rs b/crates/ui-egui/tests/it/editor_taps.rs new file mode 100644 index 0000000000..2f35696611 --- /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::new("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/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..12fc0cd92e 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; diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs new file mode 100644 index 0000000000..145446cc13 --- /dev/null +++ b/crates/ui-egui/tests/it/pointer.rs @@ -0,0 +1,633 @@ +//! 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. + +#[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::new("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::new("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 tab carried over a link shows neither its tip nor its cursor, and the link answers +/// again once the tab is let go. +#[test] +fn a_dragged_panel_tab_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 Inspector's tab is picked up and carried over it. + h.hover_at(at(&h, 150.0, 100.0)); + h.run_steps(4); + let tab = h.get_by(|n| n.role() == Role::Tab && n.label().as_deref() == Some("Inspector")).rect().center(); + 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 tab is still being dragged"); + assert!(!tip_shown(&h), "no link tip under a dragged panel tab"); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand); + } + // Back to the tab and let go there, which leaves the layout 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"); +} + +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 }); + for _ in 0..10 { + h.run_steps(2); + } +} + +#[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::new("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::new("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 }); +} + +#[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::new("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); +} + +#[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::new("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/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..aa96ab20dc 100644 --- a/crates/ui-egui/tests/it/support/mod.rs +++ b/crates/ui-egui/tests/it/support/mod.rs @@ -1,7 +1,10 @@ -//! 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`]). use egui_kittest::Harness; use pdfcraft_ui_egui::PdfCraftApp; +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 /// not a time, and a busy runner may not have finished the work within one. Sleeps briefly between diff --git a/crates/ui-egui/tests/it/ui.rs b/crates/ui-egui/tests/it/ui.rs index c15a5c869b..7582b6c4dc 100644 --- a/crates/ui-egui/tests/it/ui.rs +++ b/crates/ui-egui/tests/it/ui.rs @@ -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")); + h.get_by_label("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")); diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index b825968ff9..1e87d9a167 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -1468,6 +1468,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. diff --git a/crates/ui-egui/tests/it/viewer.rs b/crates/ui-egui/tests/it/viewer.rs index 7795e05134..915c47b41a 100644 --- a/crates/ui-egui/tests/it/viewer.rs +++ b/crates/ui-egui/tests/it/viewer.rs @@ -2087,3 +2087,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/xfa/README.md b/crates/xfa/README.md index 0180d0f473..e48d8cef52 100644 --- a/crates/xfa/README.md +++ b/crates/xfa/README.md @@ -38,10 +38,12 @@ 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. A field or exclusion group that must be filled in gets the `Required` flag (a radio - group on its group field), which viewers outline when they highlight fields. 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. A field or exclusion group that must be filled in gets the `Required` flag (a radio + group on its group field), which viewers outline when they highlight fields. 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 @@ -84,6 +86,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 5a831b07c9..4ed1a67215 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<'_> { @@ -368,6 +398,9 @@ impl Emitter<'_> { ff |= 1 << 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")); @@ -469,6 +502,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 @@ -590,6 +633,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) { @@ -598,6 +647,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. @@ -924,6 +1027,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()))?; @@ -941,7 +1047,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, @@ -951,7 +1057,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)?)); @@ -966,6 +1082,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| { @@ -1190,7 +1310,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| { @@ -1219,6 +1339,7 @@ pub fn strip(doc: &mut Document) -> Result { for r in streams { doc.free(r); } + doc.require_full_save(); Ok(out) } @@ -1309,3 +1430,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 fd3eb294eb..62fad7f7fd 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -1574,6 +1574,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(); @@ -1636,7 +1638,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() @@ -3075,6 +3081,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(); @@ -3083,7 +3296,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"[..])); @@ -3416,6 +3632,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:?}"); @@ -3501,6 +3718,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>| { @@ -3846,3 +4073,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/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/localization-parity.md b/docs/localization-parity.md index e6754d1cee..04434712a3 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -79,8 +79,9 @@ help/docs polish (catalog and script work are already done), not missing UI stri | 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 | +| Persian | `fa` | 2,776 entries (every bundled key) | 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). +**20 other languages** ship (34 catalogs plus English = **35** 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. Acrobat languages PdfCraft lacks: Danish, Dutch, Swedish, Hebrew. Vietnamese is shipped here but not by Acrobat. @@ -88,6 +89,7 @@ Acrobat languages PdfCraft lacks: Danish, Dutch, Swedish, Hebrew. Vietnamese is | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | Added Persian (`fa`, 2,776 entries, every bundled key); right-to-left like Arabic and Kurdish with the same layout limits; 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..234542c755 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,7 +12,7 @@ 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). @@ -57,6 +57,8 @@ These need bidirectional support in the text layout itself, not in the catalog. 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 (`ێ ڵ ڕ ۆ ە ڤ ژ چ گ پ`). +The Persian catalog (`fa.tsv`, interface name **فارسی**) covers the current 2,776-key union, 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`, `bidi::display_rtl` at load; entries are written in logical order here) and has the same four limits above. 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. + 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 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. diff --git a/docs/roadmap.md b/docs/roadmap.md index 6b079d12f1..832fabf54f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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 | @@ -252,6 +252,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/parity/acrobat-features.toml b/parity/acrobat-features.toml index 4e3f432e9f..928a36d4be 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" @@ -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" @@ -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/xfa/src/tests.rs::fields_that_must_be_filled_in_are_required", "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; a field or exclusion group that must be filled in, validate nullTest=\"error\", is Required, so field highlighting outlines it as another viewer does on a real form), 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/xfa/src/tests.rs::fields_that_must_be_filled_in_are_required", "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; a field or exclusion group that must be filled in, validate nullTest=\"error\", is Required, so field highlighting outlines it as another viewer does on a real form), 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" @@ -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" diff --git a/vendor/README.md b/vendor/README.md index 812d7df10e..e5f57ec1ad 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -17,7 +17,7 @@ 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` | 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); }