diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 78e28cc02c..cab750afa6 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -8,33 +8,33 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | 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; 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; 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; 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; 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; 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; 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; 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; 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; 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; 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; 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; 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; 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; 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; 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; 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; 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; 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; 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; 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/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; 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; 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; 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; 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; 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; 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; 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 | +| `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/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 +285,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; 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; 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 | +| `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; 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 | +| `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; 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; 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; 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 | +| `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 a1a26e0362..e5688a2f67 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; the 2026-10-22 reply-count label is an AI-assisted translation 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; the 2026-10-22 reply-count label is an AI-assisted translation 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; the 2026-10-22 reply-count label is an AI-assisted translation 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; the 2026-10-22 reply-count label is an AI-assisted translation 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; the 2026-10-22 reply-count label is an AI-assisted translation 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; the 2026-10-22 reply-count label is an AI-assisted translation 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; the 2026-10-22 reply-count label is an AI-assisted translation 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; the 2026-10-22 reply-count label is an AI-assisted translation 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; the 2026-10-22 reply-count label is an AI-assisted translation 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; the 2026-10-22 reply-count label is an AI-assisted translation 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; the 2026-10-22 reply-count label is an AI-assisted translation 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; the 2026-10-22 reply-count label is an AI-assisted translation 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; the 2026-10-22 reply-count label is an AI-assisted translation 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; the 2026-10-22 reply-count label is an AI-assisted translation 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; the 2026-10-22 reply-count label is an AI-assisted translation 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; the 2026-10-22 reply-count label is an AI-assisted translation 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; the 2026-10-22 reply-count label is an AI-assisted translation 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; the 2026-10-22 reply-count label is an AI-assisted translation 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; the 2026-10-22 reply-count label is an AI-assisted translation 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,7 +206,7 @@ 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; the 2026-10-22 reply-count label is an AI-assisted translation 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" @@ -216,7 +216,7 @@ usage = "Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n/mod. 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; the 2026-10-22 reply-count label is an AI-assisted translation 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 +226,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; the 2026-10-22 reply-count label is an AI-assisted translation 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 +236,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; the 2026-10-22 reply-count label is an AI-assisted translation 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 +245,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; the 2026-10-22 reply-count label is an AI-assisted translation 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 +254,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; the 2026-10-22 reply-count label is an AI-assisted translation 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 +264,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; the 2026-10-22 reply-count label is an AI-assisted translation 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 +275,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; the 2026-10-22 reply-count label is an AI-assisted translation 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 +3396,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; the 2026-10-22 reply-count label is an AI-assisted translation 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 +3406,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; the 2026-10-22 reply-count label is an AI-assisted translation 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 +3428,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; the 2026-10-22 reply-count label is an AI-assisted translation 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 +3482,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; the 2026-10-22 reply-count label is an AI-assisted translation 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 +3492,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; the 2026-10-22 reply-count label is an AI-assisted translation 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 +3502,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; the 2026-10-22 reply-count label is an AI-assisted translation 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/Cargo.lock b/Cargo.lock index 93c047eef3..8b49c9521c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2591,8 +2591,6 @@ dependencies = [ [[package]] name = "hayro-postscript" version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa" [[package]] name = "hayro-syntax" @@ -4235,9 +4233,11 @@ dependencies = [ "pdfcraft-annot", "pdfcraft-content", "pdfcraft-cos", + "pdfcraft-filters", "pdfcraft-model", "pdfcraft-render", "thiserror 2.0.21", + "zune-jpeg", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 2714debb62..e7acfd31f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -115,6 +115,7 @@ hayro-interpret = { path = "vendor/hayro-interpret" } hayro = { path = "vendor/hayro" } hayro-syntax = { path = "vendor/hayro-syntax" } hayro-cmap = { path = "vendor/hayro-cmap" } +hayro-postscript = { path = "vendor/hayro-postscript" } hayro-jbig2 = { path = "vendor/hayro-jbig2" } lopdf = { path = "vendor/lopdf" } winit = { path = "vendor/winit" } diff --git a/ROADMAP.md b/ROADMAP.md index 65e509f116..6542ec2f9e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,13 @@ **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away -> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-22 · **Change:** minor (M14, #3558: rebase guarded macOS per-file association recovery onto current main; estimates unchanged) +> **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M8, #3205 follow-up: labelled Italian international phone numbers with a slash match whole; estimates unchanged) +> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged) +> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged) +> **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M10: Word table cells use the widths of their own grid columns; estimates unchanged) +> **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M10: table spans retain text from covered slots in Office exports; estimates unchanged) +> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M10: image export preserves soft-mask Decode opacity ranges; estimates unchanged) +> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M10: vector printing preserves existing page XObjects when adding annotation appearances; estimates unchanged) > **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-21 · **Change:** minor (M3, #3808: the Pages inspector has an independent 50–150% thumbnail size slider and buttons; selection and current-page following are retained, with headless ui.set coverage. Estimates unchanged.) > **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M11 #3507: image merging preserves content stream boundaries; estimates unchanged) > **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged) @@ -54,6 +60,8 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (architectural paper controls from #599; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (closed issues synced: #774, #756 and the #799–#821 batch; earlier: localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M6 #2892: Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged) +> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2589: Active XFA skips PDF-container fallback scripts while retaining requested messages and saved scripts; ordinary/static/unlaid-out documents and explicit console/field calls keep their existing behavior. Estimates unchanged) +> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M7 #2592: Styled fallback fonts batch up to 240 glyphs per actual face and retain source styles. Budget refusal and distinct same-BaseFont descriptor weights have mutation-checked regressions; coverage and complex mixed runs remain limited. Estimates unchanged) > **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M7 #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Estimates unchanged) > **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2569: Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Estimates unchanged) > **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2572: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Estimates unchanged) @@ -191,7 +199,25 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **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. +- **2026-10-23 (M8, #3205 follow-up):** Phone-pattern redaction matches complete labelled Italian geographic numbers such as `Tel. +39 0426/123456`, with synthetic matcher and mark/apply/save/reopen coverage; unlabelled international slash numbers remain unsupported. +- **2026-10-23 (M7, #2636 partial):** A new `PasteContent` edit pastes an added text box or image copied in the same document or another, keeping its style, picture, turns, flips and crop, as one undo step; `content_copy` drives it headlessly. A paste lands beside earlier copies (18 pt steps) and stays on the page. A pasted image reuses its picture object in its own document and after the first paste into another; there a colour profile is dropped and CMYK colours stored losslessly are turned to RGB. The desktop keys and menus follow in separate PRs. Estimates unchanged. +- **2026-10-23 (M11, optimizer resolution):** Image downsampling includes each page's `/UserUnit` scale, preserving images already below the physical resolution threshold, including shared nested forms. + Synthetic measurement and optimization regressions cover direct and indirect units and page-local defaults. + Estimates unchanged. +- **2026-10-23 (M10, Word table widths):** Word export sizes each cell from the grid columns it spans, keeping unequal columns and cells after a merged cell consistent with the table grid. +- **2026-10-23 (M10, table continuation text):** Office exporters retain text from table slots covered by another cell's span as paragraph text. + Synthetic regressions check every input fragment survives exactly once in Word, HTML, RTF and PowerPoint, while ordinary superscripts stay in their table cells. +- **2026-10-23 (M10, image export):** Soft-mask Decode intervals preserve partial opacity in Export all images and Save Image As. + Synthetic regressions cover direct and indirect ranges, inverted/subrange alpha, packed masks, JPEG masks and unchanged image component Decode. +- **2026-10-23 (M10, print resources):** Vector printing preserves original page artwork when existing XObjects use annotation resource names. + The regression checks two distinct appearances, inherited resources, print/reopen pixels and unchanged source data. + Estimates unchanged. +- **2026-10-21 (M3, window resize and move on Wayland):** With PdfCraft's own title bar on GNOME/Wayland, a second resize by a window edge, or a second move by the empty tab strip, works straight after the first; it waited for a click inside the window, because the compositor keeps the button's release from egui. A unit test covers the release given back; checked by hand in a test build. Estimates unchanged. - **2026-10-22 (M4, #3916 partial):** Document edits and Undo/Redo cancel an Organize page drag. Pointer-release regressions check exact page order and save/reopen. +- **2026-10-22 (M10, #3752 paper):** CUPS jobs ask for the chosen paper in every size. The 13 sizes the Print dialog sent no paper for (A6, A0–A2, ARCH, ANSI C–E) use PWG 5101.1 names, checked against a synthetic PPD with `cupsfilter`, and `doc_print` to a printer sends the paper too. The macOS tray half (Auto Select instead of the PPD's default tray) is a craft-print change that needs a pin bump here. Estimates unchanged. - **2026-10-22 (M14, macOS association recovery candidate):** Successful desktop open/save/sign can queue recovery of an existing per-file binding to the running PDFCraft bundle through NSWorkspace. Metadata-only watcher changes retain undo and avoid false overwrite prompts; real same-length/same-mtime edits remain protected. Rebased on 0.12.0 and reuses #3042's shared atomic metadata preservation; the bounded recovery parser does not narrow save limits. Prior native Preview TEST-signature and Finder workflows passed on macOS 27.0.1; other versions remain unqualified. Estimates unchanged. - **2026-10-13 (M7, free image rotation):** A selected page image has a rotate knob (Shift: 15 degree steps) and `image_edit` a `rotate_by` action; the angle is written as one undoable `cm`. Snapping to page edges while moving is in #1740; resize snapping is not done. Estimates unchanged. @@ -231,6 +257,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-20 (M3, #3541/#3620):** Comment hover follows the individual marked lines instead of the enclosing multiline rectangle, and overlapping comments follow paint order. Synthetic UI regressions cover all four text-markup kinds, page/view rotations, neighboring highlights, legacy rectangle fallback and unchanged PDF bytes. Estimates unchanged. - **2026-10-20 (M10, #3532 partial):** Vector printing retains existing widget artwork when its appearance lacks Subtype. A print-owned copy adds Form dispatch metadata. The source stream and all explicit subtypes stay unchanged. Synthetic source/output raster checks cover resources, selected/shared appearances, placement, flags and content modes. Appearance regeneration and physical printer validation remain outside this fix. Estimates unchanged. +- **2026-10-20 (M5, arrow keys move comments):** With a comment selected, the arrow keys move it a screen pixel and ⌘/Ctrl or Shift and an arrow ten, instead of scrolling the view or turning the page; up is up on screen at any zoom and rotation, and each press is one undo step. Text markup and locked comments stay put. Estimates unchanged. + - **2026-10-19 (M14, #3373):** The main menu and its long submenus scroll in short windows, keeping their commands reachable. Estimates unchanged. - **2026-10-19 (M3, #3387):** Save-prompt shortcuts yield to later consent and utility dialogs; a synthetic UI regression checks that an edited document stays open. Estimates unchanged. @@ -249,6 +277,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-18 (M6, #2892):** Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged. - **2026-10-18 (M7, #3212):** Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, with original content preserved. +- **2026-10-18 (M12, #2589):** Active XFA skips PDF-container fallback scripts while retaining requested messages and saved scripts; ordinary/static/unlaid-out documents and explicit console/field calls keep their existing behavior. Estimates unchanged. +- **2026-10-18 (M7, #2592):** Styled fallback fonts batch up to 240 glyphs per actual face and retain source styles. Budget refusal and distinct same-BaseFont descriptor weights have mutation-checked regressions; coverage and complex mixed runs remain limited. Estimates unchanged. - **2026-10-18 (M7, #3187):** Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Estimates unchanged. - **2026-10-18 (M12, #2569):** Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Estimates unchanged. - **2026-10-18 (M12, #2572):** Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Estimates unchanged. @@ -276,6 +306,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-16 (M7, #2140):** Header & footer (and Bates numbering, which uses the same dialog) can shrink the page content so the header and footer don't cover it: the original content is scaled evenly into the band they leave free, centred across, also on rotated pages; Remove and Update take the scaling away, and margins that leave no room are refused. Dialog checkbox with a preview of the band, `shrink` on `doc_header_footer`, all catalogs. Annotations and added text and images are not moved yet. Estimates unchanged. - **2026-10-20 (M14, Spanish catalog gaps):** `es.tsv` gains the 143 `(context, English)` keys the other catalogs carried and it was missing, so Spanish covers the whole bundled key union like Lithuanian and Vietnamese: calendar months and weekdays, the `certificate` context, X.509 key usages, signature-revision and digital-ID lines, password validation, eight plural messages, the combine-from-folder strings, the rendering and stale-file notices and the shortcut-spelled tooltips. Counts in `localization-parity.md` re-measured against the current union (`vi` restated against it for the same reason). Status stays `partial`: no translated help, and OS details stay outside the catalog. Estimates unchanged. - **2026-10-16 (M14, localization):** Czech goes from menus-and-dialogs to the whole surface: 2,674 entries after adding the ~220 keys the other catalogs had -- the sentences the engine words (signature details, accessibility findings and report, PDF/A problems, pages to print, history labels), the date picker's weekday heads, the certificate viewer's fields and shared buttons. "Tag" is *tag* throughout (the Tags panel said *značky*, which is also a redaction mark), and "{n} tags" no longer reads "1 značek". Two new catalog tests. Estimates unchanged. +- **2026-10-16 (M5, #2583, #1869):** A sticky note on a page with `/Rotate` is drawn upright, with its top-left where it was placed. Notes are saved with the NoRotate flag, and the renderer now honours it: the appearance is turned against the page's rotation about the upper-left corner of `/Rect`, which stays put (ISO 32000-2 §12.5.3; vendored hayro-interpret patch 37). Attachment icons follow the same rule. Selection, hover, the edit composer, the Comments panel's flash and the `comment_list` and `doc_info` rectangles use the drawn box, and `comment_add` anchors an icon's displayed top-left at `at`. Estimates unchanged. +- **2026-10-16 (M11, #1201):** Optimize decodes a JPEG with its colour transform: an Adobe APP14 marker's flag, else the filter's `/ColorTransform` (ISO 32000-2 §7.4.8). An RGB JPEG stored untransformed (`/ColorTransform 0`, no marker) used to be converted from YCbCr anyway and came out in other colours, recompressed or merged from strips; it now keeps them. A JPEG whose marker and `/ColorTransform` disagree, or whose components are named R, G and B without `/ColorTransform 0`, is left as it is, since readers differ on it. Estimates unchanged. - **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged. - **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged. - **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. A page area's `occur min` is honoured too: one the body never reached gets the pages it still needs after the body (the once-only closing page of a form, with its button, comes last). Acrobat Pro also puts that page last, and when printing keeps it but leaves it blank (`relevant="-print"` on the page area), which PdfCraft does not do yet; both re-observed on a synthetic fixture, see the 2026-10-19 line. Synthetic regression tests. Estimates unchanged. @@ -302,6 +334,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-22 (M14, JPEG and TIFF export):** Export ▸ Image as JPEG or TIFF refuses a raster that is not exactly width × height pixels, as PNG export has since #2425. A raster a pixel short panicked inside the JPEG and TIFF encoders, and a partial pixel at the end was dropped and the rest written as a valid image. Rendered pages are always the right size, so what exports is unchanged. Estimates unchanged. - **2026-10-13 (G14, Vietnamese localization):** Added Vietnamese (`vi`, Tiếng Việt) as an interface and date language with a 2,591-entry catalog (`plural_none`) covering 100% of the current localizable key union (plus `theme`/`Appearance`). Locale tags `vi` / `vi-VN` / `vi_VN.UTF-8` resolve; calendar months use numeric `Tháng 1`…`12` / `tháng 1`…`12`. Native-speaker community review recorded. The twelve-language localization mean moves from ≈59% to ≈68%; weighted readiness ≈49% → ≈50%. Status stays `partial`: engine/OS details, document contents, filenames, CLI/MCP and external help stay outside the catalog by design. +- **2026-10-12 (M10, Edit on a scanned page):** Turning Edit on (its tab, Edit text & images or Add text) for a scanned page (an image covering at least half of a page with no text of its own; comments and text boxes over it do not count) recognizes the page first, like Acrobat's "Converting scanned page to Editable Text and Images", so its words open for editing. Once per page per window, one undoable "Recognize text" step, with the language of the Recognize Text dialog (English only for now, gap 7). Pages with real text (a flyer with a background picture), read-only files and other tools are left alone. The photographs on a scan are still part of the one page image. - **2026-10-13 (M3, typed zoom):** The right rail's zoom percentage is an editable field: type 150, 150% or 62.5 % and press Enter, Escape discards, and nonsense keeps the current zoom. The control channel's zoom option shares its parser, which now refuses zero, negative and non-finite values. - **2026-10-13 (M4, indirect page rotation):** Rotation queries and edits resolve indirect integer values on pages and their page-tree ancestors. Turning a page already at 270° by 90° now saves it at 0°, including when its rotation is inherited. Synthetic full/incremental save-and-reopen tests cover nested inheritance, sibling preservation, negative/large angles and invalid-value fallback. Estimates unchanged. - **2026-10-14 (M1, Flate fallback):** Raw deflate whose first bytes resemble a zlib header no longer loses a decoding-limit error during fallback. Strict and tolerant filter regressions and a COS stream regression cover refusal at the caller limit and successful decoding within it. No parity status or estimate changes. @@ -349,6 +382,9 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-15 (community, M14, Polish signature panel):** A Polish signature panel listed its details in English: the catalog lacked every signature wording, the changes after signing they name and 34 UI literals. All 101 are translated; `polish_covers_ui_literals` and `polish_says_what_a_signature_says` keep them so. Estimates unchanged. - **2026-10-14 (M14, Finnish localization):** Added Finnish as another interface and date language, with a 2,674-entry catalog covering every key of the other bundled catalogs and two-form plurals (partitive after numbers other than 1). Calendar dates use partitive month names (`14. lokakuuta 2026`). Coverage tests check commands/tools, scanned UI literals and bundled catalog keys. AI-assisted; native proofreading pending. - **2026-10-14 (M14, Catalan localization):** Added Catalan as another interface and date language, with a 2,592-entry clean-room catalog, one/other plurals and coverage tests for commands, tools, UI literals and every key in the other catalogs. A native Catalan speaker (the contributor) has reviewed the translation. Estimates unchanged. + +- **2026-10-15 (M3, #1248):** A fresh install follows the desktop's appearance: the theme preference defaults to System (dark on a dark desktop, Light where the desktop says nothing); saved settings and `--theme` still win. Dark page rendering is Reading colours, and the page reading themes are #1732. +- **2026-10-15 (M3, #1767):** Restore last view settings when reopening documents, as Acrobat's preference does: a file opened again shows the page, zoom and page display it was last shown with (remembered for the last 100 files when a tab closes or settings are saved), over the file's own initial view; on by default, Preferences ▸ Documents and view turns it off. - **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged. - **2026-10-13 (community, M3, #531 touchpad momentum):** On Wayland a two-finger swipe keeps gliding after the fingers lift and slows to a stop, measured from the gesture's last 0.1 s and decaying with a 0.35 s time constant through the #759 1:1 path. Any wheel input, click, key, zoom or edge ends it (fingers resting on the touchpad don't yet: the vendored winit binds no Wayland hold gestures); single-page paging, macOS (which sends its own momentum), the web, and Animation effects Off never glide. Unit tests for the physics and two headless view tests. Estimates unchanged. - **2026-10-13 (M3, native-open admission):** Later prepared replies stay queued while a modal or native picker owns the foreground. Picker completion, cancellation and abandonment wake retained opens. Regressions cover recipient ownership, exactly-once admission, source/history preservation and one-shot repaint requests. Estimates unchanged. @@ -641,6 +677,12 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 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 | M11: image downsampling measures physical resolution after page-local UserUnit scaling, including shared nested forms; synthetic measurement and optimization regressions. 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. | +| 2026-10-23 | minor | M10: Export all images and Save Image As apply soft-mask Decode intervals to PNG alpha before resizing, including inverted/subrange, indirect, packed and JPEG masks. Estimates unchanged. | +| 2026-10-23 | minor | M10: annotation appearances use unoccupied XObject names, preserving original page artwork and distinct appearances through inherited resources and print/reopen. Estimates unchanged. | | 2026-10-22 | minor | M4, #3916 partial: document edits and Undo/Redo cancel Organize drags. Pointer-release and save/reopen regressions check page order. Estimates unchanged. | | 2026-10-20 | minor | M4: initial-view window and interface options resolve indirect boolean ViewerPreferences values; synthetic true/false and save/reopen regressions retain defaults for malformed entries. Estimates unchanged. | | 2026-10-20 | minor | M6, #3502: form detection preserves comments across content streams; estimates unchanged. | @@ -686,8 +728,15 @@ Newest first: the date, what moved, and the new overall estimate. - **2026-10-13 (M7):** Edit a PDF ▸ Add content: Header and footer, Watermark and Background open an Add / Update / Remove menu (Update and Remove enabled only when the document has that mark), as Acrobat's Watermark control does. The catalogue gains `ToolItem::actions`, so any tool can list more commands (#1192). - **2026-10-18 (M8, #1350):** Published the history-preserving integration; simplified the inherited-form resource guard for Rust 1.96 Clippy. Formatting, 64 redaction tests, scoped Clippy, layering, assets and the scoped wasm check pass locally. CI remains pending; estimates unchanged. + +- 2026-10-18: M11 / #3227: mark target-version compatibility partial; document the unchecked newer-content limitation. + +- **2026-10-18 (PR #3227 integration):** M11: integrate upstream b0e5c6eb; retain partial target-version compatibility claims and effective-version reporting, correcting the borrowed version parser lifetime. Scope and estimates unchanged. + - **2026-10-12 (i18n):** Albanian (`sq`, Shqip) added: 2,343-entry catalog, month and weekday names, Preferences entry; not yet reviewed by a native speaker. - **2026-10-21 (M4, #3865):** A new bookmark records where the reader is: the top-left of the selected text, or the top of the window, as `/XYZ left top null` (zoom kept), instead of the page top (`Edit::AddBookmark` takes `left` and `top`; `bookmark_add` takes `top` and `left`; `PageInfo::xyz_for_view`). - **2026-10-18 (renderer, hayro patches 19/35):** A mesh shading (types 4–7) is sampled over its own triangles instead of the whole path: a BricsCAD plan with 310 one-triangle `sh` fills took 2 s per 1024 px tile at every zoom (and hit the 20 s watchdog on a busy machine); now about 70 ms, so zooming into dense topology drawings stays fluid. Estimates unchanged. + +- **2026-10-22 (PR #3227 integration):** M11: integrate upstream `80b7d215`; retain partial target-version compatibility and upstream content-boundary and image-size regression coverage. Scope and estimates unchanged. diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs index d0f23242d6..9ac3b21589 100644 --- a/apps/pdfcraft-cli/src/main.rs +++ b/apps/pdfcraft-cli/src/main.rs @@ -84,7 +84,24 @@ help and feedback: https://discord.gg/artcraft" ); fn main() -> ExitCode { - let args: Vec = std::env::args().skip(1).collect(); + // `std::env::args` panics on an argument that is not UTF-8 ("called `Result::unwrap()` on an + // `Err` value"), which a Latin-1 file name is, and it panics while collecting them: even + // `--help` ended that way. Such a name cannot be passed on either, since the paths here are + // `str`, so it is a usage error (photocraft#1108). + let mut args: Vec = Vec::new(); + for arg in std::env::args_os().skip(1) { + match arg.into_string() { + Ok(a) => args.push(a), + Err(bad) => { + let _ = writeln!( + std::io::stderr().lock(), + "pdfcraft-cli: `{}` is not valid UTF-8, so it cannot be used as an argument", + bad.to_string_lossy() + ); + return ExitCode::FAILURE; + } + } + } // ` --help` too; only right after the command, so a later `-h` (a value) still reaches it. let command = match args.get(1).map(String::as_str) { Some("--help" | "-h") => Some("--help"), @@ -169,7 +186,7 @@ fn version() -> Result<(), CliError> { /// found so scripts and CI can gate on it, and when the audit itself cannot run. fn audit(args: &[String]) -> Result<(), CliError> { validate_options("audit", args, AUDIT_OPTIONS)?; - let path = *positional(args).first().ok_or("audit: missing file")?; + let path = *positional(args, AUDIT_OPTIONS).first().ok_or("audit: missing file")?; let bytes = read(path)?; let password = flag(args, "--password"); let doc = pdfcraft_cos::Document::open_with_password(bytes.clone(), password).map_err(|e| format!("{path}: {e}"))?; @@ -208,6 +225,28 @@ struct OptionSpec { takes_value: bool, } +/// The options whose value is a file or folder. Only these refuse a value that is another +/// option: a file named `--password` is never what was meant, while a password can be anything +/// (`--password --page` is a password of `--page`, which tests/text.rs pins), and so can a title. +/// The way to name a file that does start with `-` is `./--name`, as `--root` already offered. +const PATH_VALUE_OPTIONS: [&str; 6] = ["--out", "--out-dir", "--json", "--root", "--control", "--script"]; + +/// Whether a token can be the value of a path option. Another option cannot: `--out --password` +/// used to write the PDF to a file called `--password` and exit 0 (photocraft#6944). +fn can_be_path_value(name: &str, value: Option<&str>) -> bool { + !PATH_VALUE_OPTIONS.contains(&name) || value.is_some_and(|v| !v.is_empty() && !v.starts_with("--")) +} + +/// The complaint about a path option whose value is missing or is another option. +fn needs_value(command: &str, name: &str, value: Option<&str>) -> String { + match value { + Some(v) if !v.is_empty() => { + format!("{command}: {name} needs a value and `{v}` is another option; write `./{v}` for a file of that name") + } + _ => format!("{command}: {name} needs a value"), + } +} + /// Refuse unknown options and malformed confinement flags before any file is read. fn validate_options(command: &str, args: &[String], specs: &[OptionSpec]) -> Result<(), CliError> { if specs.iter().any(|spec| spec.name == "--root") { @@ -230,9 +269,19 @@ fn validate_options(command: &str, args: &[String], specs: &[OptionSpec]) -> Res let Some(spec) = specs.iter().find(|spec| spec.name == arg) else { return Err(format!("{command}: unknown option --{name}").into()); }; - // Other missing or malformed values are left to the command, which reports them in its own - // terms (`text` and `extract` explain what a page value must be). - i += if spec.takes_value { 2 } else { 1 }; + // A file's name is not another option, which also closes the gap the walk left for + // those: an unknown option in that value position was skipped along with it. Other + // values, missing or malformed, are left to the command, which reports them in its + // own terms (`text` and `extract` explain what a page value must be). + i += if spec.takes_value { + let value = args.get(i + 1).map(String::as_str); + if !can_be_path_value(spec.name, value) { + return Err(needs_value(command, spec.name, value).into()); + } + 2 + } else { + 1 + }; } else { i += 1; } @@ -278,19 +327,24 @@ const UI_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--control", takes_value: #[cfg(feature = "mcp")] const MCP_OPTIONS: &[OptionSpec] = &[OptionSpec { name: "--root", takes_value: true }, OptionSpec { name: "--compact", takes_value: false }]; -fn positional(args: &[String]) -> Vec<&str> { +/// The arguments that are not options or their values. Which tokens are values is the spec's +/// answer: skipping the one after every option made `check-one --edit file.pdf` report "missing +/// file", the file having been read as `--edit`'s value. +fn positional<'a>(args: &'a [String], specs: &[OptionSpec]) -> Vec<&'a str> { let mut out = Vec::new(); - let mut skip = false; - for a in args { - if skip { - skip = false; - continue; + let mut i = 0; + while let Some(a) = args.get(i) { + match specs.iter().find(|spec| spec.name == a.as_str()) { + Some(spec) if spec.takes_value => i += 2, + // An option that takes no value, and an unknown one, which `validate_options` has + // already refused by the time this runs. + Some(_) => i += 1, + None if a.starts_with("--") => i += 1, + None => { + out.push(a.as_str()); + i += 1; + } } - if a.starts_with("--") { - skip = true; - continue; - } - out.push(a.as_str()); } out } @@ -301,7 +355,7 @@ fn read(path: &str) -> Result>, String> { fn info(args: &[String]) -> Result<(), CliError> { validate_options("info", args, INFO_OPTIONS)?; - let path = *positional(args).first().ok_or("info: missing file")?; + let path = *positional(args, INFO_OPTIONS).first().ok_or("info: missing file")?; let bytes = read(path)?; let password = flag(args, "--password"); // The renderer takes only the user password of R2–R4 files; `Session::open` takes either. @@ -354,7 +408,7 @@ fn info(args: &[String]) -> Result<(), CliError> { fn text(args: &[String]) -> Result<(), CliError> { validate_options("text", args, TEXT_OPTIONS)?; - let path = *positional(args).first().ok_or("text: missing file")?; + let path = *positional(args, TEXT_OPTIONS).first().ok_or("text: missing file")?; let mut selected_page = None; // Validate every supplied value before reading, retaining the first valid selection. let mut options = args.iter(); @@ -482,7 +536,13 @@ fn edit(args: &[String]) -> Result<(), CliError> { continue; } } - let value = args.get(i + 1).map(String::as_str).ok_or_else(|| format!("edit: {arg} needs a value"))?; + // `edit` parses its own options (they run in order), so `--out` is checked here in the + // same way: it wrote a PDF to a file called `--full`. + let value = args.get(i + 1).map(String::as_str); + if !can_be_path_value(arg, value) { + return Err(needs_value("edit", arg, value).into()); + } + let value = value.ok_or_else(|| format!("edit: {arg} needs a value"))?; match arg { "--out" => out = Some(value), "--password" => password = Some(value), @@ -569,7 +629,7 @@ fn file_stem(path: &str) -> String { fn combine(args: &[String]) -> Result<(), CliError> { validate_options("combine", args, COMBINE_OPTIONS)?; let out = flag(args, "--out").ok_or("combine: missing --out")?; - let inputs = positional(args); + let inputs = positional(args, COMBINE_OPTIONS); if inputs.len() < 2 { return Err("combine: give at least two input files".into()); } @@ -580,7 +640,7 @@ fn combine(args: &[String]) -> Result<(), CliError> { fn extract(args: &[String]) -> Result<(), CliError> { validate_options("extract", args, EXTRACT_OPTIONS)?; - let path = *positional(args).first().ok_or("extract: missing file")?; + let path = *positional(args, EXTRACT_OPTIONS).first().ok_or("extract: missing file")?; let out = flag(args, "--out").ok_or("extract: missing --out")?; let pages = page_list(flag(args, "--pages").ok_or("extract: missing --pages")?)?; let mut session = pdfcraft_engine::Session::new(); @@ -592,7 +652,7 @@ fn extract(args: &[String]) -> Result<(), CliError> { fn split(args: &[String]) -> Result<(), CliError> { validate_options("split", args, SPLIT_OPTIONS)?; use pdfcraft_engine::SplitBy; - let path = *positional(args).first().ok_or("split: missing file")?; + let path = *positional(args, SPLIT_OPTIONS).first().ok_or("split: missing file")?; let by = match (flag(args, "--every"), flag(args, "--before")) { (Some(n), None) => { // A count the splitter cannot hold is refused here, before the file is opened, @@ -637,7 +697,7 @@ fn parse_dpi(value: &str) -> Result { fn render(args: &[String]) -> Result<(), CliError> { validate_options("render", args, RENDER_OPTIONS)?; - let path = *positional(args).first().ok_or("render: missing file")?; + let path = *positional(args, RENDER_OPTIONS).first().ok_or("render: missing file")?; let page: usize = flag(args, "--page").unwrap_or("1").parse().ok().filter(|page| *page > 0).ok_or("bad --page: expected a positive page number")?; let page_index = page - 1; @@ -688,6 +748,10 @@ fn render(args: &[String]) -> Result<(), CliError> { pdfcraft_render::RenderWarning::ContentTruncated => { let _ = writeln!(std::io::stderr().lock(), "warning: page {page}: content past the page's safety budget was skipped"); } + pdfcraft_render::RenderWarning::ColourFallback => { + let _ = + writeln!(std::io::stderr().lock(), "warning: page {page}: a colour that could not be computed was painted in a fallback colour"); + } } } Ok(()) @@ -696,7 +760,7 @@ fn render(args: &[String]) -> Result<(), CliError> { /// Child-process body for `check`: prints one JSON line. fn check_one(args: &[String]) -> Result<(), CliError> { validate_options("check-one", args, CHECK_ONE_OPTIONS)?; - let path = *positional(args).first().ok_or("check-one: missing file")?; + let path = *positional(args, CHECK_ONE_OPTIONS).first().ok_or("check-one: missing file")?; let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("36"))?; let start = Instant::now(); let bytes = read(path)?; @@ -802,7 +866,7 @@ fn check(args: &[String]) -> Result<(), CliError> { ); let dpi = flag(args, "--dpi").unwrap_or("36").to_string(); parse_dpi(&dpi)?; - let files = collect(&positional(args)); + let files = collect(&positional(args, CHECK_OPTIONS)); if files.is_empty() { return Err("check: no PDF files found".into()); } @@ -985,9 +1049,9 @@ fn run(args: &[String]) -> Result<(), CliError> { } return Ok(()); } - let tool = *positional(args).first().ok_or("run: missing tool name (see `pdfcraft-cli tools`)")?; + let tool = *positional(args, RUN_OPTIONS).first().ok_or("run: missing tool name (see `pdfcraft-cli tools`)")?; let mut obj = serde_json::Map::new(); - for kv in positional(args).iter().skip(1) { + for kv in positional(args, RUN_OPTIONS).iter().skip(1) { let (k, v) = kv.split_once('=').ok_or(format!("run: expected key=value, got {kv:?}"))?; let value = serde_json::from_str(v).unwrap_or_else(|_| serde_json::Value::String(v.to_string())); obj.insert(k.to_string(), value); @@ -1053,7 +1117,7 @@ fn ui(args: &[String]) -> Result<(), CliError> { // Refused rather than truncated, which would quietly reach some other port. let port = u16::try_from(port).map_err(|_| format!("{file}: {port} is not a port number"))?; let token = info["token"].as_str().ok_or(format!("{file}: no token"))?; - let pos = positional(args); + let pos = positional(args, UI_OPTIONS); let method = *pos.first().ok_or("ui: missing method (state, inspect, click, drag, type, key, command, commands, set, open, screenshot)")?; let method = if method.starts_with("ui.") { method.to_string() } else { format!("ui.{method}") }; let mut params = serde_json::Map::new(); diff --git a/apps/pdfcraft-cli/tests/it/options.rs b/apps/pdfcraft-cli/tests/it/options.rs index 9576d59a8f..7955157e28 100644 --- a/apps/pdfcraft-cli/tests/it/options.rs +++ b/apps/pdfcraft-cli/tests/it/options.rs @@ -27,3 +27,89 @@ fn non_edit_commands_reject_unknown_options_before_io() { assert!(stderr.contains("unknown option") && stderr.contains(unknown), "{args:?}: {stderr}"); } } + +/// photocraft#6944: an option took the next token whatever it was, so a line with a value missing +/// in the middle of it did something else entirely — `extract in.pdf --pages 1 --out --password` +/// wrote the PDF to a file called `--password` and exited 0. The unknown option hidden in a value +/// position passed the check above too, since the walk skipped it along with the option. +/// +/// Only the options whose value is a file or folder refuse one: a password or a title can be any +/// string, `--page` included, which `text`'s own tests pin and this does not change. +#[test] +fn a_file_option_refuses_a_value_that_is_another_option() { + let dir = std::env::temp_dir().join(format!("pdfcraft-cli-values-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("temp dir"); + for (option, args) in [ + ("--out", vec!["extract", "missing.pdf", "--pages", "1", "--out", "--password"]), + ("--out", vec!["combine", "a.pdf", "b.pdf", "--out", "--nonsense"]), + ("--out", vec!["render", "missing.pdf", "--out", "--dpi", "96"]), + ("--out-dir", vec!["split", "missing.pdf", "--every", "2", "--out-dir", "--password"]), + ("--json", vec!["check", "missing.pdf", "--json", "--dpi"]), + ("--control", vec!["ui", "--control", "--out", "state"]), + // `edit` parses its own options, in the order given, and had the same gap. + ("--out", vec!["edit", "missing.pdf", "--out", "--full"]), + ] { + let out = Command::new(BIN).current_dir(&dir).args(&args).output().expect("run cli"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(!out.status.success(), "{args:?} unexpectedly succeeded"); + assert!(stderr.contains(&format!("{option} needs a value")), "{args:?}: {stderr}"); + assert!(stderr.contains("another option"), "{args:?}: {stderr}"); + } + assert_eq!(std::fs::read_dir(&dir).expect("read dir").count(), 0, "nothing was written"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// And a file whose name really does start with `-` is still reachable, the way the shell reaches +/// one and `--root` already documented: `./--name`. The error is then about the input file, which +/// is as far as these lines get here. +#[test] +fn a_file_whose_name_starts_with_dashes_is_still_reachable() { + // The error is about the file the command could not open, so the test asks which file it + // names -- not how the OS words "not found", which differs on Windows (#3849 review). + for (names, args) in [ + ("missing.pdf", vec!["extract", "missing.pdf", "--pages", "1", "--out", "./--dash.pdf"]), + ("missing.pdf", vec!["edit", "missing.pdf", "--out", "./--dash.pdf"]), + ("./--dash.pdf", vec!["info", "./--dash.pdf"]), + ] { + let out = Command::new(BIN).args(&args).output().expect("run cli"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(!out.status.success(), "{args:?}: no such file either way"); + assert!(stderr.contains(names), "{args:?}: the error is about {names}: {stderr}"); + // Not a usage error: the `./--dash.pdf` reached the command as a file name. + assert!(!stderr.contains("needs a value") && !stderr.contains("unknown option"), "{args:?}: {stderr}"); + } +} + +/// The token after an option that takes no value is not its value: `check-one --edit file.pdf` +/// reported "check-one: missing file", the file having been read as `--edit`'s operand. +#[test] +fn a_flag_that_takes_no_value_does_not_swallow_the_file() { + for args in [vec!["check-one", "--edit", "missing.pdf"], vec!["check-one", "missing.pdf", "--edit"]] { + let out = Command::new(BIN).args(&args).output().expect("run cli"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(!out.status.success(), "{args:?}"); + // Which file the error is about, not how the OS says it is missing (#3849 review). + assert!(stderr.contains("missing.pdf"), "{args:?}: the error is about the file: {stderr}"); + assert!(!stderr.contains("missing file"), "the file was found, and then not found: {stderr}"); + assert!(!stderr.contains("needs a value"), "{args:?}: {stderr}"); + } +} + +/// photocraft#1108: an argument that is not UTF-8 panicked in `std::env::args` ("called +/// `Result::unwrap()` on an `Err` value"), while collecting the arguments — so it panicked even +/// for `--help`. Such a name cannot be used here, where paths are `str`, so it is a usage error. +#[cfg(unix)] +#[test] +fn an_argument_that_is_not_utf8_is_an_error_not_a_panic() { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + + for args in [vec![OsStr::new("info"), OsStr::from_bytes(b"/tmp/caf\xe9.pdf")], vec![OsStr::new("--help"), OsStr::from_bytes(b"caf\xe9")]] { + let out = Command::new(BIN).args(&args).output().expect("run cli"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert_eq!(out.status.code(), Some(1), "an error, not a panic (101): {stderr}"); + assert!(stderr.contains("not valid UTF-8"), "{stderr}"); + assert!(!stderr.contains("panicked"), "{stderr}"); + } +} diff --git a/contributors/people.toml b/contributors/people.toml index 4f2b22775c..dd75a07314 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -176,3 +176,7 @@ real_name = "Nick Nyssen" [people.agent-reed] sync_github_name = true + +[people.dotCooCoo] +real_name = "Robert Lee" +sync_github_name = true diff --git a/crates/a11y/src/alt.rs b/crates/a11y/src/alt.rs index 87cfce5acb..720b8a6a1e 100644 --- a/crates/a11y/src/alt.rs +++ b/crates/a11y/src/alt.rs @@ -211,6 +211,25 @@ fn close(marks: &mut Vec, matching: &mut usize, out: &mut HashMap Option<[f64; 4]> { + let r = op.name(0).and_then(|n| xobjects.get(n)).and_then(Object::as_ref)?; + Some(match &*doc.get(r) { + Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Form") => { + let bb: Vec = + s.dict.get(b"BBox").and_then(|b| b.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default(); + let m = Matrix::from_operands(s.dict.get(b"Matrix").and_then(|m| m.as_array()).map(Vec::as_slice).unwrap_or(&[])) + .unwrap_or(Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])); + match bb[..] { + [a, b, c, d] => m.then(top).bbox([a, b, c, d]), + _ => return None, + } + } + _ => top.bbox([0.0, 0.0, 1.0, 1.0]), + }) +} + /// Where each of the marked content `ids` is drawn on a page (images, forms and paths), in one pass /// over the page. What a sequence draws counts for every sequence of `ids` around it too. pub(crate) fn content_bboxes(doc: &Document, page: &Dict, ids: &HashSet) -> HashMap { @@ -274,20 +293,7 @@ pub(crate) fn content_bboxes(doc: &Document, page: &Dict, ids: &HashSet) -> path.clear(); } b"Do" if inside => { - let Some(r) = op.name(0).and_then(|n| xobjects.get(n)).and_then(Object::as_ref) else { continue }; - let unit = match &*doc.get(r) { - Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Form") => { - let bb: Vec = - s.dict.get(b"BBox").and_then(|b| b.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default(); - let m = Matrix::from_operands(s.dict.get(b"Matrix").and_then(|m| m.as_array()).map(Vec::as_slice).unwrap_or(&[])) - .unwrap_or(Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])); - match bb[..] { - [a, b, c, d] => m.then(&top).bbox([a, b, c, d]), - _ => continue, - } - } - _ => top.bbox([0.0, 0.0, 1.0, 1.0]), - }; + let Some(unit) = xobject_bbox(doc, &xobjects, &op, &top) else { continue }; draw(&mut marks, unit); } b"BI" if inside => draw(&mut marks, top.bbox([0.0, 0.0, 1.0, 1.0])), diff --git a/crates/a11y/src/autotag.rs b/crates/a11y/src/autotag.rs index 4462025cf5..9b65f6972b 100644 --- a/crates/a11y/src/autotag.rs +++ b/crates/a11y/src/autotag.rs @@ -132,6 +132,38 @@ fn harmless_inside(op: &[u8]) -> bool { ) } +/// The `(BT, ET)` operator index pairs of each text object that shows text, or why the page cannot be tagged. +fn text_spans(ops: &[Op], index: usize) -> Result, AutoTagError> { + let mut start = None; + let mut has_text = false; + let mut spans = Vec::new(); + for (at, op) in ops.iter().enumerate() { + match op.op.as_slice() { + b"BT" if start.is_none() => { + start = Some(at); + has_text = false; + } + b"ET" => { + let Some(first) = start.take() else { return Err(unsupported(index, "unmatched ET operator")) }; + if has_text { + spans.push((first, at)); + if spans.len() > MAX_TEXT_OBJECTS { + return Err(unsupported(index, "too many text objects (maximum 4096)")); + } + } + } + b"Tj" | b"TJ" | b"'" | b"\"" if start.is_some() => has_text = true, + name if start.is_some() && harmless_inside(name) => {} + name if start.is_none() && harmless_outside(name) => {} + _ => return Err(unsupported(index, format!("unsupported or painted operator {}", String::from_utf8_lossy(&op.op)))), + } + } + if start.is_some() { + return Err(unsupported(index, "unterminated BT text object")); + } + Ok(spans) +} + fn plan(doc: &Document) -> Result, AutoTagError> { let root = doc.root().ok_or(AutoTagError::NoCatalog)?; let catalog = doc.get(root); @@ -163,33 +195,7 @@ fn plan(doc: &Document) -> Result, AutoTagError> { if parsed.skipped > 0 || parsed.ops.len() > MAX_OPERATORS { return Err(unsupported(index, "content has malformed or too many operators")); } - let mut start = None; - let mut has_text = false; - let mut spans = Vec::new(); - for (at, op) in parsed.ops.iter().enumerate() { - match op.op.as_slice() { - b"BT" if start.is_none() => { - start = Some(at); - has_text = false; - } - b"ET" => { - let Some(first) = start.take() else { return Err(unsupported(index, "unmatched ET operator")) }; - if has_text { - spans.push((first, at)); - if spans.len() > MAX_TEXT_OBJECTS { - return Err(unsupported(index, "too many text objects (maximum 4096)")); - } - } - } - b"Tj" | b"TJ" | b"'" | b"\"" if start.is_some() => has_text = true, - name if start.is_some() && harmless_inside(name) => {} - name if start.is_none() && harmless_outside(name) => {} - _ => return Err(unsupported(index, format!("unsupported or painted operator {}", String::from_utf8_lossy(&op.op)))), - } - } - if start.is_some() { - return Err(unsupported(index, "unterminated BT text object")); - } + let spans = text_spans(&parsed.ops, index)?; total = total.saturating_add(spans.len()); out.push(Plan { page: page.obj, ops: parsed.ops, spans }); } diff --git a/crates/annot/README.md b/crates/annot/README.md index 8ba3ca8c4e..8108201835 100644 --- a/crates/annot/README.md +++ b/crates/annot/README.md @@ -9,10 +9,12 @@ Comments (annotations), ISO 32000-2 §12.5. Layer L3, depends on `pdfcraft-cos`, line and arrow (`Line` with `/LE`), freehand drawing (`Ink`) and text box (`FreeText`). - **Appearance streams** (`appearance::build`) generated from the annotation dictionary, so new and restyled comments look the same in every viewer. Highlights multiply (`/BM /Multiply`). + A line's leader lines (`/LL`, `/LLE`, `/LLO`) and caption (`/Cap`, `/CP`, `/CO`) are drawn as + ISO 32000-2 Table 178 places them, the caption in black Helvetica 10. Note icons are PdfCraft's own drawings. Text boxes use standard Helvetica (WinAnsi), with line breaking by approximate Helvetica proportions (no font program is bundled). Text it can't - draw (in a text box, callout, Fill & Sign text, measurement label or dynamic stamp's by-line; - `appearance::undrawable_text`) is kept as typed and drawn as `?`, and the comment is marked with + draw (in a text box, callout, Fill & Sign text, line caption, measurement label or dynamic + stamp's by-line; `appearance::undrawable_text`) is kept as typed and drawn as `?`, and the comment is marked with the first such character (`pdfcraft_fonts::UNDRAWN_MARK`) so the engine reports the edit and flattening refuses to make it permanent. Two refusals (`AnnotError::Undrawable`): a redaction mark's overlay text, which becomes page content when applied; and a restyle or resize of a @@ -51,6 +53,11 @@ deterministic; the engine supplies them. signature) and PdfCraft's rubber stamps counterrotate their appearance on rotated pages, so the picture or label reads upright as the page is displayed. Restyling a picture stamp keeps that appearance matrix. The rotation is the page's `/Rotate` at placement. +- Sticky notes and file attachments are not counterrotated: they carry the NoRotate flag and + their icon is drawn in an unturned 20 × 20 box. A viewer keeps them upright on a turned page, + about the upper-left corner of `/Rect` (ISO 32000-2 §12.5.3). PdfCraft's renderer does so + (vendored hayro-interpret patch 37), and `Summary::no_rotate` tells the UI where the icon is + drawn. ## Not yet diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 66a86aa30f..530438db6d 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -180,23 +180,42 @@ fn interior_color(d: &Dict, fallback: Option) -> Option { color(d, b"IC").flatten().or(fallback) } +/// The border width (ISO 32000-2 §12.5.4): `/BS /W` when there is a border style dictionary, which +/// makes `/Border` ignored (Table 166), else the third element of `/Border [hr vr w [dash]]`, read +/// whether or not the optional fourth element, a dash array, follows. 1 when neither says, and +/// when the number given is negative or not finite. pub(crate) fn border_width(d: &Dict) -> f64 { - if let Some(w) = d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()) { - return w.max(0.0); - } - match nums(d, b"Border") { - Some(b) if b.len() >= 3 => b[2].max(0.0), - _ => 1.0, - } + let width = match d.get(b"BS").and_then(Object::as_dict) { + Some(bs) => bs.get(b"W").and_then(Object::as_f64), + None => d.get(b"Border").and_then(Object::as_array).and_then(|b| b.get(2)).and_then(Object::as_f64), + }; + width.filter(|w| w.is_finite() && *w >= 0.0).unwrap_or(1.0) } -/// Dash pattern operator for a dashed border, if any. +/// Elements a dash array may have before it is drawn solid instead: the lengths are written into +/// the appearance as they are, so a file can't make it arbitrarily large. +const MAX_DASH: usize = 64; + +/// A dash array (§8.4.3.6) as drawn: every element finite and non-negative, not all zero (that +/// pattern is invalid) and at most [`MAX_DASH`] long. `None` for anything else, which is drawn solid. +fn dash_array(o: &Object) -> Option> { + let a = o.as_array().filter(|a| !a.is_empty() && a.len() <= MAX_DASH)?; + let v: Vec = a.iter().map(Object::as_f64).collect::>()?; + (v.iter().all(|x| x.is_finite() && *x >= 0.0) && v.iter().any(|x| *x > 0.0)).then_some(v) +} + +/// Dash pattern operator for a dashed border, if any: `/BS /S /D` with its `/D` (default `[3]`, +/// Table 168), or, without `/BS`, the dash array that may follow the width in `/Border` (Table 166). fn dash(d: &Dict) -> String { - let Some(bs) = d.get(b"BS").and_then(|b| b.as_dict()) else { return String::new() }; - if bs.name(b"S") != Some(b"D") { - return String::new(); - } - let arr = nums(bs, b"D").filter(|a| !a.is_empty() && a.iter().all(|x| *x >= 0.0)).unwrap_or_else(|| vec![3.0]); + let arr = match d.get(b"BS").and_then(Object::as_dict) { + Some(bs) if bs.name(b"S") != Some(b"D") => return String::new(), + Some(bs) => match bs.get(b"D") { + None => Some(vec![3.0]), + Some(o) => dash_array(o), + }, + None => d.get(b"Border").and_then(Object::as_array).and_then(|b| b.get(3)).and_then(dash_array), + }; + let Some(arr) = arr else { return String::new() }; format!("[{}] 0 d\n", arr.iter().map(|x| n(*x)).collect::>().join(" ")) } @@ -283,13 +302,14 @@ fn text_turn(d: &Dict) -> i64 { /// The first text [`build`] would draw in the standard fonts (Helvetica, WinAnsiEncoding) that /// they can't, which would come out as `?`: what it is (for the message) and the character. The -/// text of a text box, callout or Fill & Sign text, a measurement's label, a dynamic stamp's -/// "By …" line, and a redaction mark's overlay text (drawn when it is applied). `None` when all of +/// text of a text box, callout or Fill & Sign text, a line's caption, a measurement's label, a +/// dynamic stamp's "By …" line, and a redaction mark's overlay text (drawn when it is applied). `None` when all of /// it can be drawn. pub fn undrawable_text(d: &Dict) -> Option<(&'static str, char)> { let text = |key: &[u8]| d.get(key).and_then(Object::as_string).map(PdfString::to_text); let (what, text) = match d.name(b"Subtype")? { b"FreeText" => ("the comment's text", text(b"Contents")?), + b"Line" if !d.contains(b"PCMeasureValue") => ("the line's caption", line_caption(d)?), b"Line" | b"PolyLine" | b"Polygon" => ("the measurement label", text(b"PCMeasureValue")?), b"Stamp" if matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) => ("the stamp's \"By\" line", text(b"PCByLine")?), // Drawn in Helvetica when the redaction is applied: refused when it is marked. @@ -299,6 +319,55 @@ pub fn undrawable_text(d: &Dict) -> Option<(&'static str, char)> { Some((what, first_undrawable(&text)?)) } +/// `/Font` resources naming `/Helv`: standard Helvetica with WinAnsiEncoding. +fn helvetica_resource() -> Object { + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type1")); + font.set(b"BaseFont".to_vec(), Object::name("Helvetica")); + font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + let mut fonts = Dict::new(); + fonts.set(b"Helv".to_vec(), Object::Dict(font)); + Object::Dict(fonts) +} + +/// Where a caret and its paragraph symbol go within `rect`: the box the caret is drawn in, and for +/// `/Sy /P` the symbol's baseline origin and font size (ISO 32000-2 Table 183). +/// +/// The caret lies within `/Rect` less `/RD` (left, top, right, bottom), when `/RD` is four finite, +/// non-negative numbers that leave a box; otherwise within `/Rect`. The ¶ sits to the right of the +/// caret, its height a little over half the caret's, in the room `/RD` leaves on the right; when +/// that is too narrow, the caret gives up to half its width and the symbol shrinks to what is left, +/// so both stay inside `/Rect` (the form's `/BBox`). +fn caret_layout(d: &Dict, rect: [f64; 4]) -> ([f64; 4], Option<(f64, f64, f64)>) { + let rd = nums(d, b"RD").filter(|r| r.len() == 4 && r.iter().all(|x| x.is_finite() && *x >= 0.0)); + let mut caret = match rd { + Some(rd) => [rect[0] + rd[0], rect[1] + rd[3], rect[2] - rd[2], rect[3] - rd[1]], + None => rect, + }; + if !(caret[2] > caret[0] && caret[3] > caret[1]) { + caret = rect; + } + if d.name(b"Sy") != Some(b"P") { + return (caret, None); + } + let size = (caret[3] - caret[1]) * 0.6; + let (gap, glyph) = (size * 0.1, text_width("\u{b6}", size)); + let need = gap + glyph; + let room = rect[2] - caret[2]; + if room < need { + caret[2] -= (need - room).min((caret[2] - caret[0]) * 0.5); + } + let room = rect[2] - caret[2]; + let scale = if need > room { room / need } else { 1.0 }; + let size = size * scale; + if !(size.is_finite() && size > 0.0) { + return (caret, None); + } + // The pilcrow descends about a sixth of the size below its baseline. + (caret, Some((caret[2] + gap * scale, caret[1] + size * 0.18, size))) +} + /// Draw the normal appearance of an annotation, or `None` if this subtype/variant isn't supported. /// Text the standard fonts can't draw comes out as `?`: [`undrawable_text`] says which, and /// [`crate::set_appearance`] marks the annotation with it (`pdfcraft_fonts::UNDRAWN_MARK`). @@ -321,6 +390,8 @@ pub fn build(d: &Dict) -> Option { let w = border_width(d); let mut res = Dict::new(); let mut c = String::new(); + // Content after `c` that isn't UTF-8 (WinAnsi text). + let mut tail: Vec = Vec::new(); let markup = matches!(subtype.as_slice(), b"Highlight" | b"Underline" | b"StrikeOut" | b"Squiggly"); if let Some(gs) = ext_gstate(opacity, subtype == b"Highlight") { res.set(b"ExtGState".to_vec(), Object::Dict(gs)); @@ -445,13 +516,29 @@ pub fn build(d: &Dict) -> Option { return None; } let fill = interior_color(d, Some(col)).unwrap_or(col); + let layout = line_layout(d, [(l[0], l[1]), (l[2], l[3])], w); + let [p, q] = layout.line; c.push_str(&format!("{}{}{} w 1 J 1 j\n{}", rg_stroke(col), rg(fill), n(w), dash(d))); - c.push_str(&format!("{} {} m {} {} l S\n[] 0 d\n", n(l[0]), n(l[1]), n(l[2]), n(l[3]))); - for (end, (tip, from)) in ends.iter().zip([((l[0], l[1]), (l[2], l[3])), ((l[2], l[3]), (l[0], l[1]))]) { + for [s, e] in layout.leaders.iter().chain(&layout.strokes).chain(&layout.tick) { + c.push_str(&format!("{} {} m {} {} l S\n", n(s.0), n(s.1), n(e.0), n(e.1))); + } + c.push_str("[] 0 d\n"); + for (end, (tip, from)) in ends.iter().zip([(p, q), (q, p)]) { if !line_end(&mut c, end, tip, from, w) { return None; } } + if let Some((text, m)) = layout.caption { + // The caption is black, in the standard font the measurement label uses. + tail.extend( + format!("q 0 g BT /Helv {} Tf {} {} {} {} {} {} Tm ", n(CAPTION_SIZE), n(m[0]), n(m[1]), n(m[2]), n(m[3]), n(m[4]), n(m[5])) + .bytes(), + ); + // WinAnsi bytes go into the stream as-is, not through UTF-8. + tail.extend(literal(&win_ansi(&text))); + tail.extend_from_slice(b" Tj ET Q\n"); + res.set(b"Font".to_vec(), helvetica_resource()); + } } b"Polygon" | b"PolyLine" => { let v = nums(d, b"Vertices").filter(|v| v.len() >= 4 && v.len() % 2 == 0 && v.iter().all(|x| x.is_finite()))?; @@ -512,7 +599,8 @@ pub fn build(d: &Dict) -> Option { b"Caret" => { // A filled caret: two curved flanks meeting at the top centre. let col = stroke.unwrap_or([0.0, 0.47, 0.84]); - let [x0, y0, x1, y1] = rect; + let (caret, pilcrow) = caret_layout(d, rect); + let [x0, y0, x1, y1] = caret; let (cx, h) = ((x0 + x1) / 2.0, y1 - y0); c.push_str(&format!( "{}{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c h f\n", @@ -532,11 +620,22 @@ pub fn build(d: &Dict) -> Option { n(x1), n(y0) )); + if let Some((x, y, size)) = pilcrow { + // `/Sy /P`: a new paragraph symbol beside the caret (Table 183), in its colour. + // 0xB6 is the pilcrow in WinAnsiEncoding. + let mut out = c.into_bytes(); + out.extend(format!("BT /Helv {} Tf {} {} Td (", n(size), n(x), n(y)).bytes()); + out.push(0xB6); + out.extend_from_slice(b") Tj ET\n"); + res.set(b"Font".to_vec(), helvetica_resource()); + return Some(form(rect, &out, res)); + } } b"Ink" => { let col = stroke?; let list = d.get(b"InkList")?.as_array()?; - c.push_str(&format!("{}{} w 1 J 1 j\n", rg_stroke(col), n(w))); + // `/BS` gives an ink stroke its width and dash pattern (Table 182). + c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(col), n(w), dash(d))); for s in list { let pts: Vec = s.as_array()?.iter().map(|o| o.as_f64()).collect::>()?; let pts: Vec<(f64, f64)> = pts.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); @@ -670,7 +769,8 @@ pub fn build(d: &Dict) -> Option { } let kind = crate::StampKind::from_name(other)?; let by = d.get(b"PCByLine").and_then(|o| o.as_string()).map(PdfString::to_text); - return Some(turned(stamp(kind, draw, stroke.unwrap_or(kind.color()), by.as_deref(), opacity, res))); + let sign_here = kind.group() == crate::StampGroup::SignHere; + return Some(turned(stamp(kind.label(), sign_here, draw, stroke.unwrap_or(kind.color()), by.as_deref(), opacity, res))); } } if turn != 0 { @@ -773,14 +873,7 @@ pub fn build(d: &Dict) -> Option { y -= size * 1.2; } out.extend_from_slice(b"ET\n"); - let mut font = Dict::new(); - font.set(b"Type".to_vec(), Object::name("Font")); - font.set(b"Subtype".to_vec(), Object::name("Type1")); - font.set(b"BaseFont".to_vec(), Object::name("Helvetica")); - font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - let mut fonts = Dict::new(); - fonts.set(b"Helv".to_vec(), Object::Dict(font)); - res.set(b"Font".to_vec(), Object::Dict(fonts)); + res.set(b"Font".to_vec(), helvetica_resource()); let mut stream = form(full, &out, res); if let Some(m) = matrix { stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect())); @@ -792,6 +885,7 @@ pub fn build(d: &Dict) -> Option { // PdfCraft measurement captions are kept separate from the comment's free-form text. // A restyle regenerates the path and its value together. let mut out = c.into_bytes(); + out.extend(tail); if let Some(value) = d.get(b"PCMeasureValue").and_then(Object::as_string) { let value = value.to_text(); if value.chars().count() <= 256 && matches!(subtype.as_slice(), b"Line" | b"PolyLine" | b"Polygon") { @@ -803,22 +897,285 @@ pub fn build(d: &Dict) -> Option { // WinAnsi bytes (e.g. 0xB2 for "²") go into the stream as-is, not through UTF-8. out.extend(literal(&win_ansi(&value))); out.extend_from_slice(b" Tj ET\n"); - let mut font = Dict::new(); - font.set(b"Type".to_vec(), Object::name("Font")); - font.set(b"Subtype".to_vec(), Object::name("Type1")); - font.set(b"BaseFont".to_vec(), Object::name("Helvetica")); - font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - let mut fonts = Dict::new(); - fonts.set(b"Helv".to_vec(), Object::Dict(font)); - res.set(b"Font".to_vec(), Object::Dict(fonts)); + res.set(b"Font".to_vec(), helvetica_resource()); } } Some(form(rect, &out, res)) } +/// The label and colour of a standard rubber stamp name (ISO 32000-2 Table 184), for a stamp +/// another program wrote without an appearance. Names are matched byte for byte, as PDF names +/// are compared (§7.3.5). Eight are stamps of PdfCraft's palette; the other six take a label of +/// their own and the palette's colour for their meaning. +fn standard_stamp(name: &[u8]) -> Option<(&'static str, Rgb)> { + use crate::StampKind::*; + let palette = [Approved, NotApproved, NotForPublicRelease, Confidential, Final, ForComment, Draft, ForPublicRelease]; + if let Some(kind) = palette.into_iter().find(|k| k.name().as_bytes() == name) { + return Some((kind.label(), kind.color())); + } + let (label, like) = match name { + b"Experimental" => ("EXPERIMENTAL", Draft), + b"AsIs" => ("AS IS", Draft), + b"Expired" => ("EXPIRED", NotApproved), + b"Sold" => ("SOLD", Approved), + b"Departmental" => ("DEPARTMENTAL", Draft), + b"TopSecret" => ("TOP SECRET", Confidential), + _ => return None, + }; + Some((label, like.color())) +} + +/// The neutral grey of a stamp whose name has no drawing. +const PLACEHOLDER_GREY: Rgb = [0.45, 0.45, 0.45]; + +/// The display appearance of a rubber stamp another program wrote without one: the stamp its +/// `/Name` names (Draft when there is none, Table 184's default) in its `/C` colour or the +/// stamp's own, or for any other name a thin grey frame with both diagonals, so the stamp stays +/// visible and can be selected. The frame is grey whatever the `/C`: it stands in for a stamp +/// that cannot be drawn, so it takes none of that stamp's colour. The name is never drawn as text. +/// +/// For display only ([`crate::with_missing_appearances`]): [`build`] declines these stamps, so +/// edits (restyling, flattening) never write this drawing into the file. `None` for any other +/// annotation, one of PdfCraft's own stamps, a dictionary with an `/AP` (a stamp's own artwork is +/// never replaced; the display copy passes the dictionary without an `/AP` that has no normal +/// appearance), or a `/Rect` that isn't finite or is under a point wide or high. +pub(crate) fn display_stamp(d: &Dict) -> Option { + let own = matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) || d.contains(b"PCPicture") || d.contains(b"PCOutline"); + if d.name(b"Subtype") != Some(b"Stamp") || own || d.contains(b"AP") { + return None; + } + let rect = nums(d, b"Rect")?; + let [a, b, c, e] = rect.as_slice() else { return None }; + let rect = [a.min(*c), b.min(*e), a.max(*c), b.max(*e)]; + if !rect.iter().all(|v| v.is_finite()) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { + return None; + } + let opacity = d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0).clamp(0.0, 1.0); + // A malformed `/C` falls back to the stamp's own colour rather than hiding the stamp. + let stroke = color(d, b"C").flatten(); + let mut res = Dict::new(); + if let Some(gs) = ext_gstate(opacity, false) { + res.set(b"ExtGState".to_vec(), Object::Dict(gs)); + } + // A stamp whose intent (`/IT`, PDF 2.0) is an image or a snapshot has no `/Name` to default. + let stamp_intent = d.name(b"IT").is_none_or(|it| it == b"Stamp"); + let name = d.name(b"Name").or(stamp_intent.then_some(b"Draft".as_slice())); + if let Some((label, col)) = name.and_then(standard_stamp) { + return Some(stamp(label, false, rect, stroke.unwrap_or(col), None, opacity, res)); + } + let [x0, y0, x1, y1] = rect; + let (a0, b0, a1, b1) = (x0 + 0.5, y0 + 0.5, x1 - 0.5, y1 - 0.5); + let mut c = String::new(); + if opacity < 1.0 { + c.push_str("/GS0 gs\n"); + } + c.push_str(&format!( + "{}1 w\n{} {} {} {} re\n{} {} m {} {} l\n{} {} m {} {} l\nS\n", + // Grey whatever the `/C`: the placeholder is not the stamp, so it takes none of its colour. + rg_stroke(PLACEHOLDER_GREY), + n(a0), + n(b0), + n(a1 - a0), + n(b1 - b0), + n(a0), + n(b0), + n(a1), + n(b1), + n(a0), + n(b1), + n(a1), + n(b0) + )); + Some(form(rect, c.as_bytes(), res)) +} + +/// Size of a line's caption, as of a measurement's label. +const CAPTION_SIZE: f64 = 10.0; + +/// Characters of a line's caption drawn at most, as for a measurement's label. +const MAX_CAPTION_CHARS: usize = 256; + +/// Leader lengths and caption offsets (Table 178) are held within this many points (200 inches, +/// the largest page side ISO 32000-2 Annex C allows), so an absurd value can't overflow. +const MAX_LINE_EXTENT: f64 = 14_400.0; + +/// A Table 178 length or offset: finite and within [`MAX_LINE_EXTENT`], 0 when absent or not one. +fn line_length(v: Option) -> f64 { + v.filter(|v| v.is_finite()).map_or(0.0, |v| v.clamp(-MAX_LINE_EXTENT, MAX_LINE_EXTENT)) +} + +/// The caption a Line with `/Cap true` shows (ISO 32000-2 Table 178): its `/Contents`, else the +/// plain text of its `/RC`, on one line and cut to [`MAX_CAPTION_CHARS`]. `None` without a +/// caption, and for PdfCraft's own measurements, whose label (`/PCMeasureValue`) is the caption. +fn line_caption(d: &Dict) -> Option { + if !matches!(d.get(b"Cap"), Some(Object::Bool(true))) || d.contains(b"PCMeasureValue") { + return None; + } + let text = |key: &[u8]| d.get(key).and_then(Object::as_string).map(PdfString::to_text); + let raw = text(b"Contents").filter(|t| !t.trim().is_empty()).or_else(|| text(b"RC").map(|rc| rich_plain(&rc)))?; + let words: Vec<&str> = raw.split_whitespace().collect(); + let caption: String = words.join(" ").chars().filter(|c| !c.is_control()).take(MAX_CAPTION_CHARS).collect(); + (!caption.is_empty()).then_some(caption) +} + +/// The text of rich text (§12.7.4.3, XHTML): its markup dropped, the five XML entities and +/// numeric character references read, block ends read as spaces. +fn rich_plain(rc: &str) -> String { + let mut out = String::new(); + let mut tag: Option = None; + // Rich text past this is not caption material; it bounds the work. + for ch in rc.chars().take(MAX_CAPTION_CHARS * 64) { + match (&mut tag, ch) { + (None, '<') => tag = Some(String::new()), + (Some(name), '>') => { + let name = + name.trim_start_matches('/').split(|c: char| c.is_whitespace() || c == '/').next().unwrap_or_default().to_ascii_lowercase(); + if matches!(name.as_str(), "p" | "br" | "div" | "li") { + out.push(' '); + } + tag = None; + } + (Some(name), c) => { + if name.len() < 16 { + name.push(c); + } + } + (None, c) => out.push(c), + } + } + let mut text = String::new(); + let mut rest = out.as_str(); + while let Some(at) = rest.find('&') { + text.push_str(rest.get(..at).unwrap_or_default()); + let after = rest.get(at + 1..).unwrap_or_default(); + let entity = after.find(';').filter(|&end| end <= 10).and_then(|end| Some((after.get(..end)?, end))); + let decoded = entity.and_then(|(name, end)| { + let c = match name { + "amp" => '&', + "lt" => '<', + "gt" => '>', + "quot" => '"', + "apos" => '\'', + _ => { + let num = name.strip_prefix('#')?; + let code = match num.strip_prefix(['x', 'X']) { + Some(hex) => u32::from_str_radix(hex, 16).ok()?, + None => num.parse().ok()?, + }; + char::from_u32(code)? + } + }; + Some((c, end)) + }); + match decoded { + Some((c, end)) => { + text.push(c); + rest = after.get(end + 1..).unwrap_or_default(); + } + None => { + text.push('&'); + rest = after; + } + } + } + text.push_str(rest); + text +} + +/// Where a Line annotation's parts go (ISO 32000-2 §12.5.6.7, Table 178). +#[derive(Debug, Default)] +struct LineLayout { + /// The line itself, start to end: `/L`, or `/L` moved out by its leader lines. + line: [(f64, f64); 2], + /// Strokes along the line: the whole line, or the two pieces either side of an inline caption. + strokes: Vec<[(f64, f64); 2]>, + /// The leader lines, from `/L` (past the `/LLO` gap) to the line and on by `/LLE`. + leaders: Vec<[(f64, f64); 2]>, + /// A short stroke from the line to a caption `/CO` moved off it. + tick: Option<[(f64, f64); 2]>, + /// The caption and its text matrix. + caption: Option<(String, [f64; 6])>, +} + +/// Lay out a Line annotation from `/L` = `[a b]`, line width `w`, and its leader and caption +/// entries. +/// +/// Leader lines: a positive `/LL` puts the line on the left of travel from `a` to `b` (above `/L` +/// for a line drawn left to right), so the leader lines, followed from the line to `/L`, turn +/// clockwise from the direction of travel; a negative one puts it on the right. The line sits +/// `|LL| + LLO` from `/L`. `/LLE` and `/LLO` are non-negative: a negative one counts as 0. +/// +/// Caption (`/Cap true`): centred on the line's midpoint and turned with it so it reads left to +/// right (bottom to top for a vertical line). `/CP /Inline` (the default) centres it on the line, +/// which is broken around it; `/Top` sets it on top of the unbroken line. `/CO [h v]` moves it `h` +/// along the line and `v` across it (positive: up in reading direction); a caption moved off an +/// inline line gets a tick from the line to it. +/// +/// A zero-length or overflowing `/L` has no direction: it gets neither leader lines nor caption. +/// Every point is finite; if anything derived from the entries is not, they are left out. +fn line_layout(d: &Dict, [a, b]: [(f64, f64); 2], w: f64) -> LineLayout { + let plain = LineLayout { line: [a, b], strokes: vec![[a, b]], ..LineLayout::default() }; + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let len = dx.hypot(dy); + if !len.is_finite() || len <= 0.0 { + return plain; + } + let (ux, uy) = (dx / len, dy / len); + let mut out = LineLayout { line: [a, b], ..LineLayout::default() }; + let ll = line_length(d.get(b"LL").and_then(Object::as_f64)); + if ll != 0.0 { + // The unit normal from `/L` towards the line: left of travel for a positive /LL. + let s = ll.signum(); + let (nx, ny) = (-uy * s, ux * s); + let llo = line_length(d.get(b"LLO").and_then(Object::as_f64)).max(0.0); + let lle = line_length(d.get(b"LLE").and_then(Object::as_f64)).max(0.0); + let at = |p: (f64, f64), k: f64| (p.0 + nx * k, p.1 + ny * k); + let off = ll.abs() + llo; + out.line = [at(a, off), at(b, off)]; + out.leaders = vec![[at(a, llo), at(a, off + lle)], [at(b, llo), at(b, off + lle)]]; + } + let [p, _] = out.line; + let along = |t: f64| (p.0 + ux * t, p.1 + uy * t); + let mut gap = None; + if let Some(text) = line_caption(d) { + let size = CAPTION_SIZE; + let width = text_width(&text, size); + // Reading direction, and "up" across it. + let (tx, ty) = if ux < 0.0 || (ux == 0.0 && uy < 0.0) { (-ux, -uy) } else { (ux, uy) }; + let (vx, vy) = (-ty, tx); + let co = nums(d, b"CO").filter(|c| c.len() == 2).unwrap_or_default(); + let h = line_length(co.first().copied()); + let v = line_length(co.get(1).copied()); + let top = d.name(b"CP") == Some(b"Top"); + // The caption's centre, as a distance along the line from `p`. + let centre = len / 2.0 + h * (tx * ux + ty * uy); + let mid = along(centre); + // Baseline across the line: on top of it, or with the text centred on it. + let base = if top { w.max(0.0) / 2.0 + 2.0 + v } else { v - size * 0.35 }; + let origin = (mid.0 - tx * width / 2.0 + vx * base, mid.1 - ty * width / 2.0 + vy * base); + out.caption = Some((text, [tx, ty, -ty, tx, origin.0, origin.1])); + if !top && v.abs() < size / 2.0 { + let half = width / 2.0 + 2.0; + gap = Some(((centre - half).clamp(0.0, len), (centre + half).clamp(0.0, len))); + } else if !top { + let reach = v - v.signum() * size * 0.4; + out.tick = Some([mid, (mid.0 + vx * reach, mid.1 + vy * reach)]); + } + } + out.strokes = match gap { + Some((g0, g1)) => [(0.0, g0), (g1, len)].into_iter().filter(|(s, e)| e > s).map(|(s, e)| [along(s), along(e)]).collect(), + None => vec![out.line], + }; + let finite = |q: &(f64, f64)| q.0.is_finite() && q.1.is_finite(); + let all_finite = + out.line.iter().chain(out.strokes.iter().flatten()).chain(out.leaders.iter().flatten()).chain(out.tick.iter().flatten()).all(finite) + && out.caption.as_ref().is_none_or(|(_, m)| m.iter().all(|x| x.is_finite())); + if all_finite { out } else { plain } +} + /// A rubber stamp: a rounded frame (a pointed tag for sign-here stamps) with the label in bold /// capitals, and the dynamic stamps' "By … at …" line. -fn stamp(kind: crate::StampKind, rect: [f64; 4], col: Rgb, by: Option<&str>, opacity: f64, mut res: Dict) -> Stream { +fn stamp(label: &str, sign_here: bool, rect: [f64; 4], col: Rgb, by: Option<&str>, opacity: f64, mut res: Dict) -> Stream { let [x0, y0, x1, y1] = rect; let h = y1 - y0; let mut c = String::new(); @@ -827,7 +1184,7 @@ fn stamp(kind: crate::StampKind, rect: [f64; 4], col: Rgb, by: Option<&str>, opa } let lw = (h * 0.07).clamp(1.0, 3.0); let inset = lw / 2.0 + 0.5; - if kind.group() == crate::StampGroup::SignHere { + if sign_here { // A tag pointing left, filled, with white text. let tip = h * 0.45; c.push_str(&format!( @@ -893,16 +1250,15 @@ fn stamp(kind: crate::StampKind, rect: [f64; 4], col: Rgb, by: Option<&str>, opa n(b0) )); } - let label = kind.label(); let (title_h, by_h) = if by.is_some() { (h * 0.5, h * 0.22) } else { (h * 0.52, 0.0) }; - let text_area = if kind.group() == crate::StampGroup::SignHere { (x0 + h * 0.45, x1 - lw * 2.0) } else { (x0 + lw * 2.0, x1 - lw * 2.0) }; + let text_area = if sign_here { (x0 + h * 0.45, x1 - lw * 2.0) } else { (x0 + lw * 2.0, x1 - lw * 2.0) }; let mut size = title_h; let tw = text_width(label, size) * 1.12; if tw > text_area.1 - text_area.0 - 4.0 { size *= (text_area.1 - text_area.0 - 4.0) / tw; } let tw = text_width(label, size) * 1.12; - let text_col = if kind.group() == crate::StampGroup::SignHere { [1.0, 1.0, 1.0] } else { col }; + let text_col = if sign_here { [1.0, 1.0, 1.0] } else { col }; let ty = if by.is_some() { y0 + h * 0.48 } else { y0 + (h - size * 0.72) / 2.0 }; let mut out = c.into_bytes(); out.extend( diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index ef4363ebf0..7acd044df0 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -842,6 +842,17 @@ fn annotation_flags(doc: &Document, d: &Dict) -> i64 { d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0) } +/// Whether the renderer keeps the annotation upright on a turned page: the NoRotate flag, with +/// `/F` read as the renderer reads it (a real truncated, a value that isn't a `u32` no flags). +fn drawn_upright(doc: &Document, d: &Dict) -> bool { + let f = match d.get(b"F").map(|f| doc.resolve(f)).as_deref() { + Some(Object::Int(i)) => *i, + Some(Object::Real(r)) => *r as i64, + _ => 0, + }; + u32::try_from(f).is_ok_and(|f| i64::from(f) & FLAG_NO_ROTATE != 0) +} + /// Whether any page has a Fill & Sign annotation that flatten would bake in (not hidden). pub fn has_visible_fill_sign(doc: &Document) -> bool { let Ok(pages) = page_refs(doc) else { return false }; @@ -1586,7 +1597,14 @@ pub fn with_missing_appearances_cached(doc: &Document, cache: &mut AppearanceCac apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, stream).ok(); continue; } - let Some(stream) = build_appearance(doc, &d) else { continue }; + // Another program's stamp gets a drawing for display only (ISO 32000-2 Table 184). Its + // `/AP`, if any, has no `/N` to keep here. + let other_stamp = || { + let mut bare = d.clone(); + bare.remove(b"AP"); + appearance::display_stamp(&bare) + }; + let Some(stream) = build_appearance(doc, &d).or_else(other_stamp) else { continue }; cache.insert(r, &stream); // Fails only when `r` isn't a dictionary, which the build just ruled out. apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, stream).ok(); @@ -2266,6 +2284,8 @@ pub struct Summary { pub intent: Option, /// A Fill & Sign mark ([`is_fill_sign`]). pub fill_sign: bool, + /// The NoRotate flag (`/F` bit 5), as `pdfcraft_render::Annotation::no_rotate`. + pub no_rotate: bool, /// A line's two ends (`/L`), or the corners of connected lines, a polygon or a cloud /// (`/Vertices`), in user space; empty for other comments. pub points: Vec<[f32; 2]>, @@ -2361,6 +2381,7 @@ pub fn summaries(doc: &Document) -> Vec { locked: annotation_flags(doc, d) & FLAG_LOCKED != 0, intent: d.get(b"IT").and_then(|o| doc.resolve(o).as_name().map(|n| String::from_utf8_lossy(n).into_owned())), fill_sign: is_fill_sign(doc, d), + no_rotate: drawn_upright(doc, d), points, }); } diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 20cc89b106..4f0e0fb1fc 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1014,6 +1014,89 @@ fn a_note_without_an_appearance_is_shown_at_icon_size() { assert_eq!(rects(&doc), original, "the source document is unchanged"); } +/// The fourteen rubber stamp names viewers draw without an appearance (ISO 32000-2 Table 184). +const TABLE_184_STAMPS: [(&str, &str); 14] = [ + ("Approved", "APPROVED"), + ("Experimental", "EXPERIMENTAL"), + ("NotApproved", "NOT APPROVED"), + ("AsIs", "AS IS"), + ("Expired", "EXPIRED"), + ("NotForPublicRelease", "NOT FOR PUBLIC RELEASE"), + ("Confidential", "CONFIDENTIAL"), + ("Final", "FINAL"), + ("Sold", "SOLD"), + ("Departmental", "DEPARTMENTAL"), + ("ForComment", "FOR COMMENT"), + ("TopSecret", "TOP SECRET"), + ("Draft", "DRAFT"), + ("ForPublicRelease", "FOR PUBLIC RELEASE"), +]; + +fn stamp_dict(extra: &str) -> Dict { + let doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + format!("<< /Type /Annot /Subtype /Stamp /Rect [20 40 140 100] {extra} >>").as_bytes(), + ]); + list(&doc, 0).remove(0) +} + +fn drawn(d: &Dict) -> String { + String::from_utf8_lossy(&appearance::display_stamp(d).expect("a stamp is drawn").decoded().unwrap()).into_owned() +} + +/// A stamp another program wrote without an appearance draws the stamp its `/Name` names, Draft +/// when it has none, and a frame with both diagonals for any other name, never the name itself. +#[test] +fn a_stamp_without_an_appearance_draws_the_stamp_it_names() { + for (name, label) in TABLE_184_STAMPS { + let content = drawn(&stamp_dict(&format!("/Name /{name}"))); + assert!(content.contains(&format!("({label}) Tj")), "{name}: {content}"); + } + assert!(drawn(&stamp_dict("")).contains("(DRAFT) Tj"), "Table 184's default"); + assert!(drawn(&stamp_dict("/IT /Stamp")).contains("(DRAFT) Tj"), "a stamp intent keeps the default"); + // Any other name, a name in another case (names are compared byte for byte, §7.3.5), and an + // image stamp (`/IT`, which has no `/Name` to default): the frame and its diagonals. + for extra in ["/Name /MyCustomStamp", "/Name /draft", "/Name /APPROVED", "/IT /StampImage", "/Name /PCVoid"] { + let content = drawn(&stamp_dict(extra)); + assert!(!content.contains("Tj") && content.contains(" re\n") && content.matches(" l\n").count() == 2, "{extra}: {content}"); + } + // `/C` colours the stamp, as it does PdfCraft's own. + assert!(drawn(&stamp_dict("/Name /Final /C [1 0 1]")).contains("1 0 1 RG")); + // The placeholder stays grey whatever the `/C`: it is not the stamp, so it takes none of its colour. + let placeholder = drawn(&stamp_dict("/Name /MyCustomStamp /C [1 0 0]")); + assert!(placeholder.contains("0.45 0.45 0.45 RG") && !placeholder.contains("1 0 0 RG"), "{placeholder}"); + // A stamp's own artwork is never replaced, and the drawing is for display only: edits + // (restyling, flattening) still find nothing they may write. + assert!(appearance::display_stamp(&stamp_dict("/Name /Draft /AP << /N 9 0 R >>")).is_none()); + assert!(appearance::build(&stamp_dict("/Name /Draft")).is_none()); + assert!(appearance::display_stamp(&stamp_dict("/Name /Draft /PCStamp true")).is_none(), "PdfCraft's own stamps are drawn by `build`"); + assert!(appearance::display_stamp(&stamp_dict("/Name /Draft /Rect [5 5 5 50]")).is_none(), "no width"); +} + +/// The display copy draws a stamp that has no normal appearance, and keeps one that has. +#[test] +fn the_display_copy_draws_stamps_without_a_normal_appearance_only() { + let doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R 7 0 R] >>", + b"<< /Type /Annot /Subtype /Stamp /Rect [20 40 140 100] /Name /TopSecret >>", + b"<< /Type /Annot /Subtype /Stamp /Rect [20 140 140 200] /Name /Draft /AP << /N 8 0 R >> >>", + b"<< /Type /Annot /Subtype /Stamp /Rect [20 240 140 300] /Name /Sold /AP << /D 8 0 R >> >>", + b"<< /Type /Annot /Subtype /Stamp /Rect [20 340 140 400] /Name /Unheard >>", + b"<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] /Length 8 >>\nstream\n1 0 0 rg\nendstream", + ]); + let display = with_missing_appearances(&doc).expect("three stamps need an appearance"); + let shown = list(&display, 0); + assert!(ap_content(&display, &shown[0]).contains("(TOP SECRET) Tj")); + assert_eq!(shown[1].get(b"AP"), list(&doc, 0)[1].get(b"AP"), "an existing appearance is kept"); + assert!(ap_content(&display, &shown[2]).contains("(SOLD) Tj"), "an /AP without /N has nothing to keep"); + assert!(!ap_content(&display, &shown[3]).contains("Tj"), "an unknown name gets the placeholder"); + assert!(list(&doc, 0)[0].get(b"AP").is_none(), "the document itself is untouched"); +} + #[test] fn every_line_ending_is_drawn() { let filled = [LineEnding::Square, LineEnding::Circle, LineEnding::Diamond, LineEnding::ClosedArrow, LineEnding::RClosedArrow]; @@ -1316,9 +1399,9 @@ fn cached_appearances_are_reused_while_annotations_are_unchanged() { .expect("strips"); let mut cache = AppearanceCache::default(); let first = with_missing_appearances_cached(&doc, &mut cache).expect("builds"); - assert_eq!((cache.builds(), cache.hits()), (1, 0), "only the note needs an appearance"); + assert_eq!((cache.builds(), cache.hits()), (2, 0), "the note and the file's stamp need an appearance"); let second = with_missing_appearances_cached(&doc, &mut cache).expect("reuses"); - assert_eq!((cache.builds(), cache.hits()), (1, 1), "nothing is redrawn"); + assert_eq!((cache.builds(), cache.hits()), (2, 2), "nothing is redrawn"); let [a, b] = [&first, &second].map(|copy| ap_content(copy, &list(copy, 0)[idx])); assert!(!a.is_empty()); assert_eq!(a, b, "the reused appearance is byte-identical"); @@ -1675,3 +1758,339 @@ fn a_star_note_keeps_its_star_when_recoloured() { let Object::Stream(s) = &*doc.get(n) else { panic!() }; assert_eq!(s.decoded().unwrap(), note_drawing(Some("Star"), [0.0, 0.0, 1.0])); } + +/// The built appearance's content for an annotation dictionary written in PDF syntax. +fn content_of(src: &str) -> String { + let doc = document_of(&[src.as_bytes()]); + let d = doc.get(ObjRef::new(1, 0)).as_dict().cloned().expect("a dictionary"); + let stream = appearance::build(&d).expect("drawn"); + String::from_utf8_lossy(&stream.decoded().unwrap()).into_owned() +} + +/// ISO 32000-2 Table 166: `/Border [hr vr w [dash]]`; the optional fourth element, a dash array, +/// used to hide the width (drawn 1 pt solid instead of 3 pt dashed). +#[test] +fn legacy_border_width_and_dash_are_drawn() { + let square = |border: &str| content_of(&format!("<< /Subtype /Square /Rect [0 0 100 100] /C [1 0 0] {border} >>")); + let c = square("/Border [0 0 3 [6 3]]"); + assert!(c.contains("3 w") && c.contains("[6 3] 0 d"), "{c}"); + assert!(c.contains("1.5 1.5 97 97 re"), "the stroke is inset by half its width: {c}"); + let c = square("/Border [0 0 3]"); + assert!(c.contains("3 w") && !c.contains(" d\n"), "no dash array: solid: {c}"); + // `/BS` overrides `/Border` (Table 166), for the width and the dashes alike. + let c = square("/Border [0 0 3 [6 3]] /BS << /W 2 >>"); + assert!(c.contains("2 w") && !c.contains(" d\n"), "{c}"); + let c = square("/Border [0 0 3 [6 3]] /BS << /W 2 /S /D /D [4 1] >>"); + assert!(c.contains("2 w") && c.contains("[4 1] 0 d"), "{c}"); + // A /BS without /W means its default width 1, not the ignored /Border's. + let c = square("/Border [0 0 3] /BS << /S /S >>"); + assert!(c.contains("1 w") && !c.contains("3 w"), "{c}"); + // A FreeText border and a line take the legacy dash array too. + let c = content_of("<< /Subtype /FreeText /Rect [0 0 100 40] /Contents (x) /Border [0 0 2 [5 2]] >>"); + assert!(c.contains("2 w") && c.contains("[5 2] 0 d"), "{c}"); + let c = content_of("<< /Subtype /Line /Rect [0 0 100 100] /L [10 10 90 90] /C [0 0 1] /Border [0 0 2 [5 2]] >>"); + assert!(c.contains("[5 2] 0 d"), "{c}"); +} + +/// Dash arrays that can't be drawn (§8.4.3.6: all zeros is invalid; negative or non-numeric +/// lengths, and absurdly long arrays) draw solid, without a panic. A non-finite or negative +/// width falls back to 1. +#[test] +fn bad_dash_arrays_draw_solid() { + let square = |border: &str| content_of(&format!("<< /Subtype /Square /Rect [0 0 100 100] /C [1 0 0] {border} >>")); + let long = format!("[{}]", vec!["1"; 65].join(" ")); + for bad in ["[0 0]", "[-3 2]", "[]", "[(a) 2]", "5", long.as_str()] { + let c = square(&format!("/Border [0 0 3 {bad}]")); + assert!(c.contains("3 w") && !c.contains(" d\n"), "{bad}: {c}"); + let c = square(&format!("/BS << /W 3 /S /D /D {bad} >>")); + assert!(c.contains("3 w") && !c.contains(" d\n"), "{bad}: {c}"); + } + assert!(square(&format!("/Border [0 0 3 [{}]]", vec!["1"; 64].join(" "))).contains(" 0 d\n"), "64 elements are drawn"); + // `/S /D` without `/D` uses the default `[3]` (Table 168). + assert!(square("/BS << /W 3 /S /D >>").contains("[3] 0 d")); + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Square")); + d.set(b"Rect".to_vec(), Object::Array([0.0, 0.0, 100.0, 100.0].map(Object::Real).to_vec())); + d.set(b"C".to_vec(), Object::Array([1.0, 0.0, 0.0].map(Object::Real).to_vec())); + for w in [f64::NAN, f64::INFINITY, -2.0] { + let dash = Object::Array([f64::NAN, 2.0].map(Object::Real).to_vec()); + d.set(b"Border".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), Object::Real(w), dash])); + let c = String::from_utf8_lossy(&appearance::build(&d).unwrap().decoded().unwrap()).into_owned(); + assert!(c.contains("1 w") && !c.contains(" d\n") && !c.contains("NaN") && !c.contains("inf"), "{w}: {c}"); + } +} + +/// Table 182: an ink annotation's `/BS` gives the width and dash pattern its paths are drawn with. +#[test] +fn ink_draws_its_border_style_dashes() { + let ink = |style: &str| content_of(&format!("<< /Subtype /Ink /Rect [0 0 100 100] /C [1 0 0] /InkList [[5 5 50 95 95 5]] {style} >>")); + let c = ink("/BS << /W 3 /S /D /D [6 3] >>"); + assert!(c.contains("3 w 1 J 1 j\n[6 3] 0 d"), "{c}"); + assert!(!ink("/BS << /W 3 >>").contains(" d\n")); + assert!(ink("/Border [0 0 2 [4 4]]").contains("[4 4] 0 d")); +} + +/// Table 183: `/Sy /P` associates a new paragraph symbol (¶) with the caret. It is drawn to the +/// caret's right in the caret's colour, inside `/Rect`; `/Sy /None` (the default) draws none. +#[test] +fn a_caret_with_a_paragraph_symbol_draws_the_pilcrow() { + let caret = |extra: &str| { + let doc = document_of(&[format!("<< /Subtype /Caret /Rect [30 30 60 60] /C [0.9 0.1 0.1] {extra} >>").as_bytes()]); + let d = doc.get(ObjRef::new(1, 0)).as_dict().cloned().unwrap(); + let s = appearance::build(&d).unwrap(); + (s.decoded().unwrap(), s.dict.get(b"Resources").and_then(Object::as_dict).is_some_and(|r| r.contains(b"Font"))) + }; + let has_pilcrow = |c: &[u8]| c.windows(5).any(|w| w == b"(\xb6) T"); + let (c, font) = caret("/Sy /P"); + assert!(has_pilcrow(&c) && font, "{}", String::from_utf8_lossy(&c)); + let text = String::from_utf8_lossy(&c).into_owned(); + assert!(text.starts_with("0.9 0.1 0.1 rg"), "{text}"); + // The caret gives up room on the right for the symbol, which stays within /Rect. + let td: Vec = text.split("Tf ").nth(1).unwrap().split_whitespace().take(2).map(|v| v.parse().unwrap()).collect(); + let size: f64 = text.split("/Helv ").nth(1).unwrap().split_whitespace().next().unwrap().parse().unwrap(); + assert!(td[0] > 45.0 && td[0] + appearance::text_width("\u{b6}", size) <= 60.0 + 1e-6 && td[1] >= 30.0, "{text}"); + assert!(!text.contains("60 30 c h f"), "the caret no longer reaches the right edge: {text}"); + for none in ["/Sy /None", ""] { + let (c, font) = caret(none); + assert!(!has_pilcrow(&c) && !font, "{none}"); + assert!(String::from_utf8_lossy(&c).contains("60 30 c h f"), "{none}: the caret fills /Rect as before"); + } + // With /RD leaving room on the right, the caret keeps its box and the symbol fits beside it. + let (c, _) = caret("/Sy /P /RD [0 0 15 0]"); + let text = String::from_utf8_lossy(&c).into_owned(); + assert!(text.contains("45 30 c h f") && has_pilcrow(&c), "{text}"); + // Malformed /RD and a degenerate /Rect don't panic. + for rd in ["/RD [40 0 40 0]", "/RD [-1 0 0 0]", "/RD [1 2]", "/RD (x)"] { + assert!(has_pilcrow(&caret(&format!("/Sy /P {rd}")).0), "{rd}"); + } + let doc = document_of(&[b"<< /Subtype /Caret /Rect [30 30 30 30] /Sy /P >>"]); + let d = doc.get(ObjRef::new(1, 0)).as_dict().cloned().unwrap(); + assert!(appearance::build(&d).is_some()); +} + +// ── Line annotations: leader lines and captions (ISO 32000-2 §12.5.6.7, Table 178) ────────── + +/// A red Line from `l` with a 1 pt border, a generous `/Rect`, and `extra` entries. +fn table_178_line(l: [f64; 4], extra: &[(&str, Object)]) -> Dict { + let mut d = Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Line")); + d.set(b"Rect".to_vec(), Object::Array([0.0, 0.0, 600.0, 800.0].map(Object::Real).to_vec())); + d.set(b"C".to_vec(), Object::Array([1.0, 0.0, 0.0].map(Object::Real).to_vec())); + d.set(b"L".to_vec(), Object::Array(l.map(Object::Real).to_vec())); + for (k, v) in extra { + d.set(k.as_bytes().to_vec(), v.clone()); + } + d +} + +/// The built appearance's content. +fn line_drawing(d: &Dict) -> String { + let s = crate::appearance::build(d).expect("the line is drawn"); + String::from_utf8_lossy(&s.decoded().unwrap()).into_owned() +} + +/// Every straight two-point stroke (`x0 y0 m x1 y1 l S`) in a drawing. +fn segments(content: &str) -> Vec<[f64; 4]> { + content + .lines() + .filter_map(|line| { + let t: Vec<&str> = line.split_whitespace().collect(); + if t.len() != 7 || t[2] != "m" || t[5] != "l" || t[6] != "S" { + return None; + } + let v: Vec = [t[0], t[1], t[3], t[4]].iter().map(|x| x.parse().unwrap()).collect(); + Some([v[0], v[1], v[2], v[3]]) + }) + .collect() +} + +/// The caption's text matrix (`a b c d e f Tm`), if any. +fn caption_matrix(content: &str) -> Option<[f64; 6]> { + let at = content.find(" Tm")?; + let before: Vec = content[..at].split_whitespace().rev().take(6).map(|x| x.parse().unwrap()).collect(); + Some([before[5], before[4], before[3], before[2], before[1], before[0]]) +} + +fn has(segs: &[[f64; 4]], want: [f64; 4]) -> bool { + segs.iter().any(|s| s.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01)) +} + +#[test] +fn a_positive_leader_length_puts_the_line_above_a_left_to_right_line_and_a_negative_one_below() { + let up = segments(&line_drawing(&table_178_line([72.0, 620.0, 272.0, 620.0], &[("LL", Object::Real(20.0))]))); + assert!(has(&up, [72.0, 640.0, 272.0, 640.0]), "the line is 20 above /L: {up:?}"); + assert!(has(&up, [72.0, 620.0, 72.0, 640.0]) && has(&up, [272.0, 620.0, 272.0, 640.0]), "leader lines from /L up to it: {up:?}"); + assert!(!has(&up, [72.0, 620.0, 272.0, 620.0]), "nothing along /L itself: {up:?}"); + + let down = segments(&line_drawing(&table_178_line([72.0, 540.0, 272.0, 540.0], &[("LL", Object::Real(-20.0)), ("LLE", Object::Real(10.0))]))); + assert!(has(&down, [72.0, 520.0, 272.0, 520.0]), "the line is 20 below /L: {down:?}"); + assert!(has(&down, [72.0, 540.0, 72.0, 510.0]) && has(&down, [272.0, 540.0, 272.0, 510.0]), "leaders run 10 past it: {down:?}"); +} + +#[test] +fn a_leader_offset_leaves_a_gap_and_the_endings_sit_on_the_line_itself() { + let d = table_178_line( + [72.0, 460.0, 272.0, 460.0], + &[("LL", Object::Real(20.0)), ("LLO", Object::Real(10.0)), ("LE", Object::Array(vec![Object::name("OpenArrow"), Object::name("OpenArrow")]))], + ); + let content = line_drawing(&d); + let segs = segments(&content); + assert!(has(&segs, [72.0, 490.0, 272.0, 490.0]), "the line sits |LL| + LLO above /L: {segs:?}"); + assert!(has(&segs, [72.0, 470.0, 72.0, 490.0]) && has(&segs, [272.0, 470.0, 272.0, 490.0]), "leaders start LLO from /L: {segs:?}"); + // The open arrows' tips (`tip l`) are at the ends of the line, not of /L. + assert!(content.contains(" 72 490 l ") && content.contains(" 272 490 l "), "arrows at the moved line: {content}"); + assert!(!content.contains(" 72 460 l "), "no arrow on /L: {content}"); +} + +#[test] +fn an_inline_caption_breaks_the_line_around_its_text() { + let d = + table_178_line([72.0, 380.0, 272.0, 380.0], &[("Cap", Object::Bool(true)), ("Contents", Object::String(PdfString::text("Inline 50 mm")))]); + let content = line_drawing(&d); + assert!(content.contains("(Inline 50 mm) Tj"), "the caption is drawn: {content}"); + assert!(content.contains("/Helv 10 Tf"), "in Helvetica 10: {content}"); + let m = caption_matrix(&content).expect("text matrix"); + let width = crate::appearance::text_width("Inline 50 mm", 10.0); + assert!((m[4] - (172.0 - width / 2.0)).abs() < 0.01 && m[5] < 380.0 && m[5] > 380.0 - 10.0, "centred on the line: {m:?}"); + let segs = segments(&content); + assert_eq!(segs.len(), 2, "two pieces: {segs:?}"); + assert!(segs.iter().all(|s| s[2] <= 172.0 - width / 2.0 || s[0] >= 172.0 + width / 2.0), "no stroke under the text: {segs:?}"); + assert!(has(&segs, [72.0, 380.0, 172.0 - width / 2.0 - 2.0, 380.0]), "the left piece starts at the line's start: {segs:?}"); +} + +#[test] +fn a_top_caption_sits_above_the_unbroken_line() { + let d = table_178_line( + [72.0, 300.0, 272.0, 300.0], + &[("Cap", Object::Bool(true)), ("CP", Object::name("Top")), ("Contents", Object::String(PdfString::text("Top 50 mm")))], + ); + let content = line_drawing(&d); + let m = caption_matrix(&content).expect("text matrix"); + assert!(m[5] > 300.0 && m[5] < 305.0, "baseline just above the line: {m:?}"); + assert_eq!(segments(&content), vec![[72.0, 300.0, 272.0, 300.0]], "the line is whole"); +} + +#[test] +fn a_caption_offset_moves_it_along_and_off_the_line_with_a_tick() { + let co = |h: f64, v: f64| Object::Array(vec![Object::Real(h), Object::Real(v)]); + let text = ("Contents", Object::String(PdfString::text("Offset 15"))); + let base = caption_matrix(&line_drawing(&table_178_line([330.0, 700.0, 530.0, 700.0], &[("Cap", Object::Bool(true)), text.clone()]))).unwrap(); + let content = line_drawing(&table_178_line([330.0, 700.0, 530.0, 700.0], &[("Cap", Object::Bool(true)), ("CO", co(0.0, 15.0)), text.clone()])); + let up = caption_matrix(&content).unwrap(); + assert!((up[5] - base[5] - 15.0).abs() < 0.01 && (up[4] - base[4]).abs() < 0.01, "15 up: {base:?} → {up:?}"); + let segs = segments(&content); + assert!(has(&segs, [330.0, 700.0, 530.0, 700.0]), "a caption off the line leaves it whole: {segs:?}"); + assert!(segs.iter().any(|s| s[0] == 430.0 && s[2] == 430.0 && s[1] == 700.0 && s[3] > 705.0), "a tick from the midpoint up: {segs:?}"); + let along = + caption_matrix(&line_drawing(&table_178_line([330.0, 700.0, 530.0, 700.0], &[("Cap", Object::Bool(true)), ("CO", co(-30.0, 0.0)), text]))) + .unwrap(); + assert!((along[4] - base[4] + 30.0).abs() < 0.01 && (along[5] - base[5]).abs() < 0.01, "30 left: {base:?} → {along:?}"); +} + +#[test] +fn without_cap_true_no_caption_is_drawn() { + for cap in [None, Some(Object::Bool(false)), Some(Object::Int(1))] { + let mut extra = vec![("Contents", Object::String(PdfString::text("Hidden caption")))]; + if let Some(c) = cap.clone() { + extra.push(("Cap", c)); + } + let d = table_178_line([330.0, 620.0, 530.0, 620.0], &extra); + let content = line_drawing(&d); + assert!(!content.contains("Tj"), "{cap:?}: {content}"); + assert_eq!(segments(&content), vec![[330.0, 620.0, 530.0, 620.0]]); + assert!(crate::appearance::undrawable_text(&d).is_none()); + } +} + +#[test] +fn a_diagonal_lines_leaders_are_perpendicular_and_its_caption_turns_with_it() { + let d = table_178_line( + [330.0, 460.0, 530.0, 560.0], + &[ + ("LL", Object::Real(20.0)), + ("LLE", Object::Real(10.0)), + ("Cap", Object::Bool(true)), + ("Contents", Object::String(PdfString::text("Diagonal"))), + ], + ); + let content = line_drawing(&d); + let segs = segments(&content); + let (ux, uy) = (200.0 / 100f64.hypot(200.0), 100.0 / 100f64.hypot(200.0)); + let leaders: Vec<&[f64; 4]> = segs.iter().filter(|s| (s[2] - s[0]) * ux + (s[3] - s[1]) * uy < 1e-2).collect(); + assert_eq!(leaders.len(), 2, "two leader lines across the line: {segs:?}"); + for l in leaders { + let (dx, dy) = (l[2] - l[0], l[3] - l[1]); + assert!((dx.hypot(dy) - 30.0).abs() < 0.01, "|LL| + LLE long: {l:?}"); + assert!(dx < 0.0 && dy > 0.0, "up and to the left (left of travel): {l:?}"); + } + let m = caption_matrix(&content).expect("caption"); + assert!((m[0] - ux).abs() < 1e-3 && (m[1] - uy).abs() < 1e-3, "the text runs along the line: {m:?}"); + // Drawn right to left, it still reads left to right. + let back = caption_matrix(&line_drawing(&table_178_line( + [530.0, 560.0, 330.0, 460.0], + &[("Cap", Object::Bool(true)), ("Contents", Object::String(PdfString::text("x")))], + ))) + .unwrap(); + assert!(back[0] > 0.0, "{back:?}"); +} + +#[test] +fn a_caption_from_rich_text_and_one_the_font_cant_draw() { + let rc = "

Dim & 50 mm <x>

"; + let d = table_178_line([0.0, 0.0, 100.0, 0.0], &[("Cap", Object::Bool(true)), ("RC", Object::String(PdfString::text(rc)))]); + let s = crate::appearance::build(&d).unwrap().decoded().unwrap(); + assert!(s.windows(17).any(|w| w == b"(Dim & 50 mm )"), "{}", String::from_utf8_lossy(&s)); + let cjk = table_178_line([0.0, 0.0, 100.0, 0.0], &[("Cap", Object::Bool(true)), ("Contents", Object::String(PdfString::text("長さ")))]); + assert_eq!(crate::appearance::undrawable_text(&cjk), Some(("the line's caption", '長'))); + // A PdfCraft measurement's label is its caption: the comment text isn't drawn a second time. + let mut measured = cjk.clone(); + measured.set(b"PCMeasureValue".to_vec(), Object::String(PdfString::text("5 mm"))); + assert_eq!(crate::appearance::undrawable_text(&measured), None); + let content = line_drawing(&measured); + assert_eq!(content.matches(" Tj").count(), 1, "{content}"); +} + +#[test] +fn degenerate_and_absurd_leader_and_caption_values_do_not_panic() { + let cap = ("Cap", Object::Bool(true)); + let text = ("Contents", Object::String(PdfString::text(&"w".repeat(10_000)))); + // A zero-length line has no direction: no leader lines, no caption, no NaN. + let point = + line_drawing(&table_178_line([50.0, 50.0, 50.0, 50.0], &[("LL", Object::Real(20.0)), ("LLE", Object::Real(5.0)), cap.clone(), text.clone()])); + assert!(!point.contains("NaN") && !point.contains("inf") && !point.contains("Tj"), "{point}"); + assert_eq!(segments(&point).len(), 1); + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 1e300, -1e300, f64::MAX] { + let d = table_178_line( + [0.0, 0.0, 100.0, 0.0], + &[ + ("LL", Object::Real(bad)), + ("LLE", Object::Real(bad)), + ("LLO", Object::Real(bad)), + ("CO", Object::Array(vec![Object::Real(bad), Object::Real(bad)])), + cap.clone(), + text.clone(), + ], + ); + let content = line_drawing(&d); + assert!(!content.contains("NaN") && !content.contains("inf"), "{bad}: {content}"); + for s in segments(&content) { + assert!(s.iter().all(|x| x.abs() <= 50_000.0), "{bad}: {s:?}"); + } + } + // Negative LLE and LLO count as 0. + let neg = segments(&line_drawing(&table_178_line( + [0.0, 0.0, 100.0, 0.0], + &[("LL", Object::Real(10.0)), ("LLE", Object::Real(-5.0)), ("LLO", Object::Real(-5.0))], + ))); + assert!(has(&neg, [0.0, 0.0, 0.0, 10.0]) && has(&neg, [0.0, 10.0, 100.0, 10.0]), "{neg:?}"); + // Huge endpoints whose midpoint would overflow fall back to the plain line. + let huge = line_drawing(&table_178_line([f64::MAX, f64::MAX, f64::MAX / 2.0, f64::MAX], &[("LL", Object::Real(20.0)), cap, text])); + assert!(!huge.contains("NaN") && !huge.contains("inf"), "{huge}"); + // The caption is cut to 256 characters. + let long = line_drawing(&table_178_line( + [0.0, 0.0, 100.0, 0.0], + &[("Cap", Object::Bool(true)), ("Contents", Object::String(PdfString::text(&"w".repeat(10_000))))], + )); + assert!(long.contains(&format!("({}) Tj", "w".repeat(256))), "cut to 256"); + assert!(!long.contains(&"w".repeat(257))); +} diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs index f1f1350987..f2bcf63358 100644 --- a/crates/audit/src/lib.rs +++ b/crates/audit/src/lib.rs @@ -138,16 +138,25 @@ struct Budget { } impl Budget { - /// Resets the comparison count for a page with these `lines`. + /// Resets the comparison count for a page with these `lines`. A line costs its word boxes + /// or its runs, whichever are more (at least one): a cover check walks the boxes, and the + /// covered text walks the runs, which can draw no words at all (`( ) Tj`). fn start_page(&mut self, lines: &[TextLine]) { self.comparisons = 0; - self.page_cost = lines.iter().fold(0, |n, line| n.saturating_add(line.ink.len().max(1))); + self.page_cost = lines.iter().fold(0, |n, line| n.saturating_add(line.ink.len().max(line.runs().count()).max(1))); } - /// The text of the `lines` that `hides`, joined with single spaces, or `None` when they show - /// no text. Without `copying` the text is empty and never assembled: one long line under many - /// covers would otherwise be copied once per cover, outside both bounds. - fn hidden_text(&mut self, lines: &[TextLine], copying: bool, hides: impl Fn(&TextLine) -> bool) -> Result, AuditError> { + /// The text `cover` hides in the `lines` that `hides` ([`covered_words`]), joined with single + /// spaces, or `None` when they show no text. Without `copying` the text is empty and never + /// assembled: one long line under many covers would otherwise be copied once per cover, + /// outside both bounds. + fn hidden_text( + &mut self, + lines: &[TextLine], + copying: bool, + cover: &[f64; 4], + hides: impl Fn(&TextLine) -> bool, + ) -> Result, AuditError> { self.comparisons = self.comparisons.saturating_add(self.page_cost); if self.comparisons > self.limits.comparisons { return Err(AuditError::TooManyComparisons); @@ -160,7 +169,7 @@ impl Budget { if !covered.is_empty() { covered.push(' '); } - covered.push_str(line.text.trim()); + covered_words(cover, line, &mut covered); } Ok((!covered.trim().is_empty()).then_some(covered)) } @@ -188,14 +197,17 @@ fn audit(doc: &Document, limits: Limits) -> Result, AuditError budget.start_page(&lines); // 1. Opaque filled rectangles over extractable text. for r in cover::filled_rects(doc, &page.dict)? { - if let Some(covered) = budget.hidden_text(&lines, copying, |line| is_cover(&r, line))? { + if let Some(covered) = budget.hidden_text(&lines, copying, &r.rect, |line| is_cover(&r, line))? { let covered_text = budget.report_text(covered, copying)?; out.push(AuditFinding { page: n, kind: FindingKind::CoveredText, rect: r.rect, covered_text }); } } // 2. Annotation covers over extractable text. for annot in annots(doc, &page.dict)? { - if let Some(covered) = budget.hidden_text(&lines, copying, |line| match &annot { + let cover = match &annot { + AnnotationCover::Redact(rect) | AnnotationCover::Square { rect, .. } => *rect, + }; + if let Some(covered) = budget.hidden_text(&lines, copying, &cover, |line| match &annot { AnnotationCover::Redact(rect) => covers(rect, line), AnnotationCover::Square { rect, rgb } => covers(rect, line) && contrast(line.color, *rgb) < COVER_CONTRAST, })? { @@ -250,6 +262,34 @@ fn covers(cover: &[f64; 4], line: &TextLine) -> bool { inked || (boxed && (line.ink.is_empty() || word_intersects())) } +/// Appends the text of `line` that `cover` lies on to `out`: the runs whose words it overlaps +/// (#4164), so the words of the line painted beside the cover, which stay visible, are not +/// reported as hidden. Neighbouring runs are joined as the line joins them, so a word split +/// across runs stays one word; runs with others left out between them are spaced. The whole +/// line when its runs can't say: a single run, a line whose text was reordered for reading, or +/// a cover that overlaps the line's box but none of its words. +fn covered_words(cover: &[f64; 4], line: &TextLine, out: &mut String) { + let overlaps = |w: &[f64; 4]| area(&[cover[0].max(w[0]), cover[1].max(w[1]), cover[2].min(w[2]), cover[3].min(w[3])]) > 0.0; + let mut words = String::new(); + let mut last = None; + for (i, (text, ink, spaced)) in line.runs().enumerate() { + if text.trim().is_empty() || !ink.iter().any(overlaps) { + continue; + } + let apart = last.is_some_and(|l: usize| l.saturating_add(1) != i || spaced); + if apart && !words.ends_with(char::is_whitespace) && !text.starts_with(char::is_whitespace) { + words.push(' '); + } + words.push_str(text); + last = Some(i); + } + if line.runs().nth(1).is_none() || last.is_none() { + out.push_str(line.text.trim()); + } else { + out.push_str(words.trim()); + } +} + /// The area of a rectangle, 0 when it is empty or inverted. fn area(r: &[f64; 4]) -> f64 { (r[2] - r[0]).max(0.0) * (r[3] - r[1]).max(0.0) diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs index 3aca406e7c..e5fb9ab683 100644 --- a/crates/audit/src/tests.rs +++ b/crates/audit/src/tests.rs @@ -645,6 +645,33 @@ fn later_text_on_the_line_does_not_clear_an_earlier_cover() { assert!(findings[0].covered_text.contains("SECRET"), "{findings:?}"); } +/// #4164: a cover over part of a line reports the words under it, not the words of the same +/// line painted beside it, which stay visible. +#[test] +fn covered_text_leaves_out_the_words_beside_the_cover() { + let covered = |content: &[u8]| { + let findings = audit_redactions(&open(one_page(content, ""))).expect("audit must run"); + findings.into_iter().map(|f| f.covered_text).collect::>() + }; + assert_eq!(covered(b"0 g\nBT /F1 24 Tf 100 700 Td (SECRET) Tj ET\n98 688 94 32 re f\nBT /F1 24 Tf 200 700 Td (tail) Tj ET\n"), ["SECRET"]); + // A word shown in two runs with nothing between them stays one word (review on #4198). + assert_eq!(covered(b"0 g\nBT /F1 24 Tf 100 700 Td (SE) Tj (CRET) Tj ET\n98 688 94 32 re f\nBT /F1 24 Tf 200 700 Td (tail) Tj ET\n"), ["SECRET"]); + + // The same for an unapplied redaction mark over the first run only. A mark over both runs + // reports both, with the space the line has between them (`tail` is set apart here). + for (rect, expected) in [("[98 688 192 720]", "SECRET"), ("[98 688 310 720]", "SECRET tail")] { + let mut b = Builder::new(); + b.add("<< /Type /Catalog /Pages 2 0 R >>"); + b.add("<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); + b.add("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>"); + b.add_stream(b"BT /F1 24 Tf 100 700 Td (SECRET) Tj ET\nBT /F1 24 Tf 250 700 Td (tail) Tj ET\n"); + b.add(FONT); + b.add(&format!("<< /Type /Annot /Subtype /Redact /Rect {rect} >>")); + let findings = audit_redactions(&open(b.build())).expect("audit must run"); + assert_eq!(findings.iter().map(|f| (f.kind.clone(), f.covered_text.as_str())).collect::>(), [(FindingKind::UnappliedMark, expected)]); + } +} + /// Many covers over the same long line repeat its text in every finding: a small file must not /// expand into an unbounded report. Past the budget the audit fails rather than truncate. #[test] @@ -677,6 +704,28 @@ fn cover_and_line_comparisons_are_bounded() { assert!(crate::audit(&doc, limits).expect("within the budget").is_empty()); } +/// A run that draws no word still costs a comparison: the covered text walks every run of a +/// covered line, so a word followed by many `( ) Tj` must not be checked for free (review on +/// #4198). +#[test] +fn runs_without_words_count_against_the_comparison_bound() { + let mut content = b"BT /F1 24 Tf 100 700 Td (SECRET) Tj".to_vec(); + content.extend_from_slice(&b" ( ) Tj".repeat(50)); + content.extend_from_slice( + b" ET +0 g +98 688 94 32 re f +", + ); + let doc = open(one_page(&content, "")); + let lines = pdfcraft_edit::glyph_text_lines(&doc, &pdfcraft_model::pages(&doc)[0]).expect("text"); + assert_eq!(lines.iter().map(|l| (l.ink.len(), l.runs().count())).collect::>(), [(1, 51)]); + let limits = crate::Limits { comparisons: 50, covered_text: usize::MAX }; + assert!(matches!(crate::audit(&doc, limits), Err(crate::AuditError::TooManyComparisons))); + let limits = crate::Limits { comparisons: 51, covered_text: usize::MAX }; + assert_eq!(crate::audit(&doc, limits).expect("within the budget").len(), 1); +} + /// Covered text is document text: a document whose permissions withhold copying reports where /// its covers are but not what they hide, unless it was opened with the owner password. #[test] @@ -714,14 +763,15 @@ fn copy_restricted_documents_withhold_covered_text() { /// per cover, a volume neither the comparison nor the output bound charges. #[test] fn withheld_covered_text_is_not_assembled() { + const PAGE: [f64; 4] = [0.0, 0.0, 612.0, 792.0]; let doc = open(one_page(b"BT /F1 24 Tf 100 700 Td (SECRET DATA 123) Tj ET\n", "")); let lines = pdfcraft_edit::text_lines(&doc, 0).expect("text"); let mut budget = crate::Budget { limits: crate::Limits::DEFAULT, comparisons: 0, page_cost: 0, covered_text: 0 }; budget.start_page(&lines); - let withheld = budget.hidden_text(&lines, false, |_| true).expect("within the budget"); + let withheld = budget.hidden_text(&lines, false, &PAGE, |_| true).expect("within the budget"); assert_eq!(withheld.map(|t| t.capacity()), Some(0), "a finding, with no text built for it"); - assert_eq!(budget.hidden_text(&lines, false, |_| false).expect("within the budget"), None); - let copied = budget.hidden_text(&lines, true, |_| true).expect("within the budget"); + assert_eq!(budget.hidden_text(&lines, false, &PAGE, |_| false).expect("within the budget"), None); + let copied = budget.hidden_text(&lines, true, &PAGE, |_| true).expect("within the budget"); assert_eq!(copied.as_deref(), Some("SECRET DATA 123")); } diff --git a/crates/automation/README.md b/crates/automation/README.md index 7eb8ca1191..cdaaa13e64 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -33,7 +33,7 @@ its undo/redo history. - For proportional image signature resizing, use the embedded image layer's width/height ratio rather than a previously stretched annotation rectangle. The GUI preserves this ratio at corners with the opposite corner anchored; edge midpoint handles change only their own axis. `comment_edit` accepts the resulting rectangle. - `doc_close` refuses to drop unsaved changes unless `discard_changes: true`. `doc_save` writes atomically, incrementally in place, and in full for a new path. Atomic output writes call `sync_all` on the staging file before replacing the destination. A write or sync error leaves the destination intact, removes staging and leaves the document dirty for retry. A filesystem that does not support syncing returns an error; there is no unsynced fallback. This flushes file contents, but does not guarantee persistence of the parent-directory rename across power loss. - With a root set, every read and write path must resolve inside it (symlinks and `..` included). Relative paths resolve inside the root, and `..` is resolved by name before the check. Every path outside the root gets the same refusal (` is outside the allowed directory `), whether or not it exists, so a confined agent can't probe the rest of the disk; on Windows another network share or device path (`\\host\share`, `\\?\UNC\…`, `\\.\…`) is refused without being contacted. "Not found" and other filesystem errors are reported only for paths inside the root. Tools that write several files into a folder name them after the document or input file, with separators, colons and control characters replaced by `_`, so a name can't lead them out of the folder. The root itself must be a folder. -- `Automation::with_automation_roots` holds a read folder and a write folder (`pdfcraft-cli mcp --automation-read-root DIR --automation-write-root DIR`, see [docs/mcp.md](../../docs/mcp.md)). Reads must lie inside the read root and writes inside the write root, links followed. A root left out grants none of its access. Refusals read `automation path rejected: outside the read root : `. It applies on top of `with_root`. +- `Automation::with_automation_roots` holds a read folder and a write folder (`pdfcraft-cli mcp --automation-read-root DIR --automation-write-root DIR`, see [docs/mcp.md](../../docs/mcp.md)). Reads must lie inside the read root and writes inside the write root, links followed. A root left out grants none of its access. Refusals read `automation path rejected: outside the read root : `. As with a root set, on Windows a path on another network share or device path is refused with that refusal without being contacted (#4003). It applies on top of `with_root`. ## Text search and cache limits diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index a7334f2069..20243dcdae 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -575,6 +575,8 @@ impl Automation { } impl Automation { + /// Recognition settings from the arguments: `dpi` (clamped to 72–600), `language` (refused + /// unless the models read it) and `skip_text_pages`, each left at its default when absent. pub(crate) fn ocr_settings(&self, a: &Args) -> Result { let mut settings = pdfcraft_engine::ocr::OcrSettings::default(); if let Some(d) = a.opt_num("dpi")? { @@ -592,6 +594,9 @@ impl Automation { Ok(settings) } + /// Recognizes text in each of `paths` and writes the result under the same file name in + /// `folder`, reporting per file its output, word count and skipped pages, or its error. A + /// path outside the root refuses the whole call before any work starts. pub(crate) fn ocr_recognize_files(&mut self, a: &Args) -> Result { let settings = self.ocr_settings(a)?; let folder = self.resolve(a.str("folder")?, true)?; @@ -601,15 +606,26 @@ impl Automation { for p in &paths { self.resolve_for(p, crate::file_access::Access::Read, true)?; } + // Two inputs that would write the same output file overwrite each other while both report + // success, so the whole batch is refused here: before the folder is created, anything is + // read, or the OCR engines load. + let targets: Vec = paths.iter().map(|p| child(&folder, &input_file_name(p))).collect(); + let shared = shared_file_names(&targets); + if !shared.is_empty() { + return Err(ToolError::InvalidArgs(format!( + "more than one input would write to the same output file, so each would replace the one before it. Colliding file names: {}. Give each input a different file name, or run them in separate batches with different folders.", + shared.join(", ") + ))); + } std::fs::create_dir_all(&folder).map_err(|e| failed(e.to_string()))?; let ocr = pdfcraft_engine::ocr::engine().map_err(failed)?; let mut out = Vec::new(); for p in paths { - let name = std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document.pdf".into()); + let name = input_file_name(p); let result = - self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| std::fs::read(&src).map_err(|e| e.to_string())).and_then(|bytes| { - pdfcraft_engine::ocr::recognize_file(&name, std::sync::Arc::new(bytes), None, settings.clone(), &ocr, |_, _| true) - }); + self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| crate::read_input_bytes(&src).map_err(|e| e.to_string())).and_then( + |bytes| pdfcraft_engine::ocr::recognize_file(&name, std::sync::Arc::new(bytes), None, settings.clone(), &ocr, |_, _| true), + ); out.push(match result { Ok(r) => { let target = child(&folder, &name); @@ -623,6 +639,8 @@ impl Automation { Ok(json!({ "files": out })) } + /// Recognizes text on the open document's `pages` (every page when absent) and adds it, + /// reporting the total word count and, per page, why it was skipped or what it found. pub(crate) fn ocr_recognize(&mut self, a: &Args) -> Result { let settings = self.ocr_settings(a)?; let pages = if a.opt_ints("pages")?.is_some() { self.pages(a, "pages")? } else { Vec::new() }; @@ -642,6 +660,8 @@ impl Automation { Ok(json!({ "words": found.iter().map(|p| p.words.len()).sum::(), "pages": list })) } + /// Whether text recognition's models are installed, the directories searched for them (in + /// order), and the languages it reads, each as a code and a name. pub(crate) fn ocr_status(&self) -> Result { use pdfcraft_engine::ocr; let dirs: Vec = ocr::Models::search_dirs().iter().map(|d| d.to_string_lossy().into_owned()).collect(); @@ -650,6 +670,39 @@ impl Automation { } } +/// The file name an input is written under: its own file name, or `document.pdf` when it has none. +fn input_file_name(path: &str) -> String { + std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document.pdf".into()) +} + +/// The output file names that two or more of `targets` share, each spelling listed once, in input +/// order. Names are compared without regard to case on Windows and macOS and exactly elsewhere; on +/// Windows a trailing dot or space is not part of the name (`scan.pdf.` is `scan.pdf`). Unicode +/// normalization forms (NFC and NFD spellings of one name) are not compared, so such inputs are +/// not caught. The folder is the same for every target, so only the names are compared. +fn shared_file_names(targets: &[std::path::PathBuf]) -> Vec { + let mut groups: std::collections::BTreeMap)> = std::collections::BTreeMap::new(); + for target in targets { + let name = target.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let key = if cfg!(windows) { + name.trim_end_matches(['.', ' ']).to_lowercase() + } else if cfg!(target_os = "macos") { + // Case only; NFC and NFD spellings of one name stay distinct here (a known limit). + name.to_lowercase() + } else { + name.clone() + }; + let (count, spellings) = groups.entry(key).or_insert_with(|| (0, Vec::new())); + *count += 1; + if !spellings.contains(&name) { + spellings.push(name); + } + } + groups.into_values().filter(|(count, _)| *count > 1).flat_map(|(_, spellings)| spellings).collect() +} + +/// A script's request to the viewer as the JSON that running a script reports; a side panel +/// goes by its fixed id. fn request_json(r: &pdfcraft_engine::js::Request) -> Value { use pdfcraft_engine::js::Request as R; match r { @@ -661,6 +714,7 @@ fn request_json(r: &pdfcraft_engine::js::Request) -> Value { R::Focus(f) => json!({ "focus": f }), R::Beep => json!({ "beep": true }), R::SaveAs => json!({ "save_as": true }), + R::ShowPanel(p) => json!({ "panel": p.id() }), } } @@ -669,7 +723,9 @@ impl Automation { let id = self.doc(a)?.id; let o = self.session.run_javascript(id, a.str("script")?, a.opt_str("field")?).map_err(failed)?; let reqs: Vec = o.requests.iter().map(request_json).collect(); - Ok(json!({ "alerts": o.alerts, "console": o.console, "requests": reqs, "error": o.error, "result": o.result })) + Ok( + json!({ "alerts": o.alerts, "console": o.console, "requests": reqs, "error": o.error, "document_errors": o.doc_errors, "result": o.result }), + ) } pub(crate) fn js_document_scripts(&self, a: &Args) -> Result { @@ -700,7 +756,7 @@ impl Automation { let mut files = Vec::new(); for p in a.strs("paths")? { let path = self.resolve(p, false)?; - files.push((p.to_string(), std::fs::read(&path).map_err(|e| failed(format!("{p}: {e}")))?)); + files.push((p.to_string(), crate::read_input_bytes(&path).map_err(|e| failed(format!("{p}: {e}")))?)); } let csv = pdfcraft_engine::merge_data_files(&files).map_err(failed)?; let target = self.resolve(a.str("path")?, true)?; @@ -845,7 +901,7 @@ impl Automation { let result = self .resolve(p, false) .map_err(|e| e.to_string()) - .and_then(|src| std::fs::read(&src).map_err(|e| e.to_string())) + .and_then(|src| crate::read_input_bytes(&src).map_err(|e| e.to_string())) .and_then(|bytes| run_on(&action, &name, std::sync::Arc::new(bytes), |_, _| {})); out.push(match result { Ok(r) => { @@ -1042,3 +1098,21 @@ mod review_html_tests { assert!(!html.contains(" + + + + + +"##; + let mut s = Session::new(); + let id = s.open("files.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(xdp)), None).expect("opens"); + s.take_js_output(id); + s.apply(id, Edit::XfaEvent { som: "form[0].files[0]".into(), activity: "click".into() }).unwrap(); + let out = s.take_js_output(id); + assert_eq!(out.requests, [pdfcraft_js::Request::ShowPanel(pdfcraft_js::Panel::Attachments)], "{out:?}"); + s.apply(id, Edit::SetFieldValue { name: "note".into(), value: FieldValue::Text("x".into()) }).unwrap(); + let out = s.take_js_output(id); + assert!(out.requests.is_empty(), "only a click shows a panel: {out:?}"); + assert!(out.console.iter().any(|c| c.contains("show a side panel")), "{:?}", out.console); +} + +/// A validate script that asks for Save As and a side panel gets neither, with a console note +/// for each; a button's script may still open Save As. +#[test] +fn a_fields_own_script_may_not_save_as_or_show_a_panel() { + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("order.pdf", None, Arc::new(scripted_form()), None).unwrap(); + s.apply( + id, + Edit::SetFieldScript { + name: "qty".into(), + event: "validate".into(), + script: Some("app.execMenuItem('SaveAs'); app.execMenuItem('ShowHideFileAttachment');".into()), + }, + ) + .unwrap(); + s.take_js_output(id); + s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("4".into()) }).unwrap(); + let out = s.take_js_output(id); + assert!(out.requests.is_empty(), "a validate script runs unasked: {out:?}"); + assert_eq!(out.console.iter().filter(|c| c.contains("only a button may")).count(), 2, "{:?}", out.console); + // A button's script may. + let o = s.run_javascript(id, "app.execMenuItem('SaveAs');", Some("go")).unwrap(); + assert_eq!(o.requests, [js::Request::SaveAs]); +} + /// A page whose forms go past the reading limit and whose picture goes past the image budget is /// listed once among the truncated pages. #[test] @@ -7479,3 +8259,109 @@ fn a_page_truncated_for_forms_and_pictures_is_listed_once() { assert!(pages[0].images.is_empty(), "the picture is past the budget"); assert_eq!(truncated, [0]); } + +/// ISO 32000-2 Table 192, 12.7.5.2.3 and Table 228 (Comb): a check box, a push button, a comb +/// field the file leaves to the viewer. The page as displayed and printed as an image shows the +/// check box's caption symbol in its text colour, the button's grey face and the comb's cells in +/// the border colour, and saving writes the file as it was. +#[test] +fn buttons_and_combs_left_to_the_viewer_are_displayed() { + let pdf = b"%PDF-1.7\n\ + 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 400 200] /Annots [6 0 R 7 0 R 8 0 R] >> endobj\n\ + 4 0 obj << /Fields [6 0 R 7 0 R 8 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\ + 5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\ + 6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (k) /V /Yes /AS /Yes /DA (/ZaDb 0 Tf 1 0 0 rg) /MK << /CA (8) /BC [0 0 0] >> /Rect [20 150 60 190] /F 4 /P 3 0 R >> endobj\n\ + 7 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (o) /DA (/Helv 12 Tf 0 g) /MK << /CA (Press) /BG [0.8 0.8 0.8] /BC [0 0 0] >> /BS << /W 2 /S /B >> /Rect [100 150 220 190] /F 4 /P 3 0 R >> endobj\n\ + 8 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /Ff 16777216 /MaxLen 4 /T (d) /V (AB) /DA (/Helv 12 Tf 0 g) /MK << /BC [0 0 1] >> /Rect [20 80 220 110] /F 4 /P 3 0 R >> endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF" + .to_vec(); + let render = |bytes: Arc>| { + let mut r = pdfcraft_render::PageRenderer::new(bytes, pdfcraft_render::RenderConfig::default()); + r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }) + }; + // Pixels of the page (drawn at scale 1, 400 x 200) in user-space box x0..x1, y0..y1 that `keep`. + let count = |page: &pdfcraft_render::RenderedPage, (x0, y0, x1, y1): (u32, u32, u32, u32), keep: &dyn Fn(&[u8; 4]) -> bool| { + let (px, _) = page.rgba.as_chunks::<4>(); + (200 - y1..200 - y0) + .flat_map(|y| (x0..x1).map(move |x| (x, y))) + .filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(keep)) + .count() + }; + let red = |p: &[u8; 4]| p[0] > 200 && p[1] < 80 && p[2] < 80; + let grey = |p: &[u8; 4]| (180..=230).contains(&p[0]) && p[0] == p[1] && p[1] == p[2]; + // Bluish: a 1 pt line on a pixel edge is half blue on each side. + let blue = |p: &[u8; 4]| p[2] > 200 && p[0] < 180 && p[1] < 180 && p[0] + 40 < p[2]; + let mut s = Session::new(); + let id = s.open("form.pdf", None, Arc::new(pdf), None).unwrap(); + let shown = render(s.get(id).unwrap().display.clone()); + assert!(count(&shown, (24, 154, 56, 186), &red) > 60, "the check box's red cross"); + assert!(count(&shown, (104, 154, 216, 186), &grey) > 1000, "the push button's grey face"); + // The comb's three inner lines at x = 70, 120 and 170, between its cells. + for x in [70, 120, 170] { + assert!(count(&shown, (x - 1, 84, x + 1, 106), &blue) >= 20, "a cell line at {x}"); + } + assert_eq!(count(&shown, (95, 84, 97, 106), &blue), 0, "no line inside a cell"); + // Print as Image: the page as a picture, at 72 dpi so it is the size of the page here. + let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false }; + let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap(); + let printed = render(Arc::new(pictures)); + assert!(count(&printed, (24, 154, 56, 186), &red) > 60, "the check box prints"); + assert!(count(&printed, (104, 154, 216, 186), &grey) > 1000, "and the push button"); + // The file is saved as it was: no widget has an appearance. + let saved = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap(); + for f in pdfcraft_forms::fields(&saved) { + assert!(saved.get(f.widgets[0].obj).as_dict().is_some_and(|d| !d.contains(b"AP")), "{} has no appearance", f.name); + } +} + +/// ISO 32000-2 Tables 166 and 183, drawn on the page: a square without an appearance whose +/// `/Border [0 0 3 [6 3]]` names its width before a dash array is stroked 3 pt wide and dashed +/// (it was 1 pt and solid), and a caret with `/Sy /P` shows a ¶ to its right. +#[test] +fn legacy_border_dashes_and_the_caret_paragraph_symbol_are_drawn() { + let original = Arc::new(pdf_with_annots(&[ + b"<< /Type /Annot /Subtype /Square /Rect [20 180 120 280] /Border [0 0 3 [6 3]] /C [1 0 0] >>", + b"<< /Type /Annot /Subtype /Caret /Rect [20 20 80 80] /Sy /P /C [0 0 1] >>", + ])); + let mut s = Session::new().with_clock(|| 1_700_000_000); + let id = s.open("noap.pdf", None, original, None).expect("opens"); + let p = shown(&s, id, 0); + let px = |x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec(); + // y down: the bottom edge's stroke is centred 1.5 pt inside /Rect, on row 118. + let row: Vec> = (24..116).map(|x| px(x, 118)).collect(); + let red = row.iter().filter(|c| c[0] > 200 && c[1] < 80).count(); + let white = row.iter().filter(|c| c[1] > 200).count(); + assert!(red > 40 && white > 15, "3 pt and dashed: {red} red, {white} white"); + // The ¶ sits in the right part of the caret's /Rect, where the caret no longer reaches. + let blue = (250..270).flat_map(|y| (68..80).map(move |x| (x, y))).filter(|&(x, y)| { + let c = px(x, y); + c[2] > 150 && c[0] < 100 + }); + assert!(blue.count() > 20, "the pilcrow is drawn beside the caret"); +} + +/// A one-page 200 × 300 document whose page lists `annots` (objects 4, 5, …). +fn pdf_with_annots(annots: &[&[u8]]) -> Vec { + let list: String = (0..annots.len()).map(|i| format!("{} 0 R ", i + 4)).collect(); + let page = format!("<< /Type /Page /Parent 2 0 R /Annots [{list}] >>"); + let mut objs: Vec<&[u8]> = + vec![b"<< /Type /Catalog /Pages 2 0 R >>", b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>", page.as_bytes()]; + objs.extend_from_slice(annots); + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + bytes.extend_from_slice(o); + bytes.extend_from_slice(b"\nendobj\n"); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + bytes +} diff --git a/crates/engine/src/word_count.rs b/crates/engine/src/word_count.rs index bd31c27e4c..feeb0a066f 100644 --- a/crates/engine/src/word_count.rs +++ b/crates/engine/src/word_count.rs @@ -110,6 +110,16 @@ fn standard_role(doc: &pdfcraft_cos::Document, map: &Dict, role: &[u8]) -> Vec bool { + let resources = page.get(b"Resources").map(|o| doc.resolve(o)); + let objects = resources.as_ref().and_then(|o| o.as_dict()).and_then(|d| d.get(b"XObject")).map(|o| doc.resolve(o)); + objects + .as_ref() + .and_then(|o| o.as_dict()) + .is_some_and(|d| d.iter().any(|(_, value)| matches!(&*doc.resolve(value), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Form")))) +} + type Exclusions = HashMap>; /// Resolve page-local MCIDs through the structure hierarchy, including inherited roles and @@ -179,11 +189,7 @@ fn exclusions(source: &ExportSource, options: Options) -> Result<(Exclusions, bo if !out.contains_key(&index) { continue; } - let resources = page.dict.get(b"Resources").map(|o| doc.resolve(o)); - let objects = resources.as_ref().and_then(|o| o.as_dict()).and_then(|d| d.get(b"XObject")).map(|o| doc.resolve(o)); - let has_form = objects.as_ref().and_then(|o| o.as_dict()).is_some_and(|d| { - d.iter().any(|(_, value)| matches!(&*doc.resolve(value), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Form"))) - }); + let has_form = page_has_form_xobject(&doc, &page.dict); if has_form { out.remove(&index); unclassified = true; diff --git a/crates/engine/src/xfa.rs b/crates/engine/src/xfa.rs index 6166426bbf..2f98efdb04 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -53,6 +53,7 @@ fn click_only(e: &XfaEffect) -> Option<&'static str> { XfaEffect::Print => Some("print"), XfaEffect::SaveAs => Some("save"), XfaEffect::LaunchUrl(_) => Some("open a link"), + XfaEffect::ShowPanel(_) => Some("show a side panel"), XfaEffect::SetFocus(_) => Some("move the focus"), _ => None, } @@ -628,6 +629,7 @@ impl<'a, 'b> Runner<'a, 'b> { XfaEffect::Print => self.run.out.requests.push(Request::Print), XfaEffect::SaveAs => self.run.out.requests.push(Request::SaveAs), XfaEffect::LaunchUrl(u) => self.run.out.requests.push(Request::LaunchUrl(u.clone())), + XfaEffect::ShowPanel(p) => self.run.out.requests.push(Request::ShowPanel(*p)), XfaEffect::SetFocus(som) => { if let Some(name) = self.by_som().get(som).cloned() { self.run.out.requests.push(Request::Focus(name)); @@ -858,6 +860,28 @@ pub(crate) fn on_data_replaced(doc: &mut pdfcraft_cos::Document, tpl: &Template, r.finish().map(|_| ()) } +/// A text value typed into the laid-out XFA date field `name` by another of its edit picture's +/// patterns, written in the first ([`pdfcraft_xfa::typed_date`]); `None` for anything else. +/// `tpl` is the template the scripts use, when they run; otherwise it is read from `doc`. +pub(crate) fn typed_date( + doc: &pdfcraft_cos::Document, + tpl: Option<&Template>, + name: &str, + value: &pdfcraft_forms::FieldValue, +) -> Option { + let pdfcraft_forms::FieldValue::Text(text) = value else { return None }; + let som = pdfcraft_xfa::fields_by_som(doc).into_iter().find_map(|(som, n)| (n == name).then_some(som))?; + let read; + let tpl = match tpl { + Some(t) => t, + None => { + read = pdfcraft_xfa::template_of(doc).ok()?; + &read + } + }; + pdfcraft_xfa::typed_date(tpl, &som, text).map(pdfcraft_forms::FieldValue::Text) +} + /// A button's click script (or any event, by SOM path and activity), then calculations. pub(crate) fn on_event(doc: &mut pdfcraft_cos::Document, tpl: &Template, som: &str, activity: &str, run: &mut XfaRun) -> Result<(), String> { let mut r = Runner::new(doc, tpl, run, EVENT_BUDGET_MS); diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index c8dc65951c..1303254d66 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -256,7 +256,7 @@ pub(crate) fn tables(blocks: &[Block]) -> (Vec, Vec) { } let right = used.iter().map(|&i| blocks[i].rect[2]).fold(f64::MIN, f64::max) + COL_TOL; let mut rows_out = Vec::new(); - let mut slots: Vec> = vec![None; ncols]; + let mut slots: Vec)>> = vec![None; ncols]; for row in run.drain(..) { for slot in slots.iter_mut() { *slot = None; @@ -266,20 +266,28 @@ pub(crate) fn tables(blocks: &[Block]) -> (Vec
, Vec) { let Some(slot) = edges.iter().position(|e| (*e - b.rect[0]).abs() <= COL_TOL) else { continue }; let span = edges[slot + 1..].iter().filter(|e| **e > b.rect[0] + COL_TOL && **e < b.rect[2] - COL_TOL).count() + 1; let cell = Cell { text: b.text.trim().to_string(), size: b.size, bold: b.bold, italic: b.italic, color: b.color, span }; - match &mut slots[slot] { - Some(c) => { + match slots.get_mut(slot) { + Some(Some((c, sources))) => { c.text.push(' '); c.text.push_str(&cell.text); + sources.push(bi); } - None => slots[slot] = Some(cell), + Some(empty @ None) => *empty = Some((cell, vec![bi])), + None => continue, } - consumed[bi] = true; } let mut materialized = Vec::with_capacity(ncols); let mut column = 0; while column < ncols { - match slots[column].take() { - Some(mut cell) => { + match slots.get_mut(column).and_then(Option::take) { + Some((mut cell, sources)) => { + // A spanning cell can skip later slots. Only consume source blocks + // when their text is emitted, leaving covered text in paragraph flow. + for bi in sources { + if let Some(taken) = consumed.get_mut(bi) { + *taken = true; + } + } cell.span = cell.span.min(ncols - column).max(1); column += cell.span; materialized.push(cell); @@ -517,17 +525,18 @@ fn docx_table(t: &Table) -> String { s.push_str(""); for row in &t.rows { s.push_str(""); + let mut column = 0usize; for c in row { let span = if c.span > 1 { format!("", c.span) } else { String::new() }; - let w: i64 = widths.iter().take(c.span.min(widths.len())).sum(); + let w: i64 = widths.iter().skip(column).take(c.span).sum(); s.push_str(&format!( "{span}{}", run_xml(&c.text, c.size, c.bold, c.italic, c.color) )); + column = column.saturating_add(c.span); } // Pad the grid so every row covers all columns (Word rejects short rows). - let used: usize = row.iter().map(|c| c.span).sum(); - for _ in used..t.cols.len() { + for _ in column..t.cols.len() { s.push_str(""); } s.push_str(""); @@ -1040,6 +1049,27 @@ mod tests { assert!(xml.contains("")); } + #[test] + fn word_table_cell_widths_follow_the_columns_they_span() { + let cell = |text: &str, span| Cell { text: text.into(), size: 11.0, bold: false, italic: false, color: [0.0; 3], span }; + let table = Table { + rect: [0.0, 0.0, 300.0, 100.0], + cols: vec![0.0, 50.0, 150.0], + rows: vec![ + vec![cell("First", 1), cell("Second", 1), cell("Third", 1)], + vec![cell("First", 1), cell("Last two", 2)], + vec![cell("First two", 2), cell("Third", 1)], + vec![cell("All", 3)], + vec![cell("First", 1)], + ], + }; + let xml = docx_table(&table); + assert!(xml.contains(""), "{xml}"); + let widths: Vec> = + xml.split("").skip(1).map(|row| row.split(""), "the prize table was lost: {h}"); assert!(h.contains("30,000") && h.contains("15,000"), "{h}"); + let (tables, consumed) = tables(&p.blocks); + assert!(consumed.iter().all(|used| *used), "ordinary table continuations stay in their cells"); + let cells: Vec<&str> = tables.iter().flat_map(|t| &t.rows).flatten().map(|c| c.text.as_str()).collect(); + assert!(["st", "nd", "rd", "th"].iter().all(|suffix| cells.contains(suffix))); } #[test] diff --git a/crates/forms/README.md b/crates/forms/README.md index ac48d6c588..4aec223ee9 100644 --- a/crates/forms/README.md +++ b/crates/forms/README.md @@ -39,8 +39,9 @@ let cells: Vec = grid.cells_for(names)?; // "Table1_1", "Table - Inheritable attributes (`/FT`, `/Ff`, `/V`, `/DV`, `/DA`, `/Q`, `/MaxLen`) are resolved down the field tree; the form's `/DA` and `/Q` are the last fallback. -- Text and choice fields get new appearance streams (`/Tx BMC … EMC`): auto-size (`0 Tf`), comb, - multiline wrapping, password masking, quadding, `/MK` background and border, list-box selection. +- Text and choice fields get new appearance streams (`/Tx BMC … EMC`): auto-size (`0 Tf`), comb + (cells parted in the border colour), multiline wrapping, password masking, quadding, `/MK` + background and border, list-box selection. The `/DA` font is used when `/DR` has it as a simple font or a composite font with a predefined Unicode CMap; otherwise Helvetica (WinAnsi). A value or button caption the font can't hold (Chinese in a Helvetica field, a character beyond U+FFFF in a UCS-2 CMap) is kept, as Acrobat @@ -55,7 +56,9 @@ let cells: Vec = grid.cells_for(names)?; // "Table1_1", "Table drawn from their values, as typing them would draw them; the engine shows and prints that copy and saves the file as it was (#3470). `with_field_appearances_cached` keeps each widget's drawing while the objects it was drawn from are the same (`FieldAppearanceCache`), so typing in - one field redraws that field, not the form. + one field redraws that field, not the form. Check boxes and radio buttons with no appearance for + their state are drawn from `/MK` (border, background, and when on the `/MK /CA` ZapfDingbats + symbol in the `/DA` colour), and push buttons with none from `/MK`, `/BS` and their caption. - Check boxes and radio buttons switch `/V` and each widget's `/AS`. Widgets with no appearances get PdfCraft's own (a drawn check mark or dot; no symbol font needed). - Imported `/MaxLen 0` is treated as unlimited (also clearing an inherited limit), while the diff --git a/crates/forms/src/actions.rs b/crates/forms/src/actions.rs index cdc89e5632..f58bd10783 100644 --- a/crates/forms/src/actions.rs +++ b/crates/forms/src/actions.rs @@ -159,6 +159,60 @@ pub fn field_actions(doc: &Document, name: &str) -> Result usize { + let mut seen = std::collections::HashSet::new(); + if let Object::Ref(r) = action { + seen.insert(*r); + } + let mut stack: Vec = doc.resolve(action).as_dict().and_then(|d| d.get(b"Next").cloned()).into_iter().collect(); + let mut count = 0; + while let Some(o) = stack.pop() { + if count >= MAX_CHAIN { + break; + } + if let Object::Ref(r) = &o + && !seen.insert(*r) + { + continue; + } + match &*doc.resolve(&o) { + Object::Array(items) => stack.extend(items.iter().cloned()), + Object::Dict(d) => { + count += 1; + stack.extend(d.get(b"Next").cloned()); + } + _ => {} + } + } + count +} + +/// For each trigger of field `name` (its first widget) whose action has others chained after it, +/// how many there are. +pub fn field_action_chains(doc: &Document, name: &str) -> Result, FormError> { + let all = fields(doc); + let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; + let Some(w) = f.widgets.first() else { return Ok(Vec::new()) }; + let wd = doc.get(w.obj).as_dict().cloned().unwrap_or_default(); + let aa = wd.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).unwrap_or_default(); + Ok(Trigger::ALL + .into_iter() + .filter_map(|t| { + let o = match t.key() { + None => wd.get(b"A").cloned().or_else(|| aa.get(b"U").cloned()), + Some(k) => aa.get(k).cloned(), + }?; + let n = chained(doc, &o); + (n > 0).then_some((t, n)) + }) + .collect()) +} + fn write(doc: &mut Document, a: &FieldAction, pages: &[pdfcraft_cos::ObjRef]) -> Result, FormError> { let mut d = Dict::new(); d.set(b"Type".to_vec(), Object::name("Action")); @@ -207,11 +261,13 @@ fn write(doc: &mut Document, a: &FieldAction, pages: &[pdfcraft_cos::ObjRef]) -> } /// Replace field `name`'s actions (on every widget) with `actions`; triggers not listed lose -/// theirs. `Other` actions are left as they are. +/// theirs. `Other` actions, and actions that read the same as before, are left as they are (with +/// their chain and the keys this module doesn't read); a replaced action keeps its chain. pub fn set_field_actions(doc: &mut Document, name: &str, actions: &[(Trigger, FieldAction)]) -> Result<(), FormError> { let all = fields(doc); let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?.clone(); let pages = crate::page_refs(doc); + let mut scripts = crate::script_budget(); for w in &f.widgets { let wd = doc.get(w.obj).as_dict().cloned().unwrap_or_default(); let mut aa = wd.get(b"AA").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).unwrap_or_default(); @@ -221,10 +277,23 @@ pub fn set_field_actions(doc: &mut Document, name: &str, actions: &[(Trigger, Fi if matches!(new, Some(FieldAction::Other(_))) { continue; } + let old = match t.key() { + None => wd.get(b"A").cloned().or_else(|| aa.get(b"U").cloned()), + Some(k) => aa.get(k).cloned(), + }; + let old_action = old.as_ref().and_then(|o| read(doc, o, &pages, &mut scripts)); + if new.is_some() && new == old_action.as_ref() { + continue; + } + // The list shows a trigger's first action: replacing it keeps the ones chained after it. + let next = new.and(old).and_then(|o| doc.resolve(&o).as_dict().and_then(|d| d.get(b"Next").cloned())); let obj = match new { Some(a) => write(doc, a, &pages)?, None => None, }; + if let (Some(Object::Ref(r)), Some(next)) = (&obj, next) { + doc.update_dict(*r, |d| d.set(b"Next".to_vec(), next))?; + } match t.key() { None => { a_entry = obj; diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index 3b6f6f20d6..da25c211c0 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -72,6 +72,134 @@ pub fn auto_text_size_by(width: f64, height: f64, multiline: bool, line_width: i if tw.is_finite() && tw > width { (size * width / tw).max(4.0) } else { size } } +/// How a text field's widget lays its value out in [`field_appearance_drawn`], for asking +/// whether a value fits it ([`TextBox::fits`]): what a field that doesn't scroll (`DoNotScroll`, +/// ISO 32000-2 Table 228) still takes. Built from the same pieces the appearance uses: the `/DA` +/// font from the form's `/DR` (Helvetica where that font can't show the text), the room inside +/// the border and the 2 pt padding, the `/DA` size (or the automatic size), and wrapping for a +/// multi-line field; the two must change together. +#[derive(Clone, Debug, PartialEq)] +pub struct TextBox { + /// The `/DA` font from `/DR`, when it is one the appearance can draw with. + font: Option, + /// Width and height inside the border and padding. + inner_w: f64, + inner_h: f64, + /// The `/DA` size; `None` for an automatic (`0 Tf`) size, which shrinks to fit. + size: Option, + multiline: bool, + password: bool, + /// A comb field: its cells, not its width, bound it (`/MaxLen`). + comb: bool, +} + +/// The [`TextBox`] widget `w` of text field `f` lays its value out in. +pub fn text_box(doc: &Document, f: &Field, w: &Widget) -> TextBox { + let wobj = doc.get(w.obj); + let wd = wobj.as_dict().cloned().unwrap_or_default(); + let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0)); + let place = placement(mk_rotation(doc, &wd), page_w, page_h); + let da = parse_da(wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).as_deref().unwrap_or(&f.da)); + let pad = 2.0 + border_width(doc, &wd); + let multiline = f.has(flags::MULTILINE); + TextBox { + font: dr_font(doc, &da.font).map(|(_, enc)| enc), + inner_w: (place.layout_w - 2.0 * pad).max(1.0), + inner_h: place.layout_h - 2.0 * pad, + size: (da.size > 0.0 && da.size.is_finite()).then_some(da.size), + multiline, + password: f.has(flags::PASSWORD), + comb: f.has(flags::COMB) && !multiline && !f.has(flags::PASSWORD) && f.max_len.is_some_and(|m| m > 0), + } +} + +impl TextBox { + /// Whether `text` fits: on one line within the width, or, multi-line, wrapped within the + /// height, as the appearance draws it. An automatic size shrinks before text stops fitting, + /// so such a field is full only when the text no longer fits at its smallest size (4 pt). A + /// comb field always fits (its `/MaxLen` bounds it). + pub fn fits(&self, text: &str) -> bool { + if self.comb || text.is_empty() { + return true; + } + let shown: String = if self.password { "*".repeat(text.chars().count()) } else { text.to_string() }; + // The font the appearance would choose for this text. + let can_show = |enc: &FontText| if self.multiline { shown.split(['\n', '\r']).all(|l| enc.can_encode(l)) } else { enc.can_encode(&shown) }; + let font = self.font.as_ref().filter(|enc| can_show(enc)).unwrap_or(&FontText::WinAnsi); + let width_of = |t: &str, size: f64| font.width(t, size); + // The smallest an automatic size goes ([`auto_text_size_by`]). + let size = self.size.unwrap_or(4.0); + if self.multiline { + // As the appearance draws it: the first baseline is 0.85 em below the padded top and + // a line is drawn while its descent (0.207 em) stays inside the border, so the + // padding below the last line is not needed: `inner_h` plus 2 pt. + // Long words break by character, so a line is wider than the box only when it is a + // single character wider than the box, which the appearance still cuts at the sides. + let lines = wrap_with(&shown, size, self.inner_w, width_of); + let above = lines.len().saturating_sub(1) as f64; + above * size * 1.15 + 1.057 * size <= self.inner_h + 2.0 + 0.01 && lines.iter().all(|l| width_of(l, size) <= self.inner_w + 0.01) + } else { + // A line break in a single-line value is drawn as a space. + let line: String = shown.chars().map(|c| if matches!(c, '\n' | '\r') { ' ' } else { c }).collect(); + width_of(&line, size) <= self.inner_w + 1e-6 + } + } + + /// An edit from `before` to `after` as a field that doesn't scroll takes it: all of it when + /// `after` fits, and otherwise as much of the text it inserted (typed or pasted) as still + /// fits, keeping what it removed removed. Returns the text and where the caret goes (in + /// characters, after what was kept of the insertion). + pub fn fitted_edit(&self, before: &str, after: &str) -> (String, usize) { + let (b, a): (Vec, Vec) = (before.chars().collect(), after.chars().collect()); + // The edit replaced b[p..b.len() - q] with a[p..a.len() - q]. + let p = b.iter().zip(&a).take_while(|(x, y)| x == y).count(); + let most = b.len().min(a.len()).saturating_sub(p); + let q = b.iter().rev().zip(a.iter().rev()).take(most).take_while(|(x, y)| x == y).count(); + // q <= a.len() - p, so these are in range; `get` keeps it so if that ever changes. + let end = a.len().saturating_sub(q); + let (prefix, inserted, suffix) = (a.get(..p).unwrap_or_default(), a.get(p..end).unwrap_or_default(), a.get(end..).unwrap_or_default()); + let with = |k: usize| prefix.iter().chain(inserted.get(..k).unwrap_or_default()).chain(suffix).collect::(); + if self.fits(after) { + return (after.to_string(), p + inserted.len()); + } + // The longest of the insertion's first `from..=to` characters that fits, halving: in one + // font a longer text is never narrower. `None` when even `from` doesn't fit. + let longest = |from: usize, to: usize| -> Option { + if from > to || !self.fits(&with(from)) { + return None; + } + let (mut lo, mut hi) = (from, to); + while lo < hi { + let mid = lo + (hi - lo).div_ceil(2); + if self.fits(&with(mid)) { lo = mid } else { hi = mid - 1 } + } + Some(lo) + }; + // The font can change once, from the form's own to Helvetica, at the first inserted + // character the form's font can't show: each side of that point is searched on its own. + let n = inserted.len(); + let kept = match self.font_switch(prefix, inserted, suffix) { + Some(t) => longest(0, t.saturating_sub(1)).into_iter().chain(longest(t, n)).max(), + None => longest(0, n), + } + .unwrap_or(0); + (with(kept), p + kept) + } + + /// Where an insertion changes the font [`Self::fits`] measures in: how much of `inserted` + /// (between `prefix` and `suffix`) first includes a character the form's own font can't show, + /// when everything around it can be shown in that font. `None` when the font never changes. + fn font_switch(&self, prefix: &[char], inserted: &[char], suffix: &[char]) -> Option { + let enc = self.font.as_ref().filter(|_| !self.password)?; + // As `fits` decides: a multi-line value is checked line by line, its breaks aside. + let shows = |c: &char| (self.multiline && matches!(c, '\n' | '\r')) || enc.can_encode(&c.to_string()); + if !prefix.iter().chain(suffix).all(shows) { + return None; + } + inserted.iter().position(|c| !shows(c)).map(|i| i + 1) + } +} + /// `/MK /R` as a quadrant, or 0 when it is missing or not a multiple of 90. pub(crate) fn mk_rotation(doc: &Document, wd: &Dict) -> i64 { let Some(raw) = wd.get(b"MK") else { return 0 }; @@ -277,10 +405,14 @@ fn stroke_op(fill: &str) -> String { } } -/// Background and border from `/MK` and `/BS`; returns (content, border width). +/// Background and border from `/MK` and `/BS`; returns (content, border width). A round XFA +/// check button's ([`is_round`]) are a circle inside the widget instead of its rectangle. fn frame(doc: &Document, wd: &Dict, w: f64, h: f64) -> (String, f64) { let mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()); let mut c = String::new(); + if is_round(doc, wd) { + return round_frame(doc, wd, mk.as_ref(), w, h); + } if let Some(bg) = color_array(doc, mk.as_ref(), b"BG") { c.push_str(&format!("{bg}\n0 0 {} {} re f\n", n(w), n(h))); } @@ -346,12 +478,89 @@ fn frame(doc: &Document, wd: &Dict, w: f64, h: f64) -> (String, f64) { (c, 0.0) } +/// 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 { + matches!(wd.get(b"PCXfaRound").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))) +} + +/// [`frame`] for a round check button: the `/MK /BG` background as a disc and the `/MK /BC` +/// border as a circle, both centred in the `w` × `h` box and as wide as its shorter side. A +/// dashed border stays dashed and an underline stays an underline; any other style is drawn +/// solid, and a border as wide as the circle fills it. Returns the border width. +fn round_frame(doc: &Document, wd: &Dict, mk: Option<&Dict>, w: f64, h: f64) -> (String, f64) { + let mut c = String::new(); + let (cx, cy, r) = (w / 2.0, h / 2.0, w.min(h).max(0.0) / 2.0); + if let Some(bg) = color_array(doc, mk, b"BG") { + c.push_str(&format!("{bg}\n{}f\n", circle_path(cx, cy, r))); + } + let bs = wd.get(b"BS").map(|b| doc.resolve(b)).and_then(|b| b.as_dict().cloned()); + let bw = bs.as_ref().and_then(|b| b.get(b"W")).and_then(|x| doc.resolve(x).as_f64()).unwrap_or(1.0).max(0.0); + let style = bs.as_ref().and_then(|b| b.name(b"S").map(<[u8]>::to_vec)).unwrap_or_default(); + match color_array(doc, mk, b"BC") { + // An underline stays one: only the bottom edge, as for any widget. + Some(bc) if bw > 0.0 && style == b"U" => { + c.push_str(&format!("{}\n{} w\n0 {} m {} {} l S\n", stroke_op(&bc), n(bw), n(bw / 2.0), n(w), n(bw / 2.0))); + (c, bw) + } + // A border as wide as the circle fills it: a disc in the border's colour. + Some(bc) if bw > 0.0 && bw >= 2.0 * r => { + c.push_str(&format!("{bc}\n{}f\n", circle_path(cx, cy, r))); + (c, 2.0 * r) + } + Some(bc) if bw > 0.0 => { + let dashed = style == b"D"; + // The stroke is centred on the path: inset by half its width, it stays inside. + let path = circle_path(cx, cy, r - bw / 2.0); + c.push_str(&format!("{}\n{} w\n{}{path}S\n[] 0 d\n", stroke_op(&bc), n(bw), if dashed { "[3] 0 d\n" } else { "" })); + (c, bw) + } + _ => (c, 0.0), + } +} + +/// A closed circle of radius `r` around (`cx`, `cy`) as four Bézier arcs, ending with a space +/// so a paint operator can follow. +fn circle_path(cx: f64, cy: f64, r: f64) -> String { + // The control-point distance for a quarter circle. + let k = 0.5523 * r; + format!( + "{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c ", + n(cx + r), + n(cy), + n(cx + r), + n(cy + k), + n(cx + k), + n(cy + r), + n(cx), + n(cy + r), + n(cx - k), + n(cy + r), + n(cx - r), + n(cy + k), + n(cx - r), + n(cy), + n(cx - r), + n(cy - k), + n(cx - k), + n(cy - r), + n(cx), + n(cy - r), + n(cx + k), + n(cy - r), + n(cx + r), + n(cy - k), + n(cx + r), + n(cy) + ) +} + /// The width [`frame`] insets the content by: the `/BS` border width when the widget has a /// border colour, doubled for beveled and inset borders, and 0 without a border. fn border_width(doc: &Document, wd: &Dict) -> f64 { let mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()); let bs = wd.get(b"BS").map(|b| doc.resolve(b)).and_then(|b| b.as_dict().cloned()); - let bw = bs.as_ref().and_then(|b| b.get(b"W")).and_then(|x| doc.resolve(x).as_f64()).unwrap_or(1.0).max(0.0); + let bw = line_width(doc, wd); if color_array(doc, mk.as_ref(), b"BC").is_none() || bw <= 0.0 { return 0.0; } @@ -359,6 +568,37 @@ fn border_width(doc: &Document, wd: &Dict) -> f64 { if style == b"B" || style == b"I" { 2.0 * bw } else { bw } } +/// The widget's border line width, `/BS /W` (default 1, Table 168), as [`frame`] strokes it. +fn line_width(doc: &Document, wd: &Dict) -> f64 { + let bs = wd.get(b"BS").map(|b| doc.resolve(b)).and_then(|b| b.as_dict().cloned()); + // `f64::max` turns NaN into 0, as it does a negative width. + bs.as_ref().and_then(|b| b.get(b"W")).and_then(|x| doc.resolve(x).as_f64()).unwrap_or(1.0).max(0.0) +} + +/// Comb fields stop drawing their cells past this many; they would be a solid block anyway. +const MAX_COMB_DIVIDERS: usize = 500; + +/// The lines between the `cells` positions of a comb field `w` × `h` (ISO 32000-2 Table 228, +/// Comb: the field is divided into `MaxLen` equally spaced positions), in the border's colour and +/// width, inside the border. None without a border, or past [`MAX_COMB_DIVIDERS`] cells. +fn comb_dividers(doc: &Document, wd: &Dict, w: f64, h: f64, cells: usize) -> String { + let mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()); + let inset = border_width(doc, wd); + let lw = line_width(doc, wd); + let Some(bc) = color_array(doc, mk.as_ref(), b"BC") else { return String::new() }; + if !(inset > 0.0 && inset.is_finite() && lw > 0.0 && lw.is_finite()) || !(2..=MAX_COMB_DIVIDERS).contains(&cells) { + return String::new(); + } + let cell = w / cells as f64; + let mut c = format!("{}\n{} w\n", stroke_op(&bc), n(lw)); + for i in 1..cells { + let x = cell * i as f64; + c.push_str(&format!("{} {} m {} {} l\n", n(x), n(inset), n(x), n(h - inset))); + } + c.push_str("S\n"); + c +} + /// Where a list box draws its options: one row per option from the top, starting with option /// `top` (`/TI`, §12.7.4.4). The appearance PdfCraft writes and the viewer's in-place list share /// it, so a click picks the option drawn under the pointer (#1524). @@ -449,6 +689,45 @@ pub fn field_appearance_as(doc: &Document, f: &Field, w: &Widget, values: &[Stri /// hold it: the form's font is a simple (WinAnsi) font, Helvetica stands in for one PdfCraft can't /// write with (an embedded subset, `Identity-H`), or a UCS-2 CMap meets a character beyond U+FFFF. pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[String], format: bool) -> (Stream, Option) { + let (stream, unencodable, _) = field_appearance_fit(doc, f, w, values, format); + (stream, unencodable) +} + +/// A text value that its box cuts off at the field's font size (#4018). +#[derive(Clone, Debug, PartialEq)] +pub enum Overflow { + /// One line `width` points wide, with `room` for it, at `size` points. + Wide { width: f64, room: f64, size: f64 }, + /// `lines` wrapped lines, of which the box shows `room`, at `size` points. + Tall { lines: usize, room: usize, size: f64 }, + /// One line `height` points high (ascent to descent), with `room` for it, at `size` points. + High { height: f64, room: f64, size: f64 }, +} + +impl std::fmt::Display for Overflow { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Wide { width, room, size } => { + write!(f, "the value does not fit: {} pt wide at {} pt, room for {} pt", fmt(*width), fmt(*size), fmt(*room)) + } + Self::Tall { lines, room, size } => write!(f, "the value does not fit: {lines} lines at {} pt, room for {room}", fmt(*size)), + Self::High { height, room, size } => { + write!(f, "the value does not fit: {} pt high at {} pt, room for {} pt", fmt(*height), fmt(*size), fmt(*room)) + } + } + } +} + +/// Whether the text field's value, drawn in widget `w`, is cut off by its box. +pub fn value_overflow(doc: &Document, f: &Field, w: &Widget) -> Option { + if f.kind != FieldKind::Text { + return None; + } + field_appearance_fit(doc, f, w, &f.value, true).2 +} + +/// [`field_appearance_drawn`], and whether the text drawn is cut off by the box. +fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String], format: bool) -> (Stream, Option, Option) { // The Format event: what is shown, not what is stored. let formatted: Vec; let values = if format @@ -513,6 +792,7 @@ pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[S } }; let mut size = da.size; + let mut overflow = None; match f.kind { FieldKind::List => { // Every option, one per line from the top (from `/TI`); selected ones highlighted. @@ -539,13 +819,25 @@ pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[S size = auto_text_size(&text, inner_w, height - 2.0 * pad, true, width_of); } let mut y = height - pad - size * 0.85; - for line in wrap_with(&text, size, inner_w, width_of) { + let lines = wrap_with(&text, size, inner_w, width_of); + // The lines whose descent stays inside the border; the rest are cut off. + let mut room = 0; + for line in &lines { if y < -size { break; } - show(&mut body, x_for(&line, size), y, &line); + if y - 0.207 * size >= bw - 0.01 { + room += 1; + } + show(&mut body, x_for(line, size), y, line); y -= size * 1.15; } + if room < lines.len() { + overflow = Some(Overflow::Tall { lines: lines.len(), room, size }); + } else if let Some(tw) = lines.iter().map(|l| width_of(l, size)).filter(|tw| *tw > inner_w + 0.01).reduce(f64::max) { + // A character wider than the box is a line of its own, still cut at the sides. + overflow = Some(Overflow::Wide { width: tw, room: inner_w, size }); + } } else { if size == 0.0 { // Comb cells hold one character each, so only the height limits them. @@ -557,14 +849,24 @@ pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[S } // Centre Helvetica's ascent (0.718) and descent (0.207) vertically. let y = (height - 0.925 * size) / 2.0 + 0.207 * size; + // The clip box is inside the border; a line taller than it is cut at the top and bottom. + let room_h = height - 2.0 * bw; + if 0.925 * size > room_h + 0.01 { + overflow = Some(Overflow::High { height: 0.925 * size, room: room_h, size }); + } if comb { let cells = f.max_len.unwrap_or(1).max(1); let cell = width / cells as f64; + c.push_str(&comb_dividers(doc, &wd, width, height, cells)); for (i, ch) in text.chars().take(cells).enumerate() { let s = ch.to_string(); show(&mut body, cell * i as f64 + (cell - width_of(&s, size)) / 2.0, y, &s); } } else { + let tw = width_of(&text.replace(['\n', '\r'], " "), size); + if overflow.is_none() && tw > inner_w + 0.01 { + overflow = Some(Overflow::Wide { width: tw, room: inner_w, size }); + } show(&mut body, x_for(&text, size), y, &text); } } @@ -595,13 +897,17 @@ pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[S d.set(b"Subtype".to_vec(), Object::name("Form")); set_form_box(&mut d, place); d.set(b"Resources".to_vec(), Object::Dict(res)); - (Stream::flate(d, &content), unencodable) + // Nothing drawn is nothing cut, however small the box. + let overflow = overflow.filter(|_| !text.is_empty()); + (Stream::flate(d, &content), unencodable, overflow) } /// On/Off appearances for a check box or radio button that has none (a check mark or a dot, /// drawn as paths, so no symbol font is needed). +/// The mark is drawn in the colour of the widget's `/DA`, else of `f_da` (the field's): the +/// Appearance tab's Text Color (#4261). /// The streams are added to `doc` as indirect objects (streams can't be direct objects). -pub fn check_box_states(doc: &mut Document, w: &Widget, kind: FieldKind, on_name: &str) -> Dict { +pub fn check_box_states(doc: &mut Document, w: &Widget, kind: FieldKind, on_name: &str, f_da: &str) -> Dict { let wobj = doc.get(w.obj); let wd = wobj.as_dict().cloned().unwrap_or_default(); let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0)); @@ -609,6 +915,7 @@ pub fn check_box_states(doc: &mut Document, w: &Widget, kind: FieldKind, on_name let (width, height) = (place.layout_w, place.layout_h); let (frame_c, _) = frame(doc, &wd, width, height); let style = crate::author::CheckStyle::of_widget(doc, &wd, kind); + let da = parse_da(wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).as_deref().unwrap_or(f_da)); let mut form = |content: String| -> Object { let mut d = Dict::new(); d.set(b"Type".to_vec(), Object::name("XObject")); @@ -617,7 +924,7 @@ pub fn check_box_states(doc: &mut Document, w: &Widget, kind: FieldKind, on_name Object::Ref(doc.add(Object::Stream(Stream::flate(d, content.as_bytes())))) }; let s = width.min(height); - let mark = check_mark(style, width / 2.0, height / 2.0, s); + let mark = check_mark(style, width / 2.0, height / 2.0, s, &da.color); let mut nd = Dict::new(); nd.set(crate::name_bytes(on_name), form(format!("{frame_c}{mark}"))); nd.set(b"Off".to_vec(), form(frame_c)); @@ -627,12 +934,13 @@ pub fn check_box_states(doc: &mut Document, w: &Widget, kind: FieldKind, on_name } /// The on-state mark for `style` (#94), centred on (`cx`, `cy`) in a box whose smaller side is -/// `s`, drawn in black. -fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64) -> String { +/// `s`, drawn in the fill colour operation `color` (`0 g`, `0 0 1 rg`). +fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64, color: &str) -> String { use crate::author::CheckStyle; + let (fill, stroke) = (color, stroke_op(color)); // A filled polygon through `pts`. let polygon = |pts: &[(f64, f64)]| { - let mut c = String::from("0 g\n"); + let mut c = format!("{fill}\n"); for (i, (x, y)) in pts.iter().enumerate() { c.push_str(&format!("{} {} {} ", n(*x), n(*y), if i == 0 { "m" } else { "l" })); } @@ -642,7 +950,7 @@ fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64) -> Str let line = n((s * 0.1).max(1.0)); match style { CheckStyle::Check => format!( - "0 G\n{line} w 1 J 1 j\n{} {} m {} {} l {} {} l S\n", + "{stroke}\n{line} w 1 J 1 j\n{} {} m {} {} l {} {} l S\n", n(cx - s * 0.28), n(cy), n(cx - s * 0.08), @@ -653,7 +961,7 @@ fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64) -> Str CheckStyle::Cross => { let d = s * 0.25; format!( - "0 G\n{line} w 1 J\n{} {} m {} {} l S\n{} {} m {} {} l S\n", + "{stroke}\n{line} w 1 J\n{} {} m {} {} l S\n{} {} m {} {} l S\n", n(cx - d), n(cy - d), n(cx + d), @@ -666,7 +974,7 @@ fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64) -> Str } CheckStyle::Square => { let d = s * 0.22; - format!("0 g\n{} {} {} {} re f\n", n(cx - d), n(cy - d), n(2.0 * d), n(2.0 * d)) + format!("{fill}\n{} {} {} {} re f\n", n(cx - d), n(cy - d), n(2.0 * d), n(2.0 * d)) } CheckStyle::Diamond => { let d = s * 0.3; @@ -688,7 +996,7 @@ fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64) -> Str let r = s * 0.25; let k = 0.5523 * r; format!( - "0 g\n{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c f\n", + "{fill}\n{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c f\n", n(cx + r), n(cy), n(cx + r), @@ -720,6 +1028,94 @@ fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64) -> Str } } +/// How a check box or radio button widget `w` of field `f` looks in state on (`on`) or Off when +/// the file has no appearance for that state: its `/MK` background and border ([`frame`]), and +/// when on, its caption `/MK /CA` (ISO 32000-2 Table 192: the normal caption, which check boxes +/// and radio buttons may use too) as a ZapfDingbats character, as in the check box example of +/// 12.7.5.2.3: `4` (a check mark) by default, `l` (a dot) for a radio button. The character is +/// drawn in the `/DA` colour, at the `/DA` size or, for an automatic size (`0 Tf`), filling most +/// of the box. ZapfDingbats is one of the standard 14 fonts, so no font program is needed. +pub(crate) fn button_state_shown(doc: &Document, f: &Field, w: &Widget, on: bool) -> Stream { + let wobj = doc.get(w.obj); + let wd = wobj.as_dict().cloned().unwrap_or_default(); + let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0)); + let place = placement(mk_rotation(doc, &wd), page_w, page_h); + let (width, height) = (place.layout_w, place.layout_h); + let (frame_c, bw) = frame(doc, &wd, width, height); + let mut content = frame_c.into_bytes(); + let mut res = Dict::new(); + if on { + let mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()); + let caption = mk.as_ref().and_then(|mk| mk.get(b"CA")).and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())); + let dingbats = pdfcraft_fonts::Std14::ZapfDingbats; + // The caption's first character as a ZapfDingbats code, when the font has a glyph there. + let code = caption + .and_then(|s| s.chars().next()) + .and_then(|ch| u8::try_from(u32::from(ch)).ok()) + .filter(|&code| dingbats.width(code) > 0.0) + .unwrap_or(if f.kind == FieldKind::Radio { b'l' } else { b'4' }); + let da = parse_da(wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).as_deref().unwrap_or(&f.da)); + // The glyph's advance and (about) its height, in ems. + let (advance, tall) = (dingbats.width(code) / 1000.0, 0.75); + let room = |side: f64| (side - 2.0 * bw - 2.0).max(1.0); + // An automatic size leaves a margin of a tenth of the room on each side. + let size = if da.size > 0.0 { da.size } else { (0.8 * room(width) / advance.max(0.1)).min(0.8 * room(height) / tall).clamp(1.0, 300.0) }; + let (x, y) = ((width - advance * size) / 2.0, (height - tall * size) / 2.0 + 0.05 * size); + content.extend(format!("q\nBT\n/ZaDb {} Tf\n{}\n{} {} Td\n", n(size), da.color, n(x), n(y)).bytes()); + content.extend(literal(&[code])); + content.extend_from_slice(b" Tj\nET\nQ\n"); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type1")); + font.set(b"BaseFont".to_vec(), Object::name("ZapfDingbats")); + let mut fonts = Dict::new(); + fonts.set(b"ZaDb".to_vec(), Object::Dict(font)); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + } + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Form")); + set_form_box(&mut d, place); + d.set(b"Resources".to_vec(), Object::Dict(res)); + Stream::flate(d, &content) +} + +/// How a push button widget `w` of field `f` with no normal appearance looks: its `/MK` +/// background and border, in the `/BS` width and style ([`frame`]: Table 168, `B` beveled, `I` +/// inset), with its caption `/MK /CA` (Table 192) centred in the `/DA` font, size and colour. +/// A button with an icon (`/MK /I`) is drawn as PdfCraft draws its own buttons, icon included. +pub(crate) fn push_button_shown(doc: &Document, f: &Field, w: &Widget) -> Stream { + let wobj = doc.get(w.obj); + let wd = wobj.as_dict().cloned().unwrap_or_default(); + let mk = wd.get(b"MK").map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()).unwrap_or_default(); + if mk.contains(b"I") { + return crate::author::button_appearance(doc, w, &f.da).0; + } + let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0)); + let place = placement(mk_rotation(doc, &wd), page_w, page_h); + let (width, height) = (place.layout_w, place.layout_h); + let (frame_c, _) = frame(doc, &wd, width, height); + let mut content = frame_c.into_bytes(); + let caption = mk.get(b"CA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).unwrap_or_default(); + let mut fonts = Dict::new(); + if !caption.is_empty() && mk.get(b"TP").and_then(|t| doc.resolve(t).as_int()) != Some(1) { + let da = wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())); + // The automatic size PdfCraft's own buttons use. + let auto = ((height - 4.0) * 0.6).clamp(4.0, 14.0); + let (ops, name, font, _) = button_caption(doc, da.as_deref().unwrap_or(&f.da), &caption, width, height, auto); + content.extend(ops); + fonts.set(name.into_bytes(), font); + } + let mut res = Dict::new(); + res.set(b"Font".to_vec(), Object::Dict(fonts)); + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Form")); + set_form_box(&mut d, place); + d.set(b"Resources".to_vec(), Object::Dict(res)); + Stream::flate(d, &content) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index 95cd9d9a8c..873ef3d716 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -134,10 +134,9 @@ pub(crate) fn ensure_form(doc: &mut Document) -> Result { if !af.contains(b"DA") { af.set(b"DA".to_vec(), PdfString::literal(b"/Helv 0 Tf 0 g".to_vec())); } - let mut dr = af.get(b"DR").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); - let mut fonts = dr.get(b"Font").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); + let (dr, mut fonts) = form_fonts(doc, &af); + let mut added = inline_fonts_as_objects(doc, &mut fonts); // Acrobat's resource names for the standard fonts its Appearance tab offers. - let mut added = false; for (name, base) in [("Helv", "Helvetica"), ("TiRo", "Times-Roman"), ("Cour", "Courier")] { if fonts.contains(name.as_bytes()) { continue; @@ -147,17 +146,105 @@ pub(crate) fn ensure_form(doc: &mut Document) -> Result { f.set(b"Subtype".to_vec(), Object::name("Type1")); f.set(b"BaseFont".to_vec(), Object::name(base)); f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - fonts.set(name.as_bytes().to_vec(), Object::Dict(f)); + let r = doc.add(Object::Dict(f)); + fonts.set(name.as_bytes().to_vec(), Object::Ref(r)); added = true; } if added { - dr.set(b"Font".to_vec(), Object::Dict(fonts)); - af.set(b"DR".to_vec(), Object::Dict(dr)); + store_form_fonts(doc, &mut af, dr, fonts); } doc.set(r, Object::Dict(af)); Ok(r) } +/// The form dictionary's `/DR` and its `/Font` dictionary, resolved (empty where missing). +fn form_fonts(doc: &Document, af: &Dict) -> (Dict, Dict) { + let dr = af.get(b"DR").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); + let fonts = dr.get(b"Font").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); + (dr, fonts) +} + +/// Each /DR font its own object: Acrobat, redrawing a text field after it is typed in, shows +/// nothing when the font is a dictionary written inline in /DR (#3441). Fonts written inline +/// (by an earlier save, or another writer) become objects saying the same; a font that is an +/// object already, or an entry that isn't a dictionary, is left alone. Whether any changed. +fn inline_fonts_as_objects(doc: &mut Document, fonts: &mut Dict) -> bool { + let inline: Vec<(Vec, Dict)> = fonts.iter().filter_map(|(k, v)| Some((k.clone(), v.as_dict()?.clone()))).collect(); + let changed = !inline.is_empty(); + for (name, font) in inline { + let r = doc.add(Object::Dict(font)); + fonts.set(name, Object::Ref(r)); + } + changed +} + +/// Write `fonts` back as the `/DR /Font` of the form dictionary `af`. An indirect `/DR` or +/// `/Font` stays the object it is. +fn store_form_fonts(doc: &mut Document, af: &mut Dict, mut dr: Dict, fonts: Dict) { + match dr.get(b"Font").and_then(Object::as_ref) { + Some(fr) => doc.set(fr, Object::Dict(fonts)), + None => dr.set(b"Font".to_vec(), Object::Dict(fonts)), + } + match af.get(b"DR").and_then(Object::as_ref) { + Some(dr_ref) => doc.set(dr_ref, Object::Dict(dr)), + None => af.set(b"DR".to_vec(), Object::Dict(dr)), + } +} + +/// Filling a field: the form's inline `/DR` fonts become objects (see +/// [`inline_fonts_as_objects`]), so a form an earlier version of PdfCraft made works in Acrobat +/// once it has been filled here too (#3441). Only the font storage changes; nothing is added. +/// Not in a form a signature covers: there the file stays as the signer saw it, apart from the +/// value itself. +pub(crate) fn form_fonts_as_objects(doc: &mut Document, all: &[crate::Field]) -> Result<(), FormError> { + let signed = all.iter().any(|f| f.kind == crate::FieldKind::Signature && holds_value(doc, f.obj)); + if signed { + return Ok(()); + } + let Some(root) = doc.root() else { return Ok(()) }; + let Some(entry) = doc.get(root).as_dict().and_then(|c| c.get(b"AcroForm").cloned()) else { return Ok(()) }; + match entry { + Object::Ref(r) => { + let Some(mut af) = doc.get(r).as_dict().cloned() else { return Ok(()) }; + let (dr, mut fonts) = form_fonts(doc, &af); + if inline_fonts_as_objects(doc, &mut fonts) { + store_form_fonts(doc, &mut af, dr, fonts); + doc.set(r, Object::Dict(af)); + } + } + // A form dictionary written directly in the catalog (the catalog's /AcroForm need not be + // an indirect reference) is written back there. + Object::Dict(mut af) => { + let (dr, mut fonts) = form_fonts(doc, &af); + if inline_fonts_as_objects(doc, &mut fonts) { + store_form_fonts(doc, &mut af, dr, fonts); + doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(af)))?; + } + } + _ => {} + } + Ok(()) +} + +/// Whether the field `r`, or the nearest ancestor that has one, holds a `/V` (the entry is +/// inheritable, ISO 32000-2 §12.7.4). The `/Parent` chain is followed at most 64 deep, as deep +/// as the field walk goes, so a cyclic chain ends. +fn holds_value(doc: &Document, r: ObjRef) -> bool { + let mut at = r; + for _ in 0..=64 { + let obj = doc.get(at); + let Some(d) = obj.as_dict() else { return false }; + if d.contains(b"V") { + return true; + } + match d.reference(b"Parent") { + Some(p) => at = p, + None => return false, + } + } + false +} + /// The Appearance tab: borders, colours and text. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Look { @@ -486,14 +573,228 @@ fn add_to_fields(doc: &mut Document, af: ObjRef, field: ObjRef) -> Result<(), Fo Ok(()) } -/// The first free "N" name. -fn free_name(existing: &[Field], base: &str) -> String { +/// The most parts a dotted field name may have ("a.b.c" has three). +const MAX_NAME_PARTS: usize = 32; + +/// The field attributes a field inherits from its parents (ISO 32000-2 §12.7.4.1). +const INHERITED_KEYS: [&[u8]; 7] = [b"FT", b"Ff", b"V", b"DV", b"DA", b"Q", b"MaxLen"]; + +/// The parts of a full field name: `a.b` is the field `b` under the parent field `a`, since a +/// field's own (partial) name can't contain a period (ISO 32000-2 §12.7.4.2) (#2696). +fn name_parts(name: &str) -> Result, FormError> { + let parts: Vec<&str> = name.split('.').take(MAX_NAME_PARTS.saturating_add(1)).collect(); + if parts.len() > MAX_NAME_PARTS { + return invalid(format!("field names can have at most {MAX_NAME_PARTS} parts")); + } + if parts.iter().any(|p| p.trim().is_empty()) { + return invalid("field names can't be empty, or have an empty part between periods"); + } + Ok(parts) +} + +/// Why the full name `name` can't be used beside the field named `other`, if it can't: the +/// same name, or one inside the other (a field that has a value can't also hold fields). +fn clash(name: &str, other: &str) -> Option { + if name == other { + return Some(format!("a field named {name:?} already exists")); + } + let inside = |a: &str, b: &str| a.strip_prefix(b).is_some_and(|rest| rest.starts_with('.')); + (inside(name, other) || inside(other, name)).then(|| format!("{name:?} can't be used beside the field {other:?}")) +} + +/// The array `key` of `holder` (`/Fields` of the form, `/Kids` of a field). +fn list_of(doc: &Document, holder: ObjRef, key: &[u8]) -> Vec { + let d = doc.get(holder).as_dict().cloned().unwrap_or_default(); + d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default() +} + +/// Add `field` to `parent`'s `/Kids`, or to the form's `/Fields` at the top. +fn link(doc: &mut Document, af: ObjRef, parent: Option, field: ObjRef) -> Result<(), FormError> { + let Some(p) = parent else { return add_to_fields(doc, af, field) }; + let mut kids = list_of(doc, p, b"Kids"); + kids.push(Object::Ref(field)); + doc.update_dict(p, |d| d.set(b"Kids".to_vec(), Object::Array(kids)))?; + Ok(()) +} + +/// The node named `name` (its own `/T`) directly under `parent`, or at the top of the form. +fn child_named(doc: &Document, af: ObjRef, parent: Option, name: &str) -> Option { + let list = match parent { + Some(p) => list_of(doc, p, b"Kids"), + None => list_of(doc, af, b"Fields"), + }; + list.iter() + .filter_map(|o| o.as_ref()) + .find(|r| doc.get(*r).as_dict().and_then(|d| d.get(b"T").cloned()).and_then(|t| crate::text_of(&doc.resolve(&t))).as_deref() == Some(name)) +} + +/// The values a parent field passes to the fields under it that can't be turned off by giving +/// the field a neutral one of its own, as `/Ff` and `/Q` can. +const PASSED_VALUES: [&[u8]; 3] = [b"V", b"DV", b"MaxLen"]; + +/// Whether `field` may go in the field tree under the full name `parts`, changing nothing. The +/// field list holds only terminal fields, so a parent left empty by a move isn't in it: a node +/// of the same name there is found here. +/// `inherited` is what the field will hold as its own before it moves (see [`inherited`]). +fn check_place(doc: &Document, af: ObjRef, field: ObjRef, parts: &[&str], inherited: &Dict) -> Result<(), FormError> { + let Some((own, parents)) = parts.split_last() else { return invalid("field names can't be empty") }; + let mut owned = doc.get(field).as_dict().cloned().unwrap_or_default(); + for (k, v) in inherited.iter() { + owned.set(k.to_vec(), v.clone()); + } + let mut at: Option = None; + let mut whole_path = true; + for part in parents { + let Some(r) = child_named(doc, af, at, part) else { + whole_path = false; + break; + }; + // A value, reset value or length limit held by a parent would pass to this field. + let held = doc.get(r).as_dict().is_some_and(|d| PASSED_VALUES.iter().any(|k| d.contains(k) && !owned.contains(k))); + if held { + return invalid(format!("{part:?} holds a value for the fields under it, so {:?} can't be put there", parts.join("."))); + } + at = Some(r); + } + // A field being moved is still in the tree here, but under another full name (a move to its + // own name changes nothing and doesn't get here), so a node found is another. + if whole_path && child_named(doc, af, at, own).is_some() { + return invalid(format!("a field named {:?} already exists", parts.join("."))); + } + Ok(()) +} + +/// Put `field` in the field tree under the full name `parts`: its own `/T` is the last part, +/// and the parts before it name parent fields, reused where the tree has them and made where +/// not (Acrobat makes `a.b` a field `b` under a field `a`). +fn attach(doc: &mut Document, af: ObjRef, field: ObjRef, parts: &[&str]) -> Result<(), FormError> { + let Some((own, parents)) = parts.split_last() else { return invalid("field names can't be empty") }; + check_place(doc, af, field, parts, &Dict::new())?; + let mut parent: Option = None; + let mut inherits = false; + for part in parents { + let next = match child_named(doc, af, parent, part) { + Some(r) => { + inherits |= doc.get(r).as_dict().is_some_and(|d| d.contains(b"Ff") || d.contains(b"Q")); + r + } + None => { + let mut d = Dict::new(); + d.set(b"T".to_vec(), PdfString::text(part)); + d.set(b"Kids".to_vec(), Object::Array(Vec::new())); + if let Some(p) = parent { + d.set(b"Parent".to_vec(), Object::Ref(p)); + } + let r = doc.add(Object::Dict(d)); + link(doc, af, parent, r)?; + r + } + }; + parent = Some(next); + } + doc.update_dict(field, |d| { + d.set(b"T".to_vec(), PdfString::text(own)); + match parent { + Some(p) => d.set(b"Parent".to_vec(), Object::Ref(p)), + None => { + d.remove(b"Parent"); + } + } + // An existing parent's flags and alignment would otherwise pass to this field. + if inherits { + for k in [&b"Ff"[..], b"Q"] { + if !d.contains(k) { + d.set(k.to_vec(), Object::Int(0)); + } + } + } + })?; + link(doc, af, parent, field) +} + +/// What `field` inherits from its parents and doesn't hold itself, changing nothing. +fn inherited(doc: &Document, field: ObjRef) -> Dict { + let own = doc.get(field).as_dict().cloned().unwrap_or_default(); + let mut add = Dict::new(); + let mut seen = std::collections::HashSet::new(); + let mut parent = own.reference(b"Parent"); + while let Some(p) = parent { + if seen.len() > 64 || !seen.insert(p) { + break; + } + let Some(d) = doc.get(p).as_dict().cloned() else { break }; + for k in INHERITED_KEYS { + if let Some(v) = d.get(k).filter(|_| !own.contains(k) && !add.contains(k)) { + add.set(k.to_vec(), v.clone()); + } + } + parent = d.reference(b"Parent"); + } + add +} + +/// Copy onto `field` what it inherits (`add`, from [`inherited`]), so it keeps it when it moves. +fn own_inherited(doc: &mut Document, field: ObjRef, add: &Dict) -> Result<(), FormError> { + if add.iter().next().is_some() { + doc.update_dict(field, |d| { + for (k, v) in add.iter() { + d.set(k.to_vec(), v.clone()); + } + })?; + } + Ok(()) +} + +/// Take `field` out of the field tree: from its parent's `/Kids`, or from `/Fields`. +fn detach(doc: &mut Document, field: ObjRef) -> Result<(), FormError> { + let parent = doc.get(field).as_dict().and_then(|d| d.reference(b"Parent")); + let remove_from = |doc: &mut Document, holder: ObjRef, key: &[u8]| -> Result<(), FormError> { + let mut d = doc.get(holder).as_dict().cloned().unwrap_or_default(); + let list = d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + let kept: Vec = list.into_iter().filter(|o| o.as_ref() != Some(field)).collect(); + d.set(key.to_vec(), Object::Array(kept)); + doc.set(holder, Object::Dict(d)); + Ok(()) + }; + match parent { + Some(p) => remove_from(doc, p, b"Kids")?, + None => { + let root = doc.root().ok_or(FormError::NoForm)?; + match doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) { + Some(Object::Ref(af)) => remove_from(doc, af, b"Fields")?, + // A form dictionary held directly in the catalog. + Some(Object::Dict(mut af)) => { + let list = af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + let kept: Vec = list.into_iter().filter(|o| o.as_ref() != Some(field)).collect(); + af.set(b"Fields".to_vec(), Object::Array(kept)); + doc.update_dict(root, |d| d.set(b"AcroForm".to_vec(), Object::Dict(af)))?; + } + _ => {} + } + } + } + Ok(()) +} + +/// The first free "N" name: not a field's, and not held by a node at the top of the field +/// tree (`held`), such as a parent field left empty by a move, which isn't in `existing`. +fn free_name(existing: &[Field], held: &[String], base: &str) -> String { (1..=u64::MAX) .map(|n| format!("{base}{n}")) - .find(|c| !existing.iter().any(|f| f.name == *c || f.name.starts_with(&format!("{c}.")))) + .find(|c| !held.contains(c) && !existing.iter().any(|f| f.name == *c || f.name.starts_with(&format!("{c}.")))) .unwrap_or_else(|| base.to_string()) } +/// The own names (`/T`) of the nodes in the form's `/Fields`, changing nothing. +fn top_names(doc: &Document) -> Vec { + let Some(root) = doc.root() else { return Vec::new() }; + let Some(af) = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").map(|o| doc.resolve(o))).and_then(|o| o.as_dict().cloned()) else { + return Vec::new(); + }; + let list = af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); + list.iter().filter_map(|o| doc.resolve(o).as_dict().and_then(|d| d.get(b"T").cloned())).filter_map(|t| crate::text_of(&doc.resolve(&t))).collect() +} + fn widget_dict(page: ObjRef, rect: [f64; 4]) -> Dict { let mut d = Dict::new(); d.set(b"Type".to_vec(), Object::name("Annot")); @@ -527,20 +828,18 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel _ => name, }; let name = match name.map(str::trim) { - Some(n) if !n.is_empty() => { - if n.contains('.') { - return invalid("field names can't contain a period"); - } - n.to_string() - } - _ => free_name(&existing, kind.base_name()), + Some(n) if !n.is_empty() => n.to_string(), + _ => free_name(&existing, &top_names(doc), kind.base_name()), }; + let parts = name_parts(&name)?; let radio_group = match kind { NewField::Radio { group, .. } => existing.iter().find(|f| f.kind == FieldKind::Radio && Some(&f.name) == group.as_ref()).cloned(), _ => None, }; - if radio_group.is_none() && existing.iter().any(|f| f.name == name) { - return invalid(format!("a field named {name:?} already exists")); + if radio_group.is_none() + && let Some(why) = existing.iter().find_map(|f| clash(&name, &f.name)) + { + return invalid(why); } let af = ensure_form(doc)?; let mut w = widget_dict(page_ref, rect); @@ -637,12 +936,11 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel let mut g = Dict::new(); g.set(b"FT".to_vec(), Object::name("Btn")); g.set(b"Ff".to_vec(), Object::Int((flags::RADIO | flags::NO_TOGGLE_TO_OFF) as i64)); - g.set(b"T".to_vec(), PdfString::text(&field_name)); g.set(b"V".to_vec(), Object::name("Off")); g.set(b"DA".to_vec(), PdfString::literal(b"/ZaDb 0 Tf 0 g".to_vec())); g.set(b"Kids".to_vec(), Object::Array(Vec::new())); let g = doc.add(Object::Dict(g)); - add_to_fields(doc, af, g)?; + attach(doc, af, g, &parts)?; g } }; @@ -666,13 +964,10 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel caption: None, list: None, }; - let ap = appearance::check_box_states(doc, &w, FieldKind::Radio, export); + 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)))?; } - _ => { - doc.update_dict(widget, |d| d.set(b"T".to_vec(), PdfString::text(&name)))?; - add_to_fields(doc, af, widget)?; - } + _ => attach(doc, af, widget, &parts)?, } add_to_page(doc, page_ref, widget)?; redraw_field(doc, &field_name)?; @@ -709,7 +1004,7 @@ fn redraw_widgets(doc: &mut Document, name: &str, only: Option) -> Result } FieldKind::CheckBox | FieldKind::Radio => { let on = w.on_state.clone().unwrap_or_else(|| "Yes".into()); - appearance::check_box_states(doc, w, f.kind, &on) + appearance::check_box_states(doc, w, f.kind, &on, &f.da) } FieldKind::PushButton => { let (s, m) = button_appearance(doc, w, &f.da); @@ -775,7 +1070,7 @@ fn form_stream(place: crate::appearance::Placement, content: Vec, resources: /// color its `/DA` asks for (the widget's, else `field_da`, the field's own or inherited one). /// Also returns the first caption character the font can't draw (drawn as `?`), for the caller to /// mark and report. -fn button_appearance(doc: &Document, w: &Widget, field_da: &str) -> (Stream, Option) { +pub(crate) fn button_appearance(doc: &Document, w: &Widget, field_da: &str) -> (Stream, Option) { let (mut c, place) = frame_only(doc, w); let (width, height) = (place.layout_w, place.layout_h); let wobj = doc.get(w.obj); @@ -919,18 +1214,52 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result own, + _ => n, + }; + if n.contains('.') { + // A full name: the field moves under the parent fields it names (#2696). + let parts = name_parts(n)?; + if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(n, &x.name)) { + return invalid(why); + } + let af = ensure_form(doc)?; + // Checked before anything changes, so a refused move leaves the field as it was. + let add = inherited(doc, f.obj); + check_place(doc, af, f.obj, &parts, &add)?; + // From here a failure (a write that `check_place` couldn't foresee) would leave the + // field half moved in `doc`. The engine runs every edit on a copy of the document and + // keeps it only when the edit succeeds (`Session::apply`), so the open document never + // sees that. + own_inherited(doc, f.obj, &add)?; + detach(doc, f.obj)?; + attach(doc, af, f.obj, &parts)?; + new_name = n.to_string(); + } else { + if n.is_empty() { + return invalid("field names can't be empty"); + } + // Rename the terminal part (the field's own /T). The parents' part stays, so the name + // that must be free is the full one: `MASTER.a` renamed to `b` takes `MASTER.b` (ISO + // 32000-2 §12.7.4.2). The field itself is no clash when it keeps its own name. + let prefix = f.name.rsplit_once('.').map(|(p, _)| format!("{p}.")).unwrap_or_default(); + new_name = format!("{prefix}{n}"); + if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(&new_name, &x.name)) { + return invalid(why); + } + // A node of that name beside it that isn't a terminal field, such as a parent + // field left empty by a move. + let af = ensure_form(doc)?; + let parent = doc.get(f.obj).as_dict().and_then(|d| d.reference(b"Parent")); + if child_named(doc, af, parent, n).is_some_and(|r| r != f.obj) { + return invalid(format!("a field named {new_name:?} already exists")); + } + doc.update_dict(f.obj, |d| d.set(b"T".to_vec(), PdfString::text(n)))?; } - // Rename the terminal part (the field's own /T). The parents' part stays, so the name that - // must be free is the full one: `MASTER.a` renamed to `b` takes `MASTER.b` (ISO 32000-2 - // §12.7.4.2). The field itself is no clash when it keeps its own name. - let prefix = f.name.rsplit_once('.').map(|(p, _)| format!("{p}.")).unwrap_or_default(); - new_name = format!("{prefix}{n}"); - if all.iter().any(|x| x.obj != f.obj && x.name == new_name) { - return invalid(format!("a field named {new_name:?} already exists")); - } - doc.update_dict(f.obj, |d| d.set(b"T".to_vec(), PdfString::text(n)))?; // Calculations read the field by its new name, unless another field keeps the old one. if all.iter().filter(|x| x.name == f.name).count() == 1 { rename_in_calculations(doc, &all, &f.name, &new_name)?; @@ -1262,33 +1591,7 @@ fn delete_found_field(doc: &mut Document, f: Field) -> Result<(), FormError> { } } } - // Out of the field tree: from its parent's /Kids, or from /Fields. - let parent = doc.get(f.obj).as_dict().and_then(|d| d.reference(b"Parent")); - let remove_from = |doc: &mut Document, holder: ObjRef, key: &[u8]| -> Result<(), FormError> { - let mut d = doc.get(holder).as_dict().cloned().unwrap_or_default(); - let list = d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); - let kept: Vec = list.into_iter().filter(|o| o.as_ref() != Some(f.obj)).collect(); - d.set(key.to_vec(), Object::Array(kept)); - doc.set(holder, Object::Dict(d)); - Ok(()) - }; - match parent { - Some(p) => remove_from(doc, p, b"Kids")?, - None => { - let root = doc.root().ok_or(FormError::NoForm)?; - match doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) { - Some(Object::Ref(af)) => remove_from(doc, af, b"Fields")?, - // A form dictionary held directly in the catalog. - Some(Object::Dict(mut af)) => { - let list = af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); - let kept: Vec = list.into_iter().filter(|o| o.as_ref() != Some(f.obj)).collect(); - af.set(b"Fields".to_vec(), Object::Array(kept)); - doc.update_dict(root, |d| d.set(b"AcroForm".to_vec(), Object::Dict(af)))?; - } - _ => {} - } - } - } + detach(doc, f.obj)?; // Out of the calculation order too: a `/CO` entry would keep the field, its value and its // widgets reachable for a full save. drop_from_calculation_order(doc, |r| r == f.obj || widgets.contains(&r)) diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index d120c3cd3e..75c3dbee00 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -24,7 +24,7 @@ mod calc_order; pub mod detect; mod scripting; pub mod table; -pub use actions::{FieldAction, Trigger, field_actions, set_field_actions}; +pub use actions::{FieldAction, Trigger, field_action_chains, field_actions, set_field_actions}; pub use author::{ BorderStyle, CheckStyle, FieldFont, FieldProps, Look, LookPatch, MIN_FIELD_SIDE, NewField, add_field, check_style, delete_field, delete_field_object, duplicate_field, look, redraw_field, set_button_icon, set_props, @@ -48,6 +48,10 @@ pub enum FormError { ReadOnly(String), #[error("{0}")] Invalid(String), + /// The value doesn't fit the field's format (its keystroke check refused it), which is + /// checked before any of the field's scripts run. + #[error("{0}")] + Format(String), #[error("{0}")] Cos(#[from] pdfcraft_cos::CosError), } @@ -932,6 +936,7 @@ pub fn set_value_with(doc: &mut Document, name: &str, value: &FieldValue, script return Err(FormError::ReadOnly(name.into())); } write_value(doc, f, value, scripts)?; + 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. @@ -1049,7 +1054,7 @@ fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scripts: &mut return invalid(format!("{:?} takes at most {max} characters", f.name)); } // Keystroke (on commit) and Validate, as Acrobat runs them before accepting a value. - let t = &af::keystroke(&f.actions.format, &f.name, t).map_err(FormError::Invalid)?; + let t = &af::keystroke(&f.actions.format, &f.name, t).map_err(FormError::Format)?; af::validate(&f.actions.validate, t).map_err(FormError::Invalid)?; let t = &scripting::accept(doc, f, t, scripts)?; doc.update_dict(f.obj, |d| { @@ -1191,7 +1196,7 @@ fn set_states(doc: &mut Document, f: &Field, on: Option<&str>) -> Result<(), For let has_ap = doc.get(w.obj).as_dict().and_then(|d| d.get(b"AP").cloned()).is_some(); if !has_ap { let on_name = w.on_state.clone().unwrap_or_else(|| on.unwrap_or("Yes").to_string()); - let ap = appearance::check_box_states(doc, w, f.kind, &on_name); + let ap = appearance::check_box_states(doc, w, f.kind, &on_name, &f.da); doc.update_dict(w.obj, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?; } doc.update_dict(w.obj, |d| d.set(b"AS".to_vec(), name_obj(state)))?; @@ -1293,16 +1298,158 @@ pub(crate) fn ask_viewers_to_redraw(doc: &mut Document) -> Result<(), FormError> Ok(()) } +/// Text fields with the Password flag (inherited or their own) whose value (`/V`, inherited or +/// their own) is not empty and not their default value (`/DV`, which the form's author wrote and +/// a save restores), or one of whose separate widgets carries a `/V`. +fn password_fields(doc: &Document) -> Vec { + if !has_password_flag(doc) { + return Vec::new(); + } + let on_widget = |f: &Field| f.widgets.iter().any(|w| w.obj != f.obj && doc.get(w.obj).as_dict().is_some_and(|d| d.get(b"V").is_some())); + let typed = |f: &Field| f.value.iter().any(|v| !v.is_empty()) && f.value.first() != f.default.first(); + fields(doc).into_iter().filter(|f| f.kind == FieldKind::Text && f.has(flags::PASSWORD) && (typed(f) || on_widget(f))).collect() +} + +/// Whether any field dictionary sets the Password flag (#3943 review). A field that inherits the +/// flag has an ancestor that sets it, so that ancestor is found instead; a kid clearing the flag +/// 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. +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(); + let mut stack: Vec<(Object, usize)> = top.into_iter().map(|o| (o, 0)).collect(); + 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) { + continue; + } + if sets_it(r) { + return true; + } + let kids = doc.get(r).as_dict().and_then(|d| 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, depth + 1))); + } + // Widgets on the pages, and the fields above them. + page_refs(doc).into_iter().any(|p| { + let annots = + doc.get(p).as_dict().and_then(|d| d.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned())).unwrap_or_default(); + annots.iter().filter_map(Object::as_ref).filter(|r| !seen.contains(r)).any(|r| field_chain(doc, r).into_iter().any(sets_it)) + }) +} + +/// Whether any text field with the Password flag holds a value in `doc`. +pub fn has_password_values(doc: &Document) -> bool { + !password_fields(doc).is_empty() +} + +/// The typed values of the text fields with the Password flag, as (name, text) pairs, for +/// [`restore_password_values`] after the document was rebased on a file written without them. +pub fn password_values(doc: &Document) -> Vec<(String, String)> { + password_fields(doc).into_iter().filter_map(|f| f.value.first().map(|v| (f.name.clone(), v.clone()))).collect() +} + +/// Put values from [`password_values`] back into the open document, in memory only. A field +/// that is gone or read-only keeps what it has. +pub fn restore_password_values(doc: &mut Document, values: &[(String, String)]) -> Result<(), FormError> { + for (name, v) in values { + match set_value(doc, name, &FieldValue::Text(v.clone())) { + Ok(()) | Err(FormError::NoSuchField(_) | FormError::ReadOnly(_)) => {} + Err(e) => return Err(e), + } + } + Ok(()) +} + +/// `r` and its field ancestors (`/Parent`), nearest first, at most 32 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 { + 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; + } + chain.push(parent); + cur = parent; + } + chain +} + +/// Remove the values of the text fields with the Password flag, for a copy of the document about +/// to be written to a file (#3926). ISO 32000-2 §12.7.5.3, Table 231, Password flag, note: "it is +/// imperative that PDF processors never store the value of the text field in the PDF file if +/// this flag is set". As Acrobat saves such a field (#3943 review, black-box test): a field with +/// a default value (`/DV`, which the form's author wrote) gets it back as its value, one without +/// is left empty, and its appearance is drawn again from that value, so the masking characters +/// no longer show how long the typed password was. `/DV` and the field's scripts stay. +/// +/// A value can sit on the field, on one of its widgets, or on an ancestor it inherits from +/// (`/V` is inheritable). Each is removed; an ancestor's only when every field inheriting from it +/// is such a password field. Where an ancestor's value is shared with other fields and stays, the +/// password field gets an empty value of its own so it no longer inherits it. Returns how many +/// fields were cleared. +pub fn clear_password_values(doc: &mut Document) -> Result { + let targets = password_fields(doc); + if targets.is_empty() { + return Ok(0); + } + let is_password = |f: &Field| f.kind == FieldKind::Text && f.has(flags::PASSWORD); + // Each field's ancestors, found once: only values change below, never `/Parent` (#3943 + // review: walking them again for every ancestor of every target was quadratic). + let chains: Vec<(bool, Vec)> = fields(doc).iter().map(|g| (is_password(g), field_chain(doc, g.obj))).collect(); + for f in &targets { + let chain = field_chain(doc, f.obj); + for w in &f.widgets { + if w.obj != f.obj { + doc.update_dict(w.obj, |d| { + d.remove(b"V"); + })?; + } + } + doc.update_dict(f.obj, |d| { + d.remove(b"V"); + })?; + let mut shared_value_kept = false; + for a in chain.iter().skip(1) { + if doc.get(*a).as_dict().is_none_or(|d| d.get(b"V").is_none()) { + continue; + } + let exclusive = chains.iter().filter(|(_, chain)| chain.contains(a)).all(|(password, _)| *password); + if exclusive { + doc.update_dict(*a, |d| { + d.remove(b"V"); + })?; + } else { + shared_value_kept = true; + } + } + let restored: Vec = f.default.first().cloned().into_iter().collect(); + match restored.first() { + Some(dv) => doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text(dv)))?, + None if shared_value_kept => doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), PdfString::text("")))?, + None => {} + } + redraw(doc, f, &restored, &mut NoScripts)?; + } + Ok(targets.len()) +} + /// Whether the form asks viewers to draw its fields' appearances themselves (`/NeedAppearances`). pub fn need_appearances(doc: &Document) -> bool { acroform(doc).is_some_and(|af| matches!(af.get(b"NeedAppearances").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true)))) } -/// A copy of `doc` in which text and choice fields are drawn from their values, for displaying -/// the document (`None` when no widget needs it): every such widget when the form sets -/// `/NeedAppearances` (ISO 32000-2 Table 224: the writer left the appearances to the viewer), -/// and otherwise each widget without a normal appearance (`/AP /N`). Drawn as typing the value -/// would draw it (#3470). `doc` is not changed: saving writes the file as it was. +/// A copy of `doc` in which form fields are drawn the way the file leaves to the viewer, for +/// displaying the document (`None` when no widget needs it). Text and choice fields are drawn from +/// their values: every one when the form sets `/NeedAppearances` (ISO 32000-2 Table 224: the +/// writer left the appearances to the viewer), and otherwise each widget without a normal +/// appearance (`/AP /N`), as typing the value would draw it (#3470). Check boxes and radio buttons +/// with no normal appearance for their state (`/AS`) are drawn from their `/MK` background, border +/// and caption, and push buttons with none from their `/MK` and `/BS` (Table 192). `doc` is not +/// changed: saving writes the file as it was. pub fn with_field_appearances(doc: &Document) -> Option { with_field_appearances_cached(doc, &mut FieldAppearanceCache::default()) } @@ -1312,18 +1459,45 @@ pub fn with_field_appearances(doc: &Document) -> Option { pub fn with_field_appearances_cached(doc: &Document, cache: &mut FieldAppearanceCache) -> Option { let all = need_appearances(doc); let mut copy: Option = None; - for f in fields(doc).iter().filter(|f| matches!(f.kind, FieldKind::Text | FieldKind::Combo | FieldKind::List)) { + for f in fields(doc) { for w in &f.widgets { let widget = doc.get(w.obj); - let drawn = widget.as_dict().and_then(|d| d.get(b"AP")).is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N"))); - if drawn && !all { + let normal = widget + .as_dict() + .and_then(|d| d.get(b"AP")) + .and_then(|ap| doc.resolve(ap).as_dict().and_then(|ap| ap.get(b"N").map(|n| doc.resolve(n)))); + // A check box or radio button shows the appearance of its state (12.7.5.2.3): `/AS`, + // else a check box's value, else Off. An Off state may go without one (it shows + // nothing); an on state needs one. + let state = match f.kind { + FieldKind::CheckBox | FieldKind::Radio => { + let value = f.value.first().filter(|v| f.kind == FieldKind::CheckBox && !v.is_empty() && v.as_str() != "Off"); + Some(w.state.clone().or_else(|| value.cloned()).unwrap_or_else(|| "Off".into())) + } + _ => None, + }; + let missing = match f.kind { + FieldKind::Text | FieldKind::Combo | FieldKind::List => normal.is_none() || all, + FieldKind::PushButton => normal.is_none(), + FieldKind::CheckBox | FieldKind::Radio => match (normal.as_deref(), state.as_deref()) { + (None, _) => true, + (Some(Object::Dict(states)), Some(s)) => s != "Off" && !states.contains(&name_bytes(s)), + _ => false, + }, + FieldKind::Signature => false, + }; + if !missing { continue; } let inputs = appearance_inputs(doc, w); let stream = match cache.get(w.obj, &inputs) { Some(stream) => stream, None => { - let (stream, _) = appearance::field_appearance_drawn(doc, f, w, &f.value, true); + let stream = match f.kind { + FieldKind::CheckBox | FieldKind::Radio => appearance::button_state_shown(doc, &f, w, state.as_deref() != Some("Off")), + FieldKind::PushButton => appearance::push_button_shown(doc, &f, w), + _ => appearance::field_appearance_drawn(doc, &f, w, &f.value, true).0, + }; cache.insert(w.obj, inputs, &stream); stream } @@ -1331,10 +1505,25 @@ pub fn with_field_appearances_cached(doc: &Document, cache: &mut FieldAppearance let target = copy.get_or_insert_with(|| doc.clone()); let ap = target.add(Object::Stream(stream)); let mut fresh = Dict::new(); - fresh.set(b"N".to_vec(), Object::Ref(ap)); + match &state { + // The drawn look under its state's name, and that state as the widget's own. + Some(s) => { + let mut states = Dict::new(); + states.set(name_bytes(s), Object::Ref(ap)); + fresh.set(b"N".to_vec(), Object::Dict(states)); + } + None => fresh.set(b"N".to_vec(), Object::Ref(ap)), + } let apd = merged_appearance(target, w.obj, &fresh); // Fails only when the widget isn't a dictionary; that widget then keeps its own look. - target.update_dict(w.obj, |d| d.set(b"AP".to_vec(), Object::Dict(apd))).ok(); + target + .update_dict(w.obj, |d| { + d.set(b"AP".to_vec(), Object::Dict(apd)); + if let Some(s) = &state { + d.set(b"AS".to_vec(), Object::Name(name_bytes(s))); + } + }) + .ok(); } } copy diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index ea683bb67f..54bc137abd 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -387,6 +387,229 @@ fn opt_export_values_select_button_states() { assert!(err.to_string().contains("options: Post, Pick-up"), "{err}"); } +/// What fits a text field is measured as its appearance draws it: in the field's own font (its +/// `/DR` font, Helvetica where that can't show the text), inside the border and padding, at its +/// size, wrapped for multi-line, at the smallest automatic size, as asterisks for a password, +/// and without a limit for a comb field. +#[test] +fn a_text_field_says_what_fits_it_as_its_appearance_draws_it() { + // Each widget is 100 x 20 pt (multi-line: 100 x 40 pt) with no border: 96 pt inside the 2 pt + // padding (36 pt high for the multi-line one). + let tx = |name: &str, da: &str, ff: u32, extra: &str, top: i32, h: i32| { + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /DA ({da}) /Ff {ff}{extra} /Rect [50 {} 150 {top}] /P 3 0 R >>", top - h) + }; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R] >>".into(), + "<< /Fields [7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R /Cour 6 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>".into(), + tx("one", "/Helv 12 Tf 0 g", 0, "", 780, 20), + tx("auto", "/Helv 0 Tf 0 g", 0, "", 750, 20), + tx("many", "/Helv 12 Tf 0 g", flags::MULTILINE, "", 720, 40), + tx("cour", "/Cour 12 Tf 0 g", 0, "", 670, 20), + tx("pin", "/Helv 12 Tf 0 g", flags::PASSWORD, "", 640, 20), + tx("comb", "/Helv 12 Tf 0 g", flags::COMB, " /MaxLen 5", 610, 20), + ]; + let doc = document(&objs); + let all = fields(&doc); + let tb = |name: &str| { + let f = field(&all, name); + appearance::text_box(&doc, f, &f.widgets[0]) + }; + let helv = |s: &str, size: f64| pdfcraft_fonts::helvetica_width(s, size); + // Single line at 12 pt: as wide as the 96 pt inside, and no wider. + let fits_12: String = std::iter::repeat_n('W', (96.0 / helv("W", 12.0)) as usize).collect(); + assert!(tb("one").fits(&fits_12) && !tb("one").fits(&format!("{fits_12}W")), "{}", fits_12.len()); + // An automatic size shrinks first: full only when 4 pt no longer fits. + let fits_4: String = std::iter::repeat_n('W', (96.0 / helv("W", 4.0)) as usize).collect(); + assert!(!tb("one").fits(&fits_4) && tb("auto").fits(&fits_4) && !tb("auto").fits(&format!("{fits_4}W"))); + // Multi-line: lines of 12 x 1.15 pt each in 36 pt, so two lines fit and three don't. + assert!(tb("many").fits("one\ntwo") && !tb("many").fits("one\ntwo\nthree")); + let two_lines_of_words = "word ".repeat(((96.0 / helv("word ", 12.0)) as usize) * 2); + assert!(tb("many").fits(two_lines_of_words.trim_end()) && !tb("many").fits(&format!("{two_lines_of_words}word"))); + // The field's own font: narrow i's that Helvetica fits are too wide in Courier (600/1000 em). + let i_s: String = std::iter::repeat_n('i', 14).collect(); + assert!(tb("one").fits(&i_s) && !tb("cour").fits(&i_s), "Courier is 0.6 em a character: {}", 14.0 * 0.6 * 12.0); + // A password is drawn, so measured, as asterisks. + let thirty_i: String = std::iter::repeat_n('i', 30).collect(); + assert!(tb("one").fits(&thirty_i) && !tb("pin").fits(&thirty_i), "{} pt of asterisks", helv(&"*".repeat(30), 12.0)); + // A comb field's cells bound it, not its width. + assert!(tb("comb").fits(&"W".repeat(50))); +} + +/// #4339 review: a multi-line field is full when its appearance stops drawing lines (a line is +/// drawn while its descent stays inside the border), not when `lines x size x 1.15` outgrows the +/// padded height: a 16 pt field at 12 pt draws one line. +#[test] +fn a_short_multiline_field_takes_the_one_line_it_draws() { + let tx = |name: &str, extra: &str, h: i32| { + format!( + "<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /DA (/Helv 12 Tf 0 g) /Ff {}{extra} /Rect [50 {} 150 700] /P 3 0 R >>", + flags::MULTILINE, + 700 - h + ) + }; + 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 [6 0 R 7 0 R] >>".into(), + "<< /Fields [6 0 R 7 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + tx("plain", "", 16), + tx("boxed", " /MK << /BC [0] >>", 16), + ]; + let doc = document(&objs); + let all = fields(&doc); + let tb = |name: &str| { + let f = field(&all, name); + appearance::text_box(&doc, f, &f.widgets[0]) + }; + assert!(tb("plain").fits("one line") && !tb("plain").fits("one\ntwo")); + // A 1 pt border takes 1 pt from each side: the line's descent then falls outside it. + assert!(!tb("boxed").fits("one line")); +} + +/// A multi-line field narrower than one glyph: wrapping can't break inside a character, so the +/// appearance cuts that character at the sides (`Overflow::Wide`), and `fits` must say so too. +#[test] +fn a_multiline_field_rejects_a_glyph_wider_than_the_field() { + // 12 x 40 pt with no border: 8 pt inside the 2 pt padding. At 12 pt Helvetica a W is 11.3 pt + // wide and an i 2.7 pt. + 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 [6 0 R] >>".into(), + "<< /Fields [6 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + format!( + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (thin) /V (W) /DA (/Helv 12 Tf 0 g) /Ff {} /Rect [50 660 62 700] /P 3 0 R >>", + flags::MULTILINE + ), + ]; + let doc = document(&objs); + let all = fields(&doc); + let f = field(&all, "thin"); + let tb = appearance::text_box(&doc, f, &f.widgets[0]); + assert!(tb.fits("i") && tb.fits("i\ni")); + assert!(matches!(appearance::value_overflow(&doc, f, &f.widgets[0]), Some(appearance::Overflow::Wide { .. }))); + assert!(!tb.fits("W") && !tb.fits("iW")); +} + +/// An edit to a field that doesn't scroll keeps as much of what it inserted as fits, wherever the +/// caret was, and always keeps a deletion. +#[test] +fn an_edit_to_a_full_field_keeps_what_fits() { + 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] >>".into(), + "<< /Fields [5 0 R] /DA (/Helv 12 Tf 0 g) >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (one) /DA (/Helv 12 Tf 0 g) /Rect [50 760 150 780] /P 3 0 R >>".into(), + ]; + let doc = document(&objs); + let all = fields(&doc); + let f = field(&all, "one"); + let tb = appearance::text_box(&doc, f, &f.widgets[0]); + // Typing in the middle of "Short": the X's go in until the field is full, the end stays. + let typed = format!("Sh{}ort", "X".repeat(40)); + let (kept, caret) = tb.fitted_edit("Short", &typed); + let xs = kept.chars().filter(|c| *c == 'X').count(); + assert!(kept.starts_with("ShX") && kept.ends_with("Xort") && xs < 40, "{kept}"); + assert!(tb.fits(&kept) && !tb.fits(&format!("Sh{}ort", "X".repeat(xs + 1))), "as many as fit: {xs}"); + assert_eq!(caret, 2 + xs, "the caret after the last X kept"); + // What fits is kept whole. + assert_eq!(tb.fitted_edit("Short", "Shorter"), ("Shorter".to_string(), 7)); + // A deletion is kept even from text that already overflows (set by a script, say). + let long = "W".repeat(20); + assert!(!tb.fits(&long)); + assert_eq!(tb.fitted_edit(&long, &"W".repeat(19)), ("W".repeat(19), 19)); + // Replacing a selection with too much keeps the removal and what fits of the replacement. + let (kept, _) = tb.fitted_edit("Short", &format!("S{}t", "W".repeat(30))); + assert!(kept.starts_with("SW") && kept.ends_with('t') && tb.fits(&kept), "{kept}"); +} + +/// When the field's own font can't show a character of a paste, the text from there on is drawn +/// in Helvetica, which can be narrower: the longest part that fits may come after that point, and +/// is still the one kept. +#[test] +fn an_edit_keeps_the_longest_part_that_fits_across_a_change_of_font() { + // A subset font holding only A, 1 em wide: 12 pt an A at 12 pt, against Helvetica's 8 pt. + 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 [6 0 R] >>".into(), + "<< /Fields [6 0 R] /DA (/Wide 12 Tf 0 g) /DR << /Font << /Wide 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+Wide /FirstChar 65 /LastChar 65 /Widths [1000] /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (one) /DA (/Wide 12 Tf 0 g) /Rect [50 760 150 780] /P 3 0 R >>".into(), + ]; + let doc = document(&objs); + let all = fields(&doc); + let f = field(&all, "one"); + let tb = appearance::text_box(&doc, f, &f.widgets[0]); + // 96 pt inside: 8 A's in the subset font, or 11 characters (8.004 pt each) once a B brings + // Helvetica in. + assert!(tb.fits(&"A".repeat(8)) && !tb.fits(&"A".repeat(9))); + let pasted = format!("{}B{}", "A".repeat(10), "A".repeat(30)); + let (kept, caret) = tb.fitted_edit("", &pasted); + assert_eq!(kept, format!("{}B", "A".repeat(10)), "the 11 characters that fit in Helvetica, not the 8 before the B"); + assert!(!tb.fits(&format!("{}BA", "A".repeat(10))), "and no more"); + assert_eq!(caret, 11); +} + +/// A check box laid out for a round XFA check button (`/PCXfaRound true`) draws its background +/// and border as a circle inside the widget, inset by half the border so the stroke stays in, +/// or a disc when the border is as wide as the circle (an underline stays one); one without the +/// key keeps its rectangle. +#[test] +fn a_round_xfa_check_button_draws_a_round_background_and_border() { + let widget = |name: &str, extra: &str| { + format!( + "<< /Type /Annot /Subtype /Widget /FT /Btn /T ({name}) /Rect [50 700 70 720] /P 3 0 R /MK << /BG [0.8 0.85 1] /BC [1 0 0] >> /BS << /W 1 /S /S >>{extra} >>" + ) + }; + 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] >>".into(), + "<< /Fields [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R] >>".into(), + widget("round", " /PCXfaRound true"), + widget("square", ""), + widget("dashed", " /PCXfaRound true /BS << /W 2 /S /D >>").replace("/BS << /W 1 /S /S >>", ""), + widget("thick", " /PCXfaRound true /BS << /W 30 /S /S >>").replace("/BS << /W 1 /S /S >>", ""), + widget("underlined", " /PCXfaRound true /BS << /W 1 /S /U >>").replace("/BS << /W 1 /S /S >>", ""), + ]; + let mut doc = document(&objs); + for name in ["round", "square", "dashed", "thick", "underlined"] { + redraw_field(&mut doc, name).unwrap(); + } + // The Off state: only the frame. + let off = |name: &str| { + let all = fields(&doc); + let w = field(&all, name).widgets[0].obj; + let wd = doc.get(w).as_dict().cloned().unwrap(); + let n = doc.resolve(wd.get(b"AP").unwrap().as_dict().unwrap().get(b"N").unwrap()); + let r = n.as_dict().unwrap().reference(b"Off").expect("an Off appearance"); + let Object::Stream(s) = &*doc.get(r) else { panic!("Off is a stream") }; + String::from_utf8_lossy(&s.decoded().unwrap()).into_owned() + }; + let round = off("round"); + assert!(!round.contains(" re "), "no rectangle: {round}"); + assert!(round.contains("0.8 0.85 1 rg\n20 10 m"), "the background is a disc as wide as the box: {round}"); + assert!(round.contains("1 0 0 RG\n1 w\n19.5 10 m") && round.contains(" c S"), "the border is a circle inset by half its width: {round}"); + let square = off("square"); + assert!(square.contains("0 0 20 20 re f") && square.contains("re S"), "a square box keeps its rectangle: {square}"); + let dashed = off("dashed"); + assert!(dashed.contains("[3] 0 d\n19 10 m"), "a dashed round border stays dashed: {dashed}"); + // A border wider than the 20pt circle fills it: a disc in the border's colour, no stroke. + let thick = off("thick"); + assert!(thick.contains("1 0 0 rg\n20 10 m") && !thick.contains(" S\n"), "a border as wide as the circle is a disc: {thick}"); + // An underline stays the bottom edge, under a round background. + let underlined = off("underlined"); + assert!(underlined.contains("1 0 0 RG\n1 w\n0 0.5 m 20 0.5 l S"), "an underline stays an underline: {underlined}"); +} + /// Issue #1323: a text field without a format keeps a whitespace-only value as typed; only an /// empty value clears it. #[test] @@ -1270,7 +1493,7 @@ fn formats_validation_and_calculations_run_like_acrobat() { // Keystroke and validation errors use Acrobat's messages and change nothing. assert_eq!( set_value(&mut doc, "Qty", &FieldValue::Text("lots".into())), - Err(FormError::Invalid("The value entered does not match the format of the field [ Qty ]".into())) + Err(FormError::Format("The value entered does not match the format of the field [ Qty ]".into())) ); assert_eq!( set_value(&mut doc, "Qty", &FieldValue::Text("120".into())), @@ -1345,6 +1568,32 @@ fn renaming_a_field_renames_it_in_the_calculations_that_read_it() { assert_eq!((field(&all, "sum").value.clone(), field(&all, "product").value.clone()), (vec!["12".to_string()], vec!["20".to_string()])); } +/// A dotted name moves the field under new parent fields (#2696), and the calculations that read +/// it follow it to its full new name, as they follow a plain rename. +#[test] +fn a_dotted_rename_moves_the_field_and_its_calculations_follow() { + use crate::af::{CalcOp, Calculate}; + let mut doc = one_page(); + let text = NewField::Text { multiline: false }; + for (i, name) in ["a", "b", "sum", "script"].into_iter().enumerate() { + let y = 700.0 - 40.0 * i as f64; + add_field(&mut doc, 0, [50.0, y, 250.0, y + 20.0], &text, Some(name)).unwrap(); + } + let calculate = + FieldProps { calculate: Some(Calculate::Simple { op: CalcOp::Sum, fields: vec!["a".into(), "b".into()] }), ..FieldProps::default() }; + set_props(&mut doc, "sum", &calculate).unwrap(); + set_field_script(&mut doc, "script", "calculate", Some("event.value = getField(\"a\").value;")).unwrap(); + set_props(&mut doc, "a", &FieldProps { name: Some("Group.a".into()), ..FieldProps::default() }).unwrap(); + let mut doc = reopen(&doc); + let all = fields(&doc); + assert!(all.iter().any(|f| f.name == "Group.a") && all.iter().all(|f| f.name != "a")); + assert_eq!(field(&all, "sum").actions.calculate, Calculate::Simple { op: CalcOp::Sum, fields: vec!["Group.a".into(), "b".into()] }); + assert_eq!(field(&all, "script").actions.scripts.calculate.as_deref(), Some("event.value = getField(\"Group.a\").value;")); + set_value(&mut doc, "Group.a", &FieldValue::Text("10".into())).unwrap(); + set_value(&mut doc, "b", &FieldValue::Text("2".into())).unwrap(); + assert_eq!(field(&fields(&doc), "sum").value, ["12"]); +} + /// The rename reaches a script stored as a stream, an indirect action and the only widget's /// actions; it leaves a script that isn't UTF-8 as it is, and a name another field still has. #[test] @@ -1726,6 +1975,49 @@ fn check_box_styles_change_the_mark() { ); } +#[test] +fn check_mark_takes_the_fields_text_colour() { + // #4261: Appearance ▸ Text Color is written to /DA; the mark of a check box or radio button + // is drawn in it (Acrobat does the same), black when the /DA has no colour. + let mut doc = one_page(); + let check = add_field(&mut doc, 0, [50.0, 700.0, 70.0, 720.0], &NewField::CheckBox, None).unwrap(); + let radio = add_field(&mut doc, 0, [50.0, 600.0, 70.0, 620.0], &NewField::Radio { group: None, export: "A".into() }, None).unwrap(); + let on_ap = |doc: &Document, name: &str| { + let all = fields(doc); + let w = &field(&all, name).widgets[0]; + let on = w.on_state.clone().unwrap(); + let n = doc + .get(w.obj) + .as_dict() + .unwrap() + .get(b"AP") + .unwrap() + .as_dict() + .unwrap() + .get(b"N") + .unwrap() + .as_dict() + .unwrap() + .reference(on.as_bytes()) + .unwrap(); + let Object::Stream(s) = &*doc.get(n) else { panic!() }; + String::from_utf8_lossy(&s.decoded().unwrap()).into_owned() + }; + assert!(on_ap(&doc, &check).contains("0 G"), "black by default"); + let blue = FieldProps { appearance: Some(LookPatch { text: Some([0.0, 0.0, 1.0]), ..Default::default() }), ..Default::default() }; + for name in [&check, &radio] { + set_props(&mut doc, name, &blue).unwrap(); + let ap = on_ap(&reopen(&doc), name); + // The check strokes, the radio's default circle fills. + let op = if name == &check { "0 0 1 RG" } else { "0 0 1 rg" }; + assert!(ap.contains(op), "{name}: the mark is blue: {ap}"); + assert!(!ap.contains("0 G\n") && !ap.contains("0 g\n"), "{name}: no black left: {ap}"); + } + // A style change redraws too, and keeps the colour. + set_props(&mut doc, &check, &FieldProps { check_style: Some(CheckStyle::Square), ..FieldProps::default() }).unwrap(); + assert!(on_ap(&doc, &check).contains("0 0 1 rg"), "a square is blue as well"); +} + #[test] fn ordering_tabs_manually() { let mut doc = one_page(); @@ -1907,6 +2199,80 @@ fn field_actions_round_trip_on_every_trigger() { assert_eq!(fields(&doc).iter().find(|f| f.name == "go").unwrap().button, Some(af::ButtonAction::Named("Print".into()))); } +#[test] +fn editing_one_script_keeps_the_other_actions_and_the_chain() { + let mut doc = fixture(); + let go = field(&fields(&doc), "go").obj; + let action = |doc: &mut Document, entries: Vec<(&[u8], Object)>| { + let mut a = Dict::new(); + for (k, v) in entries { + a.set(k.to_vec(), v); + } + doc.add(Object::Dict(a)) + }; + let next = action(&mut doc, vec![(b"S", Object::name("URI")), (b"URI", Object::String(PdfString::literal(b"https://example.org".to_vec())))]); + let up = action( + &mut doc, + vec![(b"S", Object::name("JavaScript")), (b"JS", Object::String(PdfString::text("app.alert(1);"))), (b"Next", Object::Ref(next))], + ); + let blur = action( + &mut doc, + vec![(b"S", Object::name("JavaScript")), (b"JS", Object::String(PdfString::text("this.dirty = true;"))), (b"Foo", Object::Int(7))], + ); + let mut aa = Dict::new(); + aa.set(b"Bl".to_vec(), Object::Ref(blur)); + doc.update_dict(go, |d| { + d.set(b"A".to_vec(), Object::Ref(up)); + d.set(b"AA".to_vec(), Object::Dict(aa)); + }) + .unwrap(); + let mut acts = field_actions(&doc, "go").unwrap(); + assert_eq!(acts[0], (Trigger::MouseUp, FieldAction::JavaScript("app.alert(1);".into()))); + acts[0].1 = FieldAction::JavaScript("app.alert(2);".into()); + set_field_actions(&mut doc, "go", &acts).unwrap(); + let w = doc.get(go).as_dict().cloned().unwrap(); + // The action that did not change is the same object, with its keys this crate doesn't read. + let aa = w.get(b"AA").unwrap().as_dict().unwrap().clone(); + assert_eq!(aa.get(b"Bl"), Some(&Object::Ref(blur))); + // The edited script keeps the actions chained after it. + let a = doc.resolve(w.get(b"A").unwrap()).as_dict().cloned().unwrap(); + assert_eq!(a.get(b"Next"), Some(&Object::Ref(next))); + assert_eq!(field_actions(&doc, "go").unwrap()[0], (Trigger::MouseUp, FieldAction::JavaScript("app.alert(2);".into()))); +} + +#[test] +fn replacing_a_chained_action_with_another_type_keeps_the_chain() { + let mut doc = fixture(); + let go = field(&fields(&doc), "go").obj; + let s = |t: &str| Object::String(PdfString::text(t)); + let mut named = Dict::new(); + named.set(b"S".to_vec(), Object::name("Named")); + named.set(b"N".to_vec(), Object::name("NextPage")); + let named = doc.add(Object::Dict(named)); + // A chain as an array: a link, then a menu item that points back at the link (a loop). + let mut uri = Dict::new(); + uri.set(b"S".to_vec(), Object::name("URI")); + uri.set(b"URI".to_vec(), s("https://example.org")); + let uri = doc.add(Object::Dict(uri)); + doc.update_dict(named, |d| d.set(b"Next".to_vec(), Object::Ref(uri))).unwrap(); + let chain = Object::Array(vec![Object::Ref(uri), Object::Ref(named)]); + let mut up = Dict::new(); + up.set(b"S".to_vec(), Object::name("JavaScript")); + up.set(b"JS".to_vec(), s("app.alert(1);")); + up.set(b"Next".to_vec(), chain.clone()); + doc.update_dict(go, |d| d.set(b"A".to_vec(), Object::Dict(up))).unwrap(); + assert_eq!(field_action_chains(&doc, "go").unwrap(), [(Trigger::MouseUp, 2)]); + set_field_actions(&mut doc, "go", &[(Trigger::MouseUp, FieldAction::Uri("https://example.com".into()))]).unwrap(); + let w = doc.get(go).as_dict().cloned().unwrap(); + let a = doc.resolve(w.get(b"A").unwrap()).as_dict().cloned().unwrap(); + assert_eq!(a.get(b"Next"), Some(&chain)); + assert_eq!(field_actions(&doc, "go").unwrap(), [(Trigger::MouseUp, FieldAction::Uri("https://example.com".into()))]); + assert_eq!(field_action_chains(&doc, "go").unwrap(), [(Trigger::MouseUp, 2)]); + // Deleting the trigger's action deletes its chain with it. + set_field_actions(&mut doc, "go", &[]).unwrap(); + assert_eq!(field_action_chains(&doc, "go").unwrap(), []); +} + #[test] fn push_buttons_read_hide_actions() { let mut doc = fixture(); @@ -3097,3 +3463,840 @@ fn a_skipped_field_leaves_its_widget_to_the_next() { let widgets: Vec<(&str, usize)> = all.iter().map(|f| (f.name.as_str(), f.widgets.len())).collect(); assert_eq!(widgets, [("b", 1)]); } + +/// #2696: a dotted name is a full name. `MASTER.first` is a field `first` under a parent field +/// `MASTER` (a partial name can't contain a period, ISO 32000-2 §12.7.4.2); a second `MASTER.` +/// name joins that parent; renaming to a dotted name moves the field, keeping what it inherited; +/// and a name that would make one field both hold a value and hold fields is refused. +#[test] +fn dotted_field_names_are_made_with_parent_fields() { + let mut doc = fixture(); + let top = |doc: &Document| { + let root = doc.root().unwrap(); + let af = doc.get(root).as_dict().and_then(|d| d.reference(b"AcroForm")).unwrap(); + doc.get(af).as_dict().and_then(|d| d.get(b"Fields").and_then(|o| o.as_array().map(|a| a.len()))).unwrap() + }; + let own_name = |doc: &Document, f: &Field| doc.get(f.obj).as_dict().and_then(|d| d.get(b"T").and_then(text_of)); + let parent_of = |doc: &Document, f: &Field| doc.get(f.obj).as_dict().and_then(|d| d.reference(b"Parent")).unwrap(); + let text = NewField::Text { multiline: false }; + let before = top(&doc); + assert_eq!(add_field(&mut doc, 0, [50.0, 300.0, 200.0, 320.0], &text, Some("MASTER.account_firstname")).unwrap(), "MASTER.account_firstname"); + add_field(&mut doc, 0, [50.0, 260.0, 200.0, 280.0], &text, Some("MASTER.street1")).unwrap(); + assert_eq!(top(&doc), before + 1, "one parent field at the top"); + let saved = reopen(&doc); + let all = fields(&saved); + let (a, b) = (field(&all, "MASTER.account_firstname"), field(&all, "MASTER.street1")); + assert_eq!(own_name(&saved, b).as_deref(), Some("street1"), "the field's own name has no period"); + let master = parent_of(&saved, a); + assert_eq!(parent_of(&saved, b), master, "both under one MASTER"); + assert_eq!(saved.get(master).as_dict().and_then(|d| d.get(b"T").and_then(text_of)).as_deref(), Some("MASTER")); + + // Renaming to a dotted name moves the field: zip joins the existing address parent. + assert_eq!(set_props(&mut doc, "zip", &FieldProps { name: Some("address.zip".into()), ..FieldProps::default() }).unwrap(), "address.zip"); + assert_eq!(top(&doc), before, "zip left the top"); + let all = fields(&doc); + let (zip, city) = (field(&all, "address.zip"), field(&all, "address.city")); + assert_eq!(parent_of(&doc, zip), parent_of(&doc, city)); + assert_eq!(zip.max_len, Some(5), "its own settings move with it"); + + // What a moved field inherited stays; what a new field would inherit from a parent it + // joins doesn't apply to it. + let address = parent_of(&doc, city); + doc.update_dict(address, |d| { + d.set(b"Ff".to_vec(), Object::Int(flags::READ_ONLY as i64)); + d.set(b"V".to_vec(), Object::String(PdfString::literal(b"Springfield".to_vec()))); + }) + .unwrap(); + let city = field(&fields(&doc), "address.city").clone(); + assert!(city.read_only() && city.value == ["Springfield"]); + set_props(&mut doc, "address.city", &FieldProps { name: Some("billing.city".into()), ..FieldProps::default() }).unwrap(); + let moved = field(&fields(&doc), "billing.city").clone(); + assert!(moved.read_only() && moved.value == ["Springfield"], "the moved field keeps what it inherited"); + // A parent's value, reset value or length limit would pass to a new field under it (review + // on #2957), and a field can't turn them off: such a parent takes no new field. + let street = |doc: &mut Document| add_field(doc, 0, [300.0, 300.0, 450.0, 320.0], &text, Some("address.street")); + for (key, value) in [ + (&b"V"[..], Object::String(PdfString::literal(b"x".to_vec()))), + (b"DV", Object::String(PdfString::literal(b"x".to_vec()))), + (b"MaxLen", Object::Int(3)), + ] { + doc.update_dict(address, |d| { + for k in [&b"V"[..], b"DV", b"MaxLen"] { + d.remove(k); + } + d.set(key.to_vec(), value); + }) + .unwrap(); + let mut copy = doc.clone(); + assert!(street(&mut copy).is_err(), "address holds /{}", String::from_utf8_lossy(key)); + } + doc.update_dict(address, |d| { + d.remove(b"MaxLen"); + }) + .unwrap(); + street(&mut doc).unwrap(); + let new = field(&fields(&doc), "address.street").clone(); + assert!(!new.read_only(), "a new field doesn't take its parent's flags"); + assert!(new.value.iter().all(|v| v.is_empty()) && new.max_len.is_none(), "nor a value or length limit: {:?} {:?}", new.value, new.max_len); + + // A parent left empty by a move isn't a terminal field, but its name is still taken. + add_field(&mut doc, 0, [300.0, 340.0, 450.0, 360.0], &text, Some("box.only")).unwrap(); + set_props(&mut doc, "box.only", &FieldProps { name: Some("elsewhere.only".into()), ..FieldProps::default() }).unwrap(); + assert!(fields(&doc).iter().all(|f| !f.name.starts_with("box")), "box holds no field now"); + let mut copy = doc.clone(); + assert!(add_field(&mut copy, 0, [300.0, 380.0, 450.0, 400.0], &text, Some("box")).is_err(), "a new field named box"); + let mut copy = doc.clone(); + assert!(set_props(&mut copy, "name", &FieldProps { name: Some("box".into()), ..FieldProps::default() }).is_err(), "a rename to box"); + // Under it, the name is free: the empty parent takes a field again. + add_field(&mut doc, 0, [300.0, 380.0, 450.0, 400.0], &text, Some("box.again")).unwrap(); + // Only a whole path that exists can be taken: under box.sub, made new, "again" is free. + add_field(&mut doc, 0, [300.0, 420.0, 450.0, 440.0], &text, Some("box.sub.again")).unwrap(); + // A field holding its own value may join a parent holding one; and the parents a new path + // makes are new, even where an existing field has the name of a later part. + doc.update_dict(address, |d| d.set(b"V".to_vec(), Object::String(PdfString::literal(b"x".to_vec())))).unwrap(); + set_props(&mut doc, "billing.city", &FieldProps { name: Some("address.city".into()), ..FieldProps::default() }).unwrap(); + assert_eq!(field(&fields(&doc), "address.city").value, ["Springfield"]); + add_field(&mut doc, 0, [300.0, 460.0, 450.0, 480.0], &text, Some("nope.address.x")).unwrap(); + // Renaming the last part: to its own name is no change; to an empty parent's name beside it + // is taken. + assert_eq!(set_props(&mut doc, "address.city", &FieldProps { name: Some("city".into()), ..FieldProps::default() }).unwrap(), "address.city"); + doc.update_dict(address, |d| { + d.remove(b"V"); + }) + .unwrap(); + add_field(&mut doc, 0, [300.0, 500.0, 450.0, 520.0], &text, Some("address.grp.x")).unwrap(); + set_props(&mut doc, "address.grp.x", &FieldProps { name: Some("elsewhere.x".into()), ..FieldProps::default() }).unwrap(); + assert!( + set_props(&mut doc.clone(), "address.city", &FieldProps { name: Some("grp".into()), ..FieldProps::default() }).is_err(), + "address.grp is taken" + ); + // A move the destination refuses leaves the field where it was (review on #2957): it was + // taken out of the tree before the destination was checked, and stayed out. + doc.update_dict(address, |d| d.set(b"MaxLen".to_vec(), Object::Int(3))).unwrap(); + let at_top = top(&doc); + assert!(set_props(&mut doc, "name", &FieldProps { name: Some("address.name".into()), ..FieldProps::default() }).is_err()); + assert_eq!(top(&doc), at_top, "name is still in the form's /Fields"); + // Nor is what it inherits copied onto it first (review on #2957): src.f inherits /V from src. + add_field(&mut doc, 0, [300.0, 540.0, 450.0, 560.0], &text, Some("src.f")).unwrap(); + let f = field(&fields(&doc), "src.f").obj; + let src = doc.get(f).as_dict().and_then(|d| d.reference(b"Parent")).unwrap(); + doc.update_dict(src, |d| d.set(b"V".to_vec(), Object::String(PdfString::literal(b"inh".to_vec())))).unwrap(); + assert!(set_props(&mut doc, "src.f", &FieldProps { name: Some("address.f".into()), ..FieldProps::default() }).is_err()); + assert!(!doc.get(f).as_dict().unwrap().contains(b"V"), "src.f still only inherits its value"); + // What it inherits counts as its own where it goes: with /V on address and no /MaxLen, + // src.f moves, keeping its value. + doc.update_dict(address, |d| { + d.remove(b"MaxLen"); + d.set(b"V".to_vec(), Object::String(PdfString::literal(b"x".to_vec()))); + }) + .unwrap(); + set_props(&mut doc, "src.f", &FieldProps { name: Some("address.f".into()), ..FieldProps::default() }).unwrap(); + assert_eq!(field(&fields(&doc), "address.f").value, ["inh"]); + doc.update_dict(address, |d| { + d.remove(b"V"); + }) + .unwrap(); + doc.update_dict(address, |d| { + d.remove(b"MaxLen"); + }) + .unwrap(); + + for bad in ["MASTER", "MASTER.street1.x", "MASTER..x", ".x", "x.", "MASTER. "] { + assert!(add_field(&mut doc, 0, [300.0, 200.0, 450.0, 220.0], &text, Some(bad)).is_err(), "{bad:?}"); + assert!(set_props(&mut doc, "name", &FieldProps { name: Some(bad.into()), ..FieldProps::default() }).is_err(), "{bad:?}"); + } + let deep = vec!["p"; 33].join("."); + assert!(add_field(&mut doc, 0, [300.0, 200.0, 450.0, 220.0], &text, Some(&deep)).is_err(), "too deep"); + assert!(set_props(&mut doc, "name", &FieldProps { name: Some("MASTER.street1".into()), ..FieldProps::default() }).is_err(), "taken"); +} + +/// Review on #2957: a parent left empty by a move keeps its name from the next default name +/// (`Text1` after `Text1.x` moved away); and a full name that keeps the parents is a plain +/// rename, so the field stays in its place among its parent's /Kids and keeps inheriting. +#[test] +fn empty_parents_and_same_parent_renames() { + let mut doc = fixture(); + let text = NewField::Text { multiline: false }; + let auto = |doc: &mut Document, y: f64| add_field(doc, 0, [50.0, y, 200.0, y + 20.0], &text, None); + let mut copy = doc.clone(); + let first = auto(&mut copy, 100.0).unwrap(); + add_field(&mut doc, 0, [50.0, 140.0, 200.0, 160.0], &text, Some(&format!("{first}.x"))).unwrap(); + set_props(&mut doc, &format!("{first}.x"), &FieldProps { name: Some("other.x".into()), ..FieldProps::default() }).unwrap(); + assert!(fields(&doc).iter().all(|f| !f.name.starts_with(&first)), "{first} is an empty parent now"); + let next = auto(&mut doc, 100.0).expect("a default name the empty parent doesn't hold"); + assert_ne!(next, first); + + let all = fields(&doc); + let city = field(&all, "address.city").obj; + let address = doc.get(city).as_dict().and_then(|d| d.reference(b"Parent")).unwrap(); + doc.update_dict(address, |d| d.set(b"V".to_vec(), Object::String(PdfString::literal(b"Springfield".to_vec())))).unwrap(); + let kids = |doc: &Document| doc.get(address).as_dict().and_then(|d| d.get(b"Kids").and_then(|o| o.as_array().cloned())).unwrap(); + let before = kids(&doc); + assert_eq!( + set_props(&mut doc, "address.city", &FieldProps { name: Some("address.town".into()), ..FieldProps::default() }).unwrap(), + "address.town" + ); + assert_eq!(kids(&doc), before, "same place among the kids"); + assert!(!doc.get(city).as_dict().unwrap().contains(b"V"), "still inherits its value rather than owning a copy"); + assert_eq!(field(&fields(&doc), "address.town").value, ["Springfield"]); +} + +/// Review on #2957: spaces in a typed name. The ends of the whole name are dropped, as Acrobat +/// does when a field is renamed; a space beside a period stays in the part it belongs to (`a. b` +/// is `b` with a leading space under `a`), as Acrobat keeps it and as ISO 32000-2 §12.7.4.2 +/// allows: only a period is barred from a partial name. +#[test] +fn a_typed_name_loses_its_ends_but_keeps_spaces_beside_periods() { + let mut doc = fixture(); + let own_name = |doc: &Document, full: &str| { + let all = fields(doc); + doc.get(field(&all, full).obj).as_dict().and_then(|d| d.get(b"T").and_then(text_of)) + }; + let text = NewField::Text { multiline: false }; + assert_eq!(add_field(&mut doc, 0, [50.0, 300.0, 200.0, 320.0], &text, Some(" plain ")).unwrap(), "plain"); + assert_eq!(add_field(&mut doc, 0, [50.0, 260.0, 200.0, 280.0], &text, Some("a. b")).unwrap(), "a. b"); + assert_eq!(add_field(&mut doc, 0, [50.0, 220.0, 200.0, 240.0], &text, Some("c .d")).unwrap(), "c .d"); + assert_eq!(set_props(&mut doc, "zip", &FieldProps { name: Some(" e. f ".into()), ..FieldProps::default() }).unwrap(), "e. f"); + let saved = reopen(&doc); + assert_eq!(own_name(&saved, "plain").as_deref(), Some("plain")); + assert_eq!(own_name(&saved, "a. b").as_deref(), Some(" b")); + assert_eq!(own_name(&saved, "c .d").as_deref(), Some("d")); + assert_eq!(own_name(&saved, "e. f").as_deref(), Some(" f")); + let parent = |full: &str| { + let all = fields(&saved); + let p = saved.get(field(&all, full).obj).as_dict().and_then(|d| d.reference(b"Parent")).unwrap(); + saved.get(p).as_dict().and_then(|d| d.get(b"T").and_then(text_of)) + }; + assert_eq!(parent("c .d").as_deref(), Some("c "), "the space before the period is the parent's"); + assert_eq!(parent("e. f").as_deref(), Some("e")); + // A part that is only spaces is still empty. + assert!(add_field(&mut doc, 0, [300.0, 300.0, 450.0, 320.0], &text, Some("g. .h")).is_err()); +} + +/// The form's `/DR /Font` entries, resolved through an indirect `/DR` or `/Font`. +fn dr_fonts(doc: &Document) -> Dict { + let af = acroform(doc).unwrap(); + let dr = doc.resolve(af.get(b"DR").unwrap()); + doc.resolve(dr.as_dict().unwrap().get(b"Font").unwrap()).as_dict().cloned().unwrap() +} + +/// #3441: Acrobat shows nothing in a text field it redraws after typing when the field's `/DR` +/// font is a dictionary written inline. A form PdfCraft makes holds each font as its own object. +#[test] +fn a_new_form_holds_its_fonts_as_objects() { + 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(), + ]); + add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")).unwrap(); + let doc = reopen(&doc); + let fonts = dr_fonts(&doc); + for (name, base) in [("Helv", "Helvetica"), ("TiRo", "Times-Roman"), ("Cour", "Courier")] { + let r = fonts.reference(name.as_bytes()).unwrap_or_else(|| panic!("{name} is an object: {fonts:?}")); + assert_eq!(doc.get(r).as_dict().and_then(|f| f.name(b"BaseFont")), Some(base.as_bytes())); + } +} + +/// #3441: a form saved earlier with inline `/DR` fonts gets them as objects once a field is +/// added, saying the same; an indirect `/Font` dictionary stays the object it was. +#[test] +fn inline_form_fonts_become_objects_and_an_indirect_font_dictionary_stays_one() { + for indirect in [false, true] { + let font = "/Helv << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> /Mine << /Type /Font /Subtype /Type1 /BaseFont /Symbol >>"; + let (fonts_entry, extra) = + if indirect { ("6 0 R".to_string(), vec![format!("<< {font} >>")]) } else { (format!("<< {font} >>"), Vec::new()) }; + 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(), + format!("<< /Fields [] /DA (/Helv 0 Tf 0 g) /DR << /Font {fonts_entry} >> >>"), + "null".into(), + ]; + objs.extend(extra); + let mut doc = document(&objs); + add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")).unwrap(); + let doc = reopen(&doc); + let fonts = dr_fonts(&doc); + for (name, base) in [("Helv", "Helvetica"), ("Mine", "Symbol"), ("TiRo", "Times-Roman")] { + let r = fonts.reference(name.as_bytes()).unwrap_or_else(|| panic!("{name} is an object (indirect {indirect}): {fonts:?}")); + assert_eq!(doc.get(r).as_dict().and_then(|f| f.name(b"BaseFont")), Some(base.as_bytes()), "{name} says the same"); + } + let dr = doc.resolve(acroform(&doc).unwrap().get(b"DR").unwrap()); + assert_eq!(dr.as_dict().unwrap().reference(b"Font").is_some(), indirect, "the /Font dictionary keeps its form"); + } +} + +/// #3441: an indirect `/DR` stays the object it is, and a form whose three standard fonts are +/// all already there (inline) still gets them as objects, though none is added. +#[test] +fn an_indirect_resource_dictionary_and_a_complete_inline_set_are_handled() { + let inline = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"; + let all = format!( + "/Helv {inline} /TiRo << /Type /Font /Subtype /Type1 /BaseFont /Times-Roman >> /Cour << /Type /Font /Subtype /Type1 /BaseFont /Courier >>" + ); + for indirect_dr in [false, true] { + let dr = if indirect_dr { "5 0 R".to_string() } else { format!("<< /Font << {all} >> >>") }; + let doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + format!("<< /Fields [] /DA (/Helv 0 Tf 0 g) /DR {dr} >>"), + format!("<< /Font << {all} >> >>"), + ]); + let mut doc = doc; + add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")).unwrap(); + let doc = reopen(&doc); + let fonts = dr_fonts(&doc); + for name in ["Helv", "TiRo", "Cour"] { + assert!(fonts.reference(name.as_bytes()).is_some(), "{name} is an object (indirect /DR {indirect_dr}): {fonts:?}"); + } + assert_eq!(acroform(&doc).unwrap().reference(b"DR").is_some(), indirect_dr, "/DR keeps its form"); + } +} + +/// #3441: fonts the form already has are its own, not replaced by the standard ones: an inline +/// `/Helv` that is Arial becomes an object that is still Arial, a `/TiRo` object stays that +/// object, and a form needing no change leaves its `/DR` object unwritten. The fonts PdfCraft +/// adds use WinAnsi, as Acrobat's do. +#[test] +fn existing_form_fonts_are_kept_and_an_unchanged_resource_dictionary_is_not_rewritten() { + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Fields [] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv << /Type /Font /Subtype /Type1 /BaseFont /Arial >> /TiRo 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Times-Bold >>".into(), + ]); + add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")).unwrap(); + let doc = reopen(&doc); + let fonts = dr_fonts(&doc); + let font = |name: &str| doc.get(fonts.reference(name.as_bytes()).unwrap()).as_dict().cloned().unwrap(); + assert_eq!(font("Helv").name(b"BaseFont"), Some(&b"Arial"[..]), "the form's own Helv"); + assert_eq!(fonts.reference(b"TiRo"), Some(pdfcraft_cos::ObjRef::new(5, 0)), "the same object"); + assert_eq!(font("Cour").name(b"Encoding"), Some(&b"WinAnsiEncoding"[..])); + + // Every font an object already, /DR an object: adding a field leaves /DR as it was. + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Fields [] /DA (/Helv 0 Tf 0 g) /DR 5 0 R >>".into(), + "<< /Font << /Helv 6 0 R /TiRo 6 0 R /Cour 6 0 R >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + ]); + add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")).unwrap(); + assert!(!doc.modified_objects().contains(&5), "/DR unchanged: {:?}", doc.modified_objects()); +} + +/// #3441, exhaustively: every way a form's `/DR` may hold its fonts. `/DR` missing, inline or an +/// object; its `/Font` missing, inline, an object or not a dictionary; `/Helv` missing, inline, +/// an object, a reference to nothing, `null` or a number; and a font of the form's own, inline or +/// an object. After a field is added, whatever the shape: +/// - no font is a dictionary written inline; +/// - a font that was an object is the same object, and one that was inline says the same as an +/// object; +/// - a `/DR` or `/Font` that was an object (and a dictionary) still is one; +/// - an entry that is not a dictionary (`null`, a number, a reference to nothing) is left alone; +/// - Helv, TiRo and Cour are there. +#[test] +fn every_shape_of_form_fonts_ends_as_objects() { + let font = |base: &str| format!("<< /Type /Font /Subtype /Type1 /BaseFont /{base} >>"); + let mut cases = 0; + for dr_shape in ["missing", "inline", "object"] { + for font_shape in ["missing", "inline", "object", "number"] { + for helv in ["missing", "inline", "object", "dangling", "null", "number"] { + for mine in ["missing", "inline", "object"] { + if dr_shape == "missing" && (font_shape != "missing" || helv != "missing" || mine != "missing") { + continue; + } + if matches!(font_shape, "missing" | "number") && (helv != "missing" || mine != "missing") { + continue; + } + cases += 1; + let what = format!("/DR {dr_shape}, /Font {font_shape}, /Helv {helv}, /Mine {mine}"); + let mut entries = String::new(); + match helv { + "inline" => entries += &format!("/Helv {} ", font("Arial")), + "object" => entries += "/Helv 7 0 R ", + "dangling" => entries += "/Helv 99 0 R ", + "null" => entries += "/Helv null ", + "number" => entries += "/Helv 3 ", + _ => {} + } + match mine { + "inline" => entries += &format!("/Mine {} ", font("Symbol")), + "object" => entries += "/Mine 8 0 R ", + _ => {} + } + let fonts_value = match font_shape { + "inline" => format!("<< {entries}>>"), + "object" => "6 0 R".into(), + "number" => "7".into(), + _ => String::new(), + }; + let dr_dict = if fonts_value.is_empty() { "<< >>".to_string() } else { format!("<< /Font {fonts_value} >>") }; + let dr_value = match dr_shape { + "inline" => dr_dict.clone(), + "object" => "5 0 R".into(), + _ => String::new(), + }; + let af = if dr_value.is_empty() { "<< /Fields [] >>".to_string() } else { format!("<< /Fields [] /DR {dr_value} >>") }; + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + af, + if dr_shape == "object" { dr_dict } else { "null".into() }, + if font_shape == "object" { format!("<< {entries}>>") } else { "null".into() }, + font("Helvetica-Oblique"), + font("ZapfDingbats"), + ]); + add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")) + .unwrap_or_else(|e| panic!("{what}: {e}")); + let doc = reopen(&doc); + let af = acroform(&doc).unwrap(); + let dr = doc.resolve(af.get(b"DR").unwrap_or_else(|| panic!("{what}: no /DR"))); + let fonts = dr_fonts(&doc); + for (name, value) in fonts.iter() { + assert!(value.as_dict().is_none(), "{what}: {} is inline", String::from_utf8_lossy(name)); + } + for name in ["Helv", "TiRo", "Cour"] { + assert!(fonts.contains(name.as_bytes()), "{what}: no {name}"); + } + let base = + |name: &[u8]| fonts.reference(name).and_then(|r| doc.get(r).as_dict().and_then(|f| f.name(b"BaseFont").map(<[u8]>::to_vec))); + match helv { + "inline" => assert_eq!(base(b"Helv").as_deref(), Some(&b"Arial"[..]), "{what}"), + "object" => assert_eq!(fonts.reference(b"Helv"), Some(pdfcraft_cos::ObjRef::new(7, 0)), "{what}"), + "dangling" => assert_eq!(fonts.reference(b"Helv"), Some(pdfcraft_cos::ObjRef::new(99, 0)), "{what}"), + "null" => assert_eq!(fonts.get(b"Helv"), Some(&Object::Null), "{what}"), + "number" => assert_eq!(fonts.get(b"Helv"), Some(&Object::Int(3)), "{what}"), + _ => assert_eq!(base(b"Helv").as_deref(), Some(&b"Helvetica"[..]), "{what}"), + } + match mine { + "inline" => assert_eq!(base(b"Mine").as_deref(), Some(&b"Symbol"[..]), "{what}"), + "object" => assert_eq!(fonts.reference(b"Mine"), Some(pdfcraft_cos::ObjRef::new(8, 0)), "{what}"), + _ => assert!(!fonts.contains(b"Mine"), "{what}"), + } + if dr_shape == "object" { + assert_eq!(af.reference(b"DR"), Some(pdfcraft_cos::ObjRef::new(5, 0)), "{what}: /DR stays its object"); + } + if font_shape == "object" { + assert_eq!(dr.as_dict().unwrap().reference(b"Font"), Some(pdfcraft_cos::ObjRef::new(6, 0)), "{what}: /Font stays its object"); + } + } + } + } + } + assert_eq!(cases, 1 + 2 * (2 + 2 * 6 * 3), "every shape was tried"); +} + +/// #3441 (review follow-up): filling a field converts the form's inline `/DR` fonts, so a form +/// an earlier PdfCraft made works in Acrobat once it has been filled here. Nothing is added. A +/// form a signature covers keeps `/DR` as the signer saw it, and a form whose fonts are objects +/// already leaves its `/DR` object unwritten. +#[test] +fn filling_a_field_makes_inline_form_fonts_objects_unless_the_form_is_signed() { + let inline = "/Helv << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"; + let form = |signed: bool, dr: &str| { + let sig = if signed { "<< /Type /Annot /Subtype /Widget /FT /Sig /T (sig) /V 7 0 R /Rect [0 0 0 0] /P 3 0 R >>" } else { "null" }; + let fields = if signed { "[5 0 R 6 0 R]" } else { "[5 0 R]" }; + document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>".into(), + format!("<< /Fields {fields} /DA (/Helv 0 Tf 0 g) /DR {dr} >>"), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /DA (/Helv 12 Tf 0 g) /Rect [50 700 300 720] /P 3 0 R >>".into(), + sig.into(), + "<< /Type /Sig /Filter /Adobe.PPKLite /Contents <00> >>".into(), + "<< /Font << /Helv 9 0 R >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + ]) + }; + let mut doc = form(false, &format!("<< /Font << {inline} >> >>")); + set_value(&mut doc, "name", &FieldValue::Text("Ada".into())).unwrap(); + let doc = reopen(&doc); + let fonts = dr_fonts(&doc); + assert!(fonts.reference(b"Helv").is_some(), "Helv became an object: {fonts:?}"); + assert_eq!(fonts.len(), 1, "nothing added: {fonts:?}"); + assert_eq!(field(&fields(&doc), "name").value, ["Ada"]); + + let mut doc = form(true, &format!("<< /Font << {inline} >> >>")); + set_value(&mut doc, "name", &FieldValue::Text("Ada".into())).unwrap(); + let doc = reopen(&doc); + assert!(dr_fonts(&doc).get(b"Helv").is_some_and(|h| h.as_dict().is_some()), "a signed form keeps /DR as it was"); + + // An empty signature field signs nothing: the form is converted as an unsigned one. + let mut doc = form(true, &format!("<< /Font << {inline} >> >>")); + doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| { + d.remove(b"V"); + }) + .unwrap(); + set_value(&mut doc, "name", &FieldValue::Text("Ada".into())).unwrap(); + assert!(dr_fonts(&reopen(&doc)).reference(b"Helv").is_some(), "an unsigned signature field doesn't hold /DR back"); + + let mut doc = form(false, "8 0 R"); + set_value(&mut doc, "name", &FieldValue::Text("Ada".into())).unwrap(); + assert!(!doc.modified_objects().contains(&8), "/DR unchanged: {:?}", doc.modified_objects()); +} + +/// #3441 (review follow-up): a signature value held by a parent field signs its kid too (`/V` is +/// inheritable, ISO 32000-2 Table 226), so the form still counts as signed and keeps its `/DR`. +#[test] +fn a_signature_value_on_a_parent_field_keeps_the_form_fonts_as_signed() { + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R 8 0 R] >>".into(), + "<< /Fields [5 0 R 6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> >> >> >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /DA (/Helv 12 Tf 0 g) /Rect [50 700 300 720] /P 3 0 R >>".into(), + "<< /FT /Sig /T (sig) /V 7 0 R /Kids [8 0 R] >>".into(), + "<< /Type /Sig /Filter /Adobe.PPKLite /Contents <00> >>".into(), + "<< /Type /Annot /Subtype /Widget /T (s1) /Parent 6 0 R /Rect [0 0 0 0] /P 3 0 R >>".into(), + ]); + let kid = pdfcraft_cos::ObjRef::new(8, 0); + assert!(fields(&doc).iter().any(|f| f.obj == kid && f.kind == FieldKind::Signature), "the kid is the signature field"); + set_value(&mut doc, "name", &FieldValue::Text("Ada".into())).unwrap(); + let doc = reopen(&doc); + assert!(dr_fonts(&doc).get(b"Helv").is_some_and(|h| h.as_dict().is_some()), "an inherited signature value keeps /DR as it was"); + assert_eq!(field(&fields(&doc), "name").value, ["Ada"]); +} + +/// #3441 (review follow-up): a catalog that holds `/AcroForm` as a direct dictionary gets its +/// inline `/DR` fonts converted too when a field is filled. +#[test] +fn filling_a_field_converts_inline_fonts_of_a_direct_form_dictionary() { + let mut doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> >> >> >> >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /DA (/Helv 12 Tf 0 g) /Rect [50 700 300 720] /P 3 0 R >>".into(), + ]); + set_value(&mut doc, "name", &FieldValue::Text("Ada".into())).unwrap(); + let doc = reopen(&doc); + let fonts = dr_fonts(&doc); + assert!(fonts.reference(b"Helv").is_some(), "Helv became an object: {fonts:?}"); + assert_eq!(fonts.len(), 1, "nothing added: {fonts:?}"); + assert_eq!(field(&fields(&doc), "name").value, ["Ada"]); +} + +/// #3926 / #3943 review: a password value can be inherited from an ancestor field or sit on a +/// separate widget. Clearing removes both; an ancestor's value shared with a non-password field +/// stays for that field, and the password field gets an empty value of its own instead. +#[test] +fn inherited_and_widget_password_values_are_cleared() { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 9 0 R 11 0 R] >>".into(), // 3 + "<< /Fields [5 0 R 8 0 R 10 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), // 4 + "<< /T (acct) /FT /Tx /Ff 8192 /V (secret1) /Kids [6 0 R] >>".into(), // 5 flag and value inherited + "<< /Type /Annot /Subtype /Widget /T (pin) /Parent 5 0 R /Rect [50 700 150 720] /P 3 0 R >>".into(), // 6 + "<< /Type /Annot /Subtype /Widget /Parent 8 0 R /V (secret3) /Rect [50 650 150 670] /P 3 0 R >>".into(), // 7 value on the widget + "<< /FT /Tx /T (code) /Ff 8192 /Kids [7 0 R] >>".into(), // 8 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (pw) /Ff 8192 /Parent 10 0 R /Rect [50 600 150 620] /P 3 0 R >>".into(), // 9 + "<< /T (grp) /V (shared2) /Kids [9 0 R 11 0 R] >>".into(), // 10 shared with a plain field + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (plain) /Parent 10 0 R /Rect [50 550 150 570] /P 3 0 R >>".into(), // 11 + ]; + let mut doc = document(&objs); + assert!(has_password_values(&doc)); + let value = |doc: &Document, n: &str| fields(doc).into_iter().find(|f| f.name == n).map(|f| f.value).unwrap_or_default(); + assert_eq!(value(&doc, "acct.pin"), ["secret1"], "inherited before"); + assert_eq!(clear_password_values(&mut doc).unwrap(), 3); + assert!(!has_password_values(&doc)); + for n in ["acct.pin", "code", "grp.pw"] { + assert!(value(&doc, n).iter().all(|v| v.is_empty()), "{n} cleared"); + } + assert_eq!(value(&doc, "grp.plain"), ["shared2"], "the shared value stays for the plain field"); + let has_v = |doc: &Document, n: u32| doc.get(pdfcraft_cos::ObjRef { num: n, generation: 0 }).as_dict().is_some_and(|d| d.get(b"V").is_some()); + assert!(!has_v(&doc, 5), "an ancestor only password fields inherit from loses its value"); + assert!(!has_v(&doc, 7), "a widget's value is removed"); + assert!(has_v(&doc, 10), "a shared ancestor keeps its value"); +} + +/// #3943 review: a cleared password field saves as Acrobat saves it (black-box test). One with a +/// default value gets it back, and its appearance shows as many masking characters as the +/// default has, not as the typed password had; one without is left empty and draws none. The +/// default value and the scripts stay, and a field already at its default needs no clearing. +#[test] +fn a_cleared_password_field_returns_to_its_default_value() { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R] >>".into(), // 3 + "<< /Fields [6 0 R 7 0 R 8 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), // 4 + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), // 5 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (pw) /Ff 8192 /DV (DEF-1) /V (NEWPASS-77777) /AA << /K << /S /JavaScript /JS (1;) >> >> /Rect [50 700 250 720] /P 3 0 R >>".into(), // 6 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (pw3) /Ff 8192 /V (PW3-TYPED-9) /Rect [50 650 250 670] /P 3 0 R >>".into(), // 7 + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (blank) /Ff 8192 /V [()] /Rect [50 600 250 620] /P 3 0 R >>".into(), // 8 holds nothing (an empty string in an array) + ]; + let mut doc = document(&objs); + assert_eq!(clear_password_values(&mut doc).unwrap(), 2); + let all = fields(&doc); + let (pw, pw3) = (field(&all, "pw"), field(&all, "pw3")); + assert_eq!(pw.value, ["DEF-1"], "back to its default"); + assert_eq!(pw.default, ["DEF-1"], "the default stays"); + assert!(pw.actions.scripts.keystroke.is_some(), "the scripts stay"); + assert!(pw3.value.iter().all(|v| v.is_empty()), "no default: empty"); + let shown = ap(&doc, &pw.widgets[0]); + assert!(shown.contains("(*****)") && !shown.contains("NEWPASS") && !shown.contains("******"), "{shown}"); + let shown3 = ap(&doc, &pw3.widgets[0]); + assert!(!shown3.contains('*') && !shown3.contains("PW3"), "{shown3}"); + assert!(!has_password_values(&doc), "a field at its default holds no typed password"); + assert_eq!(clear_password_values(&mut doc).unwrap(), 0); +} + +/// #3943 review: the quick check that skips forms without a Password field still sees one whose +/// widget is only on the page, not listed in `/Fields`; one that inherits the flag from a parent +/// that is not listed either; and one deeper in the field tree whose widget is on no page. Each +/// is alone in its own document, so each path of the check is needed. +#[test] +fn a_password_field_missing_from_the_field_list_is_still_cleared() { + let form = |fields: &str, annots: &str, rest: &[&str]| { + 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 [{annots}] >>"), + format!("<< /Fields [{fields}] /DA (/Helv 0 Tf 0 g) >>"), + ]; + objs.extend(rest.iter().map(|o| o.to_string())); + document(&objs) + }; + let page_only = + form("", "5 0 R", &["<< /Type /Annot /Subtype /Widget /FT /Tx /T (pin) /Ff 8192 /V (secret5) /Rect [50 700 150 720] /P 3 0 R >>"]); + let inherited = form( + "", + "5 0 R", + &[ + "<< /Type /Annot /Subtype /Widget /T (kid) /Parent 6 0 R /Rect [50 650 150 670] /P 3 0 R >>", + "<< /FT /Tx /T (acct) /Ff 8192 /V (secret6) /Kids [5 0 R] >>", + ], + ); + let off_page = form( + "5 0 R", + "", + &[ + "<< /T (grp) /Kids [6 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (hidden) /Ff 8192 /V (secret7) /Parent 5 0 R /Rect [0 0 0 0] >>", + ], + ); + for (what, mut doc) in [("page only", page_only), ("inherited", inherited), ("on no page", off_page)] { + assert!(has_password_values(&doc), "{what}"); + assert_eq!(clear_password_values(&mut doc).unwrap(), 1, "{what}"); + assert!(fields(&doc).iter().all(|f| f.value.iter().all(|v| v.is_empty())), "{what}: {:?}", fields(&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] +fn a_value_wider_or_taller_than_its_box_is_reported() { + let long = "Doe, Jane (represented by John Doe)"; + let field = |name: &str, rect: &str, extra: &str, value: &str| { + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /V ({value}) /Rect [{rect}] /P 3 0 R {extra} >>") + }; + let doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R] >>".into(), + "<< /Fields [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + field("single", "50 700 190 718", "", long), + field("multi", "50 650 190 668", "/Ff 4096", long), + field("auto", "50 600 190 618", "/DA (/Helv 0 Tf 0 g)", long), + field("wide", "50 550 350 568", "", long), + field("short", "50 500 190 518", "/Ff 4096", "Ada"), + ]); + let overflow = |name: &str| { + let f = fields(&doc).into_iter().find(|f| f.name == name).unwrap(); + appearance::value_overflow(&doc, &f, &f.widgets[0]) + }; + match overflow("single") { + Some(appearance::Overflow::Wide { width, room, size }) => { + assert_eq!(size, 12.0); + assert!((width - 201.4).abs() < 1.0 && room < 140.0, "{width} in {room}"); + } + other => panic!("single: {other:?}"), + } + assert_eq!(overflow("multi"), Some(appearance::Overflow::Tall { lines: 2, room: 1, size: 12.0 })); + assert_eq!(overflow("auto"), None, "auto size shrinks to fit"); + assert_eq!(overflow("wide"), None); + assert_eq!(overflow("short"), None); +} + +/// #4018: a single line taller than its box is cut at the top and bottom, and a character wider +/// than a multiline box is a line of its own that is still cut at the sides; both are reported. +/// An empty value draws nothing, so nothing is cut however small the box. +#[test] +fn a_line_taller_than_its_box_or_a_character_wider_than_it_is_reported() { + let field = |name: &str, rect: &str, extra: &str, value: &str| { + format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /V ({value}) /Rect [{rect}] /P 3 0 R {extra} >>") + }; + let doc = document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R] >>".into(), + "<< /Fields [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + field("low", "50 700 190 708", "", "Ada"), + field("comb", "50 650 190 658", "/Ff 16777216 /MaxLen 5", "Ada"), + field("narrow", "50 500 56 560", "/Ff 4096", "WW"), + field("roomy", "50 400 190 418", "", "Ada"), + field("empty", "50 350 190 358", "", ""), + field("empty-multi", "50 300 190 304", "/Ff 4096", ""), + ]); + let overflow = |name: &str| { + let f = fields(&doc).into_iter().find(|f| f.name == name).unwrap(); + appearance::value_overflow(&doc, &f, &f.widgets[0]) + }; + for name in ["low", "comb"] { + match overflow(name) { + Some(o @ appearance::Overflow::High { height, room, size }) => { + assert_eq!(size, 12.0); + assert!((height - 11.1).abs() < 0.01 && room <= 8.0, "{name}: {height} in {room}"); + assert!(o.to_string().starts_with("the value does not fit: 11.1 pt high at 12 pt"), "{o}"); + } + other => panic!("{name}: {other:?}"), + } + } + match overflow("narrow") { + Some(appearance::Overflow::Wide { width, room, size }) => { + assert_eq!(size, 12.0); + assert!(width > room && (width - 11.32).abs() < 0.1, "{width} in {room}"); + } + other => panic!("narrow: {other:?}"), + } + assert_eq!(overflow("roomy"), None); + assert_eq!(overflow("empty"), None); + assert_eq!(overflow("empty-multi"), None); +} + +/// Check boxes, radio buttons and push buttons whose file gives them no appearance, a comb field, +/// a combo box and a list box, for the display copy. +fn left_buttons() -> Document { + let widget = |n: &str, rest: &str| format!("<< /Type /Annot /Subtype /Widget /T ({n}) /F 4 /P 3 0 R {rest} >>"); + document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R 15 0 R 16 0 R 17 0 R 18 0 R] >>".into(), + "<< /Fields [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R 15 0 R 16 0 R 17 0 R 18 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + widget("i", "/FT /Btn /V /Yes /AS /Yes /DA (/ZaDb 0 Tf 0 g) /MK << /CA (4) /BC [0 0 0] >> /Rect [50 700 74 724]"), // 6 + widget("j", "/FT /Btn /V /Yes /AS /Yes /DA (/ZaDb 0 Tf 0 0 1 rg) /MK << /CA (l) /BC [0 0 0] >> /Rect [100 700 124 724]"), + widget("k", "/FT /Btn /V /Yes /AS /Yes /DA (/ZaDb 0 Tf 1 0 0 rg) /MK << /CA (8) /BC [0 0 0] >> /Rect [150 700 174 724]"), + widget("l", "/FT /Btn /V /Yes /AS /Yes /DA (/ZaDb 9 Tf 0 g) /MK << /CA (H) /BC [0 0 0] /BG [1 1 0] >> /Rect [200 700 224 724]"), + widget("off", "/FT /Btn /V /Off /AS /Off /DA (/ZaDb 0 Tf 0 g) /MK << /CA (4) /BC [0 0 0] >> /Rect [250 700 274 724]"), // 10 + widget("radio", "/FT /Btn /Ff 32768 /V /A /AS /A /DA (/ZaDb 0 Tf 0 g) /MK << /BC [0 0 0] >> /Rect [300 700 324 724]"), + widget( + "o", + "/FT /Btn /Ff 65536 /DA (/Helv 12 Tf 0 g) /MK << /CA (Press me) /BG [0.8 0.8 0.8] /BC [0 0 0] >> /BS << /W 2 /S /B >> /Rect [50 600 170 624]", + ), + widget("own", "/FT /Btn /V /Yes /AS /Yes /MK << /CA (4) /BC [0 0 0] >> /Rect [350 700 374 724] /AP << /N << /Yes 19 0 R /Off 19 0 R >> >>"), + widget("half", "/FT /Btn /V /Yes /AS /Yes /DA (/ZaDb 0 Tf 0 g) /MK << /CA (8) >> /Rect [400 700 424 724] /AP << /N << /Off 19 0 R >> >>"), + widget("bad", "/FT /Btn /V /Yes /AS /Yes /DA (/ZaDb nan Tf 1 x rg) /MK << /CA 42 /BC [(no) /Name] /BG 7 >> /BS << /W -3 >> /Rect [450 700 474 724]"), // 15 + widget("d", "/FT /Tx /Ff 16777216 /MaxLen 6 /V (ABC123) /DA (/Helv 12 Tf 0 g) /MK << /BC [0 0 1] >> /Rect [50 500 170 524]"), + widget("m", "/FT /Ch /Ff 131072 /Opt [(Apple) (Banana)] /V (Banana) /Q 2 /DA (/Helv 12 Tf 0 g) /MK << /BC [0 0 0] >> /Rect [50 400 170 424]"), + widget("n", "/FT /Ch /Opt [(One) (Two)] /V (Two) /DA (/Helv 11 Tf 0 g) /MK << /BC [0 0 0] >> /Rect [200 400 320 460]"), + "<< /Length 4 >>\nstream\nq\nQ\nendstream".into(), // 19 + ]) +} + +/// The appearance stream of widget `w` in `state` (a check box's `/AP /N /`). +fn state_ap(doc: &Document, w: &Widget, state: &str) -> String { + let wd = doc.get(w.obj).as_dict().cloned().unwrap(); + let ap = doc.resolve(wd.get(b"AP").unwrap()).as_dict().cloned().unwrap(); + let n = doc.resolve(ap.get(b"N").unwrap()).as_dict().cloned().unwrap(); + let r = n.reference(state.as_bytes()).expect("the state's appearance"); + let Object::Stream(s) = &*doc.get(r) else { panic!("a stream") }; + String::from_utf8_lossy(&s.decoded().unwrap()).into_owned() +} + +/// The decoded appearance PdfCraft would save for text or choice field `name` of `doc`. +fn saved_look(doc: &Document, name: &str) -> String { + let all = fields(doc); + let f = field(&all, name); + String::from_utf8_lossy(&appearance::field_appearance(doc, f, &f.widgets[0], &f.value).decoded().unwrap()).into_owned() +} + +/// ISO 32000-2 Table 192 and 12.7.5.2.3: a check box with no appearance shows its `/MK /BC` +/// border and, when on, its caption `/MK /CA` as a ZapfDingbats character in the `/DA` colour, +/// sized to the box when the `/DA` size is 0. +#[test] +fn check_boxes_without_an_appearance_show_their_caption_symbol() { + let doc = left_buttons(); + let copy = with_field_appearances(&doc).expect("buttons to draw"); + let all = fields(©); + for (name, symbol, colour) in [("i", "(4) Tj", "0 g"), ("j", "(l) Tj", "0 0 1 rg"), ("k", "(8) Tj", "1 0 0 rg"), ("l", "(H) Tj", "0 g")] { + let shown = state_ap(©, &field(&all, name).widgets[0], "Yes"); + assert!(shown.contains(symbol), "{name}: {shown}"); + assert!(shown.contains(&format!("{colour}\n")), "{name} in its colour: {shown}"); + assert!(shown.contains("0 0 0 RG\n1 w\n0.5 0.5 23 23 re S"), "{name} has its border: {shown}"); + } + // A /DA size is kept; the automatic one fills the box inside the border. + let l = state_ap(©, &field(&all, "l").widgets[0], "Yes"); + assert!(l.contains("/ZaDb 9 Tf") && l.contains("1 1 0 rg\n0 0 24 24 re f"), "{l}"); + let i = state_ap(©, &field(&all, "i").widgets[0], "Yes"); + let size: f64 = i.split("/ZaDb ").nth(1).and_then(|s| s.split(' ').next()).and_then(|s| s.parse().ok()).unwrap(); + assert!((18.0..=26.0).contains(&size), "{size}"); + let w = copy.get(field(&all, "i").widgets[0].obj); + let ap = copy.resolve(w.as_dict().unwrap().get(b"AP").unwrap()); + let n = copy.resolve(ap.as_dict().unwrap().get(b"N").unwrap()); + let Object::Stream(s) = &*copy.get(n.as_dict().unwrap().reference(b"Yes").unwrap()) else { panic!() }; + let font = s.dict.get(b"Resources").and_then(|r| r.as_dict()).and_then(|r| r.get(b"Font")).and_then(|f| f.as_dict()).and_then(|f| f.get(b"ZaDb")); + assert_eq!(font.and_then(|f| f.as_dict()).and_then(|f| f.name(b"BaseFont")), Some(&b"ZapfDingbats"[..]), "a standard-14 font, no program"); +} + +/// An Off check box shows only its border, a radio button without a caption a dot (`l`), a box +/// whose file has only an Off look gets an on look, and one with its own look keeps it. +#[test] +fn button_states_without_an_appearance() { + let doc = left_buttons(); + let copy = with_field_appearances(&doc).expect("buttons to draw"); + let all = fields(©); + let off = state_ap(©, &field(&all, "off").widgets[0], "Off"); + assert!(off.contains("re S") && !off.contains("Tj"), "{off}"); + assert!(state_ap(©, &field(&all, "radio").widgets[0], "A").contains("(l) Tj")); + assert!(state_ap(©, &field(&all, "half").widgets[0], "Yes").contains("(8) Tj")); + let own = |d: &Document| d.get(field(&fields(d), "own").widgets[0].obj).as_dict().and_then(|w| w.get(b"AP").cloned()); + assert_eq!(own(©), own(&doc), "a check box with its own look keeps it"); + // Nonsense in /MK, /DA and /BS: the default symbol, no border, and no panic. + let bad = state_ap(©, &field(&all, "bad").widgets[0], "Yes"); + assert!(bad.contains("(4) Tj") && !bad.contains("re S"), "{bad}"); + // The document itself is untouched. + assert!(doc.get(field(&fields(&doc), "i").widgets[0].obj).as_dict().is_some_and(|w| !w.contains(b"AP"))); +} + +/// Table 192 and Table 168: a push button with no appearance shows its `/MK /BG` background, its +/// beveled `/MK /BC` border in the `/BS` width, and its caption `/MK /CA` centred. +#[test] +fn push_buttons_without_an_appearance_show_their_caption_and_frame() { + let copy = with_field_appearances(&left_buttons()).expect("buttons to draw"); + let shown = ap(©, &field(&fields(©), "o").widgets[0]); + for part in ["0.8 0.8 0.8 rg\n0 0 120 24 re f", "0 0 0 RG\n2 w\n1 1 118 22 re S", "1 g\n", "0.5 g\n", "(Press me) Tj", "/Helv 12.00 Tf"] { + assert!(shown.contains(part), "{part}: {shown}"); + } +} + +/// ISO 32000-2 Table 228, Comb: the field is divided into MaxLen equally spaced positions, and a +/// bordered comb field shows them: MaxLen - 1 lines in the border colour, in its saved look too. +#[test] +fn comb_fields_show_their_cells() { + let mut doc = left_buttons(); + let shown = saved_look(&doc, "d"); + let lines: Vec<&str> = shown.lines().filter(|l| l.ends_with(" l") && l.contains(" 1 m ")).collect(); + assert_eq!(lines, ["20 1 m 20 23 l", "40 1 m 40 23 l", "60 1 m 60 23 l", "80 1 m 80 23 l", "100 1 m 100 23 l"], "{shown}"); + assert!(shown.contains("0 0 1 RG\n1 w\n20 1 m"), "in the border colour: {shown}"); + // Past the cap no lines, and a huge MaxLen is no trouble; without a border, none either. + let r = field(&fields(&doc), "d").widgets[0].obj; + doc.update_dict(r, |w| w.set(b"MaxLen".to_vec(), Object::Int(1 << 40))).unwrap(); + let huge = saved_look(&doc, "d"); + assert!(!huge.contains(" 1 m ") && huge.contains("(A) Tj"), "{huge}"); + doc.update_dict(r, |w| { + w.set(b"MaxLen".to_vec(), Object::Int(6)); + w.remove(b"MK"); + }) + .unwrap(); + let plain = saved_look(&doc, "d"); + assert!(!plain.contains(" m ") && plain.contains("(A) Tj"), "{plain}"); +} diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs index 4f31d04d34..7b0d0e88a7 100644 --- a/crates/js/src/formcalc.rs +++ b/crates/js/src/formcalc.rs @@ -1948,7 +1948,7 @@ fn parse_date(text: &str, picture: &str) -> Option<(i64, u32, u32)> { } /// Number of days in month `m` (1-12) of year `y`, leap years included. -fn days_in(y: i64, m: u32) -> u32 { +pub(crate) fn days_in(y: i64, m: u32) -> u32 { // The length of the month from chrono (the first of the next month, less the first of this // one). An invalid month keeps the old 31-day default. // Month lengths repeat every 400 years, so the year within its cycle is enough (and always @@ -2325,10 +2325,11 @@ impl Interp<'_> { } text = if frac.is_empty() { g } else { format!("{g}.{frac}") }; } + let nonzero = text.chars().any(|c| ('1'..='9').contains(&c)); if pic.contains('$') { text = format!("${text}"); } - if num < 0.0 { + if num < 0.0 && nonzero { text = format!("-{text}"); } Val::Str(text) diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 13bdc4ba36..4db899c1c1 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -13,8 +13,8 @@ //! `required`, `display`, `hidden`, `numItems`, `getItemAt`, `currentValueIndices`, //! `isBoxChecked`, `checkThisBox`, `exportValues`, `charLimit`, `textColor`, `fillColor`, //! `setFocus`. -//! - `app` (`alert`, `beep`, `response`, `launchURL`, `viewerType`, `viewerVersion`, -//! `platform`, `setTimeOut`), `util` (`printf`, `printd`, `printx`), `console` (`println`, +//! - `app` (`alert`, `beep`, `response`, `launchURL`, `execMenuItem`, `viewerType`, `viewerVersion`, +//! `platform`, `setTimeOut`), `util` (`printf`, `printd`, `printx`, `scand`), `console` (`println`, //! `show`, `clear`), `display` and `color`. //! //! The engine is a sandbox: no file, network or timer access exists, and loops and recursion @@ -28,7 +28,7 @@ use std::collections::BTreeSet; use std::sync::Arc; use boa_engine::context::HostHooks; -use boa_engine::object::builtins::{JsArray, JsFunction}; +use boa_engine::object::builtins::{JsArray, JsDate, JsFunction}; use boa_engine::object::{FunctionObjectBuilder, ObjectInitializer}; use boa_engine::property::Attribute; use boa_engine::realm::Realm; @@ -164,6 +164,36 @@ pub enum Request { Beep, /// `app.execMenuItem("SaveAs")` (XFA forms' Save buttons). SaveAs, + /// `app.execMenuItem("ShowHide…")`: show the side panel, or hide it when it is showing. + ShowPanel(Panel), +} + +/// A side panel a script can show or hide (`app.execMenuItem("ShowHideFileAttachment")`, …). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Panel { + /// The document's bookmarks (outline). + Bookmarks, + /// Page thumbnails. + Pages, + /// The files attached to the document. + Attachments, + /// The document's signatures and their validity. + Signatures, + /// The document's layers (optional content). + Layers, +} + +impl Panel { + /// The panel's name in automation output, fixed whatever the variant is called. + pub fn id(self) -> &'static str { + match self { + Panel::Bookmarks => "bookmarks", + Panel::Pages => "pages", + Panel::Attachments => "attachments", + Panel::Signatures => "signatures", + Panel::Layers => "layers", + } + } } /// What running a script did. @@ -181,6 +211,9 @@ pub struct Outcome { pub requests: Vec, /// The script's error (syntax error, exception, or a limit reached), if it failed. pub error: Option, + /// Errors in the document-level scripts that ran first. Each is a script of its own: one + /// that throws stops where it failed, and the others and the script itself still run (#3940). + pub doc_errors: Vec, /// The script's completion value (what the console prints), unless undefined. pub result: Option, } @@ -626,6 +659,8 @@ fn message(v: &JsValue, key: &str, ctx: &mut Context) -> JsResult { } } +/// `app.alert(cMsg)`: records the message, given as a string or as an object with `cMsg`, and +/// returns 1 (the OK button). fn alert(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let m = message(&arg(args, 0), "cMsg", ctx)?; host(ctx)?.borrow_mut().alert(m); @@ -633,17 +668,49 @@ fn alert(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult Ok(JsValue::from(1)) } +/// `app.beep()`: queues a request to beep. fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { host(ctx)?.borrow_mut().request(Request::Beep); Ok(JsValue::undefined()) } +/// `app.launchURL(cURL)`: queues a request to open the URL, given as a string or as an object +/// with `cURL`. fn launch_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let u = message(&arg(args, 0), "cURL", ctx)?; host(ctx)?.borrow_mut().request(Request::LaunchUrl(u)); Ok(JsValue::undefined()) } +/// What `app.execMenuItem(name)` asks for, for the menu items forms use: the side panels, Print +/// and Save As. Other items do nothing. +pub(crate) fn menu_item(name: &str) -> Option { + Some(match name { + "ShowHideFileAttachment" => Request::ShowPanel(Panel::Attachments), + "ShowHideBookmarks" => Request::ShowPanel(Panel::Bookmarks), + "ShowHideThumbnails" => Request::ShowPanel(Panel::Pages), + "ShowHideSignatures" => Request::ShowPanel(Panel::Signatures), + "ShowHideOptCont" => Request::ShowPanel(Panel::Layers), + "Print" => Request::Print, + // Save opens Save As too, as it always has for XFA forms' buttons: a click on a form + // never overwrites the file unasked. + "SaveAs" | "Save" => Request::SaveAs, + _ => return None, + }) +} + +/// `app.execMenuItem(name)`: queues the request [`menu_item`] gives for the name (Save opens +/// Save As too); an unknown name does nothing. +fn exec_menu_item(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let item = text(&arg(args, 0), ctx)?; + if let Some(r) = menu_item(&item) { + host(ctx)?.borrow_mut().request(r); + } + Ok(JsValue::undefined()) +} + +/// `app.response(…)`: always `null`, the answer for a canceled dialog, since no one is there to +/// type one. fn response(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { // No one to answer: as if the dialog was cancelled. Ok(JsValue::null()) @@ -885,6 +952,324 @@ fn printd(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult Ok(s(&out)) } +/// A field of a `util.scand` format. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DateUnit { + /// `yyyy`: four digits. + Year, + /// `yy`: two digits, read as 1950–2049. + ShortYear, + Month, + /// `mmm`, `mmmm`: a month's name or its three-letter abbreviation. + MonthName, + Day, + /// `ddd`, `dddd`: a weekday's name, read and not checked. + Weekday, + Hour, + /// `h`, `hh`: 0–12, with `tt` for the afternoon. + Hour12, + Minute, + Second, + /// `t`, `tt`: am or pm. + AmPm, +} + +impl DateUnit { + /// A year, month or day: a format needs one of these for `util.scand` to read anything. + fn is_date(self) -> bool { + matches!(self, Self::Year | Self::ShortYear | Self::Month | Self::MonthName | Self::Day) + } + + /// Read from a run of digits, not from letters. + fn is_numeric(self) -> bool { + !matches!(self, Self::MonthName | Self::Weekday | Self::AmPm) + } +} + +/// A piece of a `util.scand` format. +#[derive(Debug, PartialEq, Eq)] +enum ScanPart { + /// A field, the digits it takes when typed together with the next one (`width`), and whether + /// the next one follows it with nothing between them in the format (`joined`). + Field { unit: DateUnit, width: usize, joined: bool }, + /// Letters the format writes as they are, which the text must have there. + Word(String), +} + +/// The fields and literal words of a `util.scand` format, in order. A numeric field is marked +/// `joined` when another numeric field follows it directly; anything but letters and digits +/// outside a field is left out. +fn scan_parts(fmt: &str) -> Vec { + let chars: Vec = fmt.chars().collect(); + let mut parts: Vec = Vec::new(); + // The last part is a field written right before this point, or letters still being collected. + let (mut after_field, mut in_word) = (false, false); + let mut i = 0; + while let Some(&c) = chars.get(i) { + // Counted for field letters only, so a long run of anything else is read once. + let run = if "ymdHhMst".contains(c) { chars.get(i..).unwrap_or_default().iter().take_while(|x| **x == c).count() } else { 1 }; + let field = match (c, run) { + ('y', 4..) => Some((DateUnit::Year, 4)), + ('y', _) => Some((DateUnit::ShortYear, 2)), + ('m', 3..) => Some((DateUnit::MonthName, 0)), + ('m', n) => Some((DateUnit::Month, n)), + ('d', 3..) => Some((DateUnit::Weekday, 0)), + ('d', n) => Some((DateUnit::Day, n)), + ('H', n) => Some((DateUnit::Hour, n.min(2))), + ('h', n) => Some((DateUnit::Hour12, n.min(2))), + ('M', n) => Some((DateUnit::Minute, n.min(2))), + ('s', n) => Some((DateUnit::Second, n.min(2))), + ('t', _) => Some((DateUnit::AmPm, 0)), + _ => None, + }; + if let Some((unit, width)) = field { + if after_field + && unit.is_numeric() + && let Some(ScanPart::Field { unit: last, joined, .. }) = parts.last_mut() + && last.is_numeric() + { + *joined = true; + } + parts.push(ScanPart::Field { unit, width, joined: false }); + (after_field, in_word) = (true, false); + i += run; + continue; + } + // `\` writes the next character as it is. + let (literal, step) = if c == '\\' { (chars.get(i + 1).copied(), 2) } else { (Some(c), 1) }; + i += step; + after_field = false; + match literal { + Some(l) if l.is_alphanumeric() => match parts.last_mut() { + Some(ScanPart::Word(w)) if in_word => w.push(l), + _ => { + parts.push(ScanPart::Word(l.to_string())); + in_word = true; + } + }, + _ => in_word = false, + } + } + parts +} + +/// A run of digits or of letters in the text `util.scand` reads. +enum ScanToken<'a> { + Digits(&'a str), + Letters(&'a str), +} + +/// The text's runs of digits and of letters, in order; anything else only separates them, and +/// may not begin or end the text. `None` when it does. +fn scan_tokens(text: &str) -> Option>> { + // Some(true) for a digit, Some(false) for a letter, None for a separator. + let kind = |c: char| (c.is_ascii_digit() || c.is_alphabetic()).then_some(c.is_ascii_digit()); + kind(text.chars().next()?)?; + kind(text.chars().next_back()?)?; + let mut out = Vec::new(); + let mut start: Option<(usize, bool)> = None; + for (at, c) in text.char_indices().chain(std::iter::once((text.len(), ' '))) { + let k = kind(c); + if let Some((from, digits)) = start + && k != Some(digits) + { + let piece = text.get(from..at)?; + out.push(if digits { ScanToken::Digits(piece) } else { ScanToken::Letters(piece) }); + start = None; + } + if let Some(digits) = k + && start.is_none() + { + start = Some((at, digits)); + } + } + Some(out) +} + +/// What `util.scand` reads. +#[derive(Debug, PartialEq, Eq)] +enum Scanned { + /// The text is empty: today, at noon. + Today, + /// A date and time: year, month (1–12), day, hours, minutes, seconds. + At(i32, u32, u32, u32, u32, u32), +} + +/// The fields `util.scand` has read so far. +#[derive(Default)] +struct ScanValues { + y: Option, + mo: Option, + d: Option, + h: Option, + mi: Option, + sec: Option, + pm: Option, + /// The hour was read from `h` or `hh`. + twelve: bool, +} + +impl ScanValues { + /// Records `v` as the value of `unit`: a `yy` year as 1950–2049, and an `h` or `hh` hour + /// marked as 12-hour. Month names, weekdays and am/pm are not read here. + fn set(&mut self, unit: DateUnit, v: u32) { + match unit { + DateUnit::Year => self.y = i32::try_from(v).ok(), + DateUnit::ShortYear => self.y = i32::try_from(v).ok().map(|v| if v < 50 { 2000 + v } else { 1900 + v }), + DateUnit::Month => self.mo = Some(v), + DateUnit::Day => self.d = Some(v), + DateUnit::Hour => self.h = Some(v), + DateUnit::Hour12 => { + self.h = Some(v); + self.twelve = true; + } + DateUnit::Minute => self.mi = Some(v), + DateUnit::Second => self.sec = Some(v), + DateUnit::MonthName | DateUnit::Weekday | DateUnit::AmPm => {} + } + } +} + +/// Reads `text` by a `util.scand` format, as another viewer was observed to (black-box): +/// +/// - Fields may be separated by anything but digits and letters, whether or not the format +/// separates them, but nothing may come before the first or after the last. +/// - Digits typed together fill the format's fields written together with exactly as many +/// digits as each has letters (`yyyymd`: 4, 1 and 1). A run of digits for one field alone may +/// have one or two, except a four-digit year. +/// - `yy` is 1950–2049; month names may be written in full or as three letters, in any case. +/// - Impossible dates are refused, but a month or day of 0 is read as 1. +/// - A format with no year, month or day reads nothing; an empty text is today, at noon. +/// +/// Not observed: a year left out is `this_year`, and a month or day left out is 1. +fn scan_date(fmt: &str, text: &str, this_year: i32) -> Option { + let parts = scan_parts(fmt); + if !parts.iter().any(|p| matches!(p, ScanPart::Field { unit, .. } if unit.is_date())) { + return None; + } + if text.is_empty() { + return Some(Scanned::Today); + } + let tokens = scan_tokens(text)?; + let mut v = ScanValues::default(); + let (mut p, mut t) = (0, 0); + while let Some(part) = parts.get(p) { + let token = tokens.get(t)?; + t += 1; + match (part, token) { + (ScanPart::Word(w), ScanToken::Letters(l) | ScanToken::Digits(l)) => { + if w.to_lowercase() != l.to_lowercase() { + return None; + } + p += 1; + } + (ScanPart::Field { unit: DateUnit::MonthName, .. }, ScanToken::Letters(l)) => { + v.mo = Some(named(&MONTHS, l)?.saturating_add(1)); + p += 1; + } + (ScanPart::Field { unit: DateUnit::Weekday, .. }, ScanToken::Letters(l)) => { + named(&DAYS, l)?; + p += 1; + } + (ScanPart::Field { unit: DateUnit::AmPm, .. }, ScanToken::Letters(l)) => { + v.pm = Some(match l.to_lowercase().as_str() { + "am" | "a" => false, + "pm" | "p" => true, + _ => return None, + }); + p += 1; + } + (ScanPart::Field { unit, width, joined }, ScanToken::Digits(run)) if unit.is_numeric() => { + // The fields written together from here, no more than the run's digits can fill, + // and the fewest of them the run fills. + let mut group = vec![(*unit, *width)]; + let (mut more, mut widths) = (*joined, *width); + while more + && widths < run.len() + && let Some(ScanPart::Field { unit, width, joined }) = parts.get(p + group.len()) + { + group.push((*unit, *width)); + widths = widths.saturating_add(*width); + more = *joined; + } + let mut sum = 0; + let fills = group.iter().position(|(_, w)| { + sum += w; + sum == run.len() + }); + if let Some(last) = fills { + let mut at = 0; + for (unit, w) in group.iter().take(last + 1) { + v.set(*unit, run.get(at..at + w)?.parse().ok()?); + at += w; + } + p += last + 1; + } else if *unit != DateUnit::Year && run.len() <= 2 { + v.set(*unit, run.parse().ok()?); + p += 1; + } else { + return None; + } + } + _ => return None, + } + } + if t != tokens.len() { + return None; + } + let y = v.y.unwrap_or(this_year); + let mo = v.mo.unwrap_or(1).max(1); + let d = v.d.unwrap_or(1).max(1); + if mo > 12 || i64::from(d) > i64::from(formcalc::days_in(i64::from(y), mo)) { + return None; + } + let mut h = v.h.unwrap_or(0); + if v.twelve && h > 12 { + return None; + } + match v.pm { + Some(true) if h < 12 => h += 12, + Some(false) if h == 12 => h = 0, + _ => {} + } + let (mi, sec) = (v.mi.unwrap_or(0), v.sec.unwrap_or(0)); + (h <= 23 && mi <= 59 && sec <= 59).then_some(Scanned::At(y, mo, d, h, mi, sec)) +} + +/// The index of the name `word` is, in full or as its first three letters, in any case. +fn named(names: &[&str], word: &str) -> Option { + let word = word.to_lowercase(); + let i = names.iter().position(|n| { + let n = n.to_lowercase(); + word == n || n.get(..3) == Some(word.as_str()) + })?; + u32::try_from(i).ok() +} + +/// `util.scand(cFormat, cDate)`: the date `cDate` gives by a `util.printd` format, as a local +/// `Date`, or `null` when it doesn't read as one (see [`scan_date`]). Only text is read. +fn scand(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let Some(typed) = arg(args, 1).as_string().map(|t| t.to_std_string_escaped()) else { + return Ok(JsValue::null()); + }; + let fmt = text(&arg(args, 0), ctx)?; + let date = JsDate::new(ctx); + let this_year = date.get_full_year(ctx)?.to_number(ctx)?; + let this_year = if this_year.is_finite() { this_year as i32 } else { 2000 }; + match scan_date(&fmt, &typed, this_year) { + None => return Ok(JsValue::null()), + Some(Scanned::Today) => { + date.set_hours(&[12.into(), 0.into(), 0.into(), 0.into()], ctx)?; + } + Some(Scanned::At(y, mo, d, h, mi, sec)) => { + // setFullYear, not the constructor, so years 0–99 stay as they are. + date.set_full_year(&[y.into(), (mo.saturating_sub(1)).into(), d.into()], ctx)?; + date.set_hours(&[h.into(), mi.into(), sec.into(), 0.into()], ctx)?; + } + } + Ok(date.into()) +} + /// `util.printx(cFormat, cSource)`: `?` any character, `X` letter or digit, `A` letter, `9` /// digit, `*` the rest, `\` escape, `>` / `<` / `=` upper / lower / as is. fn printx(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { @@ -950,6 +1335,8 @@ fn printx(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult Ok(s(&out)) } +/// The `color` object: each named color (`transparent`, `black`, `red`, `cyan`, `gray`, …) as +/// an array of its color space (`T`, `G`, `RGB` or `CMYK`) followed by its components. fn color_object(ctx: &mut Context) -> JsObject { let mut entries: Vec<(&str, Vec)> = vec![ ("transparent", vec![s("T")]), @@ -976,8 +1363,10 @@ fn color_object(ctx: &mut Context) -> JsObject { /// The XFA version scripts see as `xfa_version`. const XFA_VERSION: f64 = 3.5; -/// Define the globals every script sees: the document's functions and properties (`getField`, -/// `numPages`, `info`, the XFA version), and `app`, `util`, `console`, `display` and `color`. +/// Defines the form object model's globals: the document's functions (`getField`, `resetForm`, +/// `print`, `submitForm` and others) and properties (`numFields`, `pageNum`, `numPages`, +/// `documentFileName`, `info`, the XFA version), and the `app`, `util`, `console`, `display` and +/// `color` objects. fn install(ctx: &mut Context) -> JsResult<()> { let globals: [(&str, usize, NativeFunction); 9] = [ ("getField", 1, NativeFunction::from_fn_ptr(get_field)), @@ -1025,6 +1414,7 @@ fn install(ctx: &mut Context) -> JsResult<()> { .function(NativeFunction::from_fn_ptr(beep), js_string!("beep"), 0) .function(NativeFunction::from_fn_ptr(response), js_string!("response"), 1) .function(NativeFunction::from_fn_ptr(launch_url), js_string!("launchURL"), 2) + .function(NativeFunction::from_fn_ptr(exec_menu_item), js_string!("execMenuItem"), 1) .function(NativeFunction::from_fn_ptr(timer), js_string!("setTimeOut"), 2) .function(NativeFunction::from_fn_ptr(timer), js_string!("setInterval"), 2) .function(NativeFunction::from_fn_ptr(noop), js_string!("clearTimeOut"), 1) @@ -1041,6 +1431,7 @@ fn install(ctx: &mut Context) -> JsResult<()> { .function(NativeFunction::from_fn_ptr(printf), js_string!("printf"), 1) .function(NativeFunction::from_fn_ptr(printd), js_string!("printd"), 2) .function(NativeFunction::from_fn_ptr(printx), js_string!("printx"), 2) + .function(NativeFunction::from_fn_ptr(scand), js_string!("scand"), 2) .build(); ctx.register_global_property(js_string!("util"), util, rw)?; let console = ObjectInitializer::new(ctx) @@ -1204,16 +1595,31 @@ pub fn run(script: &str, event: &Event, doc: &DocInfo, fields: &[FieldState], do /// script can observe uncommitted writes. Read-only calculations avoid copying the whole form. pub fn run_shared(script: &str, event: &Event, doc: &DocInfo, fields: Arc>, doc_scripts: &[String], limits: Limits) -> Outcome { let failed = |why: String| Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), error: Some(why), ..Default::default() }; - if let Some(why) = std::iter::once(script).chain(doc_scripts.iter().map(String::as_str)).find_map(refuse) { + if let Some(why) = refuse(script) { return failed(why); } + // A document script too long or too deeply nested to run safely is skipped and reported, + // as one that throws is; the others and the script still run (#3940). + let mut skipped = Vec::new(); + let kept: Vec<(usize, &str)> = doc_scripts + .iter() + .enumerate() + .filter_map(|(i, d)| match refuse(d) { + Some(why) => { + skipped.push((i, why)); + None + } + None => Some((i, d.as_str())), + }) + .collect(); + let (kept, skipped) = (&kept, &skipped); #[cfg(not(target_arch = "wasm32"))] { std::thread::scope(|scope| { let spawned = std::thread::Builder::new() .name("pdfcraft-js".into()) .stack_size(SCRIPT_STACK) - .spawn_scoped(scope, || run_here(script, event, doc, fields, doc_scripts, limits)); + .spawn_scoped(scope, || run_here(script, event, doc, fields, kept, skipped, limits)); match spawned { Ok(thread) => thread.join().unwrap_or_else(|_| failed("the script stopped with an internal error".into())), Err(e) => failed(format!("the script engine could not start: {e}")), @@ -1221,7 +1627,7 @@ pub fn run_shared(script: &str, event: &Event, doc: &DocInfo, fields: Arc>, doc_scripts: &[String], limits: Limits) -> Outcome { +/// How [`Outcome::doc_errors`] names a failing document script: by its 1-based place. +fn doc_error(index: usize, error: &dyn std::fmt::Display) -> String { + format!("document script {}: {error}", index.saturating_add(1)) +} + +/// `doc_scripts` are the document scripts to run with their places among all of them; +/// `skipped` the ones refused before running, with the reason. +fn run_here( + script: &str, + event: &Event, + doc: &DocInfo, + fields: Arc>, + doc_scripts: &[(usize, &str)], + skipped: &[(usize, String)], + limits: Limits, +) -> Outcome { let mut ctx = match Context::builder().host_hooks(std::rc::Rc::new(NoCodeFromStrings)).build() { Ok(ctx) => ctx, Err(e) => { @@ -1277,12 +1698,18 @@ fn run_here(script: &str, event: &Event, doc: &DocInfo, fields: Arc = skipped.iter().map(|(i, why)| (*i, doc_error(*i, why))).collect(); let result = (|| -> JsResult<()> { install(&mut ctx)?; let ev = event_object(&mut ctx, event); ctx.register_global_property(js_string!("event"), ev.clone(), Attribute::all())?; - for d in doc_scripts { - ctx.eval(Source::from_bytes(d))?; + // One document script that throws (calling a method PdfCraft lacks, say) must not keep + // every field script in the form from running: what it defined before the error, and + // the other scripts, stay available (#3940). + for &(i, d) in doc_scripts { + if let Err(e) = ctx.eval(Source::from_bytes(d)) { + doc_errors.push((i, doc_error(i, &e))); + } } let r = ctx.eval(Source::from_bytes(script))?; if !r.is_undefined() { @@ -1299,6 +1726,8 @@ fn run_here(script: &str, event: &Event, doc: &DocInfo, fields: Arc() { let h = h.into_inner(); out.changed = h.changed.iter().map(|i| h.fields[*i].clone()).collect(); diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index b2e866faef..3d3c4ab524 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -254,6 +254,13 @@ fn kept_bytes(o: &Outcome) -> usize { o.console.iter().chain(&o.alerts).map(String::len).sum::() + o.requests.iter().map(req).sum::() } +#[test] +fn exec_menu_item_requests_are_capped_like_other_requests() { + let o = go("for (var i = 0; i < 12000; i++) app.execMenuItem('Print');", &Event::field("Mouse Up", "agree", "")); + assert_eq!(o.error, None); + assert_eq!(o.requests.len(), crate::xfa::MAX_EFFECTS); +} + #[test] fn console_alerts_and_requests_are_capped_in_count_and_bytes() { let o = go( @@ -297,6 +304,61 @@ fn document_level_functions_are_available() { assert_eq!(o.value, "8"); } +/// #3940: a document script that throws after defining a helper (here calling an Acrobat method +/// PdfCraft lacks) still leaves the helper, and the later scripts, to the field script. +#[test] +fn a_failing_document_script_does_not_stop_the_field_script() { + let docs = [ + "function double(x) { return x * 2; }\nthis.getField('qty').buttonSetIcon(null);\nvar after = 1;".to_string(), + "function plus(x) { return x + 1; }".to_string(), + "this is not javascript".to_string(), + ]; + let o = run( + "event.value = plus(double(getField('qty').value));", + &Event::field("Calculate", "total", ""), + &doc(), + &fields(), + &docs, + Limits::default(), + ); + assert_eq!((o.error.as_deref(), o.value.as_str()), (None, "9")); + assert_eq!(o.doc_errors.len(), 2, "{:?}", o.doc_errors); + assert!(o.doc_errors[0].starts_with("document script 1: TypeError"), "{:?}", o.doc_errors); + assert!(o.doc_errors[1].starts_with("document script 3: SyntaxError"), "{:?}", o.doc_errors); + // The failing script stopped where it failed. + let o = run("event.value = typeof after;", &Event::field("Calculate", "total", ""), &doc(), &fields(), &docs, Limits::default()); + assert_eq!(o.value, "undefined"); + // Working document scripts report nothing. + assert!(go("event.value = 1;", &Event::field("Calculate", "total", "")).doc_errors.is_empty()); +} + +/// A document script refused before running (too long, or nested too deeply to run safely) is +/// skipped like one that throws: never run, reported in its place, the rest still run. +#[test] +fn a_document_script_too_big_to_run_is_skipped_and_reported() { + let docs = [ + "this.getField('qty').buttonSetIcon(null);".to_string(), + format!("var big = 1{};", "+1".repeat(MAX_SCRIPT_BYTES / 2)), + "function double(x) { return x * 2; }".to_string(), + format!("var deep = {}1{};", "(".repeat(10_000), ")".repeat(10_000)), + ]; + let o = run( + "event.value = double(getField('qty').value) + typeof big;", + &Event::field("Calculate", "total", ""), + &doc(), + &fields(), + &docs, + Limits::default(), + ); + assert_eq!((o.error.as_deref(), o.value.as_str()), (None, "8undefined")); + let places: Vec<&str> = o.doc_errors.iter().map(|e| e.split(':').next().unwrap_or_default()).collect(); + assert_eq!(places, ["document script 1", "document script 2", "document script 4"], "{:?}", o.doc_errors); + assert!(o.doc_errors[1].contains("too long") && o.doc_errors[2].contains("nested too deeply"), "{:?}", o.doc_errors); + // The field script's own limits still refuse it outright. + let o = run(&format!("1{}", "+1".repeat(MAX_SCRIPT_BYTES)), &Event::field("Calculate", "total", ""), &doc(), &fields(), &docs, Limits::default()); + assert!(o.error.is_some_and(|e| e.contains("too long")) && o.doc_errors.is_empty()); +} + #[test] fn errors_and_runaway_scripts_are_contained() { let o = go("event.value = nosuch.thing;", &Event::field("Calculate", "total", "keep")); @@ -336,9 +398,11 @@ fn deeply_nested_scripts_fail_instead_of_crashing() { let o = go(s, &Event::doc("Open")); assert!(o.error.is_some(), "{}…", &s[..20]); } - // A hostile document-level script is refused too, before the field script runs. + // A hostile document-level script is refused too: it never runs, and is reported, while + // the field script still does (#3940). let o = run("event.value = 'ran';", &Event::field("Calculate", "a", ""), &doc(), &fields(), &[hostile[0].clone()], Limits::default()); - assert!(o.error.is_some()); + assert_eq!((o.error, o.value.as_str()), (None, "ran")); + assert!(o.doc_errors.len() == 1 && o.doc_errors[0].contains("nested too deeply"), "{:?}", o.doc_errors); } #[test] @@ -805,6 +869,14 @@ mod formcalc { o.result.unwrap_or_default() } + /// A negative value that rounds to zero under the picture prints without a minus sign. + #[test] + fn format_does_not_print_negative_zero() { + assert_eq!(calc(r#"Format("9.9", -0.04)"#), "0.0"); + assert_eq!(calc(r#"Format("99", -0.2)"#), "00"); + assert_eq!(calc(r#"Format("9.9", -1.5)"#), "-1.5"); + } + /// Assigning one long value to many fields at once stops at the run's byte budget and is /// charged per copy, and a reset list names each field once. #[test] @@ -1328,6 +1400,8 @@ mod session { use crate::xfa::*; use crate::xfa_session::{Script, XfaSession}; + /// A JavaScript or FormCalc script for `activity` on `target`, against the test form with + /// default limits and a 3 s timeout. fn script(source: &str, formcalc: bool, target: &str, activity: &str) -> Script { Script { source: source.into(), @@ -1339,6 +1413,9 @@ mod session { } } + /// JavaScript and FormCalc scripts run in turn in one session, each on the form the caller + /// sends: nothing an earlier script changed or defined leaks into the next, a failing script + /// doesn't end the session, and hundreds of scripts run on its one thread. #[test] fn scripts_of_one_event_run_on_one_thread_each_on_the_form_the_caller_sends() { let mut s = XfaSession::new(XfaDoc { page_count: 3, ..Default::default() }); @@ -1378,6 +1455,8 @@ mod session { assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed()); } + /// A JavaScript or FormCalc script whose engine takes longer to be ready than the script's + /// time limit still runs and returns its result, and the session stays alive. #[test] fn a_scripts_time_starts_when_its_context_is_ready() { // The engine made slow to be ready (300 ms) under a 50 ms script limit: the script is @@ -1398,6 +1477,9 @@ mod session { assert_eq!(s.dead(), None); } + /// An endless JavaScript loop past its 200 ms limit is abandoned within 2 s and leaves the + /// session dead, so nothing more runs in it; an endless FormCalc loop is abandoned or + /// reported as running too long. #[test] fn a_script_past_its_time_is_abandoned_and_the_session_is_dead_after_it() { let mut s = XfaSession::new(XfaDoc::default()); @@ -1455,6 +1537,249 @@ fn shared_field_snapshots_isolate_writes_errors_and_script_globals() { assert!(read.changed.is_empty()); } +mod scand { + use super::*; + use crate::xfa::{XfaDoc, XfaEvent, run_xfa}; + + /// Runs `util.scand(format, text)` for each case and checks the date it gives, written as + /// `yyyy-mm-dd HH:MM:ss`, or `null`. + fn assert_scand(cases: &[(&str, &str, &str)]) { + let list: Vec = cases.iter().map(|(f, t, _)| format!("['{f}', '{t}']")).collect(); + let script = format!( + "event.value = [{}].map(function (c) {{ var d = util.scand(c[0], c[1]); \ + return d === null ? 'null' : util.printd('yyyy-mm-dd HH:MM:ss', d); }}).join('|');", + list.join(", ") + ); + let o = go(&script, &Event::field("Calculate", "total", "")); + assert_eq!(o.error, None); + let got: Vec<&str> = o.value.split('|').collect(); + assert_eq!(got.len(), cases.len()); + for ((f, t, want), got) in cases.iter().zip(got) { + assert_eq!(got, *want, "util.scand('{f}', '{t}')"); + } + } + + // The cases below are another viewer's answers, observed black-box with a synthetic file. + + /// Any separator may come between fields, whether the format has that one, another or none, + /// but a text with a space or a letter before or after the date reads nothing. + #[test] + fn scand_allows_separators_between_fields_but_nothing_before_or_after_them() { + assert_scand(&[ + ("yyyymmdd", "20261018", "2026-10-18 00:00:00"), + ("yyyymmdd", "2026-10-18", "2026-10-18 00:00:00"), + ("yyyymmdd", "2026 10 18", "2026-10-18 00:00:00"), + ("mm/dd/yyyy", "10/18/2026", "2026-10-18 00:00:00"), + ("mm/dd/yyyy", "10-18-2026", "2026-10-18 00:00:00"), + ("yyyymmdd", " 20261018 ", "null"), + ("yyyymmdd", "20261018x", "null"), + ("yyyymmdd", "x20261018", "null"), + ]); + } + + /// Digits typed together fill the joined fields with exactly as many digits as each field has + /// letters, so a run of any other length reads nothing. + #[test] + fn scand_splits_digits_typed_together_by_the_formats_letter_counts() { + assert_scand(&[ + ("yyyymmdd", "261018", "null"), + ("yyyymmdd", "2026118", "null"), + ("yyyymmdd", "202610181", "null"), + ("yymmdd", "20261018", "null"), + ("yyyymdd", "2026118", "2026-01-18 00:00:00"), + ("yyyymmd", "2026118", "2026-11-08 00:00:00"), + ("yyyymd", "2026118", "null"), + ("yyyymd", "202618", "2026-01-08 00:00:00"), + ("yyyymd", "20261018", "null"), + ("yymd", "2618", "2026-01-08 00:00:00"), + ("yymd", "261018", "null"), + ]); + } + + /// A month past 12, a day past the month's end and February 29 outside a leap year are + /// refused, while a month or day of 0 is read as 1. + #[test] + fn scand_refuses_impossible_dates_and_reads_a_zero_month_or_day_as_1() { + assert_scand(&[ + ("yyyymmdd", "20261318", "null"), + ("yyyymmdd", "20260230", "null"), + ("yyyymmdd", "20260229", "null"), + ("yyyymmdd", "20240229", "2024-02-29 00:00:00"), + ("mm/dd/yyyy", "13/45/2026", "null"), + ("yyyymmdd", "20260018", "2026-01-18 00:00:00"), + ("yyyymmdd", "20261000", "2026-10-01 00:00:00"), + ]); + } + + /// A field typed by itself may have one or two digits, but a `yyyy` year needs four; `yy` reads + /// as 1950–2049; month names may be full or three letters in any case; weekday names are read. + #[test] + fn scand_reads_lone_fields_two_digit_years_and_month_and_weekday_names() { + assert_scand(&[ + ("mm/dd/yyyy", "1/5/2026", "2026-01-05 00:00:00"), + ("m/d/yyyy", "1/5/2026", "2026-01-05 00:00:00"), + ("m/d/yyyy", "01/05/2026", "2026-01-05 00:00:00"), + ("m/d/yy", "1/5/26", "2026-01-05 00:00:00"), + ("mm/dd/yyyy", "10/18/26", "null"), + ("yyyy/mm/dd", "26/10/18", "null"), + ("yymmdd", "261018", "2026-10-18 00:00:00"), + ("yymmdd", "491018", "2049-10-18 00:00:00"), + ("yymmdd", "501018", "1950-10-18 00:00:00"), + ("mmm d, yyyy", "Oct 18, 2026", "2026-10-18 00:00:00"), + ("mmm d, yyyy", "October 18, 2026", "2026-10-18 00:00:00"), + ("mmm d, yyyy", "oct 18, 2026", "2026-10-18 00:00:00"), + ("mmmm d, yyyy", "Oct 18, 2026", "2026-10-18 00:00:00"), + ("mmmm d, yyyy", "October 18, 2026", "2026-10-18 00:00:00"), + ("dd-mmm-yyyy", "18-Oct-2026", "2026-10-18 00:00:00"), + ("dddd, mmmm d, yyyy", "Sunday, October 18, 2026", "2026-10-18 00:00:00"), + ]); + } + + /// A time is read along with a date, but a format with only time fields reads nothing. + #[test] + fn scand_reads_a_time_only_with_a_date() { + assert_scand(&[ + ("mm/dd/yyyy HH:MM:ss", "10/18/2026 21:07:05", "2026-10-18 21:07:05"), + ("HH:MM", "09:07", "null"), + ("HH:MM", "9:07", "null"), + ("h:MM tt", "9:07 pm", "null"), + ("h:MM tt", "9:07 am", "null"), + ("h:MM tt", "12:07 am", "null"), + ("h:MM tt", "9:07pm", "null"), + ]); + } + + /// An empty text reads as today at noon, and a text that is not a string (a number, or none + /// at all) reads nothing. + #[test] + fn scand_reads_only_text_and_an_empty_text_is_today_at_noon() { + let o = go( + "var d = util.scand('yyyymmdd', ''), n = new Date(); \ + event.value = [d.toDateString() == n.toDateString(), d.getHours(), d.getMinutes(), d.getSeconds(), \ + util.scand('yyyymmdd', 20261018), util.scand(), util.scand('yyyymmdd')].join('|');", + &Event::field("Calculate", "total", ""), + ); + assert_eq!(o.error, None); + assert_eq!(o.value, "true|12|0|0|||"); + } + + // Not observed: the choices made where the other viewer wasn't asked. + + /// A year left out is this year, `h:MM tt` reads a 12-hour time and refuses hours it cannot + /// have, and a letter the format writes must be typed there. + #[test] + fn scand_takes_this_year_when_none_is_given_and_reads_12_hour_times_and_written_letters() { + assert_scand(&[ + ("mm/dd/yyyy h:MM tt", "10/18/2026 9:07 pm", "2026-10-18 21:07:00"), + ("mm/dd/yyyy h:MM tt", "10/18/2026 12:07 AM", "2026-10-18 00:07:00"), + ("mm/dd/yyyy h:MM tt", "10/18/2026 13:07 pm", "null"), + ("mm/dd/yyyy HH:MM", "10/18/2026 24:00", "null"), + ("yyyy-mm-ddTHH:MM", "2026-10-18T21:07", "2026-10-18 21:07:00"), + ("yyyy-mm-ddTHH:MM", "2026-10-18X21:07", "null"), + ]); + let o = go( + "var d = util.scand('mm/dd', '10/18'); event.value = [d.getFullYear() == new Date().getFullYear(), d.getMonth(), d.getDate()].join('|');", + &Event::field("Calculate", "total", ""), + ); + assert_eq!(o.error, None); + assert_eq!(o.value, "true|9|18"); + } + + /// A format of 50,000 literal letters and one of 100,000 fields, with texts to match, are read + /// in linear time. The limit is generous so a loaded runner passes (it also covers starting + /// the engine and building the strings); a quadratic scan of inputs this size takes minutes. + #[test] + fn scand_reads_long_formats_and_texts_in_linear_time() { + // Scripts can pass anything; native code isn't bounded by the loop limit. + let started = std::time::Instant::now(); + let o = go( + "var x = new Array(50001).join('x'), md = new Array(50001).join('md'), ones = new Array(100000).join('1 ') + '1'; \ + event.value = [util.scand(x + 'yyyy', x + '2026') !== null, util.scand('yyyy' + md, '2026 ' + ones) !== null].join('|');", + &Event::field("Calculate", "total", ""), + ); + assert_eq!(o.error, None); + assert_eq!(o.value, "true|true"); + assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed()); + } + + /// The check an XFA form runs on leaving a date field tries six formats with `util.scand` and + /// writes the first that reads the text back as `yyyymmdd`, or reports an invalid date. + #[test] + fn xfa_scripts_check_typed_dates_with_util_scand() { + // The check a form runs when a date field is left: the first of six formats that reads + // the typed text, written back as yyyymmdd; or an invalid date. Results as another viewer gives them. + let check = |typed: &str| { + let script = format!( + "var six = ['yyyymmdd', 'yymmdd', 'yyyymdd', 'yyyymmd', 'yyyymd', 'yymd'], out = 'invalid'; \ + for (var i = 0; i < six.length; i++) {{ var d = util.scand(six[i], '{typed}'); \ + if (d !== null) {{ out = util.printd('yyyymmdd', d); break; }} }} out" + ); + let event = XfaEvent { activity: "exit".into(), target: "form1[0].page1[0].total[0]".into(), ..Default::default() }; + run_xfa(&script, &event, &XfaDoc::default(), &super::xfa_model::form(), Limits::default()) + }; + for (typed, want) in [ + ("20261018", "20261018"), + ("261018", "20261018"), + ("2026118", "20260118"), + ("202618", "20260108"), + ("2618", "20260108"), + ("2026-10-18", "20261018"), + ("20261318", "invalid"), + ("20260230", "invalid"), + ] { + let o = check(typed); + assert_eq!(o.error, None, "{typed}: {o:?}"); + assert_eq!(o.result.as_deref(), Some(want), "{typed}"); + } + } +} + +mod menu_items { + use super::*; + use crate::xfa::{XfaDoc, XfaEffect, XfaEvent, run_xfa}; + + /// Every side panel has a fixed id in automation output. + #[test] + fn each_panel_has_a_fixed_id_for_automation() { + let ids = [Panel::Bookmarks, Panel::Pages, Panel::Attachments, Panel::Signatures, Panel::Layers].map(Panel::id); + assert_eq!(ids, ["bookmarks", "pages", "attachments", "signatures", "layers"]); + } + + /// Each side panel item, Print and SaveAs make their request, in order; an unknown item + /// makes none and is not an error. + #[test] + fn exec_menu_item_shows_side_panels_prints_and_saves() { + let o = go( + "app.execMenuItem('ShowHideFileAttachment'); app.execMenuItem('ShowHideBookmarks'); app.execMenuItem('ShowHideThumbnails'); \ + app.execMenuItem('ShowHideSignatures'); app.execMenuItem('ShowHideOptCont'); app.execMenuItem('Print'); \ + app.execMenuItem('SaveAs'); app.execMenuItem('NoSuchItem');", + &Event::field("Mouse Up", "total", ""), + ); + assert_eq!(o.error, None); + assert_eq!( + o.requests, + [ + Request::ShowPanel(Panel::Attachments), + Request::ShowPanel(Panel::Bookmarks), + Request::ShowPanel(Panel::Pages), + Request::ShowPanel(Panel::Signatures), + Request::ShowPanel(Panel::Layers), + Request::Print, + Request::SaveAs, + ] + ); + } + + /// `app.execMenuItem` works in XFA scripts too: a click shows the attachments panel. + #[test] + fn xfa_scripts_show_the_attachments_panel() { + let event = XfaEvent { activity: "click".into(), target: "form1[0].page1[0].go[0]".into(), ..Default::default() }; + let o = run_xfa("app.execMenuItem('ShowHideFileAttachment')", &event, &XfaDoc::default(), &super::xfa_model::form(), Limits::default()); + assert_eq!(o.error, None, "{o:?}"); + assert!(o.effects.contains(&XfaEffect::ShowPanel(Panel::Attachments)), "{:?}", o.effects); + } +} + /// The check XFA forms run on opening: without both globals, or with a version below 2.8, it /// alerts that the viewer can't show the form. const XFA_CHECK: &str = "if (typeof(xfa_installed) == 'undefined' || typeof(xfa_version) == 'undefined' || xfa_version < 2.8) app.alert('old viewer'); \ diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index 45abeeb026..42b1688ed5 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -15,7 +15,7 @@ use boa_engine::object::builtins::{JsArray, JsProxy}; use boa_engine::property::Attribute; use boa_engine::{Context, JsObject, JsResult, JsString, JsValue, NativeFunction, Source, js_string}; -use crate::{Limits, arg, as_number, error, function, platform, printd, printf, printx, refuse, s, text}; +use crate::{Limits, arg, as_number, error, function, platform, printd, printf, printx, refuse, s, scand, text}; /// What kind of template object a node is. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -115,6 +115,8 @@ pub enum XfaEffect { MessageBox(String), /// `xfa.host.resetData()`: the named fields, or every field when empty. ResetData(Vec), + /// `app.execMenuItem("ShowHide…")`: show or hide a side panel. + ShowPanel(crate::Panel), Print, SaveAs, LaunchUrl(String), @@ -219,7 +221,7 @@ fn payload(e: &XfaEffect) -> usize { XfaEffect::AddInstance { som } | XfaEffect::RemoveInstance { som } => som.len(), XfaEffect::MessageBox(s) | XfaEffect::LaunchUrl(s) | XfaEffect::SetFocus(s) => s.len(), XfaEffect::ResetData(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::())), - XfaEffect::Print | XfaEffect::SaveAs | XfaEffect::Beep | XfaEffect::Recalculate | XfaEffect::Relayout => 0, + XfaEffect::Print | XfaEffect::SaveAs | XfaEffect::Beep | XfaEffect::Recalculate | XfaEffect::Relayout | XfaEffect::ShowPanel(_) => 0, } } @@ -1131,39 +1133,50 @@ fn resolver(ctx: &mut Context, from: usize, many: bool) -> JsResult { // ── xfa.host, xfa.layout, xfa.event, app, console ─────────────────────────────────────────── +/// Records effect `e` on the host, as [`XHost::push`] does. fn effect(ctx: &mut Context, e: XfaEffect) -> JsResult<()> { host(ctx)?.borrow_mut().push(e); Ok(()) } +/// `xfa.host.messageBox(message)`: records the message, cut to [`MAX_LINE`] characters, and +/// returns 1. fn message_box(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let m = clip(text(&arg(args, 0), ctx)?); effect(ctx, XfaEffect::MessageBox(m))?; Ok(JsValue::from(1.0)) } +/// `xfa.host.resetData(names)`: asks to reset the comma-separated fields, or every field when +/// the list is empty. fn reset_data(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let list = text(&arg(args, 0), ctx)?; effect(ctx, XfaEffect::ResetData(reset_names(&list)))?; Ok(JsValue::undefined()) } +/// `xfa.host.print(…)`: asks to print; its arguments are ignored. fn print(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { effect(ctx, XfaEffect::Print)?; Ok(JsValue::undefined()) } +/// `xfa.host.beep()`: asks to beep. fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult { effect(ctx, XfaEffect::Beep)?; Ok(JsValue::undefined()) } +/// `xfa.host.gotoURL(url)`: asks to open the URL. fn goto_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let u = text(&arg(args, 0), ctx)?; effect(ctx, XfaEffect::LaunchUrl(u))?; Ok(JsValue::undefined()) } +/// `xfa.host.setFocus(target)`: asks to focus the node, given as a node object or as a SOM +/// expression resolved from the current node; an expression that names no node is passed on +/// as written. fn set_focus(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let target = arg(args, 0); let som = match target.as_object() { @@ -1182,11 +1195,14 @@ fn set_focus(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult JsResult { let item = text(&arg(args, 0), ctx)?; - match item.as_str() { - "SaveAs" | "Save" => effect(ctx, XfaEffect::SaveAs)?, - "Print" => effect(ctx, XfaEffect::Print)?, + match crate::menu_item(&item) { + Some(crate::Request::SaveAs) => effect(ctx, XfaEffect::SaveAs)?, + Some(crate::Request::Print) => effect(ctx, XfaEffect::Print)?, + Some(crate::Request::ShowPanel(p)) => effect(ctx, XfaEffect::ShowPanel(p))?, _ => {} } Ok(JsValue::undefined()) @@ -1233,6 +1249,9 @@ fn xfa_resolve(ctx: &mut Context, many: bool) -> JsResult { resolver(ctx, current, many) } +/// Defines the globals for one script on node `current`: `xfa` (`form`, `host`, `layout`, `event`, +/// `resolveNode` and more), `event` as the same object as `xfa.event`, `app`, `console`, `util`, +/// and the nodes in scope by name, with `_name` for each repeatable one's instance manager. fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current: usize) -> JsResult<()> { let rw = Attribute::CONFIGURABLE; let form = node_object(ctx, 0)?; @@ -1343,6 +1362,7 @@ fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current: usize) -> .function(NativeFunction::from_fn_ptr(printf), js_string!("printf"), 1) .function(NativeFunction::from_fn_ptr(printd), js_string!("printd"), 2) .function(NativeFunction::from_fn_ptr(printx), js_string!("printx"), 2) + .function(NativeFunction::from_fn_ptr(scand), js_string!("scand"), 2) .build(); ctx.register_global_property(js_string!("util"), util, rw)?; // Unqualified names: the current node's children, its siblings, then each ancestor's diff --git a/crates/optimize/Cargo.toml b/crates/optimize/Cargo.toml index 184ee9697b..456ab4c991 100644 --- a/crates/optimize/Cargo.toml +++ b/crates/optimize/Cargo.toml @@ -12,12 +12,15 @@ workspace = true [dependencies] pdfcraft-cos = { workspace = true } +pdfcraft-filters = { workspace = true } pdfcraft-content = { workspace = true } pdfcraft-model = { workspace = true } pdfcraft-annot = { workspace = true } thiserror.workspace = true # JPEG decoding (zune-jpeg) and encoding, and bicubic resampling; already in the tree. image = { version = "0.25", default-features = false, features = ["jpeg"] } +# The same JPEG decoder, used directly to read samples untransformed (/ColorTransform 0). +zune-jpeg = "0.5" [dev-dependencies] pdfcraft-render = { workspace = true } diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 3d517eef3a..cbe37f6b7b 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -21,19 +21,37 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going side-by-side tiles, rotated strips, masks. Content arrays use the shared space boundary policy, so trailing comments, split operands and graphics state keep their meaning after merging. - **Images:** the effective resolution of each image is measured where pages draw it (the CTM - through form XObjects, up to 12 deep; the smallest over all uses). If a use can't be + through form XObjects, up to 12 deep; the smallest over all uses). + Page-local `/UserUnit` scaling is included, so large-format pages are measured in physical inches. + If a use can't be measured (forms nested deeper, or drawn too many times), no image is resampled. Colour and grayscale images above a threshold are resampled with a bicubic (Catmull-Rom) filter to a target resolution and stored as JPEG (Acrobat's quality steps) or ZIP; soft masks are resampled with their image. An image is replaced only if the result is smaller. Left alone: CMYK and other colour spaces, - image masks, decode arrays, more than 8 bits, JPEG 2000, JBIG2 and CCITT. + image masks, decode arrays, more than 8 bits, JPEG 2000, JBIG2 and CCITT. A JPEG is read with + its colour transform (an Adobe marker's, else `/ColorTransform`, ISO 32000-2 §7.4.8), so one + stored untransformed keeps its colours; one whose marker and `/ColorTransform` disagree is left + alone, since readers differ on which wins. - **Discard objects:** thumbnails, alternate images, document tags, print settings. - **Clean up:** Flate for streams with no filter; for streams with an outer ASCII85 or ASCIIHex layer, that layer is removed when the result is smaller (the inner filters keep their encoded - bytes). Streams with `/DecodeParms`, images and object streams are left alone. + bytes; streams with `/DecodeParms`, images and object streams keep their ASCII layer. LZW-compressed + streams, content and images alike, are decoded and stored with Flate instead (Acrobat's "In + streams that use LZW encoding, use Flate instead"): a predictor carries over to Flate without + `EarlyChange`, filters applied before the LZW one go with it and those after it keep their + bytes. A stream whose LZW data doesn't decode cleanly is left as it is. Not yet: inline images. The engine runs Remove Hidden Information (`pdfcraft-redact`) for the user-data categories, then this, then merges identical objects and writes a full, compressed save (`pdfcraft_engine::optimizer::OptimizeJob`, which the UI runs on a worker thread with a -progress bar). JPEG decoding and -encoding and resampling come from the `image` crate. +progress bar). JPEG decoding comes from `zune-jpeg` (the decoder behind `image` and the +renderer), JPEG encoding and resampling from the `image` crate. + +## Target PDF version: partial compatibility + +The target selects the header version, clears the catalog `/Version`, controls object +streams (disabled for PDF 1.4), and checks encryption requirements. It does **not** +detect or convert content newer than the target: optional content, JPEG 2000 images, +16-bit images, OpenType fonts and PDF 2.0 features can remain. A PDF 1.4 header +therefore does not guarantee compatibility with PDF 1.4 readers or validators. +`optimize.compatibility-level` remains partial until content compatibility is checked. diff --git a/crates/optimize/src/fragments.rs b/crates/optimize/src/fragments.rs index 4fa6e21646..c069de2f95 100644 --- a/crates/optimize/src/fragments.rs +++ b/crates/optimize/src/fragments.rs @@ -176,7 +176,7 @@ fn merge(doc: &Document, run: &[Fragment], n: usize) -> Option<(Vec, u32, u3 } let obj = doc.get(f.image); let Object::Stream(s) = &*obj else { return None }; - let px = pixels(s, n)?; + let px = pixels(doc, s, n)?; let px = if (f.width, f.height) == (width, r1 - r0) { px } else { resample(&px, n, f.width, f.height, width, r1 - r0)? }; out.get_mut(r0 as usize * row..r1 as usize * row)?.copy_from_slice(px.get(..(r1 - r0) as usize * row)?); } diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 896542285d..b9761b85fc 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -158,15 +158,18 @@ pub fn effective_resolutions(doc: &Document, pages: &[ObjRef]) -> HashMap Option> { +/// 8-bit samples of an image (`n` components), decoded. `None` for a JPEG whose colour transform +/// readers may disagree about ([`ycc_encoded`]). +pub(crate) fn pixels(doc: &Document, s: &Stream, n: usize) -> Option> { let (w, h) = (u32::try_from(s.dict.int(b"Width")?).ok()?, u32::try_from(s.dict.int(b"Height")?).ok()?); if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_PIXELS { return None; } let bytes = usize::try_from(w).ok()?.checked_mul(usize::try_from(h).ok()?)?.checked_mul(n)?; if is_dct(s)? { - use image::ImageDecoder as _; - // The JPEG's own size is what gets decoded: one other than the dictionary's is refused from - // its header, before any pixels are. - let decoder = image::codecs::jpeg::JpegDecoder::new(std::io::Cursor::new(&s.raw[..])).ok()?; - if decoder.dimensions() != (w, h) { - return None; - } - let img = image::DynamicImage::from_decoder(decoder).ok()?; - count_decoded(u64::from(img.width()) * u64::from(img.height())); - // Adobe CMYK/YCCK JPEGs aren't handled (the colour space check excludes them). - let v = if n == 1 { img.into_luma8().into_raw() } else { img.into_rgb8().into_raw() }; + // The colour transform matters to three components only (§7.4.8). CMYK and YCCK JPEGs + // never get here: the colour space check excludes them. The JPEG's own size must be the + // dictionary's: `jpeg_samples` refuses another from its header, before any pixels. + let ycc = n == 3 && ycc_encoded(doc, s)?; + let v = jpeg_samples(&s.raw, usize::try_from(w).ok()?, usize::try_from(h).ok()?, n, ycc)?; + count_decoded(u64::from(w) * u64::from(h)); (v.len() == bytes).then_some(v) } else { let v = s.decoded().ok()?; @@ -281,6 +280,130 @@ pub(crate) fn pixels(s: &Stream, n: usize) -> Option> { } } +/// Whether the samples of a three-component JPEG are YCbCr, to be converted to RGB (ISO 32000-2 +/// §7.4.8). An Adobe APP14 marker's transform flag says so, overriding the filter's +/// /ColorTransform; without a marker /ColorTransform does, 1 (YCbCr) by default. +/// +/// `None`, so the image is left as it is, when readers may disagree: the marker and +/// /ColorTransform contradict each other (PdfCraft's renderer lets /ColorTransform 0 win where +/// the standard lets the marker win), either holds a value other than 0 or 1, or the parameters +/// or the JPEG's segments can't be read. +fn ycc_encoded(doc: &Document, s: &Stream) -> Option { + let entry = color_transform(doc, s)?; + match adobe_transform(&s.raw)? { + Some(t @ (0 | 1)) => entry.is_none_or(|c| c == i64::from(t)).then_some(t == 1), + Some(_) => None, + None => match entry { + None | Some(1) => Some(true), + Some(0) => Some(false), + Some(_) => None, + }, + } +} + +/// The /ColorTransform of a JPEG stream's filter parameters: `Some(None)` without one. `None` +/// if it isn't an integer, or the parameters aren't a dictionary paired with the filter as +/// §7.4 pairs them (a dictionary for a filter name, a one-element array for a one-filter array). +fn color_transform(doc: &Document, s: &Stream) -> Option> { + let Some(parms) = s.dict.get(b"DecodeParms") else { return Some(None) }; + let parms = doc.resolve(parms); + let parms = match (s.dict.get(b"Filter"), &*parms) { + (Some(Object::Array(_)), Object::Array(a)) if a.len() == 1 => doc.resolve(a.first()?), + (Some(Object::Name(_)), _) | (_, Object::Null) => parms.clone(), + _ => return None, + }; + match &*parms { + Object::Null => Some(None), + Object::Dict(d) => match d.get(b"ColorTransform").map(|c| doc.resolve(c)).as_deref() { + None => Some(None), + Some(Object::Int(c)) => Some(Some(*c)), + Some(_) => None, + }, + _ => None, + } +} + +/// Markers and fill bytes [`adobe_transform`] reads before the first scan, at most. Encoders +/// write a few dozen segments. +const MAX_HEADER_STEPS: usize = 4096; + +/// The transform flag of a JPEG's Adobe APP14 segment (0: samples stored as they are, +/// 1: YCbCr, 2: YCCK), the last one if there are several, as decoders read them; `Some(None)` +/// without one. `None` if the segments before the first scan can't be read, which includes a +/// stray byte between segments and a header of more than [`MAX_HEADER_STEPS`] steps. +/// +/// Each step reads one marker or fill byte and jumps over its segment, so the work is bounded +/// by the step count, not by the size of the stream. That matters: Optimize asks again for every +/// group of fragments it tries, and the same image can be drawn many times. +fn adobe_transform(data: &[u8]) -> Option> { + if data.get(..2)? != [0xFF, 0xD8] { + return None; + } + let mut flag = None; + let mut i = 2usize; + // Reading past the end returns. + for _ in 0..MAX_HEADER_STEPS { + // Decoders skip stray bytes between segments, but a stream can hold megabytes of them + // and each would be a step: the header counts as unreadable and the image is left as + // it is. + if *data.get(i)? != 0xFF { + return None; + } + let marker = *data.get(i.checked_add(1)?)?; + match marker { + // A fill byte before the marker. + 0xFF => i = i.checked_add(1)?, + // The first scan (or the end): no header segments after it. + 0xDA | 0xD9 => return Some(flag), + // Markers without a segment: TEM, RSTn, SOI. + 0x01 | 0xD0..=0xD8 => i = i.checked_add(2)?, + _ => { + let len = usize::from(u16::from_be_bytes([*data.get(i.checked_add(2)?)?, *data.get(i.checked_add(3)?)?])); + // The length counts its own two bytes: anything shorter is malformed. + let end = i.checked_add(2)?.checked_add(len)?; + let segment = data.get(i.checked_add(4)?..end)?; + if marker == 0xEE + && let Some(rest) = segment.strip_prefix(b"Adobe") + { + // Version, flags0 and flags1 (two bytes each), then the transform flag. + flag = Some(*rest.get(6)?); + } + i = end; + } + } + } + None +} + +/// 8-bit samples of a JPEG `w` × `h` with `n` components (grey or RGB): YCbCr converted to RGB +/// if `ycc`, otherwise the samples as stored. `None` if the JPEG doesn't match (size or number +/// of components), is too large, or its decoder reads the samples otherwise (for example +/// components named R, G and B, which decoders take as stored, while `ycc` says YCbCr). +fn jpeg_samples(data: &[u8], w: usize, h: usize, n: usize, ycc: bool) -> Option> { + use zune_jpeg::zune_core::{bytestream::ZCursor, colorspace::ColorSpace, options::DecoderOptions}; + if (w.checked_mul(h)? as u64) > MAX_PIXELS { + return None; + } + let options = DecoderOptions::default().set_strict_mode(false).set_max_width(usize::from(u16::MAX)).set_max_height(usize::from(u16::MAX)); + let mut dec = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(data), options); + dec.decode_headers().ok()?; + if dec.dimensions()? != (w, h) || usize::from(dec.info()?.components) != n { + return None; + } + let out = match (n, ycc, dec.input_colorspace()?) { + (1, _, ColorSpace::Luma) => ColorSpace::Luma, + (3, true, ColorSpace::YCbCr) => ColorSpace::RGB, + // As stored: no conversion either way. The decoder reports an Adobe marker's 0 as CMYK + // until it decodes, and then reads three such components as RGB. + (3, false, ColorSpace::YCbCr) => ColorSpace::YCbCr, + (3, false, ColorSpace::RGB | ColorSpace::CMYK) => ColorSpace::RGB, + _ => return None, + }; + dec.set_options(options.jpeg_set_out_colorspace(out)); + let px = dec.decode().ok()?; + (px.len() == w.checked_mul(h)?.checked_mul(n)?).then_some(px) +} + pub(crate) fn resample(px: &[u8], n: usize, w: u32, h: u32, nw: u32, nh: u32) -> Option> { use image::imageops::{FilterType, resize}; Some(match n { @@ -297,11 +420,17 @@ fn jpeg(px: &[u8], n: usize, w: u32, h: u32, quality: u8) -> Option> { Some(out) } -/// Whether [`pixels`] reads `s` as it is drawn: a codec it decodes and no decode array that -/// changes anything. `None` when the colour space isn't grey or RGB. +/// Whether [`pixels`] reads `s` as it is drawn: a codec it decodes, no decode array that +/// changes anything, and for an RGB JPEG a colour transform readers agree on. `None` when the +/// colour space isn't grey or RGB. pub(crate) fn decodable(doc: &Document, s: &Stream) -> Option { let n = components(doc, s.dict.get(b"ColorSpace")?)?; - Some(is_dct(s).is_some() && s.dict.get(b"Decode").is_none_or(|d| is_identity_decode(doc, d, n))) + let dct = is_dct(s); + Some( + dct.is_some() + && s.dict.get(b"Decode").is_none_or(|d| is_identity_decode(doc, d, n)) + && (n != 3 || dct == Some(false) || ycc_encoded(doc, s).is_some()), + ) } /// Whether `decode` is the default decode array for `n` components ([0 1] each), which changes @@ -313,7 +442,9 @@ fn is_identity_decode(doc: &Document, decode: &Object, n: usize) -> bool { } /// A new image stream from 8-bit samples: the original dictionary with the new size and filter. -/// A decode array is dropped: only images without one, or with the default one, get here. +/// A decode array is dropped: only images without one, or with the default one, get here. So are +/// the filter parameters: the samples are decoded, and a JPEG written here is plain YCbCr with no +/// Adobe marker, which is what a missing /ColorTransform means. fn image_stream(old: &Dict, px: &[u8], n: usize, w: u32, h: u32, compression: Compression, was_dct: bool) -> Option { let mut d = old.clone(); for k in [&b"Filter"[..], b"DecodeParms", b"Decode", b"Length", b"DL"] { @@ -374,7 +505,7 @@ fn process(doc: &Document, s: &Stream, ppi: f64, settings: &ImageSettings, n: us } else { (w, h) }; - let px = pixels(s, n)?; + let px = pixels(doc, s, n)?; let px = if (nw, nh) != (w, h) { resample(&px, n, w, h, nw, nh)? } else { px }; let new = image_stream(d, &px, n, nw, nh, settings.compression, was_dct)?; // A soft mask must keep the image's dimensions: resample it with the image (lossless). @@ -387,7 +518,7 @@ fn process(doc: &Document, s: &Stream, ppi: f64, settings: &ImageSettings, n: us return None; } let (mw, mh) = (u32::try_from(m.dict.int(b"Width")?).ok()?, u32::try_from(m.dict.int(b"Height")?).ok()?); - let mpx = pixels(m, 1)?; + let mpx = pixels(doc, m, 1)?; let mpx = resample(&mpx, 1, mw, mh, nw, nh)?; smask = Some((mr, image_stream(&m.dict, &mpx, 1, nw, nh, Compression::Flate, false)?)); } @@ -460,13 +591,13 @@ mod dimension_tests { dict.set(b"Width".to_vec(), Object::Int(width)); dict.set(b"Height".to_vec(), Object::Int(height)); let stream = Stream::from_raw(dict, vec![0; 4]); - assert!(pixels(&stream, channels).is_none(), "{width} x {height} x {channels}"); + assert!(pixels(&Document::new_empty(), &stream, channels).is_none(), "{width} x {height} x {channels}"); } let mut dict = Dict::new(); dict.set(b"Width".to_vec(), Object::Int(2)); dict.set(b"Height".to_vec(), Object::Int(2)); let stream = Stream::from_raw(dict, vec![1, 2, 3, 4, 5]); - assert_eq!(pixels(&stream, 1), Some(vec![1, 2, 3, 4])); + assert_eq!(pixels(&Document::new_empty(), &stream, 1), Some(vec![1, 2, 3, 4])); } /// A JPEG larger than its dictionary says is refused from its header, before its pixels are decoded. @@ -481,9 +612,9 @@ mod dimension_tests { Stream::from_raw(dict, data.clone()) }; DECODED_PIXELS.with(|c| c.set(0)); - assert!(pixels(&stream(10, 10), 1).is_none()); + assert!(pixels(&Document::new_empty(), &stream(10, 10), 1).is_none()); assert_eq!(DECODED_PIXELS.with(|c| c.get()), 0, "pixels decoded"); - assert_eq!(pixels(&stream(1000, 1000), 1).map(|p| p.len()), Some(1000 * 1000)); + assert_eq!(pixels(&Document::new_empty(), &stream(1000, 1000), 1).map(|p| p.len()), Some(1000 * 1000)); } } diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index c8b9b67690..9f9fcdf2be 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -9,12 +9,13 @@ //! threshold are resampled (bicubic) to a target resolution and recompressed as JPEG or Flate. //! A new image replaces the old one only if it is smaller. Images PdfCraft can't decode //! faithfully (CMYK and other colour spaces, masks, decode arrays other than the default, -//! JPEG 2000, JBIG2, CCITT, other than 8 bits) are left alone. +//! JPEG 2000, JBIG2, CCITT, other than 8 bits, JPEGs whose Adobe marker and `/ColorTransform` +//! disagree) are left alone. //! - **Discard objects:** page thumbnails, alternate images, document tags (structure tree), //! print settings. //! - **Clean up:** drop images and forms that pages list but never draw (such as a deleted //! image); Flate-compress streams that have no filter; remove a redundant outer ASCII85 or -//! ASCIIHex layer from streams that have one. +//! ASCIIHex layer from streams that have one; store LZW-compressed streams with Flate instead. //! //! Metadata, attachments, comments, scripts, private data, hidden layers, bookmarks and form //! fields are discarded by `pdfcraft-redact`'s Remove Hidden Information, which the engine @@ -30,7 +31,8 @@ mod unused; pub use audit::{SpaceCategory, SpaceUse, audit_space}; -use pdfcraft_cos::{Document, ObjRef, Object, Stream, serialize}; +use pdfcraft_cos::{Dict, Document, MAX_PAGE_DECODED, ObjRef, Object, Stream, serialize}; +use pdfcraft_filters::{Filter, Params}; pub use images::effective_resolutions; @@ -38,12 +40,62 @@ pub use images::effective_resolutions; pub enum OptimizeError { #[error("the document has no page tree")] NoPages, + #[error("the document's encryption requires PDF {required} or later (chosen: PDF {chosen})")] + IncompatibleEncryption { required: &'static str, chosen: &'static str }, #[error(transparent)] Cos(#[from] pdfcraft_cos::CosError), #[error("the optimization was cancelled")] Cancelled, } +/// The target header version and object-stream policy for an optimized copy. +/// +/// Partial compatibility only: encryption requirements are checked, but content newer than +/// the target is neither detected nor converted. A lower header does not guarantee that +/// older readers can read the document. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Compatibility { + /// Keep the document's version (upgraded to 1.5 when object streams are written). + #[default] + Retain, + Pdf1_4, + Pdf1_5, + Pdf1_6, + Pdf1_7, + Pdf2_0, +} + +impl Compatibility { + pub const ALL: [Compatibility; 6] = + [Compatibility::Retain, Compatibility::Pdf1_4, Compatibility::Pdf1_5, Compatibility::Pdf1_6, Compatibility::Pdf1_7, Compatibility::Pdf2_0]; + + /// The name tools use (`"retain"`, `"1.4"`, `"1.5"`, `"1.6"`, `"1.7"`, `"2.0"`). + pub fn name(self) -> &'static str { + match self { + Compatibility::Retain => "retain", + Compatibility::Pdf1_4 => "1.4", + Compatibility::Pdf1_5 => "1.5", + Compatibility::Pdf1_6 => "1.6", + Compatibility::Pdf1_7 => "1.7", + Compatibility::Pdf2_0 => "2.0", + } + } + + pub fn from_name(name: &str) -> Option { + let trimmed = name.trim(); + let stripped = if trimmed.get(..4).is_some_and(|p| p.eq_ignore_ascii_case("pdf ")) { &trimmed[4..] } else { trimmed }; + Compatibility::ALL.into_iter().find(|c| c.name().eq_ignore_ascii_case(stripped)) + } + + /// The three-character `%PDF-` header version, when a target version is chosen. + pub fn version(self) -> Option<&'static str> { + match self { + Compatibility::Retain => None, + _ => Some(self.name()), + } + } +} + /// Where an optimization is, reported to [`optimize_with_progress`]'s callback. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Stage { @@ -89,6 +141,8 @@ impl ImageSettings { #[derive(Clone, Debug, PartialEq)] pub struct Settings { + /// Target PDF version (`Retain` by default). + pub compatibility: Compatibility, /// Put page pictures drawn as many stacked fragments back together first ([`fragments`]). pub merge_fragments: bool, pub color: ImageSettings, @@ -100,16 +154,30 @@ pub struct Settings { /// Flate-compress streams stored without a filter, and strip a redundant outer ASCII85 or /// ASCIIHex layer from streams that have one. pub flate_unencoded: bool, + /// Clean Up: pack non-stream objects into compressed object streams with a cross-reference + /// stream (PDF 1.5+, §7.5.7–7.5.8). Always off when `compatibility` is [`Compatibility::Pdf1_4`]. + pub object_streams: bool, + /// Clean Up: decode LZW-compressed streams and store them with Flate instead. + pub lzw_to_flate: bool, /// Clean Up: remove links (and bookmark destinations) that point nowhere. pub remove_invalid_links: bool, /// Clean Up: remove named destinations nothing refers to. pub remove_unreferenced_dests: bool, } +impl Settings { + /// Whether `write_full` should pack non-stream objects into compressed object streams: + /// enabled when `object_streams` is set and the target version is not PDF 1.4 (§7.5.7). + pub fn effective_object_streams(&self) -> bool { + self.object_streams && self.compatibility != Compatibility::Pdf1_4 + } +} + impl Default for Settings { /// Acrobat's Reduce File Size choices. fn default() -> Self { Self { + compatibility: Compatibility::Retain, merge_fragments: true, color: ImageSettings::REDUCE, gray: ImageSettings::REDUCE, @@ -118,6 +186,8 @@ impl Default for Settings { discard_tags: false, discard_print_settings: false, flate_unencoded: true, + object_streams: true, + lzw_to_flate: true, remove_invalid_links: true, remove_unreferenced_dests: true, } @@ -178,6 +248,8 @@ pub struct Report { pub tags_removed: bool, pub print_settings: usize, pub streams_compressed: usize, + /// Streams whose LZW compression was replaced with Flate (Clean Up). + pub lzw_streams: usize, /// Links removed and bookmarks whose destination was cleared (Clean Up). pub invalid_links: usize, pub invalid_bookmarks: usize, @@ -186,6 +258,20 @@ pub struct Report { pub unused_xobjects: usize, } +/// The minimum PDF version required by `doc`'s active security handler, when it uses features +/// introduced after PDF 1.4 (crypt filters in 1.5, AES-128 in 1.6, AES-256 in 1.7). +fn required_encryption_version(doc: &Document) -> Option<&'static str> { + let h = doc.output_handler()?; + let d = h.dict(); + if d.v >= 5 || d.crypt_filters.iter().any(|(_, m)| *m == pdfcraft_cos::CryptMethod::Aes256) { + Some("1.7") + } else if d.v == 4 { + if d.crypt_filters.iter().any(|(_, m)| *m == pdfcraft_cos::CryptMethod::Aes128) { Some("1.6") } else { Some("1.5") } + } else { + None + } +} + /// Optimize `doc` in place. pub fn optimize(doc: &mut Document, settings: &Settings) -> Result { optimize_with_progress(doc, settings, &mut |_| true) @@ -195,6 +281,13 @@ pub fn optimize(doc: &mut Document, settings: &Settings) -> Result bool) -> Result { + // Lexical ordering is valid for these fixed versions with single-digit components. + if let Some(chosen) = settings.compatibility.version() + && let Some(required) = required_encryption_version(doc) + && chosen < required + { + return Err(OptimizeError::IncompatibleEncryption { required, chosen }); + } let mut report = Report::default(); let pages = pdfcraft_annot::page_refs(doc).map_err(|_| OptimizeError::NoPages)?; if settings.merge_fragments { @@ -230,6 +323,16 @@ pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress: } } let root = doc.root(); + if let Some(chosen) = settings.compatibility.version() { + doc.set_version(chosen); + if let Some(root) = root + && doc.get(root).as_dict().is_some_and(|c| c.contains(b"Version")) + { + doc.update_dict(root, |c| { + c.remove(b"Version"); + })?; + } + } if settings.discard_tags && let Some(root) = root && doc.get(root).as_dict().is_some_and(|c| c.contains(b"StructTreeRoot")) @@ -279,6 +382,19 @@ pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress: if settings.remove_unreferenced_dests { report.unreferenced_dests = links::remove_unreferenced_dests(doc)?; } + // Before the unencoded-stream pass, which would otherwise take the ASCII layer off an + // `[/ASCII85Decode /LZWDecode]` stream first and count it twice. + if settings.lzw_to_flate { + for num in doc.object_numbers() { + let r = ObjRef { num, generation: doc.generation(num) }; + let obj = doc.get(r); + let Object::Stream(s) = &*obj else { continue }; + if let Some(replacement) = lzw_to_flate(s, MAX_LZW_DECODED) { + doc.set(r, Object::Stream(replacement)); + report.lzw_streams += 1; + } + } + } if settings.flate_unencoded { for num in doc.object_numbers() { let r = ObjRef { num, generation: doc.generation(num) }; @@ -305,6 +421,101 @@ pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress: Ok(report) } +/// Largest decoded LZW stream converted, as for a page's content: the data and its check are +/// held together, so a few hundred kilobytes of hostile LZW can't ask for gigabytes. +const MAX_LZW_DECODED: usize = MAX_PAGE_DECODED; + +/// `s` with its LZW compression replaced by Flate (Acrobat's "In streams that use LZW encoding, +/// use Flate instead"). The filters up to the last LZW one are decoded, strictly and with its +/// predictor; the result is Flate-encoded under the same predictor, without `EarlyChange`, and +/// the filters after it keep their encoded bytes. A stream that doesn't decode cleanly, or +/// whose filters or parameters can't be read for certain, or that decodes past `max` bytes, is +/// left as it is. +fn lzw_to_flate(s: &Stream, max: usize) -> Option { + // An external file's data isn't in `raw`; cross-reference and object streams are rewritten + // on save, so converting them would change nothing in the file. + if s.dict.contains(b"F") || matches!(s.dict.name(b"Type"), Some(b"XRef" | b"ObjStm")) { + return None; + } + let names: Vec<&[u8]> = match s.dict.get(b"Filter")? { + Object::Name(n) => vec![n.as_slice()], + Object::Array(a) => a.iter().map(Object::as_name).collect::>()?, + _ => return None, + }; + // `/Crypt` was applied when the object was loaded, and dropping it would change how the + // stream is encrypted. + if names.contains(&&b"Crypt"[..]) { + return None; + } + let lzw = names.iter().rposition(|n| matches!(*n, b"LZWDecode" | b"LZW"))?; + // One entry per filter, each a dictionary or null; anything else (an indirect reference, an + // array of the wrong length) would be read as defaults, so the data might not decode as + // written. + let parms: Vec> = match s.dict.get(b"DecodeParms") { + None | Some(Object::Null) => vec![None; names.len()], + Some(Object::Dict(d)) if names.len() == 1 => vec![Some(d)], + Some(Object::Array(a)) if a.len() == names.len() => a + .iter() + .map(|p| match p { + Object::Dict(d) => Some(Some(d)), + Object::Null => Some(None), + _ => None, + }) + .collect::>()?, + _ => return None, + }; + let chain = s.filters(); + let stages = chain.get(..=lzw)?; + let (_, params) = stages.last()?; + // The decoded stages' parameters must be direct integers: `filters` reads anything else (a + // reference, 12.5, a name) as the default, and the dictionary carried over would then + // describe the new data differently from how it was encoded. + let direct = |p: &Option<&Dict>| p.is_none_or(|d| d.iter().all(|(_, v)| matches!(v, Object::Int(_)))); + if !parms.get(..=lzw)?.iter().all(direct) { + return None; + } + let data = pdfcraft_filters::decode(stages, &s.raw, max).ok()?; + let flate = Params { early_change: 1, ..params.clone() }; + let raw = pdfcraft_filters::encode(&Filter::Flate, &flate, &data).ok()?; + // The predictor's encoder must give back exactly what its decoder read (a short last row, + // unusual parameters), or the stream would change. The limit counts a PNG predictor's tag + // bytes too, at most one per row of at least one byte. + if pdfcraft_filters::decode(&[(Filter::Flate, flate)], &raw, data.len().saturating_mul(2).saturating_add(1)).ok()? != data { + return None; + } + // Without a predictor the other parameters say nothing to Flate. + let first = parms.get(lzw).copied().flatten().filter(|_| params.predictor > 1).map(|d| { + let mut d = d.clone(); + d.remove(b"EarlyChange"); + Object::Dict(d) + }); + let rest_names = names.get(lzw + 1..)?; + let rest_parms = parms.get(lzw + 1..)?; + let mut dict = s.dict.clone(); + dict.remove(b"Length"); + if rest_names.is_empty() { + dict.set(b"Filter".to_vec(), Object::name("FlateDecode")); + match first { + Some(p) => dict.set(b"DecodeParms".to_vec(), p), + None => { + dict.remove(b"DecodeParms"); + } + } + } else { + let filters = std::iter::once(Object::name("FlateDecode")).chain(rest_names.iter().map(|n| Object::Name(n.to_vec()))); + dict.set(b"Filter".to_vec(), Object::Array(filters.collect())); + let parms: Vec = std::iter::once(first.unwrap_or(Object::Null)) + .chain(rest_parms.iter().map(|p| p.map_or(Object::Null, |d| Object::Dict(d.clone())))) + .collect(); + if parms.iter().all(|p| matches!(p, Object::Null)) { + dict.remove(b"DecodeParms"); + } else { + dict.set(b"DecodeParms".to_vec(), Object::Array(parms)); + } + } + Some(Stream::from_raw(dict, raw)) +} + /// `s` without its outer ASCII85 or ASCIIHex layer (`[/ASCII85Decode /FlateDecode]` becomes /// `[/FlateDecode]`), when that is smaller. Only the outer layer is decoded: strictly, and within /// the stored size, which ASCII text never falls below, so the inner encoded bytes stay exact. @@ -335,3 +546,20 @@ fn stored_len(s: &Stream) -> Option { serialize(&Object::Dict(dict), &mut out); out.len().checked_add(s.raw.len()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_lzw_stream_that_decodes_past_the_limit_is_left_as_it_is() { + let data = b"0 0 m 612 792 l S\n".repeat(64); + let raw = pdfcraft_filters::encode(&Filter::Lzw, &Params::default(), &data).unwrap(); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::name("LZWDecode")); + let s = Stream::from_raw(dict, raw); + assert!(lzw_to_flate(&s, data.len() - 1).is_none()); + let converted = lzw_to_flate(&s, data.len()).unwrap(); + assert_eq!(converted.decoded_strict().unwrap(), data); + } +} diff --git a/crates/optimize/src/links.rs b/crates/optimize/src/links.rs index 082d56e7df..cbd64eb928 100644 --- a/crates/optimize/src/links.rs +++ b/crates/optimize/src/links.rs @@ -152,11 +152,12 @@ fn referenced_names(doc: &Document) -> HashSet> { Object::Dict(d) => { // Resolve first: /Dest and a GoTo's /D may be an indirect reference to the name // string, and an unresolved ref has no name, so the destination it uses would - // look unreferenced and be removed. + // look unreferenced and be removed. A GoToR or GoToE /D names a destination in + // another document, so it does not count as a use of this one's. if let Some(n) = d.get(b"Dest").and_then(|x| dest_name(doc.resolve(x).as_ref())) { out.insert(n); } - if matches!(d.name(b"S"), Some(b"GoTo" | b"GoToR" | b"GoToE")) + if d.name(b"S") == Some(b"GoTo") && let Some(n) = d.get(b"D").and_then(|x| dest_name(doc.resolve(x).as_ref())) { out.insert(n); diff --git a/crates/optimize/tests/jpeg_color_transform.rs b/crates/optimize/tests/jpeg_color_transform.rs new file mode 100644 index 0000000000..c4dfabc9d9 --- /dev/null +++ b/crates/optimize/tests/jpeg_color_transform.rs @@ -0,0 +1,418 @@ +//! Optimize keeps a JPEG's colours whatever its colour transform says (#1201). +//! +//! A JPEG with three or four components can store its samples as they are or converted to YCbCr +//! (YCCK). An Adobe APP14 marker in the JPEG says which; without one, the filter's +//! `/ColorTransform` does (ISO 32000-2 §7.4.8, default 1 for three components). Each image here +//! is drawn by PdfCraft's own renderer before and after Optimize, and the two must show the same +//! colours, up to what a JPEG re-encode loses. + +use std::sync::Arc; + +use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; +use pdfcraft_optimize::{OptimizeError, Settings, Stage, optimize, optimize_with_progress}; + +/// A photo-like RGB picture: colourful gradients with a little texture, so the colour transform +/// matters everywhere and a medium-quality JPEG is smaller than a maximum-quality one. +fn photo(w: u32, h: u32) -> Vec { + let mut px = Vec::with_capacity((w * h * 3) as usize); + for y in 0..h { + for x in 0..w { + let t = ((x ^ y) & 7) as u8; + px.push((x * 255 / w) as u8 ^ t); + px.push((y * 255 / h) as u8); + px.push((255 - (x + y) * 127 / (w + h)) as u8 ^ (t << 2)); + } + } + px +} + +fn rgb_jpeg(px: &[u8], w: u32, h: u32) -> Vec { + let mut out = Vec::new(); + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 100).encode(px, w, h, image::ExtendedColorType::Rgb8).unwrap(); + out +} + +/// The JPEG's segments before its first scan: (marker, start of the marker, end of the segment). +fn segments(jpeg: &[u8]) -> Vec<(u8, usize, usize)> { + assert_eq!(&jpeg[..2], &[0xFF, 0xD8]); + let (mut i, mut out) = (2, Vec::new()); + loop { + assert_eq!(jpeg[i], 0xFF, "a marker at {i}"); + let marker = jpeg[i + 1]; + let end = i + 2 + u16::from_be_bytes([jpeg[i + 2], jpeg[i + 3]]) as usize; + out.push((marker, i, end)); + if marker == 0xDA { + return out; + } + i = end; + } +} + +/// `jpeg` with an Adobe APP14 segment whose transform flag is `transform` (0: samples stored as +/// they are, 1: YCbCr, 2: YCCK). +fn with_adobe(jpeg: &[u8], transform: u8) -> Vec { + assert!(segments(jpeg).iter().all(|s| s.0 != 0xEE), "no Adobe segment yet"); + let mut v = jpeg[..2].to_vec(); + v.extend_from_slice(&[0xFF, 0xEE, 0, 14, b'A', b'd', b'o', b'b', b'e', 0, 100, 0, 0, 0, 0, transform]); + v.extend_from_slice(&jpeg[2..]); + v +} + +/// `jpeg` with its three components named R, G and B (in the frame and scan headers), which +/// JPEG decoders read as samples stored untransformed. +fn with_rgb_ids(jpeg: &[u8]) -> Vec { + let mut v = jpeg.to_vec(); + let mut ids = Vec::new(); + for (marker, i, _) in segments(jpeg) { + match marker { + 0xC0 => { + assert_eq!(v[i + 9], 3); + for k in 0..3 { + ids.push(v[i + 10 + 3 * k]); + v[i + 10 + 3 * k] = b"RGB"[k]; + } + } + 0xDA => { + for k in 0..v[i + 4] as usize { + let id = v[i + 5 + 2 * k]; + v[i + 5 + 2 * k] = b"RGB"[ids.iter().position(|&x| x == id).unwrap()]; + } + } + _ => {} + } + } + v +} + +/// A baseline JPEG `w` × `h` (multiples of 8) with four components, every 8 × 8 block one flat +/// colour `block(bx, by)` as stored, and an Adobe APP14 segment if `adobe` is given. +fn four_component_jpeg(w: u16, h: u16, adobe: Option, block: impl Fn(u16, u16) -> [u8; 4]) -> Vec { + fn put(bits: &mut Vec, value: u32, n: u32) { + bits.extend((0..n).rev().map(|i| value >> i & 1 == 1)); + } + let mut v = vec![0xFF, 0xD8]; + if let Some(t) = adobe { + v.extend_from_slice(&[0xFF, 0xEE, 0, 14, b'A', b'd', b'o', b'b', b'e', 0, 100, 0, 0, 0, 0, t]); + } + // Every quantiser 1: a flat block's DC coefficient is 8 × (sample − 128), exactly. + v.extend_from_slice(&[0xFF, 0xDB, 0, 67, 0]); + v.extend_from_slice(&[1; 64]); + v.extend_from_slice(&[0xFF, 0xC0, 0, 20, 8]); + v.extend_from_slice(&h.to_be_bytes()); + v.extend_from_slice(&w.to_be_bytes()); + v.push(4); + for id in 1..=4 { + v.extend_from_slice(&[id, 0x11, 0]); + } + // DC table: categories 0 to 11 with four-bit codes equal to the category. AC table: EOB, "0". + v.extend_from_slice(&[0xFF, 0xC4, 0, 31, 0x00, 0, 0, 0, 12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + v.extend(0..12u8); + v.extend_from_slice(&[0xFF, 0xC4, 0, 20, 0x10, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + v.extend_from_slice(&[0xFF, 0xDA, 0, 14, 4, 1, 0, 2, 0, 3, 0, 4, 0, 0, 63, 0]); + let mut bits = Vec::new(); + let mut pred = [0i32; 4]; + for by in 0..h / 8 { + for bx in 0..w / 8 { + for (c, s) in block(bx, by).into_iter().enumerate() { + let dc = (i32::from(s) - 128) * 8; + let diff = dc - pred[c]; + pred[c] = dc; + let category = 32 - diff.unsigned_abs().leading_zeros(); + put(&mut bits, category, 4); + put(&mut bits, if diff < 0 { (diff + (1 << category) - 1) as u32 } else { diff as u32 }, category); + put(&mut bits, 0, 1); + } + } + } + bits.resize(bits.len().div_ceil(8) * 8, true); + for byte in bits.chunks(8).map(|b| b.iter().fold(0u8, |a, &x| a << 1 | u8::from(x))) { + v.push(byte); + if byte == 0xFF { + v.push(0); + } + } + v.extend_from_slice(&[0xFF, 0xD9]); + v +} + +/// An image XObject holding `jpeg`. +fn jpeg_image(doc: &mut Document, w: u32, h: u32, colour_space: &str, jpeg: Vec, parms: Parms) -> ObjRef { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w as i64)); + d.set(b"Height".to_vec(), Object::Int(h as i64)); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name(colour_space)); + let ct = |c: i64| { + let mut p = Dict::new(); + p.set(b"ColorTransform".to_vec(), Object::Int(c)); + Object::Dict(p) + }; + match parms { + Parms::None => d.set(b"Filter".to_vec(), Object::name("DCTDecode")), + Parms::Dict(c) => { + d.set(b"Filter".to_vec(), Object::name("DCTDecode")); + d.set(b"DecodeParms".to_vec(), ct(c)); + } + Parms::Array(c) => { + d.set(b"Filter".to_vec(), Object::Array(vec![Object::name("DCTDecode")])); + d.set(b"DecodeParms".to_vec(), Object::Array(vec![ct(c)])); + } + } + doc.add(Object::Stream(Stream::from_raw(d, jpeg))) +} + +/// How an image's `/DecodeParms` gives its `/ColorTransform`. +#[derive(Clone, Copy, Debug)] +enum Parms { + None, + /// `/Filter /DCTDecode /DecodeParms << /ColorTransform c >>`. + Dict(i64), + /// `/Filter [/DCTDecode] /DecodeParms [<< /ColorTransform c >>]`. + Array(i64), +} + +/// A document with one page, `w` × `h` pt, whose content is `content` drawing `images`. +fn one_page(doc: &mut Document, w: u32, h: u32, images: &[(String, ObjRef)], content: &str) { + let pages = doc.root().and_then(|r| doc.get(r).as_dict().and_then(|d| d.reference(b"Pages"))).unwrap(); + let mut x = Dict::new(); + for (name, r) in images { + x.set(name.as_bytes().to_vec(), Object::Ref(*r)); + } + let mut res = Dict::new(); + res.set(b"XObject".to_vec(), Object::Dict(x)); + let c = doc.add(Object::Stream(Stream::from_raw(Dict::new(), content.as_bytes().to_vec()))); + let mut p = Dict::new(); + p.set(b"Type".to_vec(), Object::name("Page")); + p.set(b"Parent".to_vec(), Object::Ref(pages)); + p.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), (w as i64).into(), (h as i64).into()])); + p.set(b"Resources".to_vec(), Object::Dict(res)); + p.set(b"Contents".to_vec(), Object::Ref(c)); + let r = doc.add(Object::Dict(p)); + doc.update_dict(pages, |d| { + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(r)])); + d.set(b"Count".to_vec(), Object::Int(1)); + }) + .unwrap(); +} + +/// A document whose one page shows `jpeg` (`w` × `h` px) at `ppi`. +fn showing(w: u32, h: u32, ppi: u32, colour_space: &str, jpeg: Vec, parms: Parms) -> (Document, ObjRef) { + let mut doc = Document::new_empty(); + let img = jpeg_image(&mut doc, w, h, colour_space, jpeg, parms); + let (pw, ph) = (w * 72 / ppi, h * 72 / ppi); + one_page(&mut doc, pw, ph, &[("Im".to_string(), img)], &format!("q {pw} 0 0 {ph} 0 0 cm /Im Do Q")); + (doc, img) +} + +fn stream(doc: &Document, r: ObjRef) -> Stream { + match &*doc.get(r) { + Object::Stream(s) => s.clone(), + _ => panic!("not a stream"), + } +} + +/// The page as PdfCraft's renderer draws it, `scale` device pixels per point: RGB rows. +fn render(doc: &Document, scale: f32) -> (u32, u32, Vec) { + let bytes = write_full(doc, &SaveOptions::default()).unwrap(); + let mut r = pdfcraft_render::PageRenderer::new(Arc::new(bytes), pdfcraft_render::RenderConfig::default()); + let out = r.render(pdfcraft_render::RenderRequest { page: 0, scale, ..Default::default() }); + assert!(out.error.is_none(), "{:?}", out.error); + (out.width, out.height, out.rgba.chunks(4).flat_map(|p| [p[0], p[1], p[2]]).collect()) +} + +/// The mean difference per colour component between two renders of one page, leaving out a +/// two-pixel border (where the picture's edge is smoothed). +fn mean_difference(a: &(u32, u32, Vec), b: &(u32, u32, Vec)) -> f64 { + assert_eq!((a.0, a.1), (b.0, b.1), "renders of one page size"); + let (w, h) = (a.0 as usize, a.1 as usize); + let (mut sum, mut count) = (0u64, 0u64); + for y in 2..h - 2 { + for x in 2..w - 2 { + for c in 0..3 { + let i = (y * w + x) * 3 + c; + sum += u64::from(a.2[i].abs_diff(b.2[i])); + count += 1; + } + } + } + sum as f64 / count as f64 +} + +/// Re-encoding a JPEG at medium quality moves a component by about this much on average; a +/// colour transform applied or dropped wrongly moves it by tens. +const TOLERANCE: f64 = 4.0; + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Outcome { + /// Re-encoded (smaller), as a plain YCbCr JPEG with no `/DecodeParms`. + Reencoded, + /// Left exactly as it was. + Untouched, +} + +#[test] +fn optimize_keeps_the_colours_of_an_rgb_jpeg_whatever_its_colour_transform() { + use Outcome::*; + let (w, h) = (128, 128); + let plain = rgb_jpeg(&photo(w, h), w, h); + // What the cases rely on: one JPEG read with and without its colour transform shows very + // different colours, so a transform lost or added by Optimize can't hide in the tolerance. + let transformed = render(&showing(w, h, 72, "DeviceRGB", plain.clone(), Parms::None).0, 1.0); + let as_stored = render(&showing(w, h, 72, "DeviceRGB", plain.clone(), Parms::Dict(0)).0, 1.0); + let apart = mean_difference(&transformed, &as_stored); + assert!(apart > 10.0 * TOLERANCE, "the fixture tells the two readings apart: {apart:.1}"); + + let mut failures = Vec::new(); + for (what, adobe, rgb_ids, parms, expect) in [ + ("no Adobe marker, no /ColorTransform", None, false, Parms::None, Reencoded), + ("no Adobe marker, /ColorTransform 1", None, false, Parms::Dict(1), Reencoded), + ("no Adobe marker, /ColorTransform 0", None, false, Parms::Dict(0), Reencoded), + ("no Adobe marker, /ColorTransform 0 in a filter array", None, false, Parms::Array(0), Reencoded), + ("Adobe YCbCr marker", Some(1), false, Parms::None, Reencoded), + ("Adobe YCbCr marker, /ColorTransform 1", Some(1), false, Parms::Dict(1), Reencoded), + ("Adobe untransformed marker", Some(0), false, Parms::None, Reencoded), + ("Adobe untransformed marker, /ColorTransform 0", Some(0), false, Parms::Dict(0), Reencoded), + // The marker overrides /ColorTransform in the standard, but readers differ (PdfCraft's + // renderer lets /ColorTransform 0 win): the image is left as it is. + ("Adobe YCbCr marker, /ColorTransform 0", Some(1), false, Parms::Dict(0), Untouched), + ("Adobe untransformed marker, /ColorTransform 1", Some(0), false, Parms::Dict(1), Untouched), + // Components named R, G and B: JPEG decoders read them untransformed, the standard's + // default transforms them. Only an explicit /ColorTransform 0 settles it. + ("components R, G, B, no /ColorTransform", None, true, Parms::None, Untouched), + ("components R, G, B, /ColorTransform 0", None, true, Parms::Dict(0), Reencoded), + ] { + let mut jpeg = plain.clone(); + if rgb_ids { + jpeg = with_rgb_ids(&jpeg); + } + if let Some(t) = adobe { + jpeg = with_adobe(&jpeg, t); + } + let (mut doc, img) = showing(w, h, 72, "DeviceRGB", jpeg, parms); + let original = stream(&doc, img); + let before = render(&doc, 1.0); + // Reduce File Size: an image at 72 ppi isn't resampled, only recompressed as JPEG 60. + let report = optimize(&mut doc, &Settings::default()).unwrap(); + let after = render(&doc, 1.0); + let new = stream(&doc, img); + let outcome = if new == original { + Untouched + } else { + assert_eq!(new.dict.name(b"Filter"), Some(&b"DCTDecode"[..]), "{what}"); + assert!(!new.dict.contains(b"DecodeParms"), "{what}: a plain JPEG has no colour transform to state"); + assert_eq!(report.images_recompressed, 1, "{what}"); + Reencoded + }; + let difference = mean_difference(&before, &after); + if outcome != expect || difference > TOLERANCE { + failures.push(format!("{what}: {outcome:?} (expected {expect:?}), colours moved by {difference:.1}")); + } + } + assert!(failures.is_empty(), "\n{}", failures.join("\n")); +} + +#[test] +fn optimize_leaves_cmyk_jpegs_as_they_are_with_or_without_an_adobe_marker() { + // 256 px drawn at 288 ppi: Reduce File Size would resample a colour space it handles. + let (w, h) = (256u16, 256u16); + let block = |bx: u16, by: u16| [(bx * 8) as u8, (by * 8) as u8, (255 - (bx + by) * 4) as u8, ((bx ^ by) * 8) as u8]; + for (adobe, parms) in [ + (None, Parms::None), + (None, Parms::Dict(0)), + (None, Parms::Dict(1)), + (Some(0), Parms::None), + (Some(0), Parms::Dict(1)), + (Some(2), Parms::None), + (Some(2), Parms::Dict(0)), + ] { + let what = format!("Adobe marker {adobe:?}, {parms:?}"); + let jpeg = four_component_jpeg(w, h, adobe, block); + let (mut doc, img) = showing(u32::from(w), u32::from(h), 288, "DeviceCMYK", jpeg, parms); + let original = doc.get(img); + let before = render(&doc, 4.0); + optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(doc.get(img), original, "{what}: left as it is"); + assert!(before == render(&doc, 4.0), "{what}: the page looks the same"); + } +} + +#[test] +fn merging_jpeg_strips_keeps_their_colour_transform() { + // A page picture printed as 16 JPEG strips stored untransformed (/ColorTransform 0, no + // Adobe marker): the merged image must show the strips' colours, not their samples converted + // from YCbCr. + let (w, h, rows) = (128u32, 128u32, 8u32); + let px = photo(w, h); + let mut doc = Document::new_empty(); + let mut images = Vec::new(); + let mut content = String::new(); + for k in 0..h / rows { + let band = &px[(k * rows * w * 3) as usize..((k + 1) * rows * w * 3) as usize]; + let img = jpeg_image(&mut doc, w, rows, "DeviceRGB", rgb_jpeg(band, w, rows), Parms::Dict(0)); + images.push((format!("S{k}"), img)); + content += &format!("q {w} 0 0 {rows} 0 {} cm /S{k} Do Q\n", h - (k + 1) * rows); + } + one_page(&mut doc, w, h, &images, &content); + let before = render(&doc, 1.0); + let report = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(report.merged_images, 1, "{report:?}"); + let difference = mean_difference(&before, &render(&doc, 1.0)); + assert!(difference <= TOLERANCE, "colours moved by {difference:.1}"); +} + +/// `jpeg` with `bytes` right after its SOI marker. +fn after_soi(jpeg: &[u8], bytes: &[u8]) -> Vec { + let mut v = jpeg[..2].to_vec(); + v.extend_from_slice(bytes); + v.extend_from_slice(&jpeg[2..]); + v +} + +#[test] +fn a_jpeg_header_that_takes_more_than_a_few_steps_to_read_is_left_as_it_is() { + // Decoders skip stray bytes between segments and any number of fill bytes before a marker, + // so a stream can make a header scan walk megabytes. Optimize reads the header again for + // every group of fragments it tries: it stops at the first stray byte, or after a few + // thousand steps, and leaves such an image as it is. + let (w, h) = (128, 128); + let plain = rgb_jpeg(&photo(w, h), w, h); + for (what, jpeg, expect) in [ + ("three fill bytes before the first segment", after_soi(&plain, &[0xFF; 3]), Outcome::Reencoded), + ("one stray byte before the first segment", after_soi(&plain, &[0]), Outcome::Untouched), + ("8 MB of stray bytes before the first segment", after_soi(&plain, &vec![0; 8_000_000]), Outcome::Untouched), + ("10,000 fill bytes before the first segment", after_soi(&plain, &[0xFF; 10_000]), Outcome::Untouched), + ] { + let (mut doc, img) = showing(w, h, 72, "DeviceRGB", jpeg, Parms::None); + let original = stream(&doc, img); + optimize(&mut doc, &Settings::default()).unwrap(); + let outcome = if stream(&doc, img) == original { Outcome::Untouched } else { Outcome::Reencoded }; + assert_eq!(outcome, expect, "{what}"); + } +} + +#[test] +fn cancelling_works_from_the_first_call_with_a_jpeg_drawn_many_times() { + // One JPEG with 8 MB of stray bytes after SOI, drawn 64 times at the same place (the case + // reported on #3012). Fragment merging reads its header for each group it tries, before the + // first progress call. The header scan stops at the first stray byte, so that call is the + // first image, and cancelling there stops Optimize. + let (w, h) = (128, 8); + let jpeg = after_soi(&rgb_jpeg(&photo(w, h), w, h), &vec![0; 8_000_000]); + let mut doc = Document::new_empty(); + let img = jpeg_image(&mut doc, w, h, "DeviceRGB", jpeg, Parms::None); + let content = format!( + "q {w} 0 0 {h} 0 0 cm /Im Do Q +" + ) + .repeat(64); + one_page(&mut doc, w, h, &[("Im".to_string(), img)], &content); + let mut stages = Vec::new(); + let result = optimize_with_progress(&mut doc, &Settings::default(), &mut |stage| { + stages.push(stage); + false + }); + assert!(matches!(result, Err(OptimizeError::Cancelled)), "{result:?}"); + assert_eq!(stages, [Stage::Images { done: 0, total: 1 }]); +} diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 6d9a9bb44e..3e69e27cb9 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -3,7 +3,10 @@ use std::sync::Arc; use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full}; -use pdfcraft_optimize::{Compression, ImageSettings, Settings, SpaceCategory, audit_space, effective_resolutions, optimize}; +use pdfcraft_filters::{Filter, Params}; +use pdfcraft_optimize::{ + Compatibility, Compression, ImageSettings, OptimizeError, Settings, SpaceCategory, audit_space, effective_resolutions, optimize, +}; fn image(doc: &mut Document, w: u32, h: u32, n: usize, jpeg: bool, smask: Option) -> ObjRef { // A smooth gradient with a little texture (photo-like). @@ -74,6 +77,57 @@ fn stream(doc: &Document, r: ObjRef) -> Stream { } } +#[test] +fn image_resolution_uses_the_pages_physical_unit() { + for (unit, scale) in [ + (None, 1.0), + (Some(Object::Int(10)), 10.0), + (Some(Object::Real(2.5)), 2.5), + (Some(Object::Int(0)), 1.0), + (Some(Object::Int(-10)), 1.0), + (Some(Object::Real(0.5)), 1.0), + (Some(Object::name("Ten")), 1.0), + (Some(Object::Int(100_000)), pdfcraft_model::MAX_USER_UNIT), + ] { + for indirect in [false, true] { + let mut doc = Document::new_empty(); + let img = image(&mut doc, 720, 720, 1, false, None); + let p = page(&mut doc, &[("Im", img)], "q 72 0 0 72 0 0 cm /Im Do Q"); + let parent = doc.get(p).as_dict().unwrap().reference(b"Parent").unwrap(); + // UserUnit is page-local, unlike Resources and MediaBox. + doc.update_dict(parent, |d| d.set(b"UserUnit".to_vec(), Object::Int(10))).unwrap(); + if let Some(unit) = unit.clone() { + let unit = if indirect { Object::Ref(doc.add(unit)) } else { unit }; + doc.update_dict(p, |d| d.set(b"UserUnit".to_vec(), unit)).unwrap(); + } + let ppi = effective_resolutions(&doc, &[p])[&img]; + assert!((ppi - 720.0 / scale).abs() < 1e-9, "unit={unit:?}, indirect={indirect}: {ppi}"); + } + } +} + +#[test] +fn optimization_retains_an_image_below_threshold_on_a_scaled_page() { + let mut doc = Document::new_empty(); + let img = image(&mut doc, 720, 720, 3, false, None); + let p = page(&mut doc, &[("Im", img)], "q 72 0 0 72 0 0 cm /Im Do Q"); + doc.update_dict(p, |d| d.set(b"UserUnit".to_vec(), Object::Int(10))).unwrap(); + // 720 pixels across ten inches is 72 ppi, already below the 225 ppi threshold. + let report = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(report.images_resampled, 0, "{report:?}"); + let image = stream(&doc, img); + assert_eq!((image.dict.int(b"Width"), image.dict.int(b"Height")), (Some(720), Some(720))); + let reopened = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + let dimensions: Vec<_> = reopened + .scan_objects() + .filter_map(|(_, object)| match &*object { + Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => Some((s.dict.int(b"Width"), s.dict.int(b"Height"))), + _ => None, + }) + .collect(); + assert_eq!(dimensions, [(Some(720), Some(720))]); +} + #[test] fn images_are_measured_where_drawn_and_downsampled() { let mut doc = Document::new_empty(); @@ -925,6 +979,23 @@ fn nested_forms(doc: &mut Document, img: ObjRef, levels: usize) -> ObjRef { inner } +#[test] +fn shared_forms_are_measured_in_each_pages_physical_units() { + for reverse in [false, true] { + let mut doc = Document::new_empty(); + let img = image(&mut doc, 720, 720, 3, false, None); + let outer = nested_forms(&mut doc, img, 3); + let normal = page(&mut doc, &[("F", outer)], "q 72 0 0 72 0 0 cm /F Do Q"); + let scaled = page(&mut doc, &[("F", outer)], "q 72 0 0 72 0 0 cm /F Do Q"); + doc.update_dict(scaled, |d| d.set(b"UserUnit".to_vec(), Object::Int(10))).unwrap(); + let pages = if reverse { [scaled, normal] } else { [normal, scaled] }; + assert_eq!(effective_resolutions(&doc, &pages)[&img], 72.0, "reverse={reverse}"); + let report = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(report.images_resampled, 0, "{report:?}"); + assert_eq!(stream(&doc, img).dict.int(b"Width"), Some(720)); + } +} + #[test] fn a_deeply_nested_form_is_measured_at_its_largest_use() { // 1200 px drawn 1 in wide (1200 ppi) and then 8 in wide (150 ppi): 150 ppi, so it isn't resampled. @@ -1036,6 +1107,77 @@ fn images_are_not_resampled_when_a_page_enters_more_forms_than_allowed() { assert_eq!(effective_resolutions(&doc, &refs)[&img], f64::INFINITY); } +#[test] +fn compatibility_level_sets_version_unpacks_for_1_4_and_checks_encryption() { + let mut base = Document::new_empty(); + page(&mut base, &[], "BT /F1 12 Tf 72 720 Td (Compatibility test) Tj ET"); + let root = base.root().unwrap(); + base.update_dict(root, |c| c.set(b"Version".to_vec(), Object::name("1.6"))).unwrap(); + + for (compat, expected) in [ + (Compatibility::Pdf1_4, "1.4"), + (Compatibility::Pdf1_5, "1.5"), + (Compatibility::Pdf1_6, "1.6"), + (Compatibility::Pdf1_7, "1.7"), + (Compatibility::Pdf2_0, "2.0"), + ] { + let mut doc = base.clone(); + let settings = Settings { compatibility: compat, ..Settings::default() }; + optimize(&mut doc, &settings).unwrap(); + assert_eq!(doc.version(), expected); + assert!(!doc.get(root).as_dict().unwrap().contains(b"Version"), "overriding catalog /Version is cleared"); + let bytes = write_full(&doc, &SaveOptions { object_streams: settings.effective_object_streams(), ..SaveOptions::default() }).unwrap(); + assert!(bytes.starts_with(format!("%PDF-{expected}\n").as_bytes()), "{expected}"); + let has_objstm = bytes.windows(7).any(|w| w == b"/ObjStm"); + assert_eq!(has_objstm, compat != Compatibility::Pdf1_4, "{compat:?}"); + let info = pdfcraft_render::inspect(Arc::new(bytes), None).unwrap(); + assert_eq!(info.pdf_version, expected); + } + + // Disabling object_streams on PDF 1.7 writes a classic cross-reference table. + let mut doc = base.clone(); + let settings = Settings { compatibility: Compatibility::Pdf1_7, object_streams: false, ..Settings::default() }; + optimize(&mut doc, &settings).unwrap(); + let bytes = write_full(&doc, &SaveOptions { object_streams: settings.effective_object_streams(), ..SaveOptions::default() }).unwrap(); + assert!(bytes.starts_with(b"%PDF-1.7\n") && !bytes.windows(7).any(|w| w == b"/ObjStm")); + + // Encryption that requires a newer PDF version than the chosen compatibility is refused. + let mut aes256 = base.clone(); + aes256 + .set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "u", + owner_password: "o", + permissions: -1, + encrypt_metadata: true, + seed: [1; 32], + }) + .unwrap(); + assert!(matches!( + optimize(&mut aes256, &Settings { compatibility: Compatibility::Pdf1_6, ..Settings::default() }), + Err(OptimizeError::IncompatibleEncryption { required: "1.7", chosen: "1.6" }) + )); + optimize(&mut aes256, &Settings { compatibility: Compatibility::Pdf2_0, ..Settings::default() }).unwrap(); + assert_eq!(aes256.version(), "2.0"); + + let mut aes128 = base; + aes128 + .set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes128, + user_password: "u", + owner_password: "o", + permissions: -1, + encrypt_metadata: true, + seed: [1; 32], + }) + .unwrap(); + assert!(matches!( + optimize(&mut aes128, &Settings { compatibility: Compatibility::Pdf1_5, ..Settings::default() }), + Err(OptimizeError::IncompatibleEncryption { required: "1.6", chosen: "1.5" }) + )); + optimize(&mut aes128, &Settings { compatibility: Compatibility::Pdf1_6, ..Settings::default() }).unwrap(); +} + #[test] fn image_resolution_keeps_comments_across_content_pieces() { for ending in ["", "\n", "\r", "\r\n"] { @@ -1131,6 +1273,50 @@ fn indirectly_referenced_destination_is_kept() { assert!(!left.contains(&b"unused".to_vec())); } +#[test] +fn a_destination_named_only_by_a_remote_or_embedded_go_to_is_removed() { + // The /D of a GoToR or GoToE action names a destination in another document. A local + // destination of the same name that nothing else uses is unreferenced. + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], "BT ET"); + let s = |t: &str| Object::String(pdfcraft_cos::PdfString::literal(t.as_bytes().to_vec())); + let link = |doc: &mut Document, action: Dict| { + let mut link = Dict::new(); + link.set(b"Type".to_vec(), Object::name("Annot")); + link.set(b"Subtype".to_vec(), Object::name("Link")); + link.set(b"Rect".to_vec(), Object::Array(vec![0.into(), 0.into(), 10.into(), 10.into()])); + link.set(b"A".to_vec(), Object::Dict(action)); + Object::Ref(doc.add(Object::Dict(link))) + }; + let mut go_to = Dict::new(); + go_to.set(b"S".to_vec(), Object::name("GoTo")); + go_to.set(b"D".to_vec(), s("local")); + let mut remote = Dict::new(); + remote.set(b"S".to_vec(), Object::name("GoToR")); + remote.set(b"F".to_vec(), s("other.pdf")); + remote.set(b"D".to_vec(), s("remote")); + let mut target = Dict::new(); + target.set(b"R".to_vec(), Object::name("C")); + target.set(b"N".to_vec(), s("attached.pdf")); + let mut embedded = Dict::new(); + embedded.set(b"S".to_vec(), Object::name("GoToE")); + embedded.set(b"T".to_vec(), Object::Dict(target)); + embedded.set(b"D".to_vec(), s("embedded")); + let links = vec![link(&mut doc, go_to), link(&mut doc, remote), link(&mut doc, embedded)]; + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(links))).unwrap(); + let fit = Object::Array(vec![Object::Ref(p), Object::name("Fit")]); + let mut tree = Dict::new(); + tree.set(b"Names".to_vec(), Object::Array(vec![s("embedded"), fit.clone(), s("local"), fit.clone(), s("remote"), fit])); + let tree = doc.add(Object::Dict(tree)); + let mut names = Dict::new(); + names.set(b"Dests".to_vec(), Object::Ref(tree)); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"Names".to_vec(), Object::Dict(names))).unwrap(); + let report = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!(report.unreferenced_dests, 2); + assert_eq!(named_dest_keys(&doc), vec![b"local".to_vec()]); +} + /// The keys in the catalog's /Names /Dests name tree (one level, as the optimizer writes it). fn named_dest_keys(doc: &Document) -> Vec> { let cat = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap(); @@ -1139,3 +1325,214 @@ fn named_dest_keys(doc: &Document) -> Vec> { let arr = doc.resolve(tree.get(b"Names").unwrap()).as_array().cloned().unwrap(); arr.chunks(2).filter_map(|c| doc.resolve(&c[0]).as_string().map(|s| s.bytes.clone())).collect() } + +/// Page content long enough for LZW codes to grow past 9 bits, where `EarlyChange` matters. +fn lzw_ops() -> Vec { + (0..300).flat_map(|i| format!("{} {} m {} {} l S\n", i % 97, i * 7 % 389, i * 13 % 612, i * 31 % 792).into_bytes()).collect() +} + +fn lzw(data: &[u8], params: &Params) -> Vec { + pdfcraft_filters::encode(&Filter::Lzw, params, data).unwrap() +} + +/// `params` as a `/DecodeParms` dictionary, only the entries that differ from the defaults. +fn decode_parms(p: &Params) -> Dict { + let mut d = Dict::new(); + for (k, v, default) in [ + (&b"Predictor"[..], p.predictor, 1), + (b"Colors", p.colors, 1), + (b"BitsPerComponent", p.bits_per_component, 8), + (b"Columns", p.columns, 1), + (b"EarlyChange", p.early_change, 1), + ] { + if v != default { + d.set(k.to_vec(), Object::Int(v)); + } + } + d +} + +/// A 64 × 48 RGB image, LZW-encoded with `params`. +fn lzw_image(doc: &mut Document, params: &Params) -> (ObjRef, Vec) { + let (w, h) = (64u32, 48u32); + let px: Vec = (0..w * h).flat_map(|i| [(i % w * 4) as u8, (i / w * 5) as u8, ((i % w + i / w) * 2) as u8]).collect(); + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("XObject")); + d.set(b"Subtype".to_vec(), Object::name("Image")); + d.set(b"Width".to_vec(), Object::Int(w.into())); + d.set(b"Height".to_vec(), Object::Int(h.into())); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB")); + d.set(b"Filter".to_vec(), Object::name("LZWDecode")); + d.set(b"DecodeParms".to_vec(), Object::Dict(decode_parms(params))); + (doc.add(Object::Stream(Stream::from_raw(d, lzw(&px, params)))), px) +} + +/// Settings that leave images to the clean-up: no resampling, no recompression. +fn clean_up_only() -> Settings { + let keep = ImageSettings { downsample: false, compression: Compression::Retain, ..ImageSettings::REDUCE }; + Settings { color: keep, gray: keep, ..Settings::default() } +} + +#[test] +fn lzw_streams_become_flate_and_decode_to_the_same_bytes() { + let ops = lzw_ops(); + let early = Params::default(); + let late = Params { early_change: 0, ..Params::default() }; + let png = Params { predictor: 15, colors: 3, columns: 64, ..Params::default() }; + let png_late = Params { early_change: 0, ..png.clone() }; + let mut doc = Document::new_empty(); + let content = |doc: &mut Document, p: &Params| { + let mut d = Dict::new(); + d.set(b"Filter".to_vec(), Object::name("LZWDecode")); + if *p != Params::default() { + d.set(b"DecodeParms".to_vec(), Object::Dict(decode_parms(p))); + } + doc.add(Object::Stream(Stream::from_raw(d, lzw(&ops, p)))) + }; + let c_early = content(&mut doc, &early); + let c_late = content(&mut doc, &late); + let (img, px) = lzw_image(&mut doc, &png); + let (img_late, _) = lzw_image(&mut doc, &png_late); + page(&mut doc, &[("Im", img), ("Im2", img_late)], "q 64 0 0 48 72 600 cm /Im Do Q q 64 0 0 48 172 600 cm /Im2 Do Q"); + let pg = pdfcraft_annot::page_refs(&doc).unwrap()[0]; + doc.update_dict(pg, |d| { + d.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(c_early), Object::Ref(c_late), d.get(b"Contents").unwrap().clone()])) + }) + .unwrap(); + // Each LZW stream reads as its own data before, so a decoder ignoring EarlyChange fails here. + assert_eq!((stream(&doc, c_early).decoded_strict().unwrap(), stream(&doc, c_late).decoded_strict().unwrap()), (ops.clone(), ops.clone())); + assert!(lzw(&ops, &early) != lzw(&ops, &late), "the two code widths differ on this data"); + + let r = optimize(&mut doc, &clean_up_only()).unwrap(); + assert_eq!(r.lzw_streams, 4, "{r:?}"); + for (label, at, data) in + [("content", c_early, &ops), ("content, EarlyChange 0", c_late, &ops), ("image", img, &px), ("image, EarlyChange 0", img_late, &px)] + { + let after = stream(&doc, at); + assert_eq!(after.dict.get(b"Filter"), Some(&Object::name("FlateDecode")), "{label}"); + assert_eq!(&after.decoded_strict().unwrap(), data, "{label}"); + let parms = after.dict.get(b"DecodeParms").and_then(Object::as_dict); + assert!(parms.is_none_or(|p| !p.contains(b"EarlyChange")), "{label}: EarlyChange is LZW's alone"); + } + // The image keeps its PNG predictor, now applied under Flate. + let parms = stream(&doc, img).dict.get(b"DecodeParms").and_then(Object::as_dict).cloned().unwrap(); + assert_eq!((parms.int(b"Predictor"), parms.int(b"Colors"), parms.int(b"Columns")), (Some(15), Some(3), Some(64))); + assert!(!stream(&doc, c_late).dict.contains(b"DecodeParms"), "nothing left to say for the content stream"); +} + +#[test] +fn a_page_drawn_from_lzw_streams_renders_the_same_after_conversion() { + let png = Params { predictor: 12, colors: 3, columns: 64, early_change: 0, ..Params::default() }; + let mut doc = Document::new_empty(); + let (img, _) = lzw_image(&mut doc, &png); + let pg = page(&mut doc, &[("Im", img)], ""); + let ops = b"0.2 0.5 0.8 rg 36 36 300 200 re f q 256 0 0 192 72 400 cm /Im Do Q\n".repeat(4); + let mut d = Dict::new(); + d.set(b"Filter".to_vec(), Object::name("LZWDecode")); + d.set(b"DecodeParms".to_vec(), Object::Dict(decode_parms(&Params { early_change: 0, ..Params::default() }))); + let c = doc.add(Object::Stream(Stream::from_raw(d, lzw(&ops, &Params { early_change: 0, ..Params::default() })))); + doc.update_dict(pg, |p| p.set(b"Contents".to_vec(), Object::Ref(c))).unwrap(); + let render = |doc: &Document| { + let bytes = write_full(doc, &SaveOptions::default()).unwrap(); + let mut r = pdfcraft_render::PageRenderer::new(Arc::new(bytes), pdfcraft_render::RenderConfig::default()); + let out = r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(out.error.is_none(), "{:?}", out.error); + out.rgba + }; + let before = render(&doc); + // The picture and the rectangle are drawn: the page is not blank. + assert!(before.chunks(4).any(|p| p[..3] != [255, 255, 255])); + let r = optimize(&mut doc, &clean_up_only()).unwrap(); + assert_eq!(r.lzw_streams, 2, "{r:?}"); + assert_eq!(stream(&doc, c).dict.name(b"Filter"), Some(&b"FlateDecode"[..])); + assert!(render(&doc) == before, "the page renders pixel for pixel as before"); +} + +#[test] +fn lzw_inside_a_filter_chain_is_replaced_and_the_other_stages_kept() { + let ops = lzw_ops(); + let inner = lzw(&ops, &Params::default()); + // An ASCII85 layer outside the LZW goes with it; a JPEG inside it stays encoded as it was. + let (mut doc, c) = with_content(filtered(&["ASCII85Decode", "LZWDecode"], ascii85(&inner))); + let photo = image(&mut doc, 40, 30, 3, true, None); + let jpeg = stream(&doc, photo).raw.to_vec(); + let mut picture = filtered(&["LZWDecode", "DCTDecode"], lzw(&jpeg, &Params::default())); + picture.dict.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Null, Object::Null])); + let pic = doc.add(Object::Stream(picture)); + // An ASCII85 layer over LZW with a PNG predictor: the predictor's parameters move to the one + // Flate filter left, without EarlyChange. + let rows: Vec = (0..40 * 30).map(|i| (i % 40 * 3 + i / 40) as u8).collect(); + let png = Params { predictor: 12, columns: 40, early_change: 0, ..Params::default() }; + let mut predicted = filtered(&["ASCII85Decode", "LZWDecode"], ascii85(&lzw(&rows, &png))); + predicted.dict.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Null, Object::Dict(decode_parms(&png))])); + let pred = doc.add(Object::Stream(predicted)); + let r = optimize(&mut doc, &clean_up_only()).unwrap(); + assert_eq!(r.lzw_streams, 3, "{r:?}"); + let after = stream(&doc, pred); + assert_eq!(after.dict.get(b"Filter"), Some(&Object::name("FlateDecode"))); + let parms = after.dict.get(b"DecodeParms").and_then(Object::as_dict).cloned().unwrap(); + assert_eq!((parms.int(b"Predictor"), parms.int(b"Columns"), parms.contains(b"EarlyChange")), (Some(12), Some(40), false)); + assert_eq!(after.decoded_strict().unwrap(), rows); + let after = stream(&doc, c); + assert_eq!(after.dict.get(b"Filter"), Some(&Object::name("FlateDecode"))); + assert_eq!(after.decoded_strict().unwrap(), ops); + let after = stream(&doc, pic); + assert_eq!(after.dict.get(b"Filter"), Some(&Object::Array(vec![Object::name("FlateDecode"), Object::name("DCTDecode")]))); + assert!(!after.dict.contains(b"DecodeParms"), "all null: dropped"); + let inside = pdfcraft_filters::decode(&[(Filter::Flate, Params::default())], &after.raw, 1 << 20).unwrap(); + assert_eq!(inside, jpeg, "the JPEG bytes are kept exactly"); +} + +#[test] +fn lzw_streams_that_cannot_be_read_or_are_not_asked_for_stay_unchanged() { + let ops = lzw_ops(); + let good = lzw(&ops, &Params::default()); + // 9-bit codes 511 after a literal: past the table, corrupt. + let corrupt = vec![0x80, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]; + assert!(filtered(&["LZWDecode"], corrupt.clone()).decoded_strict().is_err()); + let mut bad_predictor = filtered(&["LZWDecode"], good.clone()); + let mut parms = Dict::new(); + parms.set(b"Predictor".to_vec(), Object::Int(12)); + parms.set(b"Columns".to_vec(), Object::Int(7)); + bad_predictor.dict.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + let mut indirect_parms = filtered(&["LZWDecode"], good.clone()); + indirect_parms.dict.set(b"DecodeParms".to_vec(), Object::Ref(ObjRef { num: 999, generation: 0 })); + // Columns the reader can't take as an integer: read as the default 1 here, so the data is + // predicted with one column and decodes, but a viewer resolving the value would use another. + let one_column = Params { predictor: 12, ..Params::default() }; + let odd_columns = |columns: Object| { + let mut s = filtered(&["LZWDecode"], lzw(&ops, &one_column)); + let mut parms = Dict::new(); + parms.set(b"Predictor".to_vec(), Object::Int(12)); + parms.set(b"Columns".to_vec(), columns); + s.dict.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + s + }; + assert_eq!(odd_columns(Object::Real(12.5)).decoded_strict().unwrap(), ops, "read with one column"); + let mut objstm = filtered(&["LZWDecode"], good.clone()); + objstm.dict.set(b"Type".to_vec(), Object::name("ObjStm")); + let mut external = filtered(&["LZWDecode"], good.clone()); + external.dict.set(b"F".to_vec(), Object::String(pdfcraft_cos::PdfString::literal(b"data.bin".to_vec()))); + let cases = [ + ("corrupt LZW", filtered(&["LZWDecode"], corrupt), Settings::default()), + ("corrupt under a PNG predictor", bad_predictor, Settings::default()), + ("indirect DecodeParms", indirect_parms, Settings::default()), + ("external file", external, Settings::default()), + ("object stream, rewritten on save", objstm, Settings::default()), + ("Columns by reference", odd_columns(Object::Ref(ObjRef { num: 9, generation: 0 })), Settings::default()), + ("Columns not an integer", odd_columns(Object::Real(12.5)), Settings::default()), + ("crypt filter", filtered(&["Crypt", "LZWDecode"], good.clone()), Settings::default()), + ("option off", filtered(&["LZWDecode"], good.clone()), Settings { lzw_to_flate: false, ..Settings::default() }), + ]; + for (label, s, settings) in cases { + let (mut doc, c) = with_content(s.clone()); + let r = optimize(&mut doc, &settings).unwrap(); + assert_eq!(r.lzw_streams, 0, "{label}"); + assert_eq!(stream(&doc, c), s, "{label}"); + } + // The same data, readable and asked for, is converted: the cases above fail for their reason. + let (mut doc, c) = with_content(filtered(&["LZWDecode"], good)); + assert_eq!(optimize(&mut doc, &Settings::default()).unwrap().lzw_streams, 1); + assert_eq!(stream(&doc, c).decoded_strict().unwrap(), ops); +} diff --git a/crates/organize/src/attachments.rs b/crates/organize/src/attachments.rs new file mode 100644 index 0000000000..b0bf222991 --- /dev/null +++ b/crates/organize/src/attachments.rs @@ -0,0 +1,166 @@ +//! Document-level file attachments (Acrobat: Edit ▸ Attach file; the Attachments panel): the +//! `/Names /EmbeddedFiles` name tree of file specifications, each with its embedded stream. +//! +//! A FileAttachment annotation (a paper clip on a page) is a comment and lives in the annot +//! crate; this is the document level only. + +use pdfcraft_cos::{Dict, Document, Object, PdfString, Stream}; + +use crate::OrganizeError; +use crate::import::{name_tree_entries, set_attachments}; + +/// The keys of the document-level attachments, in name-tree order. +pub fn attachment_keys(doc: &Document) -> Vec { + entries(doc).into_iter().map(|(k, _)| PdfString::literal(k).to_text()).collect() +} + +/// Attach `bytes` to the document under `name` (a file name; any folder part is dropped). The +/// name tree's key is the name, made unique with " (2)", " (3)"… when it is already taken, and +/// is returned so the caller can find the attachment again. `date` is a PDF date string for the +/// embedded file's `/ModDate`; `mime` its `/Subtype` when known. +pub fn add_attachment( + doc: &mut Document, + name: &str, + bytes: &[u8], + description: Option<&str>, + mime: Option<&str>, + date: Option<&str>, +) -> Result { + let name = file_name(name); + if name.is_empty() { + return Err(OrganizeError::Invalid("the attachment needs a file name".into())); + } + let mut all = entries(doc); + let taken: Vec = all.iter().map(|(k, _)| PdfString::literal(k.clone()).to_text()).collect(); + let mut key = name.clone(); + let mut n = 2; + while taken.contains(&key) { + key = format!("{name} ({n})"); + n += 1; + } + + let mut params = Dict::new(); + params.set(b"Size".to_vec(), Object::Int(bytes.len() as i64)); + if let Some(d) = date { + params.set(b"ModDate".to_vec(), Object::String(PdfString::literal(d.as_bytes().to_vec()))); + } + let mut ef = Dict::new(); + ef.set(b"Type".to_vec(), Object::name("EmbeddedFile")); + if let Some(m) = mime.map(str::trim).filter(|m| !m.is_empty()) { + ef.set(b"Subtype".to_vec(), Object::Name(m.as_bytes().to_vec())); + } + ef.set(b"Params".to_vec(), Object::Dict(params)); + let stream = doc.add(Object::Stream(Stream::flate(ef, bytes))); + + let mut spec = Dict::new(); + spec.set(b"Type".to_vec(), Object::name("Filespec")); + spec.set(b"F".to_vec(), Object::String(PdfString::text(&key))); + spec.set(b"UF".to_vec(), Object::String(PdfString::text(&key))); + if let Some(d) = description.map(str::trim).filter(|d| !d.is_empty()) { + spec.set(b"Desc".to_vec(), Object::String(PdfString::text(d))); + } + let mut files = Dict::new(); + files.set(b"F".to_vec(), Object::Ref(stream)); + files.set(b"UF".to_vec(), Object::Ref(stream)); + spec.set(b"EF".to_vec(), Object::Dict(files)); + let spec = doc.add(spec); + + all.push((PdfString::text(&key).bytes, Object::Ref(spec))); + set_attachments(doc, all)?; + Ok(key) +} + +/// Remove the document-level attachment whose name-tree key is `key`. The embedded stream is +/// left for the writer's garbage collection, as with every other removal. +pub fn delete_attachment(doc: &mut Document, key: &str) -> Result<(), OrganizeError> { + let mut all = entries(doc); + let before = all.len(); + all.retain(|(k, _)| PdfString::literal(k.clone()).to_text() != key); + if all.len() == before { + return Err(OrganizeError::Invalid(format!("there is no attachment named {key}"))); + } + if all.is_empty() { clear_embedded_files(doc) } else { set_attachments(doc, all) } +} + +/// The `/EmbeddedFiles` entries (key, file specification), in tree order. +fn entries(doc: &Document) -> Vec<(Vec, Object)> { + let Some(tree) = doc + .root() + .and_then(|r| doc.get(r).as_dict().cloned()) + .and_then(|c| c.get(b"Names").map(|n| doc.resolve(n))) + .and_then(|n| n.as_dict().and_then(|d| d.get(b"EmbeddedFiles").cloned())) + else { + return Vec::new(); + }; + let mut out = Vec::new(); + name_tree_entries(doc, &tree, 0, &mut std::collections::HashSet::new(), &mut out); + out +} + +/// Drop `/EmbeddedFiles` from the catalog's `/Names` (and `/Names` itself when nothing is left). +fn clear_embedded_files(doc: &mut Document) -> Result<(), OrganizeError> { + let root = doc.root().ok_or(OrganizeError::NoPageTree)?; + let names = doc.get(root).as_dict().and_then(|c| c.get(b"Names").cloned()); + match names { + Some(Object::Ref(r)) => { + let mut empty = false; + doc.update_dict(r, |n| { + n.remove(b"EmbeddedFiles"); + empty = n.is_empty(); + })?; + if empty { + doc.update_dict(root, |c| { + c.remove(b"Names"); + })?; + } + } + Some(Object::Dict(mut n)) => { + n.remove(b"EmbeddedFiles"); + doc.update_dict(root, |c| { + if n.is_empty() { + c.remove(b"Names"); + } else { + c.set(b"Names".to_vec(), Object::Dict(n)); + } + })?; + } + _ => {} + } + Ok(()) +} + +/// The last path component of `name`, trimmed: a dragged-in path or a folder part never becomes +/// the attachment's name. +fn file_name(name: &str) -> String { + name.rsplit(['/', '\\']).next().unwrap_or(name).trim().to_string() +} + +/// A media type from the file's extension, for the embedded file's `/Subtype`; `None` when it +/// is not one of the common kinds (the attachment is still fine without it). +pub fn mime_for_name(name: &str) -> Option<&'static str> { + let ext = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase())?; + Some(match ext.as_str() { + "pdf" => "application/pdf", + "txt" | "log" | "md" => "text/plain", + "csv" => "text/csv", + "htm" | "html" => "text/html", + "xml" => "text/xml", + "json" => "application/json", + "zip" => "application/zip", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "tif" | "tiff" => "image/tiff", + "svg" => "image/svg+xml", + "doc" => "application/msword", + "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "xls" => "application/vnd.ms-excel", + "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "ppt" => "application/vnd.ms-powerpoint", + "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "rtf" => "application/rtf", + "eml" => "message/rfc822", + "msg" => "application/vnd.ms-outlook", + _ => return None, + }) +} diff --git a/crates/organize/src/import.rs b/crates/organize/src/import.rs index 1162a810cd..ef11f10007 100644 --- a/crates/organize/src/import.rs +++ b/crates/organize/src/import.rs @@ -34,6 +34,37 @@ const SKIP_ON_ANNOT: &[&[u8]] = &[b"StructParent", b"P"]; /// objects, each shallow enough for the parser, would otherwise exhaust the stack (#1800). const MAX_COPY_DEPTH: usize = 256; +/// Keys that stay on the widget when a combined field/widget is split (#1810): the annotation's +/// appearance, placement and page. Field state (name, value, flags, default appearance) moves to +/// the new parent field, where the widgets inherit it. +const WIDGET_KEYS: &[&[u8]] = &[ + b"Type", + b"Subtype", + b"Rect", + b"P", + b"AP", + b"AS", + b"MK", + b"BS", + b"Border", + b"C", + b"CA", + b"F", + b"H", + b"R", + b"NM", + b"M", + b"StructParent", + b"StructParents", + b"OC", + b"Tabs", + b"A", + b"Contents", + b"BM", + b"Lang", + b"AF", +]; + struct Copier<'a> { src: &'a Document, /// Current recursion depth, bounded by [`MAX_COPY_DEPTH`]. @@ -404,27 +435,33 @@ fn import_pages_mapped( let r = dst.add(Object::Null); let c = copier.copy_value(dst, &resolved, Some(r))?; dst.set(r, c); - // `/Kids` may be an array object of its own: add to it there, so the - // field keeps its other widgets. - match dst.get(copied).as_dict().and_then(|f| f.get(b"Kids").cloned()) { - Some(Object::Ref(list)) => { - let mut kids = dst.get(list).as_array().cloned().unwrap_or_default(); - kids.push(Object::Ref(r)); - dst.set(list, Object::Array(kids)); - } - _ => dst.update_dict(copied, |f| { - let mut kids = f.get(b"Kids").and_then(|k| k.as_array()).cloned().unwrap_or_default(); - kids.push(Object::Ref(r)); - f.set(b"Kids".to_vec(), Object::Array(kids)); - })?, - } + add_kid(dst, copied, r)?; annot_pages.insert(r, new); out.push(Object::Ref(r)); continue; } - // A widget that is its own field (no parent to hang a second widget on) stays - // the one object, shown on both pages, so the field stays one. + // A widget that is its own field (no parent to hang a second widget on): on + // its first copy it is split into a parent field and a widget child (#1810); + // every later copy of the page gets one more widget child of that field, so + // the pages no longer share one widget whose `/P` names only the last page. let own_field = resolved.as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget") && !d.contains(b"Parent")); + if own_field + && let Some(first) = a.as_ref().and_then(|r| copier.map.get(&r).copied()) + && let Some(parent) = dst.get(first).as_dict().and_then(|d| d.reference(b"Parent")) + { + let r = dst.add(Object::Null); + let c = copier.copy_value(dst, &resolved, Some(r))?; + let Object::Dict(d) = c else { continue }; + let (_, mut d) = field_and_widget(dst, &d); + d.set(b"Parent".to_vec(), Object::Ref(parent)); + // The new widget is not a field root of its own. + copier.fields.retain(|f| *f != r); + dst.set(r, Object::Dict(d)); + add_kid(dst, parent, r)?; + annot_pages.insert(r, new); + out.push(Object::Ref(r)); + continue; + } let annots = if own_field { &mut copier } else { own.as_mut().unwrap_or(&mut copier) }; // Direct (inline) annotation dictionaries become indirect objects, so every // copied annotation can be fixed up the same way. @@ -438,6 +475,9 @@ fn import_pages_mapped( } _ => continue, }; + if own_field && copier.fields.contains(&r) { + split_own_field(dst, &mut copier.fields, r); + } annot_pages.insert(r, new); out.push(Object::Ref(r)); } @@ -570,7 +610,7 @@ fn name_tree_lookup(src: &Document, node: &Object, key: &[u8], depth: u8, seen: } /// All (key, value) pairs of a name tree, in order, each node read once. -fn name_tree_entries(src: &Document, node: &Object, depth: u8, seen: &mut HashSet, out: &mut Vec<(Vec, Object)>) { +pub(crate) fn name_tree_entries(src: &Document, node: &Object, depth: u8, seen: &mut HashSet, out: &mut Vec<(Vec, Object)>) { if depth > 32 || node.as_ref().is_some_and(|r| !seen.insert(r)) { return; } @@ -834,6 +874,73 @@ fn qualified_optional(prefix: &str, name: &str) -> String { if name.is_empty() { prefix.to_string() } else { qualified(prefix, name) } } +/// Add `kid` to `field`'s `/Kids`, in the array object itself when `/Kids` is one, so the field +/// keeps its other widgets. +fn add_kid(dst: &mut Document, field: ObjRef, kid: ObjRef) -> Result<(), OrganizeError> { + match dst.get(field).as_dict().and_then(|f| f.get(b"Kids").cloned()) { + Some(Object::Ref(list)) => { + let mut kids = dst.get(list).as_array().cloned().unwrap_or_default(); + kids.push(Object::Ref(kid)); + dst.set(list, Object::Array(kids)); + } + _ => dst.update_dict(field, |f| { + let mut kids = f.get(b"Kids").and_then(|k| k.as_array()).cloned().unwrap_or_default(); + kids.push(Object::Ref(kid)); + f.set(b"Kids".to_vec(), Object::Array(kids)); + })?, + } + Ok(()) +} + +/// PdfCraft patch (#1810): split a widget that doubles as its own field into a parent field +/// holding the field keys (name, value, flags…) and a widget child holding the annotation keys +/// (rectangle, appearance, page). The parent takes the widget's place among the top-level fields; +/// later copies of the page add widget children of it, so every page has its own widget and the +/// field stays one. +fn split_own_field(dst: &mut Document, fields: &mut [ObjRef], widget: ObjRef) { + let Some(d) = dst.get(widget).as_dict().cloned() else { return }; + let (mut field, mut child) = field_and_widget(dst, &d); + let parent = dst.add(Object::Null); + field.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(widget)])); + child.set(b"Parent".to_vec(), Object::Ref(parent)); + dst.set(parent, Object::Dict(field)); + dst.set(widget, Object::Dict(child)); + if let Some(entry) = fields.iter_mut().find(|f| **f == widget) { + *entry = parent; + } +} + +/// A combined field/widget has two additional-action namespaces (ISO 32000-1 tables 194/196). +/// Partition them as well as the ordinary entries, including when copying a later widget. +fn field_and_widget(doc: &Document, d: &Dict) -> (Dict, Dict) { + let (mut field, mut child) = (Dict::new(), Dict::new()); + for (k, v) in d.iter() { + if k.as_slice() == b"AA" + && let Some(actions) = doc.resolve(v).as_dict().cloned() + { + let (mut field_actions, mut widget_actions) = (Dict::new(), Dict::new()); + for (trigger, action) in actions.iter() { + if [b"E".as_slice(), b"X", b"D", b"U", b"Fo", b"Bl", b"PO", b"PC", b"PV", b"PI"].contains(&trigger.as_slice()) { + widget_actions.set(trigger.clone(), action.clone()); + } else { + field_actions.set(trigger.clone(), action.clone()); + } + } + if field_actions.iter().next().is_some() { + field.set(k.clone(), Object::Dict(field_actions)); + } + if widget_actions.iter().next().is_some() { + child.set(k.clone(), Object::Dict(widget_actions)); + } + } else if WIDGET_KEYS.contains(&k.as_slice()) { + child.set(k.clone(), v.clone()); + } else { + field.set(k.clone(), v.clone()); + } + } + (field, child) +} + /// Add top-level fields to the destination's `/AcroForm /Fields` (creating the form if needed). fn register_fields(dst: &mut Document, fields: &[ObjRef]) -> Result<(), OrganizeError> { if fields.is_empty() { @@ -868,12 +975,26 @@ fn register_fields(dst: &mut Document, fields: &[ObjRef]) -> Result<(), Organize /// A new document containing copies of `pages` from `src` (Extract Pages / Split). /// Document information (title, author…) is carried over, and so is the print standard the -/// source declares (PDF/X: its output intents and identification, #263). +/// source declares (PDF/X: its output intents and identification, #263), and the bookmarks that +/// go to the copied pages. pub fn extract_pages(src: &Document, pages: &[usize]) -> Result { let mut out = Document::new_empty(); // One source: nothing to deduplicate. Its fields keep their names (a source with two // same-named field roots stays one field, as it was), so Extract and Split never rename. let imported = import_pages_mapped(&mut out, src, pages, 0, FieldConflicts::Merge)?; + // The bookmarks of these pages come along, mapped to the copies (#4020). + let outline = kept_bookmarks(source_outline(src, imported.page_map)); + if !outline.is_empty() { + let root = crate::outline::container(&mut out, &[])?; + if let Some((first, last, _)) = write_outline_level(&mut out, root, &outline)? { + out.update_dict(root, |d| { + d.set(b"First".to_vec(), Object::Ref(first)); + d.set(b"Last".to_vec(), Object::Ref(last)); + })?; + let top = crate::outline::children(&out, root); + crate::outline::relink_and_recount(&mut out, root, &top)?; + } + } for key in crate::INFO_KEYS { if let Some(v) = crate::info(src, key) { crate::set_info(&mut out, key, &v)?; @@ -1178,18 +1299,24 @@ fn collect_attachments(dst: &mut Document, src: &Document, out: &mut Vec<(Vec, Object)>) -> Result<(), OrganizeError> { +pub(crate) fn set_attachments(dst: &mut Document, mut entries: Vec<(Vec, Object)>) -> Result<(), OrganizeError> { if entries.is_empty() { return Ok(()); } + // Names are compared and numbered as text: a key written as UTF-16 (a name with an accent + // or Chinese letters) must not get ASCII bytes appended to it. let mut seen = std::collections::HashSet::new(); for (k, _) in entries.iter_mut() { - let base = k.clone(); + let base = pdfcraft_cos::PdfString::literal(k.clone()).to_text(); + let mut text = base.clone(); let mut n = 2; - while !seen.insert(k.clone()) { - *k = [base.as_slice(), format!(" ({n})").as_bytes()].concat(); + while !seen.insert(text.clone()) { + text = format!("{base} ({n})"); n += 1; } + if text != base { + *k = pdfcraft_cos::PdfString::text(&text).bytes; + } } entries.sort_by(|a, b| a.0.cmp(&b.0)); let mut names = Vec::new(); @@ -1201,6 +1328,14 @@ fn set_attachments(dst: &mut Document, mut entries: Vec<(Vec, Object)>) -> R tree.set(b"Names".to_vec(), Object::Array(names)); let tree = dst.add(tree); let root = dst.root().ok_or(OrganizeError::NoPageTree)?; + // `/Names` may be an indirect dictionary (Acrobat writes it so): update it where it is, so + // its other trees (/Dests, /JavaScript…) stay. + if let Some(Object::Ref(names)) = dst.get(root).as_dict().and_then(|c| c.get(b"Names").cloned()) + && dst.get(names).as_dict().is_some() + { + dst.update_dict(names, |n| n.set(b"EmbeddedFiles".to_vec(), Object::Ref(tree)))?; + return Ok(()); + } dst.update_dict(root, |c| { let mut n = match c.get(b"Names") { Some(Object::Dict(d)) => d.clone(), @@ -1245,6 +1380,18 @@ fn source_outline(src: &Document, pages: HashMap) -> Vec) -> Vec { + nodes + .into_iter() + .filter_map(|mut n| { + n.children = kept_bookmarks(n.children); + (n.dest.is_some() || !n.children.is_empty()).then_some(n) + }) + .collect() +} + /// One level of bookmarks from `first` (its siblings), each with its children. Cycle- and size-safe: /// at most `budget` bookmarks are read in all, and nesting stops at 32 levels. fn outline_level(m: &Copier<'_>, first: Option, depth: u8, budget: &mut usize) -> Vec { diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index 9b731a1f21..e24056a9c3 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -13,6 +13,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, XrefEntry}; +mod attachments; mod boxes; mod dedupe; mod import; @@ -24,6 +25,7 @@ mod transition; pub mod view; mod xmp; +pub use attachments::{add_attachment, attachment_keys, delete_attachment, mime_for_name}; pub use boxes::{BoxSpec, PageBox, page_boxes, set_page_box}; pub use dedupe::dedupe_resources; pub use import::{ @@ -658,10 +660,21 @@ pub fn check_custom_key(name: &str) -> Result<(), OrganizeError> { Ok(()) } -/// Read a document-information entry as text. +/// Read a document-information entry as text: `/Info`, else the XMP packet (Title, Author, +/// Subject, Keywords, Creator and Producer, see `xmp`). pub fn info(doc: &Document, key: &str) -> Option { - let info = doc.trailer().get(b"Info").map(|o| doc.resolve(o))?; - info.as_dict()?.get(key.as_bytes()).and_then(|v| doc.resolve(v).as_string().map(|s| s.to_text())) + let in_info = doc + .trailer() + .get(b"Info") + .map(|o| doc.resolve(o)) + .and_then(|info| info.as_dict()?.get(key.as_bytes()).and_then(|v| doc.resolve(v).as_string().map(|s| s.to_text()))); + // Files that state it only in their XMP packet (#2373). + in_info.filter(|v| !v.trim_matches('\0').trim().is_empty()).or_else(|| xmp::info_value(doc, key)) +} + +/// Whether the document information has an entry `key`, of any type. +pub fn has_info(doc: &Document, key: &str) -> bool { + doc.trailer().get(b"Info").map(|o| doc.resolve(o)).is_some_and(|info| info.as_dict().is_some_and(|d| d.get(key.as_bytes()).is_some())) } /// Set (or clear, with an empty value) a document-information entry. @@ -691,12 +704,18 @@ fn set_info_entry(doc: &mut Document, key: &[u8], entry: Option) -> Resu })?; } _ => { - let Some(v) = entry else { return Ok(()) }; let mut d = match doc.trailer().get(b"Info") { Some(Object::Dict(d)) => d.clone(), _ => Dict::new(), }; - d.set(key.to_vec(), v); + match entry { + Some(v) => d.set(key.to_vec(), v), + None => { + if d.remove(key).is_none() { + return Ok(()); + } + } + } let r = doc.add(d); doc.trailer_mut().set(b"Info".to_vec(), Object::Ref(r)); } diff --git a/crates/organize/src/outline.rs b/crates/organize/src/outline.rs index 4bcfe93ae3..cc1bbb0884 100644 --- a/crates/organize/src/outline.rs +++ b/crates/organize/src/outline.rs @@ -553,10 +553,7 @@ pub fn add_bookmark_at( let at = index.min(kids.len()); kids.insert(at, item); relink(doc, parent, &kids)?; - // Show the new bookmark: open its parent (the root is always open). - if !parent_path.is_empty() && doc.get(parent).as_dict().and_then(|d| d.int(b"Count")).is_none_or(|c| c <= 0) { - put(doc, parent, b"Count", Some(Object::Int(1)))?; - } + show_children(doc, parent, parent_path)?; recount(doc)?; let mut path = parent_path.to_vec(); path.push(at); @@ -699,11 +696,23 @@ pub fn move_bookmark(doc: &mut Document, from: &[usize], to_parent: &[usize], in let at = index.min(kids.len()); kids.insert(at, r); relink(doc, parent, &kids)?; + if parent != old_parent { + show_children(doc, parent, &to_parent)?; + } recount(doc)?; to_parent.push(at); Ok(to_parent) } +/// Open `parent` so a bookmark just added or moved into it shows (the root is always open). +/// Only the sign of `/Count` matters here: [`recount`] sets its value. +fn show_children(doc: &mut Document, parent: ObjRef, parent_path: &[usize]) -> Result<()> { + if !parent_path.is_empty() && doc.get(parent).as_dict().and_then(|d| d.int(b"Count")).is_none_or(|c| c <= 0) { + put(doc, parent, b"Count", Some(Object::Int(1)))?; + } + Ok(()) +} + /// Expand or collapse a bookmark that has children. pub fn set_bookmark_open(doc: &mut Document, path: &[usize], open: bool) -> Result<()> { let (r, _) = resolve_path(doc, path)?; diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index b5c4488821..1ba8861cbe 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -528,6 +528,29 @@ fn custom_document_properties_are_listed_set_removed_and_their_names_checked() { assert!(custom_info(&doc).is_empty()); } +/// An `/Info` dictionary written directly in the trailer: a cleared entry is removed, and the +/// other entries are kept. +#[test] +fn document_info_entries_are_cleared_from_an_info_dictionary_in_the_trailer() { + let b = build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R >>".into(), + body("X"), + ], + "/Root 1 0 R /Info << /Title (Report) /Project (Apollo) /Revision (7) >>", + ); + let mut doc = open(b); + set_info(&mut doc, "Missing", "").unwrap(); + assert!(matches!(doc.trailer().get(b"Info"), Some(Object::Dict(_))), "clearing an absent entry changes nothing"); + set_info(&mut doc, "Project", "").unwrap(); + set_info(&mut doc, "Title", "").unwrap(); + let doc = save_and_reopen(&doc); + assert_eq!(custom_info(&doc), [("Revision".to_string(), "7".to_string())]); + assert_eq!(info(&doc, "Title"), None); +} + /// Padding is not part of a custom value, in any mix of NULs and whitespace, a value that is only /// padding is not listed, and such entries don't count towards the 256 listed: a real property /// after 300 blank ones is still there. @@ -628,6 +651,43 @@ fn document_info_edits_update_the_xmp_packet() { assert_eq!((dict.name(b"Type"), dict.name(b"Subtype"), dict.get(b"Filter")), (Some(&b"Metadata"[..]), Some(&b"XML"[..]), None)); } +/// A file that states its title, author and so on only in XMP (PDF 2.0 deprecates `/Info`) shows +/// them in Document Properties (#2373); `/Info` wins where it has a value. +#[test] +fn document_info_falls_back_to_the_xmp_packet() { + let xmp = LIBREOFFICE_XMP + .replace( + "Untitled 1", + "BerichtReport & “notes”", + ) + .replace("Old Author", "Ada\nGrace") + .replace("Writer 1.0", "Writer 1.0"); + let mut doc = doc_with_xmp(xmp.as_bytes()); + // Both: /Info's own values, XMP for what /Info lacks. + assert_eq!(info(&doc, "Title").as_deref(), Some("Untitled 1")); + assert_eq!(info(&doc, "Subject").as_deref(), Some("Old subject")); + // XMP only. + doc.trailer_mut().remove(b"Info"); + let doc = full_roundtrip(&doc); + assert_eq!(info(&doc, "Title").as_deref(), Some("Report & \u{201C}notes\u{201D}")); + assert_eq!(info(&doc, "Author").as_deref(), Some("Ada; Grace")); + assert_eq!(info(&doc, "Subject").as_deref(), Some("Old subject")); + assert_eq!(info(&doc, "Producer").as_deref(), Some("Writer 1.0")); + assert_eq!(info(&doc, "Keywords"), None, "an empty property is no value"); + assert_eq!(info(&doc, "Rating"), None, "no XMP counterpart"); + // The attribute form, and a packet that isn't UTF-8 is not read. + let attrs = "\ + "; + let mut doc = doc_with_xmp(attrs.as_bytes()); + doc.trailer_mut().remove(b"Info"); + assert_eq!(info(&doc, "Keywords").as_deref(), Some("a, b & c")); + let mut latin1 = LIBREOFFICE_XMP.as_bytes().to_vec(); + latin1.extend_from_slice(b""); + let mut doc = doc_with_xmp(&latin1); + doc.trailer_mut().remove(b"Info"); + assert_eq!(info(&doc, "Subject"), None); +} + #[test] fn document_info_edits_leave_files_without_usable_xmp_alone() { // No packet: none is added. @@ -931,9 +991,111 @@ fn links_are_rewired_to_copied_pages_or_dropped() { assert!(!link.contains(b"Dest") && !link.contains(b"A")); } -/// A widget annotation that is its own form field (no `/Parent`). -fn own_field(doc: &Document, r: ObjRef) -> bool { - doc.get(r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget") && !d.contains(b"Parent")) +/// Issue #1810: a widget that is its own field was shared between both copies of a repeated +/// page, so only the last page could identify it (its `/P`), and per-page annotation edits were +/// impossible. Each copy must get its own widget sharing one parent field (name and value). +#[test] +fn repeated_page_splits_a_combined_field_widget() { + let out = full_roundtrip(&extract_pages(&doc_a(), &[1, 1]).unwrap()); + let ps = pages(&out).unwrap(); + let widget = |i: usize| -> ObjRef { + let list = page_dict(&out, i).get(b"Annots").map(|a| out.resolve(a).as_array().cloned().unwrap_or_default()).unwrap_or_default(); + list.iter() + .filter_map(|a| a.as_ref()) + .find(|r| out.get(*r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget"))) + .expect("a widget annotation") + }; + let (w1, w2) = (widget(0), widget(1)); + assert_ne!(w1, w2, "each copy of the page has its own widget"); + for (i, w) in [(0, w1), (1, w2)] { + let d = out.get(w).as_dict().unwrap().clone(); + for key in [b"T".as_slice(), b"V", b"FT"] { + assert!(!d.contains(key), "a widget inherits field entries: {key:?}"); + } + assert_eq!(d.reference(b"P"), Some(ps[i].obj), "/P names the page holding the widget"); + let parent = d.reference(b"Parent").expect("the widget has a parent field"); + let f = out.get(parent).as_dict().unwrap().clone(); + assert_eq!(f.get(b"T").and_then(|v| v.as_string()).map(|s| s.to_text()).as_deref(), Some("name")); + assert_eq!(f.get(b"V").and_then(|v| v.as_string()).map(|s| s.to_text()).as_deref(), Some("Ada")); + let kids: Vec = + f.get(b"Kids").and_then(|k| out.resolve(k).as_array().cloned()).unwrap_or_default().iter().filter_map(|k| k.as_ref()).collect(); + assert!(kids.contains(&w1) && kids.contains(&w2), "one field holds both widgets: {kids:?}"); + let fields: Vec = catalog(&out) + .get(b"AcroForm") + .map(|f| out.resolve(f)) + .and_then(|f| f.as_dict().cloned()) + .and_then(|f| f.get(b"Fields").map(|x| out.resolve(x))) + .and_then(|f| f.as_array().cloned()) + .unwrap_or_default() + .iter() + .filter_map(|f| f.as_ref()) + .collect(); + assert_eq!(fields, [parent], "the parent is the one top-level field"); + } +} + +#[test] +fn splitting_a_combined_widget_keeps_annotation_actions_and_field_triggers() { + let mut src = doc_a(); + let page = pages(&src).unwrap()[1].obj; + let list = page_dict(&src, 1).get(b"Annots").and_then(|a| a.as_array()).cloned().unwrap(); + let widget = + list.iter().filter_map(Object::as_ref).find(|r| src.get(*r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget"))).unwrap(); + let mut action = Dict::new(); + action.set(b"S".to_vec(), Object::name("GoTo")); + action.set(b"D".to_vec(), Object::Array(vec![Object::Ref(page), Object::name("Fit")])); + let mut named = Dict::new(); + named.set(b"S".to_vec(), Object::name("Named")); + named.set(b"N".to_vec(), Object::name("NextPage")); + let mut aa = Dict::new(); + aa.set(b"E".to_vec(), Object::Dict(named.clone())); + aa.set(b"K".to_vec(), Object::Dict(named)); + let aa = src.add(Object::Dict(aa)); + src.update_dict(widget, |d| { + d.set(b"A".to_vec(), Object::Dict(action)); + d.set(b"AA".to_vec(), Object::Ref(aa)); + }) + .unwrap(); + let out = full_roundtrip(&extract_pages(&src, &[1, 1]).unwrap()); + let ps = pages(&out).unwrap(); + for i in 0..2 { + let w = annots(&out, i).into_iter().find(|d| d.name(b"Subtype") == Some(b"Widget")).unwrap(); + let action = out.resolve(w.get(b"A").expect("the widget keeps its action")); + let dest = action.as_dict().unwrap().get(b"D").and_then(|a| a.as_array()).unwrap()[0].as_ref().unwrap(); + assert!(ps.iter().any(|p| p.obj == dest), "the action targets a copied page"); + let waa = out.resolve(w.get(b"AA").expect("widget triggers")); + assert!(waa.as_dict().unwrap().contains(b"E")); + assert!(!waa.as_dict().unwrap().contains(b"K")); + let parent = out.get(w.reference(b"Parent").unwrap()).as_dict().unwrap().clone(); + assert!(!parent.contains(b"A")); + let faa = out.resolve(parent.get(b"AA").expect("field triggers")); + assert!(faa.as_dict().unwrap().contains(b"K")); + assert!(!faa.as_dict().unwrap().contains(b"E")); + } +} + +#[test] +fn splitting_a_combined_widget_keeps_its_annotation_only_keys() { + let mut src = doc_a(); + let list = page_dict(&src, 1).get(b"Annots").and_then(|a| a.as_array()).cloned().unwrap(); + let widget = + list.iter().filter_map(Object::as_ref).find(|r| src.get(*r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget"))).unwrap(); + src.update_dict(widget, |d| { + d.set(b"Contents".to_vec(), Object::String(PdfString::text("alt text"))); + d.set(b"BM".to_vec(), Object::name("Multiply")); + d.set(b"Lang".to_vec(), Object::String(PdfString::text("en-US"))); + d.set(b"AF".to_vec(), Object::Array(Vec::new())); + }) + .unwrap(); + let out = full_roundtrip(&extract_pages(&src, &[1, 1]).unwrap()); + for i in 0..2 { + let w = annots(&out, i).into_iter().find(|d| d.name(b"Subtype") == Some(b"Widget")).unwrap(); + let parent = out.get(w.reference(b"Parent").unwrap()).as_dict().unwrap().clone(); + for key in [b"Contents".as_slice(), b"BM", b"Lang", b"AF"] { + assert!(w.contains(key), "the widget keeps {key:?}"); + assert!(!parent.contains(key), "the parent field does not take {key:?}"); + } + } } #[test] @@ -949,15 +1111,11 @@ fn repeated_page_gets_independent_annotations() { assert!(!refs[i].is_empty()); assert_eq!(refs[i].len(), refs[i + 1].len()); for (a, b) in refs[i].iter().zip(&refs[i + 1]) { - // A widget that is its own field stays one object on both pages, so the field stays - // one (#790); every other annotation is copied. - if !own_field(&out, *a) { - assert_ne!(a, b, "each copy of the page has its own annotation objects"); - } + assert_ne!(a, b, "each copy of the page has its own annotation objects"); } } for (i, list) in refs.iter().enumerate() { - for a in list.iter().filter(|a| !own_field(&out, **a)) { + for a in list.iter() { assert_eq!(out.get(*a).as_dict().unwrap().reference(b"P"), Some(ps[i].obj), "/P names the page holding the copy"); } } @@ -978,7 +1136,32 @@ fn form_fields_come_along_and_are_registered() { assert_eq!(fields.len(), 1); let field = out.resolve(&fields[0]).as_dict().cloned().unwrap(); assert_eq!(field.get(b"V").and_then(|v| v.as_string()).map(|s| s.to_text()).as_deref(), Some("Ada")); - assert_eq!(field.reference(b"P"), Some(pages(&out).unwrap()[0].obj)); + // The widget child, not the field, names the page (#1810). + let kid = + field.get(b"Kids").map(|k| out.resolve(k)).and_then(|k| k.as_array().cloned()).and_then(|k| k.first().and_then(|k| k.as_ref())).unwrap(); + assert_eq!(out.get(kid).as_dict().unwrap().reference(b"P"), Some(pages(&out).unwrap()[0].obj)); +} + +/// #4020: Extract keeps the bookmarks that go to the pages it copies, mapped to the copies. One whose +/// page was left out goes, unless a bookmark under it stays. +#[test] +fn extract_keeps_the_bookmarks_of_the_pages_it_copies() { + let mut src = doc_a(); + crate::add_bookmark(&mut src, &[], 0, "One", 0).unwrap(); + crate::add_bookmark(&mut src, &[], 1, "Two", 1).unwrap(); + crate::add_bookmark(&mut src, &[1], 0, "Two.a", 2).unwrap(); + crate::add_bookmark(&mut src, &[], 2, "Three", 2).unwrap(); + let marks = |d: &Document| -> Vec<(String, usize, Option)> { bookmarks(d).into_iter().map(|b| (b.title, b.path.len(), b.page)).collect() }; + let s = |t: &str| t.to_string(); + + let all = full_roundtrip(&extract_pages(&src, &[0, 1, 2]).unwrap()); + assert_eq!(marks(&all), [(s("One"), 1, Some(0)), (s("Two"), 1, Some(1)), (s("Two.a"), 2, Some(2)), (s("Three"), 1, Some(2))]); + + let last = full_roundtrip(&extract_pages(&src, &[2]).unwrap()); + assert_eq!(marks(&last), [(s("Two"), 1, None), (s("Two.a"), 2, Some(0)), (s("Three"), 1, Some(0))], "One's page was left out"); + + let none = full_roundtrip(&extract_pages(&doc_a(), &[0]).unwrap()); + assert!(!none.get(none.root().unwrap()).as_dict().unwrap().contains(b"Outlines"), "no bookmarks, no outline"); } #[test] @@ -1565,6 +1748,18 @@ fn bookmarks_add_rename_move_delete() { assert_eq!(crate::move_bookmark(&mut d, &[0], &[2], 9).unwrap(), vec![1, 1]); assert_eq!(titles(&crate::bookmarks(&d)), ["Intro", "Results[Table,Figure 1]"]); assert_eq!(crate::move_bookmark(&mut d, &[1], &[1, 0], 0), Err(crate::OutlineError::IntoItself)); + // Into a bookmark with no children yet (Indent): it opens, so the moved one shows. + assert_eq!(crate::move_bookmark(&mut d, &[1, 1], &[0], 0).unwrap(), vec![0, 0]); + let intro = crate::bookmarks(&d)[0].obj; + assert_eq!(count_of(&d, intro), Some(1)); + // Reordering inside a closed parent leaves it closed. + crate::add_bookmark(&mut d, &[0], 1, "Second", 0).unwrap(); + crate::set_bookmark_open(&mut d, &[0], false).unwrap(); + crate::move_bookmark(&mut d, &[0, 1], &[0], 0).unwrap(); + assert_eq!(count_of(&d, intro), Some(-2)); + crate::delete_bookmark(&mut d, &[0, 0]).unwrap(); + crate::move_bookmark(&mut d, &[0, 0], &[1], 9).unwrap(); + assert_eq!(titles(&crate::bookmarks(&d)), ["Intro", "Results[Table,Figure 1]"]); crate::delete_bookmark(&mut d, &[1, 0]).unwrap(); assert_eq!(titles(&crate::bookmarks(&d)), ["Intro", "Results[Figure 1]"]); @@ -2232,6 +2427,46 @@ fn initial_view_round_trips_and_keeps_scripts() { assert!(crate::set_initial_view(&mut doc, &crate::InitialView { page: 9, ..Default::default() }).is_err()); } +#[test] +fn an_unchanged_opening_destination_keeps_its_position_and_name() { + use crate::view::{Magnification, Navigation}; + let page2 = Object::Ref(pdfcraft_cos::ObjRef::new(5, 0)); + let xyz = Object::Array(vec![page2.clone(), Object::name("XYZ"), Object::Int(100), Object::Int(500), Object::Int(2)]); + let mut named = pdfcraft_cos::Dict::new(); + named.set(b"S".to_vec(), Object::name("GoTo")); + named.set(b"D".to_vec(), Object::name("there")); + let mut dests = pdfcraft_cos::Dict::new(); + dests.set(b"there".to_vec(), Object::Array(vec![page2, Object::name("Fit")])); + let mut prefs = pdfcraft_cos::Dict::new(); + prefs.set(b"NonFullScreenPageMode".to_vec(), Object::name("UseOutlines")); + for action in [xyz, Object::Dict(named)] { + let mut doc = open(fixture()); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| { + c.set(b"OpenAction".to_vec(), action.clone()); + c.set(b"Dests".to_vec(), Object::Dict(dests.clone())); + c.set(b"PageMode".to_vec(), Object::name("FullScreen")); + c.set(b"ViewerPreferences".to_vec(), Object::Dict(prefs.clone())); + }) + .unwrap(); + // Another option changes: the opening action is written back as it was, and so is the + // full-screen panel, which the dialog doesn't edit. + let v = crate::initial_view(&doc); + assert_eq!(v.non_full_screen, Navigation::Bookmarks); + crate::set_initial_view(&mut doc, &crate::InitialView { hide_toolbar: true, ..v.clone() }).unwrap(); + let catalog = doc.get(root).as_dict().cloned().unwrap(); + assert_eq!(catalog.get(b"OpenAction"), Some(&action)); + let doc = save_and_reopen(&doc); + 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(); + 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); + } +} + // ── Split / Extract keep only the resources the part draws (#204) ────────────────────────────── /// A content stream with no text operators (an image draw). @@ -2850,8 +3085,9 @@ fn import_renames_a_field_colliding_with_another_incoming_field() { assert_eq!(form_field_names(&dst), ["Other", "Text1", "Text1-1"]); } -/// A page listed twice shows its widget on both copies — also when the field was renamed: both -/// pages' widgets belong to the one renamed field, and no second Text1 appears. +/// A page listed twice shows its widget on both copies — also when the field was renamed: each +/// copy has its own widget, both widgets belong to the one renamed field (#1810), and no second +/// Text1 appears. #[test] fn a_repeated_page_joins_the_renamed_field() { let mut dst = doc_with_field("Text1"); @@ -2868,10 +3104,14 @@ fn a_repeated_page_joins_the_renamed_field() { .and_then(|l| l.as_array().cloned()) .unwrap(); let renamed = fields[1].clone(); + let mut widgets = Vec::new(); for page in [1, 2] { let list = page_dict(&dst, page).get(b"Annots").map(|a| dst.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap(); - assert!(list.contains(&renamed), "page {page}'s widget belongs to the renamed field"); + let widget = list.iter().find_map(|a| a.as_ref()).expect("a widget on the page"); + widgets.push(widget); + assert_eq!(dst.get(widget).as_dict().unwrap().reference(b"Parent"), renamed.as_ref(), "page {page}'s widget belongs to the renamed field"); } + assert_ne!(widgets[0], widgets[1], "each copy has its own widget"); } /// Combining forms keeps same-named fields separate — the second file's colliding field is @@ -3040,6 +3280,87 @@ fn a_refused_import_takes_back_what_it_added_wherever_it_is_numbered() { assert!(run.is_ok(), "a refused import must leave the document as it was, without panicking"); } +/// What Edit ▸ Attach file added survives Combine files (upstream's writer), names +/// with accents included, and the combined document takes more attachments and deletions. +#[test] +fn attachments_added_in_the_app_survive_combine_and_more_can_be_attached() { + let embedded = |doc: &Document, key: &str| -> (Vec, Option) { + let names = doc.resolve(catalog(doc).get(b"Names").unwrap()).as_dict().cloned().unwrap(); + let tree = doc.resolve(names.get(b"EmbeddedFiles").unwrap()).as_dict().cloned().unwrap(); + let pairs = tree.get(b"Names").and_then(|n| n.as_array()).cloned().unwrap(); + let at = pairs.chunks(2).position(|p| p[0].as_string().unwrap().to_text() == key).unwrap_or_else(|| panic!("no attachment {key}")); + let spec = doc.resolve(&pairs[at * 2 + 1]).as_dict().cloned().unwrap(); + let desc = spec.get(b"Desc").and_then(Object::as_string).map(|s| s.to_text()); + let ef = spec.get(b"EF").and_then(Object::as_dict).unwrap().get(b"F").cloned().unwrap(); + let Object::Stream(stream) = doc.resolve(&ef).as_ref().clone() else { panic!("the embedded file is a stream") }; + (stream.decoded().unwrap(), desc) + }; + let mut a = Document::open(Arc::new(fixture())).unwrap(); + add_attachment(&mut a, "invoice.csv", b"a,b", Some("March"), Some("text/csv"), None).unwrap(); + add_attachment(&mut a, "año.txt", b"uno", None, None, None).unwrap(); + let mut b = Document::open(Arc::new(fixture())).unwrap(); + add_attachment(&mut b, "invoice.csv", b"x,y", None, None, None).unwrap(); + add_attachment(&mut b, "año.txt", b"dos", None, None, None).unwrap(); + let a = save_and_reopen(&a); + let b = save_and_reopen(&b); + let mut out = full_roundtrip(&combine(&[("A", &a), ("B", &b)]).unwrap()); + // Both files' attachments, the second copies numbered as text (not bytes glued onto UTF-16). + assert_eq!(attachment_keys(&out), ["invoice.csv", "invoice.csv (2)", "año.txt", "año.txt (2)"]); + assert_eq!(embedded(&out, "invoice.csv"), (b"a,b".to_vec(), Some("March".into()))); + assert_eq!(embedded(&out, "invoice.csv (2)"), (b"x,y".to_vec(), None)); + assert_eq!(embedded(&out, "año.txt (2)").0, b"dos"); + // The combined document goes on taking attachments, and losing them, like any other. + assert_eq!(add_attachment(&mut out, "invoice.csv", b"z", None, None, None).unwrap(), "invoice.csv (3)"); + delete_attachment(&mut out, "invoice.csv (2)").unwrap(); + let out = full_roundtrip(&out); + assert_eq!(attachment_keys(&out), ["invoice.csv", "invoice.csv (3)", "año.txt", "año.txt (2)"]); + assert_eq!(embedded(&out, "invoice.csv (3)").0, b"z"); +} + +#[test] +fn attachments_are_added_listed_and_deleted() { + let mut doc = Document::open(Arc::new(fixture())).unwrap(); + assert!(attachment_keys(&doc).is_empty()); + // A path loses its folder; the key is the file name. + let key = add_attachment(&mut doc, "/Users/eva/Desktop/invoice.csv", b"a,b\n1,2\n", Some("March"), Some("text/csv"), Some("D:20261012120000Z")) + .unwrap(); + assert_eq!(key, "invoice.csv"); + // The same name again is kept apart. + assert_eq!(add_attachment(&mut doc, "invoice.csv", b"x", None, None, None).unwrap(), "invoice.csv (2)"); + assert_eq!(add_attachment(&mut doc, " ", b"x", None, None, None), Err(OrganizeError::Invalid("the attachment needs a file name".into()))); + let doc = save_and_reopen(&doc); + assert_eq!(attachment_keys(&doc), ["invoice.csv", "invoice.csv (2)"]); + // What was written: a Filespec with the embedded stream, its size and type, and the description. + let root = doc.root().unwrap(); + let names = doc.get(root).as_dict().unwrap().get(b"Names").cloned().unwrap(); + let tree = doc.resolve(&names).as_dict().unwrap().get(b"EmbeddedFiles").cloned().unwrap(); + let tree = doc.resolve(&tree); + let pairs = tree.as_dict().unwrap().get(b"Names").cloned().unwrap(); + let pairs = doc.resolve(&pairs).as_array().cloned().unwrap(); + assert_eq!(pairs.len(), 4); + let spec = doc.resolve(&pairs[1]); + let spec = spec.as_dict().unwrap(); + assert_eq!(spec.get(b"Type"), Some(&Object::name("Filespec"))); + assert_eq!(spec.get(b"UF").and_then(Object::as_string).map(PdfString::to_text).as_deref(), Some("invoice.csv")); + assert_eq!(spec.get(b"Desc").and_then(Object::as_string).map(PdfString::to_text).as_deref(), Some("March")); + let ef = spec.get(b"EF").and_then(Object::as_dict).unwrap().get(b"F").cloned().unwrap(); + let ef = doc.resolve(&ef); + let Object::Stream(stream) = ef.as_ref() else { panic!("the embedded file is a stream") }; + assert_eq!(stream.dict.get(b"Subtype"), Some(&Object::Name(b"text/csv".to_vec()))); + let params = stream.dict.get(b"Params").and_then(Object::as_dict).unwrap(); + assert_eq!(params.get(b"Size"), Some(&Object::Int(8))); + assert_eq!(stream.decoded().unwrap(), b"a,b\n1,2\n"); + // Deleting: the one named goes, the other stays; the last one takes the tree with it. + let mut doc = doc; + delete_attachment(&mut doc, "invoice.csv").unwrap(); + assert_eq!(attachment_keys(&doc), ["invoice.csv (2)"]); + assert_eq!(delete_attachment(&mut doc, "invoice.csv"), Err(OrganizeError::Invalid("there is no attachment named invoice.csv".into()))); + delete_attachment(&mut doc, "invoice.csv (2)").unwrap(); + let doc = save_and_reopen(&doc); + assert!(attachment_keys(&doc).is_empty()); + assert!(doc.get(doc.root().unwrap()).as_dict().unwrap().get(b"Names").is_none()); +} + #[test] fn rotate_repeated_indices_accumulate_from_the_current_page_value() { for level in 0..3 { diff --git a/crates/organize/src/view.rs b/crates/organize/src/view.rs index 0f115d3051..b51df1aa55 100644 --- a/crates/organize/src/view.rs +++ b/crates/organize/src/view.rs @@ -63,6 +63,10 @@ pub struct InitialView { pub language: Option, /// Right-edge binding (`/Direction /R2L`). pub right_to_left: bool, + /// The navigation panel on leaving full screen (`/ViewerPreferences /NonFullScreenPageMode`, + /// ISO 32000-2 Table 147), read only when `full_screen` is set. It is read, not written: + /// [`set_initial_view`] leaves the entry as it is. + pub non_full_screen: Navigation, } fn catalog(doc: &Document) -> Result<(pdfcraft_cos::ObjRef, Dict), OrganizeError> { @@ -104,6 +108,15 @@ pub fn initial_view(doc: &Document) -> InitialView { v.hide_toolbar = flag(b"HideToolbar"); v.hide_window_ui = flag(b"HideWindowUI"); v.right_to_left = name(&vp, b"Direction").as_deref() == Some(b"R2L"); + // Table 147: meaningful only with `/PageMode /FullScreen`, and only these four values. + if v.full_screen { + v.non_full_screen = match name(&vp, b"NonFullScreenPageMode").as_deref() { + Some(b"UseOutlines") => Navigation::Bookmarks, + Some(b"UseThumbs") => Navigation::Pages, + Some(b"UseOC") => Navigation::Layers, + _ => Navigation::PageOnly, + }; + } } // The opening destination: an explicit array, or a GoTo action's. let dest = c.get(b"OpenAction").map(|o| doc.resolve(o)).and_then(|o| match &*o { @@ -149,8 +162,13 @@ pub fn displays_doc_title(doc: &Document) -> bool { } /// Write the initial view. An opening action that isn't a plain destination (a script, say) is -/// kept unless the page or magnification asks for a destination. +/// kept unless the page or magnification asks for a destination, and an opening action whose page +/// and magnification `v` leaves as [`initial_view`] reads them is kept as it is. pub fn set_initial_view(doc: &mut Document, v: &InitialView) -> Result<(), OrganizeError> { + let same_destination = { + let now = initial_view(doc); + now.page == v.page && now.magnification == v.magnification + }; let (root, c) = catalog(doc)?; let pages = crate::walk(doc)?; if v.page >= pages.len() { @@ -238,6 +256,9 @@ pub fn set_initial_view(doc: &mut Document, v: &InitialView) -> Result<(), Organ } } match (&dest, open_action_is_dest) { + // The opening page and magnification are as read: the action stays as written, with + // its position or named destination (§12.3.2.4) that the view model doesn't hold. + _ if same_destination => {} (Some(d), _) => c.set(b"OpenAction".to_vec(), Object::Array(d.clone())), (None, true) => { c.remove(b"OpenAction"); @@ -294,4 +315,40 @@ mod tests { assert_eq!(flags(initial_view(&doc)), [false; 6]); } } + + /// A catalog with `/PageMode page_mode` and `/ViewerPreferences << /NonFullScreenPageMode mode >>`. + fn with_modes(page_mode: &str, mode: Object) -> Document { + let mut doc = Document::new_empty(); + let mode = doc.add(mode); + let mut preferences = Dict::new(); + preferences.set(b"NonFullScreenPageMode".to_vec(), Object::Ref(mode)); + let root = doc.root().unwrap(); + doc.update_dict(root, |c| { + c.set(b"PageMode".to_vec(), Object::name(page_mode)); + c.set(b"ViewerPreferences".to_vec(), Object::Dict(preferences)); + }) + .unwrap(); + doc + } + + #[test] + fn non_full_screen_page_mode_is_read_with_full_screen_only() { + for (mode, want) in [ + ("UseOutlines", Navigation::Bookmarks), + ("UseThumbs", Navigation::Pages), + ("UseOC", Navigation::Layers), + ("UseNone", Navigation::PageOnly), + // Not a value Table 147 lists. + ("UseAttachments", Navigation::PageOnly), + ("FullScreen", Navigation::PageOnly), + ] { + let v = initial_view(&with_modes("FullScreen", Object::name(mode))); + assert!(v.full_screen); + assert_eq!(v.non_full_screen, want, "{mode}"); + // Ignored unless the document opens in full screen. + let v = initial_view(&with_modes("UseThumbs", Object::name(mode))); + assert_eq!((v.navigation, v.non_full_screen), (Navigation::Pages, Navigation::PageOnly), "{mode}"); + } + assert_eq!(initial_view(&with_modes("FullScreen", Object::Int(1))).non_full_screen, Navigation::PageOnly); + } } diff --git a/crates/organize/src/xmp.rs b/crates/organize/src/xmp.rs index cc3a78ccf4..5b6f3930c8 100644 --- a/crates/organize/src/xmp.rs +++ b/crates/organize/src/xmp.rs @@ -92,6 +92,128 @@ fn edit_packet(doc: &mut Document, edit: impl FnOnce(&str) -> Option) -> Ok(true) } +/// The value the catalog's XMP packet gives the document information key `key`, for files that +/// state their title, author and so on only there (`/Info` is deprecated in PDF 2.0, and some +/// writers leave it out). A language alternative gives its `x-default` item (else the first), +/// several authors are joined with "; ". `None` for keys XMP doesn't mirror, files without XMP, +/// packets that can't be read safely, and empty values. +pub(crate) fn info_value(doc: &Document, key: &str) -> Option { + let (name, _, shape) = property(key)?; + let root = doc.root()?; + let r = doc.get(root).as_dict()?.reference(b"Metadata")?; + let obj = doc.get(r); + let Object::Stream(stream) = &*obj else { return None }; + let xmp = String::from_utf8(stream.decoded_within(MAX_XMP).ok()?).ok()?; + get_property(&xmp, name, shape) +} + +/// The text of the property `name` in `xmp`, element or attribute form (see [`info_value`]). +fn get_property(xmp: &str, name: &str, shape: Shape) -> Option { + let value = if let Some((start, end)) = element_span(xmp, name) { + let element = xmp.get(start..end)?; + let open_end = element.find('>')?; + // ``: no value. + if element.get(..open_end)?.ends_with('/') { + return None; + } + let inner = element.get(open_end + 1..)?.strip_suffix(&format!(""))?; + let items = list_items(inner); + if items.is_empty() { + // An empty `` or `` has no value, not its markup. + text_value(inner)? + } else if shape == Shape::LangAlt { + let (_, text) = items.iter().find(|(lang, _)| lang.as_deref() == Some("x-default")).or(items.first())?; + text_value(text)? + } else { + items.iter().filter_map(|(_, text)| text_value(text)).filter(|t| !t.is_empty()).collect::>().join("; ") + } + } else { + let (_, value_start, value_end, _) = attribute_span(xmp, name)?; + unescape(xmp.get(value_start..value_end)?.trim()) + }; + Some(value).filter(|v| !v.trim().is_empty()) +} + +/// Character data `text` as a value, unescaped; `None` when it still holds markup (a `<`, which +/// a value escapes as `<`), so a structure the reader doesn't take apart never shows as text. +fn text_value(text: &str) -> Option { + let text = text.trim(); + (!text.contains('<')).then(|| unescape(text)) +} + +/// The `rdf:li` items of a property's content: each one's `xml:lang` (if any) and its text. +fn list_items(inner: &str) -> Vec<(Option, &str)> { + let mut items = Vec::new(); + let mut rest = inner; + while let Some(i) = rest.find("' || c == '/' || c.is_whitespace()) { + rest = after; + continue; + } + let Some(tag_end) = after.find('>') else { break }; + let tag = after.get(..tag_end).unwrap_or_default(); + let lang = tag.find("xml:lang=").and_then(|at| { + let v = tag.get(at + "xml:lang=".len()..)?; + let quote = v.chars().next().filter(|c| matches!(c, '"' | '\''))?; + let v = v.get(1..)?; + Some(v.get(..v.find(quote)?)?.to_string()) + }); + let Some(body) = after.get(tag_end + 1..) else { break }; + if tag.ends_with('/') { + items.push((lang, "")); + rest = body; + continue; + } + let Some(close) = body.find("") else { break }; + items.push((lang, body.get(..close).unwrap_or_default())); + rest = body.get(close..).unwrap_or_default(); + } + items +} + +/// XML text with its character and entity references resolved (an unknown one is kept as is). +fn unescape(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut rest = text; + while let Some(i) = rest.find('&') { + out.push_str(rest.get(..i).unwrap_or_default()); + let after = rest.get(i..).unwrap_or_default(); + let Some(semi) = after.find(';').filter(|&n| n <= 12) else { + out.push('&'); + rest = after.get(1..).unwrap_or_default(); + continue; + }; + let entity = after.get(1..semi).unwrap_or_default(); + let c = match entity { + "amp" => Some('&'), + "lt" => Some('<'), + "gt" => Some('>'), + "quot" => Some('"'), + "apos" => Some('\''), + _ => entity + .strip_prefix("#x") + .or_else(|| entity.strip_prefix("#X")) + .and_then(|h| u32::from_str_radix(h, 16).ok()) + .or_else(|| entity.strip_prefix('#').and_then(|d| d.parse().ok())) + .and_then(char::from_u32), + }; + match c { + Some(c) => { + out.push(c); + rest = after.get(semi + 1..).unwrap_or_default(); + } + None => { + out.push('&'); + rest = after.get(1..).unwrap_or_default(); + } + } + } + out.push_str(rest); + out +} + /// `xmp` with the property `name` (namespace `ns`) set to `value`, or removed when `value` is /// empty; `None` when nothing changes or the packet has no `rdf:RDF` to add the property to. fn set_property(xmp: &str, name: &str, ns: &str, shape: Shape, value: &str) -> Option { @@ -219,6 +341,28 @@ mod tests { Old subject\n\ \n \n\n"; + #[test] + fn values_are_read_back_unescaped() { + assert_eq!(unescape("a & b <c> AB &bogus; � & end"), "a & b AB &bogus; � & end"); + assert_eq!(get_property(PACKET, "dc:title", Shape::LangAlt).as_deref(), Some("Untitled 1")); + assert_eq!(get_property(PACKET, "dc:creator", Shape::Seq).as_deref(), Some("Old Author")); + assert_eq!(get_property(PACKET, "pdf:Producer", Shape::Text).as_deref(), Some("Writer 1.0")); + assert_eq!(get_property(PACKET, "pdf:Keywords", Shape::Text), None); + // No x-default: the first language; empty and self-closed items are skipped. + let alt = "RapportReport"; + assert_eq!(get_property(alt, "dc:title", Shape::LangAlt).as_deref(), Some("Rapport")); + let seq = " Ada"; + assert_eq!(get_property(seq, "dc:creator", Shape::Seq).as_deref(), Some("Ada")); + // Unfinished markup reads as nothing rather than as a wrong value. + assert_eq!(get_property("Cut", "dc:title", Shape::LangAlt), None); + // An empty alternative or list, or an item holding markup, has no value: never the markup. + assert_eq!(get_property("", "dc:title", Shape::LangAlt), None); + assert_eq!(get_property("", "dc:title", Shape::LangAlt), None); + assert_eq!(get_property("", "dc:creator", Shape::Seq), None); + let nested = "Ada"; + assert_eq!(get_property(nested, "dc:creator", Shape::Seq).as_deref(), Some("Ada")); + } + #[test] fn element_values_are_replaced_in_place() { let out = set_property(PACKET, "dc:title", DC, Shape::LangAlt, "Guide & ").unwrap(); diff --git a/crates/organize/tests/attachment_names_cleanup.rs b/crates/organize/tests/attachment_names_cleanup.rs new file mode 100644 index 0000000000..b24a4611bc --- /dev/null +++ b/crates/organize/tests/attachment_names_cleanup.rs @@ -0,0 +1,44 @@ +//! Deleting the last document attachment when the catalog's `/Names` is an indirect dictionary +//! (as some producers write it) must not leave an empty `/Names` behind. + +use std::sync::Arc; + +use pdfcraft_cos::{Document, Object}; +use pdfcraft_organize::{add_attachment, attachment_keys, delete_attachment}; + +/// One page, and a catalog whose `/Names` is the indirect (empty) dictionary 4 0 R. +fn indirect_names_pdf() -> Vec { + let objs: [&[u8]; 4] = [ + b"<< /Type /Catalog /Pages 2 0 R /Names 4 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>", + b"<< >>", + ]; + 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 +} + +#[test] +fn deleting_the_last_attachment_removes_an_indirect_empty_names_dictionary() { + let mut doc = Document::open(Arc::new(indirect_names_pdf())).unwrap(); + let key = add_attachment(&mut doc, "notes.txt", b"hello", None, None, None).unwrap(); + assert_eq!(attachment_keys(&doc), ["notes.txt"]); + delete_attachment(&mut doc, &key).unwrap(); + assert!(attachment_keys(&doc).is_empty()); + let root = doc.root().unwrap(); + let catalog = doc.get(root).as_dict().cloned().unwrap(); + assert!(catalog.get(b"Names").is_none(), "the catalog keeps no empty /Names: {:?}", catalog.get(b"Names").map(|n| matches!(n, Object::Ref(_)))); +} diff --git a/crates/print/README.md b/crates/print/README.md index ae60e1a2c9..1379be2b42 100644 --- a/crates/print/README.md +++ b/crates/print/README.md @@ -35,6 +35,7 @@ spool::submit(&pdf, &spool::Job { printer: None, copies: 2, ..Default::default() Each source page becomes a Form XObject (its content wrapped in q/Q, plus the printable annotations); sheets place them with a clip. The result is a fresh, unencrypted, garbage-collected file (callers check the print permission). +Annotation appearances receive unused XObject names, preserving existing page resources even when they use the same naming prefix. The GUI Print dialog remembers its last selected paper size across application sessions, including when Print is cancelled. The preference stores a canonical name such as `A4`; diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index db65492c19..b731aff00f 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -149,6 +149,19 @@ pub enum BookletSubset { BackOnly, } +/// How a booklet goes through the printer (#2707): it decides two-sided printing itself, as +/// Acrobat does (its Print dialog hides Two-sided while Booklet is chosen; Using Acrobat X Pro, +/// Print dialog box options: "Acrobat automatically enables duplex printing, if available, for +/// booklet printing"). Both sides flip on the short edge, the fold of a 2-up sheet turned +/// sideways; front side only or back side only is one pass of a manual duplex and prints +/// single-sided. +pub fn booklet_duplex(subset: BookletSubset) -> spool::Duplex { + match subset { + BookletSubset::BothSides => spool::Duplex::ShortEdge, + BookletSubset::FrontOnly | BookletSubset::BackOnly => spool::Duplex::Off, + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum Binding { #[default] @@ -307,20 +320,45 @@ pub const PAPERS: [(&str, (f64, f64)); 21] = [ pub const LABEL_PAPER: usize = 19; /// The CUPS media name for `PAPERS[index]`, so the spooler feeds the sheet the PDF was laid out -/// on rather than the queue's default (PPD keywords; `w288h432` is the usual 4×6 label, `Env10` -/// the US #10 envelope). +/// on rather than the queue's default; `None` only past the end of `PAPERS`. The common sizes +/// use their PPD keywords (`w288h432` is the usual 4×6 label, `Env10` the US #10 envelope). +/// The rest use PWG 5101.1 names (ARCH E1 is its `wide-format` size), spelled exactly as the +/// spec and the IANA registry have them: a driverless printer may take only the names it +/// advertises. They carry their dimensions, so CUPS feeds the PPD size of those dimensions +/// whatever the driver calls it (`ARCHD`, `AnsiD`) (#3752). pub fn cups_media(index: usize) -> Option<&'static str> { - match PAPERS.get(index)?.0 { - "US Letter" => Some("Letter"), - "US Legal" => Some("Legal"), - "Tabloid" => Some("Tabloid"), - "A3" => Some("A3"), - "A4" => Some("A4"), - "A5" => Some("A5"), - "US #10 Envelope (4.125 x 9.5 in)" => Some("Env10"), - _ if index == LABEL_PAPER => Some("w288h432"), - _ => None, - } + Some(match PAPERS.get(index)?.0 { + "US Letter" => "Letter", + "US Legal" => "Legal", + "Tabloid" => "Tabloid", + "A3" => "A3", + "A4" => "A4", + "A5" => "A5", + "A6" => "iso_a6_105x148mm", + "ARCH A (9 x 12 in)" => "na_arch-a_9x12in", + "ARCH B (12 x 18 in)" => "na_arch-b_12x18in", + "ARCH C (18 x 24 in)" => "na_arch-c_18x24in", + "ARCH D (24 x 36 in)" => "na_arch-d_24x36in", + "ARCH E1 (30 x 42 in)" => "na_wide-format_30x42in", + "ARCH E (36 x 48 in)" => "na_arch-e_36x48in", + "ANSI C (17 x 22 in)" => "na_c_17x22in", + "ANSI D (22 x 34 in)" => "na_d_22x34in", + "ANSI E (34 x 44 in)" => "na_e_34x44in", + "A2" => "iso_a2_420x594mm", + "A1" => "iso_a1_594x841mm", + "A0" => "iso_a0_841x1189mm", + "US #10 Envelope (4.125 x 9.5 in)" => "Env10", + _ if index == LABEL_PAPER => "w288h432", + // A size added to PAPERS without a name here: its tests fail, and meanwhile the queue's + // paper stands, as it did for every size before #1583. + _ => return None, + }) +} + +/// The job option that feeds `PAPERS[index]` (`media`, see [`cups_media`]), for the Print +/// dialog's jobs and `doc_print`'s alike. +pub fn media_option(index: usize) -> Option<(String, String)> { + cups_media(index).map(|m| ("media".to_string(), m.to_string())) } /// Whether most of page `index`'s text runs bottom-to-top as the page displays (a label laid @@ -719,19 +757,57 @@ pub fn keep_printed(doc: &mut Document, content: Content) { } } +/// The standard annotation types of ISO 32000-2 Table 171. +const STANDARD_ANNOTATION_TYPES: [&[u8]; 28] = [ + b"Text", + b"Link", + b"FreeText", + b"Line", + b"Square", + b"Circle", + b"Polygon", + b"PolyLine", + b"Highlight", + b"Underline", + b"Squiggly", + b"StrikeOut", + b"Caret", + b"Stamp", + b"Ink", + b"Popup", + b"FileAttachment", + b"Sound", + b"Movie", + b"Screen", + b"Widget", + b"PrinterMark", + b"TrapNet", + b"Watermark", + b"3D", + b"Redact", + b"Projection", + b"RichMedia", +]; + /// Does annotation `d` print under `content`? fn prints(doc: &Document, d: &Dict, content: Content) -> bool { + const INVISIBLE: i64 = 1; const HIDDEN: i64 = 2; const PRINT: i64 = 4; let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); if flags & PRINT == 0 || flags & HIDDEN != 0 { return false; } - let subtype = d.name(b"Subtype").unwrap_or(b""); + // `/Subtype` may be an indirect reference (ISO 32000-2 §7.3.10), so resolve it first. + let subtype_obj = d.get(b"Subtype").map(|s| doc.resolve(s)); + let subtype = subtype_obj.as_deref().and_then(Object::as_name).unwrap_or(b""); match subtype { b"Widget" => true, b"Link" | b"Popup" => false, b"Stamp" => content != Content::Document && content != Content::FormFieldsOnly, + // ISO 32000-2 Table 167: an Invisible annotation of a type that isn't standard (Table + // 171) is not printed even with its Print flag set. + _ if flags & INVISIBLE != 0 && !STANDARD_ANNOTATION_TYPES.contains(&subtype) => false, _ => content == Content::DocumentAndMarkups, } } @@ -801,7 +877,7 @@ fn page_form( } let annots = page.dict.get(b"Annots").and_then(|a| doc.resolve(a).as_array().cloned()).unwrap_or_default(); let mut xobjects = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); - let mut k = 0; + let mut k = 0usize; for a in annots { let Some(d) = doc.resolve(&a).as_dict().cloned() else { continue }; if !prints(doc, &d, content) { @@ -833,8 +909,15 @@ fn page_form( } else { ap }; - let name = format!("PCPrA{k}"); - k += 1; + // Keep the page's own XObjects, including names previous print operations generated. + // Check the growing map so each appearance also keeps the name just assigned to it. + let name = loop { + let name = format!("PCPrA{k}"); + k = k.checked_add(1).ok_or_else(|| PrintError::Invalid("too many annotation appearance resources".into()))?; + if !xobjects.contains(name.as_bytes()) { + break name; + } + }; let [a0, b0, c0, d0, e0, f0] = m.0; body.extend(format!("q {} {} {} {} {} {} cm /{name} Do Q\n", n(a0), n(b0), n(c0), n(d0), n(e0), n(f0)).bytes()); xobjects.set(name.into_bytes(), Object::Ref(ap)); diff --git a/crates/print/src/range.rs b/crates/print/src/range.rs index d03f31a3ca..f0d8939da6 100644 --- a/crates/print/src/range.rs +++ b/crates/print/src/range.rs @@ -70,7 +70,7 @@ pub fn select_pages(count: usize, range: Option<&str>, labels: &[String], subset Some(_) if labels.iter().any(|l| l == part) => pages.push(page_of(part, count, labels)?), Some((a, b)) => { let from = if a.trim().is_empty() { 0 } else { page_of(a, count, labels)? }; - let to = if b.trim().is_empty() { count.saturating_sub(1) } else { page_of(b, count, labels)? }; + let to = if b.trim().is_empty() { count.checked_sub(1).ok_or(PrintError::NoPages)? } else { page_of(b, count, labels)? }; if from <= to { pages.extend(from..=to); } else { @@ -115,3 +115,18 @@ fn narrow(pages: Vec, subset: Subset, reverse: bool) -> Result } Ok(out) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn open_ended_range_of_empty_document_is_an_error() { + assert!(select_pages(0, Some("-"), &[], Subset::All, false).is_err()); + } + + #[test] + fn open_ended_range_covers_all_pages() { + assert_eq!(select_pages(5, Some("-"), &[], Subset::All, false).unwrap(), vec![0, 1, 2, 3, 4]); + } +} diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index 84ab248c78..bc0454c999 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -674,6 +674,8 @@ fn architectural_sheets_preserve_full_scale() { assert!(fit[0].placed[0].matrix.0[0] < 1.0, "Fit adds margins and must not masquerade as full scale"); } +/// A sheet matches a preset either way round and rounded to whole points, but not half a point off; +/// empty, negative, infinite, NaN and custom sizes match none. #[test] fn paper_matching_accepts_rounded_metric_sizes_but_not_custom_or_invalid_sizes() { for (i, (_, (w, h))) in PAPERS.iter().enumerate() { @@ -687,9 +689,69 @@ fn paper_matching_accepts_rounded_metric_sizes_but_not_custom_or_invalid_sizes() } } +/// #3752: 13 of the 21 sizes had no media name, so their jobs went out on the queue's default +/// paper (A4 on the report's queue) whatever the sheets were laid out on. +#[test] +fn every_paper_reaches_the_spooler_by_a_name_of_its_size() { + let ppd_keywords = ["Letter", "Legal", "Tabloid", "A3", "A4", "A5", "w288h432", "Env10"]; + for (i, (name, (w, h))) in PAPERS.iter().enumerate() { + let media = cups_media(i).unwrap_or_else(|| panic!("{name} has no media name")); + assert_eq!(media_option(i), Some(("media".to_string(), media.to_string()))); + let args = lp_args(&Job { options: media_option(i).into_iter().collect(), ..Job::default() }); + assert!(args.contains(&format!("media={media}")), "{name}: lp is given it: {args:?}"); + if ppd_keywords.contains(&media) { + continue; + } + // The rest are PWG 5101.1 names, which end in their dimensions: CUPS feeds the driver's + // size of those dimensions, so they must be the paper's. + let dims = media.rsplit('_').next().unwrap_or_default(); + let (dims, points_per_unit) = match (dims.strip_suffix("mm"), dims.strip_suffix("in")) { + (Some(mm), _) => (mm, 72.0 / 25.4), + (_, Some(inches)) => (inches, 72.0), + _ => panic!("{name}: {media} is not a PWG name"), + }; + let (pw, ph) = dims.split_once('x').unwrap_or_else(|| panic!("{name}: {media} has no dimensions")); + let (pw, ph) = (pw.parse::().unwrap() * points_per_unit, ph.parse::().unwrap() * points_per_unit); + assert!((pw - w).abs() < 0.5 && (ph - h).abs() < 0.5, "{name}: {media} is {pw:.2} x {ph:.2} pt, not {w} x {h}"); + } + assert_eq!((cups_media(PAPERS.len()), media_option(PAPERS.len())), (None, None)); + // The names themselves, spelled as PWG 5101.1 has them: a driverless printer may take only the + // names it advertises, so one CUPS can merely parse (ARCH A was `arch_a_9x12in`) is not enough. + // ARCH E1 is 5101.1's `wide-format` size: `na_arch-e1_30x42in` was asked for once and never + // registered. + let names: Vec<(&str, &str)> = PAPERS.iter().enumerate().map(|(i, (name, _))| (*name, cups_media(i).unwrap_or_default())).collect(); + assert_eq!( + names, + [ + ("US Letter", "Letter"), + ("US Legal", "Legal"), + ("Tabloid", "Tabloid"), + ("A3", "A3"), + ("A4", "A4"), + ("A5", "A5"), + ("A6", "iso_a6_105x148mm"), + ("ARCH A (9 x 12 in)", "na_arch-a_9x12in"), + ("ARCH B (12 x 18 in)", "na_arch-b_12x18in"), + ("ARCH C (18 x 24 in)", "na_arch-c_18x24in"), + ("ARCH D (24 x 36 in)", "na_arch-d_24x36in"), + ("ARCH E1 (30 x 42 in)", "na_wide-format_30x42in"), + ("ARCH E (36 x 48 in)", "na_arch-e_36x48in"), + ("ANSI C (17 x 22 in)", "na_c_17x22in"), + ("ANSI D (22 x 34 in)", "na_d_22x34in"), + ("ANSI E (34 x 44 in)", "na_e_34x44in"), + ("A2", "iso_a2_420x594mm"), + ("A1", "iso_a1_594x841mm"), + ("A0", "iso_a0_841x1189mm"), + ("4 x 6 in label (101.6 x 152.4 mm)", "w288h432"), + ("US #10 Envelope (4.125 x 9.5 in)", "Env10"), + ] + ); +} + +/// #2734: the US #10 envelope (4.125 × 9.5 in) is offered, fed by its PPD name and laid out at +/// its own size. #[test] fn us_number_10_envelope_is_offered_and_fed_by_name() { - // 4.125 × 9.5 in (#2734). let index = matching_paper((684.0, 297.0)).unwrap(); assert_eq!(PAPERS[index].0, "US #10 Envelope (4.125 x 9.5 in)"); assert_eq!(PAPERS[index].1, (297.0, 684.0)); @@ -961,3 +1023,39 @@ fn odd_and_even_pages_are_page_numbers_not_places_in_the_range() { // A range with no page of the kind prints nothing. assert_eq!(select_pages(12, Some("3, 5"), &[], Subset::Even, false), Err(PrintError::NoPages)); } + +/// ISO 32000-2 Table 167: an Invisible annotation of a type that isn't standard does not print, +/// even with its Print flag; Invisible means nothing for a standard type. +#[test] +fn invisible_annotations_of_unknown_types_do_not_print() { + let doc = fixture(1); + let annot = |subtype: Option<&str>, flags: i64| { + let mut d = Dict::new(); + if let Some(s) = subtype { + d.set(b"Subtype".to_vec(), Object::name(s)); + } + d.set(b"F".to_vec(), Object::Int(flags)); + d + }; + let all = Content::DocumentAndMarkups; + assert!(prints(&doc, &annot(Some("MyCustomType"), 4), all), "an unknown type prints without Invisible"); + assert!(!prints(&doc, &annot(Some("MyCustomType"), 5), all), "Invisible + Print on an unknown type"); + assert!(!prints(&doc, &annot(None, 5), all), "no Subtype is no standard type"); + assert!(prints(&doc, &annot(Some("Square"), 5), all), "Invisible doesn't apply to a standard type"); + assert!(prints(&doc, &annot(Some("Widget"), 5), Content::FormFieldsOnly), "nor to a field"); + + // An indirect `/Subtype` (§7.3.10) is resolved: a standard type stays standard. + let mut doc = doc; + let square = doc.add(Object::name("Square")); + let mut indirect = annot(None, 5); + indirect.set(b"Subtype".to_vec(), Object::Ref(square)); + assert!(prints(&doc, &indirect, all), "an indirect standard Subtype"); +} + +/// #2707: a booklet's two-sided setting follows its subset. +#[test] +fn a_booklet_prints_both_sides_on_the_short_edge() { + assert_eq!(booklet_duplex(BookletSubset::BothSides), spool::Duplex::ShortEdge); + assert_eq!(booklet_duplex(BookletSubset::FrontOnly), spool::Duplex::Off); + assert_eq!(booklet_duplex(BookletSubset::BackOnly), spool::Duplex::Off); +} diff --git a/crates/print/src/tests/appearances.rs b/crates/print/src/tests/appearances.rs index acf8ece6f1..0d3107d1d7 100644 --- a/crates/print/src/tests/appearances.rs +++ b/crates/print/src/tests/appearances.rs @@ -85,6 +85,68 @@ fn xobject(doc: &Document, owner: &Dict, name: &[u8]) -> ObjRef { objects.as_dict().unwrap().get(name).unwrap().as_ref().unwrap() } +/// Printing keeps occupied page resource names and distinct annotation appearances intact, +/// whether the page resources are direct or inherited from the page tree. +#[test] +fn annotation_appearances_do_not_replace_page_xobjects() { + for inherited in [false, true] { + let (mut doc, ap, widget) = widget_fixture(Some(Object::name("Form")), None); + let page = pdfcraft_model::pages(&doc)[0].obj; + let mut xobjects = Dict::new(); + for (name, color) in [("PCPrA0", "1 0 0"), ("PCPrA1", "0 0 1"), ("PCPrA3", "1 0 0")] { + let dict = Dict::from_iter([ + (b"Type".to_vec(), Object::name("XObject")), + (b"Subtype".to_vec(), Object::name("Form")), + (b"BBox".to_vec(), numbers(&[0.0, 0.0, 10.0, 10.0])), + ]); + let form = doc.add(Object::Stream(Stream::flate(dict, format!("{color} rg 0 0 10 10 re f").as_bytes()))); + xobjects.set(name.as_bytes().to_vec(), Object::Ref(form)); + } + let xobjects = doc.add(Object::Dict(xobjects)); + let resources = doc.add(Object::Dict(Dict::from_iter([(b"XObject".to_vec(), Object::Ref(xobjects))]))); + let contents = doc.add(Object::Stream(Stream::flate( + Dict::new(), + b"q 1 0 0 1 60 20 cm /PCPrA0 Do Q\nq 1 0 0 1 70 20 cm /PCPrA1 Do Q\nq 1 0 0 1 80 20 cm /PCPrA3 Do Q", + ))); + let second_ap = doc.add(Object::Stream(Stream::flate(doc.get(ap).as_dict().unwrap().clone(), b"/Ink cs 1 1 0 scn 0 0 20 10 re f"))); + let mut second_widget = doc.get(widget).as_dict().unwrap().clone(); + second_widget.set(b"Rect".to_vec(), numbers(&[10.0, 50.0, 50.0, 70.0])); + second_widget.set(b"AP".to_vec(), Object::Dict(Dict::from_iter([(b"N".to_vec(), Object::Ref(second_ap))]))); + let second_widget = doc.add(Object::Dict(second_widget)); + doc.update_dict(page, |d| { + d.set(b"Resources".to_vec(), Object::Ref(resources)); + d.set(b"Contents".to_vec(), Object::Ref(contents)); + d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(widget), Object::Ref(second_widget)])); + }) + .unwrap(); + if inherited { + let parent = doc.get(page).as_dict().unwrap().reference(b"Parent").unwrap(); + doc.update_dict(parent, |d| d.set(b"Resources".to_vec(), Object::Ref(resources))).unwrap(); + doc.update_dict(page, |d| { + d.remove(b"Resources"); + }) + .unwrap(); + } + let before = saved(&doc); + let source = render(before.clone()); + for (x, color) in [(65, [255, 0, 0, 255]), (75, [0, 0, 255, 255]), (85, [255, 0, 0, 255])] { + assert_eq!(pixel(&source, x, 75), color, "source page artwork"); + } + for (y, color) in [(40, [255, 255, 0, 255]), (70, [0, 255, 0, 255])] { + assert_eq!(pixel(&source, 30, y), color, "source widget appearance"); + } + let printed = render(impose(&doc, &print_settings(Content::Document)).unwrap()); + for (x, color) in [(65, [255, 0, 0, 255]), (75, [0, 0, 255, 255]), (85, [255, 0, 0, 255])] { + assert_eq!(pixel(&printed, x, 75), color, "printed page artwork, inherited={inherited}"); + } + for (y, color) in [(40, [255, 255, 0, 255]), (70, [0, 255, 0, 255])] { + assert_eq!(pixel(&printed, 30, y), color, "printed widget appearance, inherited={inherited}"); + } + assert_eq!(printed.rgba, source.rgba, "printing must retain page artwork and both appearances, inherited={inherited}"); + assert_eq!(saved(&doc), before, "printing must leave the source resources unchanged"); + } +} + #[test] fn missing_subtype_appearance_survives_vector_print() { let (control, _, _) = widget_fixture(Some(Object::name("Form")), None); diff --git a/crates/redact/README.md b/crates/redact/README.md index cca3d51fdc..34c9f4fe1f 100644 --- a/crates/redact/README.md +++ b/crates/redact/README.md @@ -89,6 +89,10 @@ read in full is refused, not cleaned in part. Both make the next save a full rew `patterns` finds phone numbers, e-mail addresses, credit card numbers (Luhn-checked), US Social Security numbers and dates for Search & Redact. +A phone label such as `Tel.` or `Fax:` also enables Italian international geographic numbers with a slash, such as `Tel. +39 0426/123456` or `Fax: +390426/123456` (#3205 follow-up). +The country code and national number are matched together, with the significant initial `0` and 6 to 11 national digits described in the [AGCOM numbering plan published by ITU](https://www.itu.int/dms_pub/itu-t/oth/02/02/t020200006b0001pdfe.pdf). +This narrow case accepts at most one space after `+39` and one slash immediately after the area code; international slash numbers without a phone label, or with country codes other than +39, remain unsupported. +Patterns are heuristic: dates, protocol identifiers and longer number runs are retained, and users still need to review the marks before applying them. `codes` holds the redaction code sets (U.S. FOIA and U.S. Privacy Act exemptions): `CodeSet::from_id("foia")?.overlay(&["(b)(6)"])` gives the overlay text for the picked codes. diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index 5ac6c24ea9..ffd59a26f6 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -26,7 +26,19 @@ use pdfcraft_cos::{CosError, DecodeBudget, Dict, Document, MAX_PAGE_FORMS, ObjRe use crate::{RedactError, Report, image, page_streams, tags}; use pdfcraft_fonts::pdf::Metrics; -const MAX_DEPTH: usize = 12; +/// How deep this pass follows form XObjects. A form past it is removed rather than walked, which +/// is the fail-safe choice for a redaction -- marked content inside a form we cannot read would +/// otherwise survive -- but it also removes whatever else that form drew, marked or not. So the +/// limit has to reach as deep as anything the reader can see: the renderer follows forms to 50 +/// (`MAX_NESTED_INTERPRETATION_DEPTH`) and so does Edit text's reader (`text::MAX_FORM_DEPTH`). +/// At 12, a page with a 13-deep chain lost the text of its innermost form even with nothing in it +/// marked (#3645); past 50 nothing is drawn by any of the three, so removing it costs nothing +/// visible. +pub(crate) const MAX_DEPTH: usize = 50; + +// A form a reader still shows must be walkable here, or a redaction removes content that is +// visible; the build says so rather than a test, so the two limits cannot drift apart. +const _: () = assert!(MAX_DEPTH >= pdfcraft_edit::text::MAX_FORM_DEPTH); #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum Mode { @@ -443,8 +455,8 @@ fn content_draws(doc: &Document, content: &[Vec], xo: &Dict, name: &[u8], de if depth >= MAX_DEPTH { return None; } - let Ok(_data) = s.decoded() else { return None }; - match content_draws(doc, content, xo, name, depth + 1, seen) { + let Ok(data) = s.decoded() else { return None }; + match content_draws(doc, std::slice::from_ref(&data), xo, name, depth + 1, seen) { Some(false) => {} other => return other, } @@ -1269,6 +1281,9 @@ fn xobject( { return None; } + // Too deep: this form cannot be read, so it goes (Apply) or the page is left alone + // (Verify), as a form that will not decode does. The page's decode budget below is what + // keeps a fan-out or a cycle from costing 2^50 walks. if scope.depth >= MAX_DEPTH { if scope.mode == Mode::Verify { // Sanitization removes a Form it cannot enter, so the verification scan diff --git a/crates/redact/src/patterns.rs b/crates/redact/src/patterns.rs index 88e85fb463..59e623a7e7 100644 --- a/crates/redact/src/patterns.rs +++ b/crates/redact/src/patterns.rs @@ -158,15 +158,30 @@ fn phone(s: &[char], i: usize) -> Option { if seps == 0 && !labelled(s, i) || digit(s.get(end)) || run_continues(s, end.checked_sub(1)?, 1, &SEPS) { return None; } - // Only an area code with a trunk 0 takes a slash, so "2024/123456" is no phone number; and - // only one, right after it ("0426/123456", not "0426/12/3456" or "0426 12/3456"). let slash = run.contains(&'/'); - if run.iter().filter(|c| **c == '/').count() > 1 || slash && !run.iter().take_while(|c| **c != '/').all(char::is_ascii_digit) { + // Italian geographic numbers retain their initial 0 after +39. For a labelled number, + // apply the national slash rule after that country code and an optional separating space. + // AGCOM's E.164 plan gives geographic numbers 6 to 11 national digits (#3205). + let slash_run = if plus && slash { + if !labelled(s, i) || !(8..=13).contains(&n) { + return None; + } + let national = run.strip_prefix(&['3', '9'])?; + national.strip_prefix(&[' ']).unwrap_or(national) + } else { + run + }; + // Only an area code beginning with 0 takes a slash, so "2024/123456" is no phone number; + // and only one, right after it (not "0426/12/3456" or "0426 12/3456"). + if run.iter().filter(|c| **c == '/').count() > 1 + || slash + && (slash_run.first() != Some(&'0') || !digit(slash_run.get(1)) || !slash_run.iter().take_while(|c| **c != '/').all(char::is_ascii_digit)) + { return None; } let ok = if plus { // E.164: a `+` country code and up to 15 digits in all ("+49 2151 123456"). - !slash && (8..=15).contains(&n) + (8..=15).contains(&n) } else { // North American: 10 digits, 11 with a country code ("(555) 123-4567"). let nanp = (10..=11).contains(&n) && !slash; @@ -505,6 +520,39 @@ mod tests { ); } + #[test] + /// Labelled Italian geographic numbers match completely while dates and other digit runs stay unmarked. + fn labelled_italian_international_slash_numbers_match_whole() { + for (text, number) in [ + ("tel. +39 0426/123456", "+39 0426/123456"), + ("Fax: +390426/123456", "+390426/123456"), + ("Telefono +39 02/12345678", "+39 02/12345678"), + ("tel. +39 02/1234", "+39 02/1234"), + ("tel. +39 0426/1234567", "+39 0426/1234567"), + ] { + assert_eq!(found(Pattern::Phone, text), [number], "{text}"); + } + for text in [ + "+39 0426/123456", + "tel. +39 2024/123456", + "tel. +39 0/123456789", + "tel. +39 0426/12/3456", + "tel. +39 0426 12/3456", + "tel. +39 0426/ 123456", + "tel. +39 02/123", + "tel. +39 0426/12345678", + "tel. +39 0426/1234567890", + "tel. +39 0426/123456", + "tel. +39-0426/123456", + "tel. +39 -0426/123456", + "tel. +40 0426/123456", + "protocol 2024/123456", + "on 01/10/2026", + ] { + assert!(found(Pattern::Phone, text).is_empty(), "{text}"); + } + } + #[test] fn the_tail_of_a_longer_number_is_not_a_phone_number() { // #1884: the last groups of a French social security number, an IBAN and a SIRET used diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index b952377105..9ffb07e6f8 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -289,6 +289,110 @@ fn vectors_are_removed_or_clipped_and_inline_images_go() { assert!(c.contains("150 150 m"), "the line elsewhere stays"); } +/// A chain of nested forms deeper than the pass can walk was removed from the page, taking +/// whatever else it drew with it even when nothing in it was marked. The limit now reaches as +/// deep as the renderer and Edit text read (50), so anything a reader can see can be walked; +/// past that nothing is drawn either, so removing it costs nothing visible (#3645). +#[test] +fn a_deeply_nested_form_keeps_the_text_nobody_marked() { + /// A page that draws `HEAD` at the top, then a chain of `depth` forms whose innermost draws + /// `DEEP`, then `TAIL`. Only `HEAD` is marked. + fn page_with_chain(depth: usize) -> Document { + let first = 7; + let mut objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Fm1 {first} 0 R >> >> >>" + ) + .into_bytes(), + stream("", b"BT /F1 10 Tf 10 280 Td (HEAD) Tj ET /Fm1 Do BT /F1 10 Tf 10 10 Td (TAIL) Tj ET"), + FONT.replace("95 0 R", "6 0 R").into_bytes(), + widths(), + ]; + for k in 1..=depth { + let res = if k < depth { + format!("/Resources << /Font << /F1 5 0 R >> /XObject << /Fm{} {} 0 R >> >>", k + 1, first + k) + } else { + "/Resources << /Font << /F1 5 0 R >> >>".to_string() + }; + let body: Vec = if k < depth { format!("/Fm{} Do", k + 1).into_bytes() } else { b"BT /F1 10 Tf 10 150 Td (DEEP) Tj ET".to_vec() }; + objs.push(stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 300 300] {res}"), &body)); + } + pdf(objs) + } + + // Deeper than the old limit of 12 and within the new one: the innermost text survives. + for depth in [1, 12, 13, 20, 50] { + let mut doc = page_with_chain(depth); + mark(&mut doc, 0, &[[5.0, 275.0, 60.0, 295.0]], ""); + let report = apply(&mut doc, None).unwrap(); + assert_eq!(report.forms_removed, 0, "depth {depth}: a form was removed"); + let c = content(&doc, 0); + assert!(c.contains("(TAIL)"), "depth {depth}: {c}"); + // The innermost form's text is still in the saved file. + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert!(saved.windows(6).any(|w| w == b"(DEEP)"), "depth {depth}: the innermost form's text was deleted"); + } + + // Past what any reader follows, the form is still removed: the fail-safe, and nothing the + // renderer draws is lost because it stops at 50 too. + let mut doc = page_with_chain(crate::interp::MAX_DEPTH + 1); + mark(&mut doc, 0, &[[5.0, 275.0, 60.0, 295.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().forms_removed, 1, "a chain past the limit is removed"); +} + +/// Depth alone does not bound the walk: a form that draws the next level twice, or a cycle of two +/// forms, costs 2^depth interpretations, which at a depth limit of 50 is a hang on a file anyone +/// can send. The pass also counts the forms it enters, and a page past that budget is treated as +/// one it cannot read. +#[test] +fn a_form_that_fans_out_or_loops_is_bounded_by_the_page_budget() { + /// A page drawing `Fm1`, where each of `n` forms draws the next **twice** and the last draws + /// text: 2^n paths if nothing stops the walk. With `looping`, the last form draws the first + /// one again instead, so the chain has no end at all. + fn fan_out(n: usize, looping: bool) -> Document { + let first = 7; + let mut objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Fm1 {first} 0 R >> >> >>" + ) + .into_bytes(), + stream("", b"BT /F1 10 Tf 10 280 Td (HEAD) Tj ET /Fm1 Do"), + FONT.replace("95 0 R", "6 0 R").into_bytes(), + widths(), + ]; + for k in 1..=n { + let (next, body) = if k < n { + (first + k, format!("/Fm{0} Do /Fm{0} Do", k + 1)) + } else if looping { + (first, "/Fm1 Do /Fm1 Do".to_string()) + } else { + (first, "BT /F1 10 Tf 10 150 Td (DEEP) Tj ET".to_string()) + }; + let name = if k < n { format!("/Fm{}", k + 1) } else { "/Fm1".to_string() }; + let res = format!("/Resources << /Font << /F1 5 0 R >> /XObject << {name} {next} 0 R >> >>"); + objs.push(stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 300 300] {res}"), body.as_bytes())); + } + pdf(objs) + } + + for looping in [false, true] { + let start = std::time::Instant::now(); + let mut doc = fan_out(crate::interp::MAX_DEPTH, looping); + mark(&mut doc, 0, &[[5.0, 275.0, 60.0, 295.0]], ""); + let result = apply(&mut doc, None); + let took = start.elapsed(); + // An unbounded walk would never end; the limit leaves room for a loaded test machine. + assert!(took < std::time::Duration::from_secs(120), "looping {looping}: the walk took {took:?}"); + // The page's form budget stops the walk, and Apply refuses the page rather than + // removing forms it could not read. + assert!(result.is_err(), "looping {looping}: the page past its form budget is refused"); + } +} + #[test] fn shared_form_xobjects_are_copied_not_changed() { let form = @@ -720,19 +824,19 @@ fn form_chain_at_max_depth_is_counted_and_removed_by_hidden_layer_sanitization() use crate::sanitize::{Hidden, remove_hidden, scan}; const PRIVATE: &[u8] = b"PRIVATE_FORM_BEYOND_MAX_DEPTH"; - // MAX_DEPTH is 12. The final Form is encountered at that depth and is removed - // conservatively without being entered. Verify must count that removal or sanitize - // can skip the HiddenLayers pass entirely. + // The final Form is encountered at MAX_DEPTH and is removed conservatively without being + // entered. Verify must count that removal or sanitize can skip the HiddenLayers pass entirely. + let max = crate::interp::MAX_DEPTH; let mut forms = Vec::new(); - for depth in 0..=12 { - let content = if depth == 12 { + for depth in 0..=max { + let content = if depth == max { b"BT /F1 10 Tf 10 100 Td (PRIVATE_FORM_BEYOND_MAX_DEPTH) Tj ET".to_vec() } else { format!("/F{} Do", depth + 1).into_bytes() }; forms.push(stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", &content)); } - let xobjects = (0..=12).map(|depth| format!("/F{depth} {} 0 R", 7 + depth)).collect::>().join(" "); + let xobjects = (0..=max).map(|depth| format!("/F{depth} {} 0 R", 7 + depth)).collect::>().join(" "); let mut doc = one_page(b"/F0 Do", &format!("/XObject << {xobjects} >>"), forms); assert!( write_full(&doc, &SaveOptions::default()).unwrap().windows(PRIVATE.len()).any(|w| w == PRIVATE), diff --git a/crates/redact/src/tests/surviving_references.rs b/crates/redact/src/tests/surviving_references.rs index d3aa98af67..a297ded2a4 100644 --- a/crates/redact/src/tests/surviving_references.rs +++ b/crates/redact/src/tests/surviving_references.rs @@ -66,3 +66,42 @@ fn an_image_a_surviving_form_draws_is_kept() { assert_eq!(apply(&mut doc, None).unwrap().images_removed, 1); assert!(full_save_holds(&doc, b"IMAGESECRET"), "the image the form still draws outside the mark"); } + +/// The surviving form draws the image through a nested form without /Resources of its own, +/// which draws through the outer form's: the image is kept. +#[test] +fn an_image_a_surviving_forms_nested_form_draws_is_kept() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(), + stream("", b"q 100 0 0 100 0 0 cm /Im Do Q /Fm Do"), + b"<< /XObject << /Im 6 0 R /Fm 7 0 R /Inner 8 0 R >> >>".to_vec(), + stream("/Type /XObject /Subtype /Image /Width 11 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"IMAGESECRET"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources 5 0 R", b"/Inner Do"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"q 50 0 0 50 200 200 cm /Im Do Q"), + ]); + mark(&mut doc, 0, &[[0.0, 0.0, 120.0, 120.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_removed, 1); + assert!(full_save_holds(&doc, b"IMAGESECRET"), "the image the nested form still draws outside the mark"); +} + +/// Two pages share one resource dictionary, and the second draws the image only through a form +/// without /Resources: redacting the image on the first page keeps it for the second. +#[test] +fn an_image_another_page_draws_through_a_form_without_resources_is_kept() { + let mut doc = pdf(vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 5 0 R /Resources 7 0 R >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 6 0 R /Resources 7 0 R >>".to_vec(), + stream("", b"q 100 0 0 100 0 0 cm /Im Do Q"), + stream("", b"/Inner Do"), + b"<< /XObject << /Im 8 0 R /Inner 9 0 R >> >>".to_vec(), + stream("/Type /XObject /Subtype /Image /Width 11 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", b"IMAGESECRET"), + stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"q 50 0 0 50 200 200 cm /Im Do Q"), + ]); + mark(&mut doc, 0, &[[0.0, 0.0, 120.0, 120.0]], ""); + assert_eq!(apply(&mut doc, None).unwrap().images_removed, 1); + assert!(full_save_holds(&doc, b"IMAGESECRET"), "the second page still draws the image through its form"); +} diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 0840e8938e..71b672e547 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -111,6 +111,10 @@ pub struct DocInfo { pub attachments: Vec, /// Pages with a presentation transition (`/Trans`) or display time (`/Dur`), in page order. pub transitions: Vec, + /// Where the document opens (`/OpenAction`): the (0-based page, view) of its destination, + /// named destinations resolved. `None` without one, for an action that goes nowhere in this + /// document (a script, say), or for one that doesn't resolve to a page of it. + pub open_dest: Option<(usize, DestView)>, pub warnings: Vec, } @@ -402,6 +406,9 @@ pub struct OutlineItem { /// Where on `page` the bookmark goes ([`DestView::Top`] for a bookmark without a page). pub view: DestView, pub children: Vec, + /// Whether the bookmark starts open: only a positive `/Count` says so (ISO 32000-2 §12.3.3, + /// Table 151). A negative one is closed, and so is a missing one, which a bookmark with + /// children should not have. pub open: bool, /// The title's colour (`/C`, DeviceRGB, ISO 32000-2 §12.3.3 Table 151), or `None` for the /// default black or a value that isn't three numbers. @@ -445,6 +452,10 @@ pub struct Annotation { /// A Fill & Sign mark: typed text, a check, cross, dot or line, or a typed, drawn or picture /// signature or initials (the rules of `pdfcraft_annot::is_fill_sign`). pub fill_sign: bool, + /// The NoRotate flag (`/F` bit 5), set on sticky notes and attachments: on a turned page the + /// appearance stays upright, turned about the upper-left corner of `rect` + /// ([`Self::shown_rect`]). + pub no_rotate: bool, /// The points that shape a line (its two ends, `/L`) or connected lines, a polygon or a /// cloud (their corners, `/Vertices`), in user space; empty for other comments. pub points: Vec<[f32; 2]>, @@ -472,6 +483,58 @@ impl Annotation { 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 + /// use this, so they match the drawing. + pub fn shown_rect(&self, rotation: u16) -> [f32; 4] { + self.shown(self.rect.map(f64::from), rotation).map(|v| v as f32) + } + + /// [`Self::shown_rect`] for this comment with its `/Rect` at `rect` (one being moved or + /// resized). + pub fn shown(&self, rect: [f64; 4], rotation: u16) -> [f64; 4] { + if self.no_rotate { upright_rect(rect, rotation) } else { rect } + } + + /// The `/Rect` that shows this comment in the user-space box `shown`: the inverse of + /// [`Self::shown`], for a box resized as drawn. + pub fn rect_for_shown(&self, shown: [f64; 4], rotation: u16) -> [f64; 4] { + if self.no_rotate { rect_from_upright(shown, rotation) } else { shown } + } +} + +/// The user-space box a NoRotate annotation with `/Rect` `rect` is drawn in on a page turned +/// clockwise by `rotation` (ISO 32000-2 §12.5.3): `rect` turned as far counterclockwise about its +/// upper-left corner `(x0, y1)`, which stays where it is on the page. As displayed, the box's +/// top-left is that corner, the rectangle's width runs across and its height down, as on an +/// unturned page. `rect` itself for 0 or anything that isn't a quarter turn. The vendored +/// renderer draws the appearance there (hayro-interpret patch 37). +fn upright_rect(rect: [f64; 4], rotation: u16) -> [f64; 4] { + let [x0, y0, x1, y1] = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; + let (w, h) = (x1 - x0, y1 - y0); + match rotation { + 90 => [x0, y1, x0 + h, y1 + w], + 180 => [x0 - w, y1, x0, y1 + h], + 270 => [x0 - h, y1 - w, x0, y1], + _ => [x0, y0, x1, y1], + } +} + +/// The `/Rect` whose [`upright_rect`] is `shown`: `shown` turned back about the corner that +/// stays put. +fn rect_from_upright(shown: [f64; 4], rotation: u16) -> [f64; 4] { + let [s0, s1, s2, s3] = [shown[0].min(shown[2]), shown[1].min(shown[3]), shown[0].max(shown[2]), shown[1].max(shown[3])]; + let (sw, sh) = (s2 - s0, s3 - s1); + // The upper-left corner of `/Rect` and the rectangle's width and height. + let ((x0, y1), w, h) = match rotation { + 90 => ((s0, s1), sh, sw), + 180 => ((s2, s1), sw, sh), + 270 => ((s2, s3), sh, sw), + _ => return [s0, s1, s2, s3], + }; + [x0, y1 - h, x0 + w, y1] } /// Settle the reply links of a document's annotations: a reply keeps its `parent` only when @@ -844,6 +907,25 @@ impl<'a> Inspector<'a> { { info.has_javascript = true; } + info.open_dest = catalog.get(b"OpenAction").ok().and_then(|o| self.open_dest(o)); + } + + /// The destination `/OpenAction` opens at (ISO 32000-2 §7.7.2 Table 29): an explicit + /// destination array, a `/GoTo` action's `/D`, or (leniently, since Table 29 asks for an array + /// or an action) a destination name or string; a name or string is looked up as a named + /// destination (§12.3.2.4). Any other action (a script, a remote go-to) has none here, and + /// neither has a page number past the end of the document. + fn open_dest(&self, action: &Object) -> Option<(usize, DestView)> { + let dest = match self.resolve(action) { + Object::Dictionary(d) => { + if d.get(b"S").ok().and_then(|o| self.resolve(o).as_name().ok()) != Some(&b"GoTo"[..]) { + return None; + } + d.get(b"D").ok()? + } + other => other, + }; + self.dest(dest, 0).filter(|(page, _)| *page < self.page_ids.len()) } // ── object helpers ────────────────────────────────────────────────────────────────────── @@ -1439,6 +1521,7 @@ impl<'a> Inspector<'a> { locked: d.get(b"F").ok().and_then(|f| self.resolve(f).as_i64().ok()).unwrap_or(0) & 128 != 0, intent: self.name(d, b"IT"), fill_sign, + no_rotate: d.get(b"F").is_ok_and(|f| draw_flags(self.resolve(f)) & 16 != 0), points, }; kept = kept.saturating_add(retained(&annotation)); @@ -1947,6 +2030,17 @@ fn close_outline(open: &mut Vec<(usize, OutlineItem)>, roots: &mut Vec u32 { + let f = match o { + Object::Integer(i) => *i, + Object::Real(r) => *r as i64, + _ => 0, + }; + u32::try_from(f).unwrap_or(0) +} + fn rect4(o: &Object) -> [f32; 4] { match o { Object::Array(a) if a.len() == 4 => { @@ -2402,6 +2496,56 @@ mod tests { assert_eq!(p.view_rect_to_quad([0.0, 0.0, 10.0, 5.0]), [10.0, 320.0, 20.0, 320.0, 10.0, 315.0, 20.0, 315.0]); } + /// #2583: a NoRotate comment is drawn upright with its top-left as displayed at the upper-left + /// corner of `/Rect`, its width across and its height down; a box resized as drawn maps back to + /// the `/Rect` that shows it. Other comments, and pages that aren't quarter-turned, keep `/Rect`. + #[test] + fn no_rotate_comments_are_shown_upright_from_their_corner_and_back() { + let mut note = super::Annotation { + page: 0, + subtype: "Text".into(), + author: None, + contents: None, + 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, + state: None, + quads: Vec::new(), + locked: false, + intent: None, + fill_sign: false, + no_rotate: true, + points: Vec::new(), + }; + let rect = note.rect.map(f64::from); + for rotation in [0u16, 90, 180, 270, 45] { + let (w, h) = if rotation % 180 == 90 { (140.0, 200.0) } else { (200.0, 140.0) }; + let p = super::PageInfo { width: w, height: h, label: String::new(), crop: [0.0, 0.0, 200.0, 140.0], rotation }; + let shown = note.shown_rect(rotation); + let (a, b) = (p.user_to_view(shown[0], shown[1]), p.user_to_view(shown[2], shown[3])); + let [cx, cy] = p.user_to_view(40.0, 70.0); + let want = [cx, cy, cx + 30.0, cy + 20.0]; + let got = [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]; + assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-3), "{rotation}: shown {got:?}, want {want:?}"); + assert_eq!(note.rect_for_shown(note.shown(rect, rotation), rotation), rect, "{rotation}"); + // Moved by (5, -7) and resized to 50 × 10 as drawn, back to a /Rect. + let resized = [shown[0] as f64 + 5.0, shown[1] as f64 - 7.0, 0.0, 0.0]; + let across = if rotation % 180 == 90 { [10.0, 50.0] } else { [50.0, 10.0] }; + let resized = [resized[0], resized[1], resized[0] + across[0], resized[1] + across[1]]; + let back = note.rect_for_shown(resized, rotation); + assert_eq!((back[2] - back[0], back[3] - back[1]), (50.0, 10.0), "{rotation}: {back:?}"); + assert_eq!(note.shown(back, rotation), resized, "{rotation}"); + } + note.no_rotate = false; + assert_eq!(note.shown_rect(90), note.rect); + assert_eq!(note.rect_for_shown([1.0, 2.0, 3.0, 4.0], 90), [1.0, 2.0, 3.0, 4.0]); + } + use super::*; #[test] @@ -4114,3 +4258,112 @@ mod open_action_javascript_tests { assert!(!info.has_javascript, "a /GoTo /OpenAction is not JavaScript"); } } + +#[cfg(test)] +mod open_dest_tests { + use std::sync::Arc; + + use super::{DestView, inspect}; + + /// Three 612×792 pages (objects 3, 4, 5); `catalog` is added to the catalog dictionary and + /// `extra` become objects 6, 7, …. + fn pdf(catalog: &str, extra: &[String]) -> Vec { + let mut objs = + vec![format!("<< /Type /Catalog /Pages 2 0 R {catalog} >>"), "<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 >>".to_string()]; + for _ in 0..3 { + objs.push("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>".to_string()); + } + objs.extend(extra.iter().cloned()); + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out + } + + fn open_dest(catalog: &str, extra: &[String]) -> Option<(usize, DestView)> { + inspect(Arc::new(pdf(catalog, extra)), None).expect("inspects").open_dest + } + + const XYZ: DestView = DestView::Xyz { left: Some(100.0), top: Some(500.0), zoom: Some(2.0) }; + + #[test] + fn an_explicit_destination_opens_at_its_page_and_position() { + assert_eq!(open_dest("/OpenAction [5 0 R /XYZ 100 500 2]", &[]), Some((2, XYZ))); + assert_eq!(open_dest("/OpenAction [4 0 R /FitH 400]", &[]), Some((1, DestView::FitH { top: Some(400.0) }))); + // Indirect, and a zoom of 0 keeps the zoom (Table 149). + assert_eq!( + open_dest("/OpenAction 6 0 R", &["[4 0 R /XYZ 0 792 0]".into()]), + Some((1, DestView::Xyz { left: Some(0.0), top: Some(792.0), zoom: None })) + ); + } + + #[test] + fn a_go_to_action_opens_at_its_destination_explicit_or_named() { + assert_eq!(open_dest("/OpenAction << /S /GoTo /D [5 0 R /XYZ 100 500 2] >>", &[]), Some((2, XYZ))); + // A name, through the PDF 1.1 catalog /Dests dictionary (§12.3.2.4). + assert_eq!(open_dest("/OpenAction << /S /GoTo /D /there >> /Dests << /there [5 0 R /XYZ 100 500 2] >>", &[]), Some((2, XYZ))); + // A byte string, through the /Names /Dests name tree, whose value is a dictionary with /D. + assert_eq!( + open_dest( + "/OpenAction << /S /GoTo /D (there) >> /Names << /Dests 6 0 R >>", + &["<< /Kids [7 0 R] >>".into(), "<< /Names [(here) [3 0 R /Fit] (there) << /D [5 0 R /XYZ 100 500 2] >>] >>".into()] + ), + Some((2, XYZ)) + ); + } + + #[test] + fn an_open_action_given_as_a_name_or_string_is_looked_up() { + assert_eq!(open_dest("/OpenAction /there /Dests << /there [4 0 R /Fit] >>", &[]), Some((1, DestView::Fit))); + assert_eq!( + open_dest("/OpenAction (there) /Names << /Dests << /Names [(there) [5 0 R /FitV 30]] >> >>", &[]), + Some((2, DestView::FitV { left: Some(30.0) })) + ); + } + + #[test] + fn an_open_action_that_goes_nowhere_here_has_no_destination() { + for catalog in [ + "", + "/OpenAction << /S /JavaScript /JS (app.alert(1)) >>", + // A remote go-to's /D is a page of another file. + "/OpenAction << /S /GoToR /F (other.pdf) /D [1 /Fit] >>", + "/OpenAction << /D [4 0 R /Fit] >>", + // An unknown name, and no name dictionaries at all. + "/OpenAction << /S /GoTo /D /missing >> /Dests << /there [4 0 R /Fit] >>", + "/OpenAction /missing", + // A page number past the end, and a page reference that is no page. + "/OpenAction [7 /Fit]", + "/OpenAction [2 0 R /Fit]", + "/OpenAction 42", + ] { + assert_eq!(open_dest(catalog, &[]), None, "{catalog}"); + } + } + + #[test] + fn cyclic_and_deep_named_destinations_end() { + // Names that refer to each other. + assert_eq!(open_dest("/OpenAction /a /Dests << /a /b /b /a >>", &[]), None); + assert_eq!(open_dest("/OpenAction /a /Dests << /a << /D /a >> >>", &[]), None); + // A name tree whose node is its own kid. + assert_eq!(open_dest("/OpenAction (x) /Names << /Dests 6 0 R >>", &["<< /Kids [6 0 R] >>".into()]), None); + // A name tree deeper than the depth bound: the leaf past it isn't read. + let depth = 40; + let mut nodes: Vec = (0..depth).map(|i| format!("<< /Kids [{} 0 R] >>", 7 + i)).collect(); + nodes.push("<< /Names [(x) [4 0 R /Fit]] >>".into()); + assert_eq!(open_dest("/OpenAction (x) /Names << /Dests 6 0 R >>", &nodes), None); + // A shallow one is read. + let shallow = ["<< /Kids [7 0 R] >>".to_string(), "<< /Kids [8 0 R] >>".into(), "<< /Names [(x) [4 0 R /Fit]] >>".into()]; + assert_eq!(open_dest("/OpenAction (x) /Names << /Dests 6 0 R >>", &shallow), Some((1, DestView::Fit))); + } +} diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 64e6c55969..e906144e42 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -220,6 +220,9 @@ pub struct RenderedPage { pub enum RenderWarning { /// Content was skipped after the per-page decoded-content budget was exhausted. ContentTruncated, + /// A colour could not be computed (a colour space that cannot be read, or a Separation or + /// DeviceN tint transform that cannot be evaluated) and was painted in a fallback colour. + ColourFallback, } /// Counters for work performed by a renderer or render pool. @@ -402,12 +405,15 @@ fn warning_settings(settings: &InterpreterSettings, warnings: Arc RenderWarning::ContentTruncated, + InterpreterWarning::ColorFallback => RenderWarning::ColourFallback, + InterpreterWarning::UnsupportedFont | InterpreterWarning::ImageDecodeFailure => return, + }; + let mut warnings = lock(&target); + if !warnings.contains(&warning) { + warnings.push(warning); } }), ..settings.clone() @@ -3175,6 +3181,91 @@ trailer << /Root 1 0 R >> } } + /// #2583, #1869: a sticky note carries the NoRotate flag (`/F` bit 5), so on a page with + /// `/Rotate` its icon stays upright as displayed, turned about the upper-left corner of its + /// `/Rect`, which stays where it is on the page (ISO 32000-2 §12.5.3). It was drawn turned with + /// the page, in `/Rect` as displayed (vendored hayro-interpret patch 37). A note without the + /// flag still turns with the page. `Annotation::shown_rect` is where the UI hit-tests the + /// note, and must be where it is drawn. + #[test] + fn no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages() { + // A 30 × 20 appearance: red in its top-left quarter, blue elsewhere. + let content = "0 0 1 rg 0 0 30 20 re f 1 0 0 rg 0 10 15 10 re f"; + for rotation in [0u16, 90, 180, 270] { + let objects = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 140] /Rotate {rotation} /Annots [4 0 R 5 0 R] >>"), + // NoRotate (and Print), as PdfCraft and Acrobat save notes. + "<< /Type /Annot /Subtype /Text /F 20 /Rect [40 50 70 70] /AP << /N 6 0 R >> >>".to_string(), + // The same appearance without the flag. + "<< /Type /Annot /Subtype /Text /F 4 /Rect [120 50 150 70] /AP << /N 6 0 R >> >>".to_string(), + format!("<< /Type /XObject /Subtype /Form /BBox [0 0 30 20] /Length {} >>\nstream\n{content}\nendstream", content.len()), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + let pdf = Arc::new(pdf); + let info = crate::inspect(pdf.clone(), None).expect("opens"); + let page = &info.pages[0]; + assert_eq!(page.rotation, rotation); + let mut r = PageRenderer::new(pdf, RenderConfig::default()); + let p = r.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(p.error.is_none(), "{:?}", p.error); + // Displayed-page points are device pixels at scale 1. + let px = |x: f32, y: f32| -> [u8; 4] { + let (x, y) = (x.clamp(0.0, p.width as f32 - 1.0) as u32, y.clamp(0.0, p.height as f32 - 1.0) as u32); + p.rgba[((y * p.width + x) * 4) as usize..][..4].try_into().unwrap() + }; + let red: fn([u8; 4]) -> bool = |c| c[0] > 200 && c[1] < 60 && c[2] < 60; + let blue: fn([u8; 4]) -> bool = |c| c[2] > 200 && c[0] < 60 && c[1] < 60; + let paper: fn([u8; 4]) -> bool = |c| c.iter().all(|v| *v > 240); + + // The NoRotate note: its upper-left corner (40, 70) is the top-left of an upright + // 30 × 20 icon as displayed, red quarter top-left. + let [cx, cy] = page.user_to_view(40.0, 70.0); + for ((dx, dy), want, what) in [ + ((7.5, 5.0), red, "red top-left quarter"), + ((22.5, 5.0), blue, "blue top-right"), + ((7.5, 15.0), blue, "blue bottom-left"), + ((22.5, 15.0), blue, "blue bottom-right"), + ((-3.0, 10.0), paper, "paper left of the icon"), + ((10.0, -3.0), paper, "paper above the icon"), + ((33.0, 10.0), paper, "paper right of its 30 pt width"), + ((10.0, 23.0), paper, "paper below its 20 pt height"), + ] { + let c = px(cx + dx, cy + dy); + assert!(want(c), "rotation {rotation}: NoRotate note at corner + ({dx}, {dy}) is {c:?}, want {what}"); + } + let note = info.annotations.iter().find(|a| a.no_rotate).expect("the NoRotate note is listed"); + let shown = note.shown_rect(rotation); + let (a, b) = (page.user_to_view(shown[0], shown[1]), page.user_to_view(shown[2], shown[3])); + let box_ = [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]; + let want = [cx, cy, cx + 30.0, cy + 20.0]; + assert!(box_.iter().zip(want).all(|(v, w)| (v - w).abs() < 0.01), "rotation {rotation}: shown {box_:?}, drawn {want:?}"); + + // The note without the flag turns with the page: its red quarter stays at the + // user-space top-left of /Rect, at the displayed top-left only when unturned. + let plain = info.annotations.iter().find(|a| !a.no_rotate).expect("the plain note is listed"); + assert_eq!(plain.shown_rect(rotation), plain.rect); + let [rx, ry] = page.user_to_view(127.5, 65.0); + let [bx, by] = page.user_to_view(142.5, 55.0); + assert!(red(px(rx, ry)) && blue(px(bx, by)), "rotation {rotation}: the plain note's appearance turns with the page"); + let (a, b) = (page.user_to_view(120.0, 50.0), page.user_to_view(150.0, 70.0)); + let corner = px(a[0].min(b[0]) + 4.0, a[1].min(b[1]) + 4.0); + assert_eq!(red(corner), rotation == 0, "rotation {rotation}: displayed top-left of the plain note is {corner:?}"); + } + } + /// #1968: the highlight appearance other editors write, a transparency group whose content is /// filled with `/BM /Multiply`, drew as an opaque block over the text. Every `/Group` form /// got a layer of its own, so the Multiply blended with that layer's transparent backdrop @@ -3343,6 +3434,40 @@ trailer << /Root 1 0 R >> assert_eq!(px(&hidden, 20, 80), vec![255, 0, 0, 255], "fields stay"); } + /// ISO 32000-2 Table 167: Invisible hides an annotation of a type that isn't standard and + /// nothing else; §12.5.6.14: a popup has no appearance of its own, so its `/AP` isn't painted. + #[test] + fn invisible_unknown_annotations_and_popups_paint_nothing() { + let annots = [ + "/Subtype /MyCustomType /F 0", + "/Subtype /MyCustomType /F 1", + "/Subtype /MyCustomType /F 5", + "/F 1", + "/Subtype /Square /F 1", + "/Subtype /Popup /F 4 /Open true", + ]; + let mut pdf = String::from("%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n"); + pdf.push_str("2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n"); + let refs: Vec = (0..annots.len()).map(|i| format!("{} 0 R", 10 + i)).collect(); + pdf.push_str(&format!("3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 50] /Annots [{}] >> endobj\n", refs.join(" "))); + pdf.push_str("4 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 30 30] /Length 24 >> stream\n0 1 0 rg 0 0 30 30 re f\nendstream endobj\n"); + for (i, a) in annots.iter().enumerate() { + let x = 10 + 50 * i; + pdf.push_str(&format!("{} 0 obj << /Type /Annot {a} /Rect [{x} 10 {} 40] /AP << /N 4 0 R >> >> endobj\n", 10 + i, x + 30)); + } + pdf.push_str("trailer << /Root 1 0 R >>\n%%EOF"); + let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + let px = |x: u32| p.rgba[((25 * p.width + x) * 4) as usize..][..4].to_vec(); + let (green, white) = (vec![0, 255, 0, 255], vec![255, 255, 255, 255]); + assert_eq!(px(25), green, "an unknown type without Invisible is drawn"); + assert_eq!(px(75), white, "Invisible hides an unknown type"); + assert_eq!(px(125), white, "even with the Print flag"); + assert_eq!(px(175), white, "no Subtype is no standard type"); + assert_eq!(px(225), green, "Invisible means nothing for a standard type"); + assert_eq!(px(275), white, "a popup's appearance is not painted on the page"); + } + #[test] fn inline_pool_renders_in_priority_order() { let pool = RenderPool::new_inline(Arc::new(ONE_PAGE.to_vec()), RenderConfig::default()); @@ -7439,6 +7564,9 @@ endstream endobj // ICC profiles whose stream dictionaries are the same (vendored hayro-interpret patch 48). mod icc_profile_cache; + // A symbolic TrueType font draws through its (3, 0) cmap subtable first (#4156). + mod symbolic_truetype_cmap; + /// Render `pdf`'s first page at 72 dpi with no time limit beyond the test's own. fn render_first_page(pdf: Vec) -> RenderedPage { let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); @@ -8118,13 +8246,15 @@ trailer << /Root 1 0 R >> } } - /// ISO 32000-1 §9.3.6: only text rendering mode 3 has any effect on a Type 3 font (vendored - /// hayro-interpret patch in `show_glyph`). Print-stream PDFs set `2 Tr` for Type 3 bitmap - /// fonts, and the glyph procedure ran twice (fill, then stroke), so its anti-aliased edges - /// doubled up and the text looked bold; under `1 Tr` an uncoloured glyph took the stroking - /// colour, and under `7 Tr` it disappeared. Both glyph kinds have edges between pixels. + /// ISO 32000-2 §9.3.6: text in a Type 3 font is rendered from its glyph descriptions in every + /// text rendering mode but 3 and 7 (vendored hayro-interpret patches 42 and 64 in + /// `show_glyph`). Print-stream PDFs set `2 Tr` for Type 3 bitmap fonts, and the glyph + /// procedure ran twice (fill, then stroke), so its anti-aliased edges doubled up and the text + /// looked bold; under `1 Tr` an uncoloured glyph took the stroking colour. Modes 3 and 7 + /// render nothing (`tests/it/type3_text_clip.rs` covers the clip). Both glyph kinds have + /// edges between pixels. #[test] - fn text_rendering_modes_other_than_invisible_draw_type3_glyphs_as_filled() { + fn rendering_modes_but_3_and_7_draw_type3_glyphs_as_filled() { let render = |glyph: &str, mode: u8| { let content = format!("BT 1 0 0 RG 6 w 0 g /F1 20 Tf {mode} Tr 10.3 10.3 Td (A) Tj ET"); let pdf = format!( @@ -8153,16 +8283,22 @@ trailer << /Root 1 0 R >> for glyph in ["1 0 0 0 1 1 d1 0.12 0.12 0.71 0.71 re f", "1 0 d0 0.12 0.12 0.71 0.71 re f"] { let filled = render(glyph, 0); assert!(filled.as_chunks::<4>().0.iter().any(|p| p[0] < 128), "{glyph}: the glyph paints"); - for mode in [1, 2, 4, 5, 6, 7] { + for mode in [1, 2, 4, 5, 6] { assert!(render(glyph, mode) == filled, "{glyph}: {mode} Tr draws as 0 Tr"); } - assert!(render(glyph, 3).as_chunks::<4>().0.iter().all(|p| p[..3] == [255, 255, 255]), "{glyph}: 3 Tr hides the glyph"); + for mode in [3, 7] { + assert!(render(glyph, mode).as_chunks::<4>().0.iter().all(|p| p[..3] == [255, 255, 255]), "{glyph}: {mode} Tr hides the glyph"); + } } } // #1201: /DefaultGray, /DefaultRGB and /DefaultCMYK (vendored hayro-interpret patch 38). mod default_colour_spaces; + // #4260: DeviceN colours with many inks, and tint transforms that cannot be evaluated + // (vendored hayro-syntax patch 28, hayro-interpret patch 55). + mod devicen_inks; + /// #3109 / #3116: clipping-only text (`7 Tr`) installs a clip for subsequent painting /// but draws no pixels itself. #[test] diff --git a/crates/render/src/raster/tests/devicen_inks.rs b/crates/render/src/raster/tests/devicen_inks.rs new file mode 100644 index 0000000000..e08ce77085 --- /dev/null +++ b/crates/render/src/raster/tests/devicen_inks.rs @@ -0,0 +1,123 @@ +//! #4260: a DeviceN colour with more inks than the content parser held operands for, and a +//! colour whose tint transform cannot be evaluated (ISO 32000-2 §8.6.6.5). +//! +//! The content parser kept at most ten operands per operator (vendored hayro-syntax patch 28): +//! the eleventh operand of a twelve-ink `scn` ended the whole content stream, so the page came +//! out blank with no error. A tint transform that cannot be evaluated painted the alternate +//! space's initial colour without a word (vendored hayro-interpret patch 55): it now says so. + +use super::{RenderWarning, rgb_at}; +use crate::{PageRenderer, RenderConfig, RenderRequest, RenderedPage, RequestKind}; +use std::sync::Arc; + +/// The page is 200 × 200 pt, drawn at 72 dpi. +const SIDE: usize = 200; + +const GREEN: [u8; 3] = [0, 255, 0]; +const RED: [u8; 3] = [255, 0, 0]; +const BLUE: [u8; 3] = [0, 0, 255]; +const BLACK: [u8; 3] = [0, 0, 0]; + +/// The twelve inks of the report's print dieline. +const TWELVE_INKS: &str = + "[/Cyan /Magenta /Yellow /Black /PANTONE156C /PANTONE2227C /PANTONE1565C /PANTONE2975C /PANTONE7463C /Varnish /Dieline /SGKBlack]"; + +/// The report's tint transform: all inks at 1.0 give CMYK 1 1 1 1. +const TINT: &str = "<< /FunctionType 2 /Domain [0 1] /N 1 /C0 [0 0 0 0] /C1 [1 1 1 1] >>"; + +/// Render the one page `content` draws with the colour space `CS1` defined as `cs1`. +fn render(content: &str, cs1: &str) -> RenderedPage { + render_with(content, cs1, "") +} + +/// [`render`], with `objects` (whole indirect objects from 5 0 obj on) added to the file. +fn render_with(content: &str, cs1: &str, objects: &str) -> RenderedPage { + let pdf = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /ColorSpace << /CS1 {cs1} >> >> /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + {objects}trailer << /Root 1 0 R >>\n%%EOF", + content.len() + ); + let mut renderer = PageRenderer::new(Arc::new(pdf.into_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); + assert_eq!((page.width, page.height), (SIDE as u32, SIDE as u32)); + page +} + +/// The colour at (`x`, `y`) in page points from the bottom left. +fn at(page: &RenderedPage, x: usize, y: usize) -> [u8; 3] { + rgb_at(&page.rgba, SIDE, x, SIDE - 1 - y) +} + +/// CMYK 1 1 1 1 through the renderer's CMYK profile: very dark, whatever the exact profile. +fn is_rich_black(rgb: [u8; 3]) -> bool { + rgb.iter().all(|c| *c < 48) +} + +/// The report's page: twelve inks, all at 1.0, fill the page black. A green square drawn +/// afterwards shows the rest of the content is still read. +#[test] +fn a_twelve_ink_devicen_colour_paints() { + let content = "/CS1 cs 1 1 1 1 1 1 1 1 1 1 1 1 scn 0 0 200 200 re f 0 1 0 rg 0 0 20 20 re f"; + let page = render(content, &format!("[/DeviceN {TWELVE_INKS} /DeviceCMYK {TINT}]")); + assert!(is_rich_black(at(&page, 100, 100)), "the twelve-ink fill paints: {:?}", at(&page, 100, 100)); + assert_eq!(at(&page, 10, 10), GREEN, "what follows the twelve-ink colour is drawn"); + assert!(page.warnings.is_empty(), "{:?}", page.warnings); +} + +/// The control from the report: the same colour with four inks always painted. +#[test] +fn a_four_ink_devicen_colour_paints() { + let content = "/CS1 cs 1 1 1 1 scn 0 0 200 200 re f 0 1 0 rg 0 0 20 20 re f"; + let page = render(content, &format!("[/DeviceN [/Cyan /Magenta /Yellow /Black] /DeviceCMYK {TINT}]")); + assert!(is_rich_black(at(&page, 100, 100)), "the four-ink fill paints: {:?}", at(&page, 100, 100)); + assert_eq!(at(&page, 10, 10), GREEN); + assert!(page.warnings.is_empty(), "{:?}", page.warnings); +} + +/// An operator with more operands than the parser keeps still reads the ones nearest to it, +/// as one with a few stray operands does, and the content after it is drawn. +#[test] +fn an_operator_with_hundreds_of_operands_does_not_end_the_content() { + let strays = "0 ".repeat(300); + let content = format!("1 0 0 rg 0 0 200 200 re f {strays}0 1 0 rg 0 0 20 20 re f"); + let page = render(&content, "/DeviceRGB"); + assert_eq!(at(&page, 100, 100), RED, "the content before the stray operands is drawn"); + assert_eq!(at(&page, 10, 10), GREEN, "the operator after them reads its own three operands"); +} + +/// A tint transform that fails when it runs (a calculator function that pops more than it is +/// given) paints that one colour in the alternate space's initial colour (black in DeviceRGB) +/// and records a warning; the paints before and after it are drawn as usual. +#[test] +fn a_tint_transform_that_cannot_be_evaluated_draws_the_rest_of_the_page_and_warns() { + let program = "{ pop pop pop }"; + let function = format!( + "5 0 obj << /FunctionType 4 /Domain [0 1 0 1] /Range [0 1 0 1 0 1] /Length {} >> stream\n{program}\nendstream endobj\n", + program.len() + ); + let content = "1 0 0 rg 0 0 100 200 re f /CS1 cs 0.5 0.5 scn 100 0 100 200 re f 0 0 1 rg 0 0 50 50 re f"; + let page = render_with(content, "[/DeviceN [/Spot1 /Spot2] /DeviceRGB 5 0 R]", &function); + assert_eq!(at(&page, 75, 150), RED, "the paint before the colour is drawn"); + assert_eq!(at(&page, 150, 150), BLACK, "the colour falls back to the alternate space's initial colour"); + assert_eq!(at(&page, 25, 25), BLUE, "the paint after the colour is drawn"); + assert!(page.warnings.contains(&RenderWarning::ColourFallback), "{:?}", page.warnings); +} + +/// A tint transform that cannot even be read (an unknown function type) keeps the colour space, +/// so its paints fall back the same way, with the same warning, instead of being drawn in +/// DeviceGray from the first tint (white here). +#[test] +fn a_tint_transform_that_cannot_be_read_falls_back_and_warns() { + let cs1 = format!("[/DeviceN {TWELVE_INKS} /DeviceCMYK << /FunctionType 7 /Domain [0 1] >>]"); + let content = "/CS1 cs 1 1 1 1 1 1 1 1 1 1 1 1 scn 0 0 200 200 re f 0 1 0 rg 0 0 20 20 re f"; + let page = render(content, &cs1); + assert!(is_rich_black(at(&page, 100, 100)), "the colour falls back to CMYK black: {:?}", at(&page, 100, 100)); + assert_eq!(at(&page, 10, 10), GREEN, "what follows is drawn"); + assert!(page.warnings.contains(&RenderWarning::ColourFallback), "{:?}", page.warnings); + // Reported once per page, however many paints fell back. + assert_eq!(page.warnings.iter().filter(|w| **w == RenderWarning::ColourFallback).count(), 1); +} diff --git a/crates/render/src/raster/tests/symbolic_truetype_cmap.rs b/crates/render/src/raster/tests/symbolic_truetype_cmap.rs new file mode 100644 index 0000000000..f66aff1da1 --- /dev/null +++ b/crates/render/src/raster/tests/symbolic_truetype_cmap.rs @@ -0,0 +1,162 @@ +//! A symbolic TrueType font draws through its (3, 0) `cmap` subtable before its (1, 0) one +//! (vendored hayro-interpret patch, #4156; ISO 32000-2 §9.6.5.4: "If the font contains a (3, 0) +//! subtable" it is used, "Otherwise, if the font contains a (1, 0) subtable"). SAS writes AMT +//! fonts whose (1, 0) table is wrong, so "8" drew as "p" and "+" as "V". +//! +//! The font is built here, in memory: three glyphs (`.notdef`, an empty one and a filled square), +//! a (1, 0) subtable mapping `A` to the empty glyph and a (3, 0) subtable mapping it (as U+F041) +//! to the square. No font file is involved. + +use super::render_first_page; + +fn be16(out: &mut Vec, n: u16) { + out.extend_from_slice(&n.to_be_bytes()); +} + +fn be32(out: &mut Vec, n: u32) { + out.extend_from_slice(&n.to_be_bytes()); +} + +/// A TrueType font whose (1, 0) and (3, 0) subtables disagree about the code `A`. +fn disagreeing_font() -> Vec { + // Glyph 2: one contour, the square 0..1000 on both axes (glyph 0 and 1 are empty). + let mut square = Vec::new(); + for n in [1i16, 0, 0, 1000, 1000] { + out16(&mut square, n); + } + be16(&mut square, 3); // the contour's last point + be16(&mut square, 0); // no instructions + square.extend([1u8; 4]); // four on-curve points, two-byte coordinates + for dx in [0i16, 1000, 0, -1000] { + out16(&mut square, dx); + } + for dy in [0i16, 0, 1000, 0] { + out16(&mut square, dy); + } + let glyf = square.clone(); + let mut loca = Vec::new(); + for offset in [0u16, 0, 0, (glyf.len() / 2) as u16] { + be16(&mut loca, offset); + } + + let mut head = vec![0; 54]; + head[0..4].copy_from_slice(&[0, 1, 0, 0]); + head[12..16].copy_from_slice(&0x5f0f_3cf5u32.to_be_bytes()); + head[18..20].copy_from_slice(&1000u16.to_be_bytes()); + head[40..42].copy_from_slice(&1000i16.to_be_bytes()); + head[42..44].copy_from_slice(&1000i16.to_be_bytes()); + let mut hhea = vec![0; 36]; + hhea[0..4].copy_from_slice(&[0, 1, 0, 0]); + hhea[4..6].copy_from_slice(&1000i16.to_be_bytes()); + hhea[34..36].copy_from_slice(&3u16.to_be_bytes()); + let mut hmtx = Vec::new(); + for _ in 0..3 { + be16(&mut hmtx, 1000); + be16(&mut hmtx, 0); + } + let mut maxp = vec![0, 1, 0, 0]; + be16(&mut maxp, 3); + maxp.resize(32, 0); + + // (1, 0), format 0: `A` → glyph 1 (empty), and `B`, which the (3, 0) table leaves out, → + // glyph 2. + let mut mac = Vec::new(); + be16(&mut mac, 0); + be16(&mut mac, 262); + be16(&mut mac, 0); + let mut ids = [0u8; 256]; + ids[usize::from(b'A')] = 1; + ids[usize::from(b'B')] = 2; + mac.extend(ids); + // (3, 0), format 4: U+F041 → glyph 2 (the square). + let mut win = Vec::new(); + be16(&mut win, 4); + be16(&mut win, 32); + be16(&mut win, 0); + for n in [4u16, 4, 1, 0] { + be16(&mut win, n); // segCountX2, searchRange, entrySelector, rangeShift + } + for n in [0xF041u16, 0xFFFF, 0, 0xF041, 0xFFFF, 2u16.wrapping_sub(0xF041), 1, 0, 0] { + be16(&mut win, n); // endCode ×2, pad, startCode ×2, idDelta ×2, idRangeOffset ×2 + } + let mut cmap = Vec::new(); + be16(&mut cmap, 0); + be16(&mut cmap, 2); + for (platform, encoding, offset) in [(1u16, 0u16, 20u32), (3, 0, 20 + mac.len() as u32)] { + be16(&mut cmap, platform); + be16(&mut cmap, encoding); + be32(&mut cmap, offset); + } + cmap.extend(mac); + cmap.extend(win); + + let tables = [(*b"cmap", cmap), (*b"glyf", glyf), (*b"head", head), (*b"hhea", hhea), (*b"hmtx", hmtx), (*b"loca", loca), (*b"maxp", maxp)]; + let mut out = vec![0, 1, 0, 0]; + be16(&mut out, tables.len() as u16); + for n in [64u16, 2, 48] { + be16(&mut out, n); + } + let mut payload = Vec::new(); + let mut offset = 12 + 16 * tables.len(); + for (tag, bytes) in tables { + out.extend(tag); + let mut padded = bytes.clone(); + padded.resize(bytes.len().div_ceil(4) * 4, 0); + let sum = padded.as_chunks::<4>().0.iter().fold(0u32, |s, b| s.wrapping_add(u32::from_be_bytes(*b))); + be32(&mut out, sum); + be32(&mut out, offset as u32); + be32(&mut out, bytes.len() as u32); + offset += padded.len(); + payload.extend(padded); + } + out.extend(payload); + out +} + +fn out16(out: &mut Vec, n: i16) { + out.extend_from_slice(&n.to_be_bytes()); +} + +/// A 40 × 40 pt page that shows `text` at 30 pt from (5, 5) in the font, marked symbolic and +/// with no `/Encoding`, as SAS writes it. +fn page_with_symbolic_font(font: &[u8], text: &str) -> Vec { + let content = format!("BT /F1 30 Tf 5 5 Td ({text}) Tj ET").into_bytes(); + let objects: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>".to_vec(), + [format!("<< /Length {} >>\nstream\n", content.len()).into_bytes(), content.clone(), b"\nendstream".to_vec()].concat(), + b"<< /Type /Font /Subtype /TrueType /BaseFont /AAAAAA+Sym /FirstChar 65 /LastChar 66 /Widths [1000 1000] /FontDescriptor 6 0 R >>".to_vec(), + b"<< /Type /FontDescriptor /FontName /AAAAAA+Sym /Flags 4 /FontBBox [0 0 1000 1000] /ItalicAngle 0 /Ascent 1000 /Descent 0 /CapHeight 1000 /StemV 80 /FontFile2 7 0 R >>".to_vec(), + [format!("<< /Length {} >>\nstream\n", font.len()).into_bytes(), font.to_vec(), b"\nendstream".to_vec()].concat(), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend(format!("{} 0 obj\n", i + 1).into_bytes()); + pdf.extend(object); + pdf.extend(b"\nendobj\n"); + } + let xref = pdf.len(); + pdf.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).into_bytes()); + for offset in offsets { + pdf.extend(format!("{offset:010} 00000 n \n").into_bytes()); + } + pdf.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).into_bytes()); + pdf +} + +#[test] +fn a_symbolic_truetype_font_draws_through_its_3_0_subtable_first() { + let font = disagreeing_font(); + // The square covers (5, 5)–(35, 35) in page space: row 20 from the top is inside it. + let square_drawn = |text: &str| { + let page = render_first_page(page_with_symbolic_font(&font, text)); + let at = |x: u32, y: u32| page.rgba.get(((y * page.width + x) * 4) as usize).copied().unwrap_or(255); + assert_eq!(at(2, 2), 255, "nothing is drawn outside the square"); + at(20, 20) < 128 + }; + assert!(square_drawn("A"), "the (3, 0) subtable's square, not the (1, 0) subtable's empty glyph"); + assert!(square_drawn("B"), "a code the (3, 0) subtable leaves out still reads through (1, 0)"); +} diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 1e522b83f1..a1c2717591 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -2147,6 +2147,43 @@ mod tests { } } + /// ISO 32000-2 §9.3.6: Type 3 text under `7 Tr` is not rendered (vendored hayro-interpret + /// patch 64), but it is still clipping text, not an invisible text layer: the filter that + /// drops OCR layers keeps it, as it keeps clipping text in other fonts. + #[test] + fn clipping_mode_type3_text_is_kept_by_the_ocr_filter() { + let font = "<< /Type /Font /Subtype /Type3 /FontBBox [0 0 1000 1000] /FontMatrix [0.001 0 0 0.001 0 0] \ + /CharProcs << /A 6 0 R >> /Encoding << /Type /Encoding /Differences [65 /A] >> \ + /FirstChar 65 /LastChar 65 /Widths [1000] >>"; + let glyph = "1000 0 100 0 900 800 d1 100 0 800 800 re f"; + let content = "BT /T3 24 Tf 7 Tr 30 200 Td (AAA) Tj ET BT /T3 12 Tf 3 Tr 30 100 Td (A) Tj ET"; + 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 300 300] /Contents 4 0 R /Resources << /Font << /T3 5 0 R >> >> >>".to_string(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + font.to_string(), + format!("<< /Length {} >>\nstream\n{glyph}\nendstream", glyph.len()), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + let pdf = Pdf::new(std::sync::Arc::new(bytes)).unwrap(); + let all = extract_page_filtered(&pdf, 0, &InterpreterSettings::default(), TextFilter::default()).unwrap(); + assert_eq!(all.plain_text().matches('A').count(), 4, "{:?}", all.plain_text()); + let filtered = extract_page_filtered(&pdf, 0, &InterpreterSettings::default(), TextFilter { images: true, ..Default::default() }).unwrap(); + assert_eq!(filtered.plain_text().trim(), "AAA", "the 7 Tr text stays, the 3 Tr layer goes"); + } + /// One page per `(encoding, content)`, in Helvetica (`/F1`). fn encoded_pages(pages: &[(lopdf::Object, &[u8])]) -> Pdf { use lopdf::{Document, Object, Stream, dictionary}; diff --git a/crates/render/tests/it/cid_fallback_query.rs b/crates/render/tests/it/cid_fallback_query.rs new file mode 100644 index 0000000000..12d2f6daae --- /dev/null +++ b/crates/render/tests/it/cid_fallback_query.rs @@ -0,0 +1,94 @@ +//! The request a font resolver gets for a CIDFont the document doesn't embed carries the style +//! of the descendant's font descriptor (ISO 32000-2 §9.7.4.1, §9.7.6.1, §9.8). Contributor-original +//! synthetic fixtures. +use hayro::hayro_interpret::InterpreterSettings; +use hayro::hayro_interpret::font::{FallbackFontQuery, FontQuery}; +use hayro::hayro_interpret::hayro_cmap::CidFamily; +use hayro::hayro_syntax::Pdf; +use hayro::{RenderCache, RenderSettings, render}; +use std::sync::{Arc, Mutex}; + +/// A one-page document drawing two characters in a Type 0 font whose descendant is a +/// non-embedded CIDFontType0 with the given entries after `/CIDSystemInfo`. The name says +/// nothing about the style and matches no standard font. +fn fixture(descendant_extra: &str, descriptor: Option<&str>) -> Vec { + let mut objects = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(), + stream("", "BT /F1 24 Tf 10 40 Td <11b30ed4> Tj ET"), + "<< /Type /Font /Subtype /Type0 /BaseFont /CraftCJK-W3 /Encoding /Identity-H /DescendantFonts [6 0 R] >>".into(), + format!( + "<< /Type /Font /Subtype /CIDFontType0 /BaseFont /CraftCJK-W3 \ + /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 2 >> {descendant_extra} >>" + ), + ]; + if let Some(descriptor) = descriptor { + objects.push(descriptor.to_string()); + } + pdf(&objects) +} + +/// The fallback queries the resolver received while drawing the page. +fn fallback_queries(bytes: Vec) -> Vec { + let seen = Arc::new(Mutex::new(Vec::new())); + let sink = seen.clone(); + let settings = InterpreterSettings { + font_resolver: Arc::new(move |query| { + if let FontQuery::Fallback(f) = query { + sink.lock().unwrap().push(f.clone()); + } + None + }), + ..InterpreterSettings::default() + }; + let pdf = Pdf::new(bytes).unwrap(); + let pages = pdf.pages(); + let page = pages.first().unwrap(); + let cache = RenderCache::new(); + render(page, &cache, &settings, &RenderSettings::default()); + seen.lock().unwrap().clone() +} + +#[test] +fn a_cid_font_fallback_request_carries_its_descendants_style() { + // Flags: Serif (bit 2), Nonsymbolic (bit 6), Italic (bit 7) = 2 + 32 + 64. + let descriptor = "<< /Type /FontDescriptor /FontName /CraftCJK-W3 /Flags 98 /FontWeight 700 \ + /FontBBox [0 -120 1000 880] /ItalicAngle -12 /Ascent 880 /Descent -120 /CapHeight 700 /StemV 120 >>"; + let queries = fallback_queries(fixture("/FontDescriptor 7 0 R", Some(descriptor))); + let query = queries.first().expect("the resolver is asked for a fallback"); + assert_eq!(query.post_script_name.as_deref(), Some("CraftCJK-W3")); + assert!(query.character_collection.as_ref().is_some_and(|cc| cc.family == CidFamily::AdobeGB1), "{query:?}"); + assert!(query.is_serif, "Serif flag of the descendant's descriptor: {query:?}"); + assert!(query.is_italic, "Italic flag of the descendant's descriptor: {query:?}"); + assert_eq!(query.font_weight, 700, "/FontWeight of the descendant's descriptor: {query:?}"); +} + +#[test] +fn a_cid_font_without_a_descriptor_asks_for_the_default_style() { + let queries = fallback_queries(fixture("", None)); + let query = queries.first().expect("the resolver is asked for a fallback"); + assert_eq!(query.post_script_name.as_deref(), Some("CraftCJK-W3")); + assert!(!query.is_serif && !query.is_italic && !query.is_bold, "{query:?}"); + assert_eq!(query.font_weight, 400); +} + +fn pdf(objects: &[String]) -> Vec { + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + bytes +} + +fn stream(dict: &str, body: &str) -> String { + format!("<< {dict} /Length {} >>\nstream\n{body}\nendstream", body.len()) +} diff --git a/crates/render/tests/it/main.rs b/crates/render/tests/it/main.rs index 944fcd09be..ca9adf016e 100644 --- a/crates/render/tests/it/main.rs +++ b/crates/render/tests/it/main.rs @@ -1,6 +1,11 @@ //! The crate's integration tests, linked into one test binary. +mod cid_fallback_query; mod inline_image_ei; mod jbig2_invariants; mod named_form_properties; mod search; +mod separation_all; +mod substitute_widths; +mod tiling_cell_placement; +mod type3_text_clip; diff --git a/crates/render/tests/it/separation_all.rs b/crates/render/tests/it/separation_all.rs new file mode 100644 index 0000000000..5b519e0e3a --- /dev/null +++ b/crates/render/tests/it/separation_all.rs @@ -0,0 +1,145 @@ +//! Contributor-original synthetic pages for the special colourant names of a Separation colour +//! space (ISO 32000-2 §8.6.6.4): `/All` paints every colourant of the output device at once, so +//! on screen a tint t is the gray 1 − t whatever the alternate space and tint transform say; +//! `/None` paints nothing; any other name goes through its alternate space. +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RenderedPage}; +use std::sync::Arc; + +/// A tint transform into DeviceCMYK that gives cyan: tint t → (t, 0, 0, 0). Through it a tint +/// draws light or full cyan, never a neutral gray, so a gray result proves it was not used. +const CYAN: &str = "<< /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [1 0 0 0] /N 1 >>"; + +/// A PDF of the given objects (object 1 the catalog), with a cross-reference table. Each object +/// is its bytes: a stream's data need not be text. +fn pdf(objects: &[Vec]) -> Vec { + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + bytes.extend_from_slice(object); + bytes.extend_from_slice(b"\nendobj\n"); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + bytes +} + +fn stream(dict: &str, data: &[u8]) -> Vec { + let mut bytes = format!("<< {dict} /Length {} >>\nstream\n", data.len()).into_bytes(); + bytes.extend_from_slice(data); + bytes.extend_from_slice(b"\nendstream"); + bytes +} + +/// A 300 × 100 pt page drawing `content` with `resources`, then `extra` as objects 5 onwards. +fn page(content: &str, resources: &str, extra: &[Vec]) -> Vec { + let mut objects = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 100] /Contents 4 0 R /Resources {resources} >>").into_bytes(), + stream("", content.as_bytes()), + ]; + objects.extend_from_slice(extra); + pdf(&objects) +} + +fn render(pdf: Vec) -> RenderedPage { + let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let out = renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(out.error.is_none(), "{:?}", out.error); + assert_eq!((out.width, out.height), (300, 100)); + out +} + +/// The RGB of the pixel at (`x`, `y`) pt from the page's top left. +fn rgb(out: &RenderedPage, x: usize, y: usize) -> [u8; 3] { + let at = (y * out.width as usize + x) * 4; + let pixel = out.rgba.get(at..at + 3).expect("the pixel lies on the page"); + [pixel[0], pixel[1], pixel[2]] +} + +/// `got` is the neutral gray `want`, give or take one level of rounding. +fn assert_gray(got: [u8; 3], want: u8, what: &str) { + assert!(got.iter().all(|c| c.abs_diff(want) <= 1), "{what}: got {got:?}, want gray {want}"); +} + +/// Three boxes filled with `cs` at tints 0, 0.5 and 1, left to right. +fn three_tints(space: &str) -> RenderedPage { + let content = "/CS0 cs 0 scn 0 0 100 100 re f 0.5 scn 100 0 100 100 re f 1 scn 200 0 100 100 re f"; + render(page(content, "<< /ColorSpace << /CS0 5 0 R >> >>", &[space.as_bytes().to_vec(), CYAN.as_bytes().to_vec()])) +} + +#[test] +fn separation_all_fills_paint_the_complemented_tint_as_gray() { + let out = three_tints("[/Separation /All /DeviceCMYK 6 0 R]"); + assert_gray(rgb(&out, 50, 50), 255, "tint 0 paints no colourant: white"); + assert_gray(rgb(&out, 150, 50), 128, "tint 0.5 paints half of every colourant: 50% gray"); + assert_gray(rgb(&out, 250, 50), 0, "tint 1 paints every colourant: black"); +} + +#[test] +fn separation_all_strokes_paint_the_complemented_tint_as_gray() { + let content = "/CS0 CS 0.25 SCN 40 w 0 50 m 300 50 l S"; + let out = + render(page(content, "<< /ColorSpace << /CS0 5 0 R >> >>", &[b"[/Separation /All /DeviceCMYK 6 0 R]".to_vec(), CYAN.as_bytes().to_vec()])); + assert_gray(rgb(&out, 150, 50), 191, "a stroke at tint 0.25: 75% gray"); +} + +#[test] +fn separation_none_paints_nothing() { + // A red background, so "nothing" is told apart from white. + let content = "1 0 0 rg 0 0 300 100 re f /CS0 cs 0 scn 0 0 100 100 re f 0.5 scn 100 0 100 100 re f 1 scn 200 0 100 100 re f"; + let out = + render(page(content, "<< /ColorSpace << /CS0 5 0 R >> >>", &[b"[/Separation /None /DeviceCMYK 6 0 R]".to_vec(), CYAN.as_bytes().to_vec()])); + for x in [50, 150, 250] { + assert_eq!(rgb(&out, x, 50), [255, 0, 0], "a /None fill at x = {x} leaves the background"); + } +} + +#[test] +fn a_named_separation_still_paints_through_its_alternate() { + let out = three_tints("[/Separation /Cyan /DeviceCMYK 6 0 R]"); + let [white, half, full] = [rgb(&out, 50, 50), rgb(&out, 150, 50), rgb(&out, 250, 50)]; + assert_gray(white, 255, "tint 0 of a named colourant"); + for (what, colour) in [("tint 0.5", half), ("tint 1", full)] { + // Cyan: red well below green and blue. + assert!(colour[0] < colour[1] && colour[0] < colour[2] && colour[2] > 200, "{what} of /Cyan is cyan: {colour:?}"); + } + assert!(full[0] < half[0], "full cyan is deeper than half: {full:?} against {half:?}"); +} + +#[test] +fn a_device_n_space_naming_all_still_paints_through_its_alternate() { + // §8.6.6.4 gives All its meaning in a Separation space; a DeviceN space keeps its alternate. + let out = three_tints("[/DeviceN [/All] /DeviceCMYK 6 0 R]"); + let half = rgb(&out, 150, 50); + assert!(half[0] < half[1] && half[0] < half[2], "DeviceN [/All] at 0.5 draws its tint transform's cyan: {half:?}"); +} + +#[test] +fn separation_all_images_paint_each_sample_as_the_complemented_gray() { + // Three 8-bit samples: tints 0, 128/255 and 1. + let data = [0u8, 128, 255]; + let image = + stream("/Type /XObject /Subtype /Image /Width 3 /Height 1 /ColorSpace [/Separation /All /DeviceCMYK 6 0 R] /BitsPerComponent 8", &data); + let out = render(page("q 300 0 0 100 0 0 cm /Im Do Q", "<< /XObject << /Im 5 0 R >> >>", &[image, CYAN.as_bytes().to_vec()])); + assert_gray(rgb(&out, 50, 50), 255, "sample 0"); + assert_gray(rgb(&out, 150, 50), 127, "sample 128"); + assert_gray(rgb(&out, 250, 50), 0, "sample 255"); +} + +#[test] +fn separation_all_shadings_paint_the_complemented_gray() { + // An axial shading from tint 0 at x = 0 to tint 1 at x = 300, painted with `sh`. + let shading = "<< /ShadingType 2 /ColorSpace [/Separation /All /DeviceCMYK 6 0 R] /Coords [0 0 300 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> /Extend [true true] >>"; + let out = render(page("/Sh sh", "<< /Shading << /Sh 5 0 R >> >>", &[shading.as_bytes().to_vec(), CYAN.as_bytes().to_vec()])); + for (x, want) in [(1usize, 254u8), (150, 128), (298, 1)] { + let got = rgb(&out, x, 50); + assert!(got.iter().all(|c| c.abs_diff(want) <= 3), "shading at x = {x}: got {got:?}, want gray near {want}"); + } +} diff --git a/crates/render/tests/it/substitute_widths.rs b/crates/render/tests/it/substitute_widths.rs new file mode 100644 index 0000000000..b7c4073259 --- /dev/null +++ b/crates/render/tests/it/substitute_widths.rs @@ -0,0 +1,116 @@ +//! Contributor-original synthetic fonts: a simple font the document names but doesn't embed is +//! drawn with a standard-font stand-in, its glyphs placed at the advances `/Widths` gives +//! (ISO 32000-2 §9.6.2.1, Table 109) and their shapes kept. + +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest}; +use std::sync::Arc; + +const SIZE: f64 = 80.0; + +fn pdf(objects: &[String]) -> Vec { + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + bytes +} + +/// A page showing `text` at 80 pt in the font dictionary `font`, with the font descriptor +/// `descriptor` as object 6. +fn page(font: &str, descriptor: &str, text: &str) -> Vec { + let body = format!("BT /F1 {SIZE} Tf 20 30 Td ({text}) Tj ET"); + pdf(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 140] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()), + font.into(), + descriptor.into(), + ]) +} + +/// The runs of columns holding dark pixels, as (first, last) column. +fn ink_runs(bytes: Vec) -> Vec<(u32, u32)> { + let mut renderer = PageRenderer::new(Arc::new(bytes), RenderConfig::default()); + let image = renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(image.error.is_none(), "{:?}", image.error); + let dark = |x: u32| { + (0..image.height).any(|y| { + let at = usize::try_from((y * image.width + x) * 4).unwrap_or(usize::MAX); + image.rgba.get(at).is_some_and(|r| *r < 128) + }) + }; + let mut runs: Vec<(u32, u32)> = Vec::new(); + for x in 0..image.width { + if dark(x) { + match runs.last_mut() { + Some(run) if run.1 + 1 == x => run.1 = x, + _ => runs.push((x, x)), + } + } + } + runs +} + +fn standard(name: &str, text: &str) -> Vec<(u32, u32)> { + ink_runs(page(&format!("<< /Type /Font /Subtype /Type1 /BaseFont /{name} /Encoding /WinAnsiEncoding >>"), "<< >>", text)) +} + +fn substituted(base_font: &str, flags: u32, weight: u32, code: u8, width: u32, text: &str) -> Vec<(u32, u32)> { + let font = format!( + "<< /Type /Font /Subtype /TrueType /BaseFont /{base_font} /Encoding /WinAnsiEncoding \ + /FirstChar {code} /LastChar {code} /Widths [{width}] /FontDescriptor 6 0 R >>" + ); + let descriptor = format!( + "<< /Type /FontDescriptor /FontName /{base_font} /Flags {flags} /FontBBox [-600 -200 2000 1000] \ + /ItalicAngle 0 /Ascent 900 /Descent -200 /CapHeight 700 /StemV 140 /FontWeight {weight} >>" + ); + ink_runs(page(&font, &descriptor, text)) +} + +/// A `/Widths` entry wider than the stand-in's glyph moves the next glyph on, and the glyph keeps +/// its own shape: every advance of 600 stretched each glyph of `Arial,Bold` to 600 units, so the +/// stems of `i` and `l` became solid blocks twice their width and the text read as a heavy +/// fixed-pitch face. +#[test] +fn a_substitute_keeps_its_glyph_shapes_at_wider_advances() { + for (base_font, flags, weight, stand_in) in [ + ("Arial,Bold", 32, 700, "Helvetica-Bold"), + ("Arial,Bold", 4, 700, "Helvetica-Bold"), + ("Arial-BoldMT", 32, 700, "Helvetica-Bold"), + ("ArialMT", 32, 400, "Helvetica"), + ] { + let reference = standard(stand_in, "l"); + let [(ref_start, ref_end)] = reference[..] else { panic!("{stand_in}: one stem, {reference:?}") }; + let runs = substituted(base_font, flags, weight, b'l', 600, "ll"); + let [(first, first_end), (second, second_end)] = runs[..] else { panic!("{base_font}: two stems, {runs:?}") }; + for (start, end) in [(first, first_end), (second, second_end)] { + assert!( + (end - start).abs_diff(ref_end - ref_start) <= 1, + "{base_font} /Flags {flags}: the stem is {stand_in}'s, not stretched: {runs:?} vs {reference:?}" + ); + } + // 600 units at 80 pt is 48 pixels. + assert!((second - first).abs_diff(48) <= 1, "{base_font}: the second l is 600 units on: {runs:?}"); + assert!(first.abs_diff(ref_start) <= 1, "{base_font}: the first l starts where {stand_in}'s does: {runs:?} vs {reference:?}"); + } +} + +/// A `/Widths` entry narrower than the stand-in's glyph still narrows the glyph to it, so +/// neighbours don't overlap: `W` (944 units in Helvetica-Bold) at an advance of 400. +#[test] +fn a_substitute_still_narrows_glyphs_to_narrower_advances() { + let runs = substituted("Arial,Bold", 32, 700, b'W', 400, "WW"); + let [(first, first_end), (second, _)] = runs[..] else { panic!("two separate glyphs: {runs:?}") }; + // 400 units at 80 pt is 32 pixels. + assert!(first_end - first <= 33, "the W is narrowed to its advance: {runs:?}"); + assert!((second - first).abs_diff(32) <= 1, "the second W is 400 units on: {runs:?}"); +} diff --git a/crates/render/tests/it/tiling_cell_placement.rs b/crates/render/tests/it/tiling_cell_placement.rs new file mode 100644 index 0000000000..3a57f15085 --- /dev/null +++ b/crates/render/tests/it/tiling_cell_placement.rs @@ -0,0 +1,191 @@ +//! Tiling pattern cells sit at whole multiples of `/XStep` and `/YStep` in pattern space +//! (ISO 32000-2 8.7.3.1, Table 74), at any rendering scale, however far they are from the +//! pattern's origin. +//! +//! Each page is filled (or stroked) with a pattern whose 20 × 20 cell holds one 6 × 6 blue square +//! at (7, 7). At 110 dpi a step is 30.56 device pixels; every probe finds the blue square in a +//! disc around where the pattern matrix puts it and checks that its centroid is within one +//! device pixel of that place. + +use std::sync::Arc; + +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind}; + +/// The rendering scale for 110 dpi: a step of 20 points is 30.56 pixels, not a whole number. +const SCALE: f64 = 110.0 / 72.0; +/// The page is `SIDE` × `SIDE` points. +const SIDE: f64 = 400.0; + +/// One page `SIDE` points square whose content `content` paints with `/P1`: the 20-point cell +/// with its blue square, `/XStep` and `/YStep` 20, under pattern matrix `matrix`. +fn page_pdf(content: &str, matrix: [f64; 6]) -> Vec { + let cell = "0 0 1 rg 7 7 6 6 re f"; + let m = matrix.map(|v| format!("{v}")).join(" "); + format!( + "%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 {SIDE} {SIDE}] /Contents 4 0 R /Resources << /Pattern << /P1 5 0 R >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 20 20] /XStep 20 /YStep 20 /Matrix [{m}] /Length {} >> stream +{cell} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len(), + cell.len() + ) + .into_bytes() +} + +struct Page { + rgba: Vec, + width: usize, + height: usize, +} + +fn render(content: &str, matrix: [f64; 6]) -> Page { + let mut r = PageRenderer::new(Arc::new(page_pdf(content, matrix)), RenderConfig::default()); + let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: SCALE as f32, tag: 0, ..Default::default() }); + assert!(page.error.is_none(), "{:?}", page.error); + Page { rgba: page.rgba.to_vec(), width: page.width as usize, height: page.height as usize } +} + +/// The device position of pattern-space point `(x, y)`: the pattern matrix to page space, then +/// page space to device pixels (y down). +fn device(matrix: [f64; 6], x: f64, y: f64) -> (f64, f64) { + let [a, b, c, d, e, f] = matrix; + let (px, py) = (a * x + c * y + e, b * x + d * y + f); + (px * SCALE, (SIDE - py) * SCALE) +} + +/// The centroid of blue (weighted by how far each pixel is from white) in the disc of `radius` +/// pixels around `(cx, cy)`, and the total weight. `None` when the disc leaves the page. +fn blue_centroid(page: &Page, cx: f64, cy: f64, radius: f64) -> Option<((f64, f64), f64)> { + let (x0, x1) = ((cx - radius).floor(), (cx + radius).ceil()); + let (y0, y1) = ((cy - radius).floor(), (cy + radius).ceil()); + if x0 < 0.0 || y0 < 0.0 || x1 >= page.width as f64 || y1 >= page.height as f64 { + return None; + } + let (mut sum, mut sx, mut sy) = (0.0, 0.0, 0.0); + for j in (y0 as usize)..=(y1 as usize) { + for i in (x0 as usize)..=(x1 as usize) { + let (px, py) = (i as f64 + 0.5, j as f64 + 0.5); + if (px - cx).powi(2) + (py - cy).powi(2) > radius * radius { + continue; + } + let red = *page.rgba.get((j * page.width + i) * 4)?; + let w = f64::from(255 - red) / 255.0; + sum += w; + sx += w * px; + sy += w * py; + } + } + (sum > 0.0).then(|| ((sx / sum, sy / sum), sum)) +} + +/// Checks every cell `(k, m)` whose blue square's centre falls in the page rectangle `area` +/// (points: x0, y0, x1, y1): its square is found where the matrix puts it, within one pixel. +/// Returns how many cells were checked. +fn assert_cells_in_place(page: &Page, matrix: [f64; 6], area: [f64; 4], what: &str) -> usize { + let [a, b, c, d, _, _] = matrix; + // The square is 6 × 6 at (7, 7); its centre is (10, 10) in the cell. A disc of 6 cell + // units around it holds the whole square (half-diagonal 4.24) and no neighbour (7 away). + let units = (a * a + b * b).sqrt().min((c * c + d * d).sqrt()); + let radius = 6.0 * units * SCALE; + let area_px = 36.0 * units * units * SCALE * SCALE; + let mut checked = 0; + for k in -60..60 { + for m in -60..60 { + let (cx_cell, cy_cell) = (20.0 * f64::from(k) + 10.0, 20.0 * f64::from(m) + 10.0); + let [ea, eb, ec, ed, ee, ef] = matrix; + let (px, py) = (ea * cx_cell + ec * cy_cell + ee, eb * cx_cell + ed * cy_cell + ef); + if px < area[0] || px > area[2] || py < area[1] || py > area[3] { + continue; + } + let (cx, cy) = device(matrix, cx_cell, cy_cell); + let Some(((gx, gy), weight)) = blue_centroid(page, cx, cy, radius) else { continue }; + assert!(weight > 0.8 * area_px, "{what}: cell ({k}, {m}) at ({cx:.1}, {cy:.1}) only partly there: {weight:.1} of {area_px:.1}"); + assert!( + (gx - cx).abs() <= 1.0 && (gy - cy).abs() <= 1.0, + "{what}: cell ({k}, {m}) should be centred at ({cx:.2}, {cy:.2}) px, found at ({gx:.2}, {gy:.2})" + ); + checked += 1; + } + } + checked +} + +const IDENTITY: [f64; 6] = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]; + +/// A fill far from the pattern's origin: the cells 300 to 400 points away (15 to 19 steps) are +/// where 15 to 19 steps put them, not 0.44 pixel further for every step. +#[test] +fn far_cells_of_a_fill_sit_at_whole_steps() { + let page = render("/Pattern cs /P1 scn 0 0 400 400 re f", IDENTITY); + let near = assert_cells_in_place(&page, IDENTITY, [0.0, 0.0, 100.0, 100.0], "near the origin"); + let far = assert_cells_in_place(&page, IDENTITY, [290.0, 290.0, 400.0, 400.0], "far from the origin"); + assert!(near >= 16 && far >= 16, "{near} near, {far} far"); +} + +/// The same under a pattern matrix that scales by 1.49 and turns by 30 degrees: a step is +/// 45.5 pixels along each turned axis. +#[test] +fn far_cells_sit_at_whole_steps_under_a_turned_scaled_matrix() { + let (scale, turn) = (1.49_f64, 30_f64.to_radians()); + let matrix = [scale * turn.cos(), scale * turn.sin(), -scale * turn.sin(), scale * turn.cos(), 3.0, 5.0]; + let page = render("/Pattern cs /P1 scn 0 0 400 400 re f", matrix); + let far = assert_cells_in_place(&page, matrix, [250.0, 250.0, 390.0, 390.0], "turned and scaled"); + assert!(far >= 6, "{far} cells checked"); +} + +/// A stroke painted with the pattern: the cells under a wide border far from the origin are +/// in place on every side of it. +#[test] +fn far_cells_of_a_stroke_sit_at_whole_steps() { + let page = render("/Pattern CS /P1 SCN 40 w 200 200 180 180 re S", IDENTITY); + let mut checked = 0; + // The border is 40 points wide around the square from (200, 200) to (380, 380). + for band in [[180.0, 180.0, 400.0, 220.0], [180.0, 360.0, 400.0, 400.0], [180.0, 180.0, 220.0, 400.0], [360.0, 180.0, 400.0, 400.0]] { + checked += assert_cells_in_place(&page, IDENTITY, band, "stroke"); + } + assert!(checked >= 20, "{checked} cells checked"); + // Inside the border nothing is painted. + let (cx, cy) = device(IDENTITY, 290.0, 290.0); + let at = ((cy as usize) * page.width + cx as usize) * 4; + assert_eq!(page.rgba.get(at..at + 3), Some(&[255, 255, 255][..]), "inside the stroked square"); +} + +/// The cell pixmap's side and the scale its cell is drawn at: one step is exactly the side, the +/// side stays within 1 and the cap whatever the step, and nothing odd reaches the cast. +#[test] +fn a_cell_pixmap_spans_exactly_one_step() { + // 110 dpi, a step of 20: 30.56 pixels, drawn at 31 / 20. + assert_eq!(hayro::tiling_cell_pixels(110.0 / 72.0, 20.0, 3000.0), (31, 31.0 / 20.0)); + assert_eq!(hayro::tiling_cell_pixels(110.0 / 72.0, -20.0, 3000.0), (31, 31.0 / 20.0), "a negative step"); + for (scale, step) in [(1.0_f32, 7.3_f32), (2.37, 0.9), (0.013, 1234.5), (3.0, 999.9), (1.0, 2.0e9)] { + let (side, drawn) = hayro::tiling_cell_pixels(scale, step, 3000.0); + assert!((1..=3000).contains(&side), "{scale} {step}: {side}"); + let span = drawn * f64::from(step); + assert!((span - f64::from(side)).abs() < 1e-9 * f64::from(side), "{scale} {step}: a step spans {span}, not {side}"); + if f64::from(scale) * f64::from(step) <= 3000.0 { + assert!((span - f64::from(scale) * f64::from(step)).abs() <= 0.5 + 1e-9, "{scale} {step}: drawn {drawn}"); + } + } + // A step under half a pixel still gets one pixel (a 0 × N pixmap can't be sampled). + assert_eq!(hayro::tiling_cell_pixels(0.03, 10.0, 3000.0), (1, 0.1)); + // Steps and scales that aren't finite and positive: the side is bounded, the scale kept. + assert_eq!(hayro::tiling_cell_pixels(2.0, f32::INFINITY, 3000.0), (3000, 2.0)); + assert_eq!(hayro::tiling_cell_pixels(2.0, 0.0, 3000.0), (1, 2.0)); + let (side, drawn) = hayro::tiling_cell_pixels(f32::NAN, 20.0, 3000.0); + assert!(side == 1 && drawn.is_nan()); + // A cap past u16::MAX is lowered to it, and the scale follows the side the pixmap gets. + let (side, drawn) = hayro::tiling_cell_pixels(2.0, 1.0e6, 1.0e9); + assert_eq!(side, u16::MAX, "the cap is at most u16::MAX"); + assert!((drawn * 1.0e6 - f64::from(u16::MAX)).abs() < 1e-6, "a step spans the side: {drawn}"); + // A cap that is not a whole number, or not a number, is a whole number of pixels. + assert_eq!(hayro::tiling_cell_pixels(1.0, 100.0, 30.7), (30, 0.3)); + assert_eq!(hayro::tiling_cell_pixels(1.0, 100.0, f32::NAN), (1, 0.01)); +} diff --git a/crates/render/tests/it/type3_text_clip.rs b/crates/render/tests/it/type3_text_clip.rs new file mode 100644 index 0000000000..2cdfce5d81 --- /dev/null +++ b/crates/render/tests/it/type3_text_clip.rs @@ -0,0 +1,163 @@ +//! Contributor-original synthetic pages: text in a Type 3 font in the text rendering modes +//! (ISO 32000-2 §9.3.6, Table 104). For a Type 3 font, modes 3 and 7 render nothing, the other +//! modes render the glyph descriptions, and modes 4 to 7 add nothing to the clipping path +//! (vendored hayro-interpret patch 64). +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind}; +use std::sync::Arc; + +/// Glyph procedures in a 1000-unit glyph space (`/FontMatrix [0.001 0 0 0.001 0 0]`), named +/// by the letters that show them: the `/Differences` give them codes 65 (`A`) on, in order. +const GLYPHS: &[(&str, &[u8])] = &[ + // A: a square, x 100..900, y 0..800. + ("A", b"1000 0 100 0 900 800 d1 100 0 800 800 re f"), + // B: a triangle on the baseline with its apex at the top middle. + ("B", b"1000 0 0 0 1000 1000 d1 0 0 m 1000 0 l 500 1000 l f"), + // C: a coloured glyph (`d0`): a blue square. + ("C", b"1000 0 d0 0 0 1 rg 100 0 800 800 re f"), + // D: shows four of itself, nesting until the paint budget runs out. + ("D", b"1000 0 0 0 1000 1000 d1 BT /T3 1000 Tf 0 0 Td (DDDD) Tj ET"), +]; + +fn pdf(content: &str) -> Vec { + let names: String = GLYPHS.iter().map(|(n, _)| format!("/{n} ")).collect(); + let procs: String = GLYPHS.iter().enumerate().map(|(i, (n, _))| format!("/{n} {} 0 R ", 6 + i)).collect(); + let widths = vec!["1000"; GLYPHS.len()].join(" "); + let mut objects: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << /Font << /T3 5 0 R /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >> >>".to_vec(), + stream(content.as_bytes()), + format!( + "<< /Type /Font /Subtype /Type3 /FontBBox [0 0 1000 1000] /FontMatrix [0.001 0 0 0.001 0 0] \ + /CharProcs << {procs}>> /Encoding << /Type /Encoding /Differences [65 {names}] >> \ + /FirstChar 65 /LastChar {} /Widths [{widths}] >>", + 64 + GLYPHS.len() + ) + .into_bytes(), + ]; + objects.extend(GLYPHS.iter().map(|(_, body)| stream(body))); + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(object); + out.extend_from_slice(b"\nendobj\n"); + } + 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 << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + out +} + +fn stream(body: &[u8]) -> Vec { + let mut s = format!("<< /Length {} >>\nstream\n", body.len()).into_bytes(); + s.extend_from_slice(body); + s.extend_from_slice(b"\nendstream"); + s +} + +/// A 100 x 100 pixel rendering of a 100 x 100 pt page; `at(x, y)` reads the pixel at page +/// coordinates (y up). +struct Page(Vec); + +impl Page { + fn render(content: &str) -> Self { + let mut renderer = PageRenderer::new(Arc::new(pdf(content)), RenderConfig::default()); + let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, scale: 1.0, ..Default::default() }); + assert!(page.error.is_none(), "{content}: {:?}", page.error); + assert_eq!((page.width, page.height), (100, 100), "{content}"); + Self(page.rgba.to_vec()) + } + + fn at(&self, x: usize, y: usize) -> [u8; 3] { + let i = ((99 - y) * 100 + x) * 4; + match self.0.get(i..i + 3) { + Some([r, g, b]) => [*r, *g, *b], + _ => panic!("pixel {x},{y} is off the page"), + } + } + + /// Whether any pixel is dark in every channel: a glyph painted in the default black. + fn has_black(&self) -> bool { + self.0.as_chunks::<4>().0.iter().any(|p| p[0] < 128 && p[1] < 128 && p[2] < 128) + } +} + +const RED: [u8; 3] = [255, 0, 0]; +const BLUE: [u8; 3] = [0, 0, 255]; +const WHITE: [u8; 3] = [255, 255, 255]; + +/// `ABA` at 20 pt from (10, 40): squares x 12..28 and 52..68 (y 40..56), and a triangle from +/// (30, 40) and (50, 40) up to (40, 60). +const ABA: &str = "/T3 20 Tf 10 40 Td (ABA) Tj"; + +#[test] +fn clip_only_type3_text_is_not_rendered_and_leaves_no_clip() { + // The fill stops at x 60, inside the second square: a glyph painted under `7 Tr` would show + // past it as a bar beside the box. + let page = Page::render(&format!("BT 7 Tr {ABA} ET 1 0 0 rg 0 0 60 100 re f")); + assert_eq!(page.at(20, 48), RED, "the fill over the first square"); + assert_eq!(page.at(5, 5), RED, "no clip: the fill covers its whole box"); + assert_eq!(page.at(29, 45), RED, "no clip between the glyphs either"); + assert_eq!(page.at(33, 57), RED, "no clip beside the triangle"); + assert_eq!(page.at(64, 48), WHITE, "the second square past the fill is not rendered"); + assert_eq!(page.at(80, 80), WHITE, "outside the fill"); + assert!(!page.has_black(), "7 Tr renders no glyph"); + + // Alone, neither shape glyphs nor a coloured glyph leave a mark. + for text in [ABA, "/T3 20 Tf 10 40 Td (C) Tj"] { + let page = Page::render(&format!("BT 7 Tr {text} ET")); + assert!(page.0.as_chunks::<4>().0.iter().all(|p| p[..3] == WHITE), "7 Tr {text} renders nothing"); + } +} + +#[test] +fn invisible_type3_text_is_not_rendered() { + for text in [ABA, "/T3 20 Tf 10 40 Td (C) Tj"] { + let page = Page::render(&format!("BT 3 Tr {text} ET")); + assert!(page.0.as_chunks::<4>().0.iter().all(|p| p[..3] == WHITE), "3 Tr {text} renders nothing"); + } +} + +#[test] +fn other_modes_render_type3_glyphs_filled_and_never_clip() { + for mode in [0, 1, 2, 4, 5, 6] { + let page = Page::render(&format!("BT 0 0 1 rg 1 1 0 RG 6 w {mode} Tr {ABA} ET 1 0 0 rg 0 0 60 100 re f")); + assert_eq!(page.at(64, 48), BLUE, "{mode} Tr: the glyph is rendered, in the fill colour"); + assert_eq!(page.at(70, 48), WHITE, "{mode} Tr: and not stroked"); + assert_eq!(page.at(20, 48), RED, "{mode} Tr: the later fill over the glyph"); + assert_eq!(page.at(5, 5), RED, "{mode} Tr: the later fill is not clipped"); + assert_eq!(page.at(29, 45), RED, "{mode} Tr: not clipped between the glyphs"); + assert!(!page.has_black(), "{mode} Tr"); + } + // A coloured glyph keeps its own colour. + let page = Page::render("BT 1 0 0 rg 4 Tr /T3 20 Tf 10 40 Td (C) Tj ET"); + assert_eq!(page.at(20, 48), BLUE); +} + +#[test] +fn only_outline_glyphs_shape_a_clip_shared_with_type3_text() { + // `I` in Helvetica at 40 pt from (60, 10): its stem covers (65, 20). The square A from + // (10, 40) at 20 pt adds nothing to the clip and is not rendered. + let page = Page::render("BT 7 Tr /F1 40 Tf 60 10 Td (I) Tj /T3 20 Tf -50 30 Td (A) Tj ET 1 0 0 rg 0 0 100 100 re f"); + assert_eq!(page.at(65, 20), RED, "inside the outline glyph's clip"); + assert_eq!(page.at(20, 48), WHITE, "the Type 3 square neither clips nor paints"); + assert_eq!(page.at(5, 5), WHITE, "outside the clip"); + assert!(!page.has_black()); +} + +#[test] +fn self_nesting_type3_text_in_a_clip_mode_stays_bounded() { + // Every D shows four Ds: unbounded this is 4^n procedures. The paint nesting depth and the + // page's paint budget stop it; the page must still render. + for mode in [4, 6, 7] { + let content = format!("BT {mode} Tr /T3 20 Tf 10 40 Td (DD) Tj ET 1 0 0 rg 0 0 100 100 re f"); + let page = Page::render(&content); + assert_eq!(page.at(5, 5), RED, "{content}: no clip"); + assert!(!page.has_black(), "{content}"); + } +} diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index d9669f08ef..b522a855e5 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -1357,6 +1357,11 @@ fn classify_changes(doc: &Document, old: Option, p: Option) -> Mod // attacker-written version carries (relabelling a signed page /Type /Sig, or dropping its // /Type and adding /ModDate, must not reclassify it away from page content). let signed_pages: HashSet = pdfcraft_annot::page_refs(&old).unwrap_or_default().into_iter().collect(); + // The signed pages' /Annots arrays that are objects of their own. Adding an annotation updates + // such an array in place (the page dictionary does not change), so it is judged like a + // page's inline /Annots: by the annotations it gained. + let signed_annots: HashSet = + signed_pages.iter().filter_map(|pg| old.get(*pg).as_dict().and_then(|d| d.get(b"Annots").and_then(Object::as_ref))).collect(); // What each signed page draws from, by the role it held when signed (#1787). let signed_resources = signed_page_resources(&old, &signed_pages); let old_info = old.trailer().get(b"Info").and_then(Object::as_ref); @@ -1425,6 +1430,24 @@ fn classify_changes(doc: &Document, old: Option, p: Option) -> Mod (Some(k), _) => k, // The Document Security Store's own arrays and dictionaries, when they only grew. (_, _) if dss.contains(&num) => kind::SECURITY_STORE, + // A signed page's indirect /Annots array, still an array: what it lost, then what it + // gained. Dropping a signed annotation is never hidden behind an added signature. + (_, Object::Array(now)) if signed_annots.contains(&r) && before.as_deref().is_some_and(|b| b.as_array().is_some()) => { + let was: HashSet = + before.as_deref().and_then(Object::as_array).map(|a| a.iter().filter_map(Object::as_ref).collect()).unwrap_or_default(); + let kept: HashSet = now.iter().filter_map(Object::as_ref).collect(); + let removed: Vec = was.iter().filter(|r| !kept.contains(r)).copied().collect(); + if removed.is_empty() { + let added: Vec = now.iter().filter_map(Object::as_ref).filter(|r| !was.contains(r)).collect(); + added_annots(doc, &added) + } else if removed.iter().any(|r| old.get(*r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget"))) { + // A form field or signature widget taken off the page: no permission covers it. + kind::OTHER + } else { + // Deleting comments is a comment change (permitted at P 3 only). + kind::COMMENTS + } + } (_, Object::Dict(d)) => { let was = before.as_deref().and_then(Object::as_dict); if signed_pages.contains(&r) { @@ -1794,6 +1817,18 @@ fn page_change_kind(doc: &Document, old: &Document, d: &Dict, before: Option<&Di } } +/// Whether the catalog `d` keeps every developer extension the signed catalog `b` declared, each +/// with the same value: it may only declare more. +fn extensions_kept(doc: &Document, old: &Document, d: &Dict, b: &Dict) -> bool { + let now = d.get(b"Extensions").map(|e| doc.resolve(e)).and_then(|e| e.as_dict().cloned()); + let was = b.get(b"Extensions").map(|e| old.resolve(e)).and_then(|e| e.as_dict().cloned()); + match (was, now) { + (None, _) => true, + (Some(was), Some(now)) => was.iter().all(|(k, v)| now.get(k) == Some(v)), + (Some(_), None) => false, + } +} + fn change_kind(doc: &Document, old: &Document, d: &Dict, before: Option<&Dict>) -> &'static str { let ty = d.name(b"Type"); // A later revision cannot make an existing object a signature by relabelling it: a signature @@ -1812,9 +1847,13 @@ fn change_kind(doc: &Document, old: &Document, d: &Dict, before: Option<&Dict>) // Only a new signature value or policy: rewriting a signed one is not a signature (#1788). Some(b"Sig" | b"DocTimeStamp" | b"SigRef" | b"TransformParams") if before.is_none() => return kind::SIGNATURE, Some(b"Catalog") => { - let keys: &[&[u8]] = &[b"AcroForm", b"DSS", b"Perms", b"Metadata", b"NeedsRendering"]; + let keys: &[&[u8]] = &[b"AcroForm", b"DSS", b"Perms", b"Metadata", b"NeedsRendering", b"Extensions"]; return match before { Some(b) if without(d, keys) != without(b, keys) => kind::STRUCTURE, + // Developer extensions (ISO 32000-2 §7.12) only declare what the file uses. A + // writer adding a /DSS also declares the ESIC extension it belongs to, so a + // declaration may be added, but none that was signed may change or go. + Some(b) if !extensions_kept(doc, old, d, b) => kind::STRUCTURE, Some(b) => { if d.get(b"AcroForm") != b.get(b"AcroForm") { // Compare the forms themselves, inline or not. diff --git a/crates/sign/tests/it/pdf.rs b/crates/sign/tests/it/pdf.rs index 55aa732d78..d71e616ce7 100644 --- a/crates/sign/tests/it/pdf.rs +++ b/crates/sign/tests/it/pdf.rs @@ -144,6 +144,71 @@ fn signing_an_existing_field_and_counter_signing() { )); } +/// A page whose /Annots is an object of its own: signing and commenting update that array in +/// place, not the page. A second signature is a signature change for the first, as with an +/// inline /Annots, and a comment is still a comment (refused under a form-fill certification). +#[test] +fn an_indirect_annots_array_is_judged_by_the_annotations_it_gains() { + let page = pdfcraft_cos::ObjRef { num: 3, generation: 0 }; + let unsigned = edit_after(&fixture(), |doc| { + let annots = doc.add(Object::Array(vec![Object::Ref(pdfcraft_cos::ObjRef { num: 6, generation: 0 })])); + doc.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Ref(annots))).unwrap(); + }); + let annots_ref = |bytes: &[u8]| open(bytes).get(page).as_dict().and_then(|d| d.get(b"Annots").and_then(Object::as_ref)); + let annots = annots_ref(&unsigned).unwrap(); + let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(); + let trust = TrustStore { certs: vec![id.certificate.clone()] }; + let first = pdfcraft_sign::sign(&open(&unsigned), &id, &opts()).unwrap(); + let second = pdfcraft_sign::sign(&open(&first), &id, &SignOptions { rect: Some([20.0, 160.0, 220.0, 210.0]), ..opts() }).unwrap(); + assert_eq!(annots_ref(&second), Some(annots), "the signer kept the page's array indirect"); + let doc = open(&second); + let sigs = signatures(&doc, &second, &trust); + let s = sigs.iter().find(|s| s.field == "Signature1").unwrap(); + assert_eq!(s.modification, Modification::Allowed(vec![kind::SIGNATURE]), "{:?}", s.details); + assert_eq!(s.status, Status::Valid); + // A comment appended to the indirect array. + let comment = |doc: &mut Document| { + let mut d = pdfcraft_cos::Dict::new(); + d.set(b"Type".to_vec(), Object::name("Annot")); + d.set(b"Subtype".to_vec(), Object::name("Text")); + d.set(b"Rect".to_vec(), Object::Array(vec![Object::Int(10), Object::Int(10), Object::Int(30), Object::Int(30)])); + let r = doc.add(Object::Dict(d)); + let mut a = doc.get(annots).as_array().cloned().unwrap(); + a.push(Object::Ref(r)); + doc.set(annots, Object::Array(a)); + }; + let check = |bytes: &[u8]| signatures(&open(bytes), bytes, &trust).into_iter().find(|s| s.signed).unwrap(); + let s = check(&edit_after(&first, comment)); + assert_eq!((s.status, s.modification), (Status::Valid, Modification::Allowed(vec![kind::COMMENTS]))); + let certified = pdfcraft_sign::sign(&open(&unsigned), &id, &SignOptions { certify: Some(2), ..opts() }).unwrap(); + let s = check(&edit_after(&certified, comment)); + assert_eq!((s.status, s.modification), (Status::Invalid, Modification::Disallowed(vec![kind::COMMENTS]))); + // Under that certification, a signed comment swapped for a new signature widget is still a + // deleted comment, and a form field taken off the page is permitted by no level. + let commented = pdfcraft_sign::sign(&open(&edit_after(&unsigned, comment)), &id, &SignOptions { certify: Some(2), ..opts() }).unwrap(); + let swap = |drop: &dyn Fn(&Document, &Object) -> bool| { + edit_after(&commented, |doc| { + let mut w = pdfcraft_cos::Dict::new(); + w.set(b"Type".to_vec(), Object::name("Annot")); + w.set(b"Subtype".to_vec(), Object::name("Widget")); + w.set(b"FT".to_vec(), Object::name("Sig")); + w.set(b"T".to_vec(), PdfString::text("Later")); + w.set(b"Rect".to_vec(), Object::Array(vec![Object::Int(0); 4])); + let w = doc.add(Object::Dict(w)); + let mut a = doc.get(annots).as_array().cloned().unwrap(); + a.retain(|o| !drop(doc, o)); + a.push(Object::Ref(w)); + doc.set(annots, Object::Array(a)); + }) + }; + let subtype = + |doc: &Document, o: &Object, t: &[u8]| o.as_ref().is_some_and(|r| doc.get(r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(t))); + let s = check(&swap(&|doc, o| subtype(doc, o, b"Text"))); + assert_eq!((s.status, s.modification), (Status::Invalid, Modification::Disallowed(vec![kind::COMMENTS]))); + let s = check(&swap(&|doc, o| subtype(doc, o, b"Widget") && o.as_ref().is_some_and(|r| r.num == 6))); + assert_eq!((s.status, s.modification), (Status::Invalid, Modification::Disallowed(vec![kind::OTHER]))); +} + /// Issue #1328: a signature field whose /V is null, directly or through a reference, is unsigned: /// it is listed as unsigned and can be signed by name. #[test] @@ -666,6 +731,35 @@ fn cached_discovery_still_validates_current_timestamp_bytes_ranges_and_trust() { assert_eq!(check(&doc, &tampered, &trusted).status, Status::Invalid); } +#[test] +fn ltv_writers_may_declare_the_esic_extension_but_not_change_a_signed_one() { + let id = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap(); + let trust = TrustStore { certs: vec![id.certificate.clone()] }; + let check = |bytes: &[u8]| signatures(&open(bytes), bytes, &trust).into_iter().find(|s| s.signed).unwrap(); + let ext = |base: &[u8], level: i64| { + let mut esic = pdfcraft_cos::Dict::new(); + esic.set(b"BaseVersion".to_vec(), Object::name("1.7")); + esic.set(b"ExtensionLevel".to_vec(), Object::Int(level)); + let mut e = pdfcraft_cos::Dict::new(); + e.set(b"ESIC".to_vec(), Object::Dict(esic)); + edit_after(base, |doc| { + let root = doc.root().unwrap(); + doc.update_dict(root, |c| c.set(b"Extensions".to_vec(), Object::Dict(e))).unwrap(); + }) + }; + // As PAdES LTV writers save it: the store and the ESIC declaration in one revision. + let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &opts()).unwrap(); + let evidence = pdfcraft_sign::dss::Evidence { certs: vec![id.certificate.raw.clone()], ocsps: Vec::new(), crls: Vec::new() }; + let ltv = pdfcraft_sign::dss::embed(&open(&ext(&signed, 2)), &evidence).unwrap(); + let s = check(<v); + assert_eq!(s.status, Status::Valid, "{:?}", s.details); + assert!(matches!(&s.modification, Modification::Allowed(k) if !k.contains(&kind::STRUCTURE)), "{:?}", s.modification); + // A declaration that was signed may not change afterwards. + let signed_with = pdfcraft_sign::sign(&open(&ext(&fixture(), 2)), &id, &opts()).unwrap(); + let s = check(&ext(&signed_with, 3)); + assert_eq!((s.status, s.modification), (Status::Invalid, Modification::Disallowed(vec![kind::STRUCTURE]))); +} + #[test] fn embedding_ltv_evidence_adds_a_dss_and_keeps_signatures_valid() { use pdfcraft_sign::dss::{self, Evidence}; diff --git a/crates/ui-egui/src/calc_order_ui.rs b/crates/ui-egui/src/calc_order_ui.rs index a9a726d241..894fe17929 100644 --- a/crates/ui-egui/src/calc_order_ui.rs +++ b/crates/ui-egui/src/calc_order_ui.rs @@ -752,7 +752,7 @@ pub(crate) fn paint_page( 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.rect.width() / xf.pw.max(1.0)).clamp(0.5, 3.0); + let zoom = xf.zoom().clamp(0.5, 3.0); let focus = if view.hide_links { None } else { view.focus(selected) }; // The focus's links, and the fields at either end of them (with the focus itself). let shown: Vec<&Link> = focus.as_deref().map(|f| listed.links.iter().filter(|l| l.from == f || l.to == f).collect()).unwrap_or_default(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index c458857063..c8d3674b4d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -465,6 +465,9 @@ pub struct DocView { pub(crate) text_failed: HashSet, pub find: Option, selection: Option, + /// Select All while scrolling continuously (#2639): the text of every page of the document, + /// highlighted on each page as its text arrives and copied whole ([`DocView::whole_text`]). + pub(crate) all_text: bool, /// The selection whose action bar was closed by its Copy button: the bar stays hidden /// until the selection changes, as it does once a markup button consumes the selection. actions_dismissed: Option, @@ -496,6 +499,11 @@ pub struct DocView { zoom_anchor: Option<(usize, f32, f32, Vec2)>, /// The page view has been shown, so its scroll offset in egui's memory is this document's. shown: bool, + /// Pages that Edit already tried to turn into editable text (once each, so Undo sticks). + pub(crate) auto_ocr_tried: std::collections::HashSet, + /// Pages Edit looked at and found not to be a scan, with the document's edit generation then: + /// they are looked at again only after the document changes (an image added, say). + pub(crate) auto_ocr_skipped: std::collections::HashMap, /// Turns wheel input into page turns in single-page view. wheel: crate::wheel_pager::WheelPager, /// Until this time, apply precise (touchpad) wheel input 1:1 and suppress egui's eased copy. @@ -665,6 +673,8 @@ pub enum ViewAction { Save, Extract, Split, + /// Split before each selected page: opens Split on that mode. + SplitBeforeSelected, /// Copy the selected pages (Cut also deletes them). CopyPages { cut: bool, @@ -775,6 +785,7 @@ impl DocView { text_failed: HashSet::new(), find: None, selection: None, + all_text: false, actions_dismissed: None, selection_drag: false, clicks: 0, @@ -792,6 +803,8 @@ impl DocView { viewport_screen: Rect::NOTHING, zoom_anchor: None, shown: false, + auto_ocr_tried: Default::default(), + auto_ocr_skipped: Default::default(), wheel: Default::default(), precise_scroll_until: 0.0, wheel_boost_x: Default::default(), @@ -1020,6 +1033,7 @@ impl DocView { self.waiting_since.clear(); self.last_queue.clear(); self.selection = None; + self.all_text = false; if let Some(f) = self.find.as_mut() { f.matches.clear(); f.current = None; @@ -1126,6 +1140,35 @@ impl DocView { Some((*page, at)) } + /// The user-space `[left, top]` at the window's top-left (where `/XYZ` puts its point), on the + /// page showing there: what a destination's null coordinate keeps (ISO 32000-2 §12.3.2.2). + /// Acrobat keeps the place this way across pages, so a CAD sheet's cross-reference opens the + /// next sheet where the reader was looking. A coordinate is `None` along a screen axis the + /// page fits, which keeps the layout's own placement (centred, or the page's top); the whole + /// place is `None` before the first frame has laid pages out. + fn place_at_corner(&self, info: &DocInfo) -> Option<[Option; 2]> { + let area = self.viewport_screen; + if !area.is_positive() { + return None; + } + let corner = area.min + vec2(SIDE, 0.0); + let (page, xf) = self + .screen_xforms + .iter() + .filter(|(_, xf)| xf.rect.is_positive()) + .min_by(|a, b| a.1.rect.distance_sq_to_pos(corner).total_cmp(&b.1.rect.distance_sq_to_pos(corner)).then(a.0.cmp(&b.0)))?; + let p = info.pages.get(*page)?; + let (x, y) = xf.screen_to_view(corner.clamp(xf.rect.min, xf.rect.max)); + // `max` drops a NaN size, so the bounds stay ordered and `clamp` can't panic. + let (x, y) = (x.clamp(0.0, p.width.max(0.0)), y.clamp(0.0, p.height.max(0.0))); + // The page counts with its side gutters, as the layout places it. + let wide = xf.rect.width() + 2.0 * SIDE > area.width(); + let tall = xf.rect.height() > area.height(); + // The view's x runs across the screen unless the view is turned a quarter. + let (keep_x, keep_y) = if xf.rot.is_multiple_of(180) { (wide, tall) } else { (tall, wide) }; + Some(p.xyz_for_view(keep_x.then_some(x), keep_y.then_some(y))) + } + /// A page's thumbnail texture, when rendered. pub(crate) fn thumb_id(&self, page: usize) -> Option { self.thumbs.get(&page).map(|t| t.tex.id()) @@ -1165,6 +1208,7 @@ impl DocView { /// Select text on `page` from glyph `from` to glyph `to` (tests and automation). pub fn select_text(&mut self, page: usize, from: usize, to: usize) { + self.all_text = false; self.selection = Some(Selection::run(page, from, to)); } @@ -1273,7 +1317,12 @@ impl DocView { } /// Selected text, if any (⌘C). + /// The selected text: of the pages read so far, after Select All of the document (Copy reads + /// the others, [`DocView::pages_without_text`]). pub fn selected_text(&self) -> Option { + if self.all_text { + return Some(self.whole_text(&HashMap::new())).filter(|x| !x.is_empty()); + } let s = self.selection?; let t = self.texts.get(&s.page)?; Some(s.text(t)).filter(|x| !x.is_empty()) @@ -1328,10 +1377,16 @@ impl DocView { /// and zoomed as `dest` asks, as Acrobat does. `/XYZ` puts (left, top) at the window's /// top-left at its zoom (null or 0 keeps the zoom); `/Fit`, `/FitH` and `/FitV` switch to fit /// page, width and height with `top` or `left` at the window's edge; `/FitR` zooms so the - /// rectangle fits, centred. The `B` (content box) forms are read as the page. A null - /// coordinate keeps that scroll position (null `top` on a new page: its top), and a point - /// past the end of the document scrolls as far as it can. + /// rectangle fits, centred. The `B` (content box) forms are read as the page. A null `/XYZ` + /// coordinate keeps the reader's place: the same user-space point stays at the window's + /// top-left on the new page, as Acrobat does. A point past the end of the document scrolls + /// as far as it can. pub fn go_to_dest(&mut self, page: usize, dest: DestView, info: &DocInfo) { + // Read before the jump: it is where the reader is now. + let kept = match dest { + DestView::Xyz { left, top, .. } if left.is_none() || top.is_none() => self.place_at_corner(info), + _ => None, + }; self.go_to_page(page); let page = self.current; self.goto_point = None; @@ -1348,7 +1403,8 @@ impl DocView { self.fit = Fit::None; self.zoom_anchor = None; } - let [fx, fy] = point(left, top); + let [kept_left, kept_top] = kept.unwrap_or([None, None]); + let [fx, fy] = point(left.or(kept_left), top.or(kept_top)); (fx, fy, corner) } DestView::Fit => { @@ -1465,6 +1521,16 @@ impl DocView { self.select_anchor = (self.page_count > 0).then_some(self.current); return !self.selected.is_empty(); } + // Scrolling continuously, Select All takes the whole document's text (#2639); one page at + // a time, the page shown. + // Like the page's, true once some text is selected: the pages whose text isn't read yet are + // read when the selection is copied. + if self.continuous && self.page_count > 1 { + self.selection = None; + self.all_text = true; + return self.selected_text().is_some(); + } + self.all_text = false; let page = self.current; let Some(t) = self.texts.get(&page) else { return false }; if t.glyphs.is_empty() { @@ -1474,6 +1540,29 @@ impl DocView { true } + /// Pages whose text [`DocView::whole_text`] still needs: not read yet, nor failed. + pub(crate) fn pages_without_text(&self) -> Vec { + (0..self.page_count).filter(|p| !self.texts.contains_key(p) && !self.text_failed.contains(p)).collect() + } + + /// The whole document's text in page order, one page after another on new lines, from the + /// pages' text read so far and `more` (pages read for this, keyed by page). + pub(crate) fn whole_text(&self, more: &HashMap>) -> String { + let mut out = String::new(); + for p in 0..self.page_count { + let Some(t) = self.texts.get(&p).or_else(|| more.get(&p)) else { continue }; + let text = t.text_of(0..t.glyphs.len()); + if text.is_empty() { + continue; + } + if !out.is_empty() { + out.push('\n'); + } + out.push_str(&text); + } + out + } + /// Rotate the view 90° clockwise or counter-clockwise (View ▸ Rotate View, ⇧⌘+ / ⇧⌘−). pub fn rotate_view(&mut self, clockwise: bool) { self.rotation = (self.rotation + if clockwise { 90 } else { 270 }) % 360; @@ -1514,6 +1603,17 @@ impl DocView { self.goto = Some((self.current, 0.0)); } + /// The display shown outside full screen: the one presenting replaced, or the current one. + pub fn normal_display(&self) -> SavedDisplay { + self.presentation.unwrap_or(SavedDisplay { + layout: self.layout, + continuous: self.continuous, + cover: self.cover, + fit: self.fit, + zoom: self.zoom, + }) + } + /// Whether the view is in full screen's one-page-at-a-time display. pub fn presenting(&self) -> bool { self.presentation.is_some() @@ -1566,6 +1666,8 @@ impl DocView { pub fn set_continuous(&mut self, continuous: bool) { if self.continuous != continuous { self.continuous = continuous; + // Select All of the document belongs to continuous scrolling. + self.all_text = false; self.goto = Some((self.current, 0.0)); self.reset_coarse_state(); } @@ -2521,8 +2623,9 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s if key(Key::Escape) && !egui::Popup::is_any_open(ctx) { if view.camera.is_some() || view.camera_done.is_some() { view.pending_action = Some(ViewAction::CancelCamera); - } else if view.selection.is_some() { + } else if view.selection.is_some() || view.all_text { view.selection = None; + view.all_text = false; } else { view.find = None; } @@ -2658,9 +2761,12 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let editing_content = (app.left_open && app.left == crate::LeftPanel::Tool("edit")) || matches!(app.quick_tool, QuickTool::AddText | QuickTool::EditText); let text_style = app.text_style.clone(); + let show_secured_notice = app.show_secured_notice; let view = &mut app.views[index]; - // Opened without the owner password and something is restricted. - let secured = doc.security_summary().is_some_and(|s| !(s.owner || (s.permissions.modify() && s.permissions.assemble()))); + // Opened without the owner password and something is restricted. The notice says so unless + // the user turned it off (#4035): the restrictions themselves, and Document Properties ▸ + // Security, stay as they are. + let secured = show_secured_notice && doc.security_summary().is_some_and(|s| !(s.owner || (s.permissions.modify() && s.permissions.assemble()))); let repaired = !doc.repair_log().is_empty(); match notices(view, doc, secured, repaired, crate::sign_ui::banner(&doc.signatures), ui, &t) { #[cfg(not(target_arch = "wasm32"))] @@ -2680,6 +2786,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { Some(Notice::Signatures) => app.right = Some(RightPanel::Signatures), Some(Notice::FieldHighlights(on)) => app.view_defaults.highlight_fields = on, Some(Notice::Console) => app.dialog = Some(crate::Dialog::JsConsole), + // Back on in Preferences ▸ General. + Some(Notice::HideSecured) => app.show_secured_notice = false, None => {} } // No dialog, close prompt or palette over the page: only then does page input count. @@ -3542,6 +3650,12 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { view.signature_drag.prepare(ui.ctx(), doc, preview_target, scale); view.signature_drag.paint(painter, &pcx, &view.comments, view.pending_edit.as_ref()); + // Any press on the page ends Select All of the document: text read or not, any tool, + // on a link too. + // A press that lands on something drawn over the page (a menu, a dialog) is not one. + if view.all_text && pressed_page == Some(i) { + view.all_text = false; + } // Text layer: find matches, selection, I-beam and drag-to-select. let to_screen = |g: [f32; 4]| xf.view_rect(g); if let Some(text) = view.texts.get(&i).cloned() { @@ -3561,6 +3675,11 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } } } + if view.all_text { + for lr in text.line_rects(0..text.glyphs.len()) { + painter.rect_filled(to_screen(lr), CornerRadius::same(1), Color32::from_rgba_unmultiplied(0x3A, 0x7B, 0xF0, 70)); + } + } if let Some(sel) = view.selection.filter(|s| s.page == i) { for lr in sel.rects(&text) { painter.rect_filled(to_screen(lr), CornerRadius::same(1), Color32::from_rgba_unmultiplied(0x3A, 0x7B, 0xF0, 70)); @@ -4032,7 +4151,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { ui.separator(); } } - canvas_action = comments::context_menu(ui, view, info, prefs, allowed).or(copy_image); + canvas_action = comments::context_menu(ui, view, info, prefs, allowed, can_modify).or(copy_image); }); (wanted, visible_now) }); @@ -4166,7 +4285,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } }); } - // Text layers: visible pages for selection, every page while a search is active. + // Text layers: visible pages for selection, every page while a search is active. (Select All + // of the document needs no more: shown pages highlight as their text comes, and Copy reads + // the rest, as text far away is let go to bound memory.) let need_text = |p: &usize| !view.texts.contains_key(p) && !view.text_failed.contains(p); let mut text_pages: Vec = if hand { Vec::new() } else { visible_now.iter().copied().filter(need_text).collect() }; if view.find.as_ref().is_some_and(|f| !f.case_query.trim().is_empty()) { @@ -4204,7 +4325,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { if let Some(e) = crate::edit_text_ui::overlay(ui.ctx(), view, info) { view.pending_edit = Some(e); } - if let Some(e) = crate::forms_ui::overlay(ui.ctx(), view, info, &form, today) { + let text_box = crate::forms_ui::text_box(doc, view, &form); + if let Some(e) = crate::forms_ui::overlay(ui.ctx(), view, info, &form, text_box.as_ref(), today) { view.pending_edit = Some(e); } if let Some(e) = crate::fill_sign::type_box(ui.ctx(), view, info, &author) { @@ -4755,6 +4877,8 @@ enum Notice { Unlock, /// Close this historical snapshot tab (View signed version / View revision). Close, + /// Stop showing the secured-document notice, in every document (#4035). + HideSecured, } /// The notice bar above the pages: a file changed on disk first, then what a historical @@ -4832,6 +4956,7 @@ fn notices( } let mut open_security = false; let mut unlock = false; + let mut hide_secured = false; let mut open_repairs = false; let mut open_console = false; let mut toggled = None; @@ -4846,8 +4971,12 @@ fn notices( let mut text = crate::i18n::fmt(tl!("Dynamic XFA form laid out from its template: {pages}, {fields}."), &[("pages", &pages), ("fields", &fields)]); if !x.warnings.is_empty() { + // The notes are clauses ("a picture is left out"); the first starts a sentence here. + let notes = x.warnings.join("; "); + let mut chars = notes.chars(); text.push(' '); - text.push_str(&x.warnings.join("; ")); + text.extend(chars.next().into_iter().flat_map(char::to_uppercase)); + text.push_str(chars.as_str()); text.push('.'); } Some(("text-cursor-input", text, true)) @@ -4913,6 +5042,10 @@ fn notices( if secured && doc.can_unlock() && crate::widgets::pill_button(ui, tl!("Unlock…"), true).clicked() { unlock = true; } + // Only when the bar shows the security message: a form's stopped scripts come first. + if secured && !scripts_off && crate::widgets::pill_button(ui, tl!("Don't show again"), false).clicked() { + hide_secured = true; + } if repaired && !secured && info.fields.is_empty() && crate::widgets::pill_button(ui, tl!("Details"), false).clicked() { open_repairs = true; } @@ -4926,6 +5059,9 @@ fn notices( if unlock { return Some(Notice::Unlock); } + if hide_secured { + return Some(Notice::HideSecured); + } open_security.then_some(Notice::Security).or(toggled) } @@ -5489,6 +5625,10 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool view.pending_action = Some(ViewAction::Extract); ui.close(); } + if ui.button(tl!("Split document…")).clicked() { + view.pending_action = Some(ViewAction::SplitBeforeSelected); + ui.close(); + } if ui.add_enabled(editable, egui::Button::new(tl!("Replace pages…"))).clicked() { view.pending_action = Some(ViewAction::ReplacePages); ui.close(); @@ -5747,6 +5887,7 @@ mod tests { locked: false, intent: None, fill_sign, + no_rotate: false, points: Vec::new(), } } @@ -6025,6 +6166,219 @@ trailer << /Root 1 0 R >> assert!(v.text_failed.contains(&0) && v.text_failed.contains(&99), "failures are bytes-free: never evict"); } + /// A PDF of one line of text on each page, `lines[i]` on page `i`. + fn text_pages_pdf(lines: &[&str]) -> Vec { + let n = lines.len(); + // 1 catalog, 2 pages, 3 font, then a page and its content stream for each line. + let mut objects = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + format!("<< /Type /Pages /Count {n} /Kids [{}] >>", (0..n).map(|i| format!("{} 0 R", 4 + 2 * i)).collect::>().join(" ")), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(), + ]; + for (i, line) in lines.iter().enumerate() { + let content = format!("BT /F1 18 Tf 72 700 Td ({line}) Tj ET"); + objects.push(format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 3 0 R >> >> /Contents {} 0 R >>", + 5 + 2 * i + )); + objects.push(format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len())); + } + 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(format!("{} 0 obj\n{o}\nendobj\n", i + 1).bytes()); + } + let xref = out.len(); + out.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).bytes()); + for o in offsets { + out.extend(format!("{o:010} 00000 n \n").bytes()); + } + out.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).bytes()); + out + } + + /// #2639: scrolling continuously, Select All takes every page's text, and Copy gets all of it + /// in page order, reading the pages whose text wasn't read for display. + #[test] + fn select_all_copies_the_whole_document_while_scrolling_continuously() { + let mut app = PdfCraftApp::new(); + app.open_bytes("three.pdf", None, text_pages_pdf(&["Alpha one", "Bravo two", "Charlie three"])).unwrap(); + let v = &mut app.views[0]; + assert!(v.continuous, "continuous scrolling is the default"); + assert!(v.texts.is_empty(), "no page's text read yet"); + // Nothing shown is selected yet (false, as for a page whose text isn't read), but the whole + // document is: Copy reads every page. + assert!(!v.select_all()); + assert!(v.all_text && v.selection.is_none()); + assert_eq!(v.pages_without_text(), vec![0, 1, 2]); + // Copy reads the pages off the UI thread; the clipboard gets the text once they're read. + let ctx = egui::Context::default(); + assert!(app.copy_selected_text(0, &ctx), "the copy starts"); + assert!(app.document_copy.is_some(), "reading the pages, not on this thread"); + let mut text = None; + for _ in 0..500 { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| app.finish_document_copy(ui.ctx())); + out.textures_delta.clear(); + text = out.platform_output.commands.iter().find_map(|c| match c { + egui::OutputCommand::CopyText(t) => Some(t.clone()), + _ => None, + }); + if text.is_some() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + let text = text.expect("the copied text arrives"); + assert!(app.document_copy.is_none(), "done"); + let at = |w: &str| text.find(w).unwrap_or_else(|| panic!("{w:?} missing from {text:?}")); + assert!(at("Alpha") < at("Bravo") && at("Bravo") < at("Charlie"), "page order: {text:?}"); + assert_eq!(text.lines().count(), 3, "a line per page: {text:?}"); + // A new scope ends it: one page at a time, Select All is the page's again. + app.views[0].set_continuous(false); + assert!(!app.views[0].all_text, "not after switching to one page at a time"); + // So do a page selection and a change of the document's content (an edit, undo). + app.views[0].set_continuous(true); + app.views[0].select_all(); + app.views[0].select_text(0, 0, 0); + assert!(!app.views[0].all_text, "a page selection replaces it"); + app.views[0].select_all(); + app.views[0].invalidate_content(); + assert!(!app.views[0].all_text, "not past an edit of the document"); + } + + /// A whole-document copy reads the pages without text, keeps those already read and skips + /// those that failed before. + #[test] + fn a_document_copy_reads_only_the_pages_it_needs() { + let mut app = PdfCraftApp::new(); + app.open_bytes("three.pdf", None, text_pages_pdf(&["Alpha one", "Bravo two", "Charlie three"])).unwrap(); + app.views[0].text_failed.insert(2); + let job = crate::DocumentCopyJob::of(&app.views[0], app.session.get(app.views[0].id).unwrap()); + let kinds: Vec<_> = job.pages.iter().map(|p| matches!(p, crate::CopyPage::ToRead)).collect(); + assert_eq!(kinds, vec![true, true, false], "pages 1 and 2 to read, page 3 failed before"); + assert!(matches!(job.pages[2], crate::CopyPage::Skip)); + } + + /// A later copy wins: a whole-document copy still reading its pages never replaces what was + /// copied after it started, from the page or anywhere else (a text field, a comment). + #[test] + fn a_later_copy_cancels_a_document_copy_still_reading() { + let mut app = PdfCraftApp::new(); + app.open_bytes("three.pdf", None, text_pages_pdf(&["Alpha one", "Bravo two", "Charlie three"])).unwrap(); + let ctx = egui::Context::default(); + // Another copy in the same frame (here a text field's) drops it. + app.views[0].select_all(); + assert!(app.copy_selected_text(0, &ctx) && app.document_copy.is_some()); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + ui.ctx().copy_text("a word".to_owned()); + app.finish_document_copy(ui.ctx()); + }); + out.textures_delta.clear(); + assert!(app.document_copy.is_none(), "cancelled"); + let copies: Vec<_> = out.platform_output.commands.iter().filter(|c| matches!(c, egui::OutputCommand::CopyText(_))).collect(); + assert_eq!(copies, vec![&egui::OutputCommand::CopyText("a word".to_owned())], "the later copy stays"); + // So does copying a page selection. + app.views[0].select_all(); + assert!(app.copy_selected_text(0, &ctx) && app.document_copy.is_some()); + app.views[0].select_text(0, 0, 5); + app.copy_selected_text(0, &ctx); + assert!(app.document_copy.is_none(), "a page selection's copy cancels it"); + } + + /// A press anywhere on a page ends Select All of the document, whatever the tool (here the + /// Hand, which selects no text) and whether the page's text is read yet. + #[test] + fn a_press_on_a_page_ends_select_all_of_the_document() { + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.open_bytes("three.pdf", None, text_pages_pdf(&["Alpha one", "Bravo two", "Charlie three"])).unwrap(); + app + }); + let mut on_screen = None; + for _ in 0..600 { + h.run_steps(2); + on_screen = h.state().views[0].page_screen_rect(0); + if on_screen.is_some() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + let r = on_screen.expect("the first page is on screen"); + h.state_mut().quick_tool = crate::QuickTool::Hand; + h.state_mut().views[0].select_all(); + assert!(h.state().views[0].all_text); + let at = r.center(); + h.event(egui::Event::PointerMoved(at)); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(2); + assert!(!h.state().views[0].all_text, "a press on the page ends it"); + + // A press while a dialog is up lands on the dialog, not the page: the selection stays. + h.state_mut().views[0].select_all(); + h.state_mut().dialog = Some(crate::Dialog::JsConsole); + h.run_steps(2); + h.event(egui::Event::PointerMoved(at)); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(2); + assert!(h.state().views[0].all_text, "a press beneath a dialog doesn't end it"); + } + + /// The pages of a whole-document copy come out in order whatever was read before, and a + /// cancelled copy gives nothing (a new copy or a closed document cancels the old one). + #[test] + fn a_document_copy_reads_in_page_order_and_stops_when_cancelled() { + let mut app = PdfCraftApp::new(); + app.open_bytes("three.pdf", None, text_pages_pdf(&["Alpha one", "Bravo two", "Charlie three"])).unwrap(); + // The middle page's text read for display already; the others read by the copy. + let doc = app.session.get(app.views[0].id).unwrap(); + let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), Default::default()); + let middle = r.render(RenderRequest { page: 1, kind: RequestKind::Text, ..Default::default() }).text.unwrap(); + app.views[0].texts.insert(1, middle); + let job = || crate::DocumentCopyJob::of(&app.views[0], app.session.get(app.views[0].id).unwrap()); + let text = job().text(&std::sync::atomic::AtomicBool::new(false)).unwrap(); + assert!(text.find("Alpha").unwrap() < text.find("Bravo").unwrap() && text.find("Bravo").unwrap() < text.find("Charlie").unwrap(), "{text:?}"); + assert_eq!(job().text(&std::sync::atomic::AtomicBool::new(true)), None, "cancelled"); + } + + /// Select All keeps to the page shown one page at a time, and in a document of one page; of + /// the document, it reads the text already read and doesn't ask again for failed pages. + #[test] + fn select_all_of_the_document_only_while_scrolling_through_several_pages() { + let info = thumbnail_info(3); + let one_line = |s: &str| PageText { + glyphs: s + .chars() + .map(|c| pdfcraft_render::TextGlyph { text: c.into(), rect: [0.0, 0.0, 10.0, 10.0], quad: [[0.0, 0.0]; 4], direction: [1.0, 0.0] }) + .collect(), + line_of: s.chars().map(|_| 0).collect(), + space_before: s.chars().map(|_| false).collect(), + }; + let mut v = DocView::new(DocId(1), &info, ViewDefaults::default()); + for p in 0..3 { + v.texts.insert(p, Arc::new(one_line("abc"))); + } + v.continuous = false; + v.current = 1; + assert!(v.select_all()); + assert!(!v.all_text && v.selection.is_some_and(|s| s.page == 1), "one page at a time: the page shown"); + let single = thumbnail_info(1); + let mut w = DocView::new(DocId(2), &single, ViewDefaults::default()); + w.texts.insert(0, Arc::new(one_line("abc"))); + assert!(w.select_all()); + assert!(!w.all_text && w.selection.is_some(), "a one-page document: that page"); + // Text read for display is used; pages that failed aren't asked for again. + let mut d = DocView::new(DocId(3), &info, ViewDefaults::default()); + d.texts.insert(1, Arc::new(one_line("abc"))); + d.text_failed.insert(2); + assert!(d.select_all() && d.all_text); + assert_eq!(d.pages_without_text(), vec![0]); + } + #[test] fn text_layers_keep_the_selection_page() { let info = thumbnail_info(100); @@ -6839,6 +7193,47 @@ trailer << /Root 1 0 R >> assert_eq!(PageLayout::try_parse("facing"), None); } + #[test] + fn a_null_xyz_coordinate_keeps_the_readers_place_on_the_new_page() { + // CAD exports link each cross-reference as `/XYZ null null 0`. Acrobat keeps the + // point at the window's top-left (and the zoom) when following one to another sheet. + let info = pdfcraft_render::DocInfo { + pages: (0..3) + .map(|_| pdfcraft_render::PageInfo { width: 842.0, height: 595.0, label: String::new(), crop: [0.0, 0.0, 842.0, 595.0], rotation: 0 }) + .collect(), + ..Default::default() + }; + // Page 1 at 400 %, scrolled so its point 200 pt across and 125 pt down is at the corner + // where /XYZ puts its point. + let zoomed = |rot: u16, size: Vec2| { + let mut v = view(3, PageLayout::Single, true); + v.rotation = rot; + v.viewport_screen = Rect::from_min_size(Pos2::ZERO, vec2(1000.0, 600.0)); + v.screen_xforms = vec![(0, PageXform { rect: Rect::from_min_size(pos2(SIDE - 800.0, -500.0), size), rot, pw: 842.0, ph: 595.0 })]; + v + }; + let near = |a: Option, b: f32| a.is_some_and(|a| (a - b).abs() < 1e-4); + let mut v = zoomed(0, vec2(4.0 * 842.0, 4.0 * 595.0)); + v.go_to_dest(1, DestView::Xyz { left: None, top: None, zoom: None }, &info); + let (page, fx, fy, rel) = v.goto_point.expect("the place is kept"); + assert_eq!((v.current, page, rel), (1, 1, vec2(SIDE, 0.0))); + assert!(near(fx, 200.0 / 842.0) && near(fy, 125.0 / 595.0), "{fx:?} {fy:?}"); + // A coordinate the destination gives wins; only the null one is kept. + let mut v = zoomed(0, vec2(4.0 * 842.0, 4.0 * 595.0)); + v.go_to_dest(2, DestView::Xyz { left: Some(421.0), top: None, zoom: Some(4.0) }, &info); + let (_, fx, fy, _) = v.goto_point.expect("the place is kept"); + assert!(near(fx, 0.5) && near(fy, 125.0 / 595.0), "{fx:?} {fy:?}"); + // With the view turned a quarter, the point under the corner is still the one kept. + let mut v = zoomed(90, vec2(4.0 * 595.0, 4.0 * 842.0)); + v.go_to_dest(1, DestView::Xyz { left: None, top: None, zoom: None }, &info); + let (_, fx, fy, _) = v.goto_point.expect("the place is kept"); + assert!(near(fx, 800.0 / (4.0 * 595.0)) && near(fy, 500.0 / (4.0 * 842.0)), "{fx:?} {fy:?}"); + // A page that fits the window keeps the layout's placement: no point, as before. + let mut v = zoomed(0, vec2(800.0, 565.0)); + v.go_to_dest(1, DestView::Xyz { left: None, top: None, zoom: None }, &info); + assert_eq!((v.current, v.goto, v.goto_point), (1, Some((1, 0.0)), None)); + } + #[test] fn destinations_set_the_zoom_mode_and_the_point_to_show() { let info = pdfcraft_render::DocInfo { diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 5e8de9375e..2723d16c23 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -40,7 +40,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let full = ui.max_rect(); let drag = ui.interact(full, ui.id().with("titledrag"), Sense::click_and_drag()); if drag.drag_started() { - ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); + window_controls::desktop_grab(ui.ctx(), egui::ViewportCommand::StartDrag); } if drag.double_clicked() { let max = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false)); diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 92bc0b360e..c0e12d5d09 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -86,7 +86,10 @@ impl PdfCraftApp { } commands::is_enabled(spec, &self.session, self.active_ids().map(|(_, id)| id)) && (spec.id != "edit.copy" - || self.active.and_then(|i| self.views.get(i)).is_some_and(|v| !v.organize && v.selected_text().is_some_and(|t| !t.is_empty()))) + || self + .active + .and_then(|i| self.views.get(i)) + .is_some_and(|v| !v.organize && (v.all_text || v.selected_text().is_some_and(|t| !t.is_empty())))) && (spec.needs != commands::Needs::TwoPageView || self.active.and_then(|i| self.views.get(i)).is_some_and(crate::DocView::cover_applies)) && match spec.id { "view.previous_view" => self.active.and_then(|i| self.views.get(i)).is_some_and(|v| !v.back.is_empty()), @@ -194,6 +197,7 @@ impl PdfCraftApp { self.protect_draft = Default::default(); self.dialog = Some(Dialog::Protect); } + "edit.attach" => self.attach_file_dialog(), "protect.remove" => { if self.apply_edit(Edit::RemoveProtection) { self.notify_tr("Security will be removed when you save"); @@ -386,6 +390,7 @@ impl PdfCraftApp { K::Background }; self.marks_draft.replace = id.ends_with(".update"); + self.marks_draft.preview = None; self.dialog = Some(Dialog::Marks(kind)); } "edit.bates" => { @@ -396,6 +401,7 @@ impl PdfCraftApp { self.marks_draft.hf.text[5] = "<>".into(); } self.marks_draft.focused_box = 5; + self.marks_draft.preview = None; self.dialog = Some(Dialog::Marks(pdfcraft_engine::MarkKind::HeaderFooter)); } "edit.header_footer.remove" | "edit.watermark.remove" | "edit.background.remove" => { @@ -669,6 +675,9 @@ impl PdfCraftApp { } "page.crop" => { self.quick_tool = crate::QuickTool::Crop; + if let Some(v) = active.and_then(|i| self.views.get_mut(i)) { + v.organize = false; + } self.notify_tr("Drag a rectangle on a page to crop it; double-click a page for Set Page Boxes"); } "page.boxes" => { diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 86ef9efbfe..27e19b6f1e 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -618,8 +618,14 @@ impl PageCx<'_> { self.xf.norm_to_screen(v[0] / self.xf.pw, v[1] / self.xf.ph) } + /// The page's clockwise `/Rotate`. + fn rotation(&self) -> u16 { + self.info.pages.get(self.page).map_or(0, |p| p.rotation) + } + + /// Where a comment is drawn on screen (a sticky note stays upright on a turned page). fn screen_rect(&self, a: &Annotation) -> Rect { - self.xf.user_rect(self.info, self.page, a.rect) + self.xf.user_rect(self.info, self.page, a.shown_rect(self.rotation())) } /// Top-level comments on this page, in paint order. @@ -688,16 +694,13 @@ impl PageCx<'_> { if let Some(Edit::ResizeAnnotation { page, index, rect }) = pending && (*page, *index) == (self.page, a.index) { - return self.xf.user_rect(self.info, self.page, rect.map(|v| v as f32)); + return self.xf.user_rect(self.info, self.page, a.shown(*rect, self.rotation()).map(|v| v as f32)); } if let Some(Edit::MoveAnnotation { page, index, dx, dy }) = pending && (*page, *index) == (self.page, a.index) { - return self.xf.user_rect( - self.info, - self.page, - [a.rect[0] + *dx as f32, a.rect[1] + *dy as f32, a.rect[2] + *dx as f32, a.rect[3] + *dy as f32], - ); + let r = a.shown_rect(self.rotation()); + return self.xf.user_rect(self.info, self.page, [r[0] + *dx as f32, r[1] + *dy as f32, r[2] + *dx as f32, r[3] + *dy as f32]); } match (gesture, pointer) { (Some(Gesture::Move { page, index, from }), Some(p)) if (*page, *index) == (self.page, a.index) => r.translate(p - *from), @@ -1230,7 +1233,7 @@ fn select_input( let from_rect = cx.screen_rect(a); let shift = ui.input(|i| i.modifiers.shift); let r = resized(from_rect, handle, p - from, corner_ratio(from_rect, aspect_ratio, shift)); - let rect = cx.rect_to_user(r); + let rect = a.rect_for_shown(cx.rect_to_user(r), cx.rotation()); if r.width() >= 4.0 && r.height() >= 4.0 { view.pending_edit = Some(Edit::ResizeAnnotation { page, index, rect }); } @@ -1278,7 +1281,7 @@ fn select_input( cv.selected = Some((cx.page, a.index)); cv.composer = Some(Composer { page: cx.page, - at: [a.rect[2] as f64, a.rect[3] as f64], + at: edit_anchor(cx.info, a), kind: ComposerKind::Edit(a.index), text: a.contents.clone().unwrap_or_default(), focus: true, @@ -1550,6 +1553,20 @@ fn erase(cx: &PageCx<'_>, path: &[[f64; 2]]) -> Option { Some(Edit::Batch { label: "Erase".into(), edits }) } +/// Where the composer for editing `a` opens: the top-right corner of the comment as drawn on the +/// displayed page. +fn edit_anchor(info: &DocInfo, a: &Annotation) -> [f64; 2] { + let Some(p) = info.pages.get(a.page) else { return [f64::from(a.rect[2]), f64::from(a.rect[3])] }; + let r = a.shown_rect(p.rotation); + let (u, v) = (p.user_to_view(r[0], r[1]), p.user_to_view(r[2], r[3])); + p.view_to_user(u[0].max(v[0]), u[1].min(v[1])).map(f64::from) +} + +/// Whether comment `index` on the composer's page is at least partly inside `viewport`, as drawn. +fn edited_in_view(cx: &PageCx<'_>, index: usize, viewport: Rect) -> bool { + cx.info.annotations.iter().any(|a| a.page == cx.page && a.index == index && viewport.intersects(cx.screen_rect(a))) +} + fn new_comment(cx: &PageCx<'_>, tool: CommentTool, shape: Shape, contents: String) -> Edit { Edit::AddAnnotation(NewAnnotation { page: cx.page, shape, style: cx.prefs.style(tool), contents, author: cx.prefs.author.clone() }) } @@ -1559,11 +1576,15 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, let c = view.comments.composer.as_ref()?; let page = c.page; let Some(xf) = view.page_xform(page) else { - // Scrolled away: keep the draft until the page is back. + // Scrolled away: keep the draft until the page is back, and give it the keyboard then. + if let Some(c) = view.comments.composer.as_mut() { + c.focus = true; + } 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 anchor = cx.to_screen(c.at); + let viewport = view.viewport_rect(); let t = Tokens::get(ctx); let mut post = false; let mut cancel = false; @@ -1576,8 +1597,21 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ComposerKind::Replace => "Replacement text", ComposerKind::Edit(_) => "Edit comment", }; + // Its comment scrolled out of the document: keep the draft until it is back, rather than + // pushing the card into the window over the mode bar and toolbar (#4021). An edited comment + // that is only partly scrolled away keeps its card, at the nearest point inside the document. + let mut anchor = anchor; + if !viewport.contains(anchor) { + let part_visible = matches!(c.kind, ComposerKind::Edit(index) if edited_in_view(&cx, index, viewport)); + if !part_visible { + c.focus = true; + return None; + } + anchor = anchor.clamp(viewport.min, viewport.max); + } let pos = pos2(anchor.x + 12.0, anchor.y); - egui::Area::new(egui::Id::new(("comment-composer", view.id.0))).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| { + let area = egui::Area::new(egui::Id::new(("comment-composer", view.id.0))).order(egui::Order::Foreground).constrain_to(viewport); + area.fixed_pos(pos).show(ctx, |ui| { egui::Frame::popup(ui.style()).inner_margin(egui::Margin::same(12)).corner_radius(CornerRadius::same(8)).show(ui, |ui| { ui.set_width(260.0); ui.horizontal(|ui| { @@ -1823,6 +1857,55 @@ pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, tool: &mut QuickTool } } +/// The arrow keys move the selected comment a screen pixel; with ⌘/Ctrl or Shift, ten. Up is up +/// on screen whatever the zoom, the page's rotation and the view's; the keys are taken before +/// the view scrolls or turns the page with them. Each press is one undo step. Text markup stays +/// with its text, and a locked comment where it is. +pub(crate) fn nudge_keys(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) { + if ctx.egui_wants_keyboard_input() || view.pending_edit.is_some() { + return; + } + let cv = &view.comments; + if cv.gesture.is_some() || cv.composer.is_some() { + 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 (Some(xf), Some(p)) = (view.page_xform(page), info.pages.get(page)) else { return }; + if !movable { + return; + } + use egui::{Key, Modifiers}; + let mut step = egui::Vec2::ZERO; + ctx.input_mut(|i| { + for (key, direction) in + [(Key::ArrowLeft, vec2(-1.0, 0.0)), (Key::ArrowRight, vec2(1.0, 0.0)), (Key::ArrowUp, vec2(0.0, -1.0)), (Key::ArrowDown, vec2(0.0, 1.0))] + { + // The modified patterns first: a plain one also matches a Shift press. + if i.consume_key(Modifiers::COMMAND, key) || i.consume_key(Modifiers::SHIFT, key) { + step += direction * 10.0; + } else if i.consume_key(Modifiers::NONE, key) { + step += direction; + } + } + }); + if step == egui::Vec2::ZERO { + return; + } + // The step on screen, in user space: from the page's centre to `step` pixels away. + let user = |s: Pos2| { + let (vx, vy) = xf.screen_to_view(s); + let u = p.view_to_user(vx, vy); + [f64::from(u[0]), f64::from(u[1])] + }; + let (a, b) = (user(xf.rect.center()), user(xf.rect.center() + step)); + let (dx, dy) = (b[0] - a[0], b[1] - a[1]); + if dx.is_finite() && dy.is_finite() && (dx != 0.0 || dy != 0.0) { + view.pending_edit = Some(Edit::MoveAnnotation { page, index, dx, dy }); + } +} + /// Save attachment… and, for a PDF, Open in a new tab, first in a file attachment comment's /// menus as in the Attachments panel: the comment is where the reader finds its file. Reading /// the file changes nothing, so a read-only document offers them too. @@ -1841,7 +1924,15 @@ pub(crate) fn attachment_items(ui: &mut egui::Ui, view: &mut DocView, info: &Doc } /// Items of the canvas context menu for the selected comment (or the page). -pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo, prefs: &CommentPrefs, allowed: bool) -> Option { +/// `can_modify`: the document's pages may change (Flatten comments draws into them). +pub(crate) fn context_menu( + ui: &mut egui::Ui, + view: &mut DocView, + info: &DocInfo, + prefs: &CommentPrefs, + allowed: bool, + 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.is_reply())); match selected { @@ -1851,7 +1942,7 @@ pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo if ui.add_enabled(allowed, egui::Button::new(tl!("Edit text…"))).clicked() { view.comments.composer = Some(Composer { page, - at: [a.rect[2] as f64, a.rect[3] as f64], + at: edit_anchor(info, a), kind: ComposerKind::Edit(index), text: a.contents.clone().unwrap_or_default(), focus: true, @@ -1926,6 +2017,16 @@ pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo } } } + // Every comment of the document into its pages, as Comment ▸ Flatten comments does: signed + // or reviewed pages whose comments should no longer move. + if !info.annotations.is_empty() { + ui.separator(); + if ui.add_enabled(can_modify, egui::Button::new(tl!("Flatten comments"))).clicked() { + view.comments.selected = None; + action = Some(CanvasAction::Edit(Box::new(Edit::Flatten { comments: true, fields: false }))); + ui.close(); + } + } action } @@ -2191,6 +2292,53 @@ mod tests { assert_eq!(changing_points(Some(&dragging), None, (0, 3), &line, to_user, to, false), Some(vec![[10.0, 10.0], [60.0, 10.0]])); assert_eq!(changing_points(None, None, (0, 3), &line, to_user, to, false), None, "not changing"); } + + /// A sticky note on a page turned a quarter is drawn upright beside its `/Rect`: an edit + /// composer stays open while the drawn note is partly in view, even with `/Rect` out of it. + #[test] + fn an_upright_note_on_a_turned_page_is_in_view_where_it_is_drawn() { + let note = |no_rotate| Annotation { + page: 0, + subtype: "Text".into(), + author: None, + contents: None, + 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, + state: None, + quads: Vec::new(), + locked: false, + intent: None, + fill_sign: false, + no_rotate, + points: Vec::new(), + }; + let page = PageInfo { width: 140.0, height: 200.0, label: String::new(), crop: [0.0, 0.0, 200.0, 140.0], rotation: 90 }; + let xf = PageXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(140.0, 200.0)), rot: 0, pw: 140.0, ph: 200.0 }; + let prefs = CommentPrefs::default(); + // Screen x is user y here: `/Rect` spans x 20..40, the upright note x 40..70. + let viewport = Rect::from_min_max(pos2(50.0, 0.0), pos2(140.0, 200.0)); + for (no_rotate, in_view) in [(true, true), (false, false)] { + let info = DocInfo { pages: vec![page.clone()], annotations: vec![note(no_rotate)], ..Default::default() }; + let cx = PageCx { + page: 0, + xf: &xf, + info: &info, + tool: QuickTool::Select, + prefs: &prefs, + allowed: true, + hidden: false, + redact_mark: ([0.0; 3], 1.0), + }; + assert_eq!(edited_in_view(&cx, 0, viewport), in_view, "no_rotate: {no_rotate}"); + } + } + use pdfcraft_render::TextGlyph; /// One glyph per character, `w` points wide, on a line of `em`-point text whose baseline is at `y`, drawn @@ -2408,6 +2556,7 @@ mod tests { intent: None, fill_sign: false, points: Vec::new(), + no_rotate: false, }], ..Default::default() }; @@ -2417,7 +2566,7 @@ mod tests { let mut h = egui_kittest::Harness::new_ui_state( |ui, (view, actions): &mut (DocView, Vec)| { ui.button("Page").context_menu(|ui| { - if let Some(a) = context_menu(ui, view, &info, &prefs, true) { + if let Some(a) = context_menu(ui, view, &info, &prefs, true, true) { actions.push(a); } }); diff --git a/crates/ui-egui/src/comments_panel.rs b/crates/ui-egui/src/comments_panel.rs index 2fe11e98fa..627385b228 100644 --- a/crates/ui-egui/src/comments_panel.rs +++ b/crates/ui-egui/src/comments_panel.rs @@ -279,7 +279,8 @@ pub(crate) fn show( continue; } let thread = replies_of(a); - if let Some(e) = card(ui, t, a, thread, info, view, prefs, allowed, nav) { + let rotation = info.pages.get(a.page).map_or(0, |p| p.rotation); + if let Some(e) = card(ui, t, a, rotation, thread, info, view, prefs, allowed, nav) { edit = Some(e); } } @@ -291,6 +292,8 @@ fn card( ui: &mut egui::Ui, t: &Tokens, a: &Annotation, + // The clockwise `/Rotate` of the comment's page. + rotation: u16, thread: &[&Annotation], info: &DocInfo, view: &mut DocView, @@ -464,7 +467,7 @@ fn card( if click.clicked() { view.comments.selected = Some(key); view.comments.reply.clear(); - *nav = Some(Nav::Flash(a.page, a.rect)); + *nav = Some(Nav::Flash(a.page, a.shown_rect(rotation))); } if click.double_clicked() && allowed { view.comments.open_editor(a.page, a.index, a.contents.clone().unwrap_or_default()); @@ -606,6 +609,12 @@ pub(crate) fn header_controls(ui: &mut egui::Ui, info: &DocInfo, view: &mut DocV ui.close(); } } + // Into the pages, for good once saved: apart from the list's other commands. + ui.separator(); + if ui.button(tl!("Flatten comments")).clicked() { + command = Some("comment.flatten"); + ui.close(); + } }); let filter = icons::button(ui, "filter", 26.0, active, tl!("Filter comments")); egui::Popup::menu(&filter).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 674210529e..2ac624d3fc 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -510,7 +510,7 @@ pub(crate) fn after_refresh(view: &mut DocView, added: &[Added]) { pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, added: &[Added]) -> (Option, bool) { let Some(page) = view.content.draft.as_ref().map(|d| d.page) else { return (None, false) }; let Some(xf) = view.page_xform(page) else { return (None, false) }; - let zoom = xf.rect.width() / xf.pw.max(1.0); + let zoom = xf.zoom(); let Some(draft) = view.content.draft.as_mut() else { return (None, false) }; let font = crate::edit_text_ui::editor_font(&draft.style, (draft.style.size as f32 * zoom).clamp(1.0, 300.0)); let text = AddedText { text: draft.text.clone(), ..draft.style.clone() }; @@ -537,6 +537,16 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad } let r = screen_rect(&xf, info, page, draft.rect); let source_rect = draft.index.map(|_| screen_rect(&xf, info, page, draft.style.rect)); + // At least 60 points wide, so a short box can still be clicked into. Where that is wider than + // the box, the editor reaches past it away from the edge its text keeps (the right edge for + // right-aligned text, both halves for centred), so the text stays where the page draws it. + let width = r.width().max(60.0); + let anchor = match draft.style.align { + pdfcraft_engine::TextAlign::Center => 0.5, + pdfcraft_engine::TextAlign::Right => 1.0, + pdfcraft_engine::TextAlign::Left | pdfcraft_engine::TextAlign::Justify => 0.0, + }; + let at = r.min - egui::vec2((width - r.width()) * anchor, 0.0); let background_ready = draft.index.is_some_and(|index| view.text_background.ready_added(page, index)); let tokens = Tokens::get(ctx); let (mut commit, mut cancel, mut done) = (false, false, false); @@ -548,7 +558,7 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad egui::Area::new(egui::Id::new(("added-text", view.id.0))) .fade_in(false) .order(egui::Order::Foreground) - .fixed_pos(r.min) + .fixed_pos(at) .constrain_to(view.viewport_rect()) .constrain(false) .show(ctx, |ui| { @@ -560,18 +570,34 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad // The box is white in every theme, so the caret must not follow the theme (pale in dark mode) (#655). ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(Color32::WHITE); let [cr, cg, cb] = t.style.color.map(|v| (v.clamp(0.0, 1.0) * 255.0) as u8); + // Laid out as the page draws it, so opening the box doesn't show it realigned. + // shortcut: justified text is shown left-aligned here (TextEdit can't justify), until the editor gets its own layouter. + let halign = match t.style.align { + pdfcraft_engine::TextAlign::Center => egui::Align::Center, + pdfcraft_engine::TextAlign::Right => egui::Align::Max, + pdfcraft_engine::TextAlign::Left | pdfcraft_engine::TextAlign::Justify => egui::Align::Min, + }; + // Character spacing shows as the page will draw it, scaled like the font. + let letter_spacing = (t.style.char_spacing * f64::from(zoom)) as f32; + let mut spaced = |ui: &egui::Ui, text: &dyn egui::TextBuffer, wrap_width: f32| { + let job = spaced_job(text.as_str(), &font, Color32::from_rgb(cr, cg, cb), letter_spacing, wrap_width, halign); + ui.fonts_mut(|f| f.layout_job(job)) + }; ui.horizontal_top(|ui| { ui.spacing_mut().item_spacing.x = 4.0; - let resp = ui.add( - egui::TextEdit::multiline(&mut t.text) - .font(font) - .desired_width(r.width().max(60.0)) - .desired_rows(1) - .background_color(if background_ready { Color32::TRANSPARENT } else { Color32::WHITE }) - .text_color(Color32::from_rgb(cr, cg, cb)) - .hint_text(tl!("Type text")) - .id_salt("added-text-edit"), - ); + let mut edit = egui::TextEdit::multiline(&mut t.text) + .font(font.clone()) + .desired_width(width) + .desired_rows(1) + .horizontal_align(halign) + .background_color(if background_ready { Color32::TRANSPARENT } else { Color32::WHITE }) + .text_color(Color32::from_rgb(cr, cg, cb)) + .hint_text(tl!("Type text")) + .id_salt("added-text-edit"); + if letter_spacing != 0.0 { + edit = edit.layouter(&mut spaced); + } + let resp = ui.add(edit); if t.focus { resp.request_focus(); t.focus = false; @@ -714,6 +740,8 @@ impl crate::PdfCraftApp { /// Copy Image: image `index` on `page`, at its own resolution, to the clipboard. pub(crate) fn copy_page_image(&mut self, page: usize, index: usize) -> bool { + // A newer copy wins over a whole-document copy still reading its pages. + self.document_copy = None; let Some((_, id)) = self.active_ids() else { return false }; if !self.may_copy(id) { return false; @@ -927,6 +955,17 @@ pub(crate) fn image_panel(ui: &mut egui::Ui, t: &Tokens, img: &pdfcraft_engine:: out } +/// The editor's layout of added text with character spacing. The page puts the spacing after every +/// character, the last of a line included (`latin_width`), while epaint puts it only between +/// characters: the wrap width gives up one spacing so both break lines at the same place. +fn spaced_job(text: &str, font: &egui::FontId, color: Color32, letter_spacing: f32, wrap_width: f32, halign: egui::Align) -> egui::text::LayoutJob { + let format = egui::TextFormat { font_id: font.clone(), color, extra_letter_spacing: letter_spacing, ..Default::default() }; + let mut job = egui::text::LayoutJob::single_section(text.to_owned(), format); + job.wrap.max_width = (wrap_width - letter_spacing).max(0.0); + job.halign = halign; + job +} + #[cfg(test)] mod tests { use egui_kittest::kittest::Queryable; @@ -975,4 +1014,44 @@ mod tests { let i = flipped("Flip vertical"); assert!(i.flip_v && !i.flip_h, "Flip vertical mirrors top and bottom"); } + + /// #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] + fn editor_wraps_where_the_saved_text_does_with_trailing_spacing() { + let rows = |wrap: f32| { + let mut n = 0; + let mut h = egui_kittest::Harness::new_ui_state( + move |ui, n: &mut usize| { + let font = egui::FontId::proportional(14.0); + let job = spaced_job("AAAA BBBB", &font, Color32::BLACK, 10.0, wrap, egui::Align::Min); + *n = ui.fonts_mut(|f| f.layout_job(job)).rows.len(); + }, + n, + ); + h.run(); + n = *h.state(); + n + }; + // The text's width without the trailing spacing, as epaint measures it. + let one_line = rows(f32::INFINITY); + assert_eq!(one_line, 1); + let width = { + let mut w = 0.0; + let mut h = egui_kittest::Harness::new_ui_state( + |ui, w: &mut f32| { + let font = egui::FontId::proportional(14.0); + let job = spaced_job("AAAA BBBB", &font, Color32::BLACK, 10.0, f32::INFINITY, egui::Align::Min); + *w = ui.fonts_mut(|f| f.layout_job(job)).rect.width(); + }, + w, + ); + h.run(); + w = *h.state(); + w + }; + // Page width = width + 10 (spacing after the last character) > width + 5: the page wraps. + assert_eq!(rows(width + 5.0), 2, "the saved text wraps here, so the editor must too"); + assert_eq!(rows(width + 10.0), 1, "and keeps the line when the spacing fits"); + } } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 1550d446cc..47c5b200a3 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -36,6 +36,8 @@ //! for this session without changing the saved preference. `ui.state.default_mode` reports it. //! `theme=light|dark|system` changes the saved theme preference; `ui.state.theme_preference` //! reports the choice and `ui.state.theme` reports the resolved light/dark colours. +//! `comment-tool-pinned=true|false` remembers the annotation quick bar's pin across restarts; +//! `ui.state.comment_tool_pinned` reports the same preference. //! - `ui.open {path}`: open a file. A file whose tab is already open focuses it //! (`"focused": true`) instead of adding a tab; native opens report `pending: true` until //! they complete and are listed in `ui.state.pending_opens`; only a failed open replies `Err`. @@ -731,6 +733,7 @@ impl Host for crate::PdfCraftApp { "home": self.active.is_none(), "mode": format!("{:?}", self.mode), "default_mode": self.default_mode, + "comment_tool_pinned": self.comment_prefs.pinned, "left_panel": if self.left_open { json!(format!("{:?}", self.left)) } else { Value::Null }, "tools_side": if self.docking.tools_on_right() { "right" } else { "left" }, "right_panel": self.right.map(|r| format!("{r:?}")), diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 0337a8cbfb..086dd2346c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -36,6 +36,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { let Some(dialog) = app.dialog else { app.props_draft = None; app.view_draft = None; + app.custom_draft = None; return; }; // Properties ▸ Fonts shows the fonts as they are now, after any content edits. @@ -49,6 +50,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { { app.props_draft = Some((id, INFO_KEYS.map(|k| doc.info_value(k).unwrap_or_default()))); app.view_draft = Some((id, doc.initial_view())); + app.custom_draft = Some((id, crate::CustomDraft { props: doc.custom_info(), ..Default::default() })); } let mut apply = false; let mut split_now: Option = None; @@ -114,6 +116,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { (PropsTab::InitialView, tl!("Initial View")), (PropsTab::Security, tl!("Security")), (PropsTab::Fonts, tl!("Fonts")), + (PropsTab::Custom, tl!("Custom")), (PropsTab::Advanced, tl!("Advanced")), ] { if widgets::mode_tab(ui, label, tab == tb).clicked() { @@ -345,6 +348,89 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { row(ui, &f.name, detail); } } + PropsTab::Custom => { + // Add takes a new name, Update changes the value of the listed property the + // Name field names, Delete removes it; OK writes the changes as one step. + let editable = doc.allows_modification(); + let Some((_, d)) = app.custom_draft.as_mut() else { return }; + let l = ui.label(egui::RichText::new(tl!("Name:")).color(t.text_muted)); + ui.add_enabled( + editable, + widgets::line(&mut d.name).desired_width(420.0).background_color(t.field).id_salt("custom-name"), + ) + .labelled_by(l.id); + ui.end_row(); + let l = ui.label(egui::RichText::new(tl!("Value:")).color(t.text_muted)); + ui.add_enabled( + editable, + widgets::line(&mut d.value).desired_width(420.0).background_color(t.field).id_salt("custom-value"), + ) + .labelled_by(l.id); + ui.end_row(); + // Untrimmed: a file's `/Project#20` is not `/Project`, and a typed space is refused. + let name = d.name.clone(); + let value = d.value.trim().to_owned(); + let listed = d.props.iter().position(|(k, _)| *k == name); + // An entry the list leaves out (not text, only padding, or past its 256) keeps its value. + let file_props = doc.custom_info(); + let unlisted = doc.has_info(&name) && !file_props.iter().any(|(k, _)| *k == name); + let refused = pdfcraft_engine::check_custom_key(&name).err().filter(|_| !name.is_empty()).map(|e| e.to_string()); + ui.label(""); + // A file can hold a custom name that `check_custom_key` refuses, such as one + // with a space: the tab lists it, and Add, Update and Delete stay off for it. + let why = |b: egui::Response| match &refused { + Some(why) => b.on_disabled_hover_text(crate::i18n::words(why)), + None => b, + }; + // A name that isn't UTF-8 is listed with U+FFFD and can't be written back as it is. + let editable = editable && refused.is_none() && !name.contains(char::REPLACEMENT_CHARACTER); + // `custom_info` lists at most 256 properties, and a new name goes after the file's + // entries, so Add is off once the draft or the file's own list holds 256. + let room = d.props.len() < 256 && file_props.len() < 256; + ui.horizontal(|ui| { + let add = editable && room && listed.is_none() && !unlisted && !name.is_empty() && !value.is_empty(); + if why(ui.add_enabled(add, egui::Button::new(tl!("Add")))).clicked() { + d.props.push((name.clone(), value.clone())); + d.name.clear(); + d.value.clear(); + } + let update = editable && !value.is_empty() && listed.and_then(|n| d.props.get(n)).is_some_and(|(_, v)| *v != value); + if why(ui.add_enabled(update, egui::Button::new(tl!("Update")))).clicked() + && let Some(p) = listed.and_then(|n| d.props.get_mut(n)) + { + p.1 = value.clone(); + } + if why(ui.add_enabled(editable && listed.is_some(), egui::Button::new(tl!("Delete")))).clicked() + && let Some(n) = listed.filter(|n| *n < d.props.len()) + { + d.props.remove(n); + d.name.clear(); + d.value.clear(); + } + }); + ui.end_row(); + if d.props.is_empty() { + ui.label(""); + ui.label(egui::RichText::new(tl!("None")).color(t.text_faint)); + ui.end_row(); + } + // A click on a property fills the fields with it; values are document data. + let mut picked = None; + for (k, v) in &d.props { + if ui.selectable_label(*k == name, k.as_str()).clicked() { + picked = Some((k.clone(), v.clone())); + } + ui.scope(|ui| { + ui.set_max_width(420.0); + ui.add(egui::Label::new(v.as_str()).wrap()); + }); + ui.end_row(); + } + if let Some((k, v)) = picked { + d.name = k; + d.value = v; + } + } PropsTab::Advanced => { row(ui, "PDF version", i.pdf_version.clone()); row(ui, "Location", crate::bidi::visual(doc.path.as_deref().unwrap_or_default()).into_owned()); @@ -594,15 +680,31 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { return; } Dialog::Extract => { - let count = app.active_ids().map_or(0, |(i, _)| app.views[i].target_pages().len()); + let (selected, total) = app.active_ids().map_or((Vec::new(), 0), |(i, _)| (app.views[i].target_pages(), app.views[i].page_count())); ui.label(egui::RichText::new(tl!("Extract pages")).font(theme::semibold(18.0))); ui.add_space(8.0); - let text = if count == 1 { - tl!("1 page selected.").to_string() - } else { - crate::i18n::fmt(tl!("{n} pages selected."), &[("n", &count.to_string())]) + // Page ranges to take instead of the selection (#3593); the hint is the selection. + let hint = match crate::combine_ui::range_of(&selected, total) { + all if all.is_empty() && total > 1 => format!("1-{total}"), + all if all.is_empty() => "1".to_string(), + some => some, }; - ui.label(text); + ui.horizontal(|ui| { + let l = ui.label(tl!("Pages")); + ui.add(egui::TextEdit::singleline(&mut app.extract_draft.pages).hint_text(hint).desired_width(240.0)).labelled_by(l.id); + }); + let chosen = app.extract_draft.pages_to_extract(selected, total); + match &chosen { + Ok(pages) if pages.len() == 1 => { + ui.label(tl!("1 page selected.")); + } + Ok(pages) => { + ui.label(crate::i18n::fmt(tl!("{n} pages selected."), &[("n", &pages.len().to_string())])); + } + Err(e) => { + ui.colored_label(ui.visuals().error_fg_color, e); + } + } ui.add_space(4.0); // The name the pages are saved under (#737); separate files add " (page N)". ui.horizontal(|ui| { @@ -613,7 +715,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { ui.add(widgets::checkbox(&mut app.extract_draft.separate, tl!("Extract pages as separate files"))); ui.add_space(12.0); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if widgets::pill_button(ui, tl!("Extract"), true).clicked() { + if ui.add_enabled_ui(chosen.is_ok(), |ui| widgets::pill_button(ui, tl!("Extract"), true)).inner.clicked() { extract_now = true; close = true; } @@ -1360,8 +1462,10 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { && let Some(i) = app.active { // Keep the (possibly renamed) field selected. - let prefix = d.field.rsplit_once('.').map(|(p, _)| format!("{p}.")).unwrap_or_default(); - let name = format!("{prefix}{}", d.name.trim()); + // A dotted name is the full name; a plain one renames the field within its parent. + let typed = d.name.trim(); + let prefix = d.field.rsplit_once('.').filter(|_| !typed.contains('.')).map(|(p, _)| format!("{p}.")).unwrap_or_default(); + let name = format!("{prefix}{typed}"); if let (Some(before), Some(after)) = (before, form(app)) { let n = pdfcraft_engine::form_calculations_changed(&before, &after).iter().filter(|f| **f != name).count(); if n > 0 { @@ -1425,6 +1529,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { app.dialog = None; app.props_draft = None; app.view_draft = None; + app.custom_draft = None; app.bulk_field_props = None; } else { app.dialog = Some(next); @@ -1476,6 +1581,18 @@ fn draft_changes(app: &PdfCraftApp) -> Option> { { edits.push(Edit::SetInitialView(Box::new(v.clone()))); } + // Custom: deleted properties are cleared, then added and changed ones are set. + if let Some((cid, d)) = &app.custom_draft + && cid == id + { + let before = doc.custom_info(); + for (k, _) in before.iter().filter(|(k, _)| !d.props.iter().any(|(n, _)| n == k)) { + edits.push(Edit::SetInfo { key: k.clone(), value: String::new() }); + } + for (k, v) in d.props.iter().filter(|(k, v)| !before.iter().any(|(n, w)| n == k && w == v)) { + edits.push(Edit::SetInfo { key: k.clone(), value: v.clone() }); + } + } Some(edits) } diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 3a1ab04367..65c7bd780a 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -1838,6 +1838,57 @@ mod tests { assert!(app.views[0].line_editor.is_some(), "the paragraph stays open for editing"); } + #[test] + fn save_applies_the_paragraph_open_in_the_editor() { + // Save wrote the file without the paragraph still open in the editor (#3496). + let (mut app, mut ed) = preview_app(); + ed.text = "Text preview".into(); + app.views[0].line_editor = Some(ed); + let dir = std::env::temp_dir().join(format!("pdfcraft-ui-open-paragraph-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + app.save_override = Some(dir.join("out.pdf").to_string_lossy().into_owned()); + + assert!(app.save_active(crate::SaveTarget::InPlace)); + assert!(app.views[0].line_editor.is_none()); + let doc = app.session.get(app.views[0].id).unwrap(); + assert_eq!(doc.text_lines(0).iter().map(|l| l.text.as_str()).collect::(), "Text preview"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_refused_text_box_does_not_lose_the_open_paragraph() { + let (mut app, mut ed) = preview_app(); + ed.text = "Text preview".into(); + app.views[0].line_editor = Some(ed); + let draft = crate::content_ui::TextDraft { + page: 9, + index: None, + rect: [20.0, 20.0, 120.0, 40.0], + text: "Typed text".into(), + style: crate::content_ui::default_style(), + focus: true, + }; + app.views[0].content.draft = Some(draft.clone()); + + assert!(!app.commit_typing_in(app.views[0].id)); + assert_eq!(app.views[0].content.draft, Some(draft), "the refused text box stays open"); + assert!(app.views[0].line_editor.is_none(), "the paragraph was applied"); + let doc = app.session.get(app.views[0].id).unwrap(); + assert_eq!(doc.text_lines(0).iter().map(|l| l.text.as_str()).collect::(), "Text preview"); + } + + #[test] + fn a_refused_paragraph_stays_open_with_its_text() { + let (mut app, mut ed) = preview_app(); + ed.text = "Text preview".into(); + ed.page = 9; + app.views[0].line_editor = Some(ed.clone()); + + assert!(!app.commit_typing_in(app.views[0].id)); + assert_eq!(app.views[0].line_editor, Some(ed), "the paragraph keeps its unsaved text"); + } + #[test] fn text_background_is_cached_without_editing_and_released_on_cancel() { let (app, ed) = preview_app(); diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 1b036ba33e..ccce6c38d2 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -38,6 +38,11 @@ pub enum SaveTarget { As, } +/// Text typed into a new text box that isn't added to the page yet. +fn new_text_typed(v: &crate::DocView) -> bool { + v.content.draft.as_ref().is_some_and(|d| d.index.is_none() && !d.text.trim().is_empty()) +} + impl PdfCraftApp { /// Apply an edit to the active document. Returns `true` on success; failures are shown. pub fn apply_edit(&mut self, edit: Edit) -> bool { @@ -265,6 +270,10 @@ impl PdfCraftApp { } Some(crate::canvas::ViewAction::Extract) => self.open_extract_dialog(), Some(crate::canvas::ViewAction::Split) => self.dialog = Some(crate::Dialog::Split), + Some(crate::canvas::ViewAction::SplitBeforeSelected) => { + self.split_draft.mode = crate::SplitMode::Selection; + self.dialog = Some(crate::Dialog::Split); + } Some(crate::canvas::ViewAction::CopyPages { cut }) => self.copy_pages(cut), Some(crate::canvas::ViewAction::PastePages) => self.paste_pages(), Some(crate::canvas::ViewAction::EnhanceCamera) => self.enhance_camera(i), @@ -489,7 +498,7 @@ impl PdfCraftApp { && committed.as_ref().is_some_and(|draft| draft.name == focus.name) && let Some(field) = self.session.get(view.id).and_then(|doc| doc.form.iter().find(|field| field.name == focus.name)) { - focus.text = field.value.first().cloned().unwrap_or_default(); + focus.text = crate::forms_ui::draft_text(field); focus.picked = field.value.clone(); } if let Some(page) = signature_page @@ -537,17 +546,44 @@ impl PdfCraftApp { self.notify(crate::content_ui::undrawable_message(c)); return false; } - let typing = self - .views - .get(i) - .is_some_and(|v| v.pending_edit.is_some() || v.forms.focus.is_some() || v.fill_text.as_ref().is_some_and(|t| !t.text.trim().is_empty())); + let typing = self.views.get(i).is_some_and(|v| { + v.pending_edit.is_some() + || v.forms.focus.is_some() + || v.content.draft.is_some() + || v.line_editor.as_ref().is_some_and(|ed| ed.has_changes()) + || v.fill_text.as_ref().is_some_and(|t| !t.text.trim().is_empty()) + }); if self.active != Some(i) { if !typing { return true; } self.active = Some(i); } - self.commit_form_typing() && self.commit_open_fill_text(id) + self.commit_form_typing() && self.commit_open_fill_text(id) && self.commit_open_text_edits(id) + } + + /// The Edit a PDF ▸ Text boxes (new text, and a paragraph open for editing) commit on + /// click-away. Save does that first, so the typed text is in the file instead of being left + /// open on the screen (#3496). + fn commit_open_text_edits(&mut self, id: pdfcraft_engine::DocId) -> bool { + let Some(doc) = self.session.get(id) else { return true }; + let Some(view) = self.views.iter_mut().find(|v| v.id == id) else { return true }; + // Both are taken out of the view before either edit runs, so keep what is needed to put + // back whichever one the document refuses: typed text must not be lost. + let (draft, select_added, editor) = (view.content.draft.clone(), view.content.select_added, view.line_editor.clone()); + let added = view.content.finish_draft(doc.added()); + let paragraph = view.line_editor.take_if(|ed| ed.has_changes()).and_then(crate::edit_text_ui::finished_edit); + let added_ok = added.is_none_or(|edit| self.apply_edit(edit)); + let paragraph_ok = paragraph.is_none_or(|edit| self.apply_edit(edit)); + if let Some(view) = self.views.iter_mut().find(|v| v.id == id) { + if !added_ok { + (view.content.draft, view.content.select_added) = (draft, select_added); + } + if !paragraph_ok { + view.line_editor = editor; + } + } + added_ok && paragraph_ok } /// The Fill & Sign type box commits on click-away. Save does that first, so the text is in @@ -605,6 +641,7 @@ impl PdfCraftApp { || v.content.blocked().is_some() || v.forms.focus.as_ref().is_some_and(|f| crate::forms_ui::draft_edit(f, &doc.form).is_some()) || v.fill_text.as_ref().is_some_and(|t| !t.text.trim().is_empty()) + || new_text_typed(v) || v.line_editor.as_ref().map_or_else(|| false, |ed| ed.has_unsaved_text()) } @@ -902,10 +939,15 @@ impl PdfCraftApp { /// Close tab `index` for a close request, then ask about the next dirty document, or quit. fn close_and_continue(&mut self, ctx: &egui::Context, index: usize, req: CloseRequest) { self.close_tab(index); - // A newer prompt wins: the user may have started another close while a save was pending. + // A newer prompt wins: the user may have started another close while a save was pending, + // and seen its question, which keeps counting as seen. if (req == CloseRequest::Quit || req == CloseRequest::All) && self.close_request.is_none() { match self.first_dirty() { - Some(_) => self.close_request = Some(req), + Some(_) => { + // A question about the next document is a new one. + self.save_prompt_seen = false; + self.close_request = Some(req); + } None if req == CloseRequest::Quit => self.quit(ctx), None => {} } @@ -962,6 +1004,32 @@ impl PdfCraftApp { self.shutdown_recovery(); } } + + /// Ask for the user's attention when the "save changes?" question opens while PdfCraft is in + /// the background (Quit in the Dock menu, logging out, or an agent quitting), and withdraw the + /// request once the user comes back or the question is answered. + /// + /// The question holds up the quit the user asked for, so the request is critical, as AppKit + /// makes it for a modal panel that an inactive app presents: the Dock icon bounces until + /// PdfCraft is active, Windows flashes the taskbar button until the window is in front, and + /// X11 sets the urgency hint, which stays until it is reset. A question that opens in front + /// asks nothing, and neither does leaving it open: the user has seen it. Each document asked + /// about when quitting is a new question, so one that opens in the background asks even after + /// a question the user saw; a request that still stands carries over to it. + pub(crate) fn attend_save_prompt(&mut self, ctx: &egui::Context) { + let open = self.close_request.is_some(); + let focused = ctx.input(|i| i.focused); + if self.save_prompt_attention { + if focused || !open { + ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Reset)); + self.save_prompt_attention = false; + } + } else if open && !self.save_prompt_seen && !focused { + ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Critical)); + self.save_prompt_attention = true; + } + self.save_prompt_seen = open; + } } /// The single page a content edit changes (`None` for other edits). Like comment edits, @@ -1474,6 +1542,32 @@ mod tests { out } + #[test] + fn save_commits_the_open_text_box() { + let dir = staging_dir("open-text-box"); + let dest = dir.join("out.pdf"); + let mut app = crate::PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("a.pdf", None, one_page_pdf()).unwrap(); + app.save_override = Some(dest.to_string_lossy().into_owned()); + let style = crate::content_ui::default_style(); + app.views[0].content.draft = Some(crate::content_ui::TextDraft { + page: 0, + index: None, + rect: [20.0, 20.0, 120.0, 40.0], + text: "Typed text".into(), + style, + focus: true, + }); + + assert!(app.save_active(SaveTarget::InPlace)); + let id = app.views[0].id; + let added = app.session.get(id).unwrap().added(); + assert_eq!(added.len(), 1, "the open text box was committed before the save"); + assert!(app.views[0].content.draft.is_none()); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn save_asks_where_to_keep_an_email_attachment_and_leaves_the_temporary_copy_alone() { let dir = staging_dir("mail-temp"); @@ -1606,6 +1700,56 @@ mod revert_draft_tests { assert!(app.unsaved_flag.load(Ordering::Relaxed)); } + /// Text typed into a new text box is lost on close unless the save prompt appears; an empty + /// box has nothing to lose. + #[test] + fn text_typed_into_a_new_text_box_counts_as_unsaved_work() { + use crate::content_ui::{TextDraft, default_style}; + let mut app = app(); + let draft = |text: &str| TextDraft { page: 0, index: None, rect: [0.0; 4], text: text.into(), style: default_style(), focus: true }; + assert!(!app.has_unsaved_work(0)); + app.views[0].content.draft = Some(draft(" \n")); + assert!(!app.has_unsaved_work(0)); + app.views[0].content.draft = Some(draft("Typed")); + assert!(!app.session.get(app.views[0].id).unwrap().dirty); + assert!(app.has_unsaved_work(0)); + } + + /// Close ▸ Save, and Quit ▸ Save, write the text typed into a new text box before the tab + /// closes. + #[test] + fn saving_on_close_keeps_text_typed_into_a_new_text_box() { + use crate::content_ui::{TextDraft, default_style}; + use pdfcraft_engine::AddedContent; + for quit in [false, true] { + let dir = std::env::temp_dir().join(format!("pdfcraft-ui-new-text-{}-{quit}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("source.pdf").to_string_lossy().into_owned(); + let bytes = include_bytes!("../tests/data/form.pdf").to_vec(); + std::fs::write(&path, &bytes).unwrap(); + let mut app = PdfCraftApp::new(); + app.open_bytes("source.pdf", Some(path.clone()), bytes).unwrap(); + let rect = [72.0, 72.0, 272.0, 92.0]; + app.views[0].content.draft = Some(TextDraft { page: 0, index: None, rect, text: "Typed".into(), style: default_style(), focus: true }); + let ctx = egui::Context::default(); + if quit { + assert!(!app.request_quit(&ctx)); + } else { + app.request_close_tab(0); + } + assert!(app.close_request.is_some()); + app.resolve_close(&ctx, Some(true)); + assert!(app.views.is_empty(), "the tab closes once saved"); + + let mut reopened = PdfCraftApp::new(); + reopened.open_bytes("source.pdf", Some(path.clone()), std::fs::read(&path).unwrap()).unwrap(); + let doc = reopened.session.get(reopened.views[0].id).unwrap(); + assert!(doc.added().iter().any(|a| matches!(&a.content, AddedContent::Text(t) if t.text == "Typed")), "quit: {quit}"); + let _ = std::fs::remove_dir_all(&dir); + } + } + #[test] fn successful_revert_clears_focused_or_queued_drafts_only_on_its_document() { for queued in [false, true] { @@ -1681,3 +1825,45 @@ mod content_page_tests { assert_eq!(content_page(&Edit::SetInfo { key: "Title".into(), value: "x".into() }), None); } } + +#[cfg(test)] +mod attention_tests { + use super::CloseRequest; + use crate::PdfCraftApp; + + /// The attention requests one frame of `attend_save_prompt` sends, with the window `focused` + /// or not. + fn attend(app: &mut PdfCraftApp, ctx: &egui::Context, focused: bool) -> Vec { + let mut out = ctx.run_ui(egui::RawInput { focused, ..Default::default() }, |ui| app.attend_save_prompt(ui.ctx())); + // The frame's font texture isn't uploaded anywhere in a unit test. + out.textures_delta.clear(); + out.viewport_output + .values() + .flat_map(|v| v.commands.iter()) + .filter_map(|c| if let egui::ViewportCommand::RequestUserAttention(a) = c { Some(*a) } else { None }) + .collect() + } + + /// A save that finishes after a newer "save changes?" question opened in front, and after the + /// user went to another app, asks for no attention: the user saw that question. + #[test] + fn a_save_finishing_behind_a_newer_question_the_user_saw_asks_for_nothing() { + let ctx = egui::Context::default(); + let mut app = PdfCraftApp::new(); + for name in ["a.pdf", "b.pdf", "c.pdf"] { + let bytes = app.session.create_blank(200.0, 300.0, 1).unwrap(); + app.open_bytes(name, None, bytes.as_ref().clone()).unwrap(); + app.active = Some(app.views.len() - 1); + assert!(app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 })); + } + // a.pdf was answered with Save while quitting. While that save is pending, the user closes + // c.pdf's tab, and its question opens in front. + let c = app.views[2].id; + app.close_request = Some(CloseRequest::Tab(c)); + assert!(attend(&mut app, &ctx, true).is_empty()); + // a.pdf's save finishes once the user has gone to another app. + app.close_and_continue(&ctx, 0, CloseRequest::Quit); + assert_eq!(app.close_request, Some(CloseRequest::Tab(c)), "the newer question stands"); + assert!(attend(&mut app, &ctx, false).is_empty(), "the user has seen it"); + } +} diff --git a/crates/ui-egui/src/field_table_ui.rs b/crates/ui-egui/src/field_table_ui.rs index 9314dc927d..d9b5255bfd 100644 --- a/crates/ui-egui/src/field_table_ui.rs +++ b/crates/ui-egui/src/field_table_ui.rs @@ -342,11 +342,37 @@ fn column_bar(ui: &mut egui::Ui, d: &mut FieldTableDraft, t: &theme::Tokens) { }) .collect() }; + let n = d.table.columns.len(); + let ends = edges(&d.shares()); + let active = column_dividers(ui, d, bar, &ends); + + let painter = ui.painter_at(bar); + // Input may have resized a pair; paint the shares after handling it. + let shares = d.shares(); + let mut left = bar.left(); + for (c, (&right, share)) in edges(&shares).iter().zip(&shares).enumerate() { + // The last column ends at the bar's edge, whatever rounding says. + let right = if c + 1 == n { bar.right() } else { right.min(bar.right()) }; + let column = Rect::from_min_max(pos2(left, bar.top()), pos2(right, bar.bottom())); + painter.rect_filled(column, CornerRadius::ZERO, SELECT_BLUE.gamma_multiply(if c % 2 == 0 { 0.11 } else { 0.055 })); + if column.width() > 32.0 { + painter.text(column.center(), egui::Align2::CENTER_CENTER, format!("{:.0}%", share * 100.0), theme::medium(11.0), t.text); + } + left = right; + } + painter.rect_stroke(bar, CornerRadius::same(3), Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + for (i, &x) in edges(&shares).iter().enumerate().take(n.saturating_sub(1)) { + let stroke = if active == Some(i) { Stroke::new(3.0, SELECT_BLUE) } else { Stroke::new(1.5, t.border) }; + painter.line_segment([pos2(x, bar.top() + 1.0), pos2(x, bar.bottom() - 1.0)], stroke); + } +} + +/// Handle input at the pre-interaction edges and return the hovered, dragged or focused divider. +fn column_dividers(ui: &mut egui::Ui, d: &mut FieldTableDraft, bar: Rect, ends: &[f32]) -> Option { let n = d.table.columns.len(); // Wide enough to grab, never reaching past the middle of a column. let reach = (bar.width() / n.max(1) as f32 / 2.0).clamp(2.0, 8.0); let mut active = None; - let ends = edges(&d.shares()); for (i, &x) in ends.iter().enumerate().take(n.saturating_sub(1)) { let hit = Rect::from_min_max(pos2(x - reach, bar.top()), pos2(x + reach, bar.bottom())); let resp = ui.interact(hit, ui.id().with(("field table divider", i)), Sense::click_and_drag()); @@ -374,24 +400,7 @@ fn column_bar(ui: &mut egui::Ui, d: &mut FieldTableDraft, t: &theme::Tokens) { } } - let painter = ui.painter_at(bar); - let shares = d.shares(); - let mut left = bar.left(); - for (c, (&right, share)) in edges(&shares).iter().zip(&shares).enumerate() { - // The last column ends at the bar's edge, whatever rounding says. - let right = if c + 1 == n { bar.right() } else { right.min(bar.right()) }; - let column = Rect::from_min_max(pos2(left, bar.top()), pos2(right, bar.bottom())); - painter.rect_filled(column, CornerRadius::ZERO, SELECT_BLUE.gamma_multiply(if c % 2 == 0 { 0.11 } else { 0.055 })); - if column.width() > 32.0 { - painter.text(column.center(), egui::Align2::CENTER_CENTER, format!("{:.0}%", share * 100.0), theme::medium(11.0), t.text); - } - left = right; - } - painter.rect_stroke(bar, CornerRadius::same(3), Stroke::new(1.0, t.border), egui::StrokeKind::Inside); - for (i, &x) in edges(&shares).iter().enumerate().take(n.saturating_sub(1)) { - let stroke = if active == Some(i) { Stroke::new(3.0, SELECT_BLUE) } else { Stroke::new(1.5, t.border) }; - painter.line_segment([pos2(x, bar.top() + 1.0), pos2(x, bar.bottom() - 1.0)], stroke); - } + active } /// The table being set up, on its page: each cell with its name, or the table's outline in red @@ -401,7 +410,7 @@ pub(crate) fn paint_preview(painter: &egui::Painter, xf: &PageXform, page: usize if preview.page != page { return; } - let zoom = (xf.rect.width() / xf.pw.max(1.0)).clamp(0.5, 3.0); + let zoom = xf.zoom().clamp(0.5, 3.0); let font = theme::regular((9.0 * zoom).clamp(7.0, 13.0)); let on_screen = |r: [f64; 4]| xf.view_rect([r[0] as f32, r[1] as f32, r[2] as f32, r[3] as f32]); let outline = on_screen(preview.laid.table.rect); @@ -448,6 +457,62 @@ mod tests { d } + fn bar_frame(ctx: &egui::Context, d: &mut FieldTableDraft, key: Option, focus: Option) -> egui::FullOutput { + let events = key + .into_iter() + .flat_map(|key| { + [true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::NONE }) + }) + .collect(); + ctx.run_ui( + egui::RawInput { screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(400.0, 160.0))), events, ..Default::default() }, + |ui| { + let other = ui.button("Other control"); + if let Some(i) = focus { + ui.memory_mut(|memory| memory.request_focus(ui.id().with(("field table divider", i)))); + } else { + other.request_focus(); + } + column_bar(ui, d, &theme::Tokens::get(ctx)); + }, + ) + } + + #[test] + fn arrow_keys_resize_only_the_focused_pair_and_paint_its_new_widths() { + for columns in [3, 30] { + let ctx = egui::Context::default(); + ctx.set_fonts(theme::font_definitions()); + let mut d = draft(columns); + let before: Vec = d.table.columns.iter().map(|c| c.width).collect(); + let total: f64 = before.iter().sum(); + bar_frame(&ctx, &mut d, None, Some(0)).drop_without_applying_deltas(); + let output = bar_frame(&ctx, &mut d, Some(egui::Key::ArrowRight), Some(0)); + assert!((d.table.columns[0].width - before[0] - total * 0.01).abs() < 1e-9); + assert!((d.table.columns[1].width - before[1] + total * 0.01).abs() < 1e-9); + assert_eq!(d.table.columns.iter().skip(2).map(|c| c.width).collect::>(), before[2..]); + let painted: Vec = output + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + egui::Shape::Rect(r) if r.fill == SELECT_BLUE.gamma_multiply(0.11) || r.fill == SELECT_BLUE.gamma_multiply(0.055) => Some(r.rect), + _ => None, + }) + .collect(); + assert_eq!(painted.len(), columns); + let width: f32 = painted.iter().map(Rect::width).sum(); + assert!((f64::from(painted[0].width() / width) - d.shares()[0]).abs() < 1e-6, "paint the changed shares in the same frame"); + output.drop_without_applying_deltas(); + bar_frame(&ctx, &mut d, Some(egui::Key::ArrowLeft), Some(0)).drop_without_applying_deltas(); + assert!(d.table.columns.iter().zip(&before).all(|(c, w)| (c.width - w).abs() < 1e-9), "left reverses right on the focused divider"); + bar_frame(&ctx, &mut d, Some(egui::Key::ArrowRight), None).drop_without_applying_deltas(); + assert!( + d.table.columns.iter().zip(&before).all(|(c, w)| (c.width - w).abs() < 1e-9), + "arrows on another control leave the columns alone" + ); + } + } + /// Thirty even columns leave each pair less than twice the least share a divider keeps: the /// two bounds crossed, and `f64::clamp` panicked on the first drag. #[test] diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index aa2aad02d1..0b031c8a8a 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -166,6 +166,28 @@ pub struct ExtractDraft { /// the document's name when it opens; empty means that name too. As separate files each /// page is ` (page N).pdf`, and a single page the user renamed is `.pdf`. pub name: String, + /// The pages to extract as typed ranges, 1-based ("1-5, 8, 11-13") (#3593). Empty means the + /// selected pages (or the current one); the dialog opens with it empty. + pub pages: String, +} + +impl ExtractDraft { + /// The pages to extract (0-based, in the order typed, each once) from a document of `count` + /// pages: the typed [`Self::pages`] ranges, or `selected` when none are typed. Says what is + /// wrong with a range that can't be read or takes no page. + pub fn pages_to_extract(&self, selected: Vec, count: usize) -> Result, String> { + if self.pages.trim().is_empty() { + return Ok(selected); + } + match pdfcraft_engine::print::select_pages(count, Some(&self.pages), &[], pdfcraft_engine::print::Subset::All, false) { + Ok(p) if p.is_empty() => Err(tl!("No pages selected").to_string()), + Ok(p) => { + let mut seen = std::collections::HashSet::new(); + Ok(p.into_iter().filter(|page| seen.insert(*page)).collect()) + } + Err(e) => Err(e.to_string()), + } + } } /// `name` made safe as a file name on every platform: no path separators, reserved or control @@ -605,23 +627,35 @@ impl PdfCraftApp { } } - /// Show Extract pages, its file name filled in with the active document's (#737). + /// Show Extract pages, its file name filled in with the active document's (#737) and no + /// page ranges typed, so it takes the selected pages until some are (#3593). pub fn open_extract_dialog(&mut self) { self.extract_draft.name = self.active_ids().and_then(|(_, id)| self.session.get(id)).map(|d| strip_pdf(&d.name).to_string()).unwrap_or_default(); + self.extract_draft.pages.clear(); self.dialog = Some(crate::Dialog::Extract); } - /// Copy the selected pages (or the current page) into a new unsaved document tab. + /// Copy the pages typed in Extract pages, else the selected pages (or the current page), into + /// a new unsaved document tab. pub fn extract_selection(&mut self) { // What's typed in a form field is part of the document (#166). if !self.commit_form_typing() { return; } let Some((i, id)) = self.active_ids() else { return }; - let pages = self.views[i].target_pages(); - let stem = self.session.get(id).map(|d| strip_pdf(&d.name).to_string()).unwrap_or_default(); let opts = self.extract_draft.clone(); + let pages = match opts.pages_to_extract(self.views[i].target_pages(), self.views[i].page_count()) { + Ok(pages) => pages, + Err(e) => { + self.notify_fmt("Couldn't extract pages: {e}", &[("e", &e)]); + return; + } + }; + // Deleting takes the pages in document order (typed ranges may run backwards). + let mut doomed = pages.clone(); + doomed.sort_unstable(); + let stem = self.session.get(id).map(|d| strip_pdf(&d.name).to_string()).unwrap_or_default(); // The name typed in the dialog (#737), else the document's. let chosen = file_stem_from_user(&opts.name); let renamed = !chosen.is_empty() && chosen != file_stem_from_user(&stem); @@ -651,7 +685,7 @@ impl PdfCraftApp { } let unchanged = app.session.get(id).map(|d| d.edit_generation()) == generation; if unchanged && app.active_ids().map(|(_, active)| active) == Some(id) { - app.apply_edit(pdfcraft_engine::Edit::DeletePages { pages }); + app.apply_edit(pdfcraft_engine::Edit::DeletePages { pages: doomed }); } else { app.notify_tr("The pages were extracted but not deleted, because the document changed while you were choosing a folder."); } @@ -677,7 +711,7 @@ impl PdfCraftApp { if opts.delete { // Back on the original document. self.active = Some(i); - self.apply_edit(pdfcraft_engine::Edit::DeletePages { pages }); + self.apply_edit(pdfcraft_engine::Edit::DeletePages { pages: doomed }); } } diff --git a/crates/ui-egui/src/fill_sign.rs b/crates/ui-egui/src/fill_sign.rs index 64c8ee0d40..1684c80bea 100644 --- a/crates/ui-egui/src/fill_sign.rs +++ b/crates/ui-egui/src/fill_sign.rs @@ -712,7 +712,7 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, let xf = view.page_xform(tb.page)?; let v = info.pages.get(tb.page)?.user_to_view(tb.at[0] as f32, tb.at[1] as f32); let pos = xf.norm_to_screen(v[0] / xf.pw, v[1] / xf.ph); - let zoom = xf.rect.width() / xf.pw.max(1.0); + let zoom = xf.zoom(); let mut commit = false; let mut cancel = false; // Shown at once, like the paragraph editor: a fade-in shows the page's copy through it. diff --git a/crates/ui-egui/src/format_text_ui.rs b/crates/ui-egui/src/format_text_ui.rs index 749f09dcc2..9849920ac6 100644 --- a/crates/ui-egui/src/format_text_ui.rs +++ b/crates/ui-egui/src/format_text_ui.rs @@ -2,8 +2,9 @@ //! alignment; bold, italic, underline, superscript and subscript; line and paragraph spacing; //! horizontal scale and character spacing. One panel serves a paragraph being edited (with the //! fonts already on its page and the spacing controls) and added text (the standard families, -//! size, colour, style and alignment). What PdfCraft can't apply yet (lists, superscript and -//! subscript, paragraph spacing, or spacing for added text) is shown disabled and says so. +//! size, colour, style, alignment and character spacing). What PdfCraft can't apply yet (lists, +//! superscript and subscript, paragraph spacing, or line spacing and scale for added text) is shown +//! disabled and says so. //! //! The icons are Lucide's (AGENTS.md §1); only the arrangement follows Acrobat. @@ -197,9 +198,10 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, look: &mut AddedText, extras: // Line and paragraph spacing; horizontal scale and character spacing: two columns, each an // icon and its control, `half` wide. let half = ((ui.available_width() - 2.0 * LABEL_ICON - 8.0 - 3.0 * GAP) / 2.0).max(40.0); - ui.add_enabled_ui(extras.is_some(), |ui| { - let mut fallback = Extras::default(); - let e = extras.as_deref_mut().unwrap_or(&mut fallback); + let editing = extras.is_some(); + let mut fallback = Extras::default(); + let e = extras.as_deref_mut().unwrap_or(&mut fallback); + ui.add_enabled_ui(editing, |ui| { row(ui, |ui| { labelled_icon(ui, t, "arrow-up-down", tl!("Line spacing")); let own = tl!("Auto").to_string(); @@ -222,16 +224,20 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, look: &mut AddedText, extras: .on_disabled_hover_text(tip); }); }); - // Any value the engine takes can be typed; the lists hold the common ones. - row(ui, |ui| { + }); + // Any value the engine takes can be typed; the lists hold the common ones. Added text has + // character spacing of its own (#4245), but no scale yet. + row(ui, |ui| { + ui.add_enabled_ui(editing, |ui| { let label = labelled_icon(ui, t, "move-horizontal", tl!("Horizontal scale")); let scale = Number { value: &mut e.scale, range: SCALE_RANGE, speed: 1.0, suffix: " %" }; number_with_presets(ui, scale, &SCALES, half, (label.id, tl!("Horizontal scale"))); - ui.add_space(8.0); - let label = labelled_icon(ui, t, "arrow-left-right", tl!("Character spacing")); - let spacing = Number { value: &mut e.char_spacing, range: CHAR_SPACING_RANGE, speed: 0.1, suffix: " pt" }; - number_with_presets(ui, spacing, &CHAR_SPACINGS, half, (label.id, tl!("Character spacing"))); }); + ui.add_space(8.0); + let label = labelled_icon(ui, t, "arrow-left-right", tl!("Character spacing")); + let value = if editing { &mut e.char_spacing } else { &mut look.char_spacing }; + let spacing = Number { value, range: CHAR_SPACING_RANGE, speed: 0.1, suffix: " pt" }; + number_with_presets(ui, spacing, &CHAR_SPACINGS, half, (label.id, tl!("Character spacing"))); }); out.changed = (look.clone(), extras.as_deref().copied()) != before; diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 76e0926721..0d81e547ca 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -4,7 +4,8 @@ //! cancels, Tab moves to the next field). Check boxes and radio buttons toggle on click, or on //! Space once Tab or a click has focused them. List boxes are directly selectable in-place on the //! page (clicking picks an option; Ctrl-click toggles on multi-select lists). Combo boxes open a -//! list of their options. Every change is one undoable engine edit. +//! list of their options; an editable one (Edit flag) also takes typed text in a box over the +//! field. Every change is one undoable engine edit. use std::collections::HashSet; @@ -89,11 +90,29 @@ fn fillable(f: &FormField) -> bool { !f.read_only() && !matches!(f.kind, FormFieldKind::PushButton | FormFieldKind::Signature) } +/// Whether a combo box takes typed text as well as a choice from its list: the Edit flag +/// (ISO 32000-2 Table 233), which only means something with Combo set. +fn editable_combo(f: &FormField) -> bool { + f.kind == FormFieldKind::Combo && f.has(field_flags::EDIT) +} + +/// The text a field's editor starts with: its value, or for a combo box the shown text of the +/// option it picks (a free value typed into an editable one has no option, and shows as is). +pub(crate) fn draft_text(f: &FormField) -> String { + let value = f.value.first().cloned().unwrap_or_default(); + if f.kind == FormFieldKind::Combo + && let Some((_, shown)) = f.options.iter().find(|(export, _)| *export == value) + { + return shown.clone(); + } + value +} + fn open_focus(f: &FormField, widget: usize) -> Focus { Focus { name: f.name.clone(), widget, - text: f.value.first().cloned().unwrap_or_default(), + text: draft_text(f), picked: f.value.clone(), request_focus: true, select_all: false, @@ -273,6 +292,11 @@ pub(crate) fn draft_edit(focus: &Focus, form: &[FormField]) -> Option { let f = form.iter().find(|f| f.name == focus.name)?; let value = match f.kind { FormFieldKind::Text if f.value.first().map(String::as_str).unwrap_or("") != focus.text => FieldValue::Text(focus.text.clone()), + // Typed into an editable combo box: a shown text picks its option, anything else is the + // value itself (the engine maps it, and refuses free text when Edit is clear). + FormFieldKind::Combo if editable_combo(f) && focus.text != draft_text(f) && f.value.first() != Some(&focus.text) => { + FieldValue::Choice(if focus.text.is_empty() { Vec::new() } else { vec![focus.text.clone()] }) + } _ => return None, }; Some(Edit::SetFieldValue { name: f.name.clone(), value }) @@ -288,10 +312,53 @@ pub(crate) fn commit(view: &mut DocView, form: &[FormField]) { } } +/// The text box of an editable combo box, over the field. What is typed becomes the value when +/// Enter, Tab or a click away commits it; a pick from the list still sets an option. +#[allow(clippy::too_many_arguments)] +fn combo_editor(ctx: &egui::Context, view: &mut DocView, form: &[FormField], f: &FormField, w: &FormWidget, rect: Rect, zoom: f32, widget: usize) { + let mut enter = false; + egui::Area::new(egui::Id::new(("form-editor", view.id.0))) + .fade_in(false) + .order(egui::Order::Foreground) + .fixed_pos(rect.min) + .constrain_to(view.viewport_rect()) + .constrain(false) + .show(ctx, |ui| { + ui.set_min_size(rect.size()); + let Some(fx) = view.forms.focus.as_mut() else { return }; + let font = (field_text_size(ui.painter(), f, w, &fx.text) * zoom).clamp(3.0, 64.0); + // The box is pale yellow in every theme, as a text field's is (#655). + ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(FIELD_FILL); + let te = egui::TextEdit::singleline(&mut fx.text) + .desired_width((rect.width() - 6.0 * zoom + 6.0).max(1.0)) + .font(egui::FontId::proportional(font)) + .background_color(FIELD_FILL) + .text_color(Color32::BLACK) + .margin(egui::Margin::symmetric(3, 1)) + .id(egui::Id::new(("form-field", view.id.0, &f.name, widget))); + let r = ui.add_sized(rect.size(), te); + if std::mem::take(&mut fx.request_focus) { + r.request_focus(); + // Entered with Tab: typing replaces the value. + if std::mem::take(&mut fx.select_all) + && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) + { + let n = fx.text.chars().count(); + st.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(n)))); + st.store(ui.ctx(), r.id); + } + } + enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + }); + if enter { + commit(view, form); + } +} + const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; -/// The date picker under a focused date field. Returns the picked date, formatted with the -/// field's own format. +/// The date picker under a focused date field: a month of days, and a Today button under them. +/// Returns the picked date, formatted with the field's own format. fn calendar(ctx: &egui::Context, view: &mut DocView, field: egui::Rect, fmt: &str, today: (i64, u32, u32)) -> Option { use pdfcraft_engine::form_scripts::{DateTime, format_date, parse_date}; let fx = view.forms.focus.as_mut()?; @@ -345,7 +412,7 @@ fn calendar(ctx: &egui::Context, view: &mut DocView, field: egui::Rect, fmt: &st (Some(a), Some(b)) => (b - a).num_days() as usize, _ => 31, }; - egui::Grid::new(("date-grid", view.id.0)).spacing([4.0, 2.0]).show(ui, |ui| { + let grid = egui::Grid::new(("date-grid", view.id.0)).spacing([4.0, 2.0]).show(ui, |ui| { for d in ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"] { ui.label(egui::RichText::new(tl_ctx!("calendar weekday", d)).small()); } @@ -381,6 +448,24 @@ fn calendar(ctx: &egui::Context, view: &mut DocView, field: egui::Rect, fmt: &st } } }); + // Today, whichever month the calendar shows: picked as a click on its day would + // pick it. A year the field's date can't hold offers no button. + if let Ok(ty) = i32::try_from(today.0) { + ui.add_space(4.0); + // As wide as the grid, so the button sits centred under it without widening the popup. + ui.allocate_ui_with_layout(vec2(grid.response.rect.width(), 0.0), egui::Layout::top_down(egui::Align::Center), |ui| { + let mut button = ui.small_button(tl!("Today")); + if let Some(month) = today.1.checked_sub(1).and_then(|i| MONTHS.get(i as usize)) { + button = button.on_hover_text(crate::i18n::fmt( + tl!("{month} {day}, {y}"), + &[("month", tl_ctx!("calendar date", month)), ("day", &today.2.to_string()), ("y", &ty.to_string())], + )); + } + if button.clicked() { + picked = Some(format_date(DateTime { y: ty, m: today.1, d: today.2, hh: 0, mm: 0, ss: 0 }, fmt)); + } + }); + } }); }); if let Some(fx) = view.forms.focus.as_mut() { @@ -516,8 +601,26 @@ fn paint_unbaked_widget(painter: &egui::Painter, xf: &PageXform, f: &FormField, } } +/// How the focused text field lays text out, when it is one that doesn't scroll (`DoNotScroll`): +/// its editor then takes no more than fits ([`overlay`]). +pub(crate) fn text_box(doc: &pdfcraft_engine::Document, view: &DocView, form: &[FormField]) -> Option { + let fx = view.forms.focus.as_ref()?; + let f = form.iter().find(|f| f.name == fx.name)?; + let no_scroll = f.kind == FormFieldKind::Text && f.has(field_flags::DO_NOT_SCROLL); + no_scroll.then(|| doc.field_text_box(&fx.name, fx.widget)).flatten() +} + /// The in-place editor or option list for the focused field. Returns an edit to apply. -pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, form: &[FormField], today: (i64, u32, u32)) -> Option { +/// `text_box`, for a text field that doesn't scroll, is how its widget lays text out: the editor +/// then takes no more than fits. +pub(crate) fn overlay( + ctx: &egui::Context, + view: &mut DocView, + info: &DocInfo, + form: &[FormField], + text_box: Option<&form_appearance::TextBox>, + today: (i64, u32, u32), +) -> Option { let pressed = view.forms.pressed; if !ctx.input(|i| i.pointer.primary_down()) { view.forms.pressed = false; @@ -541,7 +644,7 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, f return None; }; let rect = widget_rect(&xf, info, page, w.rect); - let zoom = xf.rect.width() / xf.pw.max(1.0); + let zoom = xf.zoom(); let t = Tokens::get(ctx); let mut edit = None; let mut close = false; @@ -566,6 +669,8 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, f let font = (field_text_size(ui.painter(), f, w, &fx.text) * zoom).clamp(3.0, 64.0); // The field is pale yellow in every theme, so the caret must not follow the theme (pale in dark mode) (#655). ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(FIELD_FILL); + // A field that doesn't scroll takes no more than fits (ISO 32000-2 Table 228). + let before = text_box.map(|_| fx.text.clone()); let mut te = if multiline { egui::TextEdit::multiline(&mut fx.text) } else { egui::TextEdit::singleline(&mut fx.text) }; te = te // Wraps (inside the 3 px side margins) at the width the size was fitted to: @@ -589,6 +694,19 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, f laid_out = Some((out.galley, out.galley_pos)); out.response.response }); + if let (Some(tb), Some(before)) = (text_box, before) + && fx.text != before + { + let (kept, caret) = tb.fitted_edit(&before, &fx.text); + if kept != fx.text { + fx.text = kept; + if let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) { + st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(caret)))); + st.store(ui.ctx(), r.id); + } + ui.ctx().request_repaint(); + } + } if let Some(press) = fx.press.take() && let Some((galley, pos)) = laid_out && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) @@ -644,6 +762,11 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, f } } FormFieldKind::Combo => { + // An editable combo box has a text box over the field as well as its list (ISO + // 32000-2 Table 233, Edit). + if editable_combo(f) { + combo_editor(ctx, view, form, f, w, rect, zoom, focus.widget); + } let resp = egui::Area::new(egui::Id::new(("form-choices", view.id.0))) .order(egui::Order::Foreground) .fixed_pos(rect.left_bottom() + vec2(0.0, 2.0)) diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index c0e6ab85f7..12291701e2 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -611,6 +611,7 @@ 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) ضغط بنية المستند (تدفقات الكائنات) @@ -2040,6 +2041,10 @@ Perimeter المحيط Area المساحة Drawing scale مقياس الرسم + Scale المقياس + Common scales مقاييس شائعة + Enter a scale such as 1:50. أدخل مقياسًا مثل 1:50. + Applying a scale switches units to m / mm. يؤدي تطبيق المقياس إلى تحويل الوحدات إلى م / مم. Snapping الانجذاب Export measurements تصدير القياسات Open a PDF to measure it. افتح ملف PDF لقياسه. @@ -2193,6 +2198,7 @@ calendar date October أكتوبر calendar date November نوفمبر calendar date December ديسمبر 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 استعادة آخر عرض (الصفحة والتكبير وعرض الصفحات) عند فتح المستند مرة أخرى Clear Recent Files مسح الملفات الأخيرة Remove from Recent إزالة من الأخيرة Remove {name} from Recent إزالة {name} من الأخيرة @@ -2340,6 +2346,8 @@ key Space مسافة Printer paper and scaling may differ. Check driver settings. قد يختلف ورق الطابعة ومقياس الطباعة. تحقق من إعدادات برنامج التشغيل. Drawing scale changes. Use Actual size for full-scale plans. يتغير مقياس الرسم. استخدم الحجم الفعلي لطباعة المخططات بمقياسها الأصلي. 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 إعادة تحميل @@ -2758,6 +2766,11 @@ certificate Version الإصدار Make properties default جعل الخصائص افتراضية Pages selected الصفحات المحددة Page properties خصائص الصفحة + Attach a file… إرفاق ملف… + Delete attachment حذف المرفق + Attaching files is not available on the web yet إرفاق الملفات غير متاح على الويب بعد + Attach {name} إرفاق {name} + Delete attachment {key} حذف المرفق {key} Bulleted list قائمة نقطية Numbered list قائمة مرقمة Alignment المحاذاة @@ -2791,4 +2804,7 @@ certificate Version الإصدار field names can't contain a period: {name} لا يمكن أن تحتوي أسماء الحقول على نقطة: {name} two cells would both be named {name} ستحمل خليتان الاسم نفسه {name} a field named {name} already exists يوجد حقل باسم {name} بالفعل + +# 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 d21743411a..95daeb79ec 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -282,6 +282,7 @@ 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 Кодирай с Flate потоците, кодирани с LZW Remove invalid links and bookmarks Премахни невалидните връзки и отметки Remove unreferenced named destinations Премахни неизползваните именувани местоназначения Compress document structure (object streams) Компресирай структурата на документа (потоци от обекти) @@ -1902,6 +1903,10 @@ Perimeter Периметър Area Площ Drawing scale Мащаб на чертежа + Scale Мащаб + Common scales Често използвани мащаби + Enter a scale such as 1:50. Въведете мащаб, например 1:50. + Applying a scale switches units to m / mm. Прилагането на мащаб превключва единиците на m / mm. Snapping Прилепване Export measurements Експортирай измерванията Open a PDF to measure it. Отворете PDF файл, за да го измервате. @@ -2178,6 +2183,7 @@ signature pad Type Въвеждане action wizard Start Старт Margins (in) Полета (in) 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 Възстановяване на последния изглед (страница, мащаб и показване на страниците) при повторно отваряне на документ Clear Recent Files Изчисти последно отваряните файлове Remove from Recent Премахни от последно отваряните Remove {name} from Recent Премахни {name} от последно отваряните @@ -2282,6 +2288,8 @@ key Space Интервал Printer paper and scaling may differ. Check driver settings. Хартията и мащабът на принтера може да се различават. Проверете настройките на драйвера. Drawing scale changes. Use Actual size for full-scale plans. Мащабът на чертежа се променя. Използвайте Действителен размер за планове в оригинален мащаб. 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 Презареди @@ -2444,6 +2452,11 @@ animation effects Off Изключени Make properties default Задаване като свойства по подразбиране Pages selected Избрани страници Page properties Свойства на страницата + Attach a file… Прикачване на файл… + Delete attachment Изтриване на прикачения файл + Attaching files is not available on the web yet Прикачването на файлове още не е налично в уеб версията + Attach {name} Прикачване на {name} + Delete attachment {key} Изтриване на прикачения файл {key} Font Шрифт Bulleted list Списък с водещи символи Numbered list Номериран списък @@ -2478,4 +2491,7 @@ animation effects Off Изключени field names can't contain a period: {name} имената на полетата не може да съдържат точка: {name} two cells would both be named {name} две клетки биха имали едно и също име {name} a field named {name} already exists вече съществува поле с име {name} + +# 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 b0d7fe5273..bd1eb542e2 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -574,6 +574,7 @@ Discard private data of other applications Descarta les dades privades d'altres aplicacions Discard hidden text Descarta el text ocult Use Flate to encode streams that are not encoded Utilitza Flate per codificar els fluxos que no estan codificats + In streams that use LZW encoding, use Flate instead Als fluxos que utilitzen codificació LZW, utilitza Flate Remove invalid links and bookmarks Elimina els enllaços i marcadors no vàlids Remove unreferenced named destinations Elimina les destinacions amb nom sense referència Compress document structure (object streams) Comprimeix l'estructura del document (fluxos d'objectes) @@ -2000,6 +2001,10 @@ action wizard Start Inicia Perimeter Perímetre Area Àrea Drawing scale Escala del dibuix + Scale Escala + Common scales Escales habituals + Enter a scale such as 1:50. Introduïu una escala com 1:50. + Applying a scale switches units to m / mm. En aplicar una escala, les unitats canvien a m / mm. Snapping Ajust Export measurements Exporta les mesures Open a PDF to measure it. Obre un PDF per mesurar-lo. @@ -2137,6 +2142,8 @@ action wizard Start Inicia Home / End Inici / Fi H / Space (hold) H / Espai (mantén premut) Reload documents when their files change on disk Torna a carregar els documents quan els seus fitxers canviïn al disc + Don't show again No ho tornis a mostrar + Show a notice when a document is secured Mostra un avís quan un document està protegit This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Aquest fitxer ha canviat al disc. Torna'l a carregar per veure'n la versió nova; es perdran els canvis sense desar. Reload Torna a carregar Couldn't reload {name}: {e} No s'ha pogut tornar a carregar {name}: {e} @@ -2774,4 +2781,15 @@ theme Appearance Aspecte field names can't contain a period: {name} els noms dels camps no poden contenir cap punt: {name} two cells would both be named {name} dues cel·les es dirien {name} a field named {name} already exists ja existeix un camp anomenat {name} + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Avui + +# 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 Restaura l'última vista (pàgina, zoom i visualització de pàgina) quan es torna a obrir un document + Attach a file… Adjunta un fitxer… + Attach {name} Adjunta {name} + 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 2f17db6002..252e7ae6ed 100644 --- a/crates/ui-egui/src/i18n/ckb.tsv +++ b/crates/ui-egui/src/i18n/ckb.tsv @@ -581,6 +581,7 @@ 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 بەکارهێنانی Flate لە جیاتی LZW بۆ ئەو لێشاوانەی بە LZW کۆد کراون Remove invalid links and bookmarks لابردنی بەستەر و دڵخوازە نادروستەکان Remove unreferenced named destinations لابردنی مەبەستە ناونراوەکانی ئاماژەپێنەکراو Compress document structure (object streams) پەستاندنی پێکهاتەی بەڵگەنامە (لێشاوەکانی تەن) @@ -2003,6 +2004,10 @@ Perimeter چێوە Area ڕووبەر Drawing scale پێوەری وێنەکێشان + Scale پێوەر + Common scales پێوەرە باوەکان + Enter a scale such as 1:50. پێوەرێک بنووسە وەک 1:50. + Applying a scale switches units to m / mm. جێبەجێکردنی پێوەر یەکەکان دەگۆڕێت بۆ م / ملم. Snapping لکاندن Export measurements هەناردەکردنی پێوانەکان Open a PDF to measure it. PDFێک بکەرەوە بۆ پێوانەکردنی. @@ -2303,6 +2308,8 @@ key Space بۆشایی Printer paper and scaling may differ. Check driver settings. ڕەنگە کاغەز و پێوەری چاپکەر جیاواز بن. ڕێکخستنەکانی درایڤەر بپشکنە. Drawing scale changes. Use Actual size for full-scale plans. پێوەری وێنەکێشان دەگۆڕێت. «قەبارەی ڕاستەقینە» بەکاربهێنە بۆ پلانەکان بە پێوەری تەواو. Reload documents when their files change on disk دووبارە بارکردنی بەڵگەنامەکان کاتێک فایلەکانیان لەسەر دیسک دەگۆڕێن + Don't show again جارێکی تر پیشان مەدە + Show a notice when a document is secured پیشاندانی ئاگادارکردنەوە کاتێک بەڵگەنامەیەک پارێزراوە 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} @@ -2713,4 +2720,7 @@ certificate Version وەشان Make properties default کردنی تایبەتمەندییەکان بە بنەڕەتی Pages selected پەڕە هەڵبژێردراوەکان Page properties تایبەتمەندییەکانی پەڕە + +# 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 d230171fac..b9f986df82 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -139,7 +139,10 @@ Fit one full page Celá jedna stránka Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. Používá se, když PDF nepožaduje rozložení ani zvětšení. Plynulé posouvání mezi stránkami neskáče. Reopen the files that were open when PdfCraft last closed Znovu otevřít soubory, které byly otevřené při posledním zavření aplikace PdfCraft + Restore the last view (page, zoom and page display) when a document is opened again Při opětovném otevření dokumentu obnovit poslední zobrazení (stránku, zvětšení a rozložení stránek) Reload documents when their files change on disk Znovu načítat dokumenty při změně jejich souborů na disku + Don't show again Znovu nezobrazovat + Show a notice when a document is secured Zobrazit upozornění, když je dokument zabezpečený Always use my default zoom and page layout Vždy používat mé výchozí zvětšení a rozvržení stránky This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Tento soubor se na disku změnil. Znovu načíst zobrazí novou verzi a zahodí neuložené změny; Uložit ji nahradí touto verzí. Reload Znovu načíst @@ -692,6 +695,7 @@ key Space Mezerník Discard private data of other applications Zahodit soukromá data jiných aplikací Discard hidden text Zahodit skrytý text Use Flate to encode streams that are not encoded Kódovat nekódované proudy metodou Flate + In streams that use LZW encoding, use Flate instead Proudy kódované LZW kódovat metodou Flate Remove invalid links and bookmarks Odebrat neplatné odkazy a záložky Remove unreferenced named destinations Odebrat nepoužívané pojmenované cíle Compress document structure (object streams) Komprimovat strukturu dokumentu (proudy objektů) @@ -2087,6 +2091,10 @@ key Space Mezerník Perimeter Obvod Area Plocha Drawing scale Měřítko výkresu + Scale Měřítko + Common scales Běžná měřítka + Enter a scale such as 1:50. Zadejte měřítko, například 1:50. + Applying a scale switches units to m / mm. Použití měřítka přepne jednotky na m / mm. Snapping Přichytávání Export measurements Exportovat měření Open a PDF to measure it. Otevřete PDF, které chcete měřit. @@ -2555,6 +2563,11 @@ animation effects Off Vypnuto Make properties default Nastavit vlastnosti jako výchozí Pages selected Vybrané stránky Page properties Vlastnosti stránky + 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 + Attach {name} Připojit {name} + Delete attachment {key} Odstranit přílohu {key} Font Písmo Bulleted list Odrážkový seznam Numbered list Číslovaný seznam @@ -2822,4 +2835,7 @@ certificate Version Verze field names can't contain a period: {name} názvy polí nesmějí obsahovat tečku: {name} two cells would both be named {name} dvě buňky by se jmenovaly {name} a field named {name} already exists pole s názvem {name} už existuje + +# 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 6caf50ea36..eea5315649 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -584,6 +584,7 @@ Discard private data of other applications Private Daten anderer Anwendungen verwerfen Discard hidden text Ausgeblendeten Text verwerfen Use Flate to encode streams that are not encoded Nicht codierte Streams mit Flate codieren + In streams that use LZW encoding, use Flate instead In LZW-codierten Streams stattdessen Flate verwenden Remove invalid links and bookmarks Ungültige Links und Lesezeichen entfernen Remove unreferenced named destinations Nicht referenzierte benannte Ziele entfernen Compress document structure (object streams) Dokumentstruktur komprimieren (Objekt-Streams) @@ -1976,6 +1977,10 @@ Perimeter Umfang Area Fläche Drawing scale Zeichnungsmaßstab + Scale Maßstab + Common scales Gängige Maßstäbe + Enter a scale such as 1:50. Geben Sie einen Maßstab wie 1:50 ein. + Applying a scale switches units to m / mm. Beim Anwenden eines Maßstabs werden die Einheiten auf m / mm umgestellt. Snapping Einrasten Export measurements Messungen exportieren Open a PDF to measure it. Öffne ein PDF, um es zu messen. @@ -2186,6 +2191,7 @@ signature pad Type Tippen certificate Subject Zertifikatsinhaber action wizard Start Starten Reopen the files that were open when PdfCraft last closed Beim Start die Dateien wieder öffnen, die beim letzten Beenden von PdfCraft geöffnet waren + Restore the last view (page, zoom and page display) when a document is opened again Beim erneuten Öffnen eines Dokuments die letzte Ansicht (Seite, Zoom und Seitenanzeige) wiederherstellen # Labels that reach tl!() through a variable (Fill & Sign buttons, link prompts, notices, history labels, checker and certificate rows) Add {what} {what} hinzufügen @@ -2324,6 +2330,8 @@ key Space Leertaste Printer paper and scaling may differ. Check driver settings. Druckerpapier und Skalierung können abweichen. Treibereinstellungen prüfen. Drawing scale changes. Use Actual size for full-scale plans. Der Zeichnungsmaßstab ändert sich. Für maßstabsgetreue Pläne Tatsächliche Größe verwenden. Reload documents when their files change on disk Dokumente neu laden, wenn sich ihre Dateien auf dem Datenträger ändern + Don't show again Nicht mehr anzeigen + Show a notice when a document is secured Hinweis anzeigen, wenn ein Dokument geschützt ist Always use my default zoom and page layout Immer meinen Standardzoom und mein Standard-Seitenlayout verwenden This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Diese Datei wurde auf dem Datenträger geändert. „Neu laden“ zeigt die neue Version und verwirft Ihre ungespeicherten Änderungen; „Speichern“ ersetzt sie durch diese Version. Reload Neu laden @@ -2482,6 +2490,11 @@ animation effects Off Aus Make properties default Eigenschaften als Standard festlegen Pages selected Ausgewählte Seiten Page properties Seiteneigenschaften + 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 + Attach {name} {name} anhängen + Delete attachment {key} Anhang {key} löschen Font Schrift Bulleted list Aufzählung Numbered list Nummerierte Liste @@ -2516,4 +2529,7 @@ animation effects Off Aus field names can't contain a period: {name} Feldnamen dürfen keinen Punkt enthalten: {name} two cells would both be named {name} zwei Zellen hätten beide den Namen {name} a field named {name} already exists ein Feld namens {name} ist bereits vorhanden + +# 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 ed34f5c6c4..07973764a3 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1417,6 +1417,10 @@ Perimeter Περίμετρος Area Εμβαδόν Drawing scale Κλίμακα σχεδίου + Scale Κλίμακα + Common scales Συνηθισμένες κλίμακες + Enter a scale such as 1:50. Εισαγάγετε μια κλίμακα, όπως 1:50. + Applying a scale switches units to m / mm. Η εφαρμογή κλίμακας αλλάζει τις μονάδες σε m / mm. Snapping Προσκόλληση Export measurements Εξαγωγή μετρήσεων Open a PDF to measure it. Ανοίξτε ένα PDF για να πραγματοποιήσετε μετρήσεις. @@ -1467,6 +1471,7 @@ Couldn't import the signature image: {e} Δεν ήταν δυνατή η εισαγωγή της εικόνας υπογραφής: {e} Go to page… Μετάβαση στη σελίδα… 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 Επαναφορά της τελευταίας προβολής (σελίδα, ζουμ και εμφάνιση σελίδων) όταν ένα έγγραφο ανοίγει ξανά The PDF text fonts can't draw “{c}” ({code}). Choose supported text. Οι γραμματοσειρές κειμένου PDF δεν μπορούν να εμφανίσουν τον χαρακτήρα «{c}» ({code}). Επιλέξτε κείμενο που υποστηρίζεται. Open arrow Ανοιχτή αιχμή βέλους Closed arrow Κλειστή αιχμή βέλους @@ -1923,6 +1928,7 @@ 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 Χρήση Flate αντί για LZW στις ροές με κωδικοποίηση LZW Remove invalid links and bookmarks Αφαίρεση μη έγκυρων συνδέσμων και σελιδοδεικτών Remove unreferenced named destinations Αφαίρεση ονομασμένων προορισμών χωρίς αναφορές Compress document structure (object streams) Συμπίεση δομής εγγράφου (ροές αντικειμένων) @@ -2328,6 +2334,8 @@ comment menu Edit Επεξεργασία Printer paper and scaling may differ. Check driver settings. Το χαρτί και η κλίμακα του εκτυπωτή μπορεί να διαφέρουν. Ελέγξτε τις ρυθμίσεις του προγράμματος οδήγησης. Drawing scale changes. Use Actual size for full-scale plans. Η κλίμακα του σχεδίου αλλάζει. Για σχέδια σε πλήρη κλίμακα χρησιμοποιήστε το Πραγματικό μέγεθος. 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 Επαναφόρτωση @@ -2487,6 +2495,11 @@ animation effects Off Ανενεργά Make properties default Ορισμός ιδιοτήτων ως προεπιλογή Pages selected Επιλεγμένες σελίδες Page properties Ιδιότητες σελίδας + Attach a file… Επισύναψη αρχείου… + Delete attachment Διαγραφή συνημμένου + Attaching files is not available on the web yet Η επισύναψη αρχείων δεν είναι ακόμη διαθέσιμη στον ιστό + Attach {name} Επισύναψη {name} + Delete attachment {key} Διαγραφή συνημμένου {key} Font Γραμματοσειρά Bulleted list Λίστα με κουκκίδες Numbered list Αριθμημένη λίστα @@ -2521,4 +2534,7 @@ animation effects Off Ανενεργά field names can't contain a period: {name} τα ονόματα πεδίων δεν μπορούν να περιέχουν τελεία: {name} two cells would both be named {name} δύο κελιά θα είχαν το ίδιο όνομα {name} a field named {name} already exists υπάρχει ήδη πεδίο με όνομα {name} + +# 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 d48c53e6ad..d26e87ef70 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -607,6 +607,7 @@ Discard private data of other applications Descartar datos privados de otras aplicaciones Discard hidden text Descartar texto oculto Use Flate to encode streams that are not encoded Usar Flate para codificar las secuencias sin codificar + In streams that use LZW encoding, use Flate instead En las secuencias con codificación LZW, usar Flate en su lugar Remove invalid links and bookmarks Quitar enlaces y marcadores no válidos Remove unreferenced named destinations Quitar destinos con nombre sin referencias Compress document structure (object streams) Comprimir la estructura del documento (secuencias de objetos) @@ -2056,6 +2057,10 @@ action wizard Start Iniciar Perimeter Perímetro Area Área Drawing scale Escala del dibujo + Scale Escala + Common scales Escalas habituales + Enter a scale such as 1:50. Introduzca una escala como 1:50. + Applying a scale switches units to m / mm. Al aplicar una escala, las unidades cambian a m / mm. Snapping Ajuste Export measurements Exportar mediciones Open a PDF to measure it. Abre un PDF para medirlo. @@ -2160,6 +2165,7 @@ action wizard Start Iniciar Lines +/− Líneas +/− @{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last} @{login}: {prs} PR, {commits} commits, +{added} / −{deleted} líneas (Δ {delta}), +{bin_added} / −{bin_deleted} recursos binarios, {first} – {last} Reopen the files that were open when PdfCraft last closed Volver a abrir los archivos que estaban abiertos al cerrar PdfCraft por última vez + Restore the last view (page, zoom and page display) when a document is opened again Restaurar la última vista (página, zoom y presentación de páginas) al volver a abrir un documento Add {what} Añadir {what} Use {what} Usar {what} Place saved {what} Colocar {what} desde lo guardado @@ -2193,6 +2199,8 @@ action wizard Start Iniciar Home / End Inicio / Fin H / Space (hold) H / Espacio (mantener) Reload documents when their files change on disk Recargar los documentos cuando sus archivos cambien en el disco + Don't show again No volver a mostrar + Show a notice when a document is secured Mostrar un aviso cuando un documento está protegido Always use my default zoom and page layout Usar siempre mi zoom y presentación de página predeterminados This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Este archivo cambió en el disco. Recargar muestra la versión nueva y descarta los cambios sin guardar; Guardar la reemplaza por esta versión. Reload Recargar @@ -2637,6 +2645,11 @@ animation effects Off Desactivados # Historical snapshot notice bar (View signed version / View revision). You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Estás viendo la versión firmada de «{from}» (revisión {rev}). Es una instantánea: los cambios aquí no afectarán al original. You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Estás viendo la revisión {rev} de «{from}». Es una instantánea: los cambios aquí no afectarán al original. + Attach a file… Adjuntar un archivo… + Delete attachment Eliminar adjunto + Attaching files is not available on the web yet Adjuntar archivos aún no está disponible en la web + Attach {name} Adjuntar {name} + Delete attachment {key} Eliminar adjunto {key} Thumbnail size Tamaño de las miniaturas Smaller thumbnails Miniaturas más pequeñas Larger thumbnails Miniaturas más grandes @@ -2838,4 +2851,7 @@ theme Appearance Aspecto field names can't contain a period: {name} los nombres de campo no pueden contener un punto: {name} two cells would both be named {name} dos celdas se llamarían {name} a field named {name} already exists ya existe un campo llamado {name} + +# 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/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index f9d4456ce7..ab3fb4bde8 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -574,6 +574,7 @@ Discard private data of other applications Hylkää muiden sovellusten yksityiset tiedot Discard hidden text Hylkää piilotettu teksti Use Flate to encode streams that are not encoded Koodaa koodaamattomat virrat Flate-menetelmällä + In streams that use LZW encoding, use Flate instead Koodaa LZW-koodatut virrat Flate-menetelmällä Remove invalid links and bookmarks Poista virheelliset linkit ja kirjanmerkit Remove unreferenced named destinations Poista viittaamattomat nimetyt kohteet Compress document structure (object streams) Pakkaa asiakirjan rakenne (objektivirrat) @@ -1978,6 +1979,10 @@ Perimeter Ympärysmitta Area Pinta-ala Drawing scale Piirustuksen mittakaava + Scale Mittakaava + Common scales Yleiset mittakaavat + Enter a scale such as 1:50. Anna mittakaava, esimerkiksi 1:50. + Applying a scale switches units to m / mm. Mittakaavan käyttö vaihtaa yksiköiksi m / mm. Snapping Tartunta Export measurements Vie mittaukset Open a PDF to measure it. Avaa PDF mitataksesi sitä. @@ -2268,6 +2273,8 @@ key Space Välilyönti Printer paper and scaling may differ. Check driver settings. Tulostimen paperi ja skaalaus voivat poiketa. Tarkista ohjaimen asetukset. Drawing scale changes. Use Actual size for full-scale plans. Piirustuksen mittakaava muuttuu. Käytä mittakaavaisille piirustuksille Todellinen koko -valintaa. Reload documents when their files change on disk Lataa asiakirjat uudelleen, kun niiden tiedostot muuttuvat levyllä + Don't show again Älä näytä uudelleen + Show a notice when a document is secured Näytä ilmoitus, kun asiakirja on suojattu This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Tämä tiedosto muuttui levyllä. Lataa se uudelleen nähdäksesi uuden version; tallentamattomat muutokset menetetään. Reload Lataa uudelleen Couldn't reload {name}: {e} Kohdetta {name} ei voitu ladata uudelleen: {e} @@ -2496,7 +2503,7 @@ animation effects Off Ei käytössä The signer's certificate is not issued for signing documents (its extended key usage has no document-signing purpose). Allekirjoittajan varmennetta ei ole myönnetty asiakirjojen allekirjoittamiseen (sen laajennettu avaimen käyttö ei sisällä asiakirjojen allekirjoitusta). The signer's certificate has critical extensions PdfCraft does not recognize ({}). Allekirjoittajan varmenteessa on kriittisiä laajennuksia, joita PdfCraft ei tunnista ({}). The certificate of {} is not a CA certificate that may issue certificates, so it is not used to vouch for {}. Kohteen {} varmenne ei ole varmenteita myöntävä CA-varmenne, joten sitä ei käytetä kohteen {} vahvistamiseen. - The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. + The certificate of {} has critical extensions PdfCraft does not recognize, so it is not used to vouch for {}. Kohteen {} varmenteessa on kriittisiä laajennuksia, joita PdfCraft ei tunnista, joten sitä ei käytetä kohteen {} vahvistamiseen. The certificate of {} does not allow this many CA certificates below it (path length), so it is not used to vouch for {}. Kohteen {} varmenne ei salli näin montaa alempaa CA-varmennetta (polun pituus), joten sitä ei käytetä kohteen {} vahvistamiseen. The certificate of {} was not valid at the time of signing, so it is not used to vouch for {}. Kohteen {} varmenne ei ollut voimassa allekirjoitushetkellä, joten sitä ei käytetä kohteen {} vahvistamiseen. 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. Allekirjoittajan henkilöllisyys on tuntematon, koska sitä ei ole lisätty luotettujen varmenteiden luetteloon eikä mikään sen ylemmistä varmenteista ole luotettu varmenne. @@ -2516,7 +2523,7 @@ animation effects Off Ei käytössä The timestamp token could not be read ({}). Aikaleimatunnistetta ei voitu lukea ({}). The document has been altered or corrupted since the timestamp was applied. Asiakirjaa on muutettu tai se on vioittunut aikaleiman lisäämisen jälkeen. The timestamp authority's certificate was not valid at the time of timestamping. Aikaleiman myöntäjän varmenne ei ollut voimassa aikaleimaushetkellä. - The timestamp authority's certificate is not issued for time stamping. The timestamp authority's certificate is not issued for time stamping. + The timestamp authority's certificate is not issued for time stamping. Aikaleiman myöntäjän varmennetta ei ole myönnetty aikaleimaukseen. This document has not been modified since this timestamp was applied. Asiakirjaa ei ole muokattu tämän aikaleiman lisäämisen jälkeen. The document has been modified since this timestamp was applied, but the changes are permitted ({}). Asiakirjaa on muokattu tämän aikaleiman lisäämisen jälkeen, mutta muutokset ovat sallittuja ({}). The document has been altered since this timestamp was applied in ways it does not permit ({}). Asiakirjaa on muutettu tämän aikaleiman lisäämisen jälkeen tavoilla, joita se ei salli ({}). @@ -2696,69 +2703,69 @@ theme Appearance Teema # Labels added upstream 2026-10-16…18. the name is not a plain file name nimi ei ole pelkkä tiedostonimi Zoom — type a percentage and press Enter Zoomaus — kirjoita prosentti ja paina Enter -# Untranslated: English text until a translator fills these in. - Calculation order Calculation order - Fix order Fix order - Move only what must move, so every field calculates after the fields it reads. Move only what must move, so every field calculates after the fields it reads. - Earlier in calculation order Earlier in calculation order - Later in calculation order Later in calculation order - Too early Too early - Reads {name}, which calculates later, so it shows an old value. Reads {name}, which calculates later, so it shows an old value. - Loop Loop - Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Reads {names}, which read it back. No order can fix a loop: change one of the formulas. - Not in order Not in order - Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. - Back to Prepare a form 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. 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} and {last} - {field} reads no other field {field} reads no other field - {field} reads {fields} {field} reads {fields} - No field reads it No field reads it - The order saved in the PDF needs tidying The order saved in the PDF needs tidying - Moves only what must move Moves only what must move - Every field calculates after the fields it reads. Every field calculates after the fields it reads. - Every field outside a loop calculates after the fields it reads. Every field outside a loop calculates after the fields it reads. - {field} reads its own value {field} reads its own value - {fields} read each other {fields} read each other - No order can fix a loop. Change one of the formulas. No order can fix a loop. Change one of the formulas. - Edit {field}'s formula Edit {field}'s formula - Order · top calculates first Order · top calculates first - Script Script - Links on page Links on page - Show on the page which fields each field reads Show on the page which fields each field reads - Drag a field to move it, or select it and press {up} or {down}. Drag a field to move it, or select it and press {up} or {down}. - Set calculation order Set calculation order -@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} calculation now uses the new name.|{n} calculations now use the new name. -@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. 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. -@plural {fields} reads it|{fields} read it {fields} reads it|{fields} read it -@plural {n} field shows an old value|{n} fields show old values {n} field shows an old value|{n} fields show old values -@plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. -@plural {n} more field calculates too early.|{n} more fields calculate too early. {n} more field calculates too early.|{n} more fields calculate too early. -@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} 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. - Bulleted list Bulleted list - Numbered list Numbered list - Alignment Alignment - Superscript Superscript - Subscript Subscript - Paragraph spacing Paragraph spacing - Less Less - Other options Other options - Always use my default zoom and page layout Always use my default zoom and page layout - Larger thumbnails Larger thumbnails - Layer options Layer options - Printer default Printer default - Reading the printer's settings… Reading the printer's settings… - Reset to Initial Visibility Reset to Initial Visibility - Shrink the page content to make room for the header and footer Shrink the page content to make room for the header and footer - Smaller thumbnails Smaller thumbnails - The document's security settings don't allow printing The document's security settings don't allow printing - Thumbnail size Thumbnail size - Reshape comment Reshape comment - The signature's signed attributes have no content type, which CMS requires; it was read anyway. The signature's signed attributes have no content type, which CMS requires; it was read anyway. - 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. 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. - The signature's signed attributes have no message digest, so the signature does not cover the document. 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 signature's signed attributes name a content type other than data, so it is not a signature of the document. - Remove {standard} identification Remove {standard} identification +# Calculation order, text formatting, print and signature labels (AI-assisted, not separately native-reviewed) + Calculation order Laskentajärjestys + Fix order Korjaa järjestys + Move only what must move, so every field calculates after the fields it reads. Siirrä vain se, mikä on pakko, jotta jokainen kenttä lasketaan lukemiensa kenttien jälkeen. + Earlier in calculation order Aiemmaksi laskentajärjestyksessä + Later in calculation order Myöhemmäksi laskentajärjestyksessä + Too early Liian aikaisin + Reads {name}, which calculates later, so it shows an old value. Lukee kenttää {name}, joka lasketaan myöhemmin, joten se näyttää vanhan arvon. + Loop Silmukka + Reads {names}, which read it back. No order can fix a loop: change one of the formulas. Lukee kenttiä {names}, jotka lukevat sitä takaisin. Mikään järjestys ei korjaa silmukkaa: muuta jotakin kaavoista. + Not in order Ei järjestyksessä + Missing from the calculation order saved in the PDF: other PDF viewers may not calculate it. Puuttuu PDF:ään tallennetusta laskentajärjestyksestä: muut PDF-katseluohjelmat eivät ehkä laske sitä. + Back to Prepare a form Takaisin kohtaan Valmistele lomake + 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. Mikään tämän lomakkeen kenttä ei vielä laske arvoaan. Anna kentälle laskenta sen ominaisuuksien Laskenta-välilehdellä, niin se näkyy tässä. + {names} and {last} {names} ja {last} + {field} reads no other field {field} ei lue muita kenttiä + {field} reads {fields} {field} lukee: {fields} + No field reads it Mikään kenttä ei lue sitä + The order saved in the PDF needs tidying PDF:ään tallennettu järjestys kaipaa siistimistä + Moves only what must move Siirtää vain sen, mikä on pakko + Every field calculates after the fields it reads. Jokainen kenttä lasketaan lukemiensa kenttien jälkeen. + Every field outside a loop calculates after the fields it reads. Jokainen silmukan ulkopuolinen kenttä lasketaan lukemiensa kenttien jälkeen. + {field} reads its own value {field} lukee omaa arvoaan + {fields} read each other {fields} lukevat toisiaan + No order can fix a loop. Change one of the formulas. Mikään järjestys ei korjaa silmukkaa. Muuta jotakin kaavoista. + Edit {field}'s formula Muokkaa kentän {field} kaavaa + Order · top calculates first Järjestys · ylin lasketaan ensin + Script Komentosarja + Links on page Yhteydet sivulla + Show on the page which fields each field reads Näytä sivulla, mitä kenttiä kukin kenttä lukee + Drag a field to move it, or select it and press {up} or {down}. Siirrä kenttää vetämällä tai valitse se ja paina {up} tai {down}. + Set calculation order Aseta laskentajärjestys +@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} laskenta käyttää nyt uutta nimeä.|{n} laskentaa käyttää nyt uutta nimeä. +@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. Laskentajärjestyksessä on {n} kohta, joka ei laske mitään. Korjaa järjestys poistaa sen.|Laskentajärjestyksessä on {n} kohtaa, jotka eivät laske mitään. Korjaa järjestys poistaa ne. +@plural {fields} reads it|{fields} read it {fields} lukee sitä|{fields} lukevat sitä +@plural {n} field shows an old value|{n} fields show old values {n} kenttä näyttää vanhan arvon|{n} kenttää näyttää vanhoja arvoja +@plural {field} calculates before {fields}, which it reads.|{field} calculates before {fields}, which it reads. {field} lasketaan ennen lukemaansa kenttää {fields}.|{field} lasketaan ennen lukemiaan kenttiä {fields}. +@plural {n} more field calculates too early.|{n} more fields calculate too early. {n} muu kenttä lasketaan liian aikaisin.|{n} muuta kenttää lasketaan liian aikaisin. +@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} puuttuu PDF:ään tallennetusta järjestyksestä, joten muut PDF-katseluohjelmat eivät ehkä laske sitä.|{fields} puuttuvat PDF:ään tallennetusta järjestyksestä, joten muut PDF-katseluohjelmat eivät ehkä laske niitä. + Bulleted list Luettelomerkit + Numbered list Numeroitu luettelo + Alignment Tasaus + Superscript Yläindeksi + Subscript Alaindeksi + Paragraph spacing Kappaleväli + Less Vähemmän + Other options Muut asetukset + Always use my default zoom and page layout Käytä aina oletuszoomaustani ja -sivuasetteluani + Larger thumbnails Suuremmat pikkukuvat + Layer options Tasoasetukset + Printer default Tulostimen oletus + Reading the printer's settings… Luetaan tulostimen asetuksia… + Reset to Initial Visibility Palauta alkuperäinen näkyvyys + Shrink the page content to make room for the header and footer Pienennä sivun sisältöä, jotta ylä- ja alatunnisteelle jää tilaa + Smaller thumbnails Pienemmät pikkukuvat + The document's security settings don't allow printing Asiakirjan suojausasetukset eivät salli tulostamista + Thumbnail size Pikkukuvan koko + Reshape comment Muuta kommentin muotoa + The signature's signed attributes have no content type, which CMS requires; it was read anyway. Allekirjoituksen allekirjoitetuista määritteistä puuttuu sisältötyyppi, jota CMS edellyttää; se luettiin silti. + 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. Tämä allekirjoitus käyttää alle 2048-bittistä RSA-avainta, jota ei enää pidetä riittävän vahvana allekirjoituksiin. Se tarkistuu yhä, mutta siihen ei pidä luottaa. + The signature's signed attributes have no message digest, so the signature does not cover the document. Allekirjoituksen allekirjoitetuista määritteistä puuttuu viestitiiviste, joten allekirjoitus ei kata asiakirjaa. + The signature's signed attributes name a content type other than data, so it is not a signature of the document. Allekirjoituksen allekirjoitetut määritteet nimeävät muun sisältötyypin kuin data, joten se ei ole asiakirjan allekirjoitus. + Remove {standard} identification Poista {standard}-tunniste # Prepare a form ▸ Text field table (AI-assisted, not separately native-reviewed) Add text field table Lisää tekstikenttätaulukko @@ -2783,4 +2790,15 @@ theme Appearance Teema field names can't contain a period: {name} kenttien nimissä ei voi olla pistettä: {name} two cells would both be named {name} kahdella solulla olisi sama nimi {name} a field named {name} already exists kenttä nimeltä {name} on jo olemassa + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Tänään + +# 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 Palauta viimeisin näkymä (sivu, zoomaus ja sivunäyttö), kun asiakirja avataan uudelleen + Attach a file… Liitä tiedosto… + Attach {name} Liitä {name} + 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 0dfc96a5da..1caab024af 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -584,6 +584,7 @@ Discard private data of other applications Ignorer les données privées d'autres applications Discard hidden text Ignorer le texte masqué Use Flate to encode streams that are not encoded Utiliser Flate pour coder les flux qui ne sont pas codés + In streams that use LZW encoding, use Flate instead Dans les flux qui utilisent le codage LZW, utiliser Flate à la place Remove invalid links and bookmarks Retirer les liens et signets non valides Remove unreferenced named destinations Retirer les destinations nommées non référencées Compress document structure (object streams) Compresser la structure du document (flux d'objets) @@ -1971,6 +1972,10 @@ Perimeter Périmètre Area Aire Drawing scale Échelle du dessin + Scale Échelle + Common scales Échelles courantes + Enter a scale such as 1:50. Saisissez une échelle comme 1:50. + Applying a scale switches units to m / mm. Appliquer une échelle passe les unités en m / mm. Snapping Accrochage Export measurements Exporter les mesures Open a PDF to measure it. Ouvrez un PDF pour le mesurer. @@ -2041,7 +2046,10 @@ Default zoom Zoom par défaut Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. Utilisé lorsqu'un PDF ne demande ni présentation ni zoom. Le défilement continu ne saute jamais d'une page à l'autre. Reopen the files that were open when PdfCraft last closed Rouvrir les fichiers qui étaient ouverts lors de la dernière fermeture de PdfCraft + Restore the last view (page, zoom and page display) when a document is opened again Rétablir la dernière vue (page, zoom et affichage des pages) à la réouverture d’un document Reload documents when their files change on disk Recharger les documents lorsque leurs fichiers sont modifiés sur le disque + Don't show again Ne plus afficher + Show a notice when a document is secured Afficher un avis lorsqu’un document est protégé Always use my default zoom and page layout Toujours utiliser mon zoom et ma présentation de page par défaut This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Ce fichier a été modifié sur le disque. Recharger affiche la nouvelle version et abandonne vos modifications non enregistrées ; Enregistrer la remplace par cette version. Reload Recharger @@ -2481,6 +2489,11 @@ 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 + 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 + Attach {name} Joindre {name} + Delete attachment {key} Supprimer la pièce jointe {key} Font Police Bulleted list Liste à puces Numbered list Liste numérotée @@ -2781,4 +2794,7 @@ certificate Version Version field names can't contain a period: {name} les noms de champ ne peuvent pas contenir de point : {name} two cells would both be named {name} deux cellules porteraient le nom {name} a field named {name} already exists un champ nommé {name} existe déjà + +# 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 0295a3cb30..ee417e9134 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -558,6 +558,7 @@ 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) दस्तावेज़ संरचना संपीड़ित करें (ऑब्जेक्ट स्ट्रीम) @@ -1961,6 +1962,10 @@ Perimeter परिमाप Area क्षेत्रफल Drawing scale रेखाचित्र पैमाना + Scale पैमाना + Common scales सामान्य पैमाने + Enter a scale such as 1:50. 1:50 जैसा पैमाना दर्ज करें। + Applying a scale switches units to m / mm. पैमाना लागू करने पर इकाइयाँ m / mm में बदल जाती हैं। Snapping स्नैपिंग Export measurements मापन निर्यात करें Open a PDF to measure it. मापने के लिए एक पीडीएफ़ खोलें। @@ -2250,6 +2255,8 @@ key Space Space Printer paper and scaling may differ. Check driver settings. प्रिंटर का काग़ज़ और स्केलिंग अलग हो सकते हैं। ड्राइवर सेटिंग्स जाँचें। Drawing scale changes. Use Actual size for full-scale plans. रेखाचित्र पैमाना बदल जाता है। पूर्ण-पैमाने योजनाओं के लिए “वास्तविक आकार” चुनें। Reload documents when their files change on disk जब उनकी फ़ाइलें डिस्क पर बदलें तो दस्तावेज़ दोबारा लोड करें + Don't show again फिर से न दिखाएँ + Show a notice when a document is secured दस्तावेज़ सुरक्षित होने पर सूचना दिखाएँ This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. यह फ़ाइल डिस्क पर बदल गई है। नया संस्करण देखने के लिए उसे दोबारा लोड करें; आपके असहेजे बदलाव खो जाएँगे। Reload दोबारा लोड करें Couldn't reload {name}: {e} {name} दोबारा लोड नहीं हो सका: {e} @@ -2750,4 +2757,15 @@ theme Appearance स्वरूप field names can't contain a period: {name} फ़ील्ड के नामों में पूर्ण विराम (.) नहीं हो सकता: {name} two cells would both be named {name} दो सेल का नाम {name} हो जाएगा a field named {name} already exists {name} नाम का फ़ील्ड पहले से मौजूद है + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today आज + +# 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 दस्तावेज़ फिर से खोलने पर पिछला दृश्य (पृष्ठ, ज़ूम और पृष्ठ प्रदर्शन) पुनर्स्थापित करें + Attach a file… फ़ाइल संलग्न करें… + Attach {name} {name} संलग्न करें + 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 6e36ddb012..cd2e4434c6 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -582,6 +582,7 @@ Discard private data of other applications Más alkalmazások privát adatainak elvetése Discard hidden text Rejtett szöveg elvetése Use Flate to encode streams that are not encoded A kódolatlan adatfolyamok Flate kódolása + In streams that use LZW encoding, use Flate instead Az LZW kódolású adatfolyamok Flate kódolása Remove invalid links and bookmarks Érvénytelen hivatkozások és könyvjelzők eltávolítása Remove unreferenced named destinations Hivatkozás nélküli elnevezett célok eltávolítása Compress document structure (object streams) Dokumentumszerkezet tömörítése (objektumfolyamok) @@ -1961,6 +1962,10 @@ Perimeter Kerület Area Terület Drawing scale Rajz méretaránya + Scale Méretarány + Common scales Gyakori méretarányok + Enter a scale such as 1:50. Adjon meg egy méretarányt, például 1:50. + Applying a scale switches units to m / mm. A méretarány alkalmazása m / mm mértékegységre vált. Snapping Illesztés Export measurements Mérések exportálása Open a PDF to measure it. Nyiss meg egy PDF-et a méréshez. @@ -2170,6 +2175,7 @@ comment menu Edit Szerkesztés signature pad Type Gépelés action wizard Start Indítás Reopen the files that were open when PdfCraft last closed A PdfCraft legutóbbi bezárásakor megnyitott fájlok újranyitása + Restore the last view (page, zoom and page display) when a document is opened again A legutóbbi nézet (oldal, nagyítás és oldalmegjelenítés) visszaállítása a dokumentum újbóli megnyitásakor Clear Recent Files Legutóbbi fájlok listájának törlése Remove from Recent Eltávolítás a legutóbbiak közül Remove {name} from Recent {name} eltávolítása a legutóbbiak közül @@ -2318,6 +2324,8 @@ key Space Szóköz Printer paper and scaling may differ. Check driver settings. A nyomtató papírja és méretezése eltérhet. Ellenőrizze az illesztőprogram beállításait. Drawing scale changes. Use Actual size for full-scale plans. A rajz méretaránya változik. Méretarányos tervekhez használja a Tényleges méret beállítást. Reload documents when their files change on disk Dokumentumok újratöltése, ha a fájljuk megváltozik a lemezen + Don't show again Ne jelenjen meg újra + Show a notice when a document is secured Értesítés megjelenítése, ha egy dokumentum védett Always use my default zoom and page layout Mindig az alapértelmezett nagyításom és oldalelrendezésem használata This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Ez a fájl megváltozott a lemezen. Az Újratöltés az új változatot mutatja, és eldobja a nem mentett módosításokat; a Mentés erre a változatra cseréli le. Reload Újratöltés @@ -2480,6 +2488,11 @@ 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 + 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 + Attach {name} {name} csatolása + Delete attachment {key} {key} melléklet törlése Font Betűtípus Bulleted list Felsorolás Numbered list Számozott lista @@ -2514,4 +2527,7 @@ animation effects Off Ki field names can't contain a period: {name} a mezőnevek nem tartalmazhatnak pontot: {name} two cells would both be named {name} két cella neve is {name} lenne a field named {name} already exists már van {name} nevű mező + +# 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 7387e5a43e..1df73c5446 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -924,6 +924,8 @@ Redaction code: Kode sensor: Reload Muat ulang Reload documents when their files change on disk Muat ulang dokumen saat file-nya berubah di disk + Don't show again Jangan tampilkan lagi + Show a notice when a document is secured Tampilkan pemberitahuan saat dokumen diamankan Remove checkmark Hapus centang Remove invalid links and bookmarks Hapus tautan dan bookmark yang tidak valid Remove page Hapus halaman @@ -1219,6 +1221,7 @@ Update Perbarui Use 72 DPI (one point per pixel) Gunakan 72 DPI (satu titik per piksel) Use Flate to encode streams that are not encoded Gunakan Flate untuk mengodekan stream yang belum dikodekan + In streams that use LZW encoding, use Flate instead Pada stream yang memakai pengodean LZW, gunakan Flate sebagai gantinya Use a Digital ID from a file Gunakan ID Digital dari file Use custom DPI Gunakan DPI kustom Use each image's embedded DPI; use 72 DPI when it is absent. Gunakan DPI tertanam pada setiap gambar; gunakan 72 DPI jika tidak ada. @@ -1390,6 +1393,10 @@ Draw freehand Gambar bebas Drawing Gambar Drawing scale Skala gambar + Scale Skala + Common scales Skala umum + Enter a scale such as 1:50. Masukkan skala seperti 1:50. + Applying a scale switches units to m / mm. Menerapkan skala mengubah satuan menjadi m / mm. Drop-down list Daftar drop-down Duplicate pages Duplikat halaman Edit a PDF Edit PDF @@ -2751,4 +2758,15 @@ theme Appearance Tampilan field names can't contain a period: {name} nama kolom tidak boleh berisi titik: {name} two cells would both be named {name} dua sel akan sama-sama bernama {name} a field named {name} already exists kolom bernama {name} sudah ada + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Hari ini + +# 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 Pulihkan tampilan terakhir (halaman, zoom, dan tampilan halaman) saat dokumen dibuka lagi + Attach a file… Lampirkan file… + Attach {name} Lampirkan {name} + 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 c775fdbd2d..feafb8f03a 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -608,6 +608,7 @@ Discard private data of other applications Elimina dati privati di altre applicazioni Discard hidden text Elimina testo nascosto Use Flate to encode streams that are not encoded Usa Flate per codificare i flussi non codificati + In streams that use LZW encoding, use Flate instead Nei flussi con codifica LZW, usa Flate Remove invalid links and bookmarks Rimuovi collegamenti e segnalibri non validi Remove unreferenced named destinations Rimuovi destinazioni con nome non referenziate Compress document structure (object streams) Comprimi struttura del documento (flussi di oggetti) @@ -2047,6 +2048,10 @@ Perimeter Perimetro Area Area Drawing scale Scala del disegno + Scale Scala + Common scales Scale comuni + Enter a scale such as 1:50. Inserisci una scala come 1:50. + Applying a scale switches units to m / mm. Applicando una scala le unità passano a m / mm. Snapping Aggancio Export measurements Esporta misure Open a PDF to measure it. Apri un PDF per misurarlo. @@ -2208,6 +2213,7 @@ Remove saved {what} Rimuovi l'elemento salvato: {what} Remove {name} from Recent Rimuovi {name} dai recenti Reopen the files that were open when PdfCraft last closed Riapri i file aperti alla chiusura precedente di PdfCraft + Restore the last view (page, zoom and page display) when a document is opened again Ripristina l’ultima vista (pagina, zoom e visualizzazione delle pagine) quando un documento viene riaperto Reset page size Ripristina dimensione pagine Reset to printer defaults Ripristina le impostazioni predefinite della stampante Reverse closed arrow Freccia chiusa inversa @@ -2328,6 +2334,8 @@ key Space Spazio Printer paper and scaling may differ. Check driver settings. Carta e scala della stampante possono differire. Controlla le impostazioni del driver. Drawing scale changes. Use Actual size for full-scale plans. La scala del disegno cambia. Usa Dimensioni effettive per le planimetrie in scala. Reload documents when their files change on disk Ricarica i documenti quando i loro file cambiano sul disco + Don't show again Non mostrare più + Show a notice when a document is secured Mostra un avviso quando un documento è protetto Always use my default zoom and page layout Usa sempre il mio ingrandimento e layout di pagina predefiniti This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Questo file è cambiato sul disco. Ricarica mostra la nuova versione e scarta le modifiche non salvate; Salva la sostituisce con questa versione. Reload Ricarica @@ -2693,6 +2701,11 @@ certificate Key usage Utilizzo chiave certificate Self-signed Autofirmato certificate SHA-1 digest Impronta SHA-1 certificate SHA-256 digest Impronta SHA-256 + 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 + Attach {name} Allega {name} + Delete attachment {key} Elimina allegato {key} Font Carattere Bulleted list Elenco puntato Numbered list Elenco numerato @@ -2747,4 +2760,7 @@ certificate SHA-256 digest Impronta SHA-256 field names can't contain a period: {name} i nomi dei campi non possono contenere un punto: {name} two cells would both be named {name} due celle si chiamerebbero entrambe {name} a field named {name} already exists esiste già un campo chiamato {name} + +# 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 92ba262917..f7becb9e79 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -611,6 +611,7 @@ 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) 文書構造を圧縮(オブジェクトストリーム) @@ -2054,6 +2055,10 @@ Perimeter 周長 Area 面積 Drawing scale 図面の縮尺 + Scale 縮尺 + Common scales よく使う縮尺 + Enter a scale such as 1:50. 1:50 のように縮尺を入力してください。 + Applying a scale switches units to m / mm. 縮尺を適用すると、単位が m / mm に切り替わります。 Snapping スナップ Export measurements 測定結果を書き出す Open a PDF to measure it. 測定するPDFを開いてください。 @@ -2106,6 +2111,7 @@ Go to page… ページへ移動… For page sizes and margins. ページサイズと余白に使います。 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 文書を再び開くときに最後の表示(ページ、ズーム、ページ表示)を復元する 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 ビューアーではこの値からフィールドが再描画されます。 @@ -2319,6 +2325,8 @@ key Space スペース Printer paper and scaling may differ. Check driver settings. プリンターの用紙や倍率は異なる場合があります。ドライバーの設定を確認してください。 Drawing scale changes. Use Actual size for full-scale plans. 図面の縮尺が変わります。原寸で印刷するには「実際のサイズ」を使用してください。 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 再読み込み @@ -2481,6 +2489,11 @@ animation effects Off オフ Make properties default プロパティをデフォルトに設定 Pages selected 選択したページ Page properties ページのプロパティ + Attach a file… ファイルを添付… + Delete attachment 添付ファイルを削除 + Attaching files is not available on the web yet Web 版ではまだファイルを添付できません + Attach {name} {name} を添付 + Delete attachment {key} 添付ファイル {key} を削除 Font フォント Bulleted list 箇条書き Numbered list 番号付きリスト @@ -2515,4 +2528,7 @@ animation effects Off オフ field names can't contain a period: {name} フィールド名にピリオドは使えません: {name} two cells would both be named {name} 2 つのセルが同じ名前 {name} になります a field named {name} already exists {name} という名前のフィールドは既にあります + +# 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 1f8b32794b..1eb5674ed8 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -571,6 +571,7 @@ 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) Құжат құрылымын сығу (нысан ағындары) @@ -1962,6 +1963,10 @@ Perimeter Периметр Area Аудан Drawing scale Сызба масштабы + Scale Масштаб + Common scales Жиі қолданылатын масштабтар + Enter a scale such as 1:50. 1:50 сияқты масштабты енгізіңіз. + Applying a scale switches units to m / mm. Масштабты қолдану өлшем бірліктерін m / mm етіп ауыстырады. Snapping Түйістіру Export measurements Өлшемдерді экспорттау Open a PDF to measure it. Өлшеу үшін PDF құжатын ашыңыз. @@ -2113,6 +2118,7 @@ calendar date October Қазан calendar date November Қараша calendar date December Желтоқсан 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 Құжат қайта ашылғанда соңғы көріністі (бет, масштаб және бет көрсетілімі) қалпына келтіру Clear Recent Files Соңғы файлдарды тазарту Remove from Recent Соңғылардан алып тастау Remove {name} from Recent {name} дегенді соңғылардан алып тастау @@ -2318,6 +2324,8 @@ key Space Бос орын Printer paper and scaling may differ. Check driver settings. Принтер қағазы мен масштабы өзгеше болуы мүмкін. Драйвер параметрлерін тексеріңіз. Drawing scale changes. Use Actual size for full-scale plans. Сызба масштабы өзгереді. Толық масштабты жоспарлар үшін Нақты өлшемді пайдаланыңыз. 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 Қайта жүктеу @@ -2479,6 +2487,11 @@ animation effects Off Өшірулі Make properties default Қасиеттерді әдепкі ету Pages selected Таңдалған беттер Page properties Бет қасиеттері + Attach a file… Файл тіркеу… + Delete attachment Тіркемені жою + Attaching files is not available on the web yet Вебте файл тіркеу әзірге қолжетімсіз + Attach {name} {name} тіркеу + Delete attachment {key} {key} тіркемесін жою Font Қаріп Bulleted list Таңбалауыш тізім Numbered list Нөмірленген тізім @@ -2513,4 +2526,7 @@ animation effects Off Өшірулі field names can't contain a period: {name} өріс атауларында нүкте болмауы керек: {name} two cells would both be named {name} екі ұяшықтың да атауы {name} болар еді a field named {name} already exists {name} атты өріс бар + +# 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 1b81853650..a0fe5d4253 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -558,6 +558,7 @@ 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) 문서 구조 압축(개체 스트림) @@ -1961,6 +1962,10 @@ Perimeter 둘레 Area 면적 Drawing scale 도면 축척 + Scale 축척 + Common scales 자주 쓰는 축척 + Enter a scale such as 1:50. 1:50과 같은 축척을 입력하세요. + Applying a scale switches units to m / mm. 축척을 적용하면 단위가 m / mm로 바뀝니다. Snapping 스냅 Export measurements 측정값 내보내기 Open a PDF to measure it. 측정할 PDF를 여세요. @@ -2250,6 +2255,8 @@ key Space 스페이스 Printer paper and scaling may differ. Check driver settings. 프린터 용지와 배율이 다를 수 있습니다. 드라이버 설정을 확인하세요. Drawing scale changes. Use Actual size for full-scale plans. 도면 축척이 변경됩니다. 실제 크기 도면에는 '실제 크기'를 사용하세요. Reload documents when their files change on disk 파일이 디스크에서 변경되면 문서 다시 불러오기 + Don't show again 다시 표시 안 함 + Show a notice when a document is secured 문서가 보호되어 있으면 알림 표시 This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. 이 파일이 디스크에서 변경되었습니다. 새 버전을 보려면 다시 불러오세요. 저장하지 않은 변경 내용은 손실됩니다. Reload 다시 불러오기 Couldn't reload {name}: {e} {name}을(를) 다시 불러올 수 없습니다: {e} @@ -2750,4 +2757,15 @@ theme Appearance 모양 field names can't contain a period: {name} 필드 이름에는 마침표를 쓸 수 없습니다: {name} two cells would both be named {name} 두 셀의 이름이 모두 {name}이(가) 됩니다 a field named {name} already exists {name}(이)라는 필드가 이미 있습니다 + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today 오늘 + +# 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 문서를 다시 열 때 마지막 보기(페이지, 확대/축소, 페이지 표시) 복원 + Attach a file… 파일 첨부… + Attach {name} {name} 첨부 + 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 0de634d676..f14df82b20 100644 --- a/crates/ui-egui/src/i18n/lo.tsv +++ b/crates/ui-egui/src/i18n/lo.tsv @@ -579,6 +579,7 @@ 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 ໃຊ້ Flate ແທນ LZW ໃນສະຕຣີມທີ່ເຂົ້າລະຫັດດ້ວຍ LZW Remove invalid links and bookmarks ລຶບລິ້ງຄ໌ ແລະ ບຸກມາກທີ່ໃຊ້ການບໍ່ໄດ້ Remove unreferenced named destinations ລຶບຈຸດໝາຍປາຍທາງທີ່ມີຊື່ເຊິ່ງບໍ່ມີການອ້າງອີງ Compress document structure (object streams) ບີບອັດໂຄງສ້າງເອກະສານ (ສະຕຣີມວັດຖຸ) @@ -2014,6 +2015,10 @@ Perimeter ລວງຮອບ Area ເນື້ອທີ່ Drawing scale ມາດຕາສ່ວນຮູບແຕ້ມ + Scale ມາດຕາສ່ວນ + Common scales ມາດຕາສ່ວນທີ່ໃຊ້ທົ່ວໄປ + Enter a scale such as 1:50. ປ້ອນມາດຕາສ່ວນ ເຊັ່ນ 1:50. + Applying a scale switches units to m / mm. ການນຳໃຊ້ມາດຕາສ່ວນຈະປ່ຽນຫົວໜ່ວຍເປັນ m / mm. Snapping ການດູດຈັບ Export measurements ສົ່ງອອກການວັດແທກ Open a PDF to measure it. ເປີດ PDF ເພື່ອວັດແທກ. @@ -2279,6 +2284,8 @@ key Space Space Printer paper and scaling may differ. Check driver settings. ເຈ້ຍເຄື່ອງພິມ ແລະ ມາດຕາສ່ວນອາດແຕກຕ່າງກັນ. ກວດສອບການຕັ້ງຄ່າໄດຣເວີ. Drawing scale changes. Use Actual size for full-scale plans. ມາດຕາສ່ວນຮູບແຕ້ມປ່ຽນແປງ. ໃຊ້ "ຂະໜາດຈິງ" ສຳລັບແບບແຜນເຕັມສ່ວນ. Reload documents when their files change on disk ໂຫຼດເອກະສານຄືນໃໝ່ເມື່ອໄຟລ໌ໃນດິສກ໌ມີການປ່ຽນແປງ + Don't show again ບໍ່ຕ້ອງສະແດງອີກ + Show a notice when a document is secured ສະແດງການແຈ້ງເຕືອນເມື່ອເອກະສານຖືກປ້ອງກັນໄວ້ 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} @@ -2429,4 +2436,7 @@ animation effects Off ປິດ Make properties default ກຳນົດຄຸນສົມບັດເປັນຄ່າເລີ່ມຕົ້ນ Pages selected ໜ້າທີ່ເລືອກ Page properties ຄຸນສົມບັດໜ້າ + +# 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 9812077ef9..a93cf9a91d 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -975,6 +975,8 @@ Redaction code: Šalinimo kodas: Reload Įkelti iš naujo Reload documents when their files change on disk Iš naujo įkelti dokumentus, kai pasikeičia jų failai diske + Don't show again Daugiau nerodyti + Show a notice when a document is secured Rodyti pranešimą, kai dokumentas apsaugotas Always use my default zoom and page layout Visada naudoti mano numatytąjį mastelį ir puslapio išdėstymą Remove checkmark Pašalinti varnelę Remove invalid links and bookmarks Pašalinti netinkamus saitus ir žymeles @@ -987,6 +989,7 @@ Remove {n} pages Pašalinti puslapių: {n} Reopen Closed Tab Vėl atverti užvertą kortelę Reopen the files that were open when PdfCraft last closed Vėl atverti failus, kurie buvo atverti paskutinį kartą užveriant „PdfCraft“ + Restore the last view (page, zoom and page display) when a document is opened again Iš naujo atidarant dokumentą atkurti paskutinį rodinį (puslapį, mastelį ir puslapių rodymą) Repair log Taisymo žurnalas Repeat overlay text Kartoti perdangos tekstą Replace Image… Pakeisti paveikslėlį… @@ -1278,6 +1281,7 @@ Update Atnaujinti Use 72 DPI (one point per pixel) Naudoti 72 DPI (vienas taškas vienam pikseliui) Use Flate to encode streams that are not encoded Nekoduotiems srautams koduoti naudoti „Flate“ + In streams that use LZW encoding, use Flate instead LZW koduotiems srautams naudoti „Flate“ Use a Digital ID from a file Naudoti faile esantį skaitmeninį ID Use custom DPI Naudoti pasirinktinį DPI Use each image's embedded DPI; use 72 DPI when it is absent. Naudoti paveikslėlyje įrašytą DPI; jei jo nėra, naudoti 72 DPI. @@ -1449,6 +1453,10 @@ Draw freehand Brėžti laisva ranka Drawing Brėžinys Drawing scale Brėžinio mastelis + Scale Mastelis + Common scales Dažni masteliai + Enter a scale such as 1:50. Įveskite mastelį, pavyzdžiui, 1:50. + Applying a scale switches units to m / mm. Pritaikius mastelį, vienetai pakeičiami į m / mm. Drop-down list Išskleidžiamasis sąrašas Duplicate pages Dubliuoti puslapius Edit a PDF Redaguoti PDF @@ -2739,6 +2747,11 @@ key Space Tarpas The password must have at least 6 characters. Slaptažodį turi sudaryti bent 6 simboliai. This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Šis failas diske pasikeitė. Įkelkite jį iš naujo, kad pamatytumėte naują versiją; neįrašyti pakeitimai bus prarasti. theme Appearance Išvaizda + Attach a file… Pridėti failą… + Delete attachment Ištrinti priedą + Attaching files is not available on the web yet Failų pridėjimas žiniatinklyje dar negalimas + Attach {name} Pridėti {name} + Delete attachment {key} Ištrinti priedą {key} Bulleted list Sąrašas su ženkleliais Numbered list Numeruotas sąrašas Alignment Lygiuotė @@ -2776,4 +2789,7 @@ theme Appearance Išvaizda a table has 1 to {max} columns lentelėje gali būti nuo 1 iki {max} stulpelių the first row number can be at most {max} pirmosios eilutės numeris gali būti ne didesnis nei {max} the area, the spacing and the column widths must be numbers, the spacing 0 or more and each width more than 0 sritis, tarpai ir stulpelių pločiai turi būti skaičiai, tarpai – 0 arba daugiau, o kiekvienas plotis – daugiau nei 0 + +# 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/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index 9bf310cd9f..d7a43a6b33 100644 --- a/crates/ui-egui/src/i18n/nb.tsv +++ b/crates/ui-egui/src/i18n/nb.tsv @@ -933,6 +933,8 @@ Redaction code: Sladdekode: Reload Last inn på nytt Reload documents when their files change on disk Last inn dokumenter på nytt når filene endres på disk + Don't show again Ikke vis igjen + Show a notice when a document is secured Vis et varsel når et dokument er sikret Remove checkmark Fjern hake Remove invalid links and bookmarks Fjern ugyldige lenker og bokmerker Remove page Fjern side @@ -1228,6 +1230,7 @@ Update Oppdater Use 72 DPI (one point per pixel) Bruk 72 DPI (ett punkt per piksel) Use Flate to encode streams that are not encoded Bruk Flate til å kode strømmer som ikke er kodet + In streams that use LZW encoding, use Flate instead Bruk Flate i stedet for LZW i strømmer som er LZW-kodet Use a Digital ID from a file Bruk en digital ID fra en fil Use custom DPI Bruk egendefinert DPI Use each image's embedded DPI; use 72 DPI when it is absent. Bruk hvert bildes innebygde DPI; bruk 72 DPI når den mangler. @@ -1401,6 +1404,10 @@ Draw freehand Tegn på frihånd Drawing Tegning Drawing scale Tegneskala + Scale Målestokk + Common scales Vanlige målestokker + Enter a scale such as 1:50. Skriv inn en målestokk, for eksempel 1:50. + Applying a scale switches units to m / mm. Når du bruker en målestokk, byttes enhetene til m / mm. Drop-down list Nedtrekksliste Duplicate pages Dupliser sider Edit a PDF Rediger en PDF @@ -2694,4 +2701,10 @@ key Space Mellomrom # Page gaps (AI-assisted, not separately native-reviewed) Show gaps between pages Vis mellomrom mellom sidene Hide gaps to join page edges. Margins inside the PDF remain unchanged. Skjul mellomrommene slik at sidekantene møtes. Margene inne i PDF-en endres ikke. + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today I dag + +# 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 efc9f1832f..b2300e3d21 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -606,6 +606,7 @@ Discard private data of other applications Usuń prywatne dane innych aplikacji Discard hidden text Usuń ukryty tekst Use Flate to encode streams that are not encoded Zakoduj nieskompresowane strumienie metodą Flate + In streams that use LZW encoding, use Flate instead Zakoduj strumienie LZW metodą Flate Remove invalid links and bookmarks Usuń nieprawidłowe łącza i zakładki Remove unreferenced named destinations Usuń nieużywane nazwane miejsca docelowe Compress document structure (object streams) Kompresuj strukturę dokumentu (strumienie obiektów) @@ -2034,6 +2035,10 @@ Perimeter Obwód Area Pole powierzchni Drawing scale Skala rysunku + Scale Skala + Common scales Popularne skale + Enter a scale such as 1:50. Wpisz skalę, na przykład 1:50. + Applying a scale switches units to m / mm. Zastosowanie skali zmienia jednostki na m / mm. Snapping Przyciąganie Export measurements Eksportuj pomiary Open a PDF to measure it. Otwórz PDF, aby wykonać pomiar. @@ -2085,6 +2090,7 @@ Couldn't import the signature image: {e} Nie można zaimportować obrazu podpisu: {e} Go to page… Przejdź do strony… Reopen the files that were open when PdfCraft last closed Otwórz ponownie pliki otwarte przy ostatnim zamknięciu PdfCraft + Restore the last view (page, zoom and page display) when a document is opened again Przywracaj ostatni widok (stronę, powiększenie i układ stron) przy ponownym otwarciu dokumentu The PDF text fonts can't draw “{c}” ({code}). Choose supported text. Czcionki tekstu PDF nie obsługują znaku „{c}” ({code}). Wybierz obsługiwany tekst. The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. Standardowe czcionki nie obsługują znaku „{c}” ({code}). Na razie można dodawać tylko tekst ze znakami zachodnioeuropejskimi. 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. Czcionka tego pola nie obsługuje znaku „{c}” ({code}), więc PdfCraft wyświetla w jego miejscu ?. Wartość została zapisana, a inne przeglądarki PDF odrysują pole na jej podstawie. @@ -2299,6 +2305,8 @@ key Space Spacja These objects could not be moved. Select them again and try a smaller move. Nie udało się przenieść tych obiektów. Zaznacz je ponownie i spróbuj mniejszego przesunięcia. This page contains objects that cannot be selected together. Ta strona zawiera obiekty, których nie można zaznaczyć razem. Reload documents when their files change on disk Wczytuj ponownie dokumenty, gdy ich pliki zmienią się na dysku + Don't show again Nie pokazuj ponownie + Show a notice when a document is secured Pokazuj powiadomienie, gdy dokument jest zabezpieczony Always use my default zoom and page layout Zawsze używaj mojego domyślnego powiększenia i układu stron This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Ten plik zmienił się na dysku. Wczytaj ponownie pokazuje nową wersję i odrzuca niezapisane zmiany; Zapisz zastępuje ją tą wersją. Reload Wczytaj ponownie @@ -2457,6 +2465,11 @@ 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 + 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 + Attach {name} Dołącz {name} + Delete attachment {key} Usuń załącznik {key} Font Czcionka Bulleted list Lista punktowana Numbered list Lista numerowana @@ -2611,4 +2624,7 @@ animation effects Off Wyłączone field names can't contain a period: {name} nazwy pól nie mogą zawierać kropki: {name} two cells would both be named {name} dwie komórki miałyby nazwę {name} a field named {name} already exists pole o nazwie {name} już istnieje + +# 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 a20da2ad23..5f93cc5ce6 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -81,6 +81,7 @@ PdfCraft is drawing without a graphics processor, so it may be slow. Updating the graphics driver may help. O PdfCraft está desenhando sem processador gráfico, por isso pode ficar lento. Atualizar o driver de vídeo pode ajudar. Go to page… Ir para a página… Reopen the files that were open when PdfCraft last closed Reabrir os arquivos que estavam abertos quando o PdfCraft foi fechado pela última vez + Restore the last view (page, zoom and page display) when a document is opened again Restaurar a última visualização (página, zoom e exibição de páginas) ao reabrir um documento Margins Margens For page sizes and margins. Para tamanhos de página e margens. Open Abrir @@ -238,6 +239,7 @@ Discard private data of other applications Descartar dados privados de outros aplicativos Discard hidden text Descartar texto oculto Use Flate to encode streams that are not encoded Usar Flate para codificar fluxos não codificados + In streams that use LZW encoding, use Flate instead Em fluxos que usam codificação LZW, usar Flate Remove invalid links and bookmarks Remover links e marcadores inválidos Remove unreferenced named destinations Remover destinos nomeados não referenciados Compress document structure (object streams) Compactar estrutura do documento (fluxos de objetos) @@ -1774,6 +1776,10 @@ Perimeter Perímetro Area Área Drawing scale Escala de desenho + Scale Escala + Common scales Escalas comuns + Enter a scale such as 1:50. Digite uma escala como 1:50. + Applying a scale switches units to m / mm. Aplicar uma escala muda as unidades para m / mm. Snapping Alinhamento inteligente Export measurements Exportar medições Open a PDF to measure it. Abra um PDF para medi-lo. @@ -2321,6 +2327,8 @@ key Space Espaço Printer paper and scaling may differ. Check driver settings. O papel e a escala da impressora podem variar. Confira as configurações do driver. Drawing scale changes. Use Actual size for full-scale plans. A escala do desenho muda. Use Tamanho real para plantas em escala. Reload documents when their files change on disk Recarregar documentos quando os arquivos mudarem no disco + Don't show again Não mostrar novamente + Show a notice when a document is secured Mostrar um aviso quando um documento estiver protegido Always use my default zoom and page layout Sempre usar meu zoom e layout da página padrão This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Este arquivo foi alterado no disco. Recarregar mostra a nova versão e descarta suas alterações não salvas; Salvar a substitui por esta versão. Reload Recarregar @@ -2495,6 +2503,11 @@ animation effects Off Desativados Make properties default Tornar propriedades padrão Pages selected Páginas selecionadas Page properties Propriedades da página + 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 + Attach {name} Anexar {name} + Delete attachment {key} Excluir anexo {key} # Remaining keys from the current catalog union, translated from their English meaning (2026-10-21). on page {label} na página {label} @@ -2800,4 +2813,7 @@ theme Appearance Aparência a table has 1 to {max} columns uma tabela tem de 1 a {max} colunas the first row number can be at most {max} o número da primeira linha pode ser no máximo {max} the area, the spacing and the column widths must be numbers, the spacing 0 or more and each width more than 0 a área, o espaçamento e as larguras das colunas devem ser números, o espaçamento 0 ou mais e cada largura maior que 0 + +# 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 5d7440ed75..f4157022a4 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -933,6 +933,8 @@ Redaction code: Cod de anonimizare: Reload Reîncărcare Reload documents when their files change on disk Reîncarcă documentele când fișierele lor se modifică pe disc + Don't show again Nu mai afișa + Show a notice when a document is secured Afișează o notificare când un document este securizat Remove checkmark Eliminare bifă Remove invalid links and bookmarks Eliminare linkuri și semne de carte nevalide Remove page Eliminare pagină @@ -1228,6 +1230,7 @@ Update Actualizare Use 72 DPI (one point per pixel) Folosire 72 DPI (un punct pe pixel) Use Flate to encode streams that are not encoded Folosire Flate pentru codarea fluxurilor necodate + In streams that use LZW encoding, use Flate instead Folosire Flate în locul LZW pentru fluxurile codate LZW Use a Digital ID from a file Folosire ID digital dintr-un fișier Use custom DPI Folosire DPI personalizat Use each image's embedded DPI; use 72 DPI when it is absent. Folosește DPI-ul încorporat al fiecărei imagini; 72 DPI când lipsește. @@ -1399,6 +1402,10 @@ Draw freehand Desenare liberă Drawing Desen Drawing scale Scara desenului + Scale Scară + Common scales Scări uzuale + Enter a scale such as 1:50. Introduceți o scară, de exemplu 1:50. + Applying a scale switches units to m / mm. Aplicarea unei scări schimbă unitățile în m / mm. Drop-down list Listă derulantă Duplicate pages Duplicare pagini Edit a PDF Editare PDF @@ -2774,4 +2781,15 @@ theme Appearance Aspect field names can't contain a period: {name} numele câmpurilor nu pot conține punct: {name} two cells would both be named {name} două celule s-ar numi amândouă {name} a field named {name} already exists există deja un câmp numit {name} + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Astăzi + +# 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 Restabilește ultima vizualizare (pagina, zoomul și afișarea paginilor) când un document este redeschis + Attach a file… Atașare fișier… + Attach {name} Atașare {name} + 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 df56fa7497..1687750cf2 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -604,6 +604,7 @@ 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 Использовать Flate вместо LZW в потоках с кодированием LZW Remove invalid links and bookmarks Удалить недействительные ссылки и закладки Remove unreferenced named destinations Удалить неиспользуемые именованные места назначения Compress document structure (object streams) Сжать структуру документа (потоки объектов) @@ -2041,6 +2042,10 @@ Perimeter Периметр Area Площадь Drawing scale Масштаб чертежа + Scale Масштаб + Common scales Распространённые масштабы + Enter a scale such as 1:50. Введите масштаб, например 1:50. + Applying a scale switches units to m / mm. При применении масштаба единицы переключаются на м / мм. Snapping Привязка Export measurements Экспортировать измерения Open a PDF to measure it. Откройте PDF для измерения. @@ -2108,7 +2113,10 @@ Default zoom Масштаб по умолчанию Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. Используется, когда PDF не запрашивает макет или масштаб. Непрерывная прокрутка никогда не привязывается между страницами. 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 Восстанавливать последний вид (страницу, масштаб и показ страниц) при повторном открытии документа 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 Перезагрузить @@ -2394,6 +2402,11 @@ animation effects Off Выкл Make properties default Сделать свойства стандартными Pages selected Выделенные страницы Page properties Свойства страницы + Attach a file… Прикрепить файл… + Delete attachment Удалить вложение + Attaching files is not available on the web yet Прикрепление файлов в веб-версии пока недоступно + Attach {name} Прикрепить {name} + Delete attachment {key} Удалить вложение {key} Font Шрифт Bulleted list Маркированный список Numbered list Нумерованный список @@ -2428,4 +2441,7 @@ animation effects Off Выкл field names can't contain a period: {name} имена полей не могут содержать точку: {name} two cells would both be named {name} две ячейки получили бы одно имя {name} a field named {name} already exists поле с именем {name} уже существует + +# 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 2b5ab0cf05..bd51400925 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -654,6 +654,10 @@ Draw your {what} below. Spodaj narišite: {what}. Drawing Risba Drawing scale Merilo risbe + Scale Merilo + Common scales Pogosta merila + Enter a scale such as 1:50. Vnesite merilo, na primer 1:50. + Applying a scale switches units to m / mm. Z uporabo merila se enote preklopijo na m / mm. Drop PDFs or folders here, or choose them. Spustite datoteke PDF ali mape sem ali jih izberite. Drop-down list Spustni seznam Dropdown Properties Lastnosti spustnega seznama @@ -1506,6 +1510,7 @@ Rename bookmark Preimenuj zaznamek Rendering: hayro (bootstrap) · UI: egui · Icons: Lucide (ISC) · Fonts: Inter, JetBrains Mono, Dancing Script (OFL) Izris: hayro (bootstrap) · Vmesnik: egui · Ikone: Lucide (ISC) · Pisave: Inter, JetBrains Mono, Dancing Script (OFL) Reopen the files that were open when PdfCraft last closed Ponovno odpri datoteke, ki so bile odprte ob zadnjem zaprtju PdfCrafta + Restore the last view (page, zoom and page display) when a document is opened again Ob ponovnem odprtju dokumenta obnovi zadnji pogled (stran, povečavo in prikaz strani) Repair log Dnevnik popravil Repairs Popravila Repeat overlay text Ponovi besedilo prekrivanja @@ -1964,6 +1969,7 @@ Update watermark… Posodobi vodni žig… Use 72 DPI (one point per pixel) Uporabi 72 DPI (ena točka na slikovno piko) Use Flate to encode streams that are not encoded Za kodiranje nekodiranih tokov uporabi Flate + In streams that use LZW encoding, use Flate instead V tokovih s kodiranjem LZW uporabi Flate Use JavaScript Uporabi JavaScript Use a Digital ID from a file Uporabi digitalno identiteto iz datoteke Use a certificate Uporabi potrdilo @@ -2325,6 +2331,8 @@ key Space Preslednica Printer paper and scaling may differ. Check driver settings. Papir in merilo tiskalnika se lahko razlikujeta. Preverite nastavitve gonilnika. Drawing scale changes. Use Actual size for full-scale plans. Merilo risbe se spremeni. Za načrte v polnem merilu uporabite Dejansko velikost. Reload documents when their files change on disk Znova naloži dokumente, ko se njihove datoteke na disku spremenijo + Don't show again Ne prikaži več + Show a notice when a document is secured Prikaži obvestilo, ko je dokument zaščiten Always use my default zoom and page layout Vedno uporabi moje privzeto povečanje in postavitev strani This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Ta datoteka se je na disku spremenila. Ponovno naloži pokaže novo različico in zavrže neshranjene spremembe; Shrani jo zamenja s to različico. Reload Znova naloži @@ -2486,6 +2494,11 @@ animation effects Off Izklopljeni Make properties default Nastavi lastnosti kot privzete Pages selected Izbrane strani Page properties Lastnosti strani + 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 + Attach {name} Priloži {name} + Delete attachment {key} Izbriši prilogo {key} Font Pisava Bulleted list Označen seznam Numbered list Oštevilčen seznam @@ -2520,4 +2533,7 @@ animation effects Off Izklopljeni field names can't contain a period: {name} imena polj ne smejo vsebovati pike: {name} two cells would both be named {name} dve celici bi imeli enako ime {name} a field named {name} already exists polje z imenom {name} že obstaja + +# 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 c3bb902e57..b2e730081e 100644 --- a/crates/ui-egui/src/i18n/sq.tsv +++ b/crates/ui-egui/src/i18n/sq.tsv @@ -645,6 +645,10 @@ Draw your {what} below. Vizato {what} më poshtë. Drawing Vizatim Drawing scale Shkalla e vizatimit + Scale Shkalla + Common scales Shkalla të zakonshme + Enter a scale such as 1:50. Shkruani një shkallë si 1:50. + Applying a scale switches units to m / mm. Zbatimi i një shkalle i kalon njësitë në m / mm. Drop PDFs or folders here, or choose them. Hidh PDF ose dosje këtu, ose zgjidh ato. Drop-down list Listë rënëse Dropdown Properties Vetitë e listës rënëse @@ -1910,6 +1914,7 @@ Update watermark… Përditëso filigranin… Use 72 DPI (one point per pixel) Përdor 72 DPI (një pikë për piksel) Use Flate to encode streams that are not encoded Përdor Flate për të koduar rrjedhat që nuk janë të koduara + In streams that use LZW encoding, use Flate instead Në rrjedhat që përdorin kodimin LZW, përdor Flate në vend të tij Use JavaScript Përdor JavaScript Use a Digital ID from a file Përdor një ID dixhitale nga një skedar Use a certificate Përdor një certifikatë @@ -2263,6 +2268,8 @@ key Space Hapësirë Printer paper and scaling may differ. Check driver settings. Letra dhe shkallëzimi i printerit mund të ndryshojnë. Kontrolloni cilësimet e drajverit. Drawing scale changes. Use Actual size for full-scale plans. Shkalla e vizatimit ndryshon. Përdorni Madhësinë aktuale për planet në shkallë të plotë. Reload documents when their files change on disk Ringarko dokumentet kur skedarët e tyre ndryshojnë në disk + Don't show again Mos e shfaq më + Show a notice when a document is secured Shfaq një njoftim kur një dokument është i siguruar This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Ky skedar ndryshoi në disk. Ringarkojeni për të parë versionin e ri; ndryshimet tuaja të paruajtura do të humbasin. Reload Ringarko Couldn't reload {name}: {e} Nuk u ringarkua {name}: {e} @@ -2500,4 +2507,15 @@ animation effects Off Joaktiv field names can't contain a period: {name} emrat e fushave nuk mund të përmbajnë pikë: {name} two cells would both be named {name} dy qeliza do të kishin të dyja emrin {name} a field named {name} already exists ekziston tashmë një fushë me emrin {name} + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Sot + +# 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 Rikthe pamjen e fundit (faqen, zmadhimin dhe shfaqjen e faqeve) kur një dokument hapet përsëri + Attach a file… Bashkëngjit një skedar… + Attach {name} Bashkëngjit {name} + 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 7c5b0b85a0..ea4a2a3df9 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -588,6 +588,7 @@ comment menu Edit సవరించు 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) పత్రం నిర్మాణాన్ని కుదించు (ఆబ్జెక్ట్ స్ట్రీమ్‌లు) @@ -1960,6 +1961,10 @@ action wizard Start ప్రారంభించు Perimeter చుట్టుకొలత Area వైశాల్యం Drawing scale డ్రాయింగ్ స్కేల్ + Scale స్కేల్ + Common scales సాధారణ స్కేల్‌లు + Enter a scale such as 1:50. 1:50 వంటి స్కేల్‌ను నమోదు చేయండి. + Applying a scale switches units to m / mm. స్కేల్‌ను వర్తింపజేస్తే యూనిట్లు m / mm‌కు మారతాయి. Snapping స్నాపింగ్ Export measurements కొలతలను ఎగుమతి చేయి Open a PDF to measure it. కొలవడానికి ఒక PDFను తెరవండి. @@ -2027,7 +2032,10 @@ action wizard Start ప్రారంభించు Default zoom డిఫాల్ట్ జూమ్ Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. PDF ఏ లేఅవుట్‌ను గానీ జూమ్‌ను గానీ కోరనప్పుడు ఉపయోగించబడుతుంది. నిరంతర స్క్రోలింగ్ పేజీల మధ్య ఎప్పుడూ స్నాప్ అవ్వదు. 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 పత్రాన్ని మళ్లీ తెరిచినప్పుడు చివరి వీక్షణను (పేజీ, జూమ్ మరియు పేజీ ప్రదర్శన) పునరుద్ధరించండి 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 మళ్లీ లోడ్ చేయి @@ -2390,6 +2398,11 @@ animation effects Off ఆఫ్ Make properties default లక్షణాలను డిఫాల్ట్‌గా చేయి Pages selected ఎంచుకున్న పేజీలు Page properties పేజీ లక్షణాలు + Attach a file… ఫైల్‌ను జోడించు… + Delete attachment జోడింపును తొలగించు + Attaching files is not available on the web yet వెబ్‌లో ఫైల్‌లను జోడించడం ఇంకా అందుబాటులో లేదు + Attach {name} {name}ను జోడించు + Delete attachment {key} జోడింపు {key}ను తొలగించు Font ఫాంట్ Bulleted list బుల్లెట్ జాబితా Numbered list సంఖ్యల జాబితా @@ -2424,4 +2437,7 @@ animation effects Off ఆఫ్ field names can't contain a period: {name} ఫీల్డ్ పేర్లలో చుక్క ఉండకూడదు: {name} two cells would both be named {name} రెండు గడులకూ {name} అనే పేరు వస్తుంది a field named {name} already exists {name} అనే ఫీల్డ్ ఇప్పటికే ఉంది + +# 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 e13480dc3e..bab6893665 100644 --- a/crates/ui-egui/src/i18n/th.tsv +++ b/crates/ui-egui/src/i18n/th.tsv @@ -568,6 +568,7 @@ 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 ใช้ Flate แทนในสตรีมที่เข้ารหัสแบบ LZW Remove invalid links and bookmarks เอาลิงก์และบุ๊กมาร์กที่ใช้ไม่ได้ออก Remove unreferenced named destinations เอาปลายทางที่มีชื่อซึ่งไม่ถูกอ้างถึงออก Compress document structure (object streams) บีบอัดโครงสร้างเอกสาร (object streams) @@ -1984,6 +1985,10 @@ Perimeter เส้นรอบรูป Area พื้นที่ Drawing scale มาตราส่วนของแบบ + Scale มาตราส่วน + Common scales มาตราส่วนที่ใช้บ่อย + Enter a scale such as 1:50. ป้อนมาตราส่วน เช่น 1:50 + Applying a scale switches units to m / mm. การใช้มาตราส่วนจะเปลี่ยนหน่วยเป็น m / mm Snapping การยึด Export measurements ส่งออกการวัด Open a PDF to measure it. เปิด PDF เพื่อวัดระยะ @@ -2226,6 +2231,8 @@ signature pad Type พิมพ์ Printer paper and scaling may differ. Check driver settings. กระดาษและการปรับขนาดของเครื่องพิมพ์อาจต่างออกไป โปรดตรวจสอบการตั้งค่าไดรเวอร์ Drawing scale changes. Use Actual size for full-scale plans. มาตราส่วนของแบบจะเปลี่ยน ใช้ขนาดจริงสำหรับแบบแปลนเต็มมาตราส่วน Reload documents when their files change on disk โหลดเอกสารใหม่เมื่อไฟล์บนดิสก์เปลี่ยนแปลง + Don't show again ไม่ต้องแสดงอีก + Show a notice when a document is secured แสดงการแจ้งเตือนเมื่อเอกสารมีการรักษาความปลอดภัย This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. ไฟล์นี้เปลี่ยนแปลงบนดิสก์ โหลดใหม่เพื่อดูรุ่นใหม่ การเปลี่ยนแปลงที่ยังไม่ได้บันทึกจะหายไป Reload โหลดใหม่ Couldn't reload {name}: {e} โหลด {name} ใหม่ไม่ได้: {e} @@ -2681,4 +2688,10 @@ key Space Space Page properties คุณสมบัติหน้า The password must have at least 8 characters. รหัสผ่านต้องมีอย่างน้อย 8 ตัวอักษร theme Appearance ลักษณะที่ปรากฏ + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today วันนี้ + +# 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 881bc4e234..216bd3f59b 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -569,6 +569,7 @@ Discard private data of other applications Diğer uygulamaların özel verilerini at Discard hidden text Gizli metni at Use Flate to encode streams that are not encoded Kodlanmamış akışları kodlamak için Flate kullan + In streams that use LZW encoding, use Flate instead LZW kodlamalı akışlarda bunun yerine Flate kullan Remove invalid links and bookmarks Geçersiz bağlantıları ve yer işaretlerini kaldır Remove unreferenced named destinations Referans verilmeyen adlandırılmış hedefleri kaldır Compress document structure (object streams) Belge yapısını sıkıştır (nesne akışları) @@ -1934,6 +1935,10 @@ Perimeter Çevre Area Alan Drawing scale Çizim ölçeği + Scale Ölçek + Common scales Yaygın ölçekler + Enter a scale such as 1:50. 1:50 gibi bir ölçek girin. + Applying a scale switches units to m / mm. Ölçek uygulamak birimleri m / mm olarak değiştirir. Snapping Yakalama Export measurements Ölçümleri dışa aktar Open a PDF to measure it. Ölçmek için bir PDF açın. @@ -2139,6 +2144,7 @@ signature pad Type Yaz certificate Subject Konu action wizard Start Başlat Reopen the files that were open when PdfCraft last closed PdfCraft en son kapatıldığında açık olan dosyaları yeniden aç + Restore the last view (page, zoom and page display) when a document is opened again Bir belge yeniden açıldığında son görünümü (sayfa, yakınlaştırma ve sayfa görünümü) geri yükle Add {what} {what} ekle Use {what} {what} kullan Place saved {what} Kayıtlı {what} yerleştir @@ -2316,6 +2322,8 @@ key Space Boşluk Printer paper and scaling may differ. Check driver settings. Yazıcı kâğıdı ve ölçekleme farklı olabilir. Sürücü ayarlarını kontrol edin. Drawing scale changes. Use Actual size for full-scale plans. Çizim ölçeği değişir. Tam ölçekli planlar için Gerçek boyutu kullanın. Reload documents when their files change on disk Diskteki dosyaları değiştiğinde belgeleri yeniden yükle + Don't show again Bir daha gösterme + Show a notice when a document is secured Bir belge güvenli olduğunda bildirim göster Always use my default zoom and page layout Her zaman varsayılan yakınlaştırmamı ve sayfa düzenimi kullan This file changed on disk. Reload shows the new version and drops your unsaved changes; Save replaces it with this version. Bu dosya diskte değişti. Yeniden yükle yeni sürümü gösterir ve kaydedilmemiş değişikliklerinizi atar; Kaydet onu bu sürümle değiştirir. Reload Yeniden yükle @@ -2477,6 +2485,11 @@ animation effects Off Kapalı Make properties default Özellikleri varsayılan yap Pages selected Seçilen sayfalar Page properties Sayfa özellikleri + 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 + Attach {name} {name} ekle + Delete attachment {key} {key} ekini sil Font Yazı tipi Bulleted list Madde işaretli liste Numbered list Numaralı liste @@ -2511,4 +2524,7 @@ animation effects Off Kapalı field names can't contain a period: {name} alan adları nokta içeremez: {name} two cells would both be named {name} iki hücrenin de adı {name} olurdu a field named {name} already exists {name} adlı bir alan zaten var + +# 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 e77e5fe05d..115dc29004 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -609,6 +609,7 @@ 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 Використовувати Flate замість LZW у потоках із кодуванням LZW Remove invalid links and bookmarks Видалити недійсні посилання й закладки Remove unreferenced named destinations Видалити іменовані призначення без посилань Compress document structure (object streams) Стиснути структуру документа (потоки об’єктів) @@ -2038,6 +2039,10 @@ Perimeter Периметр Area Площа Drawing scale Масштаб креслення + Scale Масштаб + Common scales Поширені масштаби + Enter a scale such as 1:50. Введіть масштаб, наприклад 1:50. + Applying a scale switches units to m / mm. Застосування масштабу перемикає одиниці на м / мм. Snapping Прив’язування Export measurements Експортувати вимірювання Open a PDF to measure it. Відкрийте PDF для вимірювання. @@ -2191,6 +2196,7 @@ calendar date October жовтня calendar date November листопада calendar date December грудня 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 Відновлювати останній вигляд (сторінку, масштаб і показ сторінок) під час повторного відкриття документа Clear Recent Files Очистити список нещодавніх файлів Remove from Recent Вилучити з нещодавніх Remove {name} from Recent Вилучити {name} з нещодавніх @@ -2341,6 +2347,8 @@ key Space Пробіл Printer paper and scaling may differ. Check driver settings. Папір і масштаб принтера можуть відрізнятися. Перевірте налаштування драйвера. Drawing scale changes. Use Actual size for full-scale plans. Масштаб креслення змінюється. Для друку в початковому масштабі виберіть Фактичний розмір. 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 Перезавантажити @@ -2503,6 +2511,11 @@ animation effects Off Вимкнено Make properties default Зробити властивості типовими Pages selected Вибрані сторінки Page properties Властивості сторінки + Attach a file… Прикріпити файл… + Delete attachment Видалити вкладення + Attaching files is not available on the web yet Прикріплення файлів у веб-версії поки недоступне + Attach {name} Прикріпити {name} + Delete attachment {key} Видалити вкладення {key} Font Шрифт Bulleted list Маркований список Numbered list Нумерований список @@ -2537,4 +2550,7 @@ animation effects Off Вимкнено field names can't contain a period: {name} імена полів не можуть містити крапку: {name} two cells would both be named {name} дві клітинки мали б однакове ім'я {name} a field named {name} already exists поле з іменем {name} уже існує + +# 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 1c4c72b61f..f6a7c0430b 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -969,6 +969,8 @@ theme Appearance Giao diện Redaction code: Mã xóa nội dung nhạy cảm: Reload Tải lại Reload documents when their files change on disk Tải lại tài liệu khi tệp của chúng thay đổi trên đĩa + Don't show again Không hiển thị lại + Show a notice when a document is secured Hiển thị thông báo khi tài liệu được bảo mật Always use my default zoom and page layout Luôn dùng thu phóng và bố cục trang mặc định của tôi Remove checkmark Bỏ dấu kiểm Remove invalid links and bookmarks Gỡ các liên kết và dấu trang không hợp lệ @@ -1268,6 +1270,7 @@ theme Appearance Giao diện Update Cập nhật Use 72 DPI (one point per pixel) Dùng 72 DPI (một điểm cho mỗi điểm ảnh) Use Flate to encode streams that are not encoded Dùng Flate để mã hóa các luồng chưa được mã hóa + In streams that use LZW encoding, use Flate instead Dùng Flate thay cho LZW trong các luồng mã hóa LZW Use a Digital ID from a file Dùng Digital ID từ một tệp Use custom DPI Dùng DPI tùy chỉnh Use each image's embedded DPI; use 72 DPI when it is absent. Dùng DPI nhúng của từng hình ảnh; dùng 72 DPI khi không có. @@ -1439,6 +1442,10 @@ theme Appearance Giao diện Draw freehand Vẽ tự do Drawing Bản vẽ Drawing scale Tỷ lệ bản vẽ + Scale Tỷ lệ + Common scales Tỷ lệ thông dụng + Enter a scale such as 1:50. Nhập một tỷ lệ như 1:50. + Applying a scale switches units to m / mm. Áp dụng tỷ lệ sẽ chuyển đơn vị sang m / mm. Drop-down list Danh sách thả xuống Duplicate pages Nhân bản trang Edit a PDF Chỉnh sửa một PDF @@ -2748,6 +2755,11 @@ key Space Space # Historical snapshot notice bar (View signed version / View revision). You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Bạn đang xem phiên bản đã ký của “{from}” (bản sửa đổi {rev}). Đây là ảnh nhanh: các thay đổi ở đây không được áp dụng đến bản gốc. You are viewing revision {rev} of “{from}”. It is a snapshot: changes here won't affect the original. Bạn đang xem bản sửa đổi {rev} của “{from}”. Đây là ảnh nhanh: các thay đổi ở đây không được áp dụng đến bản gốc. + Attach a file… Đính kèm tệp… + Delete attachment Xóa tệp đính kèm + Attaching files is not available on the web yet Chưa thể đính kèm tệp trên web + Attach {name} Đính kèm {name} + Delete attachment {key} Xóa tệp đính kèm {key} Thumbnail size Kích thước hình thu nhỏ Smaller thumbnails Giảm kích thước hình thu nhỏ Larger thumbnails Tăng kích thước hình thu nhỏ @@ -2780,4 +2792,10 @@ key Space Space a table has 1 to {max} columns một bảng có từ 1 đến {max} cột the first row number can be at most {max} số hàng đầu tiên tối đa là {max} the area, the spacing and the column widths must be numbers, the spacing 0 or more and each width more than 0 vùng, khoảng cách và độ rộng cột phải là số, khoảng cách từ 0 trở lên và mỗi độ rộng lớn hơn 0 + +# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed) + Today Hôm nay + +# 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 837c929c7d..d80fe181dc 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -288,6 +288,7 @@ 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) 压缩文档结构(对象流) @@ -1910,6 +1911,10 @@ Perimeter 周长 Area 面积 Drawing scale 图面比例 + Scale 比例 + Common scales 常用比例 + Enter a scale such as 1:50. 请输入比例,例如 1:50。 + Applying a scale switches units to m / mm. 应用比例会将单位切换为 m / mm。 Snapping 吸附 Export measurements 导出测量结果 Open a PDF to measure it. 打开 PDF 以进行测量。 @@ -2142,7 +2147,10 @@ Used when opening PDFs. An explicit launch or control mode takes precedence for the session. 用于打开 PDF 时。如果启动参数或控制通道明确指定了模式,则本次会话优先使用该模式。 Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages. PDF 未指定布局或缩放时使用。连续滚动时页面不会逐页吸附。 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 再次打开文档时恢复上次的视图(页面、缩放和页面显示) 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 重新加载 @@ -2445,6 +2453,11 @@ animation effects Off 关 Make properties default 将属性设为默认 Pages selected 选定的页面 Page properties 页面属性 + Attach a file… 附加文件… + Delete attachment 删除附件 + Attaching files is not available on the web yet 网页版尚不支持附加文件 + Attach {name} 附加 {name} + Delete attachment {key} 删除附件 {key} Font 字体 Bulleted list 项目符号列表 Numbered list 编号列表 @@ -2479,4 +2492,7 @@ animation effects Off 关 field names can't contain a period: {name} 字段名称不能包含句点:{name} two cells would both be named {name} 两个单元格的名称都会是 {name} a field named {name} already exists 已存在名为 {name} 的字段 + +# 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 6f0a7beb6b..a6e1f182af 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -244,6 +244,7 @@ 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) 壓縮文件結構(物件流) @@ -2041,6 +2042,10 @@ Perimeter 周長 Area 面積 Drawing scale 圖面比例 + Scale 比例 + Common scales 常用比例 + Enter a scale such as 1:50. 請輸入比例,例如 1:50。 + Applying a scale switches units to m / mm. 套用比例會將單位切換為 m / mm。 Snapping 吸附 Export measurements 匯出測量結果 Open a PDF to measure it. 開啟 PDF 以進行測量。 @@ -2093,6 +2098,7 @@ Go to page… 轉到頁面… For page sizes and margins. 用於頁面尺寸和邊距。 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 再次開啟文件時還原上次的檢視(頁面、縮放與頁面顯示) 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 閱讀器會依據該值重新繪製此欄位。 @@ -2193,6 +2199,8 @@ key Space 空格 Tools 工具 Inspector 檢查器 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 重新載入 @@ -2431,6 +2439,11 @@ animation effects Off 關 Make properties default 將內容設為預設 Pages selected 選取的頁面 Page properties 頁面屬性 + Attach a file… 附加檔案… + Delete attachment 刪除附件 + Attaching files is not available on the web yet 網頁版尚不支援附加檔案 + Attach {name} 附加 {name} + Delete attachment {key} 刪除附件 {key} Font 字型 Bulleted list 項目符號清單 Numbered list 編號清單 @@ -2465,4 +2478,7 @@ animation effects Off 關 field names can't contain a period: {name} 欄位名稱不能包含句點:{name} two cells would both be named {name} 兩個儲存格的名稱都會是 {name} a field named {name} already exists 已有名為 {name} 的欄位 + +# 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/image_pick.rs b/crates/ui-egui/src/image_pick.rs index 53e4bdf158..403e9836c0 100644 --- a/crates/ui-egui/src/image_pick.rs +++ b/crates/ui-egui/src/image_pick.rs @@ -183,6 +183,8 @@ impl PdfCraftApp { /// Copy Image in Original Format: image `index` on `page` as a file on the clipboard (JPEG /// as stored in the PDF, otherwise PNG). pub(crate) fn copy_page_image_file(&mut self, page: usize, index: usize) { + // A newer copy wins over a whole-document copy still reading its pages. + self.document_copy = None; let Some((_, id)) = self.active_ids() else { return }; if !self.may_copy(id) { return; diff --git a/crates/ui-egui/src/js_ui.rs b/crates/ui-egui/src/js_ui.rs index 7bc3dd0444..49611293ac 100644 --- a/crates/ui-egui/src/js_ui.rs +++ b/crates/ui-egui/src/js_ui.rs @@ -2,7 +2,7 @@ //! the JavaScript console (⌘J), Document JavaScripts, and Preferences ▸ JavaScript. use egui::{Align, Layout}; -use pdfcraft_engine::js::{JsOutput, Request}; +use pdfcraft_engine::js::{JsOutput, Panel, Request}; use pdfcraft_engine::{DocId, Edit}; use crate::theme::{self, Tokens}; @@ -67,6 +67,20 @@ impl PdfCraftApp { &[("u", &u)], ), Request::SaveAs => self.run_command("file.save_as"), + Request::ShowPanel(p) => { + let panel = match p { + Panel::Bookmarks => crate::RightPanel::Bookmarks, + Panel::Pages => crate::RightPanel::Pages, + Panel::Attachments => crate::RightPanel::Attachments, + Panel::Signatures => crate::RightPanel::Signatures, + Panel::Layers => crate::RightPanel::Layers, + }; + // Show/Hide: a panel already showing closes. + self.right = (self.right != Some(panel)).then_some(panel); + if self.right.is_some() { + self.pending_panel_reveals.push(crate::docking::Panel::Inspector); + } + } Request::Focus(_) | Request::Beep | Request::Reset(_) => {} } } @@ -88,7 +102,13 @@ impl PdfCraftApp { { self.views[i].document_changed(&info); } - let out = JsOutput { alerts: o.alerts, console: o.console, requests: o.requests, errors: o.error.into_iter().collect() }; + let out = JsOutput { + alerts: o.alerts, + console: o.console, + requests: o.requests, + errors: o.error.into_iter().collect(), + doc_errors: Vec::new(), + }; self.handle_js(id, out); } Err(e) => self.notify_fmt("{field}: {e}", &[("field", field), ("e", &e.to_string())]), @@ -128,7 +148,7 @@ impl PdfCraftApp { self.views[i].document_changed(&info); } let (error, result) = (o.error.clone(), o.result.clone()); - let out = JsOutput { alerts: o.alerts, console: o.console, requests: o.requests, errors: Vec::new() }; + let out = JsOutput { alerts: o.alerts, console: o.console, requests: o.requests, errors: Vec::new(), doc_errors: Vec::new() }; self.handle_js(id, out); match error { Some(e) => self.js_console.log.push(format!("Error: {e}")), @@ -391,9 +411,14 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok // The web build has no file paths to reopen. #[cfg(not(target_arch = "wasm32"))] ui.add(widgets::checkbox(&mut app.reopen_last_session, tl!("Reopen the files that were open when PdfCraft last closed"))); + // Acrobat's "Restore last view settings when reopening documents" (#1767). + #[cfg(not(target_arch = "wasm32"))] + ui.add(widgets::checkbox(&mut app.restore_last_view, tl!("Restore the last view (page, zoom and page display) when a document is opened again"))); // The web build has no files on disk to watch. #[cfg(not(target_arch = "wasm32"))] ui.add(widgets::checkbox(&mut app.reload_changed_files, tl!("Reload documents when their files change on disk"))); + // The notice's "Don't show again" turns this off (#4035); the restrictions stay either way. + ui.add(widgets::checkbox(&mut app.show_secured_notice, tl!("Show a notice when a document is secured"))); ui.horizontal(|ui| { ui.label(tl!("Scroll speed (mouse wheel and arrow keys)")); for speed in crate::scroll_speed::ScrollSpeed::ALL { diff --git a/crates/ui-egui/src/last_view.rs b/crates/ui-egui/src/last_view.rs new file mode 100644 index 0000000000..13012b1d60 --- /dev/null +++ b/crates/ui-egui/src/last_view.rs @@ -0,0 +1,129 @@ +//! Restore last view settings when reopening documents (Acrobat's Preferences ▸ Documents +//! option; #1767): the page, zoom and page display (with the two-page cover setting) a file was +//! last shown with, remembered by its path when its tab is closed or settings are saved, and +//! applied when the file is opened again. Desktop only: a document without a file has nothing to be found by. + +use serde::Serialize; + +use crate::PdfCraftApp; +use crate::canvas::{self, ViewDefaults}; + +/// How many files' views are remembered (the most recently closed or saved first). +const MAX_REMEMBERED: usize = 100; + +/// How a file was last shown. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct LastView { + /// Absolute, as `last_session` keys files. + pub path: String, + /// 0-based. + pub page: usize, + /// As settings name it: `fit-width`, `fit-page` or a percentage, kept to its fraction + /// (`62.5%`; read back by `ViewDefaults::with_zoom`). + pub zoom: String, + /// The page display token (`canvas::display_token`). + pub display: String, + /// Whether two-page view showed a cover page (the first page on its own). + pub cover: bool, +} + +/// As `last_session` keys files (and the web has no paths to remember). +#[cfg(not(target_arch = "wasm32"))] +fn absolute(path: &str) -> String { + std::path::absolute(path).map_or_else(|_| path.to_string(), |p| p.to_string_lossy().into_owned()) +} + +#[cfg(target_arch = "wasm32")] +fn absolute(path: &str) -> String { + path.to_string() +} + +impl PdfCraftApp { + /// How view `index` shows its document now; `None` for a document without a file. In full + /// screen, the display it will go back to, at the page reached. + fn view_to_remember(&self, index: usize) -> Option { + let view = self.views.get(index)?; + let path = self.session.get(view.id)?.path.as_deref()?; + let shown = view.normal_display(); + // A fixed zoom keeps its fraction (62.5%, not 63%): `zoom_name` rounds to whole percents. + let zoom = match shown.fit { + canvas::Fit::None => format!("{}%", shown.zoom * 100.0), + fit => ViewDefaults { fit, zoom: shown.zoom, ..ViewDefaults::default() }.zoom_name(), + }; + Some(LastView { + path: absolute(path), + page: view.current, + zoom, + display: canvas::display_token(shown.layout, shown.continuous).to_string(), + cover: shown.cover, + }) + } + + /// Remember how view `index` shows its document (when its tab closes). + pub(crate) fn remember_view(&mut self, index: usize) { + if let Some(v) = self.view_to_remember(index) { + self.last_views.retain(|l| l.path != v.path); + self.last_views.insert(0, v); + self.last_views.truncate(MAX_REMEMBERED); + } + } + + /// The remembered views as settings store them: the open views first, as they are now + /// (so quitting remembers them without closing their tabs), then the rest. + pub(crate) fn last_views_to_save(&self) -> Vec { + let mut out: Vec = (0..self.views.len()).filter_map(|i| self.view_to_remember(i)).collect(); + for l in &self.last_views { + if !out.iter().any(|o| o.path == l.path) { + out.push(l.clone()); + } + } + out.truncate(MAX_REMEMBERED); + out + } + + /// Settings are untrusted: an entry without a usable path, zoom or display is dropped, and + /// the list is capped. + pub(crate) fn restore_last_views(&mut self, v: &serde_json::Value) { + let Some(list) = v.as_array() else { return }; + self.last_views = list + .iter() + .take(MAX_REMEMBERED) + .filter_map(|e| { + let path = e["path"].as_str().filter(|p| !p.is_empty())?.to_string(); + let zoom = e["zoom"].as_str().filter(|z| ViewDefaults::default().with_zoom(z).is_some())?.to_string(); + let display = e["display"].as_str().filter(|d| canvas::parse_display_token(d).is_some())?.to_string(); + let page = usize::try_from(e["page"].as_u64().unwrap_or(0)).unwrap_or(0); + let cover = e["cover"].as_bool().unwrap_or(false); + Some(LastView { path, page, zoom, display, cover }) + }) + .collect(); + } + + /// Show view `index` as its document's file was last shown, when the preference is on and + /// the file is remembered. Applied after the document's own initial view, which it + /// overrides: where the reader left off is what they asked to come back to. + pub(crate) fn apply_last_view(&mut self, index: usize) { + if !self.restore_last_view { + return; + } + let Some(view) = self.views.get(index) else { return }; + let Some(doc) = self.session.get(view.id) else { return }; + let Some(path) = doc.path.as_deref().map(absolute) else { return }; + let pages = doc.info.pages.len(); + let Some(last) = self.last_views.iter().find(|l| l.path == path).cloned() else { return }; + let Some(view) = self.views.get_mut(index) else { return }; + if let Some((layout, continuous)) = canvas::parse_display_token(&last.display) { + view.set_display(layout, continuous); + } + view.set_cover(last.cover); + if let Some(defaults) = ViewDefaults::default().with_zoom(&last.zoom) { + match defaults.fit { + canvas::Fit::None => view.set_zoom(defaults.zoom), + fit => view.set_fit(fit), + } + } + if last.page < pages { + view.go_to_page(last.page); + } + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index af9912f456..e50145f8c9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -99,6 +99,7 @@ mod home; mod icon_data; pub mod icons; pub mod last_session; +pub mod last_view; pub mod motion; mod page_selection_ui; mod pageboxes; @@ -373,9 +374,19 @@ pub enum PropsTab { InitialView, Security, Fonts, + Custom, Advanced, } +/// Document Properties ▸ Custom being edited: the properties as they will be after OK, in order, +/// and the Name and Value fields. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct CustomDraft { + pub props: Vec<(String, String)>, + pub name: String, + pub value: String, +} + /// Duplicate Field: the field and the pages (all, or a range, 1-based). #[derive(Clone, Debug, PartialEq)] pub struct DuplicateDraft { @@ -395,6 +406,120 @@ pub struct PageClip { pub pages: Vec, } +/// One page of a whole-document copy: its text as read for display, to read, or not to try +/// (reading it failed before). +enum CopyPage { + Read(std::sync::Arc), + ToRead, + Skip, +} + +/// What a whole-document copy needs, taken from the view on the UI thread: each page's text +/// layer as far as it was read, and the document to read the others from. +struct DocumentCopyJob { + pages: Vec, + bytes: std::sync::Arc>, + password: Option>, +} + +impl DocumentCopyJob { + fn of(v: &DocView, doc: &pdfcraft_engine::Document) -> DocumentCopyJob { + let pages = (0..v.page_count()) + .map(|p| match v.texts.get(&p) { + Some(t) => CopyPage::Read(t.clone()), + None if v.text_failed.contains(&p) => CopyPage::Skip, + None => CopyPage::ToRead, + }) + .collect(); + DocumentCopyJob { pages, bytes: doc.bytes.clone(), password: doc.password.as_deref().map(std::sync::Arc::from) } + } + + /// The document's text in page order, a page per line run. Each page read here is let go + /// once its text is taken, so a large document never holds every page's text layer at once. + /// `None` when `cancel` was set. + fn text(self, cancel: &std::sync::atomic::AtomicBool) -> Option { + let mut renderer: Option = None; + let mut out = String::new(); + let mut push = |text: String| { + if !text.is_empty() { + if !out.is_empty() { + out.push('\n'); + } + out.push_str(&text); + } + }; + for (page, p) in self.pages.into_iter().enumerate() { + if cancel.load(std::sync::atomic::Ordering::Relaxed) { + return None; + } + match p { + CopyPage::Read(t) => push(t.text_of(0..t.glyphs.len())), + CopyPage::ToRead => { + let r = renderer.get_or_insert_with(|| { + let config = pdfcraft_render::RenderConfig { password: self.password.clone(), ..Default::default() }; + pdfcraft_render::PageRenderer::new(self.bytes.clone(), config) + }); + let req = pdfcraft_render::RenderRequest { + page, + kind: pdfcraft_render::RequestKind::Text, + tile: None, + scale: 1.0, + tag: 0, + preview: false, + }; + if let Some(t) = r.render(req).text { + push(t.text_of(0..t.glyphs.len())); + } + } + CopyPage::Skip => {} + } + } + Some(out) + } +} + +/// A whole-document copy reading its pages off the UI thread (in place on the web, which has no +/// threads). Dropping it (a new copy, the document closed) stops the reading. +struct DocumentCopy { + doc: DocId, + rx: std::sync::mpsc::Receiver, + cancel: std::sync::Arc, +} + +impl DocumentCopy { + fn start(doc: DocId, job: DocumentCopyJob, ctx: &egui::Context) -> DocumentCopy { + let (tx, rx) = std::sync::mpsc::channel(); + let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + #[cfg(not(target_arch = "wasm32"))] + { + let (stop, ctx) = (cancel.clone(), ctx.clone()); + let spawned = std::thread::Builder::new().name("copy-document-text".into()).spawn(move || { + if let Some(text) = job.text(&stop) { + let _ = tx.send(text); + ctx.request_repaint(); + } + }); + if let Err(e) = spawned { + log::warn!("copying the document's text: {e}"); + } + } + #[cfg(target_arch = "wasm32")] + { + let _ = ctx; + if let Some(text) = job.text(&cancel) { + let _ = tx.send(text); + } + } + DocumentCopy { doc, rx, cancel } + } +} + +impl Drop for DocumentCopy { + fn drop(&mut self) { + self.cancel.store(true, std::sync::atomic::Ordering::Relaxed); + } +} + /// Files delivered asynchronously: (name, bytes). pub type Inbox = std::sync::Arc)>>>; @@ -584,6 +709,11 @@ pub struct PdfCraftApp { pub recent: Vec, /// Preferences: reopen the files that were open when PdfCraft last closed (#442). pub reopen_last_session: bool, + /// Restore last view settings when reopening documents (Preferences ▸ Documents and view; + /// #1767): the page, zoom and page display a file was last shown with. On by default. + pub restore_last_view: bool, + /// How files were last shown, most recent first (see `last_view`). + pub last_views: Vec, /// The files open when PdfCraft last closed, read from the settings for /// [`PdfCraftApp::reopen_last_files`]. pub last_session: last_session::LastSession, @@ -591,6 +721,9 @@ pub struct PdfCraftApp { quit_session: Option, /// Preferences: reload a document when its file changes on disk (#431). pub reload_changed_files: bool, + /// Preferences: tell the user, above the pages, that a document is secured (its security + /// settings restrict changes). Off only hides that notice (#4035); kept in the settings. + pub show_secured_notice: bool, /// Preferences: open every document at Default zoom and Default page display, whatever /// layout or zoom it asks for (#1618). pub always_default_view: bool, @@ -620,6 +753,8 @@ pub struct PdfCraftApp { /// Whether the window currently has the app's header bar (`None` until the first frame). app_frame_shown: Option, pub password_prompt: Option, + /// Copy of a whole document's text (#2639) whose pages are being read off the UI thread. + document_copy: Option, pub unlock_prompt: Option, pub full_screen: bool, /// Files delivered asynchronously (web drag-and-drop, web file picker). @@ -642,6 +777,12 @@ pub struct PdfCraftApp { pub close_request: Option, /// The button of the "save changes?" question the keyboard has chosen; Save when it opens. pub(crate) save_prompt_button: dialogs::PromptButton, + /// `attend_save_prompt` has seen the pending "save changes?" question. It is false again once + /// the question is answered, so the next document's question is told apart from it. + save_prompt_seen: bool, + /// The "save changes?" question asked for the user's attention and the request still stands + /// (see `attend_save_prompt`). + save_prompt_attention: bool, /// A save waiting behind [`Dialog::OverwriteChanged`] (the web build has no files to check). #[cfg(not(target_arch = "wasm32"))] pub pending_overwrite: Option, @@ -651,6 +792,8 @@ pub struct PdfCraftApp { pub props_draft: Option<(DocId, [String; 4])>, /// Document Properties ▸ Initial View (and reading options) being edited. pub view_draft: Option<(DocId, pdfcraft_engine::InitialView)>, + /// Document Properties ▸ Custom being edited. + pub custom_draft: Option<(DocId, CustomDraft)>, /// Files picked asynchronously for combine / insert (web). pub requests: files::Requests, /// Native file pickers in flight (they never block the frame; see `pickers`). @@ -959,7 +1102,8 @@ impl PdfCraftApp { full_screen_tool: None, comment_prefs: Default::default(), theme: ThemeKind::Light, - theme_preference: ThemePreference::Light, + // A fresh install follows the desktop's appearance (#1248); saved settings override. + theme_preference: ThemePreference::System, reading_colors: Default::default(), #[cfg(target_os = "linux")] desktop_theme: None, @@ -985,9 +1129,12 @@ impl PdfCraftApp { all_tools_expanded: false, recent: Vec::new(), reopen_last_session: false, + restore_last_view: true, + last_views: Vec::new(), last_session: Default::default(), quit_session: None, reload_changed_files: true, + show_secured_notice: true, always_default_view: false, #[cfg(not(target_arch = "wasm32"))] watch: Default::default(), @@ -1006,6 +1153,7 @@ impl PdfCraftApp { system_title_bar: false, app_frame_shown: None, password_prompt: None, + document_copy: None, unlock_prompt: None, full_screen: false, inbox: Default::default(), @@ -1018,11 +1166,14 @@ impl PdfCraftApp { host_dirty: None, close_request: None, save_prompt_button: Default::default(), + save_prompt_seen: false, + save_prompt_attention: false, #[cfg(not(target_arch = "wasm32"))] pending_overwrite: None, save_override: None, props_draft: None, view_draft: None, + custom_draft: None, requests: Default::default(), #[cfg(not(target_arch = "wasm32"))] pickers: Default::default(), @@ -1146,14 +1297,19 @@ impl PdfCraftApp { } /// Open a document the way it asks to be opened: navigation panel, layout, magnification, - /// page (Document Properties ▸ Initial View). With Always use my default zoom and page - /// layout on, the layout and magnification stay at the defaults the view opened with. + /// page and the place on it (Document Properties ▸ Initial View, `/OpenAction`). With Always + /// use my default zoom and page layout on, the layout and magnification stay at the defaults + /// the view opened with, and the view goes to the opening page's top. fn apply_initial_view(&mut self, index: usize, v: &pdfcraft_engine::InitialView) { use pdfcraft_engine::{InitialLayout as L, Magnification as M, Navigation as N}; let pages = self.session.get(self.views[index].id).map_or(0, |d| d.info.pages.len()); // A right panel the user closed stays closed, whatever the document asks for (#2884). + // A document that asks for full screen gets the panel it names for outside it + // (`/NonFullScreenPageMode`, Table 147), there when the prompt is declined or full + // screen is left. + let navigation = if v.full_screen { v.non_full_screen } else { v.navigation }; if self.active == Some(index) && !self.right_panel_closed { - match v.navigation { + match navigation { N::PageOnly => {} N::Bookmarks => self.right = Some(RightPanel::Bookmarks), N::Pages => self.right = Some(RightPanel::Pages), @@ -1187,8 +1343,19 @@ impl PdfCraftApp { M::FitHeight => view.fit = canvas::Fit::Height, } } - if v.page > 0 && v.page < pages { - view.go_to_page(v.page); + // The opening destination (`/OpenAction`, named destinations resolved): its page, and + // where on it and how large, as a bookmark or link to it would (ISO 32000-2 Table 149). + // With the default view kept, only the page. + let info = self.session.get(view.id).map(|d| &d.info); + match info.and_then(|i| i.open_dest.filter(|(page, _)| *page < pages).map(|d| (i, d))) { + Some((info, (page, dest))) if !self.always_default_view => view.go_to_dest(page, dest, info), + Some((_, (page, _))) => { + if page > 0 { + view.go_to_page(page); + } + } + None if v.page > 0 && v.page < pages => view.go_to_page(v.page), + None => {} } } @@ -1388,23 +1555,27 @@ impl PdfCraftApp { // Read mode opens with the panes closed; the rail still opens them (#932). A right panel // the user closed stays closed (#1590). let opening_mode = self.mode_override.unwrap_or(self.default_mode); + let initial = doc.initial_view(); if activate && self.right.is_none() && !self.right_panel_closed && opening_mode != Mode::Read { self.right = if !doc.info.annotations.is_empty() { Some(RightPanel::Comments) - } else if !doc.info.outline.is_empty() { + // A full-screen document names its panel for outside full screen, and with + // `/NonFullScreenPageMode /UseNone` (the default) no outline is visible (Table 147). + } else if !doc.info.outline.is_empty() && !initial.full_screen { Some(RightPanel::Bookmarks) } else { None }; } - let initial = doc.initial_view(); let view = DocView::new(id, &doc.info, self.view_defaults); self.views.push(view); if activate { self.active = Some(self.views.len() - 1); } self.apply_initial_view(self.views.len() - 1, &initial); - // After the document's own initial view, so leaving full screen comes back to it. + // Where the reader left off wins over the file's own initial view (#1767). + self.apply_last_view(self.views.len() - 1); + // After both, so full screen shows one fitted page and leaving it comes back to them. if self.full_screen && let Some(view) = self.views.last_mut() { @@ -1589,6 +1760,39 @@ impl PdfCraftApp { self.dialog_epoch } + /// Edit ▸ Attach a file…: choose a file, then attach it to the active document and show + /// the Attachments panel with it. + pub fn attach_file_dialog(&mut self) { + if self.active_ids().is_none() { + return; + } + #[cfg(not(target_arch = "wasm32"))] + { + let dialog = rfd::AsyncFileDialog::new().set_title(tl!("Attach a file").to_string()); + let target = self.active_ids().map(|(_, id)| id); + self.ask_one(pickers::Ask::File(dialog), target, |app, path| app.attach_file(&path)); + } + #[cfg(target_arch = "wasm32")] + self.notify_tr("Attaching files is not available on the web yet"); + } + + /// Attach the file at `path` to the active document (document level: the Attachments + /// panel, not a paper-clip comment on a page). + pub fn attach_file(&mut self, path: &std::path::Path) { + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let bytes = match std::fs::read(path) { + Ok(b) => b, + Err(e) => { + self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &e.to_string())]); + return; + } + }; + let mime = pdfcraft_engine::mime_for_name(&name).map(str::to_string); + if self.apply_edit(pdfcraft_engine::Edit::AddAttachment { name, bytes, description: None, mime }) { + self.choose_right_panel(Some(RightPanel::Attachments)); + } + } + /// Save an attachment to disk, or open a PDF attachment in a new tab. pub fn attachment_action(&mut self, doc: DocId, index: usize, open: bool) { let Some(d) = self.session.get(doc) else { return }; @@ -2007,6 +2211,7 @@ impl PdfCraftApp { if index >= self.views.len() { return; } + self.remember_view(index); let id = self.views.remove(index).id; #[cfg(not(target_arch = "wasm32"))] if let Some(path) = self.session.get(id).and_then(|d| d.path.clone()) { @@ -2278,11 +2483,11 @@ impl PdfCraftApp { } /// Changes the workspace and the tool that goes with it. The mode bar (through - /// [`Self::select_mode`]) and the Read mode command both come here, so they leave Edit alike. - /// Edit turns on Edit text & images at once, as Acrobat does: every paragraph and image on the - /// page gets its box and is editable with one click. Leaving Edit puts the editing tools down - /// again and applies a paragraph still open in the editor. - fn enter_mode(&mut self, mode: Mode) { + /// [`Self::select_mode`]), the Read mode command and the tool panels' back arrows all come + /// here, so they leave Edit alike. Edit turns on Edit text & images at once, as Acrobat does: + /// every paragraph and image on the page gets its box and is editable with one click. Leaving + /// 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; if mode == Mode::Edit { self.quick_tool = QuickTool::EditText; @@ -2370,6 +2575,8 @@ impl PdfCraftApp { "docking_right": self.right, "recent": self.recent, "reopen_last_session": self.reopen_last_session, + "restore_last_view": self.restore_last_view, + "last_views": self.last_views_to_save(), // Kept only while the preference is on. "last_session": self.reopen_last_session.then(|| self.session_to_save()), "reload_changed_files": self.reload_changed_files, @@ -2430,6 +2637,8 @@ impl PdfCraftApp { v["redact_prefs"] = self.redact_defaults.to_json(); // Set outside json! above, which is at serde_json's macro recursion limit. v["hide_empty_highlight_hover"] = serde_json::Value::Bool(self.comment_prefs.hide_empty_highlight_hover); + v["comment_tool_pinned"] = serde_json::Value::Bool(self.comment_prefs.pinned); + v["show_secured_notice"] = serde_json::Value::Bool(self.show_secured_notice); v["organize_zoom"] = serde_json::json!(self.organize_zoom); // Canonical names survive changes to the order of PAPERS and do not depend on UI language. v["print_paper"] = serde_json::json!(pdfcraft_engine::print::PAPERS.get(self.print_draft.paper).map(|(name, _)| *name)); @@ -2468,6 +2677,10 @@ impl PdfCraftApp { if let Some(on) = v["reopen_last_session"].as_bool() { self.reopen_last_session = on; } + if let Some(on) = v["restore_last_view"].as_bool() { + self.restore_last_view = on; + } + self.restore_last_views(&v["last_views"]); self.last_session = last_session::LastSession::from_json(&v["last_session"]); if let Some(on) = v["system_title_bar"].as_bool() { self.system_title_bar = on; @@ -2475,6 +2688,9 @@ impl PdfCraftApp { if let Some(on) = v["reload_changed_files"].as_bool() { self.reload_changed_files = on; } + if let Some(on) = v["show_secured_notice"].as_bool() { + self.show_secured_notice = on; + } self.pinned.restore(&v["pinned_folders"]); if let Ok(preference) = serde_json::from_value::(v["theme"].clone()) { self.set_theme_preference(preference); @@ -2554,6 +2770,9 @@ impl PdfCraftApp { if let Some(hide) = v["hide_empty_highlight_hover"].as_bool() { self.comment_prefs.hide_empty_highlight_hover = hide; } + if let Some(pinned) = v["comment_tool_pinned"].as_bool() { + self.comment_prefs.pinned = pinned; + } // Per-tool comment styles (#340): each field goes through its clamping setter, so a // malformed or hostile entry is clamped or ignored rather than trusted. if let Some(styles) = v["comment_styles"].as_array() { @@ -2683,6 +2902,7 @@ impl PdfCraftApp { "properties" => Some(Dialog::Properties(PropsTab::Description)), "security" => Some(Dialog::Properties(PropsTab::Security)), "fonts" => Some(Dialog::Properties(PropsTab::Fonts)), + "custom" => Some(Dialog::Properties(PropsTab::Custom)), "advanced" => Some(Dialog::Properties(PropsTab::Advanced)), "shortcuts" => Some(Dialog::Shortcuts), "split" => Some(Dialog::Split), @@ -2895,6 +3115,13 @@ impl PdfCraftApp { }; } ("author", _) => self.comment_prefs.author = value.to_string(), + ("comment-tool-pinned", _) => { + self.comment_prefs.pinned = match value { + "true" => true, + "false" => false, + _ => return Err("comment-tool-pinned must be true or false".into()), + }; + } ("hide-empty-highlight-hover", _) => { self.comment_prefs.hide_empty_highlight_hover = match value { "true" => true, @@ -2903,6 +3130,14 @@ impl PdfCraftApp { }; } ("ui-scale", _) => self.ui_scale = ui_scale::parse(value)?, + // `--secured-notice false`: no notice above the pages that a document is secured. + ("secured-notice", _) => { + self.show_secured_notice = match value { + "true" => true, + "false" => false, + _ => return Err("secured-notice must be true or false".into()), + }; + } ("system-title-bar", _) => { self.system_title_bar = match value { "true" => true, @@ -3027,6 +3262,9 @@ impl PdfCraftApp { && let Some(doc) = self.session.get(self.views[i].id).filter(|d| d.allows_modification()) { object_ui::nudge_keys(ctx, &mut self.views[i], doc); + object_ui::delete_keys(ctx, &mut self.views[i]); + } else if let Some(doc) = self.session.get(self.views[i].id).filter(|d| d.allows_annotation()) { + comments::nudge_keys(ctx, &mut self.views[i], &doc.info); } // A selected field moves with the arrows too (Prepare a form, #2608), before the view // turns the page with them. @@ -3076,10 +3314,23 @@ impl PdfCraftApp { } /// Shared permission gate for copying selected page text from menus and keyboard events. + /// After Select All of the document (#2639), pages whose text wasn't read for display are read + /// off the UI thread ([`DocumentCopy`]), and the clipboard gets the text when they're done. pub(crate) fn copy_selected_text(&mut self, i: usize, ctx: &egui::Context) -> bool { - let Some((id, text)) = self.views.get(i).and_then(|v| Some((v.id, v.selected_text()?))).filter(|(_, text)| !text.is_empty()) else { - return false; - }; + // A newer copy wins: a whole-document copy still reading must not replace it later. + self.document_copy = None; + let Some(v) = self.views.get(i) else { return false }; + let id = v.id; + if v.all_text && !v.pages_without_text().is_empty() { + // Checked before any page is read: a document that refuses copying reads nothing. + if !self.may_copy(id) { + return false; + } + let Some(job) = self.views.get(i).and_then(|v| Some(DocumentCopyJob::of(v, self.session.get(id)?))) else { return false }; + self.document_copy = Some(DocumentCopy::start(id, job, ctx)); + return true; + } + let Some(text) = v.selected_text().filter(|text| !text.is_empty()) else { return false }; if !self.may_copy(id) { return false; } @@ -3087,6 +3338,28 @@ impl PdfCraftApp { true } + /// The clipboard text of a whole-document copy, once its pages are read; dropped with the + /// document it was copied from, and by any later copy (a text field's, a comment's), which + /// it must not replace. Run at the end of the frame, so it sees this frame's copies. + fn finish_document_copy(&mut self, ctx: &egui::Context) { + let Some(c) = &self.document_copy else { return }; + let copied = ctx.output(|o| o.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(_) | egui::OutputCommand::CopyImage(_)))); + if copied || !self.views.iter().any(|v| v.id == c.doc) { + self.document_copy = None; + return; + } + match c.rx.try_recv() { + Ok(text) => { + self.document_copy = None; + if !text.is_empty() { + ctx.copy_text(text); + } + } + Err(std::sync::mpsc::TryRecvError::Empty) => ctx.request_repaint_after(std::time::Duration::from_millis(50)), + Err(std::sync::mpsc::TryRecvError::Disconnected) => self.document_copy = None, + } + } + /// The quick tools' keys, as in Acrobat: V selects, H pans, and holding Space pans until it /// is released. Plain letters, so not while a text field, a form field, a dialog or the /// palette has the keyboard. @@ -3125,6 +3398,8 @@ impl eframe::App for PdfCraftApp { /// would keep the place the pointer entered at: tell it where the pointer really is, so the /// page grid can show (and use) the gap under it. fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + // The button's release a window move or resize kept from egui. + window_controls::release_after_desktop_grab(ctx, raw); // Before egui reads this frame's wheel input. self.scroll_speed.apply(ctx, raw); if raw.hovered_files.is_empty() && raw.dropped_files.is_empty() { @@ -3146,6 +3421,8 @@ impl eframe::App for PdfCraftApp { storage.set_string("pdfcraft", self.persist()); } + /// Each frame's work before drawing: opens, operating-system and control-channel requests, + /// the quit guard, shortcuts, and the background jobs' results. fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { self.ctx = Some(ctx.clone()); #[cfg(not(target_arch = "wasm32"))] @@ -3306,12 +3583,15 @@ impl eframe::App for PdfCraftApp { } } self.poll_export(); + self.auto_ocr_scanned_page(); self.poll_ocr(); self.poll_optimize(); self.poll_action(); self.process_file_requests(); #[cfg(not(target_arch = "wasm32"))] self.process_picked(); + // Last, after everything that can open or answer the "save changes?" question. + self.attend_save_prompt(ctx); } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { @@ -3399,6 +3679,7 @@ impl eframe::App for PdfCraftApp { impl PdfCraftApp { fn finish_render_frame(&mut self, ctx: &egui::Context) { + self.finish_document_copy(ctx); // The Combine grid's thumbnails, whichever tab shows (renders under way still finish). self.combine_thumbs_frame(ctx); // Retire hidden documents before admitting the visible document's textures, so the diff --git a/crates/ui-egui/src/marks_ui.rs b/crates/ui-egui/src/marks_ui.rs index 4555b8db6d..fbca7bb08e 100644 --- a/crates/ui-egui/src/marks_ui.rs +++ b/crates/ui-egui/src/marks_ui.rs @@ -1,12 +1,13 @@ //! Add Header and Footer, Add Watermark and Add Background dialogs (Acrobat: Edit a PDF ▸ …; -//! dialog snapshots d13, d14, d12d). Each has the shared Page Range Options and a schematic -//! preview of the current page. "Update" opens the same dialog in replace mode. +//! dialog snapshots d13, d14, d12d). Each has the shared Page Range Options and a preview of a +//! page the range selects, with the marks laid out on it. "Update" opens the same dialog in +//! replace mode. use egui::{Align, Color32, CornerRadius, Layout, Rect, Stroke, pos2, vec2}; use pdfcraft_engine::{Background, Edit, FontFamily, HeaderFooter, MarkKind, Watermark}; use crate::theme::{self, Tokens}; -use crate::{PdfCraftApp, widgets}; +use crate::{PdfCraftApp, icons, widgets}; /// How far a watermark may be moved from the page centre, in points. const MAX_OFFSET: f64 = 20_000.0; @@ -89,6 +90,9 @@ pub struct MarksDraft { pub use_file: bool, pub file: Option<(String, std::sync::Arc>)>, pub file_page: usize, + /// The page (0-based) the preview was stepped to; `None` previews the current page. Reset + /// each time the dialog opens. + pub preview: Option, } impl Default for MarksDraft { @@ -106,6 +110,7 @@ impl Default for MarksDraft { use_file: false, file: None, file_page: 1, + preview: None, } } } @@ -124,14 +129,30 @@ fn color_button(ui: &mut egui::Ui, c: &mut [f64; 3]) { } } -/// A schematic of the current page with the marks laid out (no rendering needed). -fn preview(ui: &mut egui::Ui, t: &Tokens, size: (f64, f64), draw: impl FnOnce(&egui::Painter, Rect, f32)) { +/// The page (displayed size `size`) with the marks laid out on it. `picture` is its thumbnail, +/// rendered off the UI thread like the pages panel's; until it arrives the page is a blank sheet. +/// `tint` multiplies the page, which is how a background behind its content looks. +fn preview( + ui: &mut egui::Ui, + t: &Tokens, + size: (f64, f64), + picture: Option, + tint: Color32, + draw: impl FnOnce(&egui::Painter, Rect, f32), +) { let (w, h) = (size.0.max(1.0), size.1.max(1.0)); let s = (190.0 / w.max(h)) as f32; let (r, _) = ui.allocate_exact_size(vec2(210.0, 210.0), egui::Sense::hover()); let page = Rect::from_center_size(r.center(), vec2(w as f32 * s, h as f32 * s)); let painter = ui.painter_at(r); - painter.rect_filled(page, CornerRadius::ZERO, Color32::WHITE); + match picture { + Some(tex) => { + painter.image(tex, page, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), tint); + } + None => { + painter.rect_filled(page, CornerRadius::ZERO, tint); + } + } painter.rect_stroke(page, CornerRadius::ZERO, Stroke::new(1.0, t.border), egui::StrokeKind::Outside); draw(&painter, page, s); } @@ -141,13 +162,44 @@ fn rgb32(c: [f64; 3]) -> Color32 { Color32::from_rgb(b(c[0]), b(c[1]), b(c[2])) } +/// The tint of a page over a background of `color` at `opacity`: the colour shows where the page +/// is white and the content stays on top. A non-finite opacity is opaque, as the engine has it. +fn behind(color: [f64; 3], opacity: f64) -> Color32 { + let a = if opacity.is_finite() { opacity.clamp(0.0, 1.0) } else { 1.0 }; + rgb32(color.map(|c| 1.0 - a * (1.0 - c))) +} + +/// The page (0-based) the preview shows: the current one when the range selects it, otherwise the +/// first page the range selects (the current one again when it selects none). +fn preview_page(range: &PageRange, current: usize, count: usize) -> usize { + let pages = range.pages(count); + if pages.contains(¤t) { current } else { pages.first().copied().unwrap_or(current) } +} + +/// [`preview_page`] for the page stepped to (`stepped`), or the current page if none. A page +/// stepped to that the range leaves out is replaced by the page shown, so widening the range again +/// keeps that page rather than jumping back. +fn shown_preview(range: &PageRange, stepped: &mut Option, current: usize, count: usize) -> usize { + let shown = preview_page(range, stepped.unwrap_or(current), count); + if stepped.is_some() { + *stepped = Some(shown); + } + shown +} + +/// The page the preview steps to from `shown`: the range's next page after it (`forward`) or the +/// last one before it, or `None` at either end. +fn step_preview(range: &PageRange, shown: usize, count: usize, forward: bool) -> Option { + let pages = range.pages(count); + if forward { pages.into_iter().find(|&p| p > shown) } else { pages.into_iter().rev().find(|&p| p < shown) } +} + /// The dialog for `kind`; returns (apply, cancel). pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: MarkKind) -> (bool, bool) { let Some((i, id)) = app.active_ids() else { return (false, true) }; let current = app.views[i].current; let Some(doc) = app.session.get(id) else { return (false, true) }; let count = doc.info.pages.len(); - let page = doc.info.pages.get(current).map(|p| (p.width as f64, p.height as f64)).unwrap_or((612.0, 792.0)); let units = app.units; let d = &mut app.marks_draft; let mut browse = false; @@ -313,13 +365,22 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M ui.label(egui::RichText::new(tl!("Page Range Options")).font(theme::semibold(12.5))); d.range.ui(ui, count); ui.add_space(8.0); - // Preview of the current page. + // The preview: the current page (or the page stepped to), or the first one the range selects + // if it leaves that out, drawn from its thumbnail at its displayed size (after /Rotate), the + // frame the engine lays the marks out in. + let target = shown_preview(&d.range, &mut d.preview, current, count); + let page = doc.info.pages.get(target).map(|p| (p.width as f64, p.height as f64)).unwrap_or((612.0, 792.0)); + let picture = app.views.get_mut(i).and_then(|view| { + view.need_thumbnail(target, true); + view.thumb_id(target) + }); + let tint = if kind == MarkKind::Background && !d.use_file { behind(d.bg.color, d.bg.opacity) } else { Color32::WHITE }; let hf = d.hf.clone(); let size_h = page.1.max(1.0); - let (wm, bg, fit) = (d.wm.clone(), d.bg.clone(), d.fit); + let (wm, fit) = (d.wm.clone(), d.fit); let file_name = d.use_file.then(|| d.file.as_ref().map(|f| f.0.clone())).flatten(); ui.horizontal(|ui| { - preview(ui, t, page, |p, r, s| match kind { + preview(ui, t, page, picture, tint, |p, r, s| match kind { MarkKind::HeaderFooter => { if hf.shrink { // The band the page content shrinks into, centred across. @@ -382,19 +443,28 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M Color32::GRAY, ); } - MarkKind::Background => { - p.rect_filled(r, CornerRadius::ZERO, rgb32(bg.color).gamma_multiply(bg.opacity as f32)); - } + // A colour is the page's tint. + MarkKind::Background => {} }); ui.vertical(|ui| { - ui.label( - egui::RichText::new(crate::i18n::fmt( - tl!("Preview: page {p} of {n}"), - &[("p", &(current + 1).to_string()), ("n", &count.to_string())], - )) - .small() - .color(t.text_faint), - ); + ui.horizontal(|ui| { + // Steps through the pages the range selects. + let (back, next) = (step_preview(&d.range, target, count, false), step_preview(&d.range, target, count, true)); + if ui.add_enabled_ui(back.is_some(), |ui| icons::button(ui, "chevron-left", 22.0, false, tl!("Previous page"))).inner.clicked() { + d.preview = back.or(d.preview); + } + ui.label( + egui::RichText::new(crate::i18n::fmt( + tl!("Preview: page {p} of {n}"), + &[("p", &(target + 1).to_string()), ("n", &count.to_string())], + )) + .small() + .color(t.text_faint), + ); + if ui.add_enabled_ui(next.is_some(), |ui| icons::button(ui, "chevron-right", 22.0, false, tl!("Next page"))).inner.clicked() { + d.preview = next.or(d.preview); + } + }); let n = d.range.pages(count).len(); let applies = if n == 1 { tl!("Applies to 1 page").to_string() } else { crate::i18n::fmt(tl!("Applies to {n} pages"), &[("n", &n.to_string())]) }; @@ -546,6 +616,61 @@ mod tests { assert_eq!(r.pages(38), [0]); } + #[test] + fn the_preview_shows_a_page_the_range_selects() { + let all = PageRange::default(); + assert_eq!(preview_page(&all, 2, 5), 2, "the current page when the range selects it"); + let even = PageRange { all: true, subset: Subset::Even, ..PageRange::default() }; + assert_eq!(preview_page(&even, 0, 5), 1, "otherwise the first page the range selects"); + let later = PageRange { all: false, from: 3, to: 4, subset: Subset::All }; + assert_eq!(preview_page(&later, 0, 5), 2); + let none = PageRange { all: false, from: 4, to: 2, subset: Subset::All }; + assert_eq!(preview_page(&none, 1, 5), 1, "the current page when the range selects none"); + assert_eq!(preview_page(&all, 0, 0), 0, "no pages at all"); + } + + #[test] + fn the_preview_steps_through_the_pages_the_range_selects() { + let odd = PageRange { all: true, subset: Subset::Odd, ..PageRange::default() }; + // Pages 1, 3 and 5 (0-based 0, 2, 4) of 5. + assert_eq!(step_preview(&odd, 0, 5, true), Some(2)); + assert_eq!(step_preview(&odd, 2, 5, true), Some(4)); + assert_eq!(step_preview(&odd, 4, 5, true), None, "the last page has no next"); + assert_eq!(step_preview(&odd, 4, 5, false), Some(2)); + assert_eq!(step_preview(&odd, 0, 5, false), None, "the first page has no previous"); + // From a page the range leaves out, to the nearest one it selects on that side. + assert_eq!(step_preview(&odd, 1, 5, true), Some(2)); + assert_eq!(step_preview(&odd, 1, 5, false), Some(0)); + assert_eq!(step_preview(&odd, 0, 0, true), None, "no pages at all"); + // A page stepped to is previewed while the range selects it, and the range's first page + // once a new range leaves it out. + assert_eq!(preview_page(&odd, 2, 5), 2); + let later = PageRange { all: false, from: 4, to: 5, subset: Subset::All }; + assert_eq!(preview_page(&later, 2, 5), 3); + // Narrowing the range past the page stepped to keeps the page shown instead, also once + // the range is widened again. + let mut stepped = Some(4); + let first_two = PageRange { all: false, from: 1, to: 2, subset: Subset::All }; + assert_eq!(shown_preview(&first_two, &mut stepped, 1, 5), 0); + assert_eq!(stepped, Some(0)); + assert_eq!(shown_preview(&PageRange::default(), &mut stepped, 1, 5), 0); + // A page stepped to inside the range stays; with none stepped to, the current page shows. + let mut stepped = Some(2); + assert_eq!(shown_preview(&odd, &mut stepped, 0, 5), 2); + assert_eq!(stepped, Some(2)); + let mut stepped = None; + assert_eq!(shown_preview(&odd, &mut stepped, 4, 5), 4); + assert_eq!(stepped, None); + } + + #[test] + fn a_background_tints_the_page_it_is_behind() { + assert_eq!(behind([1.0, 0.0, 0.0], 1.0), Color32::from_rgb(255, 0, 0)); + assert_eq!(behind([1.0, 0.0, 0.0], 0.0), Color32::WHITE); + assert_eq!(behind([0.0, 0.0, 0.0], 0.5), Color32::from_rgb(128, 128, 128)); + assert_eq!(behind([0.2, 0.4, 0.6], f64::NAN), behind([0.2, 0.4, 0.6], 1.0), "opaque, as the engine has it"); + } + #[test] fn the_watermark_edit_carries_the_offset() { let mut d = MarksDraft::default(); diff --git a/crates/ui-egui/src/measure_ui.rs b/crates/ui-egui/src/measure_ui.rs index f91e4bf3f0..7c675c694a 100644 --- a/crates/ui-egui/src/measure_ui.rs +++ b/crates/ui-egui/src/measure_ui.rs @@ -55,6 +55,10 @@ pub struct MeasureView { pub unit: String, /// The unit picked for the page side of the scale; `None` follows `unit`. pub page_unit: Option, + /// The "1:N" field of the drawing scale, as typed. + pub ratio_text: String, + /// The "1:N" field was edited and not yet applied. + ratio_dirty: bool, pub precision: u8, pub name: String, pub whole_page: bool, @@ -83,6 +87,8 @@ impl Default for MeasureView { real_distance: 1.0, unit: "in".into(), page_unit: None, + ratio_text: String::new(), + ratio_dirty: false, precision: 2, name: "Drawing scale".into(), whole_page: true, @@ -99,6 +105,59 @@ impl Default for MeasureView { pub(crate) fn page_unit_of(picked: Option, unit: &str, display: crate::units::Unit) -> PageUnit { picked.or_else(|| PageUnit::from_label(unit)).or_else(|| PageUnit::from_label(display.label())).unwrap_or(PageUnit::Pt) } +/// Drawing scales an architect's rule offers, as the N of 1:N. +pub(crate) const RATIOS: [f64; 10] = [1.0, 5.0, 10.0, 20.0, 25.0, 50.0, 100.0, 200.0, 500.0, 1000.0]; +/// "1:200", "1/200", "1 : 200" or a bare "200" (a comma works as the decimal mark), as the N of 1:N. +/// `None` for anything else, for zero, negative or absurd values. +pub(crate) fn parse_ratio(text: &str) -> Option { + let text = text.trim().replace(',', "."); + let n = match text.split_once([':', '/']) { + Some((one, n)) => n.trim().parse::().ok()? / one.trim().parse::().ok()?, + None => text.parse::().ok()?, + }; + RATIO_RANGE.contains(&n).then_some(n) +} +/// The N of 1:N this panel accepts. The lower bound keeps the real distance (N / 1000 m) within the +/// minimum of its field. +const RATIO_RANGE: std::ops::RangeInclusive = 1e-3..=1e9; +/// "1:200", or "1:2.5" for a ratio that isn't a whole number: the number as it is, so the field shows exactly what applies. +pub(crate) fn format_ratio(n: f64) -> String { + format!("1:{n}") +} +/// The N of 1:N the scale fields stand for, when they are a plain drawing scale (real distance in m or mm). +fn ratio_of_fields(state: &MeasureView, display: crate::units::Unit) -> Option { + let real_mm = match state.unit.trim() { + "m" => state.real_distance * 1000.0, + "mm" => state.real_distance, + _ => return None, + }; + let paper_mm = state.drawing_points * page_unit_of(state.page_unit, &state.unit, display).points() * 25.4 / 72.0; + let n = real_mm / paper_mm; + // Float noise from the unit conversions is rounded away; anything that isn't a clean ratio isn't one. + let clean = (n * 1e6).round() / 1e6; + (RATIO_RANGE.contains(&clean) && (clean - n).abs() <= 1e-9 * n).then_some(clean) +} +/// Make the "1:N" field and the common scale picked in its list say what the scale fields now hold: the ratio, or nothing. +fn sync_ratio_text(state: &mut MeasureView, display: crate::units::Unit) { + state.ratio_text = ratio_of_fields(state, display).map(format_ratio).unwrap_or_default(); + state.ratio_dirty = false; +} +/// What the scale fields hold, to notice a hand edit. +fn scale_fields(state: &MeasureView) -> (f64, f64, String, Option) { + (state.drawing_points, state.real_distance, state.unit.clone(), state.page_unit) +} +/// Fill the scale fields for 1:N: 1 mm on the page is N mm, shown in metres. Returns `false`, changing nothing, when `n` is not a usable ratio. +pub(crate) fn set_ratio(state: &mut MeasureView, n: f64) -> bool { + if !RATIO_RANGE.contains(&n) { + return false; + } + state.unit = "m".into(); + state.page_unit = Some(PageUnit::Mm); + state.drawing_points = 1.0; + state.real_distance = n / 1000.0; + state.ratio_text = format_ratio(n); + true +} impl MeasureView { pub fn cancel(&mut self) { self.points.clear(); @@ -149,7 +208,7 @@ pub(crate) fn page_input( if state.paths.as_ref().is_none_or(|(p, g, _)| *p != page || *g != doc.edit_generation()) { state.paths = Some((page, doc.edit_generation(), doc.measurement_paths(page))); } - let pixels_per_unit = (xf.rect.width() / xf.pw).max(1e-6); + let pixels_per_unit = xf.zoom().max(1e-6); if let Some((_, _, Err(e))) = &state.paths { state.error = Some(e.clone()); } @@ -330,6 +389,7 @@ fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) { state.page_unit = None; state.precision = scale.precision; } + sync_ratio_text(state, units); } ui.label(tl!("Click vertices. Enter or double-click finishes a perimeter or area. Click the first point to close an area.")); ui.label(tl!("Shift constrains to 45 degrees. Backspace removes a vertex. Escape cancels.")); @@ -363,7 +423,28 @@ fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) { ui.add(egui::Slider::new(&mut state.sensitivity, 1.0..=20.0).text(tl!("Sensitivity"))); } ui.separator(); - ui.label(tl!("Drawing scale")); + // "1:N" fills the fields below and applies at once, as does picking a common scale. + let mut chosen = None; + let fields = scale_fields(state); + ui.horizontal(|ui| { + ui.label(tl!("Drawing scale")).on_hover_text(tl!("Applying a scale switches units to m / mm.")); + let typed = ui.add(crate::widgets::line(&mut state.ratio_text).hint_text("1:100").desired_width(56.0).char_limit(16)); + typed.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, true, tl!("Scale"))); + state.ratio_dirty |= typed.changed(); + if typed.lost_focus() && std::mem::take(&mut state.ratio_dirty) { + match parse_ratio(&state.ratio_text) { + Some(n) => chosen = Some(n), + None => state.error = Some(tl!("Enter a scale such as 1:50.").into()), + } + } + egui::ComboBox::from_id_salt("measure-common-scales").selected_text(tl!("Common scales")).width(20.0).show_ui(ui, |ui| { + for n in RATIOS { + if ui.selectable_label(state.ratio_text == format_ratio(n), format_ratio(n)).clicked() { + chosen = Some(n); + } + } + }); + }); ui.horizontal(|ui| { ui.add(egui::DragValue::new(&mut state.drawing_points).range(0.000001..=1e9)); let page_unit = page_unit_of(state.page_unit, &state.unit, units); @@ -382,6 +463,10 @@ fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) { ui.add(crate::widgets::line(&mut state.unit).desired_width(64.0).char_limit(12)); ui.add(egui::DragValue::new(&mut state.precision).range(0..=6).prefix(format!("{} ", tl!("Decimals")))); }); + // A hand edit of the fields leaves the 1:N field showing the old ratio: say the new one. + if scale_fields(state) != fields { + sync_ratio_text(state, units); + } if ui.button(tl!("Calibrate from two points")).clicked() { tool = Some(Tool::Calibrate); } @@ -427,7 +512,16 @@ fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) { } } let export = ui.button(tl!("Export measurements as CSV")).clicked(); - if apply && let Some(info) = info { + if let Some(n) = chosen + && set_ratio(state, n) + { + state.error = None; + } + // Apply scale, or a 1:N typed or picked: the fields are applied to the document at once. + if (apply || chosen.is_some()) + && doc.allows_annotation() + && let Some(info) = info + { let result = doc.measurement_to_user(page, [0.0, 0.0]).and_then(|a| doc.measurement_to_user(page, [1.0, 0.0]).map(|b| (a, b))).and_then(|(a, b)| { let page_unit = page_unit_of(state.page_unit, &state.unit, units); @@ -513,3 +607,125 @@ fn export_csv(app: &mut PdfCraftApp) { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// What Apply scale builds from the panel's fields, then a distance read with it. + fn measured(state: &MeasureView, from: Point, to: Point) -> f64 { + let page_unit = page_unit_of(state.page_unit, &state.unit, crate::units::Unit::Points); + let scale = Scale::ratio([0.0, 0.0], [1.0, 0.0], state.drawing_points, page_unit, state.real_distance, &state.unit, 4).expect("scale"); + measure::reading(Kind::Distance, &[from, to], &scale).expect("reading").value + } + + #[test] + fn typed_ratios_are_read_leniently() { + assert_eq!(parse_ratio("1:200"), Some(200.0)); + assert_eq!(parse_ratio(" 1 : 50 "), Some(50.0)); + assert_eq!(parse_ratio("1/20"), Some(20.0)); + assert_eq!(parse_ratio("250"), Some(250.0)); + assert_eq!(parse_ratio("1:2,5"), Some(2.5)); + assert_eq!(parse_ratio("2:1"), Some(0.5)); + assert_eq!(parse_ratio("1:0.001"), Some(1e-3)); + } + + #[test] + fn invalid_ratios_are_refused() { + for bad in [ + "", " ", ":", "1:", ":50", "1:0", "0", "-5", "1:-5", "0:5", "abc", "1:abc", "1:50:2", "1:inf", "NaN", "1e12", "1:1e12", "1:0.0001", + "1:1e-6", + ] { + assert_eq!(parse_ratio(bad), None, "{bad:?}"); + } + } + + #[test] + fn ratios_are_written_as_people_say_them() { + assert_eq!(format_ratio(50.0), "1:50"); + assert_eq!(format_ratio(2.5), "1:2.5"); + assert_eq!(format_ratio(0.5), "1:0.5"); + // Nothing is rounded away: the field reads the ratio that applies. + assert_eq!(format_ratio(1.234), "1:1.234"); + assert_eq!(format_ratio(0.001), "1:0.001"); + assert_eq!(parse_ratio(&format_ratio(333.333333)), Some(333.333333)); + for n in RATIOS { + assert_eq!(parse_ratio(&format_ratio(n)), Some(n)); + } + } + + #[test] + fn every_offered_ratio_reads_paper_length_times_n_in_metres_whatever_the_unit_was() { + for unit in ["mm", "cm", "m", "km", "in", "ft"] { + for n in RATIOS { + let mut state = MeasureView { unit: unit.into(), ..Default::default() }; + assert!(set_ratio(&mut state, n)); + assert_eq!(state.unit, "m"); + // 72 pt is one inch of paper: 25.4 mm x N, shown in metres. + let v = measured(&state, [0.0, 0.0], [0.0, 72.0]); + let expected = 25.4 * n / 1000.0; + assert!((v - expected).abs() < 1e-9 * expected.max(1.0), "1:{n} in {unit} gave {v}, wanted {expected}"); + } + } + } + + #[test] + fn any_starting_unit_becomes_metres() { + let mut state = MeasureView { unit: "yards?".into(), ..Default::default() }; + assert!(set_ratio(&mut state, 50.0)); + assert_eq!(state.unit, "m"); + // 1 mm of paper at 1:50 is 50 mm. + let v = measured(&state, [0.0, 0.0], [72.0 / 25.4, 0.0]); + assert!((v - 0.05).abs() < 1e-9, "{v}"); + } + + #[test] + fn the_smallest_ratio_keeps_the_real_distance_within_its_field() { + let mut state = MeasureView::default(); + assert!(set_ratio(&mut state, parse_ratio("1:0.001").expect("ratio"))); + // The real-distance field accepts 0.000001 and no less. + assert!(state.real_distance >= 0.000001, "{}", state.real_distance); + assert!(!set_ratio(&mut state, 1e-4)); + } + + #[test] + fn the_ratio_field_follows_the_scale_fields() { + let display = crate::units::Unit::Points; + let mut state = MeasureView::default(); + for n in [50.0, 2.5, 1.234, 0.001, 1e9] { + assert!(set_ratio(&mut state, n)); + sync_ratio_text(&mut state, display); + assert_eq!(state.ratio_text, format_ratio(n), "1:{n}"); + } + // A hand edit that is still a drawing scale reads as its ratio, in another page unit too. + assert!(set_ratio(&mut state, 50.0)); + state.page_unit = Some(PageUnit::Cm); + sync_ratio_text(&mut state, display); + assert_eq!(state.ratio_text, "1:5"); + state.page_unit = Some(PageUnit::Mm); + state.real_distance = 0.1; + sync_ratio_text(&mut state, display); + assert_eq!(state.ratio_text, "1:100"); + // One that isn't (another unit, or no ratio at all) empties it. + state.unit = "ft".into(); + sync_ratio_text(&mut state, display); + assert_eq!(state.ratio_text, ""); + state.unit = "m".into(); + state.real_distance = 0.1 / 3.0; + sync_ratio_text(&mut state, display); + assert_eq!(state.ratio_text, ""); + state.drawing_points = 0.0; + sync_ratio_text(&mut state, display); + assert_eq!(state.ratio_text, ""); + } + + #[test] + fn an_unusable_ratio_changes_nothing() { + let mut state = MeasureView::default(); + let before = (state.drawing_points, state.real_distance, state.unit.clone(), state.page_unit); + for n in [0.0, -1.0, f64::NAN, f64::INFINITY] { + assert!(!set_ratio(&mut state, n)); + } + assert_eq!((state.drawing_points, state.real_distance, state.unit.clone(), state.page_unit), before); + } +} diff --git a/crates/ui-egui/src/object_ui.rs b/crates/ui-egui/src/object_ui.rs index 1b090b3731..b68fe352ae 100644 --- a/crates/ui-egui/src/object_ui.rs +++ b/crates/ui-egui/src/object_ui.rs @@ -384,6 +384,46 @@ pub(crate) fn nudge_keys(ctx: &egui::Context, view: &mut DocView, doc: &Document } } +/// Delete (or Backspace) removes the selected objects, as in Acrobat: a paragraph selected by its +/// frame, or everything an area selection took, goes at once instead of one typed character at a +/// time. +pub(crate) fn delete_keys(ctx: &egui::Context, view: &mut DocView) { + if ctx.egui_wants_keyboard_input() || view.pending_edit.is_some() || view.objects.gesture.is_some() { + return; + } + let Some(page) = view.objects.page.filter(|_| !view.objects.selected.is_empty()) else { return }; + use egui::{Key, Modifiers}; + if !ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Delete) || i.consume_key(Modifiers::NONE, Key::Backspace)) { + return; + } + // The selection stays until the edit applies: `prepare` clears it when the document changes, + // and a refused delete (text used as a clipping path) leaves it selected. + view.pending_edit = delete_edit(page, view.objects.selected.iter().map(|o| o.target).collect()); +} + +/// The edit that deletes `targets` (indexes into one inventory of `page`) as one undo step. +/// Each deletion renumbers only what follows it in its own inventory, so paragraphs and images +/// go from the last to the first, and added content, which the source inventories leave out, +/// after them. +fn delete_edit(page: usize, mut targets: Vec) -> Option { + targets.sort_by_key(|t| (t.kind == ObjectKind::Added, std::cmp::Reverse(t.index))); + targets.dedup(); + let mut edits: Vec = targets + .into_iter() + .map(|t| match t.kind { + ObjectKind::Text => Edit::EditTextBlock { page, block: t.index, text: String::new(), style: pdfcraft_engine::BlockStyle::default() }, + ObjectKind::Image => Edit::EditPageImage { page, index: t.index, change: pdfcraft_engine::ImageEdit::Delete }, + ObjectKind::Added => Edit::DeleteContent { page, index: t.index }, + }) + .collect(); + if edits.len() > 1 { + // "Delete content" is an edit label already, so the history reads the same in every language. + Some(Edit::Batch { label: "Delete content".into(), edits }) + } else { + edits.pop() + } +} + pub(crate) fn paint(painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, selection: &ObjectSelection) { if selection.page != Some(page) { return; @@ -415,3 +455,40 @@ pub(crate) fn move_error(error: &pdfcraft_engine::EditError) -> String { _ => tl!("These objects could not be moved. Select them again and try a smaller move.").into(), } } + +#[cfg(test)] +mod tests { + use super::*; + + fn t(kind: ObjectKind, index: usize) -> ObjectTarget { + ObjectTarget { kind, index } + } + + /// Deleting renumbers what follows in the same inventory, so the batch takes each inventory + /// from its last item to its first, and added content (outside the source inventories) last. + #[test] + fn several_objects_delete_last_first_in_one_step() { + let targets = vec![t(ObjectKind::Added, 0), t(ObjectKind::Text, 1), t(ObjectKind::Image, 0), t(ObjectKind::Text, 3), t(ObjectKind::Added, 2)]; + let Some(Edit::Batch { label, edits }) = delete_edit(0, targets) else { panic!("several objects make one batch") }; + assert_eq!(label, "Delete content"); + let order: Vec<(char, usize)> = edits + .iter() + .map(|e| match e { + Edit::EditTextBlock { block, text, .. } => { + assert!(text.is_empty()); + ('t', *block) + } + Edit::EditPageImage { index, change: pdfcraft_engine::ImageEdit::Delete, .. } => ('i', *index), + Edit::DeleteContent { index, .. } => ('a', *index), + other => panic!("unexpected {}", other.label().wording), + }) + .collect(); + assert_eq!(order, [('t', 3), ('t', 1), ('i', 0), ('a', 2), ('a', 0)]); + } + + #[test] + fn one_object_deletes_with_its_own_edit_and_none_with_nothing() { + assert!(matches!(delete_edit(2, vec![t(ObjectKind::Image, 4)]), Some(Edit::EditPageImage { page: 2, index: 4, .. }))); + assert!(delete_edit(0, Vec::new()).is_none()); + } +} diff --git a/crates/ui-egui/src/ocr_ui.rs b/crates/ui-egui/src/ocr_ui.rs index a10cb0b0aa..977f90f2f9 100644 --- a/crates/ui-egui/src/ocr_ui.rs +++ b/crates/ui-egui/src/ocr_ui.rs @@ -41,7 +41,7 @@ impl Default for OcrDraft { impl OcrDraft { /// The recognition settings these choices make. pub fn settings(&self) -> OcrSettings { - OcrSettings { dpi: self.dpi as f32, language: self.language.clone(), skip_text_pages: !self.text_pages } + OcrSettings { dpi: self.dpi as f32, language: self.language.clone(), skip_text_pages: !self.text_pages, ..Default::default() } } } @@ -62,9 +62,32 @@ pub struct BatchProgress { pub message: Option, } +/// A page Edit should read now (see [`PdfCraftApp::scan_to_read`]). +struct ScanToRead { + view: usize, + doc: DocId, + page: usize, + /// The document's edit generation when it was judged. + generation: u64, +} + pub struct OcrRun { pub doc: DocId, pub progress: Arc>, + /// Set for a run Edit started on a scanned page, not asked for: it shows no progress, says nothing + /// when it found no words (a page with text of its own is skipped, and Edit must not make noise), + /// and its result waits for Read mode and dialogs and is dropped if the document changed. + pub automatic: Option, +} + +/// What an Edit-started run needs to know to refuse a result that no longer fits its document. +#[derive(Clone, Copy, Debug)] +pub struct AutoOcr { + /// The page it reads (0-based), when it was started. + pub page: usize, + /// The document's edit generation when it was started: a result is for those bytes only, and + /// any later change (a page deleted or moved, say) may put a different page at that index. + pub generation: u64, } pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (bool, bool) { @@ -165,16 +188,100 @@ impl PdfCraftApp { OcrPages::Range => (d.from.saturating_sub(1)..d.to).collect(), }; let settings = d.settings(); - let Some(job) = self.session.ocr_job(id, &pages, settings) else { return }; + self.start_ocr_pages(id, &pages, settings, false); + } + + /// Edit on a scanned page: like Acrobat's "Converting scanned page to Editable Text and + /// Images", the page's words are recognized first, so that they open for editing (Edit text & + /// images groups the recognised words into phrases). Runs once per page, for the page in view + /// while Edit is on (its tab, or the Edit text & images or Add text tool). The page must be a + /// picture (an image covering at least half of it) with no text of its own: comments and text + /// boxes over it do not count as text, but real page text does, and such a page (a flyer with a + /// background picture) is left alone, and is judged again only when the document changes. It + /// is one undoable step, "Recognize text", and is not run again for that page after Undo. + pub(crate) fn auto_ocr_scanned_page(&mut self) { + let Some(scan) = self.scan_to_read() else { return }; + if !pdfcraft_engine::ocr::available() { + self.views[scan.view].auto_ocr_skipped.insert(scan.page, scan.generation); + return; + } + self.views[scan.view].auto_ocr_tried.insert(scan.page); + // Only a page with no text of its own is read. Comments and text boxes over the picture do + // not count as its text (a scan with a few comments is still a scan, as in Acrobat), but + // a page with real text and a background picture is left alone. + let mut settings = self.ocr_draft.settings(); + settings.skip_text_pages = true; + settings.native_text_only = true; + self.start_ocr_pages(scan.doc, &[scan.page], settings, true); + } + + /// The page Edit should read now, if there is one: Edit is on outside Read mode with no dialog + /// open, nothing else is being recognized, the document may be changed, the page in view has + /// not been read before (nor been found no scan since the document last changed), and an image + /// covers at least half of it. Needs no OCR models, so the rules can be tested without them; + /// the page's own text is judged by the run itself. + fn scan_to_read(&mut self) -> Option { + // Edit is on when its panel is open (the Edit tab) or Edit text & images or Add text is picked, + // the same test the page view uses for editing content. + let editing = (self.left_open && self.left == crate::LeftPanel::Tool("edit")) + || matches!(self.quick_tool, crate::QuickTool::EditText | crate::QuickTool::AddText); + // Not in Read mode (which leaves the Edit tool picked but is not editing) and not beneath a + // dialog or prompt: nothing may change the document under those. It is tried again once + // they are gone. + if !editing || self.mode == crate::Mode::Read || self.modal_open() || self.ocr_run.is_some() || self.ocr_batch.is_some() { + return None; + } + let (view, doc) = self.active_ids()?; + let page = self.views[view].current; + if self.views[view].auto_ocr_tried.contains(&page) { + return None; + } + let document = self.session.get(doc)?; + if document.read_only_reason.is_some() || !document.allows_modification() { + return None; + } + // A page found not to be a scan is judged again only once the document has changed, not + // every frame (listing its images parses the page). + let generation = document.edit_generation(); + if self.views[view].auto_ocr_skipped.get(&page) == Some(&generation) { + return None; + } + let info = document.info.pages.get(page)?; + let page_area = f64::from(info.width) * f64::from(info.height); + let covered = document.page_images(page).iter().any(|i| { + let [x0, y0, x1, y1] = i.visible_rect.unwrap_or(i.rect); + page_area > 0.0 && ((x1 - x0) * (y1 - y0)).abs() >= 0.5 * page_area + }); + if !covered { + self.views[view].auto_ocr_skipped.insert(page, generation); + return None; + } + Some(ScanToRead { view, doc, page, generation }) + } + + /// Recognize `pages` (0-based; empty = all) of document `id` in the background. + fn start_ocr_pages(&mut self, id: DocId, pages: &[usize], settings: OcrSettings, automatic: bool) { + // What an automatic run's result must still fit when it arrives (see `AutoOcr`). + let auto = if automatic { + pages.first().map(|&page| AutoOcr { page, generation: self.session.get(id).map_or(0, |d| d.edit_generation()) }) + } else { + None + }; + let Some(job) = self.session.ocr_job(id, pages, settings) else { return }; let progress = Arc::new(Mutex::new(OcrProgress { total: job.pages.len(), ..Default::default() })); let p = progress.clone(); + // A failure is written on the worker thread: in the UI's language. + let lang = crate::i18n::current(); let work = move || { - let result = pdfcraft_engine::ocr::engine().map(|ocr| { - job.run(&ocr, |done, total| { - let Ok(mut s) = p.lock() else { return false }; - s.done = done; - s.total = total; - !s.cancel + crate::i18n::set_current(lang); + let result = recognize_guarded(|| { + pdfcraft_engine::ocr::engine().map(|ocr| { + job.run(&ocr, |done, total| { + let Ok(mut s) = p.lock() else { return false }; + s.done = done; + s.total = total; + !s.cancel + }) }) }); if let Ok(mut s) = p.lock() { @@ -189,7 +296,7 @@ impl PdfCraftApp { } #[cfg(target_arch = "wasm32")] work(); - self.ocr_run = Some(OcrRun { doc: id, progress }); + self.ocr_run = Some(OcrRun { doc: id, progress, automatic: auto }); self.poll_ocr(); } @@ -227,44 +334,15 @@ impl PdfCraftApp { let lang = crate::i18n::current(); let work = move || { crate::i18n::set_current(lang); - let (mut ok, mut words, mut failed) = (0, 0, Vec::new()); - match pdfcraft_engine::ocr::engine() { - Err(e) => failed.push(e), - Ok(ocr) => { - for (i, (name, bytes)) in files.into_iter().enumerate() { - if let Ok(mut s) = p.lock() { - s.done = i; - } - let r = pdfcraft_engine::ocr::recognize_file(&name, Arc::new(bytes), None, settings.clone(), &ocr, |_, _| true); - let saved = r.and_then(|r| { - #[cfg(not(target_arch = "wasm32"))] - crate::editing::write_atomically(&dir.join(&name).to_string_lossy(), &r.bytes).map_err(|e| e.to_string())?; - #[cfg(target_arch = "wasm32")] - crate::editing::download(&name, &r.bytes)?; - Ok(r.words()) - }); - match saved { - Ok(n) => { - ok += 1; - words += n; - } - Err(e) => failed.push(format!("{name}: {}", e)), - } - } - } - } - let done = if ok == 1 { - crate::i18n::fmt(tl!("Recognized {w} words in 1 file"), &[("w", &words.to_string())]) - } else { - crate::i18n::fmt(tl!("Recognized {w} words in {n} files"), &[("w", &words.to_string()), ("n", &ok.to_string())]) - }; - let mut msg = done; - if !failed.is_empty() { - msg.push_str(&crate::i18n::fmt(tl!("; failed: {list}"), &[("list", &failed.join("; "))])); - } + // A panic in the recognizer still ends the batch with a summary (#3919). + #[cfg(not(target_arch = "wasm32"))] + let summary = pdfcraft_engine::guard(|| recognize_files(files, &settings, &p, &dir)); + #[cfg(target_arch = "wasm32")] + let summary = pdfcraft_engine::guard(|| recognize_files(files, &settings, &p)); + let summary = summary.unwrap_or_else(|panic| failed(&panic)); if let Ok(mut s) = p.lock() { s.done = s.total; - s.message = Some(msg); + s.message = Some(summary); } }; #[cfg(not(target_arch = "wasm32"))] @@ -278,7 +356,54 @@ impl PdfCraftApp { self.ocr_batch = Some(progress); self.poll_ocr(); } +} +/// Recognize each file and write its searchable copy into `dir`; returns the summary to show. +fn recognize_files( + files: Vec<(String, Vec)>, + settings: &OcrSettings, + p: &Mutex, + #[cfg(not(target_arch = "wasm32"))] dir: &std::path::Path, +) -> String { + let (mut ok, mut words, mut failed) = (0, 0, Vec::new()); + match pdfcraft_engine::ocr::engine() { + Err(e) => failed.push(e), + Ok(ocr) => { + for (i, (name, bytes)) in files.into_iter().enumerate() { + if let Ok(mut s) = p.lock() { + s.done = i; + } + let r = pdfcraft_engine::ocr::recognize_file(&name, Arc::new(bytes), None, settings.clone(), &ocr, |_, _| true); + let saved = r.and_then(|r| { + #[cfg(not(target_arch = "wasm32"))] + crate::editing::write_atomically(&dir.join(&name).to_string_lossy(), &r.bytes).map_err(|e| e.to_string())?; + #[cfg(target_arch = "wasm32")] + crate::editing::download(&name, &r.bytes)?; + Ok(r.words()) + }); + match saved { + Ok(n) => { + ok += 1; + words += n; + } + Err(e) => failed.push(format!("{name}: {}", e)), + } + } + } + } + let done = if ok == 1 { + crate::i18n::fmt(tl!("Recognized {w} words in 1 file"), &[("w", &words.to_string())]) + } else { + crate::i18n::fmt(tl!("Recognized {w} words in {n} files"), &[("w", &words.to_string()), ("n", &ok.to_string())]) + }; + let mut msg = done; + if !failed.is_empty() { + msg.push_str(&crate::i18n::fmt(tl!("; failed: {list}"), &[("list", &failed.join("; "))])); + } + msg +} + +impl PdfCraftApp { /// Stop a running recognition (the pages read so far are kept). pub fn cancel_ocr(&mut self) { if let Some(r) = &self.ocr_run @@ -291,13 +416,13 @@ impl PdfCraftApp { /// Show progress; apply the result once the worker is done. pub(crate) fn poll_ocr(&mut self) { if let Some(b) = self.ocr_batch.clone() { - let msg = b.lock().ok().map(|mut s| s.message.take().ok_or((s.done, s.total))); + let msg = b.lock().map(|mut s| s.message.take().ok_or((s.done, s.total))).map_err(|e| e.to_string()); match msg { - Some(Ok(m)) => { + Ok(Ok(m)) => { self.ocr_batch = None; self.notify(m); } - Some(Err((done, total))) => { + Ok(Err((done, total))) => { let m = crate::i18n::fmt( tl!("Recognizing text… file {d} of {t}"), &[("d", &(done + 1).min(total.max(1)).to_string()), ("t", &total.max(1).to_string())], @@ -309,19 +434,43 @@ impl PdfCraftApp { ctx.request_repaint_after(std::time::Duration::from_millis(200)); } } - None => self.ocr_batch = None, + // The worker stopped while holding the lock: it will never report back (#3919). + Err(e) => { + self.ocr_batch = None; + self.notify(failed(&e)); + } } } let Some(run) = self.ocr_run.as_ref() else { return }; - let (doc, progress) = (run.doc, run.progress.clone()); - let Ok(mut s) = progress.lock() else { return }; + let (doc, progress, auto) = (run.doc, run.progress.clone(), run.automatic); + let automatic = auto.is_some(); + let mut s = match progress.lock() { + Ok(s) => s, + Err(e) => { + // As above: without this the run never clears and refuses every later one. + let e = e.to_string(); + self.ocr_run = None; + self.notify(failed(&e)); + return; + } + }; + // The result of a run Edit started waits while Read mode or a dialog is up: nothing may change + // the document beneath them, and the run may have been started before they appeared. A run + // the user asked for completes as before. + if automatic && s.result.is_some() && (self.mode == crate::Mode::Read || self.modal_open()) { + drop(s); + if let Some(ctx) = &self.ctx { + ctx.request_repaint_after(std::time::Duration::from_millis(200)); + } + return; + } let Some(result) = s.result.take() else { let msg = crate::i18n::fmt( tl!("Recognizing text… page {d} of {t}"), &[("d", &(s.done + 1).min(s.total.max(1)).to_string()), ("t", &s.total.max(1).to_string())], ); drop(s); - if self.toast.as_ref().is_none_or(|t| t.0 != msg) { + if !automatic && self.toast.as_ref().is_none_or(|t| t.0 != msg) { self.notify(msg); } if let Some(ctx) = &self.ctx { @@ -331,6 +480,17 @@ impl PdfCraftApp { }; drop(s); self.ocr_run = None; + // The result is for the bytes the run started from. If the document has changed since (a + // page deleted or moved, say) its page index may now name another page: drop it, and let + // the page in view be judged again. + if let Some(a) = auto + && self.session.get(doc).map(|d| d.edit_generation()) != Some(a.generation) + { + if let Some(view) = self.views.iter_mut().find(|v| v.id == doc) { + view.auto_ocr_tried.remove(&a.page); + } + return; + } let found = match result { Ok(f) => f, Err(e) => { @@ -347,6 +507,10 @@ impl PdfCraftApp { { view.document_changed(&info); } + // An automatic run that found nothing (a page that has text of its own) says nothing. + if automatic && words == 0 { + return; + } let words_part = if words == 1 { crate::i18n::fmt(tl!("Recognized 1 word"), &[]) } else { @@ -367,3 +531,201 @@ impl PdfCraftApp { } } } + +/// "Recognize text failed: {e}", in the current language. +fn failed(e: &str) -> String { + crate::i18n::fmt(tl!("{label} failed: {e}"), &[("label", tl!("Recognize text")), ("e", e)]) +} + +/// `recognize`, with a panic in it (a damaged model, a page the recognizer can't take) turned into +/// the run's error: the run has to end either way, or it refuses every later one (#3919). +fn recognize_guarded(recognize: impl FnOnce() -> Result) -> Result { + pdfcraft_engine::guard(recognize).unwrap_or_else(|panic| Err(failed(&panic))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Dialog, LeftPanel, Mode, QuickTool}; + use pdfcraft_engine::{Edit, Session}; + + /// A one-page PDF that is one picture covering the page. + fn scan() -> Vec { + let mut png = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(8, 8, image::Rgba([250, 250, 250, 255]))) + .write_to(&mut png, image::ImageFormat::Png) + .unwrap(); + Session::new().create_from_images(&[("scan.png".into(), png.into_inner())]).unwrap().to_vec() + } + + fn app_with(pdf: Vec) -> PdfCraftApp { + let mut app = PdfCraftApp::new(); + app.open_bytes("doc.pdf", None, pdf).unwrap(); + app.quick_tool = QuickTool::EditText; + app + } + + fn found(app: &mut PdfCraftApp) -> Option { + app.scan_to_read().map(|s| s.page) + } + + /// These rules need no OCR models (the run itself judges the page's own text), so they are + /// tested everywhere, unlike the tests that recognize something. + #[test] + fn edit_reads_a_page_that_is_a_picture_and_no_other() { + assert_eq!(found(&mut app_with(scan())), Some(0)); + let text = Session::new().create_from_text("t", "Real text, no picture").unwrap().to_vec(); + assert_eq!(found(&mut app_with(text)), None, "a page of text is no scan"); + let blank = Session::new().create_blank(595.0, 842.0, 1).unwrap().to_vec(); + assert_eq!(found(&mut app_with(blank)), None, "nor is a blank page"); + } + + #[test] + fn only_edit_being_on_starts_it() { + let mut app = app_with(scan()); + app.quick_tool = QuickTool::Select; + assert_eq!(found(&mut app), None, "another tool"); + // The Edit tab's panel counts, as for the page view, with no tool picked. + app.left = LeftPanel::Tool("edit"); + app.left_open = true; + assert_eq!(found(&mut app), Some(0), "the Edit panel"); + app.left_open = false; + assert_eq!(found(&mut app), None, "the Edit panel closed"); + } + + #[test] + fn it_waits_for_read_mode_and_dialogs_to_end() { + let mut app = app_with(scan()); + app.mode = Mode::Read; + assert_eq!(found(&mut app), None, "Read mode"); + app.mode = Mode::Edit; + app.dialog = Some(Dialog::JsConsole); + assert_eq!(found(&mut app), None, "beneath a dialog"); + app.dialog = None; + assert_eq!(found(&mut app), Some(0), "tried again once they are gone"); + } + + #[test] + fn a_page_is_read_once_and_a_page_found_no_scan_is_judged_again_after_a_change() { + let mut app = app_with(scan()); + app.views[0].auto_ocr_tried.insert(0); + assert_eq!(found(&mut app), None, "a page already read is not read again (Undo sticks)"); + + // A blank page is no scan; it is judged again only once the document changes. + let blank = Session::new().create_blank(595.0, 842.0, 1).unwrap().to_vec(); + let mut app = app_with(blank); + assert_eq!(found(&mut app), None); + assert!(app.views[0].auto_ocr_skipped.contains_key(&0), "remembered with the document's generation"); + let id = app.active_ids().unwrap().1; + let mut png = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(8, 8, image::Rgba([250, 250, 250, 255]))) + .write_to(&mut png, image::ImageFormat::Png) + .unwrap(); + let add = Edit::AddImage { page: 0, rect: Some([0.0, 0.0, 595.0, 842.0]), name: "scan.png".into(), bytes: Arc::new(png.into_inner()) }; + app.session.apply(id, add).unwrap(); + assert_eq!(found(&mut app), Some(0), "a picture over the page makes it a scan"); + } + /// A finished run Edit started is applied only once Read mode and dialogs are gone; one the user + /// asked for is applied at once. The result is made up, so no OCR models are needed. + #[test] + fn the_result_of_an_automatic_run_waits_for_read_mode_and_dialogs() { + let word = || pdfcraft_engine::ocr::PlacedWord { text: "scanned".into(), origin: [72.0, 700.0], across: [60.0, 0.0], up: [0.0, 12.0] }; + let finished = |app: &PdfCraftApp, automatic: bool| { + let generation = app.session.get(app.active_ids().unwrap().1).unwrap().edit_generation(); + let page = pdfcraft_engine::ocr::OcrPage { page: 0, words: vec![word()], already_on_page: 0, skipped: None }; + let progress = Arc::new(Mutex::new(OcrProgress { done: 1, total: 1, result: Some(Ok(vec![page])), cancel: false })); + OcrRun { doc: app.active_ids().unwrap().1, progress, automatic: automatic.then_some(AutoOcr { page: 0, generation }) } + }; + let label = |app: &PdfCraftApp| app.session.get(app.active_ids().unwrap().1).unwrap().can_undo().map(String::from); + + let mut app = app_with(scan()); + app.ocr_run = Some(finished(&app, true)); + app.mode = Mode::Read; + app.poll_ocr(); + assert!(app.ocr_run.is_some() && label(&app).is_none(), "waits in Read mode"); + app.mode = Mode::Edit; + app.dialog = Some(Dialog::JsConsole); + app.poll_ocr(); + assert!(app.ocr_run.is_some() && label(&app).is_none(), "waits beneath a dialog"); + app.dialog = None; + app.poll_ocr(); + assert!(app.ocr_run.is_none(), "taken once they are gone"); + assert_eq!(label(&app).as_deref(), Some("Recognize text")); + + // A run the user asked for is not held back. + let mut app = app_with(scan()); + app.ocr_run = Some(finished(&app, false)); + app.mode = Mode::Read; + app.poll_ocr(); + assert_eq!(label(&app).as_deref(), Some("Recognize text")); + } + /// A run's result is for the document as it was when the run started. Once the document has + /// changed, a result of Edit's is dropped, not applied to whatever page now has its index, and + /// the page may be judged again. + #[test] + fn the_result_of_an_automatic_run_is_dropped_once_the_document_has_changed() { + let mut app = app_with(scan()); + let id = app.active_ids().unwrap().1; + let generation = app.session.get(id).unwrap().edit_generation(); + let page = pdfcraft_engine::ocr::OcrPage { + page: 0, + words: vec![pdfcraft_engine::ocr::PlacedWord { text: "scanned".into(), origin: [72.0, 700.0], across: [60.0, 0.0], up: [0.0, 12.0] }], + already_on_page: 0, + skipped: None, + }; + let progress = Arc::new(Mutex::new(OcrProgress { done: 1, total: 1, result: Some(Ok(vec![page])), cancel: false })); + app.ocr_run = Some(OcrRun { doc: id, progress, automatic: Some(AutoOcr { page: 0, generation }) }); + app.views[0].auto_ocr_tried.insert(0); + // The document changes while the run is out. + let mut png = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(8, 8, image::Rgba([250, 250, 250, 255]))) + .write_to(&mut png, image::ImageFormat::Png) + .unwrap(); + app.session + .apply(id, Edit::AddImage { page: 0, rect: Some([10.0, 10.0, 50.0, 50.0]), name: "p.png".into(), bytes: Arc::new(png.into_inner()) }) + .unwrap(); + app.poll_ocr(); + assert!(app.ocr_run.is_none(), "the run is over"); + assert_eq!(app.session.get(id).unwrap().can_undo(), Some("Add image"), "its result was not applied"); + assert!(!app.views[0].auto_ocr_tried.contains(&0), "the page may be judged again"); + } + + #[test] + fn a_panicking_recognizer_ends_the_run_with_an_error() { + let error = recognize_guarded(|| -> Result<(), String> { panic!("the model file is damaged") }).unwrap_err(); + assert!(error.starts_with("Recognize text failed") && error.contains("the model file is damaged"), "{error}"); + assert_eq!(recognize_guarded(|| Ok::<_, String>(7)), Ok(7)); + } + + /// Poison `m` the way a worker that panics while it holds the lock does. + fn poison(m: &Arc>) { + let worker = m.clone(); + let _ = std::thread::spawn(move || { + let _held = worker.lock(); + panic!("the worker died"); + }) + .join(); + assert!(m.is_poisoned()); + } + + #[test] + fn a_run_whose_worker_died_ends_instead_of_waiting_forever() { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + let progress = Arc::new(Mutex::new(OcrProgress { total: 3, ..Default::default() })); + poison(&progress); + app.ocr_run = Some(OcrRun { doc: DocId(1), progress, automatic: None }); + app.poll_ocr(); + assert!(app.ocr_run.is_none(), "the next run isn't refused as already running"); + let toast = app.toast.take().map(|t| t.0).unwrap_or_default(); + assert!(toast.starts_with("Recognize text failed"), "{toast}"); + + let batch = Arc::new(Mutex::new(BatchProgress { total: 2, ..Default::default() })); + poison(&batch); + app.ocr_batch = Some(batch); + app.poll_ocr(); + assert!(app.ocr_batch.is_none()); + let toast = app.toast.take().map(|t| t.0).unwrap_or_default(); + assert!(toast.starts_with("Recognize text failed"), "{toast}"); + } +} diff --git a/crates/ui-egui/src/optimize_ui.rs b/crates/ui-egui/src/optimize_ui.rs index 34188e184a..6662bf9974 100644 --- a/crates/ui-egui/src/optimize_ui.rs +++ b/crates/ui-egui/src/optimize_ui.rs @@ -8,7 +8,7 @@ use std::sync::{Arc, Mutex}; use egui::{Align, Layout}; -use pdfcraft_engine::optimize::{Compression, ImageSettings, Preset, QUALITIES, Settings}; +use pdfcraft_engine::optimize::{Compatibility, Compression, ImageSettings, Preset, QUALITIES, Settings}; use pdfcraft_engine::optimizer::{OptimizeStage, Optimized}; use pdfcraft_engine::{DocId, EditError, Hidden, OptimizeReport}; @@ -172,6 +172,17 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut OptimizeDraft, t: &Tokens) -> (boo } } }); + ui.add_space(8.0); + ui.label(tl!("Compatibility")); + let compat_label = |c: Compatibility| match c { + Compatibility::Retain => tl!("Retain existing").to_string(), + _ => format!("PDF {}", c.name()), + }; + egui::ComboBox::from_id_salt("optimize-compatibility").selected_text(compat_label(d.settings.compatibility)).show_ui(ui, |ui| { + for c in Compatibility::ALL { + ui.selectable_value(&mut d.settings.compatibility, c, compat_label(c)); + } + }); }); ui.add_space(6.0); ui.horizontal_wrapped(|ui| { @@ -219,9 +230,19 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut OptimizeDraft, t: &Tokens) -> (boo } OptimizeTab::CleanUp => { ui.add(widgets::checkbox(&mut s.flate_unencoded, tl!("Use Flate to encode streams that are not encoded"))); + ui.add(widgets::checkbox(&mut s.lzw_to_flate, tl!("In streams that use LZW encoding, use Flate instead"))); ui.add(widgets::checkbox(&mut s.remove_invalid_links, tl!("Remove invalid links and bookmarks"))); ui.add(widgets::checkbox(&mut s.remove_unreferenced_dests, tl!("Remove unreferenced named destinations"))); - ui.add_enabled(false, widgets::checkbox(&mut true, tl!("Compress document structure (object streams)"))); + let mut object_streams = s.effective_object_streams(); + if ui + .add_enabled( + s.compatibility != Compatibility::Pdf1_4, + widgets::checkbox(&mut object_streams, tl!("Compress document structure (object streams)")), + ) + .changed() + { + s.object_streams = object_streams; + } ui.add_enabled(false, widgets::checkbox(&mut true, tl!("Remove unused objects and merge identical ones"))); } } diff --git a/crates/ui-egui/src/organize_panel.rs b/crates/ui-egui/src/organize_panel.rs index 8d4d9911ce..b02d118012 100644 --- a/crates/ui-egui/src/organize_panel.rs +++ b/crates/ui-egui/src/organize_panel.rs @@ -168,7 +168,6 @@ pub(crate) fn panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { } else if back { view.organize = false; app.left = LeftPanel::AllTools; - app.mode = crate::Mode::AllTools; } match act { Some(Act::Edit(edit)) => view.pending_edit = Some(*edit), @@ -176,6 +175,11 @@ pub(crate) fn panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { Some(Act::Command(command)) => app.run_command(command), None => {} } + // Leaving the workspace needs the whole app, so it waits until the view is done with: leaving + // Edit puts Edit text down and applies a paragraph still open on the page. + if back && !close { + app.enter_mode(crate::Mode::AllTools); + } } /// Whether a row works in this build. diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index ff52eabcd8..19c2ba65b4 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -157,7 +157,7 @@ fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static let (back, close) = panel_header(ui, t, g.label, Some(tl!("Back to all tools"))); if back { app.left = LeftPanel::AllTools; - app.mode = crate::Mode::AllTools; + app.enter_mode(crate::Mode::AllTools); } if close { app.left_open = false; @@ -302,7 +302,7 @@ fn edit_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static ui.horizontal(|ui| { if from_all_tools && icons::button(ui, "chevron-left", 26.0, false, tl!("Back to all tools")).clicked() { app.left = LeftPanel::AllTools; - app.mode = crate::Mode::AllTools; + app.enter_mode(crate::Mode::AllTools); } // "Edit" under the Edit tab, as Acrobat titles it; the tool's own name from All tools. let title = if from_all_tools { tl!(g.label) } else { tl!("Edit") }; @@ -895,6 +895,14 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { RightPanel::Search => crate::search_ui::panel(ui, &t, view, info.pages.len()), RightPanel::Compare => compare_action = crate::compare_ui::panel(ui, &t, compare, id), RightPanel::Attachments => { + let attachable = doc.allows_modification() && doc.read_only_reason.is_none(); + if attachable { + egui::Frame::NONE.inner_margin(egui::Margin::symmetric(4, 6)).show(ui, |ui| { + if ui.add(egui::Button::new(tl!("Attach a file…"))).clicked() { + panel_command = Some("edit.attach"); + } + }); + } if info.attachments.is_empty() { empty(ui, &t, "paperclip", "This document has no attachments."); } @@ -927,6 +935,12 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { { 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() }); + } }); }); }); @@ -1136,7 +1150,10 @@ fn outline_item(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, item: &OutlineIte if let Some(levels) = cx.expand { ui.data_mut(|d| d.insert_temp(id, depth < levels)); } - let mut open = cx.searching || ui.data(|d| d.get_temp::(id)).unwrap_or(item.open || depth == 0); + // A bookmark opens as the file says at every level: a positive /Count is open, a negative + // one (or none) closed (ISO 32000-2 §12.3.3, Table 151). The reader's own toggles and a + // search override it. + let mut open = cx.searching || ui.data(|d| d.get_temp::(id)).unwrap_or(item.open); let x_text = indent + 20.0; if let Some((rpath, text)) = cx.rename.as_mut() && rpath.as_slice() == path @@ -1421,6 +1438,13 @@ fn pages(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, view: &mut crate::DocVie view.pending_action = Some(crate::canvas::ViewAction::Extract); ui.close(); } + if ui.button(tl!("Split document…")).clicked() { + if view.selected.is_empty() { + view.select_pages(&[i]); + } + view.pending_action = Some(crate::canvas::ViewAction::SplitBeforeSelected); + ui.close(); + } if ui.add_enabled(editable, egui::Button::new(tl!("Replace pages…"))).clicked() { view.pending_action = Some(crate::canvas::ViewAction::ReplacePages); ui.close(); diff --git a/crates/ui-egui/src/prepare.rs b/crates/ui-egui/src/prepare.rs index f79ebf17f9..5833c38384 100644 --- a/crates/ui-egui/src/prepare.rs +++ b/crates/ui-egui/src/prepare.rs @@ -543,7 +543,7 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, let prep = &view.prepare; let pointer = ui.input(|i| i.pointer.hover_pos()); let grab = prep.grab.filter(|(gp, ..)| *gp == page); - let zoom = (xf.rect.width() / xf.pw.max(1.0)).clamp(0.5, 3.0); + let zoom = xf.zoom().clamp(0.5, 3.0); for f in form { for (wi, w) in f.widgets.iter().enumerate().filter(|(_, w)| w.page == Some(page)) { let mut r = screen_rect(xf, info, page, w.rect); @@ -560,7 +560,8 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, egui::StrokeKind::Inside, ); // The name tag: white text on black, clipped to the field. - let label = f.name.rsplit('.').next().unwrap_or(&f.name); + // The full name, as Acrobat shows it: MASTER.first, not first (#2696). + let label = f.name.as_str(); let font = theme::regular((9.0 * zoom).clamp(7.0, 13.0)); let galley = painter.layout_no_wrap(label.to_string(), font, Color32::WHITE); let size = galley.size() + vec2(6.0, 2.0); @@ -740,6 +741,7 @@ impl crate::PdfCraftApp { self.session.get(id).map(|doc| doc.form.iter().filter(|x| x.name != name).map(|x| x.name.clone()).collect()).unwrap_or_default(); d.others = others.clone(); d.actions = self.session.get(id).map(|doc| doc.field_actions(name)).unwrap_or_default(); + d.chained = self.session.get(id).map(|doc| doc.field_action_chains(name)).unwrap_or_default(); if let Some(o) = d.original.as_mut() { o.others = others; o.actions = d.actions.clone(); @@ -852,6 +854,11 @@ pub struct FieldDraft { /// Actions tab: each trigger's action, and the one being added. pub actions: Vec<(pdfcraft_engine::FieldTrigger, pdfcraft_engine::FieldAction)>, pub new_action: ActionDraft, + /// Actions tab: the trigger whose script `new_action` is editing (Edit), if any. + pub editing: Option, + /// Actions tab: how many actions the document chains after each trigger's first one. They are + /// kept when that first action is replaced. + pub chained: Vec<(pdfcraft_engine::FieldTrigger, usize)>, original: Box>, } @@ -887,7 +894,8 @@ impl FieldDraft { kind: f.kind, widget, tab: FieldTab::General, - name: f.name.rsplit('.').next().unwrap_or(&f.name).to_string(), + // The full name: a dotted name moves the field under those parent fields (#2696). + name: f.name.clone(), tooltip: f.tooltip.clone().unwrap_or_default(), caption: f.widgets.get(widget).and_then(|w| w.caption.clone()).unwrap_or_default(), read_only: f.read_only(), @@ -913,6 +921,8 @@ impl FieldDraft { others: Vec::new(), actions: Vec::new(), new_action: ActionDraft::default(), + editing: None, + chained: Vec::new(), original: Box::new(None), }; d.original = Box::new(Some(d.clone())); @@ -1533,21 +1543,27 @@ fn validate_tab(ui: &mut egui::Ui, d: &mut FieldDraft) { fn actions_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) { use pdfcraft_engine::FieldTrigger as T; - ui.label(egui::RichText::new(tl!("Add an Action")).font(theme::semibold(13.0))); + let editing = d.editing.is_some(); + ui.label(egui::RichText::new(if editing { tl!("Edit Action") } else { tl!("Add an Action") }).font(theme::semibold(13.0))); let a = &mut d.new_action; egui::Grid::new("field-actions-add").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { ui.label(tl!("Select Trigger:")); - egui::ComboBox::from_id_salt("action-trigger").selected_text(tl!(a.trigger.label())).width(220.0).show_ui(ui, |ui| { - for tr in T::ALL { - ui.selectable_value(&mut a.trigger, tr, tl!(tr.label())); - } + // Editing changes the script of one trigger's action: its trigger and kind stay. + ui.add_enabled_ui(!editing, |ui| { + egui::ComboBox::from_id_salt("action-trigger").selected_text(tl!(a.trigger.label())).width(220.0).show_ui(ui, |ui| { + for tr in T::ALL { + ui.selectable_value(&mut a.trigger, tr, tl!(tr.label())); + } + }); }); ui.end_row(); ui.label(tl!("Select Action:")); - egui::ComboBox::from_id_salt("action-kind").selected_text(tl!(ACTION_KINDS[a.kind])).width(220.0).show_ui(ui, |ui| { - for (i, k) in ACTION_KINDS.iter().enumerate() { - ui.selectable_value(&mut a.kind, i, tl!(k)); - } + ui.add_enabled_ui(!editing, |ui| { + egui::ComboBox::from_id_salt("action-kind").selected_text(tl!(ACTION_KINDS[a.kind])).width(220.0).show_ui(ui, |ui| { + for (i, k) in ACTION_KINDS.iter().enumerate() { + ui.selectable_value(&mut a.kind, i, tl!(k)); + } + }); }); ui.end_row(); let hint = match a.kind { @@ -1568,7 +1584,12 @@ fn actions_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) ui.end_row(); }); let mut error = None; - if ui.button(tl!("Add")).clicked() { + let (mut commit, mut discard) = (false, false); + ui.horizontal(|ui| { + commit = ui.button(if editing { tl!("Update") } else { tl!("Add") }).clicked(); + discard = editing && ui.button(tl!("Discard")).clicked(); + }); + if commit { match d.new_action.action() { Ok(act) => { let tr = d.new_action.trigger; @@ -1576,10 +1597,15 @@ fn actions_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) d.actions.push((tr, act)); d.actions.sort_by_key(|(x, _)| T::ALL.iter().position(|y| y == x)); d.new_action.text.clear(); + d.editing = None; } Err(e) => error = Some(e), } } + if discard { + d.new_action = ActionDraft::default(); + d.editing = None; + } if let Some(e) = error { ui.label(egui::RichText::new(tl!(&e)).small().color(t.text_muted)); } @@ -1590,19 +1616,40 @@ fn actions_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Tokens) if d.actions.is_empty() { ui.label(egui::RichText::new(tl!("No actions")).color(t.text_muted)); } - let mut remove = None; + let (mut remove, mut edit) = (None, None); for (i, (tr, act)) in d.actions.iter().enumerate() { ui.horizontal(|ui| { ui.label(egui::RichText::new(tl!(tr.label())).strong()); - ui.label(act.describe()); + let more = d.chained.iter().find(|(x, _)| x == tr).map_or(0, |(_, n)| *n); + if more == 0 { + ui.label(act.describe()); + } else { + let then = crate::i18n::trn(crate::i18n::current(), more as u64, "then {n} more action", "then {n} more actions"); + ui.label(format!("{}, {then}", act.describe())); + } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.push_id(i, |ui| ui.button(tl!("Delete"))).inner.clicked() { - remove = Some(i); - } + ui.push_id(i, |ui| { + if ui.button(tl!("Delete")).clicked() { + remove = Some(i); + } + if let pdfcraft_engine::FieldAction::JavaScript(js) = act + && ui.add_enabled(d.editing.is_none(), egui::Button::new(tl!("Edit"))).clicked() + { + edit = Some((*tr, js.clone())); + } + }); }); }); } + if let Some((trigger, text)) = edit { + d.new_action = ActionDraft { trigger, kind: 0, text }; + d.editing = Some(trigger); + } if let Some(i) = remove { + if d.actions.get(i).map(|(x, _)| Some(*x)) == Some(d.editing) { + d.new_action = ActionDraft::default(); + d.editing = None; + } d.actions.remove(i); } }); diff --git a/crates/ui-egui/src/print_ui.rs b/crates/ui-egui/src/print_ui.rs index cab6bfc236..fcc8d89d97 100644 --- a/crates/ui-egui/src/print_ui.rs +++ b/crates/ui-egui/src/print_ui.rs @@ -223,13 +223,22 @@ impl PrintDraft { self.handling == Handling::Multiple && self.order == PageOrder::CutStack } + /// The spooler job for this print: the dialog's copies, two-sided and colour settings, the + /// driver options changed from the printer's defaults, and the chosen paper. pub fn job(&self, title: &str) -> spool::Job { spool::Job { printer: self.printer.clone(), copies: self.copies.max(1), collate: self.collate, - // Cut and stack needs single-sided sheets, whatever the printer's default. - duplex: if self.cut_and_stack() { Some(spool::Duplex::Off) } else { self.duplex }, + // Cut and stack needs single-sided sheets, whatever the printer's default; a booklet + // decides two-sided itself (#2707), leaving the Two-sided choice as it was. + duplex: if self.cut_and_stack() { + Some(spool::Duplex::Off) + } else if self.handling == Handling::Booklet { + Some(print::booklet_duplex(self.booklet_subset)) + } else { + self.duplex + }, scaling: spool::Scaling::Exact, grayscale: self.grayscale, title: title.to_string(), @@ -240,7 +249,7 @@ impl PrintDraft { } .into_iter() // The sheet the PDF was laid out on, not the queue's default paper. - .chain(print::cups_media(self.paper).map(|m| ("media".to_string(), m.to_string()))) + .chain(print::media_option(self.paper)) .collect(), } } @@ -1017,22 +1026,26 @@ pub(crate) fn body( }); }); ui.end_row(); - ui.label(tl!("Two-sided:")); - ui.add_enabled_ui(!browser, |ui| { - combo( - ui, - "duplex", - &mut d.duplex, - &[ - (None, tl!("Printer default")), - (Some(spool::Duplex::Off), tl!("Off")), - (Some(spool::Duplex::LongEdge), tl!("Flip on long edge")), - (Some(spool::Duplex::ShortEdge), tl!("Flip on short edge")), - ], - 160.0, - ); - }); - ui.end_row(); + // A booklet decides two-sided itself (#2707), so the choice is hidden meanwhile, as + // in Acrobat's Print dialog; it is kept for the other layouts. + if d.handling != Handling::Booklet { + ui.label(tl!("Two-sided:")); + ui.add_enabled_ui(!browser, |ui| { + combo( + ui, + "duplex", + &mut d.duplex, + &[ + (None, tl!("Printer default")), + (Some(spool::Duplex::Off), tl!("Off")), + (Some(spool::Duplex::LongEdge), tl!("Flip on long edge")), + (Some(spool::Duplex::ShortEdge), tl!("Flip on short edge")), + ], + 160.0, + ); + }); + ui.end_row(); + } ui.label(tl!("Paper:")); let paper_name = PAPERS.get(d.paper).map_or("US Letter", |p| p.0); let before = d.paper; diff --git a/crates/ui-egui/src/recovery.rs b/crates/ui-egui/src/recovery.rs index 68d82922c3..128e576043 100644 --- a/crates/ui-egui/src/recovery.rs +++ b/crates/ui-egui/src/recovery.rs @@ -424,23 +424,51 @@ mod idle_tests { struct TestDir(PathBuf); impl TestDir { + /// A new folder of this test's own. The tests run on parallel threads of one process, and + /// two can read the same clock time (it moves in microseconds on macOS), so a counter + /// tells their folders apart; the time keeps runs of the process apart. fn new() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let n = NEXT.fetch_add(1, Ordering::Relaxed); let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); - let path = std::env::temp_dir().join(format!("pdfcraft-idle-recovery-{}-{nonce}", std::process::id())); + let path = std::env::temp_dir().join(format!("pdfcraft-idle-recovery-{}-{nonce}-{n}", std::process::id())); std::fs::create_dir(&path).unwrap(); Self(path) } + + /// The folder. fn path(&self) -> &Path { &self.0 } } impl Drop for TestDir { + /// Remove the folder and what the test left in it. fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } + /// Folders made at the same moment on parallel threads, as the tests make theirs, are each + /// their own: none fails because another test's folder took its name. + #[test] + fn test_folders_made_at_once_on_parallel_threads_are_each_their_own() { + let start = std::sync::Barrier::new(8); + let dirs: Vec = std::thread::scope(|s| { + let threads: Vec<_> = (0..8) + .map(|_| { + s.spawn(|| { + start.wait(); + (0..25).map(|_| TestDir::new()).collect::>() + }) + }) + .collect(); + threads.into_iter().flat_map(|t| t.join().unwrap()).map(|d| d.path().to_path_buf()).collect() + }); + let distinct: std::collections::HashSet<&PathBuf> = dirs.iter().collect(); + assert_eq!(distinct.len(), 200); + } + fn frame(app: &mut PdfCraftApp, ctx: &egui::Context, now: f64) -> Duration { let mut out = ctx.run_ui(egui::RawInput { time: Some(now), ..Default::default() }, |ui| { app.autosave_tick(ui.ctx(), now); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index ea76a6991a..442cd6d8e1 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -13,11 +13,12 @@ pub enum ThemeKind { Dark, } -/// The saved user choice, independent of the light/dark colours currently displayed. +/// The saved user choice, independent of the light/dark colours currently displayed. A fresh +/// install follows the system (#1248); a desktop that says nothing gets Light. #[derive(Clone, Copy, Debug, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] pub enum ThemePreference { - System, #[default] + System, Light, Dark, } diff --git a/crates/ui-egui/src/window_controls.rs b/crates/ui-egui/src/window_controls.rs index eacf854c22..d02ea5dc8c 100644 --- a/crates/ui-egui/src/window_controls.rs +++ b/crates/ui-egui/src/window_controls.rs @@ -217,7 +217,7 @@ pub fn resize_edges(ctx: &egui::Context) { let response = ui.interact(rect, ui.id().with(("edge", n)), Sense::drag()).on_hover_cursor(cursor(direction)); // Sensing only drags, a drag starts on the press itself. if response.drag_started() { - ui.ctx().send_viewport_cmd(egui::ViewportCommand::BeginResize(direction)); + desktop_grab(ui.ctx(), egui::ViewportCommand::BeginResize(direction)); } } // Without the desktop's frame or shadow, a hairline keeps the window apart from @@ -231,6 +231,41 @@ pub fn resize_edges(ctx: &egui::Context) { }); } +fn grab_id() -> egui::Id { + egui::Id::new("window_controls_desktop_grab") +} + +/// Hand the pointer to the desktop to move or resize the window. On Wayland the compositor keeps +/// the button's release and winit reports only that the pointer left, which egui doesn't count as +/// a release; egui would think the button still held, and the next press on an edge or the title +/// bar wouldn't start a drag until a click inside the window. The release comes back on the next +/// input (`release_after_desktop_grab`). +pub fn desktop_grab(ctx: &egui::Context, command: egui::ViewportCommand) { + ctx.send_viewport_cmd(command); + ctx.data_mut(|d| d.insert_temp(grab_id(), ())); + ctx.request_repaint(); +} + +/// 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. +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 (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 }); +} + #[cfg(test)] mod tests { use super::{Button, Layout, ResizeDirection, zones}; @@ -285,4 +320,73 @@ mod tests { assert!(r.width() >= 0.0 && r.height() >= 0.0); } } + + #[test] + fn a_desktop_grab_gives_the_button_release_back() { + let ctx = egui::Context::default(); + let releases = |raw: &egui::RawInput| { + raw.events.iter().filter(|e| matches!(e, egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed: false, .. })).count() + }; + let mut raw = egui::RawInput::default(); + super::release_after_desktop_grab(&ctx, &mut raw); + assert_eq!(releases(&raw), 0, "no grab, no release"); + + super::desktop_grab(&ctx, egui::ViewportCommand::StartDrag); + super::release_after_desktop_grab(&ctx, &mut raw); + assert_eq!(releases(&raw), 1, "the release the desktop kept"); + + let mut raw = egui::RawInput::default(); + super::release_after_desktop_grab(&ctx, &mut raw); + assert_eq!(releases(&raw), 0, "only once"); + + super::desktop_grab(&ctx, egui::ViewportCommand::BeginResize(ResizeDirection::East)); + let mut raw = egui::RawInput::default(); + raw.events.push(egui::Event::PointerButton { + pos: Pos2::ZERO, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + super::release_after_desktop_grab(&ctx, &mut raw); + assert_eq!(releases(&raw), 1, "not doubled when the input brings it"); + } + + #[test] + fn the_release_goes_before_the_press_of_the_next_grab() { + let button = |pressed| egui::Event::PointerButton { + pos: Pos2::new(5.0, 5.0), + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }; + let primary = |raw: &egui::RawInput| { + raw.events + .iter() + .filter_map(|e| if let egui::Event::PointerButton { pressed, .. } = e { Some(*pressed) } else { None }) + .collect::>() + }; + let ctx = egui::Context::default(); + let frame = |events: Vec, grab: Option| { + let mut raw = egui::RawInput { events, ..Default::default() }; + super::release_after_desktop_grab(&ctx, &mut raw); + let order = primary(&raw); + ctx.run_ui(raw, |ui| { + if let Some(command) = grab.clone() { + super::desktop_grab(ui.ctx(), command); + } + }) + .textures_delta + .clear(); + order + }; + // The press of the first resize starts the grab; the desktop keeps its release. + frame(vec![egui::Event::PointerMoved(Pos2::new(5.0, 5.0)), button(true)], Some(egui::ViewportCommand::BeginResize(ResizeDirection::East))); + // The next input brings the press of the second resize. + assert_eq!(frame(vec![button(true)], None), [false, true], "the kept release, then the new press"); + assert!(ctx.input(|i| i.pointer.primary_down()), "the second resize holds the button"); + + // A quick click's release belongs to its own press, not to the grab before it. + frame(vec![], Some(egui::ViewportCommand::StartDrag)); + assert_eq!(frame(vec![button(true), button(false)], None), [false, true, false]); + } } diff --git a/crates/ui-egui/tests/it/attach_picker_target.rs b/crates/ui-egui/tests/it/attach_picker_target.rs new file mode 100644 index 0000000000..ac1aed7361 --- /dev/null +++ b/crates/ui-egui/tests/it/attach_picker_target.rs @@ -0,0 +1,66 @@ +//! Edit ▸ Attach a file… attaches to the document that was active when the picker opened. If the +//! user switches tabs while choosing, the pick is dropped (and they are told) rather than the file +//! landing in whichever document is active by then. + +use egui_kittest::Harness; +use pdfcraft_ui_egui::PdfCraftApp; + +/// A one-page document with a proper xref table. +fn fixture() -> Vec { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_string(), + "<< /Type /Page /Parent 2 0 R >>".to_string(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +fn harness() -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("a.pdf", None, fixture()).unwrap(); + app.open_bytes("b.pdf", None, fixture()).unwrap(); + app + }); + h.run_steps(2); + h +} + +fn dirty(app: &PdfCraftApp, view: usize) -> bool { + app.session.get(app.views[view].id).is_some_and(|d| d.dirty) +} + +#[test] +fn an_attachment_pick_is_dropped_when_the_user_switches_tabs() { + let dir = std::env::temp_dir().join(format!("pdfcraft-attach-target-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("note.txt"); + std::fs::write(&file, b"hello").unwrap(); + + let mut h = harness(); + h.state_mut().active = Some(0); + h.state_mut().pick_override = Some(vec![file.to_string_lossy().into_owned()]); + assert!(h.state_mut().execute("edit.attach")); + // The user switches tabs before the pick arrives. + h.state_mut().active = Some(1); + h.run_steps(2); + assert!(!dirty(h.state(), 0), "the document the pick was for is unchanged"); + assert!(!dirty(h.state(), 1), "the newly active document did not get the attachment"); + let toast = h.state().toast.as_ref().map(|(m, _)| m.clone()).unwrap_or_default(); + assert!(toast.contains("document changed"), "the user is told why nothing happened: {toast:?}"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/tests/it/bookmarks.rs b/crates/ui-egui/tests/it/bookmarks.rs index 1cf78958f6..be8c5151bb 100644 --- a/crates/ui-egui/tests/it/bookmarks.rs +++ b/crates/ui-egui/tests/it/bookmarks.rs @@ -77,6 +77,7 @@ fn new_rename_reorder_indent_delete_and_undo() { menu(&mut h, "Results", "Indent"); assert_eq!(outline(&h), [("Intro".into(), Some(0), 0), ("Results".into(), Some(2), 1)]); + h.get_by_label("Results"); // its new parent opened, so it still shows menu(&mut h, "Results", "Outdent"); menu(&mut h, "Results", "Move up"); assert_eq!(outline(&h)[0].0, "Results"); @@ -310,3 +311,60 @@ fn style_and_colour_from_the_bookmark_menu() { h.run_steps(3); assert!(look(&h).0, "undoable"); } + +/// Top-level bookmarks open as the file says, like every other level: a positive /Count shows +/// the children, a negative one hides them until the reader opens it (ISO 32000-2 §12.3.3, +/// Table 151). They used to open regardless. +#[test] +fn a_closed_top_level_bookmark_opens_closed() { + const OUTLINED: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /Outlines 10 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 >> endobj +10 0 obj << /Type /Outlines /First 11 0 R /Last 13 0 R /Count 5 >> endobj +11 0 obj << /Title (Closed parent) /Parent 10 0 R /Next 12 0 R /First 21 0 R /Last 22 0 R /Count -2 /Dest [3 0 R /Fit] >> endobj +12 0 obj << /Title (Open parent) /Parent 10 0 R /Prev 11 0 R /Next 13 0 R /First 23 0 R /Last 24 0 R /Count 2 /Dest [3 0 R /Fit] >> endobj +13 0 obj << /Title (Uncounted parent) /Parent 10 0 R /Prev 12 0 R /First 25 0 R /Last 25 0 R /Dest [3 0 R /Fit] >> endobj +21 0 obj << /Title (Closed child A) /Parent 11 0 R /Next 22 0 R /Dest [3 0 R /Fit] >> endobj +22 0 obj << /Title (Closed child B) /Parent 11 0 R /Prev 21 0 R /Dest [3 0 R /Fit] >> endobj +23 0 obj << /Title (Open child A) /Parent 12 0 R /Next 24 0 R /Dest [3 0 R /Fit] >> endobj +24 0 obj << /Title (Open child B) /Parent 12 0 R /Prev 23 0 R /Dest [3 0 R /Fit] >> endobj +25 0 obj << /Title (Uncounted child) /Parent 13 0 R /Dest [3 0 R /Fit] >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + let mut h = harness(); + h.state_mut().open_bytes("outlined.pdf", None, OUTLINED.to_vec()).unwrap(); + h.run_steps(4); + let shown = |h: &Harness<'static, PdfCraftApp>, t: &str| h.query_by_label(t).is_some(); + assert!(shown(&h, "Closed parent") && shown(&h, "Open parent") && shown(&h, "Uncounted parent")); + assert!(!shown(&h, "Closed child A") && !shown(&h, "Closed child B"), "a negative /Count opens closed"); + assert!(shown(&h, "Open child A") && shown(&h, "Open child B"), "a positive /Count opens open"); + assert!(!shown(&h, "Uncounted child"), "only a positive /Count opens a bookmark"); + + // The disclosure triangle at the row's left still opens and closes it. + let toggle = |h: &mut Harness<'static, PdfCraftApp>, title: &str| { + let row = h.get_by_label(title).rect(); + let at = egui::pos2(row.left() + 8.0, row.top() + 14.0); + h.hover_at(at); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + }; + toggle(&mut h, "Closed parent"); + assert!(shown(&h, "Closed child A") && shown(&h, "Closed child B"), "opened by the reader"); + toggle(&mut h, "Closed parent"); + assert!(!shown(&h, "Closed child A"), "closed again"); + toggle(&mut h, "Open parent"); + assert!(!shown(&h, "Open child A"), "an open bookmark closes"); + + // A search still shows the matches inside a closed bookmark, and clearing it restores it. + h.get_by_label("Search").click(); + h.run_steps(1); + h.get_by_label("Search").type_text("child B"); + h.run_steps(3); + assert!(shown(&h, "Closed child B") && shown(&h, "Open child B")); + h.get_by_label("Clear").click(); + h.run_steps(3); + assert!(!shown(&h, "Closed child B") && !shown(&h, "Open child B"), "search leaves the open state alone"); +} diff --git a/crates/ui-egui/tests/it/comment_pin.rs b/crates/ui-egui/tests/it/comment_pin.rs new file mode 100644 index 0000000000..af9f802049 --- /dev/null +++ b/crates/ui-egui/tests/it/comment_pin.rs @@ -0,0 +1,161 @@ +//! The annotation quick bar's pin is a preference, including across older settings files. + +use egui::{Pos2, pos2}; +use egui_kittest::{Harness, kittest::Queryable}; +use pdfcraft_ui_egui::{PdfCraftApp, QuickTool, comments::CommentTool}; +use serde_json::{Value, json}; + +// Contributor-original blank page: no document assets or fonts are needed. +const PDF: &[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] >> endobj +xref +0 4 +0000000000 65535 f\r +0000000009 00000 n\r +0000000058 00000 n\r +0000000115 00000 n\r +trailer << /Size 4 /Root 1 0 R >> +startxref +186 +%%EOF"; + +/// Start a fresh shell with the state a previous process saved. +fn harness(saved: Option) -> 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(); + if let Some(saved) = saved { + app.restore(&saved); + } + app.set_option("language", "en").unwrap(); + app.set_option("author", "Tester").unwrap(); + app.open_bytes("pin.pdf", None, PDF.to_vec()).unwrap(); + app.set_option("left", "closed").unwrap(); + app.set_option("quick", "callout").unwrap(); + app + }); + crate::support::wait_until(&mut h, "the blank page", |app| app.views[0].page_screen_rect(0).is_some() && !app.render_pending()); + h +} + +/// Place and post a callout at known PDF coordinates, independently of panel and zoom changes. +fn callout(h: &mut Harness<'static, PdfCraftApp>, y: f32) { + let r = h.state().views[0].page_screen_rect(0).unwrap(); + let at = |x: f32| -> Pos2 { pos2(r.left() + x / 300.0 * r.width(), r.top() + (200.0 - y) / 200.0 * r.height()) }; + h.hover_at(at(40.0)); + h.run_steps(1); + h.drag_at(at(40.0)); + h.run_steps(1); + h.hover_at(at(140.0)); + h.run_steps(1); + h.drop_at(at(140.0)); + h.run_steps(3); + h.query_all_by(|node| node.placeholder() == Some("Type text")).next().unwrap().type_text("Remember this"); + h.run_steps(2); + h.get_by_label("Post").click(); + h.run_steps(3); +} + +/// Optional private visual evidence, kept outside the repository by the test runner. +fn shot(h: &mut Harness<'static, PdfCraftApp>, name: &str) { + if let Ok(dir) = std::env::var("PDFCRAFT_PIN_SHOTS") { + h.render().unwrap().save(std::path::Path::new(&dir).join(name)).unwrap(); + } +} + +#[test] +fn pin_from_quick_bar_survives_restart_and_unpinning() { + let _gpu = crate::gpu(); + let mut h = harness(None); + h.get_by_label("Keep tool selected").click(); + h.run_steps(3); + assert!(h.state().comment_prefs.pinned); + let doc = h.state().session.get(h.state().views[0].id).unwrap(); + assert!(doc.repair_log().is_empty(), "the synthetic page opens without repair"); + assert_eq!(doc.bytes.as_slice(), PDF, "changing this preference leaves the PDF unchanged"); + assert!(!doc.dirty); + let saved = h.state().persist(); + drop(h); + + let mut h = harness(Some(saved)); + assert!(h.state().comment_prefs.pinned, "the pin survives a fresh shell"); + h.get_by_label("Keep tool selected: on"); + shot(&mut h, "pin-restored.png"); + callout(&mut h, 140.0); + crate::support::wait_until(&mut h, "the pinned callout", |app| app.session.get(app.views[0].id).unwrap().info.annotations.len() == 1); + assert!(matches!(h.state().quick_tool, QuickTool::Comment(CommentTool::Callout)), "the restored pin keeps the tool selected after use"); + + h.get_by_label("Keep tool selected: on").click(); + h.run_steps(3); + assert!(!h.state().comment_prefs.pinned); + shot(&mut h, "pin-disabled.png"); + let saved = h.state().persist(); + callout(&mut h, 60.0); + crate::support::wait_until(&mut h, "the unpinned callout returning to Select", |app| { + app.session.get(app.views[0].id).unwrap().info.annotations.len() == 2 && matches!(app.quick_tool, QuickTool::Select) + }); + assert!(!matches!(h.state().quick_tool, QuickTool::Comment(_)), "unpinning restores one-shot use"); + + let mut restored = PdfCraftApp::new(); + restored.comment_prefs.pinned = true; + restored.restore(&saved); + assert!(!restored.comment_prefs.pinned, "unpinning survives the next restart too"); +} + +#[test] +fn saved_pin_round_trips_both_boolean_values() { + for pinned in [true, false] { + let mut app = PdfCraftApp::new(); + app.comment_prefs.pinned = pinned; + let saved = app.persist(); + let value: Value = serde_json::from_str(&saved).unwrap(); + assert_eq!(value["comment_tool_pinned"], json!(pinned)); + let mut restored = PdfCraftApp::new(); + restored.comment_prefs.pinned = !pinned; + restored.restore(&saved); + assert_eq!(restored.comment_prefs.pinned, pinned); + assert!(matches!(restored.quick_tool, QuickTool::Select), "remembering the pin does not select a drawing tool at startup"); + } +} + +#[test] +fn restoring_explicit_pin_choice_overrides_current_choice() { + for pinned in [true, false] { + let mut app = PdfCraftApp::new(); + app.comment_prefs.pinned = !pinned; + app.restore(&json!({"comment_tool_pinned": pinned}).to_string()); + assert_eq!(app.comment_prefs.pinned, pinned); + } +} + +#[test] +fn legacy_settings_keep_the_default_and_other_preferences() { + let mut app = PdfCraftApp::new(); + assert!(!app.comment_prefs.pinned); + let legacy = r#"{"author":"Legacy reviewer","comment_styles":[{"tool":"comment.line","color":[0.25,0.5,1.0]}]}"#; + app.restore(legacy); + assert!(!app.comment_prefs.pinned, "old settings retain the default one-shot behavior"); + assert_eq!(app.comment_prefs.author, "Legacy reviewer"); + assert_eq!(app.comment_prefs.style(CommentTool::Line).color, [0.25, 0.5, 1.0]); + app.comment_prefs.pinned = true; + app.restore(legacy); + assert!(app.comment_prefs.pinned, "a partial restore without this key leaves the current choice alone"); +} + +#[test] +fn malformed_pin_values_preserve_the_current_choice() { + for pinned in [false, true] { + let mut app = PdfCraftApp::new(); + app.comment_prefs.pinned = pinned; + for value in [json!(null), json!(0), json!(1), json!(-1), json!("true"), json!("false"), json!([]), json!({})] { + app.restore(&json!({"comment_tool_pinned": value, "author": "Still restored"}).to_string()); + assert_eq!(app.comment_prefs.pinned, pinned, "invalid pin value: {value}"); + assert_eq!(app.comment_prefs.author, "Still restored", "other valid preferences still restore"); + } + for invalid in ["", "{", r#"{"comment_tool_pinned":true"#, "[]", "null", "false", "0"] { + app.restore(invalid); + assert_eq!(app.comment_prefs.pinned, pinned); + } + } +} diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index 03f56978a6..ef06470ea5 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -665,6 +665,115 @@ fn sticky_note_composer_posts_and_returns_to_select() { assert_eq!(comments(&h).len(), 1); } +/// #2583: a sticky note is saved NoRotate, so on a turned page its icon is drawn upright from +/// where it was placed (right and down), not where its `/Rect` lies as displayed. The Select tool +/// picks it where it is drawn. +#[test] +fn a_sticky_note_on_a_turned_page_is_picked_where_it_is_drawn() { + let mut h = harness(|app| { + app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 }); + app.set_option("quick", "note").unwrap(); + }); + crate::support::settle(&mut h); + let page = h.state().views[0].page_screen_rect(0).expect("page 1 on screen"); + // Screen pixels per point of the displayed page (300 × 200 pt turned to 200 × 300). + let k = page.width() / 200.0; + let placed = page.left_top() + egui::vec2(100.0 * k, 60.0 * k); + click(&mut h, placed); + field(&h, "Add a comment").type_text("Turned"); + h.run_steps(2); + h.get_by_label("Post").click(); + h.run_steps(3); + crate::support::settle(&mut h); + let c = comments(&h); + assert_eq!(c.len(), 1); + assert_eq!((c[0].subtype.as_str(), c[0].no_rotate), ("Text", true)); + assert_eq!(h.state().quick_tool, QuickTool::Select); + // Where the note's /Rect lies as displayed, left of where it was placed: nothing. + click(&mut h, placed + egui::vec2(-10.0 * k, 10.0 * k)); + assert_eq!(h.state().views[0].comments.selected, None); + // On the icon, drawn right and down from where it was placed. + click(&mut h, placed + egui::vec2(10.0 * k, 10.0 * k)); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0))); +} + +/// The composer leaves with its comment when the page scrolls it out of the document, and comes +/// back with the draft (#4021). It used to be pushed back into the window, over the mode bar and +/// the toolbar, while its comment was out of sight. +#[test] +fn the_composer_hides_while_its_comment_is_scrolled_away() { + let mut h = harness(|app| app.set_option("quick", "note").unwrap()); + click_pt(&mut h, (250.0, 180.0)); + field(&h, "Add a comment").type_text("Draft"); + h.run_steps(2); + let editors = |h: &Harness<'static, PdfCraftApp>| -> Vec { + h.query_all_by(|n| n.role() == egui::accesskit::Role::MultilineTextInput).map(|n| n.rect()).collect() + }; + let viewport = h.state().views[0].viewport_rect(); + assert!(editors(&h).iter().any(|r| viewport.contains(r.center())), "the composer opens in the document"); + // The page scrolled up: it still shows, the comment near its top doesn't. + h.state_mut().views[0].goto = Some((0, 0.4)); + h.run_steps(4); + assert!(h.state().views[0].page_screen_rect(0).is_some_and(|r| r.bottom() > viewport.top()), "page 1 still shows"); + assert!(at(&h, 250.0, 180.0).y < viewport.top(), "the comment is scrolled away"); + let shown = editors(&h); + assert!(shown.is_empty(), "the composer stays with its comment, not over the toolbar: {shown:?} (document {viewport:?})"); + // Back again, the draft is still there. + h.state_mut().views[0].goto = Some((0, 0.0)); + h.run_steps(4); + assert!(editors(&h).iter().any(|r| viewport.contains(r.center())), "the composer is back"); + assert_eq!(h.state().views[0].comments.composer.as_ref().map(|c| c.text.as_str()), Some("Draft")); +} + +/// Editing a large comment whose top-right corner is scrolled away still shows the composer +/// while the rest of the comment is on screen, so it can be posted or cancelled (#4095). +#[test] +fn the_edit_composer_shows_while_part_of_its_comment_is_on_screen() { + let mut h = harness(|app| app.set_option("quick", "square").unwrap()); + drag_pt(&mut h, (20.0, 190.0), (280.0, 10.0)); + // What "Edit text…" opens: the composer anchored at the comment's top-right corner. + h.state_mut().views[0].comments.composer = Some(pdfcraft_ui_egui::comments::Composer { + page: 0, + at: [280.0, 190.0], + kind: pdfcraft_ui_egui::comments::ComposerKind::Edit(0), + text: String::new(), + focus: true, + }); + h.run_steps(2); + let viewport = h.state().views[0].viewport_rect(); + h.state_mut().views[0].goto = Some((0, 0.4)); + h.run_steps(4); + assert!(at(&h, 280.0, 190.0).y < viewport.top(), "the comment's top-right corner is scrolled away"); + assert!(at(&h, 280.0, 10.0).y > viewport.top(), "part of the comment is still on screen"); + let editors: Vec<_> = h.query_all_by(|n| n.role() == egui::accesskit::Role::MultilineTextInput).map(|n| n.rect()).collect(); + assert!(editors.iter().any(|r| viewport.contains(r.center())), "the editor is drawn: {editors:?} (document {viewport:?})"); + h.get_by_label("Cancel").click(); + h.run_steps(2); + assert!(h.state().views[0].comments.composer.is_none(), "Cancel closes it"); +} + +/// With its whole page scrolled off the screen the composer keeps its draft, and the editor takes +/// the keyboard again when the page comes back (#4021). +#[test] +fn the_composer_takes_the_keyboard_back_after_its_page_was_off_screen() { + let mut h = harness(|app| app.set_option("quick", "note").unwrap()); + click_pt(&mut h, (250.0, 180.0)); + field(&h, "Add a comment").type_text("Draft"); + h.run_steps(2); + let editor_focused = + |h: &Harness<'static, PdfCraftApp>| h.query_all_by(|n| n.role() == egui::accesskit::Role::MultilineTextInput).any(|n| n.is_focused()); + assert!(editor_focused(&h), "the new note's editor has the keyboard"); + let viewport = h.state().views[0].viewport_rect(); + h.state_mut().views[0].goto = Some((1, 0.6)); + h.run_steps(4); + assert!(h.state().views[0].page_screen_rect(0).is_none_or(|r| r.bottom() < viewport.top()), "page 1 is off the screen"); + assert!(!editor_focused(&h), "nothing to type into while the page is away"); + h.state_mut().views[0].goto = Some((0, 0.0)); + h.run_steps(4); + assert_eq!(h.state().views[0].comments.composer.as_ref().map(|c| c.text.as_str()), Some("Draft")); + assert!(editor_focused(&h), "the editor has the keyboard again"); +} + /// The rectangle of the comment whose text starts with `text`. fn rect_of(h: &Harness<'static, PdfCraftApp>, text: &str) -> [f32; 4] { let c = comments(h); @@ -850,6 +959,50 @@ fn line_ends_are_dragged_to_change_length_and_angle() { assert!((p[1][0] - 200.0).abs() < 1.0 && (p[1][1] - 160.0).abs() < 1.0, "{p:?}"); } +/// The arrow keys move the selected comment a screen pixel, ⌘/Ctrl or Shift and an arrow ten, +/// instead of scrolling or turning the page; each press is one undo step. Without a selection +/// the arrows scroll and turn pages as before. +#[test] +fn arrow_keys_nudge_the_selected_comment() { + use egui::{Key, Modifiers}; + let mut h = harness(|app| app.set_option("quick", "square").unwrap()); + drag_pt(&mut h, (40.0, 100.0), (140.0, 40.0)); + h.state_mut().set_option("quick", "select").unwrap(); + click_pt(&mut h, (90.0, 70.0)); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0))); + // One screen pixel in user space, at this zoom. + let pixel = 300.0 / h.state().views[0].page_screen_rect(0).unwrap().width(); + let before = comments(&h)[0].rect; + h.key_press(Key::ArrowRight); + h.run_steps(3); + let r = comments(&h)[0].rect; + assert!((r[0] - before[0] - pixel).abs() < 0.01 && (r[1] - before[1]).abs() < 0.01, "one pixel right: {r:?} from {before:?}"); + assert_eq!(h.state().views[0].current, 0, "the page didn't turn"); + let id = h.state().views[0].id; + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Move comment")); + // ⌘/Ctrl and Shift: ten pixels (down on screen is down in user space here). + h.key_press_modifiers(Modifiers::COMMAND, Key::ArrowDown); + h.run_steps(3); + let r2 = comments(&h)[0].rect; + assert!((r[1] - r2[1] - 10.0 * pixel).abs() < 0.01 && (r2[0] - r[0]).abs() < 0.01, "ten pixels down: {r2:?} from {r:?}"); + h.key_press_modifiers(Modifiers::SHIFT, Key::ArrowLeft); + h.run_steps(3); + let r3 = comments(&h)[0].rect; + assert!((r2[0] - r3[0] - 10.0 * pixel).abs() < 0.01, "ten pixels left: {r3:?} from {r2:?}"); + h.state_mut().undo(); + h.run_steps(2); + assert!((comments(&h)[0].rect[0] - r2[0]).abs() < 0.01, "each press is one undo step"); + // Nothing selected: → turns the page again. + h.key_press(Key::Escape); + h.run_steps(2); + assert_eq!(h.state().views[0].comments.selected, None); + let rect = comments(&h)[0].rect; + h.key_press(Key::ArrowRight); + h.run_steps(3); + assert_eq!(comments(&h)[0].rect, rect, "the comment stays"); + assert_eq!(h.state().views[0].current, 1, "and the view goes to the next page"); +} + #[test] fn a_shape_just_drawn_resizes_from_its_handles_without_the_select_tool() { // #2338: the new shape showed its handles, but only the Select tool answered them. @@ -1398,6 +1551,47 @@ fn page_groups_collapse_and_expand() { assert!(shown(&h, "First page note") && shown(&h, "Second page note"), "every page is open again"); } +/// Flatten comments is in the page's right-click menu and in the Comments panel's "…" menu: the +/// comments go into the page, one undo step brings them back. +#[test] +fn flatten_comments_from_the_page_menu_and_the_panel_menu() { + let mut h = harness(|app| { + app.set_option("panel", "comments").unwrap(); + app.set_option("quick", "select").unwrap(); + }); + let shape = pdfcraft_engine::Shape::Rectangle { rect: [20.0, 20.0, 80.0, 60.0] }; + h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { + page: 0, + style: pdfcraft_engine::Style::default_for(&shape), + shape, + contents: "Reviewed".into(), + author: "Tester".into(), + })); + h.state_mut().views[0].comments.selected = None; + crate::support::settle(&mut h); + assert_eq!(comments(&h).len(), 1); + + // Right-click on the page, away from the comment. + let p = at(&h, 200.0, 150.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("Flatten comments").click(); + h.run_steps(4); + assert!(comments(&h).is_empty(), "the comment went into the page"); + h.state_mut().undo(); + h.run_steps(3); + assert_eq!(comments(&h).len(), 1, "one undo step brings it back"); + + h.get_by_label("More options").click(); + h.run_steps(2); + h.get_by_label("Flatten comments").click(); + h.run_steps(4); + assert!(comments(&h).is_empty(), "the panel's menu flattens too"); +} + #[test] fn comments_take_checkmarks_lock_hide_and_summarize() { let mut h = harness(|app| { diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index dc13bdb615..0d7253707e 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -702,6 +702,76 @@ fn format_text_panel_sets_alignment_and_lists_planned_controls_disabled() { assert!(h.state().text_style.bold); } +/// Opening right-aligned added text to edit it shows it right-aligned, as the page does, not at +/// the box's left edge, where it never was. +#[test] +fn the_text_editor_keeps_the_boxs_alignment() { + let _gpu = gpu(); + let mut h = harness(); + let rect = [40.0, 195.0, 240.0, 210.0]; + let text = pdfcraft_engine::AddedText { rect, text: "Hi".into(), align: pdfcraft_engine::TextAlign::Right, ..Default::default() }; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text })); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + let point = at(&h, 200.0, 195.0); + click(&mut h, point); + assert_eq!(h.state().views[0].content.draft.as_ref().and_then(|d| d.index), Some(0), "the editor opened on the text"); + h.run_steps(3); + let r = item_on_screen(&h, rect); + let pixels = h.render().unwrap(); + let left = ink(&pixels, egui::Rect::from_min_max(r.min, pos2(r.center().x, r.max.y))); + let right = ink(&pixels, egui::Rect::from_min_max(pos2(r.center().x, r.min.y), r.max)); + assert!(left == 0 && right > 20, "the text is drawn at the right ({left} dark pixels left, {right} right)"); +} + +/// A box narrower than the editor's least width (60 points on screen): right-aligned text stays +/// at the box's right edge, not the wider editor's, which lies past it. +#[test] +fn the_text_editor_aligns_a_narrow_box_to_its_own_edge() { + let _gpu = gpu(); + let mut h = harness(); + let rect = [40.0, 195.0, 60.0, 210.0]; + let text = pdfcraft_engine::AddedText { rect, text: "Hi".into(), align: pdfcraft_engine::TextAlign::Right, ..Default::default() }; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { page: 0, text })); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + let point = at(&h, 50.0, 195.0); + click(&mut h, point); + assert_eq!(h.state().views[0].content.draft.as_ref().and_then(|d| d.index), Some(0), "the editor opened on the text"); + h.run_steps(3); + let r = item_on_screen(&h, rect); + assert!(r.width() < 60.0, "the box is narrower than the editor: {}", r.width()); + let pixels = h.render().unwrap(); + let inside = ink(&pixels, r); + let edge = ink(&pixels, egui::Rect::from_min_max(pos2(r.right() - r.width() * 0.25, r.min.y), r.max)); + let past = ink(&pixels, egui::Rect::from_min_max(pos2(r.right() + 2.0, r.min.y), pos2(r.right() + 40.0, r.max.y))); + assert!( + inside > 20 && edge > 0 && past == 0, + "the text is drawn in its box, up to its right edge ({inside} dark pixels in it, {edge} in its last quarter, {past} past it)" + ); +} + +/// Added text takes character spacing from Format text (#4245), typed like a paragraph's; its +/// line spacing and horizontal scale stay disabled until added text has them. +#[test] +fn format_text_sets_character_spacing_for_added_text() { + use egui::accesskit::Role; + use egui_kittest::kittest::NodeT; + let mut h = harness(); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + let disabled = |h: &Harness<'static, PdfCraftApp>, label: &str| h.get_by_role_and_label(Role::SpinButton, label).accesskit_node().is_disabled(); + assert!(disabled(&h, "Horizontal scale"), "added text has no scale yet"); + assert!(!disabled(&h, "Character spacing"), "added text takes character spacing"); + h.get_by_role_and_label(Role::SpinButton, "Character spacing").click(); + h.run_steps(2); + h.event(egui::Event::Text("2.5".into())); + h.run_steps(1); + h.key_press(egui::Key::Enter); + h.run_steps(4); + assert_eq!(h.state().text_style.char_spacing, 2.5); +} + /// Where a mode bar tab is, by its label. fn mode_tab(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Rect { h.get_all_by_role_and_label(egui::accesskit::Role::Button, label).map(|n| n.rect()).find(|r| r.bottom() < 120.0).expect("a mode tab") diff --git a/crates/ui-egui/tests/it/control.rs b/crates/ui-egui/tests/it/control.rs index ec29ecc973..7099ece40b 100644 --- a/crates/ui-egui/tests/it/control.rs +++ b/crates/ui-egui/tests/it/control.rs @@ -88,6 +88,25 @@ fn ok(h: &mut Harness<'static, PdfCraftApp>, c: &ControlClient, method: &str, pa call(h, c, method, params).unwrap_or_else(|e| panic!("{method}: {e}")) } +#[test] +fn comment_pin_is_reported_and_persistent() { + let (mut h, c) = harness(); + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["comment_tool_pinned"], false); + for pinned in [true, false] { + ok(&mut h, &c, "ui.set", json!({"key":"comment-tool-pinned","value":pinned})); + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["comment_tool_pinned"], pinned); + let mut restored = PdfCraftApp::new(); + restored.comment_prefs.pinned = !pinned; + restored.restore(&h.state().persist()); + assert_eq!(restored.comment_prefs.pinned, pinned); + for invalid in [json!("yes"), json!(1), json!(null), json!([]), json!({})] { + assert!(call(&mut h, &c, "ui.set", json!({"key":"comment-tool-pinned","value":invalid})).is_err()); + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["comment_tool_pinned"], pinned); + } + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["documents"][0]["dirty"], false); + } +} + #[test] fn thumbnail_size_is_settable_and_inspectable_through_the_control_channel() { let (mut h, c) = harness(); @@ -256,6 +275,65 @@ fn control_quit_preserves_dirty_documents_and_cancel_keeps_them_open() { assert!(!close_requested(&h)); } +/// Run one frame and return the attention requests it sent to the window. +fn step_attention(h: &mut Harness<'static, PdfCraftApp>) -> Vec { + h.step(); + let commands = h.output().viewport_output.values().flat_map(|v| v.commands.iter()); + commands.filter_map(|c| if let egui::ViewportCommand::RequestUserAttention(a) = c { Some(*a) } else { None }).collect() +} + +/// Send a request, run frames until it is answered and three more, and collect every attention +/// request those frames sent. +fn attention_during(h: &mut Harness<'static, PdfCraftApp>, c: &ControlClient, method: &str, params: Value) -> Vec { + let rx = c.send(method, params); + let mut sent = Vec::new(); + for _ in 0..30 { + sent.extend(step_attention(h)); + if rx.try_recv().is_ok() { + (0..3).for_each(|_| sent.extend(step_attention(h))); + return sent; + } + } + panic!("{method}: no reply after 30 frames"); +} + +/// Open `name` with an unsaved change, in a tab of its own. +fn open_unsaved(h: &mut Harness<'static, PdfCraftApp>, name: &str) { + let app = h.state_mut(); + app.open_bytes(name, None, fixture(1)).unwrap(); + app.active = Some(app.views.len() - 1); + app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 }); +} + +/// An agent quits while another app is in front, then answers the prompts itself. Quitting asks +/// about each unsaved document in turn; the request made for the first question stands for the +/// next, and answering the last withdraws it, so the taskbar or the window list stops flagging a +/// window with nothing to ask. Each question is a new one: when the first opened in front, the +/// next one asks when it opens in the background. +#[test] +fn a_save_prompt_answered_in_the_background_withdraws_its_attention_request() { + use egui::UserAttentionType::{Critical, Reset}; + let (mut h, c) = harness(); + ok(&mut h, &c, "ui.command", json!({"id": "page.insert_blank"})); + open_unsaved(&mut h, "second.pdf"); + h.input_mut().focused = false; + assert_eq!(attention_during(&mut h, &c, "ui.quit", json!({})), [Critical]); + assert!(attention_during(&mut h, &c, "ui.click", json!({"label": "Don't save"})).is_empty(), "the request stands"); + assert_eq!(attention_during(&mut h, &c, "ui.click", json!({"label": "Cancel"})), [Reset]); + + open_unsaved(&mut h, "third.pdf"); + h.input_mut().focused = true; + assert!(attention_during(&mut h, &c, "ui.quit", json!({})).is_empty(), "the first question opens in front"); + h.input_mut().focused = false; + assert!(attention_during(&mut h, &c, "ui.state", json!({})).is_empty(), "leaving it open asks nothing"); + assert_eq!(attention_during(&mut h, &c, "ui.click", json!({"label": "Don't save"})), [Critical], "the next opens in the background"); + assert_eq!(attention_during(&mut h, &c, "ui.click", json!({"label": "Cancel"})), [Reset]); + let state = ok(&mut h, &c, "ui.state", json!({})); + assert_eq!(state["close_prompt"], false); + let left: Vec<_> = state["documents"].as_array().unwrap().iter().map(|d| (d["name"].clone(), d["dirty"].clone())).collect(); + assert_eq!(left, [(json!("third.pdf"), json!(true))], "Don't save closed doc.pdf and second.pdf; Cancel kept third.pdf"); +} + #[test] fn control_quit_clean_document_requests_normal_window_close() { for params in [json!({}), Value::Null] { @@ -1366,6 +1444,58 @@ fn a_1_to_50_scale_in_millimetres_measures_a_10_mm_line_as_500_mm() { assert_eq!(measurements[0].reading.unit, "mm"); } +#[test] +fn a_plan_scale_picked_or_typed_applies_at_once() { + use pdfcraft_engine::measure::{self, Kind}; + let (mut h, c) = harness_pages(1); + let doc = h.state().views[0].id; + ok(&mut h, &c, "ui.command", json!({"id":"measure.scale"})); + h.run_steps(3); + h.state_mut().views[0].measure.unit = "m".into(); + h.run_steps(2); + // 60 mm of paper. + let line = [[20.0, 20.0], [20.0 + 60.0 / 25.4 * 72.0, 20.0]]; + let read = |h: &Harness<'static, PdfCraftApp>| { + let scale = h.state().session.get(doc).unwrap().measurement_scale(0, line[0]).unwrap(); + (measure::reading(Kind::Distance, &line, &scale).unwrap().label, scale.ratio) + }; + // Picked from the common scales: no Apply click. + h.get_by(|n| n.role() == egui::accesskit::Role::ComboBox && n.value().is_some_and(|v| v == "Common scales")).click(); + h.run_steps(2); + h.get_by_label("1:200").click_accesskit(); + h.run_steps(3); + assert_eq!(read(&h), ("12.00 m".to_string(), "1 mm = 0.2 m".to_string())); + // Typed, in the other notations; leaving the field applies. + fn field<'a>(h: &'a Harness<'static, PdfCraftApp>) -> egui_kittest::Node<'a> { + h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Scale") + } + for (typed, label) in [("1/50", "3.00 m"), ("1:100", "6.00 m")] { + field(&h).click(); + h.run_steps(1); + h.state_mut().views[0].measure.ratio_text.clear(); + field(&h).type_text(typed); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(read(&h).0, label, "{typed}"); + } + // Nonsense is refused with a message and leaves the page scale alone. + let before = read(&h); + field(&h).click(); + h.run_steps(1); + h.state_mut().views[0].measure.ratio_text.clear(); + field(&h).type_text("1:0"); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(read(&h), before); + assert!(h.state().views[0].measure.error.is_some()); + // The scale is one undoable edit. + ok(&mut h, &c, "ui.command", json!({"id":"edit.undo"})); + h.run_steps(2); + assert_ne!(read(&h), before); +} + #[test] fn docking_pointer_group_float_close_restore_and_document_commands() { use pdfcraft_ui_egui::docking::Panel; @@ -1488,6 +1618,22 @@ fn explicit_panel_requests_reveal_inactive_group_members() { h.get_by_label("Header and footer"); } +/// #3580 review: a panel a script opens (`app.execMenuItem("ShowHideBookmarks")`) is shown even +/// when the inspector is a background tab of a custom docking layout. +#[test] +fn a_script_opened_panel_reveals_an_inactive_inspector() { + let (mut h, c) = harness(); + ok(&mut h, &c, "ui.set", json!({"key":"panel", "value":"pages"})); + ok(&mut h, &c, "ui.dock", json!({"operation":"move", "panel":"inspector", "target":"tools", "zone":"center"})); + ok(&mut h, &c, "ui.dock", json!({"operation":"activate", "panel":"tools"})); + h.run_steps(3); + assert!(h.query_by_label("This document has no bookmarks.").is_none()); + let id = h.state().active_ids().unwrap().1; + h.state_mut().run_button_script(id, "", "app.execMenuItem('ShowHideBookmarks')"); + h.run_steps(4); + h.get_by_label("This document has no bookmarks."); +} + #[test] fn home_and_combine_suppress_inspector_without_forgetting_layout_or_choice() { use pdfcraft_ui_egui::docking::Panel; @@ -1674,7 +1820,9 @@ fn control_open_replies_pending_while_the_worker_opens_and_focuses_once_it_has() assert_eq!(first["pending"], true, "{first}"); // The worker completes the open on later frames; the tab shows, and only then does a - // repeated open focus it instead of reporting another pending open. + // repeated open focus it instead of reporting another pending open. The worker is a thread + // of its own, so the wait is bounded by time: a busy runner can draw 30 frames before it has + // read the file. let mut documents = 0; let opened = crate::support::step_until(&mut h, |h| { let state = ok(h, &c, "ui.state", json!({})); diff --git a/crates/ui-egui/tests/it/create.rs b/crates/ui-egui/tests/it/create.rs index 711884f8a0..a1f368922d 100644 --- a/crates/ui-egui/tests/it/create.rs +++ b/crates/ui-egui/tests/it/create.rs @@ -239,6 +239,19 @@ fn the_pdf_optimizer_dialog_picks_a_preset() { h.run_steps(2); assert!(!h.state().optimize_draft.settings.merge_fragments); h.get_by_value("Custom"); + + // Pick a target PDF version and toggle structure compression on Clean Up. + h.get_by_value("Retain existing").click(); + h.run_steps(4); + h.get_by_label("PDF 2.0").click(); + h.run_steps(2); + assert_eq!(h.state().optimize_draft.settings.compatibility, pdfcraft_engine::optimize::Compatibility::Pdf2_0); + h.get_by_label("Clean Up").click(); + h.run_steps(2); + assert!(h.state().optimize_draft.settings.object_streams); + h.get_by_label("Compress document structure (object streams)").click(); + h.run_steps(2); + assert!(!h.state().optimize_draft.settings.object_streams); } fn mixed_files(app: &PdfCraftApp) -> Vec<(String, Vec)> { diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 54b99d619d..a6523184a9 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -791,6 +791,50 @@ fn quitting_with_unsaved_changes_asks_for_each_document() { assert!(h.state().close_request.is_none()); } +/// Run `frames` frames and collect every attention request they sent to the window. +fn attention_over(h: &mut Harness<'static, PdfCraftApp>, frames: usize) -> Vec { + let mut sent = Vec::new(); + for _ in 0..frames { + h.step(); + let commands = h.output().viewport_output.values().flat_map(|v| v.commands.iter()); + sent.extend(commands.filter_map(|c| if let egui::ViewportCommand::RequestUserAttention(a) = c { Some(*a) } else { None })); + } + sent +} + +/// Close the window as the operating system does: Quit in the Dock menu, or logging out. +fn close_window(h: &mut Harness<'static, PdfCraftApp>) { + h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close); +} + +/// Quit from the Dock while another app is in front: the prompt opens in a window the user can't +/// see, so the Dock icon bounces (the taskbar button flashes) until they come back. A prompt that +/// opens in front asks for nothing. +#[test] +fn a_save_prompt_opened_in_the_background_asks_for_attention_until_the_user_returns() { + use egui::UserAttentionType::{Critical, Reset}; + let mut h = harness(2, |_| {}); + h.state_mut().views[0].select_pages(&[0]); + h.state_mut().apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 }); + h.input_mut().focused = false; + close_window(&mut h); + assert_eq!(attention_over(&mut h, 4), [Critical], "asked once, when the prompt opened"); + assert_eq!(h.state().close_request, Some(CloseRequest::Quit)); + h.input_mut().focused = true; + assert_eq!(attention_over(&mut h, 3), [Reset], "coming back withdraws the request"); + h.get_by_label_contains("Save changes to “doc.pdf”"); + h.get_by_label("Cancel").click(); + assert!(attention_over(&mut h, 3).is_empty()); + assert_eq!(h.state().views.len(), 1, "cancel keeps the document open"); + // With PdfCraft in front, the prompt is in plain sight. + close_window(&mut h); + assert!(attention_over(&mut h, 4).is_empty()); + h.get_by_label_contains("Save changes to “doc.pdf”"); + // Leaving with the prompt open doesn't ask either: the user has seen it. + h.input_mut().focused = false; + assert!(attention_over(&mut h, 3).is_empty()); +} + /// PDFs written to a fresh temp folder, removed when the test ends. struct TempPdfs(std::path::PathBuf); @@ -1109,6 +1153,216 @@ fn cancelling_properties_discards_the_draft() { assert!(h.state().props_draft.is_none()); } +fn custom_properties(h: &Harness<'static, PdfCraftApp>) -> Vec<(String, String)> { + let app = h.state(); + app.session.get(app.views[0].id).unwrap().custom_info() +} + +fn type_into(h: &mut Harness<'static, PdfCraftApp>, label: &str, text: &str) { + let field = h.get_by_role_and_label(Role::TextInput, label); + field.focus(); + field.type_text(text); + h.run_steps(2); +} + +fn clear(h: &mut Harness<'static, PdfCraftApp>, label: &str) { + h.get_by_role_and_label(Role::TextInput, label).focus(); + h.run_steps(1); + h.key_press_modifiers(Modifiers::COMMAND, Key::A); + h.key_press(Key::Backspace); + h.run_steps(2); +} + +#[test] +fn custom_properties_are_added_in_the_custom_tab_as_one_undoable_step() { + let mut h = harness(1, |app| app.set_option("dialog", "custom").unwrap()); + h.get_by_label("None"); + type_into(&mut h, "Name:", "Project"); + type_into(&mut h, "Value:", "Apollo"); + h.get_by_role_and_label(Role::Button, "Add").click(); + h.run_steps(3); + assert!(custom_properties(&h).is_empty(), "nothing is written before OK"); + h.get_by_label("Project"); + type_into(&mut h, "Name:", "Revision"); + type_into(&mut h, "Value:", "4"); + h.get_by_role_and_label(Role::Button, "Add").click(); + h.run_steps(3); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(custom_properties(&h), [("Project".to_string(), "Apollo".to_string()), ("Revision".to_string(), "4".to_string())]); + let app = h.state(); + assert_eq!(app.session.get(app.views[0].id).unwrap().can_undo(), Some("Change document properties")); + assert!(app.dialog.is_none() && app.custom_draft.is_none()); + + h.key_press_modifiers(Modifiers::COMMAND, Key::Z); + h.run_steps(3); + assert!(custom_properties(&h).is_empty()); +} + +#[test] +fn custom_properties_are_changed_and_deleted_in_the_custom_tab() { + let mut h = harness(1, |app| { + for (key, value) in [("Project", "Apollo"), ("Revision", "4")] { + assert!(app.apply_edit(pdfcraft_engine::Edit::SetInfo { key: key.into(), value: value.into() })); + } + app.set_option("dialog", "custom").unwrap(); + }); + h.get_by_label("Revision").click(); + h.run_steps(3); + h.get_by_role_and_label(Role::Button, "Delete").click(); + h.run_steps(3); + h.get_by_label("Project").click(); + h.run_steps(3); + assert!(h.get_by_role_and_label(Role::Button, "Update").accesskit_node().is_disabled(), "the value is unchanged"); + clear(&mut h, "Value:"); + type_into(&mut h, "Value:", "Apollo 2"); + h.get_by_role_and_label(Role::Button, "Update").click(); + h.run_steps(3); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(custom_properties(&h), [("Project".to_string(), "Apollo 2".to_string())]); + let app = h.state(); + let doc = app.session.get(app.views[0].id).unwrap(); + assert_eq!(doc.info_value("Revision"), None); + assert_eq!(doc.can_undo(), Some("Change document properties")); +} + +#[test] +fn the_custom_tab_refuses_names_a_custom_property_cannot_have() { + let mut h = harness(1, |app| { + assert!(app.apply_edit(pdfcraft_engine::Edit::SetInfo { key: "Project".into(), value: "Apollo".into() })); + app.set_option("dialog", "custom").unwrap(); + }); + type_into(&mut h, "Value:", "x"); + let add_disabled = |h: &Harness<'static, PdfCraftApp>| h.get_by_role_and_label(Role::Button, "Add").accesskit_node().is_disabled(); + assert!(add_disabled(&h), "no name"); + // A standard name, a name with a space, a delimiter, and a name already listed. + for name in ["Title", "Client name", "R&D/2", "Project"] { + type_into(&mut h, "Name:", name); + assert!(add_disabled(&h), "{name} is refused"); + clear(&mut h, "Name:"); + } + type_into(&mut h, "Name:", "Client"); + assert!(!add_disabled(&h)); + assert!(h.get_by_role_and_label(Role::Button, "Delete").accesskit_node().is_disabled(), "Client is not listed"); +} + +#[test] +fn a_custom_property_whose_name_the_tools_refuse_is_listed_and_left_alone() { + // `/Client#20name` and `/Project#20` are valid PDF names with a space in them, which a new custom + // property can't have; `/Project#20` is not `/Project`. `/Dept#E9` is not UTF-8 and is listed as + // "Dept\u{FFFD}". `/Revision` is a number, so it isn't listed. + let pdf = String::from_utf8(fixture(1)).unwrap().replace( + "/Root 1 0 R >>\nstartxref", + "/Root 1 0 R /Info << /Client#20name (Acme) /Project#20 (Padded) /Dept#E9 (Sales) /Project (Apollo) /Revision 4 >> >>\nstartxref", + ); + 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, pdf.into_bytes()).expect("fixture opens"); + app.set_option("dialog", "custom").unwrap(); + app + }); + h.run_steps(4); + for name in ["Client name", "Project ", "Dept\u{FFFD}"] { + h.get_by_label(name).click(); + h.run_steps(3); + for button in ["Update", "Delete"] { + assert!(h.get_by_role_and_label(Role::Button, button).accesskit_node().is_disabled(), "{button} is off for {name:?}"); + } + } + clear(&mut h, "Name:"); + type_into(&mut h, "Name:", "Revision"); + assert!(h.get_by_role_and_label(Role::Button, "Add").accesskit_node().is_disabled(), "Add would overwrite the number"); + assert_eq!(h.state().custom_draft.as_ref().map(|(_, d)| d.value.as_str()), Some("Sales")); + h.get_by_label("Project").click(); + h.run_steps(3); + clear(&mut h, "Value:"); + type_into(&mut h, "Value:", "Gemini"); + h.get_by_role_and_label(Role::Button, "Update").click(); + h.run_steps(3); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!( + custom_properties(&h), + [ + ("Client name".to_string(), "Acme".to_string()), + ("Project ".to_string(), "Padded".to_string()), + ("Dept\u{FFFD}".to_string(), "Sales".to_string()), + ("Project".to_string(), "Gemini".to_string()) + ] + ); + let app = h.state(); + let doc = app.session.get(app.views[0].id).unwrap(); + assert!(doc.has_info("Revision") && doc.info_value("Revision").is_none(), "the number is kept"); + assert!(app.dialog.is_none()); +} + +#[test] +fn a_custom_property_is_deleted_from_an_info_dictionary_in_the_trailer() { + let pdf = String::from_utf8(fixture(1)) + .unwrap() + .replace("/Root 1 0 R >>\nstartxref", "/Root 1 0 R /Info << /Project (Apollo) /Revision (4) >> >>\nstartxref"); + 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, pdf.into_bytes()).expect("fixture opens"); + app.set_option("dialog", "custom").unwrap(); + app + }); + h.run_steps(4); + h.get_by_label("Project").click(); + h.run_steps(3); + h.get_by_role_and_label(Role::Button, "Delete").click(); + h.run_steps(3); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(custom_properties(&h), [("Revision".to_string(), "4".to_string())]); + let app = h.state(); + assert_eq!(app.session.get(app.views[0].id).unwrap().can_undo(), Some("Change document properties")); +} + +#[test] +fn add_stops_at_the_256_custom_properties_the_tab_lists() { + let with_props = |count: usize| { + let entries: String = (0..count).map(|n| format!("/P{n:03} (v) ")).collect(); + let pdf = + String::from_utf8(fixture(1)).unwrap().replace("/Root 1 0 R >>\nstartxref", &format!("/Root 1 0 R /Info << {entries}>> >>\nstartxref")); + 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, pdf.into_bytes()).expect("fixture opens"); + app.set_option("dialog", "custom").unwrap(); + app + }); + h.run_steps(4); + h + }; + let add_disabled = |h: &Harness<'static, PdfCraftApp>| h.get_by_role_and_label(Role::Button, "Add").accesskit_node().is_disabled(); + + // 255 in the file: one more fits, a second does not. + let mut h = with_props(255); + type_into(&mut h, "Name:", "Client"); + type_into(&mut h, "Value:", "Acme"); + assert!(!add_disabled(&h)); + h.get_by_role_and_label(Role::Button, "Add").click(); + h.run_steps(3); + type_into(&mut h, "Name:", "Other"); + type_into(&mut h, "Value:", "x"); + assert!(add_disabled(&h), "the draft holds 256"); + assert_eq!(h.state().custom_draft.as_ref().map(|(_, d)| d.props.len()), Some(256)); + + // 257 in the file: after a Delete a new name would still be past the 256 listed. + let mut h = with_props(257); + h.get_by_label("P000").click(); + h.run_steps(3); + h.get_by_role_and_label(Role::Button, "Delete").click(); + h.run_steps(3); + type_into(&mut h, "Name:", "Client"); + type_into(&mut h, "Value:", "Acme"); + assert!(add_disabled(&h), "the file lists 256"); +} + #[test] fn edit_menu_names_the_step_to_undo() { let mut h = harness(2, |app| { @@ -2241,6 +2495,88 @@ fn split_before_selected_pages() { let _ = std::fs::remove_dir_all(dir); } +#[test] +fn split_from_organize_grid_context_menu() { + let dir = temp_path("split-org-ctx"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut h = organize(4); + h.state_mut().export_dir_override = Some(dir.to_string_lossy().into_owned()); + h.get_by_label("Page 3").click_button(egui::PointerButton::Secondary); + h.run_steps(2); + h.get_all_by_label("Split document…").last().unwrap().click(); + h.run_steps(3); + assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::Split)); + assert_eq!(h.state().split_draft.mode, pdfcraft_ui_egui::SplitMode::Selection); + h.get_by_label_contains("Creates 2 files from 4 pages"); + h.get_all_by_label("Split").last().unwrap().click(); + h.run_steps(3); + let mut names: Vec = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); + names.sort(); + assert_eq!(names, ["doc (pages 1-2).pdf", "doc (pages 3-4).pdf"]); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn split_from_pages_panel_context_menu() { + let mut h = harness(4, |app| app.right = Some(pdfcraft_ui_egui::RightPanel::Pages)); + let thumb = h.get_all_by_label("Page 3").max_by(|a, b| a.rect().left().total_cmp(&b.rect().left())).unwrap(); + thumb.click_button(egui::PointerButton::Secondary); + h.run_steps(2); + h.get_all_by_label("Split document…").last().unwrap().click(); + h.run_steps(3); + assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::Split)); + assert_eq!(h.state().split_draft.mode, pdfcraft_ui_egui::SplitMode::Selection); + h.get_by_label_contains("Creates 2 files from 4 pages"); +} + +#[test] +fn split_from_pages_panel_context_menu_on_current_page_without_selection() { + let mut h = harness(4, |app| app.right = Some(pdfcraft_ui_egui::RightPanel::Pages)); + // Navigate to Page 2 (0-indexed 1) so it is current, but keep selected empty + h.state_mut().views[0].go_to_page(1); + h.state_mut().views[0].selected.clear(); + h.run_steps(2); + assert_eq!(h.state().views[0].current, 1); + assert!(h.state().views[0].selected.is_empty()); + let thumb = h.get_all_by_label("Page 2").max_by(|a, b| a.rect().left().total_cmp(&b.rect().left())).unwrap(); + thumb.click_button(egui::PointerButton::Secondary); + h.run_steps(2); + h.get_all_by_label("Split document…").last().unwrap().click(); + h.run_steps(3); + assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::Split)); + assert_eq!(h.state().split_draft.mode, pdfcraft_ui_egui::SplitMode::Selection); + h.get_by_label_contains("Creates 2 files from 4 pages"); +} + +#[test] +fn split_from_pages_panel_context_menu_on_page_one_falls_back_to_pages() { + let mut h = harness(4, |app| app.right = Some(pdfcraft_ui_egui::RightPanel::Pages)); + let thumb = h.get_all_by_label("Page 1").max_by(|a, b| a.rect().left().total_cmp(&b.rect().left())).unwrap(); + thumb.click_button(egui::PointerButton::Secondary); + h.run_steps(2); + h.get_all_by_label("Split document…").last().unwrap().click(); + h.run_steps(3); + assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::Split)); + assert_eq!(h.state().split_draft.mode, pdfcraft_ui_egui::SplitMode::Pages); +} + +#[test] +fn split_from_pages_panel_context_menu_with_multi_selection() { + let mut h = harness(4, |app| app.right = Some(pdfcraft_ui_egui::RightPanel::Pages)); + // Select pages 2 and 4 (indices 1 and 3) + h.state_mut().views[0].select_pages(&[1, 3]); + h.run_steps(2); + let thumb = h.get_all_by_label("Page 2").max_by(|a, b| a.rect().left().total_cmp(&b.rect().left())).unwrap(); + thumb.click_button(egui::PointerButton::Secondary); + h.run_steps(2); + h.get_all_by_label("Split document…").last().unwrap().click(); + h.run_steps(3); + assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::Split)); + assert_eq!(h.state().split_draft.mode, pdfcraft_ui_egui::SplitMode::Selection); + h.get_by_label_contains("Creates 3 files from 4 pages"); +} + // ── Encrypted documents ─────────────────────────────────────────────────────────────────────── fn protected(user: &str, owner: &str, permissions: i32) -> Vec { @@ -2348,6 +2684,61 @@ fn the_security_notice_unlocks_with_the_permissions_password() { assert!(!h.state().session.get(h.state().active_ids().unwrap().1).unwrap().dirty, "unlocking is not an edit"); } +/// #4035: the secured-document notice can be turned off for good. "Don't show again" hides it +/// in every document, the choice is kept in the settings and turned back on in Preferences (or +/// `--secured-notice`); the document stays restricted and Properties ▸ Security still opens. +#[test] +fn the_security_notice_can_be_turned_off_for_good() { + let open = |settings: Option| { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + if let Some(s) = &settings { + app.restore(s); + } + app.set_option("language", "en").unwrap(); + app.open_bytes("locked.pdf", None, protected("", "owner", 0b0100)).unwrap(); + app + }); + h.run_steps(4); + h + }; + let mut h = open(None); + assert!(h.state().show_secured_notice, "on by default"); + h.get_by_label_contains("This document is secured"); + h.get_by_label("Don't show again").click(); + h.run_steps(2); + assert!(h.query_by_label_contains("This document is secured").is_none()); + assert!(!h.state().show_secured_notice); + let id = h.state().active_ids().unwrap().1; + assert!(h.state().session.get(id).unwrap().security_summary().is_some_and(|s| !s.owner), "still restricted"); + h.state_mut().set_option("dialog", "properties").unwrap(); + h.run_steps(2); + h.get_by_label("Security").click(); + h.run_steps(3); + h.get_by_label("Password security"); + h.get_by_label("User password"); + // Kept in the settings: a later session opens the same file without the notice. + let saved = h.state().persist(); + let mut h = open(Some(saved)); + assert!(h.query_by_label_contains("This document is secured").is_none(), "remembered across a restart"); + // Back on from Preferences. + h.state_mut().set_option("dialog", "preferences").unwrap(); + h.run_steps(2); + h.get_by_role_and_label(Role::CheckBox, "Show a notice when a document is secured").scroll_to_me(); + h.run_steps(4); + h.get_by_role_and_label(Role::CheckBox, "Show a notice when a document is secured").click(); + h.run_steps(2); + assert!(h.state().show_secured_notice); + h.state_mut().dialog = None; + h.run_steps(2); + h.get_by_label_contains("This document is secured"); + // Scriptable, with a clear refusal for anything else. + let app = h.state_mut(); + app.set_option("secured-notice", "false").unwrap(); + assert!(!app.show_secured_notice); + assert_eq!(app.set_option("secured-notice", "maybe"), Err("secured-notice must be true or false".into())); +} + /// Changing a restricted document's security asks for its permissions password first (Acrobat /// does too), then carries on. #[test] @@ -2433,6 +2824,11 @@ fn copying_out_of_a_document_honours_its_security() { app.system_clipboard = false; app.open_bytes("no-copy.pdf", None, protected("pw", "owner", -1 ^ 0b1_0000)).unwrap(); app.submit_password(Some(password.into())); + // One page at a time: Select All takes that page's text (scrolling continuously it would + // take both pages', #2639; what is tested here is the security of copying it). + if let Some(v) = app.views.first_mut() { + v.set_continuous(false); + } app }); // Select the page's text once its text layer is loaded (up to ~4 s: a loaded test machine @@ -3298,17 +3694,19 @@ fn editing_existing_text_in_place() { assert_eq!(texts_of(s, 0), ["Chapter One"], "the page shows it"); } -/// The Read mode command (View ▸ Read mode, its shortcut, the palette) leaves Edit as the Edit tab -/// does when another tab is chosen (#2241): Edit text & images is put down, and a paragraph still -/// open in the editor is applied instead of staying open, editable, over the page in Read mode. -#[test] -fn the_read_mode_command_applies_an_open_paragraph_and_puts_edit_text_down() { +/// Under the Edit tab, opens the line "Page 1" (at (20, 150) on a 200 × 300 page, 24 pt) in the +/// paragraph editor and retypes it as "Chapter One", leaving the editor open. +fn edit_tab_with_an_open_paragraph(h: &mut Harness<'static, PdfCraftApp>) { use pdfcraft_ui_egui::{Mode, QuickTool}; - let mut h = harness(1, |_| {}); h.get_by_label("Edit").click(); h.run_steps(3); assert_eq!((h.state().mode, h.state().quick_tool), (Mode::Edit, QuickTool::EditText)); - // Open the line "Page 1" (at (20, 150) on a 200 × 300 page, 24 pt) and retype it. + open_the_first_line(h); +} + +/// With Edit text & images on, opens the line "Page 1" in the paragraph editor and retypes it as +/// "Chapter One", leaving the editor open. +fn open_the_first_line(h: &mut Harness<'static, PdfCraftApp>) { let r = h.state().views[0].page_screen_rect(0).expect("on screen"); let at = egui::pos2(r.left() + 40.0 / 200.0 * r.width(), r.top() + (300.0 - 158.0) / 300.0 * r.height()); h.hover_at(at); @@ -3320,18 +3718,70 @@ fn the_read_mode_command_applies_an_open_paragraph_and_puts_edit_text_down() { assert_eq!(h.state().views[0].line_editor.as_ref().expect("the line opens for editing").text, "Page 1"); h.state_mut().views[0].line_editor.as_mut().unwrap().text = "Chapter One".into(); h.run_steps(1); - assert!(h.state_mut().execute("view.read_mode")); - h.run_steps(3); - let s = h.state(); - assert_eq!(s.mode, Mode::Read); - assert_eq!(s.quick_tool, QuickTool::Select, "Edit text & images is put down"); - assert!(s.views[0].line_editor.is_none(), "no paragraph editor is left open in Read mode"); +} + +/// Edit was left with the paragraph [`edit_tab_with_an_open_paragraph`] opened: Edit text & images +/// is put down, and the paragraph's edit is applied instead of its editor staying open over the page. +fn assert_edit_text_put_down_and_the_paragraph_applied(s: &PdfCraftApp) { + assert_eq!(s.quick_tool, pdfcraft_ui_egui::QuickTool::Select, "Edit text & images is put down"); + assert!(s.views[0].line_editor.is_none(), "no paragraph editor is left open"); let doc = s.session.get(s.views[0].id).unwrap(); assert_eq!(doc.can_undo(), Some("Edit text"), "the open paragraph's edit is applied"); assert_eq!(doc.text_lines(0)[0].text, "Chapter One"); assert_eq!(texts_of(s, 0), ["Chapter One"], "the page shows it"); } +/// The Read mode command (View ▸ Read mode, its shortcut, the palette) leaves Edit as the Edit tab +/// does when another tab is chosen (#2241): Edit text & images is put down, and a paragraph still +/// open in the editor is applied instead of staying open, editable, over the page in Read mode. +#[test] +fn the_read_mode_command_applies_an_open_paragraph_and_puts_edit_text_down() { + let mut h = harness(1, |_| {}); + edit_tab_with_an_open_paragraph(&mut h); + assert!(h.state_mut().execute("view.read_mode")); + h.run_steps(3); + assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Read); + assert_edit_text_put_down_and_the_paragraph_applied(h.state()); +} + +/// The Edit panel's back arrow (it has one when opened from All tools; the Edit tab is its own way +/// back) leaves for All tools as the mode bar does: Edit text & images is put down and a paragraph +/// still open in the editor is applied, instead of every paragraph keeping its box, and the editor +/// staying open, under All tools. +#[test] +fn the_edit_panel_back_arrow_applies_an_open_paragraph_and_puts_edit_text_down() { + use pdfcraft_ui_egui::{LeftPanel, Mode, QuickTool}; + let mut h = harness(1, |_| {}); + // All tools ▸ Edit a PDF ▸ Edit text & images: the Edit panel, under All tools. + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(3); + assert_eq!((h.state().mode, h.state().quick_tool), (Mode::AllTools, QuickTool::EditText)); + open_the_first_line(&mut h); + h.get_by_label("Back to all tools").click(); + h.run_steps(3); + assert_eq!((h.state().mode, h.state().left), (Mode::AllTools, LeftPanel::AllTools)); + assert_edit_text_put_down_and_the_paragraph_applied(h.state()); +} + +/// Organize pages, opened under the Edit tab, leaves Edit the same way through its own back arrow. +#[test] +fn the_organize_back_arrow_applies_an_open_paragraph_and_puts_edit_text_down() { + use pdfcraft_ui_egui::{LeftPanel, Mode}; + let mut h = harness(1, |_| {}); + edit_tab_with_an_open_paragraph(&mut h); + // What the Edit panel's Organize pages row runs. + assert!(h.state_mut().execute("page.organize")); + h.run_steps(3); + assert!(h.state().views[0].organize, "the page grid opens"); + assert!(h.state().views[0].line_editor.is_some(), "the paragraph is still open behind the grid"); + h.get_by_label("Back to all tools").click(); + h.run_steps(3); + let s = h.state(); + assert_eq!((s.mode, s.left), (Mode::AllTools, LeftPanel::AllTools)); + assert!(!s.views[0].organize, "the grid closes"); + assert_edit_text_put_down_and_the_paragraph_applied(s); +} + #[test] fn text_tool_clicking_source_text_edits_instead_of_adding_a_copy() { let mut h = harness(1, |_| {}); @@ -4239,6 +4689,30 @@ fn an_ordinary_paragraph_drag_refusal_never_rewrites_the_text() { assert!(h.state().toast.as_ref().unwrap().0.contains("Text used as a clipping path cannot be moved with this selection.")); } +/// A delete the engine refuses (text used as a clipping path) leaves the document as it was and +/// keeps the selection, so the user can still move or nudge what they had selected. +#[test] +fn a_refused_delete_keeps_the_object_selection() { + let content = "q BT 4 Tr /F1 12 Tf 60 320 Td (Clipping text) Tj ET Q"; + let mut h = paragraph_drag_harness(paragraph_drag_fixture(content, 0, 1), 0); + let id = h.state().views[0].id; + let (generation, bytes) = { + let doc = h.state().session.get(id).unwrap(); + (doc.edit_generation(), doc.bytes.clone()) + }; + // An area selection from empty page space around the paragraph. + let (center, _) = paragraph_drag_points(&h, "Clipping text", [0.0, 0.0]); + drag(&mut h, center - egui::vec2(60.0, 25.0), center + egui::vec2(60.0, 25.0)); + h.get_by_label("1 object selected"); + h.key_press(Key::Delete); + h.run_steps(4); + let doc = h.state().session.get(id).unwrap(); + assert_eq!((doc.edit_generation(), &doc.bytes), (generation, &bytes), "the refused delete changed nothing"); + assert!(doc.can_undo().is_none()); + assert!(h.state().toast.is_some(), "the refusal is reported"); + h.get_by_label("1 object selected"); +} + #[test] fn an_ordinary_paragraph_drag_cannot_reuse_an_index_after_an_edit() { let content = "BT /F1 12 Tf 60 120 Td (Discard) Tj ET BT /F1 12 Tf 60 320 Td (Moving alpha) Tj ET BT /F1 12 Tf 60 220 Td (Keep here) Tj ET"; @@ -5991,3 +6465,33 @@ fn mode_tabs_keep_the_hand_over_a_paragraph_scrolled_under_them() { click_at(&mut h, convert); assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Convert, "the tab takes the click, not the paragraph's editor"); } + +/// A paragraph selected by its frame goes with one Delete (or Backspace), as in Acrobat, instead +/// of being emptied a typed character at a time, and undo brings it back. +#[test] +fn a_paragraph_selected_by_its_frame_deletes_with_one_key() { + for key in [Key::Delete, Key::Backspace] { + let mut h = harness(2, |_| {}); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let id = h.state().views[0].id; + let blocks = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(id).unwrap().text_blocks(0); + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + let k = r.width() / 200.0; + let screen = |x: f64, y: f64| egui::pos2(r.left() + x as f32 * k, r.top() + (300.0 - y as f32) * k); + let b = blocks(&h)[0].clone(); + click_at(&mut h, screen((b.rect[0] + b.rect[2]) / 2.0, (b.rect[1] + b.rect[3]) / 2.0)); + assert!(h.state().views[0].line_editor.is_some(), "a click on the text opens it for typing"); + click_at(&mut h, screen((b.rect[0] + b.rect[2]) / 2.0, b.rect[3]) - egui::vec2(0.0, 4.0)); + assert!(h.state().views[0].line_editor.is_none(), "the frame selects the box"); + press(&mut h, Modifiers::NONE, key); + assert!(blocks(&h).is_empty(), "{key:?} deletes the whole paragraph: {:?}", blocks(&h)); + assert!(h.state().views[0].line_editor.is_none(), "no empty editor is left behind"); + assert!(h.query_all_by_label_contains("object selected").next().is_none(), "the deleted paragraph is no longer selected"); + assert_eq!(h.state().session.get(id).unwrap().text_blocks(1)[0].text, "Page 2", "the next page keeps its text"); + assert_eq!(h.state().session.get(id).unwrap().info.pages.len(), 2, "Delete removed the paragraph, not a page"); + assert!(h.state_mut().execute("edit.undo")); + h.run_steps(2); + assert_eq!(blocks(&h).iter().map(|b| b.text.as_str()).collect::>(), ["Page 1"], "undo restores it"); + } +} diff --git a/crates/ui-egui/tests/it/fill_sign.rs b/crates/ui-egui/tests/it/fill_sign.rs index 88051a9e58..566a14f186 100644 --- a/crates/ui-egui/tests/it/fill_sign.rs +++ b/crates/ui-egui/tests/it/fill_sign.rs @@ -237,6 +237,51 @@ fn fill_text_size_resizes_the_selected_text() { assert!(after > before * 2.0, "36 pt text has a taller box than 10 pt text: {before} -> {after}"); } +/// The font sizes of the texts `text` drawn in the last frame. +fn drawn_sizes(h: &Harness<'static, PdfCraftApp>, text: &str) -> Vec { + fn walk(shape: &egui::Shape, text: &str, out: &mut Vec) { + match shape { + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| walk(s, text, out)), + egui::Shape::Text(t) if t.galley.text() == text => { + out.extend(t.galley.job.sections.first().map(|s| s.format.font_id.size)); + } + _ => {} + } + } + let mut out = Vec::new(); + for clipped in &h.output().shapes { + walk(&clipped.shape, text, &mut out); + } + out +} + +/// The type box draws its text at the page's zoom whichever way the view is turned. With the +/// view turned a quarter, the page's width runs down the screen: reading the zoom off the screen +/// rectangle's width instead made the typed text a height-over-width too large (here 4/3). +#[test] +fn the_type_box_text_matches_the_page_zoom_in_a_turned_view() { + for rotate in ["0", "90"] { + let mut h = harness(); + h.state_mut().set_option("rotate", rotate).unwrap(); + h.run_steps(3); + h.state_mut().execute("sign.fill.text"); + click(&mut h, 50.0, 250.0); + h.event(egui::Event::Text("Ada".into())); + h.run_steps(2); + let s = h.state(); + let page = &s.session.get(s.views[0].id).unwrap().info.pages[0]; + let rect = s.views[0].page_screen_rect(0).expect("on screen"); + // Screen pixels per page point: the page's width lies across, or down when turned. + let zoom = if rotate == "90" { rect.height() } else { rect.width() } / page.width; + let size = s.views[0].fill_text.as_ref().expect("an open type box").size as f32; + let drawn = drawn_sizes(&h, "Ada"); + assert!(!drawn.is_empty(), "{rotate}°: the typed text is drawn"); + for d in drawn { + assert!((d - size * zoom).abs() < 0.5, "{rotate}°: drawn at {d}, the page shows it at {}", size * zoom); + } + } +} + #[test] fn the_type_box_grows_as_text_is_typed() { let mut h = harness(); diff --git a/crates/ui-egui/tests/it/forms.rs b/crates/ui-egui/tests/it/forms.rs index 676a16db4a..436792553a 100644 --- a/crates/ui-egui/tests/it/forms.rs +++ b/crates/ui-egui/tests/it/forms.rs @@ -99,6 +99,75 @@ fn a_blank_tooltip_shows_nothing() { assert!(egui::Tooltip::seconds_since_last_tooltip(&h.ctx).is_infinite(), "no tooltip was shown"); } +/// A text field that doesn't scroll (`DoNotScroll`) takes no more than it shows, measured as its +/// appearance draws it: a long paste keeps what fits, typing into a full field adds nothing, and +/// a field without the flag takes it all. +#[test] +fn a_field_that_doesnt_scroll_stops_taking_text_when_full() { + let mut h = harness(); + set_city( + &mut h, + pdfcraft_engine::FieldProps { flags: vec![(pdfcraft_engine::field_flags::DO_NOT_SCROLL, true)], font_size: Some(12.0), ..Default::default() }, + ); + let text_box = |h: &Harness<'static, PdfCraftApp>| { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().field_text_box("city", 0).expect("the city field's text box") + }; + let typing = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].forms.focus.as_ref().map(|f| f.text.clone()).unwrap_or_default(); + click_field(&mut h, "city", 0); + h.event(egui::Event::Text("W".repeat(80))); + h.run_steps(2); + let kept = typing(&h); + let tb = text_box(&h); + assert!(!kept.is_empty() && kept.len() < 80, "{} of 80 kept", kept.len()); + assert!(tb.fits(&kept) && !tb.fits(&format!("{kept}W")), "as much as fits: {}", kept.len()); + h.event(egui::Event::Text("WWW".into())); + h.run_steps(2); + assert_eq!(typing(&h), kept, "a full field takes no more"); + h.key_press(egui::Key::Tab); + h.run_steps(4); + assert_eq!(value(&h, "city"), [kept]); + // A field without the flag takes the whole paste. + click_field(&mut h, "name", 0); + h.event(egui::Event::Text("W".repeat(80))); + h.run_steps(2); + h.key_press(egui::Key::Tab); + h.run_steps(4); + assert_eq!(value(&h, "name"), ["W".repeat(80)]); +} + +/// A multi-line field that doesn't scroll, with an automatic size, takes lines until even the +/// smallest size it shrinks to can't show them all. +#[test] +fn a_multi_line_auto_sized_field_that_doesnt_scroll_stops_when_full() { + let mut h = harness(); + set_city( + &mut h, + pdfcraft_engine::FieldProps { + flags: vec![(pdfcraft_engine::field_flags::DO_NOT_SCROLL, true)], + multiline: Some(true), + font_size: Some(0.0), + ..Default::default() + }, + ); + let da = { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "city").unwrap().da.clone() + }; + assert!(da.contains(" 0 Tf"), "an automatic size: {da}"); + click_field(&mut h, "city", 0); + h.event(egui::Event::Text("line\n".repeat(60))); + h.run_steps(2); + let kept = h.state().views[0].forms.focus.as_ref().map(|f| f.text.clone()).unwrap_or_default(); + let tb = { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().field_text_box("city", 0).expect("the city field's text box") + }; + let lines = kept.lines().count(); + assert!(lines > 1 && lines < 60, "{lines} of 60 lines kept"); + assert!(tb.fits(&kept) && !tb.fits(&format!("{kept}\nline")), "as many lines as fit at 4 pt: {lines}"); +} + #[test] fn typing_into_a_text_field_and_tabbing_on() { let mut h = harness(); @@ -445,6 +514,53 @@ fn an_undelimited_date_field_takes_a_typed_date_and_its_calendar_opens_on_it() { h.get_by_label("March 2025"); } +/// #4274: the calendar had no way to pick today; from another month the only way back was the +/// arrows. Today is picked in each field's own format, whichever month the calendar shows, and +/// is one edit as a clicked day is. +#[test] +fn the_calendar_picks_today_in_the_fields_format_from_any_month() { + use egui_kittest::kittest::Queryable; + let mut h = harness(); + // 2023-11-14 on the injected clock. + let s = h.state_mut(); + s.session = std::mem::take(&mut s.session).with_clock(|| 1_700_000_000); + assert_eq!(h.state().session.today(), (2023, 11, 14)); + for (name, rect) in [("due", [50.0, 40.0, 200.0, 60.0]), ("start", [50.0, 80.0, 200.0, 100.0])] { + h.state_mut().apply_edit(pdfcraft_engine::Edit::AddField { page: 0, rect, kind: pdfcraft_engine::NewField::Date, name: Some(name.into()) }); + } + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps { + name: "start".into(), + props: Box::new(pdfcraft_engine::FieldProps { + format: Some(pdfcraft_engine::form_scripts::Format::Date("yyyy-mm-dd".into())), + ..Default::default() + }), + })); + h.run_steps(4); + // Two months on, and two months back: Today still picks the 14th of November. + for (name, arrow, shown, want) in [("due", "›", "January 2024", "11/14/2023"), ("start", "‹", "September 2023", "2023-11-14")] { + click_field(&mut h, name, 0); + h.get_by_label("November 2023"); + for _ in 0..2 { + h.get_by_label(arrow).click(); + h.run_steps(2); + } + h.get_by_label(shown); + if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") { + h.render().unwrap().save(format!("{dir}/calendar-today-{name}.png")).unwrap(); + } + h.get_by_label("Today").click(); + h.run_steps(4); + assert_eq!(value(&h, name), [want], "today in {name}'s format"); + assert!(h.state().views[0].forms.focus.is_none(), "picking closes the field, as a clicked day does"); + assert!(h.query_by_label("Today").is_none(), "and its calendar"); + } + // One edit each, as a clicked day is. + h.state_mut().undo(); + h.run_steps(2); + assert_eq!(value(&h, "start"), Vec::::new()); + assert_eq!(value(&h, "due"), ["11/14/2023"]); +} + #[test] fn lithuanian_calendar_localizes_months_and_weekdays_without_changing_field_values() { use egui_kittest::kittest::Queryable; @@ -494,9 +610,9 @@ fn wait_for_file(h: &mut Harness<'static, PdfCraftApp>, path: &std::path::Path) h.run_steps(1); } -/// A fresh directory of its own for each call. The tests share one process and run in parallel, -/// so a name alone is not enough: two tests that both used "same-frame" deleted each other's -/// directory mid-save, and the save then never wrote its file (#4186). +/// A fresh folder of its own for one test. The tests of this binary share one process id and run +/// in parallel, and two of them name their folder "same-frame": a number taken per call keeps one +/// test from clearing the folder another is saving into. fn scratch(name: &str) -> std::path::PathBuf { static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); @@ -1221,6 +1337,9 @@ fn on_screen_fit(h: &Harness<'static, PdfCraftApp>, name: &str, text: &str) -> ( } } +/// Text typed into an auto-size field shrinks to fit the box's width, while short text keeps a +/// larger size; a multiline auto-size field wraps and shrinks only as far as the box's height +/// needs, and a fixed 10 pt field keeps its size. #[test] fn auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box() { let mut h = auto_size_harness(); @@ -1303,3 +1422,109 @@ fn mode_tabs_keep_the_hand_over_a_text_field_scrolled_under_them() { click_at(&mut h, convert); assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Convert, "the tab takes the click, not the field's editor"); } + +/// Two combo boxes of Apple and Banana: `fruit` editable (Combo and Edit, ISO 32000-2 Table 233), +/// `veg` a drop-down list only. +fn combo_form() -> Vec { + pdf_from_objects(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Ch /T (fruit) /Ff 393216 /Opt [(Apple) (Banana)] /Rect [20 350 280 372] /P 3 0 R /F 4 >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + "<< /Type /Annot /Subtype /Widget /FT /Ch /T (veg) /Ff 131072 /Opt [(Apple) (Banana)] /Rect [20 300 280 322] /P 3 0 R /F 4 >>", + ]) +} + +fn combo_harness() -> Harness<'static, PdfCraftApp> { + 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("combo.pdf", None, combo_form()).unwrap(); + app.set_option("left", "closed").unwrap(); + app.set_option("zoom", "150").unwrap(); + app + }); + crate::support::settle(&mut h); + h +} + +/// An editable combo box takes a typed value that isn't among its options, and still takes a +/// pick from its list; the typed value is what Save writes. +#[test] +fn an_editable_combo_box_takes_a_typed_value() { + let dir = scratch("editable-combo"); + let out = dir.join("saved.pdf"); + let mut h = combo_harness(); + click_field(&mut h, "fruit", 0); + assert_eq!(focused(&h), Some(("fruit".into(), 0))); + h.event(egui::Event::Text("Cherry".into())); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(4); + assert_eq!(value(&h, "fruit"), ["Cherry"], "Enter commits the typed value"); + assert!(h.state().views[0].forms.focus.is_none(), "and closes the field"); + // Reopened, it shows the typed value, and the list still picks an option. + click_field(&mut h, "fruit", 0); + assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.text.as_str()), Some("Cherry")); + h.get_by_label("Banana").click(); + h.run_steps(4); + assert_eq!(value(&h, "fruit"), ["Banana"], "a pick from the list"); + // Typed again, Tab commits it, and Save writes it. + click_field(&mut h, "fruit", 0); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("Damson".into())); + h.run_steps(2); + h.key_press(egui::Key::Tab); + h.run_steps(4); + assert_eq!(value(&h, "fruit"), ["Damson"], "Tab commits the typed value"); + h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::S); + wait_for_file(&mut h, &out); + assert_eq!(saved_value(&out, "fruit"), ["Damson"]); + // Undo takes the typing back as one step. + h.state_mut().undo(); + h.run_steps(2); + assert_eq!(value(&h, "fruit"), ["Banana"]); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A combo box without the Edit flag is a drop-down list only: typing changes nothing. +#[test] +fn a_combo_box_without_edit_takes_no_typing() { + let mut h = combo_harness(); + click_field(&mut h, "veg", 0); + assert_eq!(focused(&h), Some(("veg".into(), 0))); + h.event(egui::Event::Text("Cherry".into())); + h.run_steps(2); + assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.text.as_str()), Some(""), "no text box took the typing"); + h.key_press(egui::Key::Enter); + h.run_steps(2); + h.key_press(egui::Key::Tab); + h.run_steps(4); + assert!(value(&h, "veg").is_empty(), "nothing typed reaches the field"); + // Tab went on to `fruit`, whose open list covers `veg`: close it first. + h.key_press(egui::Key::Escape); + h.run_steps(2); + click_field(&mut h, "veg", 0); + h.get_by_label("Apple").click(); + h.run_steps(4); + assert_eq!(value(&h, "veg"), ["Apple"], "its list still picks"); +} + +/// A button script's `ShowHideFileAttachment` opens the attachments panel, and running it again +/// closes it. +#[test] +fn a_form_button_shows_and_hides_the_attachments_panel() { + let mut h = harness(); + h.state_mut().set_option("panel", "none").unwrap(); + h.run_steps(2); + let id = h.state().views[0].id; + h.state_mut().run_button_script(id, "size", "app.execMenuItem('ShowHideFileAttachment');"); + h.run_steps(2); + assert_eq!(h.state().right, Some(pdfcraft_ui_egui::RightPanel::Attachments)); + // Show/Hide, as its name says: asked again, it closes. + h.state_mut().run_button_script(id, "size", "app.execMenuItem('ShowHideFileAttachment');"); + h.run_steps(2); + assert_eq!(h.state().right, None); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 36834999e6..b49642fe04 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -20,6 +20,7 @@ fn gpu() -> std::sync::MutexGuard<'static, ()> { mod accessibility; mod actions; mod animate; +mod attach_picker_target; mod bookmark_position; mod bookmarks; mod bulk_fields; @@ -28,6 +29,7 @@ mod camera; mod column_select; mod combine_grid; mod commands; +mod comment_pin; mod comments; mod compare; mod content; @@ -49,6 +51,7 @@ mod links; mod marks; mod object_selection; mod ocr; +mod open_action; mod organize_drag; mod pageboxes; mod panel_snapshots; diff --git a/crates/ui-egui/tests/it/marks.rs b/crates/ui-egui/tests/it/marks.rs index d94594be09..8861310115 100644 --- a/crates/ui-egui/tests/it/marks.rs +++ b/crates/ui-egui/tests/it/marks.rs @@ -15,10 +15,14 @@ trailer << /Root 1 0 R >> %%EOF"; fn harness() -> Harness<'static, PdfCraftApp> { - let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + harness_with(FIXTURE.to_vec()) +} + +fn harness_with(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("doc.pdf", None, FIXTURE.to_vec()).unwrap(); + app.open_bytes("doc.pdf", None, pdf.clone()).unwrap(); app }); h.run_steps(4); @@ -139,6 +143,157 @@ fn backgrounds_and_watermarks_from_a_file() { assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), Some("Add background")); } +/// Two 300 × 400 pages with content (a dark bar), the second turned a quarter (/Rotate 90), so it +/// is shown landscape, 400 × 300. +fn content_fixture() -> Vec { + let bar = "0 0 0 rg 40 300 220 60 re f"; + format!( + "%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 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /Contents 5 0 R >> endobj +4 0 obj << /Type /Page /Parent 2 0 R /Rotate 90 /Contents 5 0 R >> endobj +5 0 obj << /Length {} >> +stream +{bar} +endstream +endobj +trailer << /Root 1 0 R >> +%%EOF", + bar.len() + ) + .into_bytes() +} + +/// The pictures of page `page` (its thumbnail) painted last frame: paint order, bounds, tint. +fn page_pictures(h: &Harness<'static, PdfCraftApp>, page: usize) -> Vec<(usize, egui::Rect, egui::Color32)> { + let Some(tex) = h.state().views[0].thumb(page).map(|t| t.id()) else { return Vec::new() }; + h.output() + .shapes + .iter() + .enumerate() + .filter_map(|(k, s)| match &s.shape { + egui::Shape::Mesh(m) if m.texture_id == tex => Some((k, m.calc_bounds(), m.vertices.first()?.color)), + _ => None, + }) + .collect() +} + +/// The preview's picture of `page`, once its thumbnail has arrived (it renders off the UI thread). +fn wait_for_picture(h: &mut Harness<'static, PdfCraftApp>, page: usize) -> (usize, egui::Rect, egui::Color32) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + h.step(); + if let [one] = page_pictures(h, page)[..] { + return one; + } + assert!(std::time::Instant::now() < deadline, "the preview never showed page {}'s picture", page + 1); + std::thread::sleep(std::time::Duration::from_millis(5)); + } +} + +/// A text painted on the page last frame (the dialog's text boxes show it too): its paint order +/// and its laid-out rectangle. +fn painted(h: &Harness<'static, PdfCraftApp>, text: &str, page: egui::Rect) -> (usize, egui::Rect) { + h.output() + .shapes + .iter() + .enumerate() + .find_map(|(k, s)| match &s.shape { + egui::Shape::Text(t) if t.galley.text() == text => { + Some((k, t.galley.rect.translate(t.pos.to_vec2()))).filter(|(_, r)| page.contains(r.center())) + } + _ => None, + }) + .unwrap_or_else(|| panic!("{text:?} is painted on the page")) +} + +fn near(a: f32, b: f32, what: &str) { + assert!((a - b).abs() < 1.0, "{what}: {a} against {b}"); +} + +#[test] +fn the_preview_shows_the_page_the_range_selects_under_the_marks() { + use pdfcraft_ui_egui::marks::{PageRange, Subset}; + let mut h = harness_with(content_fixture()); + assert!(h.state_mut().execute("edit.header_footer")); + h.run_steps(2); + { + let d = &mut h.state_mut().marks_draft; + d.hf.text[1] = "TOP".into(); + d.hf.text[5] = "END".into(); + // Page 2 alone, not the current page: the preview follows the range (#2837). + d.range = PageRange { all: false, from: 2, to: 2, subset: Subset::All }; + } + let (at, page, tint) = wait_for_picture(&mut h, 1); + h.get_by_label("Preview: page 2 of 2"); + assert_eq!(tint, egui::Color32::WHITE); + assert!((page.width() / page.height() - 4.0 / 3.0).abs() < 0.01, "page 2 is shown turned, as displayed: {page:?}"); + // The marks are drawn over the page, where the engine puts them on its displayed box + // (default margins: 36 pt top and bottom, 72 pt left and right). + let s = page.width() / 400.0; + let (top_at, top) = painted(&h, "TOP", page); + let (end_at, end) = painted(&h, "END", page); + assert!(at < top_at && at < end_at, "the page is under the marks"); + near(top.center().x, page.center().x, "centre header, across"); + near(top.center().y, page.top() + 36.0 * s, "centre header, down"); + near(end.right(), page.right() - 72.0 * s, "right footer, across"); + near(end.center().y, page.bottom() - 36.0 * s, "right footer, down"); + h.get_by_label("Cancel").click(); + h.run_steps(2); + + // The watermark sits on the page's centre. + assert!(h.state_mut().execute("edit.watermark")); + h.run_steps(2); + { + let d = &mut h.state_mut().marks_draft; + d.wm.text = "DRAFT".into(); + d.wm.rotation = 0.0; + } + let (at, page, _) = wait_for_picture(&mut h, 1); + let (mark_at, mark) = painted(&h, "DRAFT", page); + assert!(at < mark_at, "the page is under the watermark"); + near(mark.center().x, page.center().x, "watermark, across"); + near(mark.center().y, page.center().y, "watermark, down"); + h.get_by_label("Cancel").click(); + h.run_steps(2); + + // A background colour shows where the page is white: it tints the page's picture. + assert!(h.state_mut().execute("edit.background")); + h.run_steps(2); + let (_, _, tint) = wait_for_picture(&mut h, 1); + assert_eq!(tint, egui::Color32::from_rgb(255, 255, 217), "the default pale yellow, opaque"); +} + +/// The preview's page stepper, beside its "page N of M" label: the dialog's own button, not the +/// page navigation's of the same name. +fn preview_step(h: &mut Harness<'static, PdfCraftApp>, label: &str, now: &str) { + let row = h.get_by_label(now).rect().center().y; + h.query_all_by_label(label).find(|n| (n.rect().center().y - row).abs() < 15.0).unwrap_or_else(|| panic!("{label} beside {now:?}")).click(); + h.run_steps(2); +} + +#[test] +fn the_preview_steps_through_the_pages_and_starts_at_the_current_one() { + let mut h = harness(); + h.state_mut().views[0].go_to_page(1); + h.run_steps(2); + assert!(h.state_mut().execute("edit.watermark")); + h.run_steps(2); + h.get_by_label("Preview: page 2 of 3"); + preview_step(&mut h, "Next page", "Preview: page 2 of 3"); + h.get_by_label("Preview: page 3 of 3"); + preview_step(&mut h, "Previous page", "Preview: page 3 of 3"); + preview_step(&mut h, "Previous page", "Preview: page 2 of 3"); + h.get_by_label("Preview: page 1 of 3"); + // Opened again, the preview is back at the current page. + h.get_by_label("Cancel").click(); + h.run_steps(2); + assert!(h.state_mut().execute("edit.watermark")); + h.run_steps(2); + h.get_by_label("Preview: page 2 of 3"); +} + /// Pages from 1 to 1 stays one page: the dialog used to turn "to" back into the last page on /// every frame while both were 1, so only page 1 could not be chosen alone. The field starts on /// the last page. diff --git a/crates/ui-egui/tests/it/ocr.rs b/crates/ui-egui/tests/it/ocr.rs index 7f2e3bdc53..d7a5501987 100644 --- a/crates/ui-egui/tests/it/ocr.rs +++ b/crates/ui-egui/tests/it/ocr.rs @@ -156,3 +156,195 @@ fn an_ocr_word_opens_for_editing_and_stays_open() { h.run_steps(4); assert_eq!(opened(&h).as_deref(), Some("Recognize"), "moving the pointer away keeps the word open"); } + +/// A page of real text, not a picture. +fn text_page() -> Vec { + let s = Session::new(); + s.create_from_text("t", "This page already has its own text").unwrap().to_vec() +} + +fn edit_harness(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("doc.pdf", None, pdf).unwrap(); + app.run_inline = true; + app + }); + h.run_steps(4); + h +} + +/// Like Acrobat's "Converting scanned page to Editable Text and Images": turning Edit on for a +/// scanned page recognizes its words first (one undoable "Recognize text" step), once. Undoing it +/// is not undone again by the next frame. +#[test] +fn editing_a_scanned_page_recognizes_its_text_first_and_only_once() { + if !pdfcraft_engine::ocr::available() { + eprintln!("skipped: OCR models not installed"); + return; + } + let mut h = edit_harness(scan()); + let id = h.state().active_ids().unwrap().1; + assert_eq!(h.state().session.get(id).unwrap().can_undo(), None, "nothing is recognized before Edit"); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(4); + assert!(h.state().ocr_run.is_none(), "finished"); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Recognize text")); + // Undo takes the text layer away and Edit does not put it back. + h.state_mut().undo(); + h.run_steps(4); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), None); + h.run_steps(4); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), None, "recognized only once per page"); +} + +/// The Edit tab (its tool panel) turns Edit on without picking Edit text & images, and Acrobat's +/// conversion happens there too. +#[test] +fn the_edit_panel_recognizes_a_scanned_page_too() { + if !pdfcraft_engine::ocr::available() { + eprintln!("skipped: OCR models not installed"); + return; + } + let mut h = edit_harness(scan()); + let id = h.state().active_ids().unwrap().1; + assert_eq!(h.state().session.get(id).unwrap().can_undo(), None); + h.state_mut().set_option("tool", "edit").unwrap(); + h.run_steps(4); + assert!(h.state().ocr_run.is_none(), "finished"); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Recognize text")); +} + +/// A scan with only a comment (a text box) over it is still a scan: Edit reads the picture. The +/// comment's own words are not read into the layer, and it counts as no page text. +#[test] +fn editing_a_scan_with_a_comment_over_it_recognizes_the_picture() { + if !pdfcraft_engine::ocr::available() { + eprintln!("skipped: OCR models not installed"); + return; + } + let with_comment = { + let mut s = Session::new(); + let id = s.open("scan.pdf", None, std::sync::Arc::new(scan()), None).unwrap(); + let (w, h) = { + let p = &s.get(id).unwrap().info.pages[0]; + (p.width as f64, p.height as f64) + }; + let shape = pdfcraft_engine::Shape::TextBox { rect: [w * 0.1, h * 0.75, w * 0.9, h * 0.85], font_size: 18.0 }; + let style = pdfcraft_engine::Style::default_for(&shape); + let note = pdfcraft_engine::NewAnnotation { page: 0, shape, style, contents: "Invoice reference 4711".into(), author: "Test".into() }; + s.apply(id, Edit::AddAnnotation(note)).unwrap(); + s.save_bytes(id).unwrap().to_vec() + }; + let mut h = edit_harness(with_comment); + let id = h.state().active_ids().unwrap().1; + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(4); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Recognize text")); +} + +/// A born-digital page with a full-page background picture and real text (a flyer, a slide +/// export) must not change just because Edit is turned on (maintainer review of #1609). +#[test] +fn editing_a_page_with_text_over_a_background_picture_changes_nothing() { + let hybrid = { + let mut s = Session::new(); + let id = s.open("scan.pdf", None, std::sync::Arc::new(scan()), None).unwrap(); + let (w, h) = { + let p = &s.get(id).unwrap().info.pages[0]; + (p.width as f64, p.height as f64) + }; + let note = + AddedText { rect: [w * 0.1, h * 0.75, w * 0.9, h * 0.85], text: "Invoice reference 4711".into(), size: 18.0, ..Default::default() }; + s.apply(id, Edit::AddText { page: 0, text: note }).unwrap(); + s.save_bytes(id).unwrap().to_vec() + }; + let mut h = edit_harness(hybrid); + let id = h.state().active_ids().unwrap().1; + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(6); + assert!(h.state().ocr_run.is_none()); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), None, "nothing was recognized or marked modified"); + assert!(!h.state().session.get(id).unwrap().dirty, "the file is not marked modified"); + // And Edit says nothing about recognizing: no progress, no "0 words ... already had text". + let toast = h.state().toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); + assert!(!toast.contains("ecogniz") && !toast.contains("already had text"), "{toast:?}"); +} + +/// A page that is not a scan when Edit looks at it is looked at again after the document changes: +/// adding a picture of text that covers it turns it into a scan, and Edit then reads it (and does +/// so once). +#[test] +fn a_page_that_becomes_a_scan_while_edit_is_on_is_recognized() { + if !pdfcraft_engine::ocr::available() { + eprintln!("skipped: OCR models not installed"); + return; + } + let png = { + let mut s = Session::new(); + let text = s.create_from_text("t", "Recognize these scanned words").unwrap(); + let id = s.open("text.pdf", None, text, None).unwrap(); + export::Exporter::new(s.get(id).unwrap()).png(0, 150.0).unwrap() + }; + let blank = Session::new().create_blank(595.0, 842.0, 1).unwrap().to_vec(); + let mut h = edit_harness(blank); + let id = h.state().active_ids().unwrap().1; + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(4); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), None, "a blank page is no scan: nothing happened"); + // The same page, now with a picture of text over all of it. + h.state_mut() + .session + .apply(id, Edit::AddImage { page: 0, rect: Some([0.0, 0.0, 595.0, 842.0]), name: "scan.png".into(), bytes: std::sync::Arc::new(png) }) + .unwrap(); + h.run_steps(6); + assert!(h.state().ocr_run.is_none(), "finished"); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Recognize text")); +} + +/// Edit changes the document only while the user is editing: not in Read mode (which leaves the Edit +/// tool picked), and not beneath a dialog. The page is recognized once those are gone. +#[test] +fn edit_recognizes_a_scan_only_outside_read_mode_and_beneath_no_dialog() { + if !pdfcraft_engine::ocr::available() { + eprintln!("skipped: OCR models not installed"); + return; + } + let mut h = edit_harness(scan()); + let id = h.state().active_ids().unwrap().1; + let undo = |h: &Harness<'static, PdfCraftApp>| -> Option { h.state().session.get(id).unwrap().can_undo().map(String::from) }; + // Read mode with the Edit tool still picked. + assert!(h.state_mut().execute("edit.edit_text")); + h.state_mut().set_option("mode", "read").unwrap(); + h.run_steps(5); + assert_eq!(undo(&h), None, "not in Read mode"); + // A dialog over the window. + h.state_mut().set_option("mode", "edit").unwrap(); + h.state_mut().dialog = Some(pdfcraft_ui_egui::Dialog::JsConsole); + h.run_steps(5); + assert_eq!(undo(&h), None, "not beneath a dialog"); + // Both gone: it is read now. + h.state_mut().dialog = None; + h.run_steps(5); + assert_eq!(undo(&h).as_deref(), Some("Recognize text")); +} + +#[test] +fn editing_a_page_that_has_text_does_not_recognize_anything() { + let mut h = edit_harness(text_page()); + let id = h.state().active_ids().unwrap().1; + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(6); + assert!(h.state().ocr_run.is_none()); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), None); +} + +/// Only the Edit tool recognizes: viewing a scan, or any other tool, leaves the file as it is. +#[test] +fn only_the_edit_tool_recognizes_a_scanned_page() { + let mut h = edit_harness(scan()); + let id = h.state().active_ids().unwrap().1; + h.run_steps(6); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), None); +} diff --git a/crates/ui-egui/tests/it/open_action.rs b/crates/ui-egui/tests/it/open_action.rs new file mode 100644 index 0000000000..a6593af707 --- /dev/null +++ b/crates/ui-egui/tests/it/open_action.rs @@ -0,0 +1,191 @@ +//! A document opens where its `/OpenAction` says (ISO 32000-2 §12.3.2.2 Table 149, §12.3.2.4): +//! the page, and the place on it, of an explicit destination, a go-to action's or a named one; +//! and a full-screen document's `/NonFullScreenPageMode` panel (Table 147). + +use egui::{Key, Modifiers}; +use egui_kittest::Harness; +use pdfcraft_render::DestView; +use pdfcraft_ui_egui::canvas::Fit; +use pdfcraft_ui_egui::{PdfCraftApp, RightPanel}; + +/// Six Letter (612×792) pages, objects 3 to 8; `catalog` is added to the catalog dictionary. +/// Object 9 is an outline (one bookmark, object 10) that `/Outlines 9 0 R` can use. +fn fixture(catalog: &str) -> Vec { + let n = 6; + let kids: Vec = (0..n).map(|i| format!("{} 0 R", 3 + i)).collect(); + let mut objs = vec![format!("<< /Type /Catalog /Pages 2 0 R {catalog} >>"), format!("<< /Type /Pages /Kids [{}] /Count {n} >>", kids.join(" "))]; + for _ in 0..n { + objs.push("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>".to_string()); + } + objs.push("<< /Type /Outlines /First 10 0 R /Last 10 0 R /Count 1 >>".to_string()); + objs.push("<< /Title (Chapter) /Parent 9 0 R /Dest [6 0 R /Fit] >>".to_string()); + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +fn harness(catalog: &str) -> Harness<'static, PdfCraftApp> { + let bytes = fixture(catalog); + 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.set_option("left", "closed").unwrap(); + app.open_bytes("open.pdf", None, bytes).expect("opens"); + app + }); + h.run_steps(4); + settle(&mut h); + h +} + +/// Run frames until rendering has finished and the layout holds still. +fn settle(h: &mut Harness<'static, PdfCraftApp>) { + for _ in 0..200 { + h.run_steps(2); + if !h.state().render_pending() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } +} + +/// The page at the top of the window and the user-space height there: where a bookmark made now +/// (⌘B) goes. +fn window_top(h: &mut Harness<'static, PdfCraftApp>) -> (usize, f32) { + h.key_press_modifiers(Modifiers::COMMAND, Key::B); + h.run_steps(3); + let id = h.state().views[0].id; + let outline = &h.state().session.get(id).unwrap().info.outline; + let made = outline.iter().find(|b| b.title == "Untitled").expect("the new bookmark"); + match made.view { + DestView::Xyz { top: Some(top), .. } => (made.page.expect("a page"), top), + other => panic!("expected /XYZ with a top, got {other:?}"), + } +} + +/// The left edge of `page` on screen and its scale (screen points per user-space point). +fn page_left(h: &Harness<'static, PdfCraftApp>, page: usize) -> (f32, f32) { + let rects = h.state().views[0].visible_page_rects(); + let (_, r) = rects.iter().find(|(p, _)| *p == page).expect("the page is shown"); + (r.left(), r.width() / 612.0) +} + +fn near(got: f32, want: f32, what: &str) { + assert!((got - want).abs() < 2.0, "{what}: {got} vs {want}"); +} + +#[test] +fn an_xyz_open_action_puts_its_point_at_the_window_top_left() { + let mut h = harness("/OpenAction [5 0 R /XYZ 100 500 2]"); + assert_eq!(h.state().views[0].current, 2); + assert_eq!((h.state().views[0].fit, h.state().views[0].zoom), (Fit::None, 2.0)); + // `left`: the page sits 100 user points further left than one opened at left 0. + let (left, scale) = page_left(&h, 2); + let (left0, _) = page_left(&harness("/OpenAction [5 0 R /XYZ 0 500 2]"), 2); + near(left0 - left, 100.0 * scale, "the page moved left by `left`"); + let (page, top) = window_top(&mut h); + assert_eq!(page, 2); + near(top, 500.0, "y 500 is at the window's top edge"); +} + +#[test] +fn a_fith_open_action_puts_its_top_at_the_window_top() { + let mut h = harness("/OpenAction [4 0 R /FitH 400]"); + assert_eq!(h.state().views[0].current, 1); + assert_eq!(h.state().views[0].fit, Fit::Width); + let (page, top) = window_top(&mut h); + assert_eq!(page, 1); + near(top, 400.0, "y 400 is at the window's top edge"); +} + +#[test] +fn a_named_open_destination_opens_its_page() { + for catalog in [ + // PDF 1.1: a name, through the catalog's /Dests. + "/OpenAction << /S /GoTo /D /there >> /Dests << /there [7 0 R /XYZ 0 792 0] >>", + // A string, through the /Names /Dests name tree. + "/OpenAction << /S /GoTo /D (there) >> /Names << /Dests << /Names [(there) [7 0 R /XYZ 0 792 0]] >> >>", + // The destination given as a name, without an action. + "/OpenAction /there /Dests << /there [7 0 R /XYZ 0 792 0] >>", + ] { + let mut h = harness(catalog); + assert_eq!(h.state().views[0].current, 4, "{catalog}"); + // A zoom of 0 keeps the default zoom. + assert_eq!(h.state().views[0].fit, Fit::Width, "{catalog}"); + let (page, top) = window_top(&mut h); + assert_eq!(page, 4, "{catalog}"); + near(top, 792.0, "the page's top is at the window's top edge"); + } + // A name nothing defines opens on the first page. + let h = harness("/OpenAction << /S /GoTo /D /missing >> /Dests << /there [7 0 R /Fit] >>"); + assert_eq!(h.state().views[0].current, 0); +} + +#[test] +fn always_my_default_view_keeps_the_zoom_and_goes_to_the_page_top() { + let bytes = fixture("/OpenAction [5 0 R /XYZ 100 500 2]"); + 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.set_option("left", "closed").unwrap(); + app.always_default_view = true; + app.open_bytes("open.pdf", None, bytes).expect("opens"); + app + }); + h.run_steps(4); + settle(&mut h); + assert_eq!(h.state().views[0].current, 2); + assert_eq!(h.state().views[0].fit, Fit::Width); + let (page, top) = window_top(&mut h); + assert_eq!(page, 2); + near(top, 792.0, "the page's top"); +} + +#[test] +fn a_full_screen_document_shows_its_non_full_screen_panel_outside_full_screen() { + for (mode, want) in [("UseThumbs", Some(RightPanel::Pages)), ("UseOC", Some(RightPanel::Layers)), ("UseNone", None)] { + let mut h = harness(&format!("/PageMode /FullScreen /ViewerPreferences << /NonFullScreenPageMode /{mode} >>")); + assert!(h.state().pending_full_screen.is_some(), "{mode}: full screen is asked for"); + assert_eq!(h.state().right, want, "{mode}: the panel behind the prompt"); + // Declining it leaves that panel. + let ctx = h.ctx.clone(); + h.state_mut().resolve_full_screen_prompt(&ctx, false); + h.run_steps(2); + assert_eq!((h.state().full_screen, h.state().right), (false, want), "{mode}: declined"); + // Entering and leaving full screen comes back to it. + h.state_mut().apply_full_screen(true); + h.run_steps(2); + h.state_mut().apply_full_screen(false); + h.run_steps(2); + assert_eq!(h.state().right, want, "{mode}: after full screen"); + } + // Without /PageMode /FullScreen the entry means nothing. + let h = harness("/ViewerPreferences << /NonFullScreenPageMode /UseThumbs >>"); + assert_eq!(h.state().right, None); +} + +#[test] +fn a_full_screen_document_with_an_outline_shows_it_only_when_it_asks_to() { + // UseNone (and its absence, the default) shows no outline outside full screen; UseOutlines does. + for (prefs, want) in [ + ("", None), + ("/ViewerPreferences << /NonFullScreenPageMode /UseNone >>", None), + ("/ViewerPreferences << /NonFullScreenPageMode /UseOutlines >>", Some(RightPanel::Bookmarks)), + ] { + let h = harness(&format!("/Outlines 9 0 R /PageMode /FullScreen {prefs}")); + assert_eq!(h.state().right, want, "{prefs}"); + } + // Not in full screen, a document with an outline still opens on it. + let h = harness("/Outlines 9 0 R"); + assert_eq!(h.state().right, Some(RightPanel::Bookmarks)); +} diff --git a/crates/ui-egui/tests/it/pageboxes.rs b/crates/ui-egui/tests/it/pageboxes.rs index 865f127707..2e2829e05b 100644 --- a/crates/ui-egui/tests/it/pageboxes.rs +++ b/crates/ui-egui/tests/it/pageboxes.rs @@ -90,3 +90,32 @@ fn duplicate_pages_copies_the_current_page() { let s = h.state(); assert_eq!(s.session.get(s.views[0].id).unwrap().info.pages.len(), 3); } + +#[test] +fn the_crop_tool_leaves_the_page_grid_so_a_page_can_be_cropped() { + let mut h = harness(); + // The Organize panel's thumbnail grid is on screen for this view: choosing + // "Crop pages" from it must leave the grid so the canvas handles a drag on + // a page instead of returning early to organize_grid. + h.state_mut().views[0].organize = true; + assert!(h.state_mut().execute("page.crop")); + assert_eq!(h.state().quick_tool, QuickTool::Crop); + assert!(!h.state().views[0].organize, "\"page.crop\" must clear view.organize"); + h.run_steps(2); + let r = h.state().views[0].page_screen_rect(0).unwrap(); + // The middle half of the page in both directions. + let (a, b) = (r.min + r.size() * 0.25, r.min + r.size() * 0.75); + h.hover_at(a); + h.run_steps(1); + h.drag_at(a); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(a + (b - a) * (k as f32 / 4.0)); + h.run_steps(1); + } + h.drop_at(b); + h.run_steps(4); + let (w, hh) = size(&h, 0); + assert!((w - 150.0).abs() < 2.0 && (hh - 200.0).abs() < 2.0, "{w}×{hh}"); + assert_eq!(size(&h, 1), (300.0, 400.0)); +} diff --git a/crates/ui-egui/tests/it/pickers.rs b/crates/ui-egui/tests/it/pickers.rs index bbab087d9a..6aa5515527 100644 --- a/crates/ui-egui/tests/it/pickers.rs +++ b/crates/ui-egui/tests/it/pickers.rs @@ -7,8 +7,8 @@ use crate::support; use egui_kittest::Harness; -use egui_kittest::kittest::Queryable; -use pdfcraft_render::PageRenderer; +use egui_kittest::kittest::{NodeT, Queryable}; +use pdfcraft_render::{PageRenderer, RenderRequest, RequestKind}; use pdfcraft_ui_egui::PdfCraftApp; /// An `n`-page document with a proper xref table. @@ -258,6 +258,58 @@ fn extract_pages_saves_under_the_file_name_typed_in_the_dialog() { let _ = std::fs::remove_dir_all(&dir); } +/// #3593: Extract pages takes page ranges typed in its Pages field, in the order typed, each page +/// once; left empty, it takes the selected pages as before. A range it can't use is refused, with +/// the reason in the dialog and the Extract button off. +#[test] +fn extract_pages_takes_the_page_ranges_typed_in_the_dialog() { + let texts = |app: &PdfCraftApp, view: usize| { + let doc = app.session.get(app.views[view].id).unwrap(); + let mut r = PageRenderer::new(doc.bytes.clone(), Default::default()); + (0..r.page_count()) + .map(|p| { + let out = r.render(RenderRequest { page: p, kind: RequestKind::Text, scale: 1.0, ..Default::default() }); + out.text.map(|t| t.plain_text().trim().to_string()).unwrap_or_default() + }) + .collect::>() + }; + let mut h = harness(&[6]); + h.state_mut().views[0].select_pages(&[1]); + assert!(h.state_mut().execute("page.extract")); + h.run_steps(2); + assert_eq!(h.state().extract_draft.pages, "", "opens on the selection"); + h.get_by_label("Pages"); + h.get_by_label_contains("1 page selected."); + // Ranges, a backward one and a repeat: in that order, each page once. + h.state_mut().extract_draft.pages = "5-6, 3-2, 6".into(); + h.run_steps(2); + h.get_by_label_contains("4 pages selected."); + h.state_mut().extract_selection(); + h.run_steps(2); + let s = h.state(); + assert_eq!(s.views.len(), 2, "the pages open in a new tab"); + assert_eq!(texts(s, 1), ["Page 5", "Page 6", "Page 3", "Page 2"]); + // A page that isn't there: the dialog says so, Extract is off and extracts nothing. + h.state_mut().active = Some(0); + h.state_mut().open_extract_dialog(); + h.state_mut().extract_draft.pages = "2, 9".into(); + h.run_steps(2); + assert!(h.get_by_label("Extract").accesskit_node().is_disabled(), "Extract is off"); + h.state_mut().extract_selection(); + h.run_steps(2); + assert_eq!(h.state().views.len(), 2, "nothing extracted"); + // Extract and delete takes the typed pages out of the document. + h.state_mut().active = Some(0); + h.state_mut().open_extract_dialog(); + h.state_mut().extract_draft.pages = "6, 1-2".into(); + h.state_mut().extract_draft.delete = true; + h.state_mut().extract_selection(); + h.run_steps(2); + let s = h.state(); + assert_eq!(texts(s, 2), ["Page 6", "Page 1", "Page 2"]); + assert_eq!(texts(s, 0), ["Page 3", "Page 4", "Page 5"]); +} + /// Left as the document's name (or emptied), the names are the ones Extract always used. #[test] fn extract_pages_keeps_the_usual_names_when_the_file_name_is_unchanged() { diff --git a/crates/ui-egui/tests/it/prepare.rs b/crates/ui-egui/tests/it/prepare.rs index bf0f6c68da..936b847d82 100644 --- a/crates/ui-egui/tests/it/prepare.rs +++ b/crates/ui-egui/tests/it/prepare.rs @@ -9,11 +9,15 @@ use pdfcraft_engine::field_table::TableColumn; use pdfcraft_ui_egui::{Dialog, PdfCraftApp, QuickTool}; fn harness() -> Harness<'static, PdfCraftApp> { - let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|cc| { + harness_with("form.pdf", include_bytes!("../data/form.pdf").to_vec()) +} + +fn harness_with(name: &'static str, bytes: Vec) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |cc| { cc.egui_ctx.options_mut(|options| options.warn_on_id_clash = true); 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(); + app.open_bytes(name, None, bytes).unwrap(); app.set_option("zoom", "150").unwrap(); app }); @@ -706,6 +710,134 @@ fn actions_tab_adds_and_removes_actions() { ); } +#[test] +fn actions_tab_edits_the_script_of_an_existing_action() { + use pdfcraft_engine::{FieldAction, FieldTrigger}; + let mut h = harness(); + h.state_mut().execute("form.prepare"); + h.run_steps(2); + let script = "if (event.value > 10) {\n app.alert('Too many');\n}"; + let actions = |h: &Harness<'static, PdfCraftApp>| { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().field_actions("city") + }; + let open = |h: &mut Harness<'static, PdfCraftApp>| { + h.state_mut().open_field_props("city", 0); + h.run_steps(2); + h.get_by_label("Actions").click(); + h.run_steps(2); + }; + // A script on Mouse Up and a link on Blur, saved with the field. + open(&mut h); + for (trigger, kind, text) in [(FieldTrigger::MouseUp, 0, script), (FieldTrigger::OnBlur, 1, "https://example.org")] { + let d = h.state_mut().field_props.as_mut().unwrap(); + d.new_action = pdfcraft_ui_egui::prepare::ActionDraft { trigger, kind, text: text.into() }; + h.run_steps(3); + h.get_by_label("Add").click(); + h.run_steps(2); + } + h.get_by_label("OK").click(); + h.run_steps(3); + let saved = actions(&h); + // Edit shows the script exactly as stored; Discard leaves everything as it was. + open(&mut h); + h.get_all_by_label("Edit").last().unwrap().click(); + h.run_steps(2); + h.get_by_label("Edit Action"); + assert_eq!(h.state().field_props.as_ref().unwrap().new_action.text, script); + h.state_mut().field_props.as_mut().unwrap().new_action.text = "app.alert('changed');".into(); + h.run_steps(2); + h.get_by_label("Discard").click(); + h.run_steps(2); + h.get_by_label("Add an Action"); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(actions(&h), saved); + // Update replaces that script only: same trigger, the link untouched. + open(&mut h); + h.get_all_by_label("Edit").last().unwrap().click(); + h.run_steps(2); + h.state_mut().field_props.as_mut().unwrap().new_action.text = "app.alert('changed');".into(); + h.run_steps(2); + h.get_by_label("Update").click(); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!( + actions(&h), + [ + (FieldTrigger::MouseUp, FieldAction::JavaScript("app.alert('changed');".into())), + (FieldTrigger::OnBlur, FieldAction::Uri("https://example.org".into())) + ] + ); +} + +/// One page with a push button "go": Mouse Up runs a script with a link chained after it, On Blur +/// runs another script. +fn chained_action_fixture() -> Vec { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/Helv 0 Tf 0 g) >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (go) /Rect [20 300 100 320] /P 3 0 R /A 5 0 R /AA << /Bl << /S /JavaScript /JS (this.dirty = true;) >> >> >>", + "<< /S /JavaScript /JS (app.alert\\(1\\);) /Next 6 0 R >>", + "<< /S /URI /URI (https://example.org) >>", + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +#[test] +fn actions_tab_shows_a_chain_and_keeps_it_when_the_first_action_is_replaced() { + use egui_kittest::kittest::NodeT; + use pdfcraft_engine::FieldTrigger; + let mut h = harness_with("chain.pdf", chained_action_fixture()); + h.state_mut().execute("form.prepare"); + h.run_steps(2); + let open = |h: &mut Harness<'static, PdfCraftApp>| { + h.state_mut().open_field_props("go", 0); + h.run_steps(2); + h.get_by_label("Actions").click(); + h.run_steps(2); + }; + open(&mut h); + h.get_by_label("Run a JavaScript, then 1 more action"); + // The first Edit is the menu bar's; the rows' follow (Mouse Up, On Blur). + assert_eq!(h.get_all_by_label("Edit").count(), 3); + h.get_all_by_label("Edit").nth(1).unwrap().click(); + h.run_steps(2); + assert_eq!(h.state().field_props.as_ref().unwrap().editing, Some(FieldTrigger::MouseUp)); + // While one script is open, no other Edit can overwrite it. + assert!(h.get_all_by_label("Edit").skip(1).all(|n| n.accesskit_node().is_disabled())); + h.get_by_label("Discard").click(); + h.run_steps(2); + assert!(h.get_all_by_label("Edit").skip(1).all(|n| !n.accesskit_node().is_disabled())); + // Replace the Mouse Up script with a link: the chained link stays after it. + { + let d = h.state_mut().field_props.as_mut().unwrap(); + d.actions.retain(|(t, _)| *t != FieldTrigger::MouseUp); + d.new_action = pdfcraft_ui_egui::prepare::ActionDraft { trigger: FieldTrigger::MouseUp, kind: 1, text: "https://example.com".into() }; + } + h.run_steps(3); + h.get_by_label("Add").click(); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + open(&mut h); + h.get_by_label("Open a web link: https://example.com, then 1 more action"); +} + /// Text field table: pick the tool and drag out an area in the empty lower part of page 1, 200 by /// 120 points from (40, 240) as displayed. The table's window opens for it. fn draw_table(h: &mut Harness<'static, PdfCraftApp>) { diff --git a/crates/ui-egui/tests/it/print.rs b/crates/ui-egui/tests/it/print.rs index ae325eaaa0..21aeca1286 100644 --- a/crates/ui-egui/tests/it/print.rs +++ b/crates/ui-egui/tests/it/print.rs @@ -873,3 +873,31 @@ fn restored_paper_keeps_preview_and_saved_sheet_dimensions() { assert!((size.0 - a4.0).abs() < 0.01 && (size.1 - a4.1).abs() < 0.01, "saved sheet: {size:?}"); std::fs::remove_file(out).unwrap(); } + +/// #2707: a booklet decides two-sided itself, as Acrobat does (its Print dialog hides Two-sided +/// while Booklet is chosen): the job flips on the short edge for both sides and prints +/// one-sided for the front or back only, and the Two-sided choice is kept for other layouts. +#[test] +fn a_booklet_decides_two_sided_itself() { + use pdfcraft_engine::print::{BookletSubset, spool::Duplex}; + let mut h = harness(); + assert!(h.state_mut().execute("print.dialog")); + h.run_steps(3); + h.state_mut().print_draft.duplex = Some(Duplex::LongEdge); + h.run_steps(1); + assert!(h.query_by_label("Two-sided:").is_some()); + h.get_by_label("Booklet").click(); + h.run_steps(2); + assert!(h.query_by_label("Two-sided:").is_none(), "hidden while Booklet is chosen"); + let d = &h.state().print_draft; + assert_eq!(d.duplex, Some(Duplex::LongEdge), "the Two-sided choice is kept"); + assert_eq!(d.job("x").duplex, Some(Duplex::ShortEdge), "both sides flip on the short edge"); + for subset in [BookletSubset::FrontOnly, BookletSubset::BackOnly] { + h.state_mut().print_draft.booklet_subset = subset; + assert_eq!(h.state().print_draft.job("x").duplex, Some(Duplex::Off), "{subset:?} is one pass of a manual duplex"); + } + h.get_by_label("Size").click(); + h.run_steps(2); + assert!(h.query_by_label("Two-sided:").is_some()); + assert_eq!(h.state().print_draft.job("x").duplex, Some(Duplex::LongEdge), "back to the choice"); +} diff --git a/crates/ui-egui/tests/it/theme.rs b/crates/ui-egui/tests/it/theme.rs index 6e86c5099f..1559838dd0 100644 --- a/crates/ui-egui/tests/it/theme.rs +++ b/crates/ui-egui/tests/it/theme.rs @@ -48,10 +48,31 @@ fn open_view_menu(h: &mut Harness<'static, PdfCraftApp>) { } } +/// A fresh install follows the desktop's appearance (#1248): dark on a dark desktop, light where +/// the desktop says nothing; a saved choice still wins. +#[test] +fn a_fresh_install_follows_the_system_theme() { + let h = harness(); + assert_theme(&h, ThemePreference::System, ThemeKind::Dark); + let mut quiet = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app + }); + quiet.run_steps(4); + assert_theme(&quiet, ThemePreference::System, ThemeKind::Light); + let mut app = PdfCraftApp::new(); + app.restore(r#"{"theme":"Light"}"#); + assert_eq!(app.theme_preference, ThemePreference::Light, "a saved choice wins over the default"); +} + #[test] fn toolbar_and_view_menu_share_three_state_preferences() { let mut h = harness(); - // Explicit light wins even on a dark desktop, including egui's popup style. + // Fresh, on a dark desktop: System, shown dark. One click on, explicit light wins even on a + // dark desktop, including egui's popup style. + assert_theme(&h, ThemePreference::System, ThemeKind::Dark); + click_toolbar(&mut h, "Use system setting"); assert_theme(&h, ThemePreference::Light, ThemeKind::Light); click_toolbar(&mut h, "Light gray"); assert_theme(&h, ThemePreference::Dark, ThemeKind::Dark); @@ -216,12 +237,13 @@ fn preferences_theme_cards_and_use_system_setting_stay_in_sync() { combo.value().unwrap_or_default() } let mut h = harness(); - h.get_by_label("Display theme: Light gray").click_secondary(); + h.get_by_label("Display theme: Use system setting").click_secondary(); h.run_steps(4); h.get_by_label("Appearance settings…").click(); h.run_steps(10); - assert_theme(&h, ThemePreference::Light, ThemeKind::Light); - assert_eq!(mode_shown(&h), "Light"); + // A fresh install follows the OS (#1248), which is dark here. + assert_theme(&h, ThemePreference::System, ThemeKind::Dark); + assert_eq!(mode_shown(&h), "Use system setting"); // The OS is dark: the Dark card's radio row picks Dark, and the combo box says so. // The cards sit below the fold of the Preferences scroll area at this window size. diff --git a/crates/ui-egui/tests/it/theme_button.rs b/crates/ui-egui/tests/it/theme_button.rs index 14f34086ce..758aa1a277 100644 --- a/crates/ui-egui/tests/it/theme_button.rs +++ b/crates/ui-egui/tests/it/theme_button.rs @@ -1,5 +1,5 @@ -//! The title bar's theme button: a click cycles System, Light and Dark; a right-click opens the -//! menu with the appearance settings. +//! The title bar's theme button: a click cycles System, Light and Dark (a fresh install starts +//! on System, #1248); a right-click opens the menu with the appearance settings. use egui_kittest::Harness; use egui_kittest::kittest::Queryable; @@ -22,9 +22,9 @@ fn preference(h: &Harness<'static, PdfCraftApp>) -> String { #[test] fn a_click_cycles_the_theme_and_a_right_click_opens_the_menu() { let mut h = harness(); - assert_eq!(preference(&h), "Light", "new installs start Light"); + assert_eq!(preference(&h), "System", "a fresh install follows the desktop (#1248)"); for (label, next) in - [("Display theme: Light gray", "Dark"), ("Display theme: Dark gray", "System"), ("Display theme: Use system setting", "Light")] + [("Display theme: Use system setting", "Light"), ("Display theme: Light gray", "Dark"), ("Display theme: Dark gray", "System")] { h.get_by_label(label).click(); h.run_steps(4); @@ -32,7 +32,7 @@ fn a_click_cycles_the_theme_and_a_right_click_opens_the_menu() { } assert!(h.query_by_label("Appearance settings…").is_none(), "a plain click opens no menu"); - h.get_by_label("Display theme: Light gray").click_secondary(); + h.get_by_label("Display theme: Use system setting").click_secondary(); h.run_steps(4); h.get_by_label("Appearance settings…").click(); h.run_steps(4); diff --git a/crates/ui-egui/tests/it/ui.rs b/crates/ui-egui/tests/it/ui.rs index 275cdab76e..c15a5c869b 100644 --- a/crates/ui-egui/tests/it/ui.rs +++ b/crates/ui-egui/tests/it/ui.rs @@ -1560,3 +1560,141 @@ fn short_window_rail_buttons_scroll_clear_of_page_navigation() { assert!(tags.bottom() <= page.top(), "Tags {tags:?} scrolls into view above the page field {page:?}"); assert_eq!(h.get(By::new().role(egui::accesskit::Role::TextInput).value("1")).rect(), page, "navigation stays put"); } + +/// A PDF with `n` empty 200×300 pt pages. +fn pages_fixture(n: usize) -> Vec { + let mut objs: Vec> = vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()]; + let kids: Vec = (0..n).map(|i| format!("{} 0 R", 3 + i)).collect(); + objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 200 300] >>", kids.join(" ")).into_bytes()); + for _ in 0..n { + objs.push(b"<< /Type /Page /Parent 2 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 +} + +/// A file opened again shows the page, zoom and page display it was last shown with (#1767, +/// Acrobat's "Restore last view settings when reopening documents"): remembered when its tab +/// closes and when settings are saved with it open, applied over the file's own initial view, +/// and not at all with the preference off. Settings are untrusted: bad entries are ignored. +#[test] +fn a_file_opened_again_shows_its_last_view() { + use pdfcraft_ui_egui::canvas::{Fit, PageLayout}; + let path = std::env::temp_dir().join(format!("pdfcraft-last-view-{}.pdf", std::process::id())).to_string_lossy().into_owned(); + let p = path.clone(); + let mut h = harness(move |app| app.open_bytes("doc.pdf", Some(p), pages_fixture(3)).expect("opens")); + let p = path.clone(); + assert!(h.state().restore_last_view, "on by default"); + { + let app = h.state_mut(); + app.set_option("zoom", "50").unwrap(); + let view = &mut app.views[0]; + view.set_display(PageLayout::TwoUp, false); + view.set_cover(true); + view.go_to_page(2); + } + h.run_steps(3); + // Saved with the tab still open: quitting remembers it. + let saved_open = h.state().persist(); + h.state_mut().close_tab(0); + let saved_closed = h.state().persist(); + for (label, saved) in [("open", saved_open), ("closed", saved_closed)] { + let (saved, p) = (saved.clone(), p.clone()); + let h = harness(move |app| { + app.restore(&saved); + app.open_bytes("doc.pdf", Some(p), pages_fixture(3)).expect("opens"); + }); + let v = &h.state().views[0]; + assert_eq!((v.current, v.fit, v.zoom, v.layout, v.continuous, v.cover), (2, Fit::None, 0.5, PageLayout::TwoUp, false, true), "{label}"); + } + // The preference off: the defaults. + let saved = h.state().persist(); + let p = path.clone(); + let h = harness(move |app| { + app.restore(&saved); + app.restore_last_view = false; + app.open_bytes("doc.pdf", Some(p), pages_fixture(3)).expect("opens"); + }); + let v = &h.state().views[0]; + assert_eq!((v.current, v.fit, v.layout, v.continuous, v.cover), (0, Fit::Width, PageLayout::Single, true, false)); + // Garbage in the settings is ignored, a usable entry among it is kept. + let mut app = PdfCraftApp::new(); + app.restore(&format!( + r#"{{"restore_last_view": "yes", "last_views": [5, {{"path": ""}}, {{"path": "{}", "page": -1, "zoom": "huge", "display": "single"}}, {{"path": "{}", "page": 1, "zoom": "fit-page", "display": "two-up"}}]}}"#, + path.replace('\\', "/"), + path.replace('\\', "/") + )); + assert!(app.restore_last_view, "a non-boolean keeps the default"); + assert_eq!(app.last_views.len(), 1); + assert_eq!((app.last_views[0].page, app.last_views[0].zoom.as_str()), (1, "fit-page")); +} + +/// #1767: a fixed zoom with a fraction comes back as it was, not rounded to a whole percent. +#[test] +fn a_fractional_zoom_is_remembered_exactly() { + use pdfcraft_ui_egui::canvas::Fit; + let path = std::env::temp_dir().join(format!("pdfcraft-last-view-frac-{}.pdf", std::process::id())).to_string_lossy().into_owned(); + let p = path.clone(); + let mut h = harness(move |app| app.open_bytes("doc.pdf", Some(p), pages_fixture(3)).expect("opens")); + h.state_mut().views[0].set_zoom(0.625); + h.run_steps(3); + h.state_mut().close_tab(0); + assert_eq!(h.state().last_views[0].zoom, "62.5%"); + let saved = h.state().persist(); + let h = harness(move |app| { + app.restore(&saved); + app.open_bytes("doc.pdf", Some(path), pages_fixture(3)).expect("opens"); + }); + let v = &h.state().views[0]; + assert_eq!((v.fit, v.zoom), (Fit::None, 0.625)); +} + +/// #1767 with full screen: the view remembered while presenting is the one full screen will go +/// back to, and a file reopened in full screen presents one fitted page, then leaves full +/// screen for the remembered view. +#[test] +fn full_screen_neither_is_remembered_nor_hides_the_last_view() { + use pdfcraft_ui_egui::canvas::{Fit, PageLayout}; + let path = std::env::temp_dir().join(format!("pdfcraft-last-view-fs-{}.pdf", std::process::id())).to_string_lossy().into_owned(); + let p = path.clone(); + let mut h = harness(move |app| app.open_bytes("doc.pdf", Some(p), pages_fixture(3)).expect("opens")); + { + let app = h.state_mut(); + app.set_option("zoom", "50").unwrap(); + let view = &mut app.views[0]; + view.set_display(PageLayout::TwoUp, false); + view.go_to_page(2); + view.enter_presentation(); + assert!(view.presenting() && view.fit == Fit::Page); + } + h.run_steps(3); + let saved = h.state().persist(); + h.state_mut().close_tab(0); + let remembered = h.state().last_views[0].clone(); + assert_eq!((remembered.page, remembered.zoom.as_str()), (2, "50%"), "not full screen's fitted page"); + let p = path.clone(); + let mut h = harness(move |app| { + app.restore(&saved); + app.full_screen = true; + app.open_bytes("doc.pdf", Some(p), pages_fixture(3)).expect("opens"); + }); + let v = &h.state().views[0]; + assert!(v.presenting(), "opened in full screen"); + assert_eq!((v.fit, v.continuous, v.current), (Fit::Page, false, 2), "one fitted page, where the reader left off"); + let view = &mut h.state_mut().views[0]; + view.leave_presentation(); + assert_eq!((view.fit, view.zoom, view.layout, view.continuous), (Fit::None, 0.5, PageLayout::TwoUp, false), "the remembered view"); +} diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index 3520f0a988..b825968ff9 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -1137,10 +1137,15 @@ trailer << /Root 1 0 R >> %%EOF"; fn dests_harness() -> Harness<'static, PdfCraftApp> { - let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + bookmarks_harness("dests.pdf", DESTS) +} + +/// `pdf` open with the Bookmarks panel showing, once the window's layout holds still. +fn bookmarks_harness(name: &'static str, pdf: &'static [u8]) -> 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("dests.pdf", None, DESTS.to_vec()).expect("opens"); + app.open_bytes(name, None, pdf.to_vec()).expect("opens"); app.set_option("left", "closed").unwrap(); app.set_option("panel", "bookmarks").unwrap(); app @@ -1190,13 +1195,15 @@ fn bookmark_destinations_scroll_to_their_position() { let d = from_viewport_corner(&h, 1, 0.25, 0.5); assert!((d.x - 70.0).abs() < 2.0 && d.y.abs() < 2.0, "the destination point is at the window's top-left: {d:?}"); - // Malformed operands fall back to the top of the page at the current zoom. + // Unusable operands are read as null, which keeps the reader's place in user space: y 300 + // was at the window's top on page 2 (its crop box starts at y 100), so on page 3 (crop from + // 0) y 300 is there too, a quarter down, at the current zoom. h.get_by_label("Broken").click(); h.run_steps(6); let v = &h.state().views[0]; assert_eq!((v.current, v.zoom), (2, 3.0)); - let d = from_viewport_corner(&h, 2, 0.0, 0.0); - assert!(d.y > 0.0 && d.y < 40.0, "page 3 is scrolled to its top: {d:?}"); + let d = from_viewport_corner(&h, 2, 0.0, 0.25); + assert!(d.y.abs() < 2.0, "y 300 on page 3 is at the top of the window: {d:?}"); } #[test] @@ -1219,6 +1226,42 @@ fn link_destinations_scroll_to_their_position() { assert!(d.y.abs() < 2.0, "y 420 on page 2 is at the top of the window: {d:?}"); } +/// Two 300×400 pages. A bookmark shows page 1 at 400% with (40, 360) at the window's top-left; +/// the link there goes to page 2 as CAD exports write their cross-references, `/XYZ null null 0`. +const KEEP_PLACE: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /Outlines 10 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /Annots [20 0 R] >> endobj +4 0 obj << /Type /Page /Parent 2 0 R >> endobj +10 0 obj << /Type /Outlines /First 11 0 R /Last 11 0 R /Count 1 >> endobj +11 0 obj << /Title (Start) /Parent 10 0 R /Dest [3 0 R /XYZ 40 360 4] >> endobj +20 0 obj << /Type /Annot /Subtype /Link /Rect [60 320 140 350] /Border [0 0 0] /Dest [4 0 R /XYZ null null 0] >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +#[test] +fn a_link_without_a_position_keeps_the_place_on_the_next_page() { + let mut h = bookmarks_harness("keep-place.pdf", KEEP_PLACE); + h.get_by_label("Start").click(); + h.run_steps(6); + assert_eq!((h.state().views[0].current, h.state().views[0].zoom), (0, 4.0)); + let (fx, fy) = (40.0 / 300.0, 40.0 / 400.0); + let d = from_viewport_corner(&h, 0, fx, fy); + assert!((d.x - 70.0).abs() < 2.0 && d.y.abs() < 2.0, "(40, 360) is at the window's top-left: {d:?}"); + + let r = rect(&h, 0).expect("page 1"); + let at = egui::pos2(r.min.x + r.width() * (100.0 / 300.0), r.min.y + r.height() * (1.0 - 335.0 / 400.0)); + h.hover_at(at); + h.run_steps(2); + h.drag_at(at); // press + h.drop_at(at); // release + h.run_steps(8); + let v = &h.state().views[0]; + assert_eq!((v.current, v.zoom), (1, 4.0), "the link goes to page 2 at the same zoom"); + let d = from_viewport_corner(&h, 1, fx, fy); + assert!((d.x - 70.0).abs() < 2.0 && d.y.abs() < 2.0, "page 2 shows the same place, not its top: {d:?}"); +} + /// Two pages with a text field on each and a non-embedded Helvetica. const FORM: &[u8] = b"%PDF-1.7 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R 7 0 R] >> >> endobj diff --git a/crates/ui-egui/tests/it/viewer.rs b/crates/ui-egui/tests/it/viewer.rs index 8052e6a316..7795e05134 100644 --- a/crates/ui-egui/tests/it/viewer.rs +++ b/crates/ui-egui/tests/it/viewer.rs @@ -85,7 +85,9 @@ fn view_history_select_all_and_find_options() { assert_eq!(h.state().views[0].current, 0); assert!(h.state_mut().views[0].view_history(true)); assert_eq!(h.state().views[0].current, 3, "next view"); - // Select all on the current page (once its text is known). + // Select all on the current page (once its text is known), one page at a time: scrolling + // continuously it takes the whole document's text (#2639). + h.state_mut().views[0].set_continuous(false); // Background rendering and text extraction can take seconds on a loaded CI runner (FreeBSD VM). for _ in 0..3000 { h.run_steps(2); @@ -1387,7 +1389,7 @@ fn pages_panel_thumbnails_have_a_context_menu() { h.run_steps(2); // The page right-clicked becomes the selection the menu acts on. assert_eq!(h.state().views[0].target_pages(), vec![2]); - for item in ["Extract pages", "Cut", "Paste after"] { + for item in ["Extract pages", "Split document…", "Cut", "Paste after"] { h.get_by_label(item); } h.get_by_label("Copy").click(); diff --git a/crates/ui-egui/tests/it/xfa.rs b/crates/ui-egui/tests/it/xfa.rs index 6db98a52b0..3b355d0bee 100644 --- a/crates/ui-egui/tests/it/xfa.rs +++ b/crates/ui-egui/tests/it/xfa.rs @@ -62,6 +62,14 @@ fn a_dynamic_xfa_form_is_laid_out_and_filled() { h.get_by_label("Hide field highlights"); } +#[test] +fn a_dynamic_xfa_form_says_when_laying_it_out_took_its_usage_rights_off() { + let xdp = pdfcraft_xfa::fixtures::template(2); + let h = open(pdfcraft_xfa::fixtures::usage_rights_shell(&xdp, "", false)); + // The note starts its own sentence after the layout's. + h.get_by_label_contains("laid out from its template: 2 pages, 11 fields. The form's usage rights were removed"); +} + #[test] fn a_dynamic_xfa_form_says_its_page_is_a_placeholder() { // No fields, the form lives in the XFA packets (an array of name/stream pairs here), but diff --git a/crates/ui-egui/tests/snapshots/panel_attachments.txt b/crates/ui-egui/tests/snapshots/panel_attachments.txt index c61b2f860a..eda049b492 100644 --- a/crates/ui-egui/tests/snapshots/panel_attachments.txt +++ b/crates/ui-egui/tests/snapshots/panel_attachments.txt @@ -1,7 +1,9 @@ Label = "Attachments" Label = "1" Button "Close" [off] +Button "Attach a file…" Image Label = "notes.txt" Label = "5 bytes" Button "Save attachment…" [off] +Button "Delete attachment" [off] diff --git a/crates/xfa/README.md b/crates/xfa/README.md index 7616625f36..23a6ef0d0e 100644 --- a/crates/xfa/README.md +++ b/crates/xfa/README.md @@ -70,6 +70,10 @@ be measured exponentially often in their nesting. A measurement budget backs thi field changes, and click for buttons. Every generated copy of a page-master button resolves its own PDF field to the shared XFA click script, so clicking an early copy works as well as the last, including through `js_run` and after save/reopen. + While this XFA layout is active, the engine does not + initialize Acrobat JavaScript with the PDF container's document-level scripts (including + non-XFA-viewer warnings). They stay available for inspection and saving; explicit console + scripts and XFA messages still run. Static XFA keeps its document scripts. Private keys PdfCraft writes (other viewers ignore them): diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs index a0307a6944..b76b3efa1a 100644 --- a/crates/xfa/src/fixtures.rs +++ b/crates/xfa/src/fixtures.rs @@ -505,6 +505,29 @@ pub fn fields_template(data: &str) -> String { ) } +/// Date fields whose edit pictures list alternatives, as forms let a date be typed several ways +/// (`date{YYYYMMDD}|date{YYMMDD}|…`); one with a single pattern; a text field; `code`, whose +/// only alternative is a text clause; `short`, whose first pattern has a two-digit year; and +/// `pick`, where two later patterns read some dates and only one gives a year the first keeps. +pub fn date_alternatives_template() -> String { + r##" + + + +"## + .to_string() +} + /// A form of `sections` sections of `rows` rows, each row with a calculated total (half in /// JavaScript, half in FormCalc) and a section sum over its rows: 2 × sections × rows + sections /// calculate scripts, every one reading values the earlier ones settled. @@ -597,6 +620,27 @@ pub fn shell(xdp: &str) -> Vec { assemble(objs) } +/// [`shell`] with usage rights: the catalog's `/Perms` holds a `/UR3` signature (a stand-in +/// dictionary, nothing verifies it), beside `others` (`/DocMDP 6 0 R`, say: object 6 is a +/// signature dictionary too). `indirect` puts `/Perms` in an object of its own. +pub fn usage_rights_shell(xdp: &str, others: &str, indirect: bool) -> Vec { + let perms = format!("<< /UR3 6 0 R {others} >>"); + let entry = if indirect { "7 0 R".to_string() } else { perms.clone() }; + let catalog = format!("<< /Type /Catalog /Pages 2 0 R /NeedsRendering true /AcroForm << /XFA 5 0 R /Fields [] >> /Perms {entry} >>"); + let mut objs: Vec> = vec![ + catalog.into_bytes(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R >>".to_vec(), + b"<< /Length 44 >>\nstream\nBT /F1 12 Tf 72 700 Td (Please wait...) Tj ET\nendstream".to_vec(), + format!("<< /Length {} >>\nstream\n{xdp}\nendstream", xdp.len()).into_bytes(), + b"<< /Type /Sig /Reference [<< /Type /SigRef /TransformMethod /UR3 >>] >>".to_vec(), + ]; + if indirect { + objs.push(perms.into_bytes()); + } + assemble(objs) +} + /// A dynamic form with two signature boxes of which `sign` has been signed: the file holds the /// field a signing application leaves (named by its SOM path, its value a signature dictionary) /// and the picture drawn in the box, a solid blue rectangle. diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index 23342168e7..670d43791a 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -14,7 +14,10 @@ use base64::Engine; use crate::XfaError; use crate::data::{DataNode, data_date_letters, data_path, data_to_iso, date_letters, is_on, iso_to_pattern}; -use crate::model::*; +use crate::model::{ + Access, Area, Border, Common, Draw, ExclGroup, Field, Font, HAlign, Layout, Margin, Node, Occur, PageArea, PageRole, Para, Placement, Presence, + Rect, Script, SomIndices, Stroke, Subform, Template, Ui, VAlign, Value, +}; use crate::text::{self, Block, Face, Span}; /// Most pages a form may lay out to. @@ -37,15 +40,52 @@ const MAX_IMAGE_BYTES: usize = 256 << 20; /// Placeholder for the page count in text laid out before the count is known. const PAGE_COUNT_MARK: char = '\u{E000}'; +/// How far below its baseline an underline is drawn, in the text's size: an underline is the +/// level line pushed just before its span, this far below it (the PDF writer tells it apart from +/// a rule by this). +pub(crate) const UNDERLINE_DROP: f64 = 0.1; + #[derive(Clone, Debug, PartialEq)] pub enum Item { - Fill { rect: Rect, color: [f64; 3] }, - Line { from: (f64, f64), to: (f64, f64), width: f64, color: [f64; 3], dashed: bool }, + Fill { + rect: Rect, + color: [f64; 3], + }, + Line { + from: (f64, f64), + to: (f64, f64), + width: f64, + color: [f64; 3], + dashed: bool, + }, + /// A box with rounded (or inverted) corners: its fill, and its edge drawn inside it. + Rounded(Box), Text(Span), - Image { rect: Rect, data: Arc>, content_type: String }, + Image { + rect: Rect, + data: Arc>, + content_type: String, + }, Widget(Box), } +/// A box with rounded corners ([`Item::Rounded`]). +#[derive(Clone, Debug, PartialEq)] +pub struct Rounded { + pub rect: Rect, + /// Top-left, top-right, bottom-right and bottom-left radii, each at most half the box's + /// shorter side. + pub radii: [f64; 4], + /// Corners whose curve goes into the box rather than around it, in the same order. + pub inverted: [bool; 4], + pub fill: Option<[f64; 3]>, + /// The edge: its width, colour and whether it is dashed. + pub edge: Option<(f64, [f64; 3], bool)>, + /// A page break cut the box here: that side has no edge, and its corners are square. + pub open_top: bool, + pub open_bottom: bool, +} + #[derive(Clone, Debug, PartialEq)] pub enum WidgetKind { Text, @@ -126,6 +166,8 @@ pub struct Widget { pub action: Option, /// Check boxes: the on and off values of the data; radio buttons: the on value. pub items: Vec, + /// A text field that doesn't scroll: it takes no more text than it shows (`DoNotScroll`). + pub no_scroll: bool, } #[derive(Clone, Debug, Default, PartialEq)] @@ -155,6 +197,8 @@ impl Ctx { Ctx { font: self.font.apply(&c.font), para: self.para.apply(&c.para), som: self.som.clone() } } + /// [`Self::child`] for object `c`, its SOM path extended by its name and `index` among the + /// siblings of that name (an unnamed object adds nothing). fn with_som(&self, c: &Common, index: usize) -> Ctx { let mut out = self.child(c); if let Some(n) = &c.name { @@ -177,6 +221,33 @@ struct Measured { size: (f64, f64), } +/// A point in the flow ([`Layouter::flow_mark`]): the page and content area, the height +/// reached there, and how many items the page had then. +#[derive(Clone, Copy, Debug)] +struct FlowMark { + page: usize, + area: usize, + content: usize, + y: f64, + items: usize, +} + +/// A flowed subform being laid out ([`Layouter::frames`]): where it sits across, its margins and +/// border, its part on the content area the flow is in, and its parts on earlier ones (page +/// index, the page's first item of the part, the part's box). +struct Frame { + x: f64, + w: f64, + margin: Margin, + border: Option, + part: FlowMark, + parts: Vec<(usize, usize, Rect)>, + /// Something inside didn't fit its content area: an item cut across areas, or one placed past + /// the area's bottom (a table row taller than an area, an overflow leader and the row after it + /// that don't fit together). The border is left out ([`Layouter::finish_frame`]). + lost: bool, +} + struct Cursor { area: usize, content: usize, @@ -211,6 +282,9 @@ struct Layouter<'t> { /// The fields and draws of the block being placed: (first item, one past the last, top, /// bottom) on the last page. A page break keeps each whole (see `split_block`). leaves: Vec<(usize, usize, f64, f64)>, + /// The flowed subforms being laid out, outermost first: a break between their children + /// closes their parts on one content area and opens new ones on the next. + frames: Vec, } /// How many instances of a repeating subform to lay out: what the data holds once it holds @@ -256,6 +330,7 @@ pub fn layout(tpl: &Template, data: Option<&DataNode>) -> Result measured: RefCell::new(HashMap::new()), measures: Cell::new(0), leaves: Vec::new(), + frames: Vec::new(), }; let ctx = Ctx { font: Font::default(), para: Para::default(), som: String::new() }; l.new_page()?; @@ -334,7 +409,7 @@ fn solid_extent(item: &Item) -> Option<(f64, f64)> { Item::Text(s) => Some((s.baseline - s.face.size * 0.8, s.baseline + s.face.size * 0.2)), Item::Image { rect, .. } => Some((rect.y, rect.bottom())), Item::Widget(w) => Some((w.rect.y, w.rect.bottom())), - Item::Fill { .. } | Item::Line { .. } => None, + Item::Fill { .. } | Item::Line { .. } | Item::Rounded(_) => None, } } @@ -369,6 +444,23 @@ fn divide(item: Item, cut: f64) -> (Option, Option) { (Some(Item::Line { from: lo, to: mid, width, color, dashed }), Some(Item::Line { from: mid, to: hi, width, color, dashed })) } } + Item::Rounded(r) => { + if r.rect.bottom() <= cut + 0.01 { + (Some(Item::Rounded(r)), None) + } else if r.rect.y >= cut - 0.01 { + (None, Some(Item::Rounded(r))) + } else { + // Each part keeps the corners on its side, rounded no more than its own height + // allows, and is open where it was cut. + let [tl, tr, br, bl] = r.radii; + let above = Rect::new(r.rect.x, r.rect.y, r.rect.w, cut - r.rect.y); + let below = Rect::new(r.rect.x, cut, r.rect.w, r.rect.bottom() - cut); + let fit = |v: f64, h: f64| v.min(h.max(0.0)); + let upper = Rounded { rect: above, radii: [fit(tl, above.h), fit(tr, above.h), 0.0, 0.0], open_bottom: true, ..(*r).clone() }; + let lower = Rounded { rect: below, radii: [0.0, 0.0, fit(br, below.h), fit(bl, below.h)], open_top: true, ..*r }; + (Some(Item::Rounded(Box::new(upper))), Some(Item::Rounded(Box::new(lower)))) + } + } other => (Some(other), None), } } @@ -380,6 +472,10 @@ fn moved(mut item: Item, dx: f64, dy: f64) -> Item { rect.x += dx; rect.y += dy; } + Item::Rounded(r) => { + r.rect.x += dx; + r.rect.y += dy; + } Item::Line { from, to, .. } => { *from = (from.0 + dx, from.1 + dy); *to = (to.0 + dx, to.1 + dy); @@ -401,7 +497,7 @@ fn is_row(node: &Node) -> bool { } /// The Acrobat date pattern for an XFA picture clause such as `date{YYYY-MM-DD}`. -fn date_pattern(picture: Option<&str>) -> String { +pub(crate) fn date_pattern(picture: Option<&str>) -> String { picture.and_then(date_letters).unwrap_or_else(|| "yyyy-mm-dd".into()) } @@ -521,7 +617,7 @@ impl Layouter<'_> { for s in b.spans { if s.face.underline { let w = s.face.width(&s.text); - let y = s.baseline + s.face.size * 0.1; + let y = s.baseline + s.face.size * UNDERLINE_DROP; self.push(Item::Line { from: (s.x, y), to: (s.x + w, y), width: (s.face.size * 0.06).max(0.3), color: s.face.color, dashed: false })?; } if !s.text.trim().is_empty() { @@ -589,6 +685,8 @@ impl Layouter<'_> { self.new_page() } + /// The current content area, how far down it the flow is, and whether anything has been + /// placed in it; before the first page, an empty letter-size page from its top. fn cursor(&self) -> (Rect, f64, bool) { match &self.cur { Some(c) => (c.rect, c.y, c.placed), @@ -596,6 +694,111 @@ impl Layouter<'_> { } } + /// Where the flow is now ([`FlowMark`]); `None` before the first page. + fn flow_mark(&self) -> Option { + let c = self.cur.as_ref()?; + Some(FlowMark { page: self.pages.len(), area: c.area, content: c.content, y: c.y, items: self.pages.last().map_or(0, |p| p.items.len()) }) + } + + /// Whether the flow is still in the content area `m` was taken in. + fn on_area(&self, m: &FlowMark) -> bool { + self.pages.len() == m.page && self.cur.as_ref().is_some_and(|c| c.area == m.area && c.content == m.content) + } + + /// Whether page `page` (an index) got anything from item `at` on. + fn has_items_from(&self, page: usize, at: usize) -> bool { + self.pages.get(page).is_some_and(|p| p.items.len() > at) + } + + /// Break the flowed subforms being laid out ([`Self::frames`]) across content areas, as + /// another viewer breaks such a section on a synthetic form: each one's part here ends after + /// its content and its bottom margin (the innermost first, each inside the next), the flow + /// moves to the next content area, and each starts a new part there after its top margin + /// (the outermost first). The top margins take only what the `need` points of what comes next + /// leave of the new area: a child that fits a fresh area but not after the margins would + /// otherwise run past its bottom, out of its border. `to_page` moves to a new page instead (a + /// child's own page break), which breaks the subforms around it the same way. + fn break_frames(&mut self, need: f64, to_page: bool) -> Result<(), XfaError> { + let (rect, mut y, _) = self.cursor(); + let page = self.pages.len().saturating_sub(1); + let here: Vec = self.frames.iter().map(|f| self.on_area(&f.part)).collect(); + let filled: Vec = self.frames.iter().map(|f| self.has_items_from(page, f.part.items)).collect(); + for ((f, here), filled) in self.frames.iter_mut().zip(here).zip(filled).rev() { + y = (y + f.margin.bottom).min(rect.bottom()); + if !here { + f.lost = true; + } else if filled && y - f.part.y >= 0.01 { + f.parts.push((page, f.part.items, Rect::new(f.x, f.part.y, f.w, y - f.part.y))); + } + } + if to_page { + self.new_page()?; + } else { + self.next_area()?; + } + let (rect, y, _) = self.cursor(); + let mut room = (rect.bottom() - y - need).max(0.0); + for i in 0..self.frames.len() { + let Some(mark) = self.flow_mark() else { break }; + let Some(f) = self.frames.get_mut(i) else { break }; + f.part = mark; + // `max` before `min`: a NaN margin counts as none and leaves `room` a number. + let top = f.margin.top.max(0.0).min(room); + room = (room - top).max(0.0); + self.advance(top); + } + Ok(()) + } + + /// Close flowed subform `f` once its content is placed: its last part ends where the flow + /// is, and its border (edges and fill) is drawn round each part, closed on every side, under + /// that part's content. One holding something that didn't fit its content area ([`Frame::lost`]) + /// is drawn without its border, and the layout says so. + fn finish_frame(&mut self, mut f: Frame) -> Result<(), XfaError> { + let Some(b) = f.border.take() else { return Ok(()) }; + if b.fill().is_none() && b.visible_edges() == [false; 4] { + return Ok(()); + } + let (area, end, _) = self.cursor(); + // The bottom margin stays in the content area, as at a break; content that itself ran + // past it has already cost the section its border ([`Self::overflowed`]). + let end = end.min(area.bottom()).max(end - f.margin.bottom); + let page = self.pages.len().saturating_sub(1); + if !self.on_area(&f.part) { + f.lost = true; + } else if self.has_items_from(page, f.part.items) && end - f.part.y >= 0.01 { + f.parts.push((page, f.part.items, Rect::new(f.x, f.part.y, f.w, end - f.part.y))); + } + if f.lost { + self.warn_once("a bordered section holding an item that doesn't fit on its page is drawn without its border"); + return Ok(()); + } + // The last part first: a part's place on its page stays where it was found. + for (page, at, rect) in f.parts.into_iter().rev() { + self.insert_border(&b, rect, page, at)?; + } + Ok(()) + } + + /// Draw border `b` round `rect` on page `page` (an index), under what that page got from item + /// `at` on. + fn insert_border(&mut self, b: &Border, rect: Rect, page: usize, at: usize) -> Result<(), XfaError> { + let before = self.pages.last().map_or(0, |p| p.items.len()); + self.draw_border(b, rect)?; + let drawn: Vec = match self.pages.last_mut() { + Some(p) => { + let from = before.min(p.items.len()); + p.items.drain(from..).collect() + } + None => return Ok(()), + }; + if let Some(target) = self.pages.get_mut(page) { + let at = at.min(target.items.len()); + target.items.splice(at..at, drawn); + } + Ok(()) + } + /// Move the cursor down (margins and spacing: the area still counts as empty). fn advance(&mut self, dy: f64) { if let Some(c) = &mut self.cur { @@ -609,16 +812,36 @@ impl Layouter<'_> { c.y += dy.max(0.0); c.placed = true; } + let (_, y, _) = self.cursor(); + self.overflowed(y); } - /// Make room for `h`: stay, or move to the next content area / page. Returns whether a - /// break happened. - fn make_room(&mut self, h: f64) -> Result { + /// Something was placed down to `bottom`: past the content area's bottom, the flowed subforms + /// it is inside can't hold it in a part, and are drawn without their border. + fn overflowed(&mut self, bottom: f64) { + let (rect, _, _) = self.cursor(); + if bottom > rect.bottom() + 0.01 { + for f in &mut self.frames { + f.lost = true; + } + } + } + + /// Make room for `h`, with `after` more drawn before it should it move (a table's overflow + /// leader): stay, or move to the next content area / page. Returns whether a break happened. + /// Something that fits a fresh area breaks the flowed subforms it is in with it + /// ([`Self::break_frames`]), a table's rows included; one taller than an area just moves on, + /// and those subforms are drawn without their border ([`Self::finish_frame`]). + fn make_room(&mut self, h: f64, after: f64) -> Result { let (rect, y, placed) = self.cursor(); if y + h <= rect.bottom() + 0.01 || !placed { return Ok(false); } - self.next_area()?; + if h <= rect.h + 0.01 { + self.break_frames(h + after, false)?; + } else { + self.next_area()?; + } Ok(true) } @@ -628,7 +851,7 @@ impl Layouter<'_> { fn flow_block(&mut self, node: &Node, x: f64, (w, h): (f64, f64), ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { let (rect, y, _) = self.cursor(); if y + h > rect.bottom() + 0.01 && h <= rect.h + 0.01 { - self.make_room(h)?; + self.make_room(h, 0.0)?; } let (rect, y, _) = self.cursor(); let first = self.pages.last().map_or(0, |p| p.items.len()); @@ -747,6 +970,7 @@ impl Layouter<'_> { c.y = (end - shift).min(c.rect.bottom()).max(c.y); c.placed = true; } + self.overflowed(end - shift); return Ok(()); } rest = below; @@ -773,7 +997,7 @@ impl Layouter<'_> { let Node::Subform(sf) = node else { return Ok(()) }; self.check_measures()?; if sf.break_before_page && self.page_has_flow() { - self.new_page()?; + self.break_frames(0.0, true)?; } // Data adds instances of a repeating subform (rows added in another viewer). let instances = self.instances(node, &ctx.som); @@ -794,6 +1018,14 @@ impl Layouter<'_> { continue; } let m = sf.common.margin; + // Its border and fill go round what it takes from here, a part on each content area. + let framed = match self.flow_mark() { + Some(part) => { + self.frames.push(Frame { x, w, margin: m, border: sf.common.border.clone(), part, parts: Vec::new(), lost: false }); + true + } + None => false, + }; self.advance(m.top); let inner_x = x + m.left; let inner_w = (w - m.horizontal()).max(1.0); @@ -818,9 +1050,12 @@ impl Layouter<'_> { } } self.advance(m.bottom); + if framed && let Some(frame) = self.frames.pop() { + self.finish_frame(frame)?; + } } if sf.break_after_page { - self.new_page()?; + self.break_frames(0.0, true)?; } Ok(()) } @@ -837,6 +1072,12 @@ impl Layouter<'_> { let cols = self.columns(sf, inner_w); let leader = sf.overflow_leader.as_ref().and_then(|name| sf.children.iter().position(|c| c.common().name.as_deref() == Some(name) && is_row(c))); + // The overflow leader starts each part after the first, before the row that moved. + let lead = leader.and_then(|li| match sf.children.get(li) { + Some(Node::Subform(l)) => Some((li, l.as_ref())), + _ => None, + }); + let lead_h = lead.map_or(0.0, |(_, l)| self.row_height(l, &cols, inner_w, ctx, depth + 1)); let sibs = SomIndices::of(&sf.children); for (ci, child) in sf.children.iter().enumerate() { if !child.common().presence.occupies() { @@ -851,15 +1092,13 @@ impl Layouter<'_> { self.warn_truncated(child, &ctx.som, instances); for inst in 0..instances { let h = self.row_height(row, &cols, inner_w, ctx, depth + 1); - if self.make_room(h)? - && let Some(li) = leader - && li != ci - && let Some(Node::Subform(l)) = sf.children.get(li) + let led = lead.filter(|(li, _)| *li != ci); + if self.make_room(h, led.map_or(0.0, |_| lead_h))? + && let Some((li, l)) = led { - let lh = self.row_height(l, &cols, inner_w, ctx, depth + 1); let (_, y, _) = self.cursor(); - self.draw_row(l, &cols, Rect::new(x, y, inner_w, lh), ctx, depth + 1, sibs.at(li))?; - self.place(lh); + self.draw_row(l, &cols, Rect::new(x, y, inner_w, lead_h), ctx, depth + 1, sibs.at(li))?; + self.place(lead_h); } let (_, y, _) = self.cursor(); self.draw_row(row, &cols, Rect::new(x, y, inner_w, h), ctx, depth + 1, csib + inst)?; @@ -1089,6 +1328,10 @@ impl Layouter<'_> { if cap.value.is_empty() && cap.reserve.is_none() { return None; } + // A check box has no text for an inline caption to run with: it is not shown. + if f.ui == Ui::CheckButton && cap.placement == Placement::Inline { + return None; + } let placement = if cap.placement == Placement::Inline { Placement::Left } else { cap.placement }; let font = ctx.font.apply(&cap.font); let para = ctx.para.apply(&cap.para); @@ -1129,13 +1372,18 @@ impl Layouter<'_> { }; match self.caption_reserve(f, inner_w, ctx) { Some((Placement::Top | Placement::Bottom, r)) => ui + r, - Some((_, r)) => { + Some((placement, r)) => { + // A check box's caption without a reserve is drawn in all the box leaves. + let w = match auto_check_reserve(f, placement, inner_w, None) { + Some(auto) => auto, + None => inner_w - r, + }; let cap = f.caption.as_ref().map(|c| { let rich = match &c.value { Value::Rich(r) => r.clone(), other => text::plain(&other.plain().unwrap_or_default()), }; - text::measure_height(&rich, (inner_w - r).max(1.0), &ctx.para.apply(&c.para), &Face::of(&ctx.font.apply(&c.font)), &|id, max| { + text::measure_height(&rich, w.max(1.0), &ctx.para.apply(&c.para), &Face::of(&ctx.font.apply(&c.font)), &|id, max| { self.embed(id, max) }) }); @@ -1403,11 +1651,35 @@ impl Layouter<'_> { } } + /// Draw border `b` around `rect`: its fill, then each shown edge as a line inside the box, or + /// the whole box as one rounded shape when its corners are rounded. fn draw_border(&mut self, b: &Border, rect: Rect) -> Result<(), XfaError> { + let vis = b.visible_edges(); + // Rounded corners: the box is one shape, filled and edged along its curves, when its + // edges are all alike (all shown and the same, or all hidden). Edges that differ keep + // square corners. + let radii = b.radii(rect.w, rect.h); + let alike = b.edges.iter().all(|e| *e == b.edges[0]); + if radii.iter().any(|r| *r > 0.0) && (vis == [true; 4] && alike || vis == [false; 4]) { + let e = b.edges[0]; + let edge = vis[0].then(|| { + let color = if e.stroke == Stroke::Relief { [0.55, 0.55, 0.55] } else { e.color.0 }; + (e.thickness.clamp(0.1, 20.0), color, matches!(e.stroke, Stroke::Dashed | Stroke::Dotted)) + }); + let shape = Rounded { + rect, + radii, + inverted: b.corners.map(|c| c.inverted), + fill: b.fill().map(|f| f.0), + edge, + open_top: false, + open_bottom: false, + }; + return self.push(Item::Rounded(Box::new(shape))); + } if let Some(fill) = b.fill() { self.push(Item::Fill { rect, color: fill.0 })?; } - let vis = b.visible_edges(); let (x0, y0, x1, y1) = (rect.x, rect.y, rect.right(), rect.bottom()); // Top, right, bottom, left; lines sit inside the box. let segs = [((x0, y0), (x1, y0)), ((x1, y0), (x1, y1)), ((x0, y1), (x1, y1)), ((x0, y0), (x0, y1))]; @@ -1578,6 +1850,7 @@ impl Layouter<'_> { let inner = rect.inset(&f.common.margin); let (mut ui, mut cap_rect) = (inner, None); if let Some((placement, reserve)) = self.caption_reserve(f, inner.w, ctx) { + let reserve = auto_check_reserve(f, placement, inner.w, Some(inner.h)).unwrap_or(reserve); let r = reserve.max(0.0); match placement { Placement::Top => { @@ -1715,12 +1988,21 @@ impl Layouter<'_> { let (kind, rect, border, value, tooltip) = match f.ui { Ui::CheckButton => { let s = f.check_size.unwrap_or(10.0).min(ui.w.max(1.0)).min(ui.h.max(1.0)); - let y = match fctx.para.v_align { - VAlign::Top => ui.y + f.ui_margin.top, - VAlign::Middle => ui.y + (ui.h - s) / 2.0, - VAlign::Bottom => ui.bottom() - s - f.ui_margin.bottom, + // Without a caption the box sits at the right of the content area, as if a + // caption on its left took the rest, so the paragraph's alignment has no room to + // act on; beside a caption that alignment places it. The box's own margin doesn't + // move it. + let x = match (cap_rect, fctx.para.h_align) { + (None, _) | (Some(_), HAlign::Right) => ui.right() - s, + (Some(_), HAlign::Center) => ui.x + (ui.w - s) / 2.0, + (Some(_), HAlign::Left | HAlign::Justify) => ui.x, }; - let r = Rect::new(ui.x + f.ui_margin.left, y, s, s); + let y = match fctx.para.v_align { + VAlign::Top => ui.y, + VAlign::Middle => ui.y + (ui.h - s) / 2.0, + VAlign::Bottom => ui.bottom() - s, + }; + let r = Rect::new(x, y, s, s); let on = f.items.first().cloned().filter(|s| !s.trim().is_empty()).unwrap_or_else(|| "1".into()); let (kind, tip, checked) = match radio { Some((group, on_value, tip, chosen)) => ( @@ -1804,10 +2086,27 @@ impl Layouter<'_> { default: template_default, action, items, + no_scroll: f.no_scroll, }))) } } +/// The room a caption without a reserve takes beside a check box: all the box leaves of a +/// content area `w` wide (and `h` tall, when known), the box at the size it can be drawn there. +/// `None` for any other field, or a caption with a reserve of its own. +fn auto_check_reserve(f: &Field, placement: Placement, w: f64, h: Option) -> Option { + if f.ui != Ui::CheckButton || f.caption.as_ref().is_none_or(|c| c.reserve.is_some()) { + return None; + } + let s = f.check_size.unwrap_or(10.0).min(w.max(1.0)).min(h.map_or(f64::INFINITY, |h| h.max(1.0))); + let rest = match (placement, h) { + (Placement::Top | Placement::Bottom, Some(h)) => h - s, + (Placement::Top | Placement::Bottom, None) => return None, + _ => w - s, + }; + Some(rest.max(0.0)) +} + /// The widget's own border from the `` border element. Without one, a check box gets a /// solid box, as Designer draws it, and any other field has no box of its own. fn widget_border(b: Option<&Border>, check: bool) -> WidgetBorder { diff --git a/crates/xfa/src/lib.rs b/crates/xfa/src/lib.rs index 93e668257f..7fe372d53a 100644 --- a/crates/xfa/src/lib.rs +++ b/crates/xfa/src/lib.rs @@ -15,6 +15,7 @@ pub mod model; pub mod packets; pub mod parse; pub mod pdf; +pub mod picture; pub mod script; pub mod text; @@ -99,6 +100,7 @@ pub use layout::{Action, BorderShape, Form, Item, MAX_PAGES, Page, Widget, Widge 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 picture::typed_date; pub use script::{ FormNode, LiveForm, MAX_FORM_NODES, NodeKind, OVERRIDES_KEY, Overrides, ScriptEvent, apply_overrides, data_of, field_grows_with_text, fields_by_som, form_tree, has_scripts, overrides, refresh_value, rerender, set_overrides, template_of, @@ -165,6 +167,9 @@ pub fn render_into(doc: &mut Document) -> Result { let written = pdf::write_form(doc, &form)?; let mut warnings = form.warnings; warnings.extend(written.warnings); + if written.usage_rights { + warnings.push(crate::pdf::USAGE_RIGHTS_REMOVED.into()); + } warnings.dedup(); Ok(Report { pages: written.pages, fields: written.fields, template_bytes: packets.xdp.len(), warnings }) } diff --git a/crates/xfa/src/model.rs b/crates/xfa/src/model.rs index b60180b7fa..0fac8db18e 100644 --- a/crates/xfa/src/model.rs +++ b/crates/xfa/src/model.rs @@ -125,11 +125,22 @@ impl Edge { } } -/// A box border: four edges (top, right, bottom, left) and a fill. +/// One corner of a border: how far it rounds the box (`radius`), and whether the curve goes +/// into the box (`inverted`) rather than around it. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Corner { + pub presence: Presence, + pub radius: f64, + pub inverted: bool, +} + +/// A box border: four edges (top, right, bottom, left), four corners (top-left, top-right, +/// bottom-right, bottom-left) and a fill. #[derive(Clone, Debug, Default, PartialEq)] pub struct Border { pub presence: Presence, pub edges: [Edge; 4], + pub corners: [Corner; 4], pub fill: Option, } @@ -144,6 +155,15 @@ impl Border { pub fn fill(&self) -> Option { if self.presence == Presence::Visible { self.fill } else { None } } + + /// How far each corner (top-left, top-right, bottom-right, bottom-left) rounds a box `w` by + /// `h`: its radius when the corner is shown, no more than half the box's shorter side, and 0 + /// for a hidden corner or a hidden border. + pub fn radii(&self, w: f64, h: f64) -> [f64; 4] { + let most = (w.min(h) / 2.0).max(0.0); + let shown = self.presence == Presence::Visible; + self.corners.map(|c| if shown && c.presence == Presence::Visible && c.radius.is_finite() { c.radius.clamp(0.0, most) } else { 0.0 }) + } } /// Font properties as written on an element; `None` inherits from the ancestors. @@ -486,6 +506,9 @@ pub struct Field { pub check_size: Option, pub check_round: bool, pub multiline: bool, + /// The field doesn't scroll in the direction its text runs out (`hScrollPolicy="off"` for one + /// line, `vScrollPolicy="off"` for several): it takes no more text than it shows. + pub no_scroll: bool, pub caption: Option"#); + // (name, width, box size, check box children, field children, box x and y in inches) + let cases: Vec = vec![ + ("none", "1.2in", Some("0.25in"), String::new(), String::new(), 0.95, 0.0), + ("left_margin", "1.2in", Some("0.25in"), String::new(), m(r#"leftInset="0.3in""#), 0.95, 0.0), + ("right_margin", "1.2in", Some("0.25in"), String::new(), m(r#"rightInset="0.3in""#), 0.65, 0.0), + ("both_margins", "1.2in", Some("0.25in"), String::new(), m(r#"leftInset="0.1in" rightInset="0.4in""#), 0.55, 0.0), + ("h_left", "1.2in", Some("0.25in"), String::new(), p(r#"hAlign="left""#), 0.95, 0.0), + ("h_center", "1.2in", Some("0.25in"), String::new(), p(r#"hAlign="center""#), 0.95, 0.0), + ("h_right", "1.2in", Some("0.25in"), String::new(), p(r#"hAlign="right""#), 0.95, 0.0), + ("h_justify", "1.2in", Some("0.25in"), String::new(), p(r#"hAlign="justify""#), 0.95, 0.0), + ("center_right_margin", "1.2in", Some("0.25in"), String::new(), m(r#"rightInset="0.3in""#) + &p(r#"hAlign="center""#), 0.65, 0.0), + ("left_left_margin", "1.2in", Some("0.25in"), String::new(), m(r#"leftInset="0.3in""#) + &p(r#"hAlign="left""#), 0.95, 0.0), + ("v_top_margin", "1.2in", Some("0.25in"), String::new(), m(r#"topInset="0.15in""#) + &p(r#"vAlign="top""#), 0.95, 0.15), + ("v_middle_margin", "1.2in", Some("0.25in"), String::new(), m(r#"topInset="0.2in""#) + &p(r#"vAlign="middle""#), 0.95, 0.275), + ("v_bottom_margin", "1.2in", Some("0.25in"), String::new(), m(r#"bottomInset="0.15in""#) + &p(r#"vAlign="bottom""#), 0.95, 0.2), + ("v_bottom", "1.2in", Some("0.25in"), String::new(), p(r#"vAlign="bottom""#), 0.95, 0.35), + ("cap_left_reserve", "1.5in", Some("0.25in"), String::new(), cap(r#"placement="left" reserve="0.5in""#, "Cap"), 0.5, 0.0), + ("cap_right_reserve", "1.5in", Some("0.25in"), String::new(), cap(r#"placement="right" reserve="0.5in""#, "Cap"), 0.0, 0.0), + ( + "cap_left_reserve_center", + "1.5in", + Some("0.25in"), + String::new(), + cap(r#"placement="left" reserve="0.5in""#, "Cap") + &p(r#"hAlign="center""#), + 0.875, + 0.0, + ), + ( + "cap_left_reserve_right", + "1.5in", + Some("0.25in"), + String::new(), + cap(r#"placement="left" reserve="0.5in""#, "Cap") + &p(r#"hAlign="right""#), + 1.25, + 0.0, + ), + ( + "cap_left_reserve_middle", + "1.5in", + Some("0.25in"), + String::new(), + cap(r#"placement="left" reserve="0.5in""#, "Cap") + &p(r#"vAlign="middle""#), + 0.5, + 0.175, + ), + ( + "cap_right_reserve_center", + "1.5in", + Some("0.25in"), + String::new(), + cap(r#"placement="right" reserve="0.5in""#, "Cap") + &p(r#"hAlign="center""#), + 0.375, + 0.0, + ), + ("cap_top_reserve", "1.5in", Some("0.25in"), String::new(), cap(r#"placement="top" reserve="0.25in""#, "Cap"), 0.0, 0.25), + ("cap_bottom_reserve", "1.5in", Some("0.25in"), String::new(), cap(r#"placement="bottom" reserve="0.25in""#, "Cap"), 0.0, 0.0), + ( + "cap_hidden_reserve", + "1.5in", + Some("0.25in"), + String::new(), + cap(r#"placement="left" reserve="0.5in" presence="hidden""#, "Cap"), + 1.25, + 0.0, + ), + ("cap_empty_reserve", "1.5in", Some("0.25in"), String::new(), r#""#); + // (name, field children, box x and y in inches from the field's top left) + let cases = [ + ("plain", String::new(), 1.25, 0.0), + ("right_margin", r#""#.to_string(), 0.95, 0.0), + ("centered", r#""#.to_string(), 1.25, 0.0), + ("cap_right_center", cap(r#"placement="right" reserve="0.5in""#) + r#""#, 0.375, 0.0), + ("cap_left_auto", cap(r#"placement="left""#), 1.25, 0.0), + ("cap_top_auto", cap(r#"placement="top""#), 0.0, 0.35), + ]; + let fields: String = cases.iter().enumerate().map(|(i, (name, extra, _, _))| radio(name, i as f64 * 1.6, extra)).collect(); + let body = format!( + r#"{fields}"# + ); + let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap(); + let laid = widgets(&form.pages[0]); + for (i, (name, _, x, y)) in cases.iter().enumerate() { + let w = laid.iter().find(|w| w.name == *name).unwrap_or_else(|| panic!("no radio button {name}")); + assert!(matches!(w.kind, WidgetKind::Radio { .. }), "{name} is a radio button"); + let fx = 36.0 + i as f64 * 1.6 * 72.0; + assert!((w.rect.x - fx - x * 72.0).abs() < 0.01, "{name}: box x {} in, expected {x}", (w.rect.x - fx) / 72.0); + assert!((w.rect.y - 36.0 - y * 72.0).abs() < 0.01, "{name}: box y {} in, expected {y}", (w.rect.y - 36.0) / 72.0); + } +} + +/// A caption without a reserve leaves room for the box at the size it is drawn, a box larger than +/// its field shrinking to fit; and a field without a height is as tall as that caption drawn in +/// all the box leaves, not at some narrower width. +#[test] +fn a_check_box_caption_without_a_reserve_is_measured_as_it_is_drawn() { + let cap = + r#""#; + let tall = r#""# + .to_string() + + cap + + "10"; + let form = layout_xml(&one_area_form(r#"layout="tb""#, &format!(r#"{tall}"#))) + .unwrap(); + let r = widgets(&form.pages[0]).iter().find(|w| w.name == "tall").map(|w| w.rect).unwrap(); + assert!((r.w - 18.0).abs() < 0.01, "the box shrinks to the field's height: {}", r.w); + assert!((r.x - 36.0 - 1.25 * 72.0).abs() < 0.01, "and still sits at the right: {} in", (r.x - 36.0) / 72.0); + + let grows = r#""#.to_string() + + cap + + r#"10"#; + let form = layout_xml(&one_area_form(r#"layout="tb""#, &format!(r#"{grows}"#))).unwrap(); + let next = widgets(&form.pages[0]).iter().find(|w| w.name == "next").map(|w| w.rect).unwrap(); + assert!((next.y - 36.0 - 18.0).abs() < 0.01, "a one-line caption beside the box keeps the field the box's height: {}", next.y - 36.0); +} + +/// A caption without a reserve beside a check box takes all the room the box leaves, and starts +/// right after it; an inline caption has no text to run with and is not drawn. +#[test] +fn a_check_box_caption_without_a_reserve_takes_the_rest_of_the_field() { + let cap = |place: &str, text: &str| { + format!(r#""#) + }; + let body = format!( + r#"{}{}{}{}"#, + placed_check("after", 0.0, 0.0, "1.5in", Some("0.25in"), "", &cap("right", "Right")), + placed_check("before", 2.0, 0.0, "1.5in", Some("0.25in"), "", &cap("left", "Left")), + placed_check("below", 4.0, 0.0, "1.5in", Some("0.25in"), "", &cap("bottom", "Below")), + placed_check("inline", 0.0, 1.0, "1.5in", Some("0.25in"), "", &cap("inline", "Inline")), + ); + let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap(); + let span = |t: &str| { + form.pages[0].items.iter().find_map(|i| match i { + Item::Text(s) if s.text == t => Some(s.clone()), + _ => None, + }) + }; + let right = span("Right").expect("the right caption is drawn"); + assert!((right.x - 36.0 - 18.0).abs() < 0.01, "a right caption starts after the box: {}", right.x - 36.0); + let left = span("Left").expect("the left caption is drawn"); + assert!((left.x - 36.0 - 2.0 * 72.0).abs() < 0.01, "a left caption starts at the field's left: {}", left.x - 36.0); + let below = span("Below").expect("the bottom caption is drawn"); + assert!(below.baseline - 36.0 > 18.0, "a bottom caption is below the box: {}", below.baseline - 36.0); + assert!(span("Inline").is_none(), "an inline caption beside a check box is not drawn"); +} + +/// A caption reserve of zero sizes the caption to its content, as no reserve does: beside a check +/// box it takes the rest of the field, and before a text field it is as wide as its text. +#[test] +fn a_zero_caption_reserve_sizes_the_caption_as_none_does() { + let cap = |reserve: &str| { + format!(r#""#) + }; + let text = |name: &str, y: &str, reserve: &str| { + format!( + r#""# + ) + }; + let body = format!( + r#"{}{}{}"#, + placed_check("zero", 0.0, 0.0, "1.5in", Some("0.25in"), "", &cap(r#" reserve="0""#)), + text("withZero", "1in", r#" reserve="0in""#), + text("without", "2in", ""), + ); + let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap(); + let right = form.pages[0].items.iter().find_map(|i| if let Item::Text(s) = i { (s.text == "Right").then_some(s.x) } else { None }); + assert!(right.is_some_and(|x| (x - 36.0 - 18.0).abs() < 0.01), "the caption starts after the box: {right:?}"); + let x_of = |name: &str| widgets(&form.pages[0]).iter().find(|w| w.name == name).map(|w| w.rect.x).unwrap(); + assert!(x_of("withZero") > 36.0 + 1.0, "the caption takes its text's width: {}", x_of("withZero")); + assert!((x_of("withZero") - x_of("without")).abs() < 0.01, "as without a reserve: {} {}", x_of("withZero"), x_of("without")); +} + #[test] fn a_signed_signature_box_shows_the_signature_the_file_holds() { let mut doc = Document::open(Arc::new(crate::fixtures::signed_shell())).unwrap(); @@ -2500,6 +3307,37 @@ fn an_anchor_type_places_the_named_point_of_the_box_at_its_x_and_y() { assert!((field.rect.x - 6.0 * 72.0).abs() < 0.01 && (field.rect.y - 72.0).abs() < 0.01, "{:?}", field.rect); } +#[test] +fn laying_a_form_out_takes_off_the_usage_rights_it_voids() { + use crate::fixtures::usage_rights_shell; + let xdp = fields_template("
JP"); + // What `/Perms` holds once the layout is saved and the file read again. + let laid_out = |bytes: Vec| { + let mut doc = Document::open(Arc::new(bytes)).unwrap(); + let report = render_into(&mut doc).unwrap(); + let doc = Document::open(Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap())).unwrap(); + let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap(); + let indirect = catalog.get(b"Perms").and_then(Object::as_ref).is_some(); + let perms = catalog.get(b"Perms").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().cloned()); + let said = report.warnings.iter().any(|w| w.contains("usage rights")); + (perms, indirect, said, report.warnings) + }; + // Usage rights alone: `/Perms` goes, and the user is told. + let (perms, _, said, warnings) = laid_out(usage_rights_shell(&xdp, "", false)); + assert!(perms.is_none(), "{perms:?}"); + assert!(said, "{warnings:?}"); + // Beside a certification, in an object of its own: only the usage rights go, and the + // object is rewritten where it is. + let (perms, indirect, said, warnings) = laid_out(usage_rights_shell(&xdp, "/DocMDP 6 0 R", true)); + let perms = perms.expect("the certification stays"); + assert!(perms.contains(b"DocMDP") && !perms.contains(b"UR3"), "{perms:?}"); + assert!(indirect, "/Perms is still its own object"); + assert!(said, "{warnings:?}"); + // A form without usage rights says nothing about them. + let (perms, _, said, warnings) = laid_out(shell(&xdp)); + assert!(perms.is_none() && !said, "{perms:?} {warnings:?}"); +} + #[test] fn stripping_takes_the_xfa_out_and_leaves_an_ordinary_document() { use crate::pdf::strip; @@ -2697,6 +3535,40 @@ fn trailing_spaces_do_not_shift_centered_or_right_aligned_text() { assert!((left_edge("right", "TITLE ") - (36.0 + w - ink)).abs() < 1e-6); } +/// `typed_date` rewrites a date typed by another of a field's edit-picture patterns in the first +/// one, and leaves alone a date the first pattern reads, text no pattern reads, a field with one +/// pattern and a field that isn't a date. +#[test] +fn a_date_typed_by_another_of_its_edit_pictures_patterns_is_written_in_the_first() { + let (tpl, _) = crate::parse(&crate::fixtures::date_alternatives_template()).unwrap(); + let typed = |som: &str, text: &str| crate::typed_date(&tpl, som, text); + let leave = "form[0].leave[0]"; + assert_eq!(typed(leave, "261018").as_deref(), Some("20261018"), "by yymmdd"); + assert_eq!(typed(leave, "2026118").as_deref(), Some("20261108"), "by yyyymd"); + assert_eq!(typed(leave, "26 10 18").as_deref(), Some("20261018"), "by yy mm dd"); + assert_eq!(typed(leave, "20261018"), None, "the first pattern reads it: it stays as typed"); + assert_eq!(typed(leave, "not a date"), None); + assert_eq!(typed(leave, "2618"), None, "no pattern has room for four digits"); + assert_eq!(typed("form[0].born[0]", "181026"), None, "one pattern: nothing else to try"); + assert_eq!(typed("form[0].name[0]", "261018"), None, "not a date field"); +} + +/// Only date clauses are other ways to type a date, and a date is rewritten only when the first +/// pattern keeps it: a later `text{…}` clause is no date pattern, a year a two-digit first +/// pattern can't hold leaves the text as typed instead of becoming another date, and a pattern +/// whose date the first can't keep gives way to a later one whose date it can. +#[test] +fn a_typed_date_is_rewritten_only_by_date_clauses_into_a_first_pattern_that_keeps_it() { + let (tpl, _) = crate::parse(&crate::fixtures::date_alternatives_template()).unwrap(); + let typed = |som: &str, text: &str| crate::typed_date(&tpl, som, text); + assert_eq!(typed("form[0].code[0]", "261018"), None, "text{{YYMMDD}} is not a date alternative"); + let short = "form[0].short[0]"; + assert_eq!(typed(short, "20261018").as_deref(), Some("261018"), "a year the first pattern keeps"); + assert_eq!(typed(short, "19011018"), None, "1901 as 01 would read back as 2001"); + // DDMMYYYY reads 20011203 as 1203-01-20, which YYMMDD can't keep; YYYYMMDD reads 2001-12-03. + assert_eq!(typed("form[0].pick[0]", "20011203").as_deref(), Some("011203"), "the next pattern that the first keeps"); +} + /// A word longer than its line is broken by character in work linear in its length. #[test] fn a_long_word_breaks_in_linear_work() { @@ -2832,3 +3704,79 @@ fn long_names_repeated_by_an_index_are_capped() { let data = build_data(&doc, &fields); assert!(data.len() < 2_000_000, "{} bytes of data built", data.len()); } + +/// #3441: a font the form already holds as an object stays that object when the XFA layout +/// writes its fields' fonts into `/DR`. +#[test] +fn rendering_keeps_the_forms_own_font_objects() { + let mut doc = Document::open(Arc::new(shell(&template(2)))).unwrap(); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type1")); + font.set(b"BaseFont".to_vec(), Object::name("Symbol")); + let mine = doc.add(Object::Dict(font)); + let mut fonts = Dict::new(); + fonts.set(b"Mine".to_vec(), Object::Ref(mine)); + let mut dr = Dict::new(); + dr.set(b"Font".to_vec(), Object::Dict(fonts)); + let root = doc.root().unwrap(); + match doc.get(root).as_dict().unwrap().get(b"AcroForm").cloned().unwrap() { + Object::Ref(af) => doc.update_dict(af, |d| d.set(b"DR".to_vec(), Object::Dict(dr))).unwrap(), + _ => doc + .update_dict(root, |c| { + if let Some(Object::Dict(af)) = c.get(b"AcroForm").cloned() { + let mut af = af; + af.set(b"DR".to_vec(), Object::Dict(dr)); + c.set(b"AcroForm".to_vec(), Object::Dict(af)); + } + }) + .unwrap(), + } + render_into(&mut doc).unwrap(); + let catalog = doc.get(root); + let acro = doc.resolve(catalog.as_dict().unwrap().get(b"AcroForm").unwrap()); + let dr = doc.resolve(acro.as_dict().unwrap().get(b"DR").unwrap()); + let fonts = doc.resolve(dr.as_dict().unwrap().get(b"Font").unwrap()); + assert_eq!(fonts.as_dict().unwrap().reference(b"Mine"), Some(mine), "the form's own font object"); + assert!(fonts.as_dict().unwrap().reference(b"Helv").is_some(), "and the layout's fonts as objects"); +} + +/// Text is measured in the standard-14 face it is drawn in, at the widths ISO 32000-2 Annex D +/// gives: bold and Times are no longer Helvetica scaled, and the horizontal scale still applies. +#[test] +fn faces_measure_text_at_their_standard_14_widths() { + use pdfcraft_fonts::Std14; + let face = |family: text::Family, bold: bool, italic: bool| text::Face { + family, + bold, + italic, + size: 10.0, + color: [0.0; 3], + underline: false, + h_scale: 1.0, + }; + let sample = "Mill Wax 0123 Departure Date"; + for (family, bold, italic, std14) in [ + (text::Family::Helvetica, false, false, Std14::Helvetica), + (text::Family::Helvetica, true, false, Std14::HelveticaBold), + (text::Family::Helvetica, true, true, Std14::HelveticaBoldOblique), + (text::Family::Times, false, false, Std14::TimesRoman), + (text::Family::Times, true, false, Std14::TimesBold), + (text::Family::Times, false, true, Std14::TimesItalic), + (text::Family::Times, true, true, Std14::TimesBoldItalic), + (text::Family::Courier, true, false, Std14::CourierBold), + ] { + let f = face(family, bold, italic); + assert!( + (f.width(sample) - std14.text_width(sample, 10.0)).abs() < 1e-9, + "{std14:?}: {} vs {}", + f.width(sample), + std14.text_width(sample, 10.0) + ); + assert_eq!(f.family.std14(bold, italic), std14); + assert_eq!(f.family.base_font(bold, italic), std14.base_font()); + } + // A condensed face is narrower by its scale. + 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); +} diff --git a/crates/xfa/src/text.rs b/crates/xfa/src/text.rs index e1f4c548b0..77076b37e0 100644 --- a/crates/xfa/src/text.rs +++ b/crates/xfa/src/text.rs @@ -3,6 +3,7 @@ //! bundled here). use crate::model::{Font, HAlign, Para, Rect, RichText, Run}; +use pdfcraft_fonts::Std14; /// The standard-14 family a typeface maps to. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -29,22 +30,27 @@ impl Family { } } + /// The standard-14 font this family draws in, as `bold` and `italic` ask. + pub fn std14(self, bold: bool, italic: bool) -> Std14 { + match (self, bold, italic) { + (Family::Helvetica, false, false) => Std14::Helvetica, + (Family::Helvetica, true, false) => Std14::HelveticaBold, + (Family::Helvetica, false, true) => Std14::HelveticaOblique, + (Family::Helvetica, true, true) => Std14::HelveticaBoldOblique, + (Family::Courier, false, false) => Std14::Courier, + (Family::Courier, true, false) => Std14::CourierBold, + (Family::Courier, false, true) => Std14::CourierOblique, + (Family::Courier, true, true) => Std14::CourierBoldOblique, + (Family::Times, false, false) => Std14::TimesRoman, + (Family::Times, true, false) => Std14::TimesBold, + (Family::Times, false, true) => Std14::TimesItalic, + (Family::Times, true, true) => Std14::TimesBoldItalic, + } + } + /// The PDF base font name for a face. pub fn base_font(self, bold: bool, italic: bool) -> &'static str { - match (self, bold, italic) { - (Family::Helvetica, false, false) => "Helvetica", - (Family::Helvetica, true, false) => "Helvetica-Bold", - (Family::Helvetica, false, true) => "Helvetica-Oblique", - (Family::Helvetica, true, true) => "Helvetica-BoldOblique", - (Family::Courier, false, false) => "Courier", - (Family::Courier, true, false) => "Courier-Bold", - (Family::Courier, false, true) => "Courier-Oblique", - (Family::Courier, true, true) => "Courier-BoldOblique", - (Family::Times, false, false) => "Times-Roman", - (Family::Times, true, false) => "Times-Bold", - (Family::Times, false, true) => "Times-Italic", - (Family::Times, true, true) => "Times-BoldItalic", - } + self.std14(bold, italic).base_font() } /// The resource name used in content streams and `/DA` strings (Acrobat's conventions). @@ -121,16 +127,12 @@ impl Face { self.family.resource_name(self.bold, self.italic) } - /// Advance of `s` at this face's size. + /// Advance of `s` at this face's size: the standard-14 font it is drawn in, at that font's + /// own widths (ISO 32000-2 Annex D) and encoded as it is written, then the horizontal scale. pub fn width(&self, s: &str) -> f64 { #[cfg(test)] MEASURED.with(|m| m.set(m.get().saturating_add(s.len() as u64))); - let designed = match self.family { - Family::Courier => s.chars().count() as f64 * 0.6 * self.size, - Family::Helvetica => pdfcraft_fonts::helvetica_width(s, self.size) * if self.bold { 1.04 } else { 1.0 }, - Family::Times => pdfcraft_fonts::helvetica_width(s, self.size) * if self.bold { 0.96 } else { 0.92 }, - }; - designed * self.h_scale + self.family.std14(self.bold, self.italic).text_width(s, self.size) * self.h_scale } } @@ -153,6 +155,9 @@ pub struct Span { pub baseline: f64, pub text: String, pub face: Face, + /// Where the text links to (its run's ``), as the template writes it, shared with the + /// link's other spans. + pub link: Option>, } /// A laid-out block of text. @@ -168,6 +173,9 @@ struct Word { text: String, face: Face, width: f64, + /// The link of the run the word came from, shared, not copied, so a long link over many + /// words costs one address. + link: Option>, } /// The width of `w` without the spaces it ends with: what it takes at the end of a line, where @@ -224,12 +232,12 @@ fn words(runs: &[Run], base: &Face, embed: &dyn Fn(&str, usize) -> String, embed cur.push(ch); if ch == ' ' { let width = face.width(&cur); - out.push(Word { text: std::mem::take(&mut cur), face: face.clone(), width }); + out.push(Word { text: std::mem::take(&mut cur), face: face.clone(), width, link: run.link.clone() }); } } if !cur.is_empty() { let width = face.width(&cur); - out.push(Word { text: cur, face, width }); + out.push(Word { text: cur, face, width, link: run.link.clone() }); } } out @@ -277,7 +285,7 @@ pub fn layout(rich: &RichText, rect: Rect, para: &Para, base: &Face, embed: &dyn if advance > avail - indent(line.first) && piece.len() > ch.len_utf8() { piece.pop(); let width = w.face.width(&piece); - line.words.push(Word { text: std::mem::take(&mut piece), face: w.face.clone(), width }); + line.words.push(Word { text: std::mem::take(&mut piece), face: w.face.clone(), width, link: w.link.clone() }); line.width += width; line.size = line.size.max(w.face.size); let done = std::mem::replace(&mut line, Line { words: Vec::new(), width: 0.0, size: base.size, first: false }); @@ -290,7 +298,7 @@ pub fn layout(rich: &RichText, rect: Rect, para: &Para, base: &Face, embed: &dyn let width = w.face.width(&piece); line.size = line.size.max(w.face.size); line.width += width; - line.words.push(Word { text: piece, face: w.face.clone(), width }); + line.words.push(Word { text: piece, face: w.face.clone(), width, link: w.link.clone() }); } first_in_para = false; continue; @@ -335,16 +343,17 @@ pub fn layout(rich: &RichText, rect: Rect, para: &Para, base: &Face, embed: &dyn }; // Baseline: ascent sits about 0.8 of the size below the line top for these faces. let baseline = y + lh - (lh - line.size * 0.8).max(0.0) / 2.0 - line.size * 0.08; + // Words of one face and one link make one span, so a link's text is a span of its own. let mut run: Option = None; for w in line.words { match &mut run { - Some(s) if s.face == w.face => s.text.push_str(&w.text), + Some(s) if s.face == w.face && s.link == w.link => s.text.push_str(&w.text), _ => { if let Some(s) = run.take() { x += s.face.width(&s.text); spans.push(s); } - run = Some(Span { x, baseline, text: w.text, face: w.face }); + run = Some(Span { x, baseline, text: w.text, face: w.face, link: w.link }); } } } diff --git a/crates/xfdf/src/lib.rs b/crates/xfdf/src/lib.rs index a6da88f4db..6131c9c96b 100644 --- a/crates/xfdf/src/lib.rs +++ b/crates/xfdf/src/lib.rs @@ -271,8 +271,10 @@ fn text_of(doc: &Document, d: &Dict, k: &[u8]) -> Option { }) } +/// The numbers of a list; empty when any entry is non-finite (`nan`, `inf`), so the list is unusable as a whole. fn csv(list: &str) -> Vec { - list.split([',', ';', ' ']).filter(|t| !t.is_empty()).filter_map(|t| t.trim().parse().ok()).collect() + let v: Vec = list.split([',', ';', ' ']).filter(|t| !t.is_empty()).filter_map(|t| t.trim().parse().ok()).collect(); + if v.iter().all(|x| x.is_finite()) { v } else { Vec::new() } } fn arr(v: &[f64]) -> Object { diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 80e7d8049d..17c48c281b 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -1874,3 +1874,62 @@ fn a_comment_locked_by_an_indirect_flag_is_not_replaced_by_an_import() { assert!(annots.contains(&Object::Ref(note)), "the locked comment was replaced"); assert_eq!(doc.get(note), before, "the locked comment was changed"); } + +#[test] +fn import_skips_annotations_with_non_finite_coordinates() { + for rect in ["nan,10,100,100", "0,inf,100,100", "0,10,infinity,100", "0,10,100,-inf"] { + let mut doc = blank(); + let xfdf = format!( + r#""# + ); + let r = import(&mut doc, xfdf.as_bytes()).unwrap(); + assert_eq!(r.comments, 1, "rect {rect}: {r:?}"); + let names = annots_of(&doc); + assert_eq!(names.len(), 1, "rect {rect}: {names:?}"); + assert_eq!(names[0].get(b"NM"), Some(&PdfString::literal(b"good".to_vec()).into()), "rect {rect}"); + } +} + +#[test] +fn import_drops_non_finite_numbers_from_every_coordinate_list() { + let xfdf = r#" + + 1,2;inf,4;5,6 + 1,2;3,-inf + + + "#; + let mut doc = blank(); + let r = import(&mut doc, xfdf.as_bytes()).unwrap(); + assert_eq!(r.comments, 5, "{r:?}"); + fn finite(doc: &Document, o: &Object) -> bool { + match &*doc.resolve(o) { + Object::Real(x) => x.is_finite(), + Object::Array(a) => a.iter().all(|e| finite(doc, e)), + _ => true, + } + } + let annots = annots_of(&doc); + assert_eq!(annots.len(), 5); + for d in &annots { + for (k, v) in d.iter() { + if k.as_slice() != b"P" { + assert!(finite(&doc, v), "{d:?}"); + } + } + assert!(d.get(b"L").is_none() && d.get(b"CL").is_none() && d.get(b"Vertices").is_none(), "{d:?}"); + } +} + +fn annots_of(doc: &Document) -> Vec { + let page = &pdfcraft_model::pages(doc)[0]; + page.dict + .get(b"Annots") + .map(|a| doc.resolve(a)) + .and_then(|a| a.as_array().cloned()) + .unwrap_or_default() + .iter() + .map(|a| doc.resolve(a).as_dict().cloned().unwrap()) + .filter(|d| d.get(b"Subtype") != Some(&Object::name("Widget"))) + .collect() +} diff --git a/docs/development.md b/docs/development.md index 1142fed333..e50907ba56 100644 --- a/docs/development.md +++ b/docs/development.md @@ -28,6 +28,24 @@ the low-disk artifacts, run `cargo clean --profile low-disk`. Building PdfCraft requires Rust 1.95 or later: the UI crate and its `egui` and `craft-ui` dependencies need it, and the workspace `rust-version` states the same minimum. +## Annotation tool pin + +The annotation quick bar's **Keep tool selected** pin is a shared preference, stored as the +boolean `comment_tool_pinned`. It survives application restarts, including when switched off. +Startup uses Select; the remembered pin applies when an annotation tool is picked. Older settings +without the key keep the default one-shot behavior. Non-boolean values are ignored, preserving +the current choice and the other valid settings. + +The existing opt-in UI control channel uses the same preference: + +```sh +pdfcraft-cli ui --control /path/to/control.json set key=comment-tool-pinned value=true +pdfcraft-cli ui --control /path/to/control.json state +``` + +`ui.state.comment_tool_pinned` reports the boolean. `ui.set` accepts `true` or `false` and refuses +other values before changing the preference. This changes interface settings only. + ## Interface appearance Display theme saves System, Light or Dark separately from the palette currently drawn. New diff --git a/docs/editing-existing-text.md b/docs/editing-existing-text.md index ed17c08d96..426058f7b7 100644 --- a/docs/editing-existing-text.md +++ b/docs/editing-existing-text.md @@ -44,6 +44,15 @@ this works only when that build input actually contains the full face. This chan add a font binary or change the release font pin. Fonts shared by releases belong in `storytold/craft-fonts` under the repository's asset rules. +When mixed-style text needs outline fallback, characters using the same actual fallback face +share Type 3 fonts, with at most 240 distinct glyphs per font. Adjacent characters with the same +font and source style are written as one run. Latin characters that the source font can encode +stay in it; Japanese serif/sans and weight choices remain separate. More than 240 missing glyphs +use additional fonts; a styled paragraph needing more than 15,360 distinct fallback glyphs is +refused with an error. Unsupported glyphs are refused before fallback objects are written. +This does not expand glyph coverage, shape mixed contextual runs or remove old unused fonts +when editing again. The separate single-style fallback path is unchanged. + Rewrapped Chinese and Japanese text, which has no spaces between words, may break between any two characters. A row never begins with closing punctuation, a closing bracket or quote, a small kana or the prolonged sound mark, and never ends with an opening bracket or quote or a currency diff --git a/docs/localization.md b/docs/localization.md index 8800362378..90c669892d 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -24,7 +24,7 @@ The Lithuanian catalog has 2,585 entries and three integer plural forms. Calenda The Vietnamese catalog has 2,674 entries and one plural form (`plural_none`), covering the current catalog-key union. Preferences theme `Appearance` uses context `theme` → `Giao diện`; object/comment/signature appearance stays plain `Diện mạo`. Form fields use `Trường`; redaction apply wording states permanent removal. Calendar months are numeric (`Tháng 1`…`12` / `tháng 1`…`12`). A native Vietnamese-speaking contributor has reviewed the translation. Status stays `partial`: no translated help, and engine/OS details stay English inside a translated frame. -The Finnish catalog has 2,674 entries: every key of the other bundled catalogs, written from the English labels in ordinary Finnish computing vocabulary and sentence case. Finnish has the English one/other plural rule, with the partitive singular after any number but 1 (`1 sivu`, `2 sivua`, `0 sivua`). Calendar headings use the nominative month (`Lokakuu 2026`); dates use the partitive (`14. lokakuuta 2026`), and Finnish is also a date language for month and weekday names. Any `fi-*` locale selects it; `sv-FI` keeps English until PdfCraft has a Swedish catalog. The bundled Latin faces cover `ä ö å š ž`. The translation is AI-assisted (Claude Code) and has not yet been proofread line by line by a native speaker. +The Finnish catalog has 2,762 entries: every key of the other bundled catalogs, written from the English labels in ordinary Finnish computing vocabulary and sentence case. Finnish has the English one/other plural rule, with the partitive singular after any number but 1 (`1 sivu`, `2 sivua`, `0 sivua`). Calendar headings use the nominative month (`Lokakuu 2026`); dates use the partitive (`14. lokakuuta 2026`), and Finnish is also a date language for month and weekday names. Any `fi-*` locale selects it; `sv-FI` keeps English until PdfCraft has a Swedish catalog. The bundled Latin faces cover `ä ö å š ž`. The translation is AI-assisted (Claude Code) and has not yet been proofread line by line by a native speaker. The Catalan catalog has 2,592 entries, written from the meaning of the English labels in standard Catalan computing vocabulary. Commands use the second-person imperative (`Desa`, `Obre`, `Cancel·la`) and notices use impersonal or informal wording. Catalan has two plural forms like English (1 is singular; 0 and every other count take the plural: `1 pàgina`, `3 pàgines`). Every `ca-*` locale, including Valencian (`ca_ES@valencia`), uses this catalog. The interpunct of `l·l` is U+00B7, which the bundled Latin faces cover (tested in `tests/it/fonts.rs`). The date picker has Catalan month and weekday names; dates are day-first like French (`dddd d mmmm yyyy`), but after a day the month carries its own preposition, elided before a vowel (`7 de març`, `7 d'abril`, `8 d'octubre`), as Czech and Russian change theirs through `months_with_day`; the year takes `\de` from the pattern (`d mmmm \de yyyy`). A native Catalan speaker (the contributor) has reviewed the translation. diff --git a/docs/mcp.md b/docs/mcp.md index 77a3c9484d..8aa30ec6e8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -90,8 +90,10 @@ config works across the suite) keep the agent to the folders you give it: nearest existing folder's links are followed and the rest must be plain names, so `..` below a folder that doesn't exist is refused, as is a link that leads nowhere (writing through it would create its target). The result is compared with the root by whole folder names: `/work2/x` is not - inside `/work`. On Windows case is ignored, UNC paths are resolved like drive-letter ones, and - device names (`CON`, `NUL`, `COM1`...) are refused. + inside `/work`. On Windows case is ignored, device names (`CON`, `NUL`, `COM1`...) are refused, + and a path on another network share or in a device namespace than the root's (`\\host\share`, + `\\?\UNC\...`, `\\.\...`) is refused by name, before it is resolved, so the host is never + contacted. A root on a share accepts the paths on that share. - A refused path is an ordinary tool error the agent can read and correct, for example `automation path rejected: outside the write root /work/project/out: /etc/x.pdf` or `automation filesystem access is not granted: write authority is absent: /work/project/out/x.pdf`. diff --git a/docs/pdf-spec-parity.md b/docs/pdf-spec-parity.md index 4ce13f195a..8cfe4be471 100644 --- a/docs/pdf-spec-parity.md +++ b/docs/pdf-spec-parity.md @@ -1,6 +1,6 @@ # PDF specification and standards parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (§12.4.4: page transitions and display times read) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (§14.3: custom properties edited in Document Properties) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) How much of ISO 32000-2 (PDF 2.0) and the PDF subset standards PdfCraft implements, set against what Acrobat Pro implements. This is PdfCraft's equivalent of a "geometry" or "codec" checklist: @@ -44,7 +44,7 @@ are borrowed; PDF 2.0 additions and most subset standards are missing. **150–2 | §12.9 | Measurement and geospatial | full | 2D measuring with viewports and calibration; no geospatial | `comment.geospatial` planned | | §12.10 | Document requirements | yes | none | | | §13 | Multimedia: sound, movies, screen, rich media, 3D (U3D, PRC) | yes | preserved untouched; not played or shown | `misc.rich-media-*`, `misc.3d-view` planned (P3) | -| §14.3 | Metadata: info dictionary, XMP | full, XMP editor | info dictionary edited, with its XMP counterparts (title, author, subject, keywords, creator tool, producer) kept in step; XMP written for PDF/A; no XMP editor or custom properties | `organize.xmp-editor` planned | +| §14.3 | Metadata: info dictionary, XMP | full, XMP editor | info dictionary edited, with its XMP counterparts (title, author, subject, keywords, creator tool, producer) kept in step; XMP written for PDF/A; custom properties listed, added, changed and deleted (Document Properties ▸ Custom, `doc_info`, `doc_set_info`); no XMP editor | `organize.custom-properties` partial, `organize.xmp-editor` planned | | §14.6–14.8 | Marked content, logical structure, tagged PDF | full, with editing tools | read (checker, bookmarks from structure, redaction of tags); no tag editing or autotag | gap 11 | | §14.9 | Accessibility support: alt text, ActualText, language | full | alt text figure by figure, language, title | `a11y.alt-text-workflow` | | §14.10 | Web capture | yes | none | | diff --git a/docs/roadmap.md b/docs/roadmap.md index cddf47679b..02ee7b2125 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,13 @@ # PdfCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-22 · **Change:** minor (M14, #3558: rebase guarded macOS per-file association recovery onto current main; estimates unchanged) +> **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M8, #3205 follow-up: labelled Italian international phone numbers with a slash match whole; estimates unchanged) +> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged) +> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged) + +> **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M10: Word table cells use the widths of their own grid columns; estimates unchanged) +> **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M10: table spans retain text from covered slots in Office exports; estimates unchanged) +> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M10: image export preserves soft-mask Decode opacity ranges; estimates unchanged) +> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M10: vector printing preserves existing page XObjects when adding annotation appearances; estimates unchanged) > **Last reviewed:** 2026-10-20 · **Last updated:** 2026-10-20 · **Change:** minor (M12 #3673: Character encoding accepts Identity-H/V with Adobe CJK collections; estimates unchanged) > **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged) @@ -74,6 +81,8 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Traditional Chinese accessibility semantics and Taiwan form/table terminology; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** patch (M1 #1337: exhausted xref hints are repaired; allocation avoids overflow; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) > **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M6 #2892: Push-button normal labels are editable per widget through Options and form_set_props, independently of field names; rendering, undo/redo and incremental/full save/reopen are covered. Estimates unchanged) +> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2589: Active XFA skips PDF-container fallback scripts while retaining requested messages and saved scripts; ordinary/static/unlaid-out documents and explicit console/field calls keep their existing behavior. Estimates unchanged) +> **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M7 #2592: Styled fallback fonts batch up to 240 glyphs per actual face and retain source styles. Budget refusal and distinct same-BaseFont descriptor weights have mutation-checked regressions; coverage and complex mixed runs remain limited. Estimates unchanged) > **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M7 #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Estimates unchanged) > **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2569: Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Estimates unchanged) > **Last reviewed:** 2026-10-18 - **Last updated:** 2026-10-18 - **Change:** minor (M12 #2572: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Estimates unchanged) @@ -153,7 +162,7 @@ total matches the parity estimate. | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. Forms without Resources use page Properties for ActualText and layer visibility; own dictionaries stop fallback. Oversized missing inline-image samples, including header and terminator overhead, no longer pin the content window (#3107 / PR #3438); normal large streamed images retain deterministic sample boundaries. Radial luminosity fades have synthetic coverage for the existing #2132 mask guard, including per-pixel references, antialiased clips, previews, scales and tiles; no additional production fix was needed. Dotted TOC leaders no longer inflate letter tracking or hide word spaces (#3086); phrase search and selection are covered. Short tracked ellipses retain their gap samples and remain searchable. Missing spacing glyphs in non-embedded Japanese CID substitutes render blank without changing PDF advances or Unicode (#3601). | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | -| 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). #3808: the Pages inspector offers a 50–150% thumbnail slider and buttons, also available through `ui.set`. 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). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. Save-prompt shortcuts yield to dialogs above them; stacked link-consent UI regression covers Escape and Return. Comment hover follows individual marked lines and original annotation paint order (#3541/#3620), independently of spatial inspection sorting; synthetic rotation, differing-upper-edge overlap and unchanged-document regressions. Optional page gaps join page edges without cropping PDF margins; current-document toggle, persisted default and UI control options are covered by viewer tests. Gap changes in Organize Pages preserve document scroll position, and both comparison panes follow menu and control choices. | +| 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). #3808: the Pages inspector offers a 50–150% thumbnail slider and buttons, also available through `ui.set`. 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). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. Save-prompt shortcuts yield to dialogs above them; stacked link-consent UI regression covers Escape and Return. Comment hover follows individual marked lines and original annotation paint order (#3541/#3620), independently of spatial inspection sorting; synthetic rotation, differing-upper-edge overlap and unchanged-document regressions. Optional page gaps join page edges without cropping PDF margins; current-document toggle, persisted default and UI control options are covered by viewer tests. Gap changes in Organize Pages preserve document scroll position, and both comparison panes follow menu and control choices. Repeated window resizes by an edge and moves by the tab strip on GNOME/Wayland work without a click in between. | | 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). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. Wrapped revision buttons stay above Binding in Document Properties (#2739); a synthetic UI test checks layout and saved revision content. | | 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). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Later native-open replies stay queued while a modal or picker owns the foreground; regressions cover recipient ownership, completion/cancellation and one-shot wakes. | | 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). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; Ctrl+Q/Cmd+Q asks before discarding unsaved documents. | @@ -176,17 +185,17 @@ total matches the parity estimate. | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Refreshed against upstream `99941daa` (2026-10-15).
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. | -| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Done since: #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | +| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Done since: #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Done since: #2592: Styled fallback fonts batch up to 240 glyphs per actual face and retain source styles. Budget refusal and distinct same-BaseFont descriptor weights have mutation-checked regressions; coverage and complex mixed runs remain limited. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.
Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). #3212: Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, without revealing hidden content. Tracked editable text distinguishes repeated glyph spacing from word gaps, with synthetic paragraph/object regressions. Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. | | M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.
Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA
Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. Renaming a field renames it in the calculations that read it (field lists, notation, literal `getField` names), in one undo step, and says how many changed. Prepare a form ▸ Calculation order, a page of its own: each calculated field's formula and value, what is wrong (fields that calculate too early, loops, fields missing from `/CO`) and Fix order, Up/Down, Alt+↑/↓ and drag, and on the page each field's step and arrows to and from the field in focus (`form_calculation_order` headlessly). | | M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks | -| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). The redaction audit counts a filled cover that hides half of any one word of a long line (#2662), applied or not. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | +| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.
Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). The redaction audit counts a filled cover that hides half of any one word of a long line (#2662), applied or not. Phone-pattern redaction also matches labelled Italian geographic numbers with `+39` and a slash after the area code (#3205 follow-up); unlabelled international slash numbers remain unsupported. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding | | M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.
Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 | -| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3532 partial: vector printing retains existing appearances with absent Subtype through a print-owned Form copy; source artwork stays unchanged. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. | -| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Content stream boundaries preserve comments, split operands and graphics state when image fragments merge. Done since: reject invalid image and soft-mask dimensions with checked sizing (#2178). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | -| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Character encoding accepts Identity-H/V with Adobe CJK collections and predefined CJK CMaps independently (#3673); per-glyph validation and embedded-font extraction remain separate.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Done since: #2569: Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Done since: #2572: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Done since: #2570: Dynamic XFA rich-text fields carry exData/maxLength into AcroForm MaxLen for reported and enforced Unicode character limits, retained after save/reopen. Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA
Reading colours: pending page backgrounds follow the configured colour (#2059). | +| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).
Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3532 partial: vector printing retains existing appearances with absent Subtype through a print-owned Form copy; source artwork stays unchanged. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Word table cells use the grid columns they span, including cells after a merged cell. Done since: text in a table slot covered by another cell's span remains as paragraph text in Word, HTML, RTF and PowerPoint. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. Image export applies soft-mask Decode opacity intervals before resizing, including indirect entries and inverted ranges. Vector printing gives annotation appearances unoccupied XObject names so existing page artwork survives. | +| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Target-version compatibility is partial: newer content is not detected or converted. Content stream boundaries preserve comments, split operands and graphics state when image fragments merge. Image downsampling includes page-local `/UserUnit` scaling when measuring physical resolution. Done since: reject invalid image and soft-mask dimensions with checked sizing (#2178). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | +| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Character encoding accepts Identity-H/V with Adobe CJK collections and predefined CJK CMaps independently (#3673); per-glyph validation and embedded-font extraction remain separate.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Done since: #2569: Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Done since: #2572: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Done since: #2570: Dynamic XFA rich-text fields carry exData/maxLength into AcroForm MaxLen for reported and enforced Unicode character limits, retained after save/reopen. Done since: #2589: Active XFA skips PDF-container fallback scripts while retaining requested messages and saved scripts; ordinary/static/unlaid-out documents and explicit console/field calls keep their existing behavior. Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA
Reading colours: pending page backgrounds follow the configured colour (#2059). | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. | | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Character encoding accepts Identity-H/V with Adobe CJK collections and predefined CJK CMaps independently (#3673); per-glyph validation and embedded-font extraction remain separate.
Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded text-only auto-tag proposal tool; text-only Apply writes marked content, paragraph tags and a parent tree, then opens the Tags panel for review. Missing: mixed-content and field autotag, heading/list/table/figure classification, Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA
Done since: CLI info, text and render use dynamic XFA layout pages, with synthetic count/text/pixel and copy-permission regressions (#1645).
Reading colours: pending page backgrounds follow the configured colour (#2059). | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers
Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. | @@ -233,12 +242,22 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st ## Revision history +**2026-10-23 · minor:** M11: image downsampling uses physical resolution after page-local `/UserUnit` scaling, including shared nested forms. +Synthetic measurement and optimization regressions cover the change. +Estimates unchanged. + **2026-10-20 · minor:** M2/#3601: substitute-font spacing aliases preserve blank ink, original `/W` and `/DW` advances, and Unicode extraction. Synthetic renderer regressions cover the reported Japanese CID fixture. Estimates unchanged. **2026-10-15 · minor:** M14/#2383 candidate: GPU device requests clamp unsupported limits while retaining the adapter texture extent. Synthetic V3D/downlevel regressions and Windows GPU-required tests pass; Raspberry Pi hardware remains unverified. Estimates unchanged. | Date | Change | Summary | |---|---|---| +| 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. | +| 2026-10-23 | minor | M10: Export all images and Save Image As apply soft-mask Decode intervals to PNG alpha before resizing, including inverted/subrange, indirect, packed and JPEG masks. Estimates unchanged. | +| 2026-10-23 | minor | M10: annotation appearances use unoccupied XObject names, preserving original page artwork and distinct appearances through inherited resources and print/reopen. Estimates unchanged. | +| 2026-10-21 | minor | M3: on GNOME/Wayland, a second resize by an edge or move by the tab strip works without a click in between (the compositor kept the button's release from egui). Estimates unchanged. | | 2026-10-22 | minor | M4, #3916 partial: edits and Undo/Redo cancel Organize page drags. Pointer-release and save/reopen regressions check page order. Estimates unchanged. | | 2026-10-22 | minor | M14 #3558: rebased macOS association recovery; reuse #3042 shared metadata preservation instead of a duplicate save wrapper. Estimates unchanged. | | 2026-10-20 | minor | M4: initial-view window and interface options resolve indirect boolean ViewerPreferences values; synthetic true/false and save/reopen regressions retain defaults for malformed entries. Estimates unchanged. | @@ -332,3 +351,4 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st | 2026-10-12 | minor | M8: cleanup removes replacement descriptions only from changed marked-content spans, preserving surviving accessibility text. Estimates unchanged. | | 2026-10-12 | minor | M8: image redaction removes alternate images and unsupported masks only on affected images. Estimates unchanged. | | 2026-10-10 | major | Created from ROADMAP.md §Milestones, §Critical path, §Risks and §Where we're lacking. Merged the duplicated M1, M2, M3 and M14 rows, re-measured Done and Remaining, added Current focus and the ranked upcoming milestones | +| 2026-10-18 | minor | #3227: M11: integrate upstream b0e5c6eb; retain partial target-version compatibility claims and effective-version reporting, correcting the borrowed version parser lifetime. Scope and estimates unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c12461793a..703f1e9d8b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -52,7 +52,7 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24) | Rulers, grids, snap to grid, guides | yes | no | planned | | Cursor coordinates, line weights | yes | no | planned | | Measuring snap to paths, endpoints, midpoints, intersections | yes | yes | shipped | -| Nudge selected objects with arrow keys (Shift for larger steps) | yes | yes in Edit text: a point, Shift ten points, for selected objects, images and an open paragraph's box (#1520); and in Prepare a form, for the selected field (#2608) | shipped | +| Nudge selected objects with arrow keys (Shift for larger steps) | yes | yes in Edit text: a point, Shift ten points, for selected objects, images and an open paragraph's box (#1520); and in Prepare a form, for the selected field (#2608); a selected comment: a screen pixel, ⌘/Ctrl or Shift ten | shipped | | Single-key tool accelerators (H, V, Z, …) | yes, opt-in | no | planned | | Context menus on pages, thumbnails, comments, fields | yes | thumbnails, comments, fields | partial | | Comment pop-ups as movable windows, connector lines | yes | inline in the panel | planned | diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index b7813920e7..df292455e8 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -121,7 +121,7 @@ fi # ---- .deb / .rpm -------------------------------------------------------------------------------- if has deb || has rpm; then - command -v nfpm >/dev/null || { echo "error: nfpm not found (https://nfpm.goreleaser.com/install/)" >&2; exit 1; } + command -v nfpm >/dev/null || { echo "error: nfpm not found (https://nfpm.goreleaser.com/docs/install/)" >&2; exit 1; } export VERSION export RELEASE="1${PDFCRAFT_BUILD_LABEL:+.$PDFCRAFT_BUILD_LABEL}" export NFPM_ARCH="$DEB_ARCH" diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 560dfab60a..9d712aa920 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -586,8 +586,8 @@ milestone = "M4" status = "shipped" commands = ["file.close"] tools = ["doc_close"] -evidence = ["crates/ui-egui/tests/it/editing.rs::closing_a_dirty_tab_asks_and_cancel_keeps_it", "crates/ui-egui/tests/it/editing.rs::closing_a_dirty_tab_can_save_first", "crates/ui-egui/tests/it/editing.rs::quitting_with_unsaved_changes_asks_for_each_document", "crates/ui-egui/tests/it/editing.rs::save_prompt_stays_inside_the_screen_for_a_long_filename", "crates/ui-egui/tests/it/editing.rs::the_save_prompt_moves_between_buttons_with_the_keyboard", "crates/ui-egui/tests/it/editing.rs::the_save_prompt_escape_still_cancels_whatever_is_chosen", "crates/ui-egui/tests/it/editing.rs::the_save_prompt_starts_on_save_every_time_it_opens"] -notes = "Save is chosen when the prompt opens, so Enter saves. Left/Right (and Up/Down) and Tab/Shift+Tab move the choice between Don't save, Cancel and Save, shown with a focus ring; Enter or Space press the chosen button; Escape cancels. Alt+D, Alt+N and Ctrl/Cmd+D still answer Don't save. Not yet: the same keys on the other confirmations (Revert, reload, overwrite)." +evidence = ["crates/ui-egui/tests/it/editing.rs::closing_a_dirty_tab_asks_and_cancel_keeps_it", "crates/ui-egui/tests/it/editing.rs::closing_a_dirty_tab_can_save_first", "crates/ui-egui/tests/it/editing.rs::quitting_with_unsaved_changes_asks_for_each_document", "crates/ui-egui/tests/it/editing.rs::save_prompt_stays_inside_the_screen_for_a_long_filename", "crates/ui-egui/tests/it/editing.rs::the_save_prompt_moves_between_buttons_with_the_keyboard", "crates/ui-egui/tests/it/editing.rs::the_save_prompt_escape_still_cancels_whatever_is_chosen", "crates/ui-egui/tests/it/editing.rs::the_save_prompt_starts_on_save_every_time_it_opens", "crates/ui-egui/tests/it/editing.rs::a_save_prompt_opened_in_the_background_asks_for_attention_until_the_user_returns", "crates/ui-egui/tests/it/control.rs::a_save_prompt_answered_in_the_background_withdraws_its_attention_request"] +notes = "Save is chosen when the prompt opens, so Enter saves. Left/Right (and Up/Down) and Tab/Shift+Tab move the choice between Don't save, Cancel and Save, shown with a focus ring; Enter or Space press the chosen button; Escape cancels. Alt+D, Alt+N and Ctrl/Cmd+D still answer Don't save. A prompt that opens while PdfCraft is in the background (Quit in the Dock menu, logging out, an agent's ui.quit) asks once for the user's attention, as a critical request: the Dock icon bounces until PdfCraft is active, the Windows taskbar button flashes until the window is in front, and X11 sets the urgency hint; coming back or answering withdraws it. Not yet: the same keys on the other confirmations (Revert, reload, overwrite); the other confirmations don't ask for attention; on macOS a prompt answered without coming back (over the control channel) leaves the Dock icon bouncing until PdfCraft is activated, because winit can't withdraw the request there." [[feature]] id = "core.dirty-indicator" @@ -695,7 +695,19 @@ status = "shipped" commands = [] tools = [] evidence = ["crates/ui-egui/tests/it/editing.rs::the_last_session_reopens_its_files_pages_and_active_tab", "crates/ui-egui/tests/it/editing.rs::quitting_with_unsaved_changes_still_remembers_every_file", "crates/ui-egui/tests/it/editing.rs::the_last_session_is_off_by_default_and_skips_missing_and_duplicate_files", "crates/ui-egui/src/last_session.rs::malformed_sessions_are_skipped_and_capped"] -notes = "Desktop, off by default (Preferences ▸ Documents and view, #442): the files open when PdfCraft last closed reopen at the page each showed, with the same tab in front; files named on the command line open after them, in front; files that are gone, and documents the Recovery dialog offers, are skipped. Quitting with unsaved changes still remembers every tab. Not yet: zoom, layout and scroll position within the page (#418); only the first encrypted file asks for its password (later ones stay closed), and a file opened after it dismisses that prompt; a save that fails partway through a quit can leave the remembered list from that quit; never-saved documents and the web build have no paths to reopen." +notes = "Desktop, off by default (Preferences ▸ Documents and view, #442): the files open when PdfCraft last closed reopen at the page each showed, with the same tab in front; files named on the command line open after them, in front; files that are gone, and documents the Recovery dialog offers, are skipped. Quitting with unsaved changes still remembers every tab. Zoom and page display come back through core.restore-last-view (#1767); not yet the scroll position within the page (#418); only the first encrypted file asks for its password (later ones stay closed), and a file opened after it dismisses that prompt; a save that fails partway through a quit can leave the remembered list from that quit; never-saved documents and the web build have no paths to reopen." + +[[feature]] +id = "core.restore-last-view" +title = "Restore last view settings when reopening documents" +area = "A" +tier = "P2" +milestone = "M3" +status = "shipped" +commands = [] +tools = [] +evidence = ["crates/ui-egui/tests/it/ui.rs::a_file_opened_again_shows_its_last_view"] +notes = "Desktop, on by default (Preferences ▸ Documents and view, #1767): a file opened again shows the page, zoom and page display it was last shown with, remembered by path when its tab closes or when settings are saved with it open (the last 100 files), and applied over the file's own initial view. Not remembered: the view rotation (display only) and the scroll position within the page; Fit height comes back as Fit width (the settings name only two fits)." [[feature]] id = "core.usage-rights-preserve" @@ -707,7 +719,7 @@ status = "planned" commands = [] tools = [] evidence = [] -notes = "preserve only" +notes = "preserve only. A dynamic XFA form PdfCraft lays out loses them: the layout voids them, so they are taken off with a note (form.xfa-dynamic)" # ================================================================================================== # B. Viewing and navigation @@ -734,8 +746,8 @@ milestone = "M2" status = "partial" commands = [] tools = [] -evidence = ["crates/render/src/raster.rs::self_referencing_tiling_pattern_terminates"] -notes = "via hayro bootstrap" +evidence = ["crates/render/src/raster.rs::self_referencing_tiling_pattern_terminates", "crates/render/tests/it/tiling_cell_placement.rs::far_cells_of_a_fill_sit_at_whole_steps", "crates/render/tests/it/tiling_cell_placement.rs::far_cells_sit_at_whole_steps_under_a_turned_scaled_matrix", "crates/render/tests/it/tiling_cell_placement.rs::far_cells_of_a_stroke_sit_at_whole_steps"] +notes = "via hayro bootstrap. Tiling pattern cells sit at whole multiples of /XStep and /YStep at any zoom, filled or stroked, under any pattern matrix. Missing: a cell is cut at its step, so content reaching past the step into the next cell is lost; a cell pixmap is capped at 3000 pixels a side, so very large steps draw at reduced resolution" [[feature]] id = "view.render-transparency" @@ -771,7 +783,7 @@ status = "partial" commands = [] tools = [] evidence = [] -notes = "hayro bootstrap with vendored patches" +notes = "hayro bootstrap with vendored patches. Text rendering modes follow ISO 32000-2 §9.3.6 for Type 3 fonts: 3 and 7 render nothing, the others render the glyph descriptions filled (never stroked), and 4 to 7 add nothing to the clipping path (hayro-interpret patches 42 and 64). Coverage is synthetic fixtures; no fidelity comparison across a Type 3 corpus yet" [[feature]] id = "view.render-cid-fonts" @@ -783,7 +795,7 @@ status = "partial" commands = [] tools = [] evidence = ["crates/render/src/raster.rs::huge_cid_width_ranges_terminate", "crates/render/src/raster.rs::non_embedded_japanese_cid_fonts_draw_with_a_craft_fonts_face", "crates/render/src/raster.rs::mincho_substitute_covers_half_width_katakana", "crates/render/src/text.rs::identity_to_unicode_names_give_text", "crates/fonts/src/pdf.rs::identity_to_unicode_names_read_codes_as_unicode"] -notes = "CID-to-Unicode tables are M2.4; a non-embedded Adobe-Japan1 font is drawn in a craft-fonts Japanese face (Mincho or Gothic by name; BIZ UDMincho for Mincho, which covers half-width katakana) when built with craft-fonts; non-embedded GB1, CNS1 and Korea1 fonts still draw nothing (no allowed face); a `/ToUnicode` given as the name `/Identity-H` or `/Identity-V` (WSCAD) reads codes as Unicode for selection, search and editing, without control characters" +notes = "CID-to-Unicode tables are M2.4; a non-embedded Adobe-Japan1 font is drawn in a craft-fonts Japanese face (Mincho or Gothic by name, else by the descendant font descriptor's Serif flag; bold Gothic for a Bold name or a `/FontWeight` of 600 or more; BIZ UDMincho for Mincho, which covers half-width katakana) when built with craft-fonts; non-embedded GB1, CNS1 and Korea1 fonts still draw nothing (no allowed face); a `/ToUnicode` given as the name `/Identity-H` or `/Identity-V` (WSCAD) reads codes as Unicode for selection, search and editing, without control characters" [[feature]] id = "view.render-image-codecs" @@ -806,8 +818,8 @@ milestone = "M2" status = "shipped" commands = [] tools = [] -evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/render/src/raster.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source", "crates/engine/src/tests.rs::fields_left_to_the_viewer_are_displayed_from_their_values", "crates/forms/src/tests.rs::need_appearances_draws_every_text_and_choice_field_in_the_display_copy", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input"] -notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved; text and choice fields are drawn from their values when the form sets /NeedAppearances (every one) or a widget has no /N (#3470), also display-only and cached per widget (a field edit on a 1000-field /NeedAppearances form takes ~13 ms against ~7 ms on a form that needs none) (a read-only document still gets the renderer's Highlight fallback only); Link borders without /AP and printing are not covered yet" +evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/render/src/raster.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source", "crates/engine/src/tests.rs::fields_left_to_the_viewer_are_displayed_from_their_values", "crates/forms/src/tests.rs::need_appearances_draws_every_text_and_choice_field_in_the_display_copy", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::check_boxes_without_an_appearance_show_their_caption_symbol", "crates/forms/src/tests.rs::push_buttons_without_an_appearance_show_their_caption_and_frame", "crates/render/src/raster.rs::invisible_unknown_annotations_and_popups_paint_nothing", "crates/print/src/tests.rs::invisible_annotations_of_unknown_types_do_not_print", "crates/engine/src/tests.rs::stamps_without_appearances_draw_the_stamp_they_name", "crates/automation/tests/it/automation.rs::page_render_draws_stamps_without_an_appearance"] +notes = "comments without /AP (FreeText, Ink, notes, stamps, shapes, markup) are drawn from their dictionaries in the page view, page_render and page images, including the CLI render command, never saved; a stamp from another program draws the stamp its /Name names (the fourteen standard names of ISO 32000-2 Table 184, Draft when there is none) or, for any other name, a grey frame with both diagonals; text and choice fields are drawn from their values when the form sets /NeedAppearances (every one) or a widget has no /N (#3470), also display-only and cached per widget (a field edit on a 1000-field /NeedAppearances form takes ~13 ms against ~7 ms on a form that needs none) (a read-only document still gets the renderer's Highlight fallback only); check boxes and radio buttons with no appearance for their /AS are drawn display-only from /MK (background, border, and when on the /MK /CA ZapfDingbats symbol in the /DA colour, a check or a dot by default), and push buttons with none from /MK and /BS with their caption; Link borders without /AP and printing are not covered yet; an annotation of a non-standard type with the Invisible flag (ISO 32000-2 Table 167) is neither drawn nor printed, and a popup's own /AP is never painted on the page (its parent's text shows in the popup window, §12.5.6.14)" [[feature]] id = "view.render-error-isolation" @@ -1178,8 +1190,8 @@ milestone = "M3" status = "shipped" commands = [] tools = ["link_open"] -evidence = ["crates/ui-egui/tests/it/view.rs::clicking_a_link_goes_to_its_destination", "crates/ui-egui/tests/it/view.rs::link_destinations_scroll_to_their_position", "crates/render/src/inspect.rs::bookmarks_and_links_keep_their_destination_view", "crates/render/src/inspect.rs::links_read_bounded_local_remote_go_to_actions", "crates/automation/tests/it/automation.rs::local_gotor_link_open_applies_destinations_and_honors_root", "crates/ui-egui/tests/it/document_links.rs::a_local_gotor_link_opens_in_a_tab_and_applies_its_destination", "crates/ui-egui/tests/it/document_links.rs::an_absolute_gotor_waits_for_consent_and_keeps_its_destination_across_tabs", "crates/ui-egui/tests/it/document_links.rs::cancelling_an_absolute_gotor_by_button_escape_or_dismissal_changes_nothing", "crates/automation/tests/it/automation.rs::absolute_gotor_consent_does_not_bypass_validation_or_passwords", "crates/render/src/inspect.rs::launch_actions_that_name_a_pdf_open_it_like_a_remote_link", "crates/automation/tests/it/automation.rs::a_launch_link_to_a_pdf_opens_it_like_a_remote_link"] -notes = "GoTo and URI links plus local-PDF GoToR links: source-relative or local absolute /F paths, /UF preferred in file-spec dictionaries, explicit numeric page/view destinations, and internal tabs. Absolute local GoToR targets require desktop Allow/Cancel confirmation or an explicit allow_absolute opt-in in link_open; relative links open normally. link_open honors --root. Named remote destinations open at default view with a warning; /NewWindow is a tab hint. A Launch action whose /F names a .pdf file (and has no /Win launch parameters) opens that PDF the same way, backslash separators read as / (#4168); URL/network file systems, GoToE, other Launch actions and other actions are not executed. Same-document destinations retain their supported /XYZ, /Fit, /FitH, /FitV and /FitR views; /FitB* is read as whole-page fit." +evidence = ["crates/ui-egui/tests/it/view.rs::clicking_a_link_goes_to_its_destination", "crates/ui-egui/tests/it/view.rs::link_destinations_scroll_to_their_position", "crates/ui-egui/tests/it/view.rs::a_link_without_a_position_keeps_the_place_on_the_next_page", "crates/ui-egui/src/canvas.rs::a_null_xyz_coordinate_keeps_the_readers_place_on_the_new_page", "crates/render/src/inspect.rs::bookmarks_and_links_keep_their_destination_view", "crates/render/src/inspect.rs::links_read_bounded_local_remote_go_to_actions", "crates/automation/tests/it/automation.rs::local_gotor_link_open_applies_destinations_and_honors_root", "crates/ui-egui/tests/it/document_links.rs::a_local_gotor_link_opens_in_a_tab_and_applies_its_destination", "crates/ui-egui/tests/it/document_links.rs::an_absolute_gotor_waits_for_consent_and_keeps_its_destination_across_tabs", "crates/ui-egui/tests/it/document_links.rs::cancelling_an_absolute_gotor_by_button_escape_or_dismissal_changes_nothing", "crates/automation/tests/it/automation.rs::absolute_gotor_consent_does_not_bypass_validation_or_passwords", "crates/render/src/inspect.rs::launch_actions_that_name_a_pdf_open_it_like_a_remote_link", "crates/automation/tests/it/automation.rs::a_launch_link_to_a_pdf_opens_it_like_a_remote_link"] +notes = "GoTo and URI links plus local-PDF GoToR links: source-relative or local absolute /F paths, /UF preferred in file-spec dictionaries, explicit numeric page/view destinations, and internal tabs. Absolute local GoToR targets require desktop Allow/Cancel confirmation or an explicit allow_absolute opt-in in link_open; relative links open normally. link_open honors --root. Named remote destinations open at default view with a warning; /NewWindow is a tab hint. A Launch action whose /F names a .pdf file (and has no /Win launch parameters) opens that PDF the same way, backslash separators read as / (#4168); URL/network file systems, GoToE, other Launch actions and other actions are not executed. Same-document destinations retain their supported /XYZ, /Fit, /FitH, /FitV and /FitR views; /FitB* is read as whole-page fit. A null /XYZ left or top keeps the reader's place, as ISO 32000-2 §12.3.2.2 says and Acrobat Reader was observed to do (synthetic fixture): the user-space point at the window's top-left stays there on the target page at the same zoom, along each axis the page overflows; CAD exports (WSCAD) write every cross-reference this way. Null /FitH top and /FitV left still go to the page edge." [[feature]] id = "view.annotation-hover" @@ -1274,8 +1286,8 @@ milestone = "M4" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/editing.rs::dragging_pages_panel_thumbnails_reorders_pages", "crates/ui-egui/tests/it/viewer.rs::pages_panel_thumbnails_have_a_context_menu", "crates/ui-egui/tests/it/editing.rs::deleting_pages_from_the_pages_panel_menu", "crates/ui-egui/tests/it/editing.rs::the_delete_key_deletes_pages_picked_in_the_pages_panel_but_never_every_page"] -notes = "Drag the selection (or the page grabbed) to reorder, with a drop bar and edge scrolling; right-click offers Extract pages, Cut, Copy, Paste after and Delete pages (also the Delete key over the panel). Not yet: Rotate, Insert and Page labels in the menu, dragging pages between documents, or dropping files onto the panel." +evidence = ["crates/ui-egui/tests/it/editing.rs::dragging_pages_panel_thumbnails_reorders_pages", "crates/ui-egui/tests/it/viewer.rs::pages_panel_thumbnails_have_a_context_menu", "crates/ui-egui/tests/it/editing.rs::split_from_pages_panel_context_menu", "crates/ui-egui/tests/it/editing.rs::deleting_pages_from_the_pages_panel_menu", "crates/ui-egui/tests/it/editing.rs::the_delete_key_deletes_pages_picked_in_the_pages_panel_but_never_every_page"] +notes = "Drag the selection (or the page grabbed) to reorder, with a drop bar and edge scrolling; right-click offers Split document…, Extract pages, Cut, Copy, Paste after and Delete pages (also the Delete key over the panel). Not yet: Rotate, Insert and Page labels in the menu, dragging pages between documents, or dropping files onto the panel." [[feature]] id = "view.bookmarks-panel" @@ -1543,15 +1555,15 @@ notes = "Keyboard, Edit-menu, page-context-menu and floating-selection Copy hono [[feature]] id = "view.select-all" -title = "Select all text on a page" +title = "Select all text" area = "B" tier = "P1" milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/viewer.rs::view_history_select_all_and_find_options"] -notes = "⌘A on the current page" +evidence = ["crates/ui-egui/tests/it/viewer.rs::view_history_select_all_and_find_options", "crates/ui-egui/src/canvas.rs::select_all_copies_the_whole_document_while_scrolling_continuously", "crates/ui-egui/src/canvas.rs::select_all_of_the_document_only_while_scrolling_through_several_pages"] +notes = "⌘A: the current page one page at a time; the whole document scrolling continuously (#2639), copied whole" [[feature]] id = "view.copy-with-layout" @@ -1670,8 +1682,8 @@ milestone = "M3" status = "shipped" commands = ["view.theme.system"] tools = [] -evidence = ["crates/ui-egui/tests/it/theme.rs::toolbar_and_view_menu_share_three_state_preferences", "crates/ui-egui/tests/it/theme.rs::saved_preferences_restore_with_legacy_light_dark_settings", "crates/ui-egui/tests/it/control.rs::theme_commands_and_options_report_preference_and_effective_colours"] -notes = "Toolbar, Display theme and --theme system share a persisted System preference that follows OS changes without losing the selected mode; ui.state reports preference and resolved colours separately." +evidence = ["crates/ui-egui/tests/it/theme.rs::a_fresh_install_follows_the_system_theme", "crates/ui-egui/tests/it/theme.rs::toolbar_and_view_menu_share_three_state_preferences", "crates/ui-egui/tests/it/theme.rs::saved_preferences_restore_with_legacy_light_dark_settings", "crates/ui-egui/tests/it/control.rs::theme_commands_and_options_report_preference_and_effective_colours"] +notes = "Toolbar, Display theme and --theme system share a persisted System preference that follows OS changes without losing the selected mode; ui.state reports preference and resolved colours separately. System is the default on a fresh install (#1248), Light where the desktop says nothing; a saved choice wins." [[feature]] id = "view.home-view" @@ -1802,8 +1814,8 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/it/editing.rs::restricted_documents_show_a_notice_and_block_page_changes", "crates/ui-egui/tests/it/editing.rs::a_secured_form_shows_its_form_notice_and_its_security", "crates/ui-egui/tests/it/xfa.rs::a_secured_dynamic_xfa_form_shows_its_layout_and_its_security"] -notes = "On a secured form (fields or XFA), the security sentence follows the form's notice, which keeps Highlight fields and the XFA layout notes." +evidence = ["crates/ui-egui/tests/it/editing.rs::restricted_documents_show_a_notice_and_block_page_changes", "crates/ui-egui/tests/it/editing.rs::a_secured_form_shows_its_form_notice_and_its_security", "crates/ui-egui/tests/it/xfa.rs::a_secured_dynamic_xfa_form_shows_its_layout_and_its_security", "crates/ui-egui/tests/it/editing.rs::the_security_notice_can_be_turned_off_for_good"] +notes = "On a secured form (fields or XFA), the security sentence follows the form's notice, which keeps Highlight fields and the XFA layout notes. The notice's Don't show again turns it off in every document (#4035), kept in the settings and turned back on in Preferences (--secured-notice); the restrictions and Document Properties > Security are unchanged." [[feature]] id = "view.pdfa-view-mode" @@ -1866,8 +1878,8 @@ milestone = "M7" status = "partial" commands = ["edit.edit_text"] tools = ["text_lines", "text_paragraphs", "text_edit", "text_edit_layout", "text_font_files", "text_font_import", "text_fonts"] -evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/it/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/it/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/it/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/it/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "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::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/it/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/it/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/it/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/it/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/it/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row", "crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face", "crates/automation/tests/it/automation.rs::editing_uzbek_and_kazakh_cyrillic_through_tools"] -notes = "The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Replacement text the paragraph's font can't show takes the craft-fonts Japanese faces when they draw all of it (Uzbek қ, Kazakh ә are in none of them), else the bundled Inter face, so those letters write in every build (#2593). Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; see docs/editing-existing-text.md" +evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/it/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/it/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/it/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/it/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "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::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/it/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/it/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/it/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/it/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/it/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row", "crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face", "crates/automation/tests/it/automation.rs::editing_uzbek_and_kazakh_cyrillic_through_tools", "crates/edit/src/tests.rs::styled_paragraph_batches_cyrillic_without_changing_source_fonts", "crates/edit/src/tests.rs::styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically", "crates/edit/src/tests.rs::styled_fallback_batches_keep_face_choices_and_range_styles", "crates/edit/src/tests.rs::styled_fallback_shards_keep_text_advances_and_alignment", "crates/edit/src/tests.rs::styled_fallback_budget_is_enforced_before_objects_are_written", "crates/edit/src/tests.rs::styled_fallback_same_base_font_and_character_keep_both_weights", "crates/automation/tests/it/automation.rs::styled_cyrillic_fallback_is_batched_through_tools_and_reopen"] +notes = "The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Replacement text the paragraph's font can't show takes the craft-fonts Japanese faces when they draw all of it (Uzbek қ, Kazakh ә are in none of them), else the bundled Inter face, so those letters write in every build (#2593). Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; Styled-path outline fallback batches by actual face with 240 glyphs per font and preserves source styles; adjacent equal-font/style characters form runs. At most 15360 distinct fallback glyphs per styled paragraph; missing glyphs are refused before fallback objects are written. Glyph coverage, mixed contextual shaping, single-style fallback and stale-font cleanup are unchanged; see docs/editing-existing-text.md" [[feature]] id = "edit.text-reflow" @@ -1914,8 +1926,8 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text"] tools = ["text_edit"] -evidence = ["crates/edit/src/tests.rs::missing_glyphs_substitute_helvetica_and_impossible_text_is_refused", "crates/fonts/src/pdf.rs::subset_fonts_only_encode_the_glyphs_they_have", "crates/fonts/src/pdf.rs::composite_fonts_use_to_unicode"] -notes = "the line's font is reused when every character has a code and a glyph (ToUnicode, encodings, Differences; subset glyph checks); otherwise Helvetica (WinAnsi) is used and reported; text no font can show is refused" +evidence = ["crates/edit/src/tests.rs::missing_glyphs_substitute_helvetica_and_impossible_text_is_refused", "crates/edit/src/tests.rs::a_bold_stand_in_font_leaves_an_earlier_regular_one_alone", "crates/fonts/src/pdf.rs::subset_fonts_only_encode_the_glyphs_they_have", "crates/fonts/src/pdf.rs::composite_fonts_use_to_unicode"] +notes = "the line's font is reused when every character has a code and a glyph (ToUnicode, encodings, Differences; subset glyph checks); otherwise a standard face of the source font's family (Helvetica, Times or Courier, in its weight and slant; WinAnsi) is used and reported; text no font can show is refused. Each standard stand-in has a resource of its own face (PCEdTimesBold, ...), so a bold paragraph's stand-in no longer turns paragraphs set earlier in a regular one bold (#4280); pages already saved with the shared PCEdHelv name by older builds are not repaired" [[feature]] id = "edit.text-format-font" @@ -1984,10 +1996,10 @@ area = "C" tier = "P1" milestone = "M7" status = "shipped" -commands = ["edit.edit_text"] -tools = ["text_edit"] -evidence = ["crates/edit/src/tests.rs::justify_underline_and_spacing", "crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/it/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/ui-egui/tests/it/editing.rs::the_format_panel_takes_any_scale_and_character_spacing"] -notes = "character spacing in points (-5 to 50) and horizontal scaling in percent (10 to 400) for a paragraph (Tc, Tz), rewrapped; Format text takes any value in those ranges typed, or a common one from its list" +commands = ["edit.edit_text", "edit.text"] +tools = ["text_edit", "page_add_text", "content_update"] +evidence = ["crates/edit/src/tests.rs::justify_underline_and_spacing", "crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/it/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/ui-egui/tests/it/editing.rs::the_format_panel_takes_any_scale_and_character_spacing", "crates/edit/src/tests.rs::added_text_character_spacing_is_drawn_and_reopens", "crates/edit/src/tests.rs::added_text_character_spacing_widens_its_layout", "crates/automation/tests/it/automation.rs::added_text_takes_character_spacing"] +notes = "character spacing in points (-5 to 50) and horizontal scaling in percent (10 to 400) for a paragraph (Tc, Tz), rewrapped; Format text takes any value in those ranges typed, or a common one from its list. Added text takes character spacing too (Tc, kept in /PCAdded and rewrapped); horizontal scaling for added text is not there yet" [[feature]] id = "edit.lists" @@ -2073,6 +2085,17 @@ tools = ["page_add_image"] evidence = ["crates/ui-egui/tests/it/content.rs::typing_moving_styling_and_deleting_added_content", "crates/ui-egui/tests/it/content.rs::a_picture_on_the_clipboard_is_pasted_onto_the_page", "crates/automation/tests/it/automation.rs::adding_content_through_tools", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable"] notes = "PNG, JPEG, TIFF, GIF, BMP; centred at natural size or in a rect. ⌘V / Ctrl+V pastes a picture from the system clipboard onto the current page the same way (desktop only)" +[[feature]] +id = "edit.cut-copy-paste-objects" +title = "Cut, copy and paste text boxes and images" +area = "C" +tier = "P1" +milestone = "M7" +status = "partial" +tools = ["content_copy"] +evidence = ["crates/automation/tests/it/automation.rs::copying_added_content_through_tools", "crates/engine/src/tests.rs::pasted_added_content_keeps_its_style_and_picture", "crates/engine/src/tests.rs::an_added_jpeg_2000_image_is_copied_as_its_file", "crates/edit/src/tests.rs::a_paste_lands_where_the_copy_was_offset_per_paste_and_stays_on_the_page", "crates/edit/src/tests.rs::a_paste_steps_past_copies_of_itself_only"] +notes = "Issue #2636, engine and automation part. Edit::PasteContent pastes an added text box or image copied in the same document or another, as one undo step, and `content_copy` drives it headlessly. A copy keeps a text box's text, font, size, colour, alignment and box, and an image's picture, box, turns, flips and crop. It lands where the item was, stepped 18 pt down and to the right past each copy already on the page, or at a given point, and stays on the page. Within a document a pasted image draws the same image object; into another document its picture goes as its JPEG or JPEG 2000 file, or else a PNG, so a colour profile is dropped and CMYK colours in a PNG are turned to RGB, and later pastes there reuse the first one's picture. The copy permission is honoured. Missing: the desktop app's ⌘X / ⌘C / ⌘V, Edit ▸ Cut and Paste and the right-click menu (follow-up PRs), the original document's paragraphs and images (open PRs #1625 and #2251), several items at once, and a check of Acrobat's own clipboard format (not observed)." + [[feature]] id = "edit.image-replace" title = "Replace an image (keep position and size)" @@ -2142,8 +2165,8 @@ milestone = "M7" status = "shipped" commands = ["edit.edit_text"] tools = ["image_save"] -evidence = ["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/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::lossless_decode_maps_each_component_before_colour_conversion", "crates/create/src/tests.rs::lossless_indexed_decode_rounds_in_index_units_then_clamps"] -notes = "right-click an image ▸ Save Image As: JPEG unchanged, others PNG. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Missing, null or malformed Decode arrays keep the default mapping. Still limited: 16-bit samples use high bytes; general JPEG and mask Decode, ICC transforms, and calibrated colors remain unsupported." +evidence = ["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/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::lossless_decode_maps_each_component_before_colour_conversion", "crates/create/src/tests.rs::lossless_indexed_decode_rounds_in_index_units_then_clamps", "crates/create/src/tests.rs::soft_mask_decode_ranges_become_exported_alpha"] +notes = "right-click an image ▸ Save Image As: JPEG unchanged, others PNG. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Missing, null or malformed Decode arrays keep the default mapping. Soft-mask Decode intervals preserve opacity before resizing, including indirect arrays and endpoints. Still limited: 16-bit samples use high bytes; general JPEG image Decode, ICC transforms, and calibrated colors remain unsupported." [[feature]] id = "edit.image-external-editor" @@ -2202,8 +2225,8 @@ milestone = "M11" status = "partial" commands = ["edit.edit_text"] tools = ["object_list", "object_move"] -evidence = ["crates/ui-egui/tests/it/object_selection.rs::marquee_and_mixed_group_drag_move_once_and_undo_together", "crates/automation/tests/it/automation.rs::mixed_objects_move_through_tools_with_generation_and_one_undo", "crates/edit/src/tests.rs::group_moves_preserve_glyph_bytes_and_unselected_text_state", "crates/engine/src/tests.rs::mixed_object_moves_are_one_undo_step_and_survive_save_reopen"] -notes = "Same-page mixed selection (#844): Shift/Ctrl/Command-click toggles, empty-space marquee (additive with modifiers), drag previews and one atomic undo step for existing paragraphs, Image/Form XObjects and added text/images. Source glyph bytes, fonts and kerning retained; unselected text and shared Form resources stay put. Generation-scoped references with optional automation guard; count and geometry bounded; unsupported vertical/clipping text refused without partial changes. Still missing: arbitrary paths, inline images, cross-page movement, group resizing, arrange/align and a general object editor. See docs/editing-multiple-objects.md." +evidence = ["crates/ui-egui/tests/it/object_selection.rs::marquee_and_mixed_group_drag_move_once_and_undo_together", "crates/automation/tests/it/automation.rs::mixed_objects_move_through_tools_with_generation_and_one_undo", "crates/edit/src/tests.rs::group_moves_preserve_glyph_bytes_and_unselected_text_state", "crates/engine/src/tests.rs::mixed_object_moves_are_one_undo_step_and_survive_save_reopen", "crates/ui-egui/tests/it/editing.rs::a_paragraph_selected_by_its_frame_deletes_with_one_key"] +notes = "Same-page mixed selection (#844): Shift/Ctrl/Command-click toggles, empty-space marquee (additive with modifiers), drag previews and one atomic undo step for existing paragraphs, Image/Form XObjects and added text/images. Source glyph bytes, fonts and kerning retained; unselected text and shared Form resources stay put. Delete or Backspace removes the selection (a paragraph selected by its frame, or a group) as one undo step. Generation-scoped references with optional automation guard; count and geometry bounded; unsupported vertical/clipping text refused without partial changes. Still missing: arbitrary paths, inline images, cross-page movement, group resizing, arrange/align and a general object editor. See docs/editing-multiple-objects.md." [[feature]] id = "edit.object-properties" @@ -2400,8 +2423,8 @@ milestone = "M7" status = "shipped" commands = ["edit.header_footer"] tools = ["doc_header_footer"] -evidence = ["crates/ui-egui/tests/it/marks.rs::header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset", "crates/edit/src/tests.rs::header_and_footer_are_drawn_in_display_space_and_wrap_the_original_content", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove"] -notes = "Standard faces only: Helvetica, Times or Courier (#3754; no bold, italic or embedded fonts yet); drawn in display space on rotated pages; artifact-tagged" +evidence = ["crates/ui-egui/tests/it/marks.rs::header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset", "crates/edit/src/tests.rs::header_and_footer_are_drawn_in_display_space_and_wrap_the_original_content", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove", "crates/ui-egui/tests/it/marks.rs::the_preview_shows_the_page_the_range_selects_under_the_marks"] +notes = "Standard faces only: Helvetica, Times or Courier (#3754; no bold, italic or embedded fonts yet); drawn in display space on rotated pages; artifact-tagged; the dialog previews the text over the page's own thumbnail (the current page, or the first one the range selects), in the interface font and with tokens unexpanded" [[feature]] id = "edit.header-footer-update-remove" @@ -2460,8 +2483,8 @@ milestone = "M7" status = "shipped" commands = ["edit.watermark"] tools = ["doc_watermark"] -evidence = ["crates/edit/src/tests.rs::watermarks_rotate_fade_and_can_go_behind", "crates/ui-egui/tests/it/marks.rs::watermark_and_background_dialogs", "crates/automation/tests/it/automation.rs::headers_watermarks_and_backgrounds_through_tools"] -notes = "Helvetica; fit-to-page or a size" +evidence = ["crates/edit/src/tests.rs::watermarks_rotate_fade_and_can_go_behind", "crates/ui-egui/tests/it/marks.rs::watermark_and_background_dialogs", "crates/automation/tests/it/automation.rs::headers_watermarks_and_backgrounds_through_tools", "crates/ui-egui/tests/it/marks.rs::the_preview_shows_the_page_the_range_selects_under_the_marks"] +notes = "Helvetica; fit-to-page or a size; the dialog previews it over the page's own thumbnail, always on top (a watermark behind the page is not drawn under its content there)" [[feature]] id = "edit.watermark-add-file" @@ -2473,7 +2496,7 @@ status = "shipped" commands = ["edit.watermark"] tools = ["doc_watermark"] evidence = ["crates/engine/src/tests.rs::backgrounds_and_watermarks_from_files"] -notes = "an image or a page of a PDF, scaled relative to the page, rotated, with opacity, behind or on top" +notes = "an image or a page of a PDF, scaled relative to the page, rotated, with opacity, behind or on top; the dialog's preview shows its frame and file name over the page, not the picture itself" [[feature]] id = "edit.watermark-update-remove" @@ -2508,8 +2531,8 @@ milestone = "M7" status = "shipped" commands = ["edit.background"] tools = ["doc_background"] -evidence = ["crates/ui-egui/tests/it/marks.rs::watermark_and_background_dialogs", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove", "crates/engine/src/tests.rs::backgrounds_and_watermarks_from_files", "crates/ui-egui/tests/it/marks.rs::backgrounds_and_watermarks_from_a_file", "crates/organize/src/tests.rs::a_page_becomes_a_form_xobject_upright"] -notes = "colour, or a picture from a file (an image or a page of a PDF) scaled relative to the page" +evidence = ["crates/ui-egui/tests/it/marks.rs::watermark_and_background_dialogs", "crates/engine/src/tests.rs::headers_footers_watermarks_and_backgrounds_show_update_and_remove", "crates/engine/src/tests.rs::backgrounds_and_watermarks_from_files", "crates/ui-egui/tests/it/marks.rs::backgrounds_and_watermarks_from_a_file", "crates/organize/src/tests.rs::a_page_becomes_a_form_xobject_upright", "crates/ui-egui/tests/it/marks.rs::the_preview_shows_the_page_the_range_selects_under_the_marks"] +notes = "colour, or a picture from a file (an image or a page of a PDF) scaled relative to the page; the dialog previews a colour as a tint of the page's own thumbnail (opaque pictures on the page are tinted there too, not in the result), a file as its frame and name" [[feature]] id = "edit.background-update-remove" @@ -2605,7 +2628,7 @@ status = "shipped" commands = ["edit.edit_text"] tools = ["text_edit"] evidence = ["crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/it/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools", "crates/ui-egui/tests/it/content.rs::format_text_panel_sets_alignment_and_lists_planned_controls_disabled", "crates/ui-egui/tests/it/content.rs::format_text_rows_line_up_and_fit_the_panel"] -notes = "Edit a PDF ▸ Format text, laid out as Acrobat's panel, for the paragraph being edited and for added text: font (the source font kept, the document's fonts, families, installed or loaded font files), size, colour, alignment, bold and italic, line spacing, horizontal scale and character spacing; each change applies at once as one undo step, keeping the paragraph open. Shown disabled with a Planned tooltip until they exist: bulleted and numbered lists (edit.lists), superscript and subscript (edit.text-super-subscript), paragraph spacing, and underline on added text." +notes = "Edit a PDF ▸ Format text, laid out as Acrobat's panel, for the paragraph being edited and for added text: font (the source font kept, the document's fonts, families, installed or loaded font files), size, colour, alignment, bold and italic, line spacing, horizontal scale and character spacing; each change applies at once as one undo step, keeping the paragraph open. Shown disabled with a Planned tooltip until they exist: bulleted and numbered lists (edit.lists), superscript and subscript (edit.text-super-subscript), paragraph spacing, and underline, line spacing and horizontal scale on added text." [[feature]] id = "edit.adobe-express-edits" @@ -2788,8 +2811,8 @@ milestone = "M4" status = "shipped" commands = ["page.extract"] tools = ["page_extract"] -evidence = ["crates/ui-egui/tests/it/editing.rs::extract_button_copies_selected_pages_to_a_new_tab", "crates/automation/tests/it/automation.rs::combine_extract_and_split", "crates/organize/src/tests.rs::extract_and_split_keep_the_pdfx_output_intent_and_identification"] -notes = "Carries the PDF/X output intent and identification (#263) in a new XMP packet; the source XMP (dates, other schemas) is not copied." +evidence = ["crates/ui-egui/tests/it/editing.rs::extract_button_copies_selected_pages_to_a_new_tab", "crates/automation/tests/it/automation.rs::combine_extract_and_split", "crates/organize/src/tests.rs::extract_and_split_keep_the_pdfx_output_intent_and_identification", "crates/ui-egui/tests/it/pickers.rs::extract_pages_takes_the_page_ranges_typed_in_the_dialog"] +notes = "Carries the PDF/X output intent and identification (#263) in a new XMP packet; the source XMP (dates, other schemas) is not copied. The dialog's Pages field takes page ranges (\"1-5, 8, 11-13\", in the order typed, each page once) instead of the selection (#3593); page labels are not accepted there yet." [[feature]] id = "organize.extract-separate-files" @@ -2920,7 +2943,7 @@ milestone = "M4" status = "shipped" commands = ["page.split"] tools = ["doc_split"] -evidence = ["crates/ui-egui/tests/it/editing.rs::split_before_selected_pages"] +evidence = ["crates/ui-egui/tests/it/editing.rs::split_before_selected_pages", "crates/ui-egui/tests/it/editing.rs::split_from_organize_grid_context_menu"] notes = "" [[feature]] @@ -3340,8 +3363,8 @@ milestone = "M4" status = "shipped" commands = ["file.properties"] tools = [] -evidence = ["crates/ui-egui/tests/it/bookmarks.rs::expanding_and_collapsing_all_bookmarks"] -notes = "Bookmarks panel options: expand all, expand top-level, collapse all" +evidence = ["crates/ui-egui/tests/it/bookmarks.rs::expanding_and_collapsing_all_bookmarks", "crates/ui-egui/tests/it/bookmarks.rs::a_closed_top_level_bookmark_opens_closed", "crates/organize/src/tests.rs::bookmarks_add_rename_move_delete"] +notes = "Bookmarks panel options: expand all, expand top-level, collapse all. Each bookmark, top level included, opens as its /Count says (positive open; negative or missing closed, ISO 32000-2 Table 151); the triangle toggles it for the session and a search shows matches inside closed ones. Adding or moving a bookmark into another opens that one. Missing: the open/closed state the reader sets is not written back to /Count on save" [[feature]] id = "organize.bookmarks-from-structure" @@ -3361,11 +3384,11 @@ title = "Add file attachments" area = "D" tier = "P2" milestone = "M12" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "shipped" +commands = ["edit.attach"] +tools = ["attachment_add"] +evidence = ["crates/organize/src/tests.rs::attachments_are_added_listed_and_deleted", "crates/organize/src/tests.rs::attachments_added_in_the_app_survive_combine_and_more_can_be_attached", "crates/automation/tests/it/automation.rs::attaching_and_deleting_files_through_the_tools"] +notes = "Edit ▸ Attach a file… (also the Attach a file… button in the Attachments panel and the Edit a PDF list): the file goes into /Names /EmbeddedFiles as a Filespec with a Flate-compressed EmbeddedFile stream (/Size, /ModDate, /Subtype from the extension); a name already taken gets \" (2)\"…; the panel opens on it. Undoable. Acrobat's Comment ▸ Attach File (a paper-clip comment on the page) is comment.attach-file-comment." [[feature]] id = "organize.attachments-delete" @@ -3373,11 +3396,11 @@ title = "Delete attachments" area = "D" tier = "P2" milestone = "M12" -status = "planned" +status = "shipped" commands = [] -tools = [] -evidence = [] -notes = "" +tools = ["attachment_delete"] +evidence = ["crates/organize/src/tests.rs::attachments_are_added_listed_and_deleted", "crates/organize/src/tests.rs::attachments_added_in_the_app_survive_combine_and_more_can_be_attached", "crates/automation/tests/it/automation.rs::attaching_and_deleting_files_through_the_tools"] +notes = "The trash button on a document-level row of the Attachments panel; the last one removed takes the /EmbeddedFiles tree with it. Undoable. A paper-clip comment is deleted as a comment." [[feature]] id = "organize.attachments-description" @@ -3448,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"] -notes = "" +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" [[feature]] id = "organize.edit-metadata" @@ -3484,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"] -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)" +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" [[feature]] id = "organize.custom-properties" @@ -3494,10 +3517,10 @@ area = "D" tier = "P2" milestone = "M4" status = "partial" -commands = [] +commands = ["file.properties"] tools = ["doc_info", "doc_set_info"] -evidence = ["crates/organize/src/tests.rs::custom_document_properties_are_listed_set_removed_and_their_names_checked", "crates/render/src/inspect.rs::custom_document_properties_are_listed_apart_from_the_standard_ones", "crates/automation/tests/it/automation.rs::custom_document_properties_are_set_listed_and_cleared_through_the_tools"] -notes = "Through the tools: doc_set_info sets or clears an /Info entry under any name the spec does not use (names checked as Acrobat does: no standard name, no empty name, no spaces or delimiters, at most 127 bytes), and doc_info lists them under custom, apart from the standard entries. Not yet: the Custom tab in Document Properties, and mirroring them into XMP (pdfx:) as Acrobat does" +evidence = ["crates/organize/src/tests.rs::custom_document_properties_are_listed_set_removed_and_their_names_checked", "crates/render/src/inspect.rs::custom_document_properties_are_listed_apart_from_the_standard_ones", "crates/automation/tests/it/automation.rs::custom_document_properties_are_set_listed_and_cleared_through_the_tools", "crates/ui-egui/tests/it/editing.rs::custom_properties_are_added_in_the_custom_tab_as_one_undoable_step", "crates/ui-egui/tests/it/editing.rs::custom_properties_are_changed_and_deleted_in_the_custom_tab", "crates/ui-egui/tests/it/editing.rs::the_custom_tab_refuses_names_a_custom_property_cannot_have", "crates/ui-egui/tests/it/editing.rs::a_custom_property_whose_name_the_tools_refuse_is_listed_and_left_alone", "crates/ui-egui/tests/it/editing.rs::a_custom_property_is_deleted_from_an_info_dictionary_in_the_trailer", "crates/ui-egui/tests/it/editing.rs::add_stops_at_the_256_custom_properties_the_tab_lists", "crates/organize/src/tests.rs::document_info_entries_are_cleared_from_an_info_dictionary_in_the_trailer"] +notes = "Document Properties ▸ Custom lists them, with Name and Value fields and Add, Update (the value of the listed property the Name field names) and Delete; OK writes the changes as one undo step with the other tabs' changes. A property already in the file under a name the tools refuse (a space, a delimiter) or a name that is not UTF-8 is listed and cannot be changed or deleted there, and Add is off for a name the file already uses for an entry that is not listed (not text, only padding, or past the 256 listed), and once the tab or the file lists 256 properties. Through the tools: doc_set_info sets or clears an /Info entry under any name the spec does not use (names checked as Acrobat does: no standard name, no empty name, no spaces or delimiters, at most 127 bytes), and doc_info lists them under custom, apart from the standard entries. Not yet: mirroring them into XMP (pdfx:) as Acrobat does" [[feature]] id = "organize.xmp-editor" @@ -3572,8 +3595,8 @@ milestone = "M5" status = "partial" commands = [] tools = ["comment_add"] -evidence = ["crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment", "crates/engine/src/tests.rs::highlight_multiplies_over_text", "crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn"] -notes = "appearances for every type PdfCraft creates (highlight multiplies; clouds, callouts, stamps, polygons, carets); rich text not yet" +evidence = ["crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment", "crates/engine/src/tests.rs::highlight_multiplies_over_text", "crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/annot/src/tests.rs::legacy_border_width_and_dash_are_drawn", "crates/annot/src/tests.rs::ink_draws_its_border_style_dashes", "crates/annot/src/tests.rs::a_caret_with_a_paragraph_symbol_draws_the_pilcrow", "crates/engine/src/tests.rs::legacy_border_dashes_and_the_caret_paragraph_symbol_are_drawn"] +notes = "appearances for every type PdfCraft creates (highlight multiplies; clouds, callouts, stamps, polygons, carets). Comments other files carry without an appearance are drawn from their dictionaries the same way, including a legacy /Border width and dash array (ISO 32000-2 Table 166), dashed ink (/BS, Table 182) and a caret's ¶ for /Sy /P (Table 183). Not yet: rich text, beveled, inset and underline border styles, sound icons" [[feature]] id = "comment.sticky-note" @@ -3584,8 +3607,8 @@ milestone = "M5" status = "shipped" commands = ["comment.note"] tools = ["comment_add"] -evidence = ["crates/ui-egui/tests/it/comments.rs::sticky_note_composer_posts_and_returns_to_select", "crates/ui-egui/tests/it/comments.rs::the_comment_composer_shortcut_only_posts_the_focused_editor", "crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment", "crates/annot/src/tests.rs::every_note_icon_draws_its_own_shape", "crates/annot/src/tests.rs::a_star_note_keeps_its_star_when_recoloured"] -notes = "our own icon drawings for the seven standard /Name values of ISO 32000-2 Table 175 (Comment, Note, Help, Insert, Key, NewParagraph, Paragraph) and seven further names (Check, Circle, Cross, CrossHairs, Star, RightArrow, UpArrow), each its own shape, chosen in Properties or comment_add, and kept when the colour changes; another name draws as Note. With a pop-up; Ctrl+Enter (also Command+Enter) posts from the focused editor, Shift+Enter adds a newline. Not yet: opening the pop-ups a file saves open (/Open)." +evidence = ["crates/ui-egui/tests/it/comments.rs::sticky_note_composer_posts_and_returns_to_select", "crates/ui-egui/tests/it/comments.rs::the_comment_composer_shortcut_only_posts_the_focused_editor", "crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment", "crates/annot/src/tests.rs::every_note_icon_draws_its_own_shape", "crates/annot/src/tests.rs::a_star_note_keeps_its_star_when_recoloured", "crates/engine/src/tests.rs::sticky_notes_stay_upright_where_they_are_placed_on_rotated_pages", "crates/render/src/raster.rs::no_rotate_notes_stay_upright_at_their_corner_on_rotated_pages", "crates/ui-egui/tests/it/comments.rs::a_sticky_note_on_a_turned_page_is_picked_where_it_is_drawn"] +notes = "our own icon drawings for the seven standard /Name values of ISO 32000-2 Table 175 (Comment, Note, Help, Insert, Key, NewParagraph, Paragraph) and seven further names (Check, Circle, Cross, CrossHairs, Star, RightArrow, UpArrow), each its own shape, chosen in Properties or comment_add, and kept when the colour changes; another name draws as Note. With a pop-up; Ctrl+Enter (also Command+Enter) posts from the focused editor, Shift+Enter adds a newline. Notes are saved with the NoRotate flag, and it is honoured: on a page with /Rotate the icon stays upright with its top-left where it was placed, and is selected and hovered there (#2583). Not yet: opening the pop-ups a file saves open (/Open); NoZoom (the icon scales with the zoom); printing and flattening still place the icon in its /Rect, turned with the page." [[feature]] id = "comment.highlight" @@ -3728,8 +3751,8 @@ milestone = "M5" status = "shipped" commands = ["comment.line"] tools = ["comment_add"] -evidence = ["crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment"] -notes = "" +evidence = ["crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment", "crates/annot/src/tests.rs::a_positive_leader_length_puts_the_line_above_a_left_to_right_line_and_a_negative_one_below", "crates/annot/src/tests.rs::an_inline_caption_breaks_the_line_around_its_text"] +notes = "A line without an appearance is drawn with its leader lines (/LL, /LLE, /LLO: the line sits |LL| + LLO from /L, on the left of travel for a positive /LL) and its caption (/Cap, /CP Inline or Top, /CO), endings at the line itself (ISO 32000-2 Table 178). Not yet: the tool can't add leader lines or a caption, the caption is always black Helvetica 10 (no /DS or rich-text styling), and drawing past /Rect is clipped (/Rect isn't grown)." [[feature]] id = "comment.arrow" @@ -3911,6 +3934,18 @@ tools = [] evidence = ["crates/ui-egui/tests/it/comments.rs::make_current_properties_default", "crates/ui-egui/tests/it/comments.rs::properties_dialog_makes_the_strikethrough_colour_default"] notes = "colour, opacity and line width become the tool's defaults (canvas and Comments panel menus, and Acrobat's Make Properties Default box in Comment Properties, applied on OK with the edited look) and survive a restart: the per-tool styles are persisted with the settings and restored through their clamping setters (#340)" +[[feature]] +id = "comment.keep-tool-selected" +title = "Keep annotation tools selected between uses and restarts" +area = "E" +tier = "P2" +milestone = "M14" +status = "shipped" +commands = ["comment.line"] +tools = [] +evidence = ["crates/ui-egui/tests/it/comment_pin.rs::pin_from_quick_bar_survives_restart_and_unpinning", "crates/ui-egui/tests/it/comment_pin.rs::legacy_settings_keep_the_default_and_other_preferences", "crates/ui-egui/tests/it/comment_pin.rs::malformed_pin_values_preserve_the_current_choice", "crates/ui-egui/tests/it/control.rs::comment_pin_is_reported_and_persistent"] +notes = "The annotation quick bar's shared pin survives restarts as a boolean setting (#4253); unpinning is remembered too. Old settings default to one-shot use, and malformed values keep the current choice. UI-only preference, reachable through opt-in ui.set key=comment-tool-pinned and reported by ui.state.comment_tool_pinned. Startup uses Select; the shared pin applies when an annotation tool is picked. Independent per-tool pins and restoring the active tool are outside this preference." + [[feature]] id = "comment.author-identity" title = "Author name from identity preferences" @@ -3971,6 +4006,18 @@ tools = ["comment_delete"] evidence = ["crates/ui-egui/tests/it/comments.rs::comments_are_selected_moved_and_deleted_with_the_select_tool", "crates/annot/src/tests.rs::delete_takes_popup_and_reply_chain"] notes = "deletes the pop-up and the reply thread" +[[feature]] +id = "comment.keyboard-nudge" +title = "Move the selected comment with the arrow keys" +area = "E" +tier = "P2" +milestone = "M5" +status = "shipped" +commands = [] +tools = ["comment_edit"] +evidence = ["crates/ui-egui/tests/it/comments.rs::arrow_keys_nudge_the_selected_comment"] +notes = "With a comment selected (Select tool, or a Fill & Sign mark picked up), an arrow key moves it a screen pixel and ⌘/Ctrl or Shift and an arrow ten, up being up on screen whatever the zoom and rotation; each press is one undo step, and the arrows scroll and turn pages again once nothing is selected. Text markup stays with its text and a locked comment where it is. Headless, comment_edit's `move` takes the offset in points." + [[feature]] id = "comment.reshape" title = "Change a line's ends and a polygon's corners after drawing" @@ -4232,8 +4279,8 @@ milestone = "M5" status = "shipped" commands = ["comment.flatten"] tools = ["doc_flatten"] -evidence = ["crates/edit/src/tests.rs::flattening_draws_appearances_into_the_page_and_removes_the_comments", "crates/edit/src/tests.rs::flattening_keeps_an_undrawn_reply_to_a_flattened_comment", "crates/engine/src/tests.rs::flattening_keeps_the_look_and_drops_the_objects", "crates/automation/tests/it/automation.rs::flattening_through_tools", "crates/edit/src/tests.rs::flattening_comments_keeps_annotations_that_are_not_comments"] -notes = "appearances become page content; pop-ups and replies removed; a comment or reply whose appearance cannot be drawn (a cloudy border, an unsupported subtype) stays as it is instead of being deleted; annotations that are not comments (links, 3D, Screen, Movie, RichMedia, PrinterMark, TrapNet, Watermark) are left in place (#3087)" +evidence = ["crates/edit/src/tests.rs::flattening_draws_appearances_into_the_page_and_removes_the_comments", "crates/edit/src/tests.rs::flattening_keeps_an_undrawn_reply_to_a_flattened_comment", "crates/engine/src/tests.rs::flattening_keeps_the_look_and_drops_the_objects", "crates/automation/tests/it/automation.rs::flattening_through_tools", "crates/edit/src/tests.rs::flattening_comments_keeps_annotations_that_are_not_comments", "crates/ui-egui/tests/it/comments.rs::flatten_comments_from_the_page_menu_and_the_panel_menu"] +notes = "also in the page's right-click menu and the Comments panel's More options menu; appearances become page content; pop-ups and replies removed; a comment or reply whose appearance cannot be drawn (a cloudy border, an unsupported subtype) stays as it is instead of being deleted; annotations that are not comments (links, 3D, Screen, Movie, RichMedia, PrinterMark, TrapNet, Watermark) are left in place (#3087)" [[feature]] id = "comment.migrate" @@ -4256,7 +4303,7 @@ milestone = "M12" status = "partial" commands = ["measure.distance"] tools = ["measure_distance"] -evidence = ["crates/automation/tests/it/automation.rs::measurements_calibrate_draw_save_reopen_and_export", "crates/measure/src/tests.rs::calibrated_lengths_concave_areas_and_axis_scales", "crates/ui-egui/tests/it/control.rs::measurement_tools_draw_live_calibrate_save_and_export"] +evidence = ["crates/automation/tests/it/automation.rs::measurements_calibrate_draw_save_reopen_and_export", "crates/measure/src/tests.rs::calibrated_lengths_concave_areas_and_axis_scales", "crates/ui-egui/tests/it/control.rs::measurement_tools_draw_live_calibrate_save_and_export", "crates/ui-egui/tests/it/control.rs::a_plan_scale_picked_or_typed_applies_at_once", "crates/ui-egui/src/measure_ui.rs::typed_ratios_are_read_leniently"] notes = "Two-point distance annotations with saved numeric captions and rectilinear scales. Partial: only single-unit decimal formats; compound (ft-in) and fractional formats are not supported (listed as unsupported, data kept), no leader lines or caption placement options, geospatial scales unsupported, and interop with Acrobat-authored measurements (reading theirs, Acrobat reading ours) is untested." [[feature]] @@ -4428,8 +4475,8 @@ milestone = "M2" status = "shipped" commands = [] tools = [] -evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/engine/src/tests.rs::validated_signatures_without_layers_keep_their_appearance", "crates/engine/src/tests.rs::real_layered_signatures_keep_their_original_appearance", "crates/engine/src/tests.rs::real_layered_signatures_round_trip_behind_a_user_password", "crates/engine/src/tests.rs::a_metadata_edit_keeps_the_original_signature_appearance", "crates/engine/src/tests.rs::saving_after_an_edit_keeps_the_baked_appearance", "crates/engine/src/tests.rs::chamfered_signature_borders_survive_trust_exports_printing_and_save", "crates/engine/src/tests.rs::mixed_signature_caption_details_survive_trust_exports_printing_and_save"] -notes = "widgets retain their original artwork. Signature validation is a separate viewer page overlay; it is never written into document streams or included in image/text exports, vector printing or Print as Image. Saves retain signed appearance bytes. Generic in-place appearance rewriting is deferred: no supported producer template is positively identified, and caption text plus drawing uniqueness does not establish an icon role. Baked Not Verified captions remain unchanged; issue #79 is not fully resolved." +evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/engine/src/tests.rs::validated_signatures_without_layers_keep_their_appearance", "crates/engine/src/tests.rs::real_layered_signatures_keep_their_original_appearance", "crates/engine/src/tests.rs::real_layered_signatures_round_trip_behind_a_user_password", "crates/engine/src/tests.rs::a_metadata_edit_keeps_the_original_signature_appearance", "crates/engine/src/tests.rs::saving_after_an_edit_keeps_the_baked_appearance", "crates/engine/src/tests.rs::chamfered_signature_borders_survive_trust_exports_printing_and_save", "crates/engine/src/tests.rs::mixed_signature_caption_details_survive_trust_exports_printing_and_save", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::button_states_without_an_appearance"] +notes = "widgets retain their original artwork. Signature validation is a separate viewer page overlay; it is never written into document streams or included in image/text exports, vector printing or Print as Image. Saves retain signed appearance bytes. Generic in-place appearance rewriting is deferred: no supported producer template is positively identified, and caption text plus drawing uniqueness does not establish an icon role. Baked Not Verified captions remain unchanged; issue #79 is not fully resolved. Check boxes and radio buttons with no appearance for their state are shown (display-only) with their /MK border and background and, when on, the /MK /CA ZapfDingbats symbol in the /DA colour (a check or a dot by default); push buttons with none show their /MK frame and caption; files that carry their own appearances are not redrawn." [[feature]] id = "form.fill-text" @@ -4440,8 +4487,8 @@ milestone = "M6" status = "shipped" commands = ["form.fields"] tools = ["form_fill", "form_fields"] -evidence = ["crates/ui-egui/tests/it/forms.rs::typing_into_a_text_field_and_tabbing_on", "crates/ui-egui/tests/it/forms.rs::auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box", "crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/engine/src/tests.rs::filling_a_form_shows_saves_and_undoes", "crates/automation/tests/it/automation.rs::orphan_form_fields_through_tools", "crates/forms/src/tests.rs::fields_sharing_a_name_take_the_same_value", "crates/ui-egui/tests/it/forms.rs::hovering_a_field_shows_its_tooltip", "crates/forms/src/tests.rs::zero_max_len_is_unlimited_and_overrides_inherited_limit"] -notes = "in-place editor; Enter or clicking away commits, Escape cancels; auto-size (0 Tf) fields shrink, and multiline ones wrap, the text being typed to fit the box, on screen as on the page (the screen measures in the interface font, so its size can be a little smaller than the page's Helvetica fit); fixed sizes are kept and clip, as in Acrobat; no format/keystroke JavaScript yet (M6.4); resting the pointer on any field shows its tooltip (/TU), as Acrobat does (#3854); imported MaxLen 0 permits text input and overrides inherited limits; positive limits remain enforced." +evidence = ["crates/ui-egui/tests/it/forms.rs::typing_into_a_text_field_and_tabbing_on", "crates/ui-egui/tests/it/forms.rs::auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box", "crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/engine/src/tests.rs::filling_a_form_shows_saves_and_undoes", "crates/automation/tests/it/automation.rs::orphan_form_fields_through_tools", "crates/forms/src/tests.rs::fields_sharing_a_name_take_the_same_value", "crates/ui-egui/tests/it/forms.rs::hovering_a_field_shows_its_tooltip", "crates/forms/src/tests.rs::zero_max_len_is_unlimited_and_overrides_inherited_limit", "crates/engine/src/tests.rs::password_field_values_are_kept_in_memory_but_written_to_no_file", "crates/forms/src/tests.rs::a_cleared_password_field_returns_to_its_default_value", "crates/forms/src/tests.rs::a_text_field_says_what_fits_it_as_its_appearance_draws_it", "crates/forms/src/tests.rs::an_edit_to_a_full_field_keeps_what_fits", "crates/ui-egui/tests/it/forms.rs::a_field_that_doesnt_scroll_stops_taking_text_when_full", "crates/ui-egui/tests/it/forms.rs::a_multi_line_auto_sized_field_that_doesnt_scroll_stops_when_full", "crates/engine/src/tests.rs::an_xfa_field_that_doesnt_scroll_opens_with_do_not_scroll_and_a_text_box", "crates/xfa/src/tests.rs::fields_that_dont_scroll_are_written_with_do_not_scroll", "crates/forms/src/tests.rs::comb_fields_show_their_cells"] +notes = "in-place editor; Enter or clicking away commits, Escape cancels; auto-size (0 Tf) fields shrink, and multiline ones wrap, the text being typed to fit the box, on screen as on the page (the screen measures in the interface font, so its size can be a little smaller than the page's Helvetica fit); fixed sizes are kept and clip, as in Acrobat; a field that doesn't scroll (DoNotScroll, ISO 32000-2 Table 228, and XFA's hScrollPolicy/vScrollPolicy=\"off\", written as it) takes no more text than its appearance shows, measured in the field's own font, padding and size (the smallest automatic size, 4 pt, for 0 Tf): typing into a full field adds nothing, and a paste or a replaced selection keeps what fits (form_fill isn't interactive and still takes any value); no format/keystroke JavaScript yet (M6.4); resting the pointer on any field shows its tooltip (/TU), as Acrobat does (#3854); imported MaxLen 0 permits text input and overrides inherited limits; positive limits remain enforced. A text field with the Password flag is saved as Acrobat saves it (#3926, black-box test): the typed value is written to no file (Save, Save As, Reduce File Size, recovery, Extract/Split/Combine, signing); the field gets back its default value (/DV) or is left empty, and its asterisks are redrawn from that value. /DV and the field's scripts stay. Known limits, which Acrobat shares: a script that copies the password into an ordinary field saves it there (Acrobat recalculates a Calculate copy on save, PdfCraft does not), and an incremental save leaves a password stored by an earlier revision in that revision. A comb field with a border colour shows its MaxLen cells as lines in that colour, in the appearances PdfCraft saves and in display-only ones (up to 500 cells)." [[feature]] id = "form.fill-checkbox-radio" @@ -4452,8 +4499,8 @@ milestone = "M6" status = "shipped" commands = ["form.fields"] tools = ["form_fill"] -evidence = ["crates/ui-egui/tests/it/forms.rs::check_boxes_radios_and_choices", "crates/forms/src/tests.rs::check_boxes_and_radios_switch_states", "crates/ui-egui/tests/it/forms.rs::tab_and_space_fill_in_a_check_box_between_text_fields", "crates/ui-egui/tests/it/forms.rs::clicking_a_check_box_while_typing_keeps_the_text_and_focuses_the_box", "crates/ui-egui/tests/it/forms.rs::radio_buttons_take_tab_space_and_arrows"] -notes = "NoToggleToOff honoured; widgets without appearances get our own. Tab and Shift+Tab stop on check boxes and on each radio button, Space toggles the focused one, and the arrows move within a radio group (#2223); a click focuses the box too" +evidence = ["crates/ui-egui/tests/it/forms.rs::check_boxes_radios_and_choices", "crates/forms/src/tests.rs::check_boxes_and_radios_switch_states", "crates/ui-egui/tests/it/forms.rs::tab_and_space_fill_in_a_check_box_between_text_fields", "crates/ui-egui/tests/it/forms.rs::clicking_a_check_box_while_typing_keeps_the_text_and_focuses_the_box", "crates/ui-egui/tests/it/forms.rs::radio_buttons_take_tab_space_and_arrows", "crates/forms/src/tests.rs::a_round_xfa_check_button_draws_a_round_background_and_border", "crates/xfa/src/tests.rs::round_check_buttons_are_marked_for_a_round_appearance", "crates/engine/src/tests.rs::round_xfa_check_buttons_open_drawn_round"] +notes = "NoToggleToOff honoured; widgets without appearances get our own, drawn round for an XFA check button with shape=\"round\" (its background and border a circle inside the widget, as another viewer draws them on a synthetic form). Tab and Shift+Tab stop on check boxes and on each radio button, Space toggles the focused one, and the arrows move within a radio group (#2223); a click focuses the box too" [[feature]] id = "form.fill-choice" @@ -4464,8 +4511,8 @@ milestone = "M6" status = "shipped" commands = ["form.fields"] tools = ["form_fill"] -evidence = ["crates/ui-egui/tests/it/forms.rs::check_boxes_radios_and_choices", "crates/forms/src/tests.rs::choices_accept_exports_or_display_text"] -notes = "combo and list boxes, multi-select lists; editable combo boxes accept free text" +evidence = ["crates/ui-egui/tests/it/forms.rs::check_boxes_radios_and_choices", "crates/forms/src/tests.rs::choices_accept_exports_or_display_text", "crates/ui-egui/tests/it/forms.rs::an_editable_combo_box_takes_a_typed_value", "crates/ui-egui/tests/it/forms.rs::a_combo_box_without_edit_takes_no_typing", "crates/automation/tests/it/automation.rs::form_fill_takes_free_text_only_in_an_editable_combo_box"] +notes = "combo and list boxes, multi-select lists; an editable combo box (Edit flag) has a text box over the field as well as its list, and takes typed text on the page and through form_fill; a combo box without Edit takes only its options. No type-ahead or spell check in the typed box" [[feature]] id = "form.push-buttons" @@ -4644,8 +4691,8 @@ milestone = "M6" status = "shipped" commands = [] tools = ["form_fill"] -evidence = ["crates/ui-egui/tests/it/forms.rs::date_fields_offer_a_calendar", "crates/ui-egui/tests/it/forms.rs::the_calendar_follows_a_complete_date_typed_while_it_is_open"] -notes = "a month calendar under date fields; the pick is written in the field's own date format; it opens on the field's date and moves to a whole date typed while it is open, staying on the month its arrows turned to otherwise" +evidence = ["crates/ui-egui/tests/it/forms.rs::date_fields_offer_a_calendar", "crates/ui-egui/tests/it/forms.rs::the_calendar_follows_a_complete_date_typed_while_it_is_open", "crates/ui-egui/tests/it/forms.rs::the_calendar_picks_today_in_the_fields_format_from_any_month"] +notes = "a month calendar under date fields; the pick is written in the field's own date format; it opens on the field's date and moves to a whole date typed while it is open, staying on the month its arrows turned to otherwise; a Today button under the days picks today from whichever month it shows, as a click on its day would (#4274)" [[feature]] id = "form.auto-complete" @@ -4956,8 +5003,8 @@ milestone = "M6" status = "shipped" commands = ["form.prepare"] tools = ["form_actions", "form_set_actions"] -evidence = ["crates/forms/src/tests.rs::field_actions_round_trip_on_every_trigger", "crates/automation/tests/it/automation.rs::field_actions_through_tools", "crates/ui-egui/tests/it/prepare.rs::actions_tab_adds_and_removes_actions"] -notes = "Six triggers; Run a JavaScript, Open a web link, Reset a form, Execute a menu item, Go to a page view, Show/hide a field, Submit a form. Other action types are kept." +evidence = ["crates/forms/src/tests.rs::field_actions_round_trip_on_every_trigger", "crates/automation/tests/it/automation.rs::field_actions_through_tools", "crates/ui-egui/tests/it/prepare.rs::actions_tab_adds_and_removes_actions", "crates/ui-egui/tests/it/prepare.rs::actions_tab_edits_the_script_of_an_existing_action", "crates/forms/src/tests.rs::editing_one_script_keeps_the_other_actions_and_the_chain", "crates/forms/src/tests.rs::replacing_a_chained_action_with_another_type_keeps_the_chain", "crates/ui-egui/tests/it/prepare.rs::actions_tab_shows_a_chain_and_keeps_it_when_the_first_action_is_replaced"] +notes = "Six triggers; Run a JavaScript, Open a web link, Reset a form, Execute a menu item, Go to a page view, Show/hide a field, Submit a form. Other action types are kept. Edit opens the script of an existing Run a JavaScript action; unchanged actions are kept as they are, and replacing a trigger's first action keeps the actions chained after it, which the row counts. Edit for the other action types, and editing the chained actions, are missing." [[feature]] id = "form.props-format" @@ -5069,8 +5116,8 @@ milestone = "M6" status = "shipped" commands = ["form.prepare"] tools = ["form_set_props"] -evidence = ["crates/forms/src/tests.rs::locked_fields_only_take_unlocking", "crates/ui-egui/tests/it/prepare.rs::preview_fills_the_form_and_locked_fields_keep_their_properties", "crates/forms/src/tests.rs::renaming_a_kid_is_checked_against_the_full_name_it_takes", "crates/forms/src/tests.rs::renaming_a_top_level_field_is_still_checked_against_top_level_names"] -notes = "rename in Field Properties ▸ General; Locked sets the widgets' annotation Locked flag (/F bit 8) and greys out the properties until unlocked; values can still be filled in" +evidence = ["crates/forms/src/tests.rs::locked_fields_only_take_unlocking", "crates/ui-egui/tests/it/prepare.rs::preview_fills_the_form_and_locked_fields_keep_their_properties", "crates/forms/src/tests.rs::renaming_a_kid_is_checked_against_the_full_name_it_takes", "crates/forms/src/tests.rs::renaming_a_top_level_field_is_still_checked_against_top_level_names", "crates/forms/src/tests.rs::dotted_field_names_are_made_with_parent_fields"] +notes = "rename in Field Properties ▸ General, which shows the full name; a dotted name (MASTER.first) is a full name and makes or joins parent fields, as Acrobat does, moving a renamed field with what it inherited (#2696); Prepare a form's table tool still refuses a period in the cell names it makes; a plain name renames the field within its parent, so the GUI cannot yet move a nested field to the top level; Locked sets the widgets' annotation Locked flag (/F bit 8) and greys out the properties until unlocked; values can still be filled in" [[feature]] id = "form.tab-order" @@ -5141,8 +5188,8 @@ milestone = "M6" status = "shipped" commands = ["tools.document_js"] tools = ["js_document_scripts", "js_set_document_script"] -evidence = ["crates/ui-egui/tests/it/javascript.rs::document_scripts_and_preferences", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats"] -notes = "Names /JavaScript tree: list, add, edit, delete; functions are available to field scripts." +evidence = ["crates/ui-egui/tests/it/javascript.rs::document_scripts_and_preferences", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/engine/src/tests.rs::a_document_script_that_throws_still_lets_the_form_calculate"] +notes = "Names /JavaScript tree: list, add, edit, delete; functions are available to field scripts. A script that throws (calling an Acrobat method PdfCraft lacks, say) stops where it failed, but the others and the field scripts still run and the error is reported once (js_run's document_errors, the console) (#3940). Not like Acrobat: the scripts run again before every field script instead of once when the document opens, so what they do at top level repeats." [[feature]] id = "form.document-actions" @@ -5165,8 +5212,8 @@ milestone = "M6" status = "partial" commands = ["tools.js_console"] tools = ["js_run"] -evidence = ["crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/js/src/tests.rs::an_xfa_form_sees_the_xfa_plugin_installed", "crates/js/src/tests.rs::a_plain_form_sees_the_xfa_version_but_not_installed", "crates/engine/src/tests.rs::scripts_in_an_xfa_form_see_the_xfa_plugin_installed"] -notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes), app, util.printf/printd/printx, console, display, color; xfa_version (3.5) in every document and xfa_installed in XFA forms, so their version check passes; no annotations, layers or dialogs (app.response returns null) yet, and no app.findComponent." +evidence = ["crates/js/src/tests.rs::calculate_scripts_read_fields_and_set_the_value", "crates/engine/src/tests.rs::form_javascript_validates_calculates_and_formats", "crates/automation/tests/it/automation.rs::javascript_through_tools", "crates/js/src/tests.rs::an_xfa_form_sees_the_xfa_plugin_installed", "crates/js/src/tests.rs::a_plain_form_sees_the_xfa_version_but_not_installed", "crates/engine/src/tests.rs::scripts_in_an_xfa_form_see_the_xfa_plugin_installed", "crates/js/src/tests.rs::scand_allows_separators_between_fields_but_nothing_before_or_after_them"] +notes = "boa (pure Rust) with the forms object model: event, getField/Field (value, readonly, display, colours, items, check boxes), app (with execMenuItem for the side panels and Save As, from a button only, and Print where this.print() works), util.printf/printd/printx/scand (scand reads dates as another viewer was observed to, in AcroForm and XFA scripts), console, display, color; xfa_version (3.5) in every document and xfa_installed in XFA forms, so their version check passes; no annotations, layers or dialogs (app.response returns null) yet, and no app.findComponent." [[feature]] id = "form.js-console" @@ -5249,8 +5296,8 @@ milestone = "M12" status = "partial" commands = ["file.open"] tools = ["doc_open", "doc_info", "form_fields", "form_fill", "js_run"] -evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn"] -notes = "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, captions, rich text, JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes, radio groups, drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; 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; metrics are approximate and Acrobat fidelity is eyeballed on real forms, not measured (#60)" +evidence = ["crates/xfa/src/tests.rs::layout_places_captions_fields_and_sizes_containers_to_their_content", "crates/xfa/src/tests.rs::tables_break_across_pages_and_repeat_their_header_row", "crates/xfa/src/tests.rs::rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields", "crates/engine/src/tests.rs::dynamic_xfa_forms_are_laid_out_on_open_filled_and_saved_incrementally", "crates/automation/tests/it/automation.rs::dynamic_xfa_forms_open_render_fill_and_save_through_tools", "crates/ui-egui/tests/it/xfa.rs::a_dynamic_xfa_form_is_laid_out_and_filled", "crates/xfa/src/tests.rs::nested_width_less_subforms_lay_out_in_polynomial_time", "crates/js/src/tests.rs::instance_managers_add_remove_and_count", "crates/xfa/src/tests.rs::the_form_tree_carries_values_presence_instances_and_the_events", "crates/engine/src/tests.rs::xfa_scripts_initialize_calculate_validate_toggle_and_add_rows", "crates/engine/src/tests.rs::xfa_date_arithmetic_reads_the_date_as_its_display_picture_shows_it", "crates/automation/tests/it/automation.rs::xfa_scripts_run_for_buttons_and_field_changes_through_tools", "crates/ui-egui/tests/it/xfa.rs::xfa_buttons_run_their_scripts_in_the_app", "crates/engine/src/tests.rs::hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted", "crates/engine/src/tests.rs::runaway_xfa_calculations_at_open_end_quickly_with_a_report", "crates/ui-egui/tests/it/xfa.rs::a_form_whose_scripts_were_turned_off_at_open_says_so", "crates/ui-egui/tests/it/xfa.rs::scripts_turned_off_by_a_click_bring_a_closed_notice_bar_back", "crates/automation/tests/it/automation.rs::doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off", "crates/xfa/src/tests.rs::the_live_form_of_nested_repeating_subforms_stops_at_its_node_budget", "crates/xfa/src/tests.rs::data_ops_of_one_event_are_one_datasets_rewrite", "crates/xfa/src/tests.rs::choice_lists_signatures_passwords_and_pictures_become_fields_and_images", "crates/xfa/src/tests.rs::choice_values_come_from_the_data_as_saved_values_and_several_for_lists", "crates/xfa/src/tests.rs::pictures_are_sized_from_their_headers_and_hostile_ones_are_refused_unallocated", "crates/engine/src/tests.rs::xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save", "crates/xfa/src/tests.rs::repeating_subforms_inside_positioned_content_get_their_instances", "crates/engine/src/tests.rs::xfa_rows_in_areas_are_laid_out_read_calculated_and_written_where_adobe_binds_them", "crates/xfa/src/tests.rs::page_area_without_occur_serves_every_body_page", "apps/pdfcraft-cli/tests/it/xfa.rs::info_reports_the_laid_out_pages_and_fields", "apps/pdfcraft-cli/tests/it/xfa.rs::text_reads_the_layout_including_a_page_absent_from_the_placeholder", "apps/pdfcraft-cli/tests/it/xfa.rs::render_matches_the_engine_layout_on_the_second_page", "apps/pdfcraft-cli/tests/it/xfa.rs::a_laid_out_form_keeps_the_original_copy_permissions", "crates/engine/src/tests.rs::filling_growable_xfa_text_reflows_and_undo_restores_the_layout", "crates/engine/src/tests.rs::xfa_text_growth_refreshes_widget_geometry_but_fixed_height_fields_keep_their_widgets", "crates/automation/tests/it/automation.rs::growable_xfa_text_paginates_after_fill_save_and_print", "crates/engine/src/tests.rs::an_xfa_script_that_fails_partway_keeps_what_it_changed_before_the_error", "crates/engine/src/tests.rs::repeated_xfa_print_buttons_run_their_click_script", "crates/automation/tests/it/automation.rs::repeated_xfa_print_buttons_work_through_tools_and_after_reopen", "crates/xfa/src/tests.rs::rich_text_max_length_reaches_the_field_and_layout", "crates/automation/tests/it/automation.rs::xfa_rich_text_max_length_is_reported_and_enforced_through_tools", "crates/xfa/src/tests.rs::bmp_and_tiff_pictures_are_decoded_and_drawn", "crates/engine/src/tests.rs::active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts", "crates/engine/src/tests.rs::active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts", "crates/engine/src/tests.rs::inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts", "crates/automation/tests/it/automation.rs::active_xfa_skips_container_fallback_scripts_after_save_and_reopen", "crates/xfa/src/tests.rs::laying_a_form_out_takes_off_the_usage_rights_it_voids", "crates/engine/src/tests.rs::a_dynamic_xfa_form_is_saved_without_the_usage_rights_its_layout_voided", "crates/xfa/src/tests.rs::faces_measure_text_at_their_standard_14_widths", "crates/xfa/src/tests.rs::rich_text_links_keep_their_address", "crates/xfa/src/tests.rs::links_in_rich_text_become_link_annotations_one_per_line", "crates/xfa/src/tests.rs::a_links_pieces_share_one_address", "crates/xfa/src/tests.rs::a_rule_between_two_pieces_of_a_link_keeps_them_apart", "crates/xfa/src/tests.rs::links_to_anything_but_web_and_email_addresses_stay_plain_text", "crates/automation/tests/it/automation.rs::links_in_xfa_rich_text_are_listed_through_tools", "crates/xfa/src/tests.rs::rounded_corners_make_a_border_one_rounded_shape", "crates/xfa/src/tests.rs::a_rounded_box_cut_by_a_page_break_keeps_its_corners_on_each_page", "crates/xfa/src/tests.rs::rounded_boxes_are_written_as_curved_paths", "crates/xfa/src/tests.rs::a_flowed_subform_is_drawn_with_its_border_under_its_content", "crates/xfa/src/tests.rs::a_check_box_sits_where_its_margins_caption_and_alignment_put_it", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_takes_the_rest_of_the_field", "crates/xfa/src/tests.rs::a_radio_button_sits_where_a_check_box_would", "crates/xfa/src/tests.rs::a_check_box_caption_without_a_reserve_is_measured_as_it_is_drawn"] +notes = "Active XFA layout skips PDF-container document-script initialization for the Acrobat console/field context, so fallback-viewer scripts do not run there. Scripts remain in the file/listing; explicit console/field and XFA alerts still run. Ordinary PDF, static XFA and unlaid-out shells keep document scripts; static-conversion undo restores the XFA policy. Changed growable multiline text (no fixed h) reflows from final datasets after edits, with or without scripts; pages/widgets refresh and undo/redo, save/reopen and print retain all text. Oversized text still uses the existing read-only page-text fallback with a warning; fixed-height fields do not reflow. Repeated page-master buttons resolve their own PDF field to the shared XFA click script; the numPages-based print script requests printing from every copy, including after save/reopen (#2572). Rich-text exData/maxLength is carried into AcroForm MaxLen, reported by form_fields and enforced by form_fill (Unicode characters); limits and accepted values survive save/reopen (#2570). laid out from the template on open by the xfa crate (positioned, tb, lr-tb, table/row, page masters (a page area with no occur repeats without limit, one with occur max=1 is used once, one with occur min=1 the body never reached gets a page after the body, as Acrobat Pro does on a synthetic form), breaks, repeated header rows, borders and fills of flowed subforms (a page break between their children gives each page's part a closed border, the margins repeated at the cut, as measured on a synthetic form in another viewer, and a child's own page break breaks them the same way; one holding an item that doesn't fit on its page, cut across pages or placed past the content area's bottom, is drawn without its border, with a layout warning), captions, rounded and inverted border corners, rich text (its http, https and mailto links clickable), JPEG/PNG/GIF/BMP/TIFF images) into real pages and AcroForm fields (text, password, date with AFDate formats, check boxes and radio groups (each box placed by its field's margins, caption and paragraph alignment as measured on two synthetic forms in another viewer: with no caption at the right of the content area, beside a reserved caption where the alignment puts it, and a caption without a reserve taking the rest of the field; a check box's own margin larger than the box draws a bigger box there, which isn't copied), drop-downs and list boxes with shown/saved item pairs and multi-select, signature fields, image fields showing their picture (pictures decoded once per document, at most 20 million pixels, and reused across re-layouts), buttons with reset/print/save/URL), filled from the datasets packet (including rows another viewer added), saved as an incremental revision with the values merged into the datasets so Adobe's viewers show them; usage rights the form carried (/Perms /UR3) are taken off with a note, as the layout voids them and a form with void usage rights opens with every field locked; doc_info reports xfa_layout. CLI info reports the laid-out page/field counts and dimensions while retaining the input file size and raw counts as pdf_pages/acroform_fields; text and render use those pages too, preserving copy permissions and leaving the source file unchanged (#1645). XFA JavaScript and FormCalc (form.xfa-formcalc) run in the js crate's XFA object model (xfa.form, SOM resolution, rawValue, formattedValue in a date display picture's pattern (number and text display pictures still give the raw value), presence, access, instance managers, xfa.host, xfa.layout, xfa.event): initialize and calculate on open, exit, validate and calculate after a field changes, click for buttons (through js_run and the app), with rows added and removed, subforms shown and hidden, and the form laid out again, all as one undo step and kept in the file. All the scripts of one event run on one thread, each in a fresh context on the form as the engine holds it then (nothing shared between scripts; the live form kept up to date in place for values, rebuilt only when the structure changed), so hundreds of calculations run within the open budget. Hostile scripts are bounded per event (merged and capped effects, messages and console lines, lower loop limits and a time budget on open and calculate, one datasets rewrite per event, print/save/links/focus from clicks only, a runaway script abandoned and the form's scripts turned off, which the app says in a toast and on the notice bar, with a button to the JavaScript console, and doc_info reports as xfa_scripts_off) and what open-time scripts changed is noted. A script that fails partway keeps what it changed before the error, as JavaScript does (forms often trip over an object an earlier version had after their first lines did the work). Missing: a page area's relevant=\"-print\" (on a synthetic form Acrobat still prints the page but leaves it blank; PdfCraft prints its content, #2574); a page break before the first section (Acrobat opens an empty body page first when the first section has breakBefore targetType=pageArea, with or without startNew, as seen on synthetic forms; PdfCraft skips a break on an empty page and lays out one page fewer, #2574); loops inside nested calls are only stopped by abandoning their thread, and script memory is not capped; the change event per keystroke; xfa.template, connections and data descriptions; a hidden instance of a repeating subform hides them all; barcodes are drawn as their text, not bars; keep rules; text is measured at the exact standard-14 widths of the face it is drawn in (bold, italic and Times included), not in the template's own typefaces, and Acrobat fidelity is eyeballed on real forms, not measured (#60)" [[feature]] id = "form.xfa-formcalc" @@ -5637,8 +5684,8 @@ milestone = "M8" status = "shipped" commands = ["redact.search"] tools = ["redact_mark"] -evidence = ["crates/redact/src/patterns.rs::phone_numbers", "crates/redact/src/patterns.rs::slashed_and_labelled_unseparated_phone_numbers", "crates/redact/src/patterns.rs::credit_cards_need_a_valid_checksum", "crates/automation/tests/it/automation.rs::redacting_through_tools"] -notes = "phone (North American, E.164 with + country code, national with trunk 0, a slash after a trunk-0 area code; numbers without separators only after a phone label such as Tel. or cell.), e-mail, credit card (Luhn), SSN, dates (English US forms); text drawn in a font whose built-in encoding we hold no table for (Symbol, ZapfDingbats, MacExpert) has no Unicode mapping, so a pattern search cannot see it -- it is skipped rather than mis-decoded" +evidence = ["crates/redact/src/patterns.rs::labelled_italian_international_slash_numbers_match_whole", "crates/automation/tests/it/automation.rs::redacting_italian_international_slash_numbers_through_tools", "crates/redact/src/patterns.rs::phone_numbers", "crates/redact/src/patterns.rs::slashed_and_labelled_unseparated_phone_numbers", "crates/redact/src/patterns.rs::credit_cards_need_a_valid_checksum", "crates/automation/tests/it/automation.rs::redacting_through_tools"] +notes = "phone (North American, E.164 with + country code, national with trunk 0, a slash after a trunk-0 area code; labelled Italian +39 geographic numbers with one slash after the area code match with their complete country code (#3205 follow-up), with or without one space after +39; international slash numbers without a phone label, or with country codes other than +39, remain unsupported; numbers without separators only after a phone label such as Tel. or cell.), e-mail, credit card (Luhn), SSN, dates (English US forms); text drawn in a font whose built-in encoding we hold no table for (Symbol, ZapfDingbats, MacExpert) has no Unicode mapping, so a pattern search cannot see it -- it is skipped rather than mis-decoded" [[feature]] id = "protect.redact-pattern-locales" @@ -5842,7 +5889,7 @@ status = "partial" commands = [] tools = ["doc_audit_redactions"] evidence = ["crates/audit/src/tests.rs::faux_redaction_is_found", "crates/audit/src/tests.rs::background_before_text_is_not_flagged", "crates/audit/src/tests.rs::light_shading_after_text_is_not_flagged", "crates/audit/src/tests.rs::graphics_state_carries_across_streams", "crates/audit/src/tests.rs::audit_fails_closed_on_undecodable_stream", "crates/audit/src/tests.rs::unapplied_redact_mark_is_found", "crates/audit/src/tests.rs::applied_redaction_at_the_end_of_a_line_is_not_flagged", "crates/audit/src/tests.rs::faux_redaction_at_the_end_of_a_line_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_short_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_mirrored_text_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_is_found", "crates/audit/src/tests.rs::faux_redaction_over_a_partial_value_after_a_long_label_on_a_rotated_page_is_found", "crates/audit/src/tests.rs::faux_redactions_with_multiple_covers_lines_and_spacing_are_found", "crates/audit/src/tests.rs::a_cover_over_one_word_of_a_long_line_is_found", "crates/audit/src/tests.rs::a_box_grazing_a_word_is_not_flagged", "crates/automation/tests/it/automation.rs::auditing_faux_redactions_through_tools", "crates/automation/tests/it/automation.rs::an_applied_redaction_at_the_end_of_a_line_audits_clean", "crates/audit/src/tests.rs::rectangle_drawn_as_a_path_covers_text_like_re", "crates/audit/src/tests.rs::non_rectangular_path_is_not_a_cover", "apps/pdfcraft-cli/tests/it/audit.rs::audit_rectangles_match_the_tool_in_top_left_points", "crates/audit/src/tests.rs::rectangle_filled_after_restore_is_a_cover", "crates/audit/src/tests.rs::later_text_on_the_line_does_not_clear_an_earlier_cover", "crates/audit/src/tests.rs::indirect_subtype_is_resolved", "crates/audit/src/tests.rs::copy_restricted_documents_withhold_covered_text", "crates/audit/src/tests.rs::cover_and_line_comparisons_are_bounded", "crates/audit/src/tests.rs::covered_text_across_findings_is_bounded"] -notes = "New pdfcraft-audit crate. A filled rectangle (re + fill, or a single m/l subpath whose corners form an axis-aligned rectangle, #1510; opaque) counts as a cover only when painted after the start of the text line it overlaps and dark enough to hide it (WCAG contrast < 3.0); page backgrounds, table shading and highlights are not flagged. Coverage is measured on the words a line draws, not its box, so the gap an applied redaction leaves is not counted as hidden text; a cover over 40% of the line's box still counts when it has a positive-area intersection with one word, so a short value after a long label is found. A cover hiding half of any one word counts too (#2662), so a box over one number in a long line, or an unapplied mark over it, is found; a sliver under half of every word it touches and under 40% of the line is not flagged. Graphics state carries across a page's content streams. Fails closed: undecodable content, or more than 50 million cover-against-word comparisons on a page or 64 MiB of covered text in the report, is an error, never a clean bill. A document that withholds copying gets every finding with the covered text left out. Also flags /Redact annotations never applied. CLI: pdfcraft-cli audit (fails when findings exist). Tool and CLI report rectangles in points from the top-left of the displayed page, after /Rotate and rounded to 0.01 pt, like the other tools." +notes = "New pdfcraft-audit crate. A filled rectangle (re + fill, or a single m/l subpath whose corners form an axis-aligned rectangle, #1510; opaque) counts as a cover only when painted after the start of the text line it overlaps and dark enough to hide it (WCAG contrast < 3.0); page backgrounds, table shading and highlights are not flagged. Coverage is measured on the words a line draws, not its box, so the gap an applied redaction leaves is not counted as hidden text; a cover over 40% of the line's box still counts when it has a positive-area intersection with one word, so a short value after a long label is found. A cover hiding half of any one word counts too (#2662), so a box over one number in a long line, or an unapplied mark over it, is found; a sliver under half of every word it touches and under 40% of the line is not flagged. Graphics state carries across a page's content streams. Fails closed: undecodable content, or more than 50 million cover-against-word comparisons on a page or 64 MiB of covered text in the report, is an error, never a clean bill. A finding's covered text is the runs of the line whose words the cover overlaps, not the words of the line painted beside it (#4164); a line of one run, a right-to-left or visual-order line, or a cover over none of its words reports the whole line. A document that withholds copying gets every finding with the covered text left out. Also flags /Redact annotations never applied. CLI: pdfcraft-cli audit (fails when findings exist). Tool and CLI report rectangles in points from the top-left of the displayed page, after /Rotate and rounded to 0.01 pt, like the other tools." [[feature]] id = "protect.remove-hidden-on-close" @@ -6569,8 +6616,8 @@ milestone = "M10" status = "partial" commands = ["edit.edit_text"] tools = ["text_edit"] -evidence = ["crates/engine/tests/it/ocr_edit.rs::editing_an_ocr_word_covers_the_scan_and_shows_the_new_text", "crates/engine/tests/it/ocr_edit.rs::an_explicit_colour_is_kept_for_the_new_text", "crates/engine/tests/it/ocr_edit.rs::ocr_words_group_into_phrases_that_edit_in_place", "crates/engine/tests/it/ocr_edit.rs::a_longer_ocr_phrase_grows_rightwards", "crates/engine/tests/it/ocr_edit.rs::a_rewritten_ocr_phrase_keeps_its_word_widths", "crates/ui-egui/tests/it/ocr.rs::an_ocr_word_opens_for_editing_and_stays_open"] -notes = "On demand, per edit: in Edit text & images, recognised text (the invisible OCR layer over a scan) is grouped into phrases (words across a word space; a wider gap such as a table cell, and each scanned line, stay separate) and opens for editing on a click. Applying draws the new text visibly in the scan's sampled ink colour (or the chosen one) and first covers the old words with the paper colour sampled around them, so the scanned word no longer shows; the phrase keeps each word's position and width and grows rightwards instead of wrapping. Still missing: Acrobat's Recognize Text output that converts a whole page to editable text at once, matching the scanned typeface (the layer's Helvetica is used), and covering on a textured or patterned background (one flat colour is used)." +evidence = ["crates/engine/tests/it/ocr_edit.rs::editing_an_ocr_word_covers_the_scan_and_shows_the_new_text", "crates/engine/tests/it/ocr_edit.rs::an_explicit_colour_is_kept_for_the_new_text", "crates/engine/tests/it/ocr_edit.rs::ocr_words_group_into_phrases_that_edit_in_place", "crates/engine/tests/it/ocr_edit.rs::a_longer_ocr_phrase_grows_rightwards", "crates/engine/tests/it/ocr_edit.rs::a_rewritten_ocr_phrase_keeps_its_word_widths", "crates/ui-egui/tests/it/ocr.rs::an_ocr_word_opens_for_editing_and_stays_open", "crates/ui-egui/tests/it/ocr.rs::editing_a_scanned_page_recognizes_its_text_first_and_only_once"] +notes = "On demand, per edit: in Edit text & images, recognised text (the invisible OCR layer over a scan) is grouped into phrases (words across a word space; a wider gap such as a table cell, and each scanned line, stay separate) and opens for editing on a click. Applying draws the new text visibly in the scan's sampled ink colour (or the chosen one) and first covers the old words with the paper colour sampled around them, so the scanned word no longer shows; the phrase keeps each word's position and width and grows rightwards instead of wrapping. Turning Edit text & images on for a scanned page (an image covering at least half of a page that has no text of its own, once per page per window; comments and text boxes over it do not count as text, a page with real text and a background picture is left alone; one undoable \"Recognize text\" step) now recognizes its words first, as Acrobat's \"Converting scanned page to Editable Text and Images\" does (English only until more OCR languages exist, gap 7). Still missing: separating the photographs on the page from the lettering, matching the scanned typeface (the layer's Helvetica is used), and covering on a textured or patterned background (one flat colour is used)." [[feature]] id = "ocr.ocr-downsample" @@ -7005,8 +7052,8 @@ milestone = "M10" status = "partial" commands = ["export.docx"] tools = ["doc_export_office"] -evidence = ["crates/edit/src/tests.rs::reading_keeps_dotted_leader_entries_whole_and_in_order", "crates/engine/src/tests.rs::exporting_office_keeps_dotted_leader_entries_as_paragraphs", "crates/export/src/lib.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/lib.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/lib.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/lib.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/it/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/lib.rs::docx_is_a_word_package", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/lib.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/lib.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/lib.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/lib.rs::docx_keeps_a_shared_page_background", "crates/edit/src/tests.rs::a_page_painted_first_with_a_full_page_fill_has_that_background", "crates/engine/src/tests.rs::exporting_to_word_keeps_the_page_background"] -notes = "Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells (a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, the page colour (a full-page opaque fill painted first, such as Edit a PDF ▸ Background) as the document background when every page shares one non-white colour (Word has no per-page background, so differing colours are dropped; #2992; checked in the XML only, not yet opened in Word), and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; dotted-leader contents entries keep the title, dots and page number together in separate paragraphs (#1757); the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. Not yet: exact font matching, vector rules, arbitrary paint-order/transparency, colour per run within a paragraph, general exact layout fidelity." +evidence = ["crates/export/src/lib.rs::word_table_cell_widths_follow_the_columns_they_span", "crates/export/src/lib.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/edit/src/tests.rs::reading_keeps_dotted_leader_entries_whole_and_in_order", "crates/engine/src/tests.rs::exporting_office_keeps_dotted_leader_entries_as_paragraphs", "crates/export/src/lib.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/lib.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/lib.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/lib.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/it/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/lib.rs::docx_is_a_word_package", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/lib.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/lib.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/lib.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/lib.rs::docx_keeps_a_shared_page_background", "crates/edit/src/tests.rs::a_page_painted_first_with_a_full_page_fill_has_that_background", "crates/engine/src/tests.rs::exporting_to_word_keeps_the_page_background"] +notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells (cell widths sum the grid columns they occupy, including cells after spans, verified in the XML; a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, the page colour (a full-page opaque fill painted first, such as Edit a PDF ▸ Background) as the document background when every page shares one non-white colour (Word has no per-page background, so differing colours are dropped; #2992; checked in the XML only, not yet opened in Word), and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; dotted-leader contents entries keep the title, dots and page number together in separate paragraphs (#1757); the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. Not yet: exact font matching, vector rules, arbitrary paint-order/transparency, colour per run within a paragraph, general exact layout fidelity." [[feature]] id = "create.export-rtf" @@ -7017,8 +7064,8 @@ milestone = "M10" status = "shipped" commands = ["export.rtf"] tools = ["doc_export_office"] -evidence = ["crates/export/src/lib.rs::rtf_escapes_and_sizes", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf"] -notes = "Paragraphs with sizes, bold, italic, text colour (colortbl), Unicode; page breaks. Images are left out." +evidence = ["crates/export/src/lib.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/lib.rs::rtf_escapes_and_sizes", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf"] +notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Paragraphs with sizes, bold, italic, text colour (colortbl), Unicode; page breaks. Images are left out." [[feature]] id = "create.export-xlsx" @@ -7041,7 +7088,7 @@ milestone = "M10" status = "partial" commands = ["export.pptx"] tools = ["doc_export_office"] -evidence = [ +evidence = ["crates/export/src/lib.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/pptx.rs::pptx_is_a_presentationml_package", "crates/export/src/pptx.rs::pptx_slide_has_title_body_and_images_in_order", "crates/export/src/pptx.rs::pptx_text_colour_and_formatting", @@ -7058,7 +7105,7 @@ evidence = [ "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", ] -notes = "PowerPoint presentation: each page becomes a slide, with the largest non-table text as slide title, remaining paragraphs as body text, images and detected tables in reading order; DrawingML formatting (font size, bold, italic, text fill colour, cell borders); wired into engine command export.pptx, automation tool doc_export_office, and Export dialog. Missing: content past the slide bottom is not paginated or shrunk across multiple slides (no normAutofit), all slides use the first page's dimensions, title is determined by largest font size rather than semantic structure, and there is no layout-preserving mode (Acrobat keeps positions). Not yet verified in PowerPoint or Keynote: pptx_opens_in_libreoffice only converts the file when LibreOffice is installed and passes silently otherwise (CI has none), so the package structure is checked by pptx_package_parts_are_complete_and_consistent instead." +notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. PowerPoint presentation: each page becomes a slide, with the largest non-table text as slide title, remaining paragraphs as body text, images and detected tables in reading order; DrawingML formatting (font size, bold, italic, text fill colour, cell borders); wired into engine command export.pptx, automation tool doc_export_office, and Export dialog. Missing: content past the slide bottom is not paginated or shrunk across multiple slides (no normAutofit), all slides use the first page's dimensions, title is determined by largest font size rather than semantic structure, and there is no layout-preserving mode (Acrobat keeps positions). Not yet verified in PowerPoint or Keynote: pptx_opens_in_libreoffice only converts the file when LibreOffice is installed and passes silently otherwise (CI has none), so the package structure is checked by pptx_package_parts_are_complete_and_consistent instead." [[feature]] id = "create.export-png" @@ -7105,8 +7152,8 @@ milestone = "M10" status = "shipped" commands = ["export.all_images"] tools = ["doc_export_all_images"] -evidence = ["crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::indexed_and_one_bit_images_decode", "crates/create/src/tests.rs::jpeg_images_with_a_soft_mask_export_as_png_with_alpha", "crates/create/src/tests.rs::soft_masks_of_another_size_and_stencil_and_colour_key_masks_become_alpha", "crates/automation/tests/it/automation.rs::exporting_all_images_through_tools", "crates/ui-egui/tests/it/export.rs::export_dialogs_write_images_and_text", "crates/create/src/tests.rs::lossless_decode_maps_each_component_before_colour_conversion", "crates/create/src/tests.rs::lossless_indexed_decode_rounds_in_index_units_then_clamps"] -notes = "each image once (page resources and nested forms); JPEG unchanged unless it has transparency, others PNG (gray/RGB/CMYK/ICC/Indexed, 1–16 bpc, stencil masks); transparency as PNG alpha: /SMask (scaled to the image size, /Matte un-blended), stencil /Mask and colour-key /Mask, JPEG images included (#645); JPX/JBIG2/CCITT masks are left out (image exported opaque); JPX/JBIG2/CCITT/separations reported as skipped; minimum size filter. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Missing, null or malformed Decode arrays keep the default mapping. Still limited: 16-bit samples use high bytes; general JPEG and mask Decode, ICC transforms, and calibrated colors remain unsupported." +evidence = ["crates/create/src/tests.rs::images_export_as_jpeg_unchanged_and_others_as_png", "crates/create/src/tests.rs::indexed_and_one_bit_images_decode", "crates/create/src/tests.rs::jpeg_images_with_a_soft_mask_export_as_png_with_alpha", "crates/create/src/tests.rs::soft_masks_of_another_size_and_stencil_and_colour_key_masks_become_alpha", "crates/create/src/tests.rs::soft_mask_decode_ranges_become_exported_alpha", "crates/create/src/tests.rs::soft_mask_decode_maps_packed_and_jpeg_masks_before_resizing", "crates/automation/tests/it/automation.rs::exporting_all_images_through_tools", "crates/ui-egui/tests/it/export.rs::export_dialogs_write_images_and_text", "crates/create/src/tests.rs::lossless_decode_maps_each_component_before_colour_conversion", "crates/create/src/tests.rs::lossless_indexed_decode_rounds_in_index_units_then_clamps"] +notes = "each image once (page resources and nested forms); JPEG unchanged unless it has transparency, others PNG (gray/RGB/CMYK/ICC/Indexed, 1–16 bpc, stencil masks); transparency as PNG alpha: /SMask (scaled to the image size, /Matte un-blended), stencil /Mask and colour-key /Mask, JPEG images included (#645); JPX/JBIG2/CCITT masks are left out (image exported opaque); JPX/JBIG2/CCITT/separations reported as skipped; minimum size filter. Lossless images apply per-component Decode; Indexed values round and clip before palette lookup. Missing, null or malformed Decode arrays keep the default mapping. Soft-mask Decode intervals preserve opacity before resizing, including indirect arrays and endpoints. Still limited: 16-bit samples use high bytes; general JPEG image Decode, ICC transforms, and calibrated colors remain unsupported." [[feature]] id = "create.export-html" @@ -7117,8 +7164,8 @@ milestone = "M10" status = "partial" commands = ["export.html"] tools = ["doc_export_office"] -evidence = ["crates/export/src/lib.rs::html_has_headings_paragraphs_and_images_in_order", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/automation/tests/it/automation.rs::exporting_office_files_says_which_pages_left_form_text_out", "crates/edit/src/tests.rs::reading_follows_forms_as_far_as_the_renderer_and_says_where_it_stops"] -notes = "Single HTML file with headings, paragraphs (with text colour), inline images, and detected tables with spans/empty cells. Text and images in form XObjects are read 50 levels deep, as the renderer does, and up to 50 000 forms a page; the tool lists pages past that in truncated_pages, with a warning (#3120). Not yet: multi-page folder output, CSS from fonts." +evidence = ["crates/export/src/lib.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/lib.rs::html_has_headings_paragraphs_and_images_in_order", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/automation/tests/it/automation.rs::exporting_office_files_says_which_pages_left_form_text_out", "crates/edit/src/tests.rs::reading_follows_forms_as_far_as_the_renderer_and_says_where_it_stops"] +notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Single HTML file with headings, paragraphs (with text colour), inline images, and detected tables with spans/empty cells. Text and images in form XObjects are read 50 levels deep, as the renderer does, and up to 50 000 forms a page; the tool lists pages past that in truncated_pages, with a warning (#3120). Not yet: multi-page folder output, CSS from fonts." [[feature]] id = "create.export-text" @@ -7274,11 +7321,11 @@ title = "Make compatible with a chosen PDF version" area = "K" tier = "P1" milestone = "M11" -status = "planned" -commands = [] -tools = [] -evidence = [] -notes = "" +status = "partial" +commands = ["optimize.advanced"] +tools = ["doc_optimize"] +evidence = ["crates/optimize/tests/optimize.rs::compatibility_level_sets_version_unpacks_for_1_4_and_checks_encryption", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_picks_a_preset"] +notes = "Retain existing (default), PDF 1.4, 1.5, 1.6, 1.7 or 2.0 in PDF Optimizer and doc_optimize; writes the chosen %PDF- header, clears any overriding catalog /Version, disables and unpacks object streams and cross-reference streams for PDF 1.4, and refuses when the document's encryption requires a newer PDF version (#3090). Partial compatibility only: content newer than the target is neither detected nor converted (including optional content, JPX images, 16-bit images, OpenType fonts and PDF 2.0 features); the chosen header does not guarantee compatibility with older readers" [[feature]] id = "optimize.optimizer" @@ -7301,8 +7348,8 @@ milestone = "M11" status = "partial" commands = ["optimize.advanced"] tools = ["doc_optimize"] -evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::a_page_picture_printed_as_one_pixel_strips_is_merged_into_one_image", "crates/optimize/tests/optimize.rs::images_that_do_not_tile_a_picture_are_left_alone", "crates/optimize/tests/optimize.rs::a_deeply_nested_form_is_measured_at_its_largest_use", "crates/optimize/tests/optimize.rs::images_are_not_resampled_when_some_uses_could_not_be_measured"] -notes = "colour and grayscale, bicubic, effective resolution measured where drawn (every use, through nested forms up to 12 deep; when a use can't be measured, nothing is resampled); image fragments stacked edge to edge are merged first (side-by-side tiles not yet); monochrome not yet" +evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/optimize/tests/optimize.rs::a_page_picture_printed_as_one_pixel_strips_is_merged_into_one_image", "crates/optimize/tests/optimize.rs::images_that_do_not_tile_a_picture_are_left_alone", "crates/optimize/tests/optimize.rs::a_deeply_nested_form_is_measured_at_its_largest_use", "crates/optimize/tests/optimize.rs::images_are_not_resampled_when_some_uses_could_not_be_measured", "crates/optimize/tests/optimize.rs::image_resolution_uses_the_pages_physical_unit", "crates/optimize/tests/optimize.rs::optimization_retains_an_image_below_threshold_on_a_scaled_page", "crates/optimize/tests/optimize.rs::shared_forms_are_measured_in_each_pages_physical_units"] +notes = "colour and grayscale, bicubic, effective resolution measured where drawn (every use, with page-local UserUnit scaling, through nested forms up to 12 deep; when a use can't be measured, nothing is resampled); image fragments stacked edge to edge are merged first (side-by-side tiles not yet); monochrome not yet" [[feature]] id = "optimize.recompress-images" @@ -7313,8 +7360,8 @@ milestone = "M11" status = "partial" commands = ["optimize.advanced"] tools = ["doc_optimize"] -evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::settings_choose_what_happens"] -notes = "JPEG (quality steps) and ZIP; no JPEG 2000" +evidence = ["crates/optimize/tests/optimize.rs::images_are_measured_where_drawn_and_downsampled", "crates/optimize/tests/optimize.rs::settings_choose_what_happens", "crates/optimize/tests/jpeg_color_transform.rs::optimize_keeps_the_colours_of_an_rgb_jpeg_whatever_its_colour_transform"] +notes = "JPEG (quality steps) and ZIP; a JPEG is decoded with its colour transform (Adobe marker, else /ColorTransform), and one whose marker and /ColorTransform disagree is left as it is; no JPEG 2000" [[feature]] id = "optimize.mono-compression" @@ -7574,11 +7621,11 @@ title = "Compress document structure (object streams)" area = "K" tier = "P1" milestone = "M11" -status = "partial" -commands = ["file.save_as"] -tools = ["doc_save"] -evidence = ["crates/cos/tests/it/writer.rs::full_save_packs_objects_into_object_streams"] -notes = "on by default for full saves; no Optimizer option yet" +status = "shipped" +commands = ["file.save_as", "optimize.advanced"] +tools = ["doc_save", "doc_optimize"] +evidence = ["crates/cos/tests/it/writer.rs::full_save_packs_objects_into_object_streams", "crates/optimize/tests/optimize.rs::compatibility_level_sets_version_unpacks_for_1_4_and_checks_encryption", "crates/automation/tests/it/automation.rs::optimizing_through_tools", "crates/ui-egui/tests/it/create.rs::the_pdf_optimizer_dialog_picks_a_preset"] +notes = "on by default for full saves and in PDF Optimizer ▸ Clean Up (and doc_optimize object_streams); turning it off or targeting PDF 1.4 unpacks object streams into standalone objects with a classic cross-reference table" [[feature]] id = "optimize.flate-unencoded" @@ -7589,8 +7636,8 @@ milestone = "M11" status = "partial" commands = ["optimize.advanced"] tools = ["doc_optimize"] -evidence = ["crates/optimize/tests/optimize.rs::discards_and_clean_up"] -notes = "unencoded streams; LZW not yet re-encoded" +evidence = ["crates/optimize/tests/optimize.rs::discards_and_clean_up", "crates/optimize/tests/optimize.rs::lzw_streams_become_flate_and_decode_to_the_same_bytes", "crates/optimize/tests/optimize.rs::a_page_drawn_from_lzw_streams_renders_the_same_after_conversion"] +notes = "Two Clean Up options, as in Acrobat: unencoded streams are Flate-compressed, and LZW streams (content and image XObjects; EarlyChange 0 and 1, with or without a predictor, LZW anywhere in a filter chain) are decoded and stored with Flate instead, keeping the predictor and the filters after the LZW one; LZW data that doesn't decode cleanly is left as it is. Not yet: LZW inline images inside content streams." [[feature]] id = "optimize.remove-invalid-links" @@ -7965,8 +8012,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print", "printers"] -evidence = ["crates/ui-egui/tests/it/print.rs::paper_choice_survives_cancel_and_settings_restore", "crates/ui-egui/tests/it/print.rs::restored_paper_keeps_preview_and_saved_sheet_dimensions", "crates/ui-egui/tests/it/print.rs::print_dialog_lays_out_sheets_and_saves_a_pdf", "crates/engine/src/tests.rs::printing_lays_out_sheets_that_render", "crates/ui-egui/src/web_print.rs::a_job_draws_every_sheet_at_its_planned_resolution_then_builds_the_page", "crates/ui-egui/src/web_print.rs::the_print_page_turns_sideways_sheets_names_other_sizes_and_ends_without_a_blank_page"] -notes = "⌘P; settings and live sheet preview; on the web the same dialog prints through the browser's print dialog; last paper selection, including cancelled Print dialogs, persists by canonical name; this change does not alter persistence of other print preferences" +evidence = ["crates/ui-egui/tests/it/print.rs::paper_choice_survives_cancel_and_settings_restore", "crates/ui-egui/tests/it/print.rs::restored_paper_keeps_preview_and_saved_sheet_dimensions", "crates/ui-egui/tests/it/print.rs::print_dialog_lays_out_sheets_and_saves_a_pdf", "crates/engine/src/tests.rs::printing_lays_out_sheets_that_render", "crates/ui-egui/src/web_print.rs::a_job_draws_every_sheet_at_its_planned_resolution_then_builds_the_page", "crates/ui-egui/src/web_print.rs::the_print_page_turns_sideways_sheets_names_other_sizes_and_ends_without_a_blank_page", "crates/print/src/tests.rs::every_paper_reaches_the_spooler_by_a_name_of_its_size", "crates/automation/src/printing.rs::printing_to_a_printer_feeds_the_laid_out_paper"] +notes = "⌘P; settings and live sheet preview; on the web the same dialog prints through the browser's print dialog; last paper selection, including cancelled Print dialogs, persists by canonical name; this change does not alter persistence of other print preferences. CUPS jobs from the dialog and doc_print ask for the chosen paper (media) in every size: PPD keywords for the common ones, PWG 5101.1 names for A6, A0–A2, ARCH and ANSI C–E, checked against a synthetic PPD with cupsfilter, not on a physical large-format printer (#3752). On macOS jobs still take the PPD's default paper tray until craft-print sends Auto Select (#3752)" [[feature]] id = "print.printer-selection" @@ -8073,8 +8120,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/print/src/tests.rs::booklets_pair_pages_for_folding"] -notes = "both/front/back, left/right binding" +evidence = ["crates/print/src/tests.rs::booklets_pair_pages_for_folding", "crates/ui-egui/tests/it/print.rs::a_booklet_decides_two_sided_itself", "crates/automation/src/printing.rs::a_booklet_decides_two_sided_itself", "crates/print/src/tests.rs::a_booklet_prints_both_sides_on_the_short_edge"] +notes = "both/front/back, left/right binding; a booklet decides two-sided itself, as Acrobat does (its Print dialog hides Two-sided while Booklet is chosen; Using Acrobat X Pro: Acrobat automatically enables duplex printing for booklet printing): both sides flip on the short edge, front or back only prints one-sided, and the Two-sided choice is kept for the other layouts (#2707)" [[feature]] id = "print.poster" @@ -8109,8 +8156,8 @@ milestone = "M10" status = "shipped" commands = ["print.dialog"] tools = ["doc_print"] -evidence = ["crates/print/src/tests.rs::imposed_pdf_has_the_sheets_and_honours_comments_and_forms"] -notes = "Print flag honoured" +evidence = ["crates/print/src/tests.rs::imposed_pdf_has_the_sheets_and_honours_comments_and_forms", "crates/print/src/tests/appearances.rs::annotation_appearances_do_not_replace_page_xobjects"] +notes = "Print flag honoured; appearance resource names preserve existing page XObjects and distinct annotations, including inherited resources" [[feature]] id = "print.print-with-comment-summary" diff --git a/vendor/README.md b/vendor/README.md index cc6c29b4b4..812d7df10e 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -17,15 +17,16 @@ 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; (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) | (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::text_rendering_modes_other_than_invisible_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) | -| 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 | (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` | +| 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 | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group; under reading colours its background is painted inside it as the initial backdrop, so a Multiply highlight blends with the dark background instead of staying an opaque block, and otherwise the background stays outside, so a blend mode over an empty part of the page keeps its colour instead of blending with the white paper (#2970); (19) `set_paint` samples a mesh shading's texture over its triangles alone (`EncodedShadingPattern::extent`, padded rather than repeated) and skips a mesh with none on the canvas: a BricsCAD plan's 310 one-triangle `sh` each covered the whole tile, 2 s per 1024 px tile at any zoom (the watchdog gave the page up on a busy machine), now about 70 ms; (20) a tiling pattern whose step is too large for a cell pixmap, where what is painted (a fill, or a stroke with its width) can only show the first cell (PowerPoint's `/XStep 2147480100` for a picture fill drawn once), has its `/BBox` drawn at full scale with a transparent border and padded instead of tiled (`shows_one_cell`; a cell whose scale isn't finite and positive, from a `/BBox` too thin to divide by, keeps the old path: `usable_cell_scale`); shrinking the cell to fit the 3000 px limit of (3) left the picture under a pixel, so network diagrams lost their pictures (#3349); (22) knockout groups: while one is innermost, fills, strokes, glyph outlines and shadings are drawn with `Compose::Copy` (weighted by coverage), so each element replaces what earlier elements of the group drew instead of compositing over it; an image or nested group, drawn into a layer of its own, first has what is under its shape cleared (`knockout_image` for an unmasked image's rectangle, only when `draw_image` will draw it, decoded at the size it is drawn; a knockout erase pass that draws a nested group's content with every paint clearing instead of painting, a shading fill's clear clipped to the shading's `/BBox` as its paint is) and then composites over it (#2971); (23) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres; (25) a tiling cell is drawn at the scale that makes one `/XStep` (`/YStep`) exactly the cell pixmap's whole number of pixels (`tiling_cell_pixels`, at least 1 and at most the cap), so the repeated pixmap puts cells at whole multiples of the step at any device scale; drawing at the unrounded scale repeated the cell up to half a pixel off at every step, and the error grew with distance from the pattern's origin (ISO 32000-2 8.7.3.1) | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::non_interpolated_images_are_not_smoothed_when_minified`, `raster::tests::non_interpolated_gray_images_are_not_smoothed_when_minified`, `raster::tests::non_integer_minification_samples_the_same_columns`, `raster::tests::non_integer_minification_samples_colour_and_mask_from_the_same_columns`, `raster::tests::non_interpolated_alpha_images_keep_their_colours_and_mask_when_minified`, `raster::tests::non_interpolated_type3_glyph_images_are_not_smoothed_when_minified`, `raster::tests::image_resampling_nearest_accepts_wide_sources_the_filter_refuses`, `raster::tests::wide_non_interpolated_sources_draw_after_shrinking`; (11) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (12) `raster::tests::preview_renders_skip_tiny_images_but_finals_draw_everything`; (13) `color_replacement_tests::{black_maps_to_text_colour, white_maps_to_background, midgrey_interpolates, alpha_copied_verbatim, only_black_threshold, line_art_false_skips_paths_but_not_text}`, `color_replacement_render_tests::{color_replacement_maps_text_and_line_art, color_replacement_maps_stencil_mask_ink}`; (14) `raster::tests::interpolated_minified_image_edges_snap_to_whole_pixels` (`cargo test -p hayro`); (15) `raster::tests::shrunk_soft_mask_larger_than_its_image_keeps_thin_strokes`; (16) `raster::tests::soft_mask_applies_to_a_shading_pattern_fill`; (17), (18) `raster::tests::reading_colors_keep_highlight_annotations_readable`; (19) `mesh_extent_tests::{a_mesh_paints_its_triangles_alone, a_mesh_keeps_the_graphics_state_alpha, a_mesh_off_the_canvas_paints_nothing}` (`cargo test -p hayro`); (20) `raster::tests::a_tiling_pattern_drawn_once_shows_its_cell`, `raster::tests::a_large_step_still_tiles_where_the_next_cell_shows`, `raster::tests::a_tiling_cell_too_thin_to_scale_keeps_the_old_path`; (22) `raster::tests::knockout_groups_replace_what_their_elements_cover`; (23) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients`; (25) `tiling_cell_placement::{far_cells_of_a_fill_sit_at_whole_steps, far_cells_sit_at_whole_steps_under_a_turned_scaled_matrix, far_cells_of_a_stroke_sit_at_whole_steps, a_cell_pixmap_spans_exactly_one_step}` (`cargo test -p pdfcraft-render --test it`) | | hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate`; (4) `segment::tests::test_referred_to_count_is_capped` (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`) | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order; (6) `find_latest_trailer`: a rebuild takes a `/Type /XRef` stream dictionary as the trailer when the file has no `trailer` keyword (the latest in the file wins, as a later `trailer` would), so a concatenated or truncated file keeps its /Info and /ID instead of failing to load; a candidate whose /Root is the catalog beats one whose /Root is another dictionary, and an in-file /Root must match its generation (#2365) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace`; (6) `inspect::tests::rebuilt_file_keeps_the_info_of_its_cross_reference_stream`, `inspect::tests::rebuilt_file_prefers_a_trailer_whose_root_is_the_catalog` | -| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); an EI followed by a delimiter (EI%..., EI[...) ends the image like one followed by white space; only a regular character after EI skips the candidate; (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW, RunLength and ASCII85 never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it.; (17) CCITT `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11), not zero rows: scanned Group 4 pages written that way rendered blank; (18) The streaming Flate decoder (`lzw_flate::flate::fallback`) copies a back-reference in runs of up to its distance (`extend_from_within`), not byte by byte. The copy reads only bytes already in the output, and a copy whose source does not exist still writes nothing, so the bytes are those a byte-by-byte copy writes. Inflating the A1 sample's content stream (278.6 MB) takes about 14% less time (0.66 s to 0.56 s); the output is identical; (19) CCITT `/Rows` below the image's `/Height` decodes to the height: a plotter driver wrote `/Rows` equal to `/Columns`, so only the first rows were decoded and the rest drawn black (#3308) (20) `stream` followed by spaces or tabs and then the line break (`stream \r\n`, as Microsoft Reporting Services writes it) starts the data after the line break; the content stream was taken to start at the space and the page rendered blank.; (21) a JP2 colour specification naming an enumerated colour space the decoder refuses (CIEJab in a veraPDF fixture, YCbCr, PhotoYCC, YPbPr, bilevel…) no longer fails the image: it is decoded again from a copy whose `colr` names the space the decoder assumes without one, greyscale, sRGB or CMYK by component count, as MuPDF and Poppler draw it (`without_refused_enumerated_colour`; the page drew blank, #3306); (22) an object stream's index of `objnum offset` pairs is parsed once per stream and kept beside its decoded data (`ObjectStreamIndexes`, `XRef::with_object_stream_indexes`), and an offset past `usize` is malformed instead of wrapping: `XRef::get_with` parsed the whole index again for every object it fetched from the stream, so reading all of a stream of N objects took N² steps (a Firefox page whose 65,542 objects share one stream took 37 s to draw and 40 s to extract its text); (23) the inline-image end-of-data look-ahead no longer rejects a candidate `EI` because a byte above 0x7F lies between it and the next one: when the candidate is the end, those bytes are the content stream that follows the image, and its strings carry such bytes (WinAnsi `(Grüße) Tj`, the two-byte codes of any Type 0 font), so the operator check that follows decides alone, bounded as in (4) (a scanned page whose OCR text held an accented word and a later capital "EI" — EINGANG, BEI, KLEIN — drew and extracted nothing after the image: 40 of 275 pages across five real documents); (24) `xref::prev_offset`: a trailer `/Prev` written as a reference `N G R` is read as the offset that object holds when the section's own entries place it (iText's `indirectPrev.pdf`; each section's entries are read before its `Prev` is followed), and otherwise as offset N (tiff2pdf; JHOVE's PDF-HUL-157 files, #2366), as `pdfcraft-cos` and other readers take it, so the earlier sections' pages are seen instead of one; (25) direct arrays and dictionaries share a reader-local nesting cap of 128, counted while skipping and while parsing a dictionary (its lenient repair reads, rather than skips, an object found where a key belongs, so `<< << …` recursed through that read); deeper objects return `None` instead of overflowing the stack (including unused trailer values, trailer entries, content operands and marked-content properties); a chain of 65 direct stitching functions now stops at this cap, before the 64-function guard; (26) an xref stream subsection must be backed by its decoded data: all-zero `/W` widths (an entry reads no bytes) and a declared count longer than the data are refused before any entry is inserted, where a tiny stream with a large `/Size` or `/Index` count inserted that many entries (from storytold/craft-libs#239); a subsection running past object number `i32::MAX` is refused instead of overflowing or turning negative; (27) an xref stream's type field is read big-endian at its full `/W[0]` width, where only its first byte was read and wider fields shifted every later entry, and `/W[0]` over 8 bytes is refused like `/W[1]` (from storytold/craft-libs#241) | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`, `content::tests::inline_image_ei_may_be_followed_by_a_delimiter`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed`; (17) `raster::tests::group4_fax_with_rows_zero_decodes_to_the_image_height`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height`; (20) `raster::tests::space_before_the_line_break_after_stream_still_renders`; (21) `raster::tests::a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn`, `filter::jpx::tests::refused_enumerated_colour_spaces_become_the_component_counts_default`, `filter::jpx::tests::what_the_decoder_handles_or_cant_be_read_is_left_alone` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (22) `raster::tests::objects_in_one_large_object_stream_are_read_in_linear_time`; (23) `text::tests::text_after_an_inline_image_survives_a_non_ascii_byte_before_a_later_ei`, `content::tests::an_inline_image_ends_at_its_own_ei_when_later_text_holds_a_non_ascii_byte` (hayro-syntax); (24) `xref::tests::prev_as_reference_reaches_the_earlier_section`, `xref::tests::prev_as_reference_to_an_object_holding_the_offset_reads_that_offset`, `pdfcraft-cos` `document::tests::a_prev_written_as_a_reference_still_reaches_the_earlier_sections`, `document::tests::a_prev_reference_to_an_object_holding_the_offset_reads_that_offset`; (25) `raster::tests::deeply_nested_direct_objects_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_content_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_the_trailer_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_as_marked_content_properties_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_are_read_up_to_the_nesting_cap`, `raster::tests::nesting_at_the_cap_fits_a_512_kib_stack` (native only), `raster::tests::function_limits_depth_guard_refuses_a_chain_of_references`; (26) `xref::tests::xref_stream_entries_need_backing_bytes`; (27) `xref::tests::xref_stream_type_field_uses_the_full_w0_width`, `xref::tests::xref_stream_loader_rejects_oversized_w0` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`) | +| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); an EI followed by a delimiter (EI%..., EI[...) ends the image like one followed by white space; only a regular character after EI skips the candidate; (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW, RunLength and ASCII85 never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependency enabled by `std`, and without `std` passwords are used as given): the renderer used the password as typed, so one that SASLprep changes (a decomposed `café`, a no-break space, the fullwidth `ab` a Japanese IME types in full-width mode) was wrong, although pdfcraft-cos accepted it: protecting a document with such an open password was rolled back ("the password is incorrect"), and a file with such a password did not open with it.; (17) CCITT `/Rows 0` means the height is not predetermined (ISO 32000-2 Table 11), not zero rows: scanned Group 4 pages written that way rendered blank; (18) The streaming Flate decoder (`lzw_flate::flate::fallback`) copies a back-reference in runs of up to its distance (`extend_from_within`), not byte by byte. The copy reads only bytes already in the output, and a copy whose source does not exist still writes nothing, so the bytes are those a byte-by-byte copy writes. Inflating the A1 sample's content stream (278.6 MB) takes about 14% less time (0.66 s to 0.56 s); the output is identical; (19) CCITT `/Rows` below the image's `/Height` decodes to the height: a plotter driver wrote `/Rows` equal to `/Columns`, so only the first rows were decoded and the rest drawn black (#3308) (20) `stream` followed by spaces or tabs and then the line break (`stream \r\n`, as Microsoft Reporting Services writes it) starts the data after the line break; the content stream was taken to start at the space and the page rendered blank.; (21) a JP2 colour specification naming an enumerated colour space the decoder refuses (CIEJab in a veraPDF fixture, YCbCr, PhotoYCC, YPbPr, bilevel…) no longer fails the image: it is decoded again from a copy whose `colr` names the space the decoder assumes without one, greyscale, sRGB or CMYK by component count, as MuPDF and Poppler draw it (`without_refused_enumerated_colour`; the page drew blank, #3306); (22) an object stream's index of `objnum offset` pairs is parsed once per stream and kept beside its decoded data (`ObjectStreamIndexes`, `XRef::with_object_stream_indexes`), and an offset past `usize` is malformed instead of wrapping: `XRef::get_with` parsed the whole index again for every object it fetched from the stream, so reading all of a stream of N objects took N² steps (a Firefox page whose 65,542 objects share one stream took 37 s to draw and 40 s to extract its text); (23) the inline-image end-of-data look-ahead no longer rejects a candidate `EI` because a byte above 0x7F lies between it and the next one: when the candidate is the end, those bytes are the content stream that follows the image, and its strings carry such bytes (WinAnsi `(Grüße) Tj`, the two-byte codes of any Type 0 font), so the operator check that follows decides alone, bounded as in (4) (a scanned page whose OCR text held an accented word and a later capital "EI" — EINGANG, BEI, KLEIN — drew and extracted nothing after the image: 40 of 275 pages across five real documents); (24) `xref::prev_offset`: a trailer `/Prev` written as a reference `N G R` is read as the offset that object holds when the section's own entries place it (iText's `indirectPrev.pdf`; each section's entries are read before its `Prev` is followed), and otherwise as offset N (tiff2pdf; JHOVE's PDF-HUL-157 files, #2366), as `pdfcraft-cos` and other readers take it, so the earlier sections' pages are seen instead of one; (25) direct arrays and dictionaries share a reader-local nesting cap of 128, counted while skipping and while parsing a dictionary (its lenient repair reads, rather than skips, an object found where a key belongs, so `<< << …` recursed through that read); deeper objects return `None` instead of overflowing the stack (including unused trailer values, trailer entries, content operands and marked-content properties); a chain of 65 direct stitching functions now stops at this cap, before the 64-function guard; (26) an xref stream subsection must be backed by its decoded data: all-zero `/W` widths (an entry reads no bytes) and a declared count longer than the data are refused before any entry is inserted, where a tiny stream with a large `/Size` or `/Index` count inserted that many entries (from storytold/craft-libs#239); a subsection running past object number `i32::MAX` is refused instead of overflowing or turning negative; (27) an xref stream's type field is read big-endian at its full `/W[0]` width, where only its first byte was read and wider fields shifted every later entry, and `/W[0]` over 8 bytes is refused like `/W[1]` (from storytold/craft-libs#241); (28) a content instruction keeps up to 256 operands (`MAX_OPERANDS`) instead of 10, and past that drops the oldest instead of ending the content stream: the eleventh operand of a twelve-ink DeviceN `scn` (ISO 32000-2 §8.6.6.5 allows any number of components) blanked the rest of the page (#4260) | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`, `content::tests::inline_image_ei_may_be_followed_by_a_delimiter`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it`; (14) `crypto::pdfcraft_password_tests`, `pdfcraft-engine` `tests::legacy_passwords_with_turkish_characters_open`; (15) `content::tests::windows_keep_every_instruction_at_every_split`, `content::tests::windows_keep_every_instruction_in_pieces_of_any_size`, `content::tests::windows_match_the_slice_on_random_content`, `content::tests::windows_drop_blanks_and_keep_a_comment_that_may_go_on`, `page::content_reader_tests::content_past_the_cap_is_read_to_the_end`, `page::content_reader_tests::a_stream_that_expands_past_the_budget_is_cut_there`, `page::content_reader_tests::a_small_expansion_under_the_cap_is_not_cut`, `raster::tests::page_content_past_the_old_cap_renders`, `raster::tests::content_reader_returns_the_same_bytes_in_any_pieces`, `raster::tests::content_bombs_stop_at_the_budget_and_say_so`, `page::content_reader_tests::plain_and_whole_pieces_keep_their_bytes`, `page::content_reader_tests::repeated_content_reference_counts_compressed_bytes_once`, `page::content_reader_tests::whole_cap_still_counts_later_unique_streams`; (16) `crypto::pdfcraft_password_tests::aes256_passwords_are_tried_in_their_saslprep_form_then_as_given`, `crypto::pdfcraft_password_tests::aes256_passwords_open_as_typed_and_in_their_saslprep_form`, `pdfcraft-engine` `tests::aes256_open_passwords_that_saslprep_changes_protect_and_reopen_as_typed`, `tests::aes256_files_whose_passwords_saslprep_changes_open_as_typed`; (17) `raster::tests::group4_fax_with_rows_zero_decodes_to_the_image_height`; (18) `filter::lzw_flate::tests::back_reference_longer_than_its_distance_repeats_its_source`, `back_reference_cut_by_the_limit_resumes_in_the_next_call` and `back_reference_reads_bytes_written_by_an_earlier_call`; each fails when the copy returns the wrong remaining count; (19) `raster::tests::group4_fax_with_rows_below_its_height_decodes_to_the_image_height`; (20) `raster::tests::space_before_the_line_break_after_stream_still_renders`; (21) `raster::tests::a_jp2_image_in_a_refused_enumerated_colour_space_is_drawn`, `filter::jpx::tests::refused_enumerated_colour_spaces_become_the_component_counts_default`, `filter::jpx::tests::what_the_decoder_handles_or_cant_be_read_is_left_alone` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (22) `raster::tests::objects_in_one_large_object_stream_are_read_in_linear_time`; (23) `text::tests::text_after_an_inline_image_survives_a_non_ascii_byte_before_a_later_ei`, `content::tests::an_inline_image_ends_at_its_own_ei_when_later_text_holds_a_non_ascii_byte` (hayro-syntax); (24) `xref::tests::prev_as_reference_reaches_the_earlier_section`, `xref::tests::prev_as_reference_to_an_object_holding_the_offset_reads_that_offset`, `pdfcraft-cos` `document::tests::a_prev_written_as_a_reference_still_reaches_the_earlier_sections`, `document::tests::a_prev_reference_to_an_object_holding_the_offset_reads_that_offset`; (25) `raster::tests::deeply_nested_direct_objects_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_content_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_in_the_trailer_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_as_marked_content_properties_do_not_overflow_the_stack`, `raster::tests::dictionaries_nested_without_keys_are_read_up_to_the_nesting_cap`, `raster::tests::nesting_at_the_cap_fits_a_512_kib_stack` (native only), `raster::tests::function_limits_depth_guard_refuses_a_chain_of_references`; (26) `xref::tests::xref_stream_entries_need_backing_bytes`; (27) `xref::tests::xref_stream_type_field_uses_the_full_w0_width`, `xref::tests::xref_stream_loader_rejects_oversized_w0` (`cargo test --manifest-path vendor/hayro-syntax/Cargo.toml`); (28) `content::ops::tests::scn_with_twelve_components_keeps_them_all`, `content::ops::tests::operands_past_the_cap_keep_the_latest_in_order` (hayro-syntax), `raster::tests::devicen_inks::a_twelve_ink_devicen_colour_paints`, `raster::tests::devicen_inks::a_four_ink_devicen_colour_paints`, `raster::tests::devicen_inks::an_operator_with_hundreds_of_operands_does_not_end_the_content` | | winit | 0.30.13 | Apache-2.0 | (1) on Windows 11 (build 22000 and later), `WM_DPICHANGED` applies the rect Windows suggests. It used to nudge the window onto the monitor `MonitorFromWindow` names, which during a drag is still the monitor being left; the nudge sent another `WM_DPICHANGED`, so a window dragged to a monitor with another scale factor grew and stayed on the wrong monitor at the wrong scale (#324). Backport of winit master 488c036a ([winit#4341](https://github.com/rust-windowing/winit/pull/4341)), which no 0.30 release has; Windows 10 keeps the old adjustment, as on winit master. (2) on Wayland, a seat's text input is destroyed when the seat goes away, not whenever it loses a capability: losing any capability (a touch screen or tablet unplugged, a remote-desktop session ending) destroyed it, and only an added capability recreated it, so IME stopped working until restart. Port of [winit#4747](https://github.com/rust-windowing/winit/pull/4747); (3) on Windows, an IME composition reports its cursor from `GCS_CURSORPOS` only when `WM_IME_COMPOSITION` says it is set, and at the end of the composition otherwise: the Microsoft Korean IME never sets it, so the cursor read as 0 (egui then drew no caret while composing; it now draws it after the syllable). Port of [winit#4746](https://github.com/rust-windowing/winit/pull/4746); (4) on Windows, a hidden window is not maximized until it is shown, and a hidden window shown while maximized gets one `ShowWindow(SW_MAXIMIZE)`: `SW_MAXIMIZE` shows a hidden window, so PdfCraft, which eframe keeps hidden until its first frame is drawn, appeared unpainted (white) when it restored a maximized window, and could lose its focus. Port of [winit#4587](https://github.com/rust-windowing/winit/pull/4587) (the later, focus-safe form of [winit#4575](https://github.com/rust-windowing/winit/pull/4575)); (5) on X11, when allowing or disallowing IME replaces the focused window's input context (egui does it each time a text field gains or loses focus), the new context is focused: no focus event follows, so it stayed unfocused until the window lost and regained focus. Port of [winit#4727](https://github.com/rust-windowing/winit/pull/4727); (6) on macOS, `insertText` commits through IME when a composition was started (`pending_commit`, set by `setMarkedText`) rather than when marked text is left: input methods that end a composition with `unmarkText` before `insertText` lost the text, and Apple Korean dropped the key typed after a syllable (`한` then `5` or `;`); other `insertText` calls stay `KeyboardInput`; a commit outside a key press (a candidate clicked with the mouse, dictation) clears the marked text and leaves no key to swallow, as in [winit#4748](https://github.com/rust-windowing/winit/pull/4748) (without it the next key, such as Backspace, was lost). `validAttributesForMarkedText` lists the underline and marked-clause attributes, without which some input methods don't compose. Port of [winit#4650](https://github.com/rust-windowing/winit/pull/4650), the 0.30 backport of [winit#4651](https://github.com/rust-windowing/winit/pull/4651) (merged on winit master), with the underline attribute spelled as AppKit's `NSUnderlineStyleAttributeName` (`NSUnderline`); (7) on macOS, disallowing IME with a composition in progress (focus leaving a text field) tells the input method to discard its marked text (`discardMarkedText`): it kept composing it once IME was allowed again, so the abandoned syllable came back (`하`, then `가` typed in the next field, became `학`, then `하` + `가`; Japanese `か` then `さ` became `傘`). Port of [winit#4745](https://github.com/rust-windowing/winit/pull/4745); (8) on macOS, text inserted outside a key press without a composition (the emoji picker) is reported as `Ime::Commit`; it was dropped. Port of the emoji-picker part of [winit#4749](https://github.com/rust-windowing/winit/pull/4749) (0.30 backport of [winit#4748](https://github.com/rust-windowing/winit/pull/4748)); its `selectedRange` change for dictation is left out because it switches on press-and-hold for every held key (an accent menu instead of key repeat, and the chosen accent added after the repeated letters); (9) on macOS, a key whose text was committed through IME isn't also sent as `KeyboardInput` unless AppKit forwards it as a command (Enter): Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`, `insertText(" ")`, `setMarkedText("")`, and the last call reset winit's state, so the space arrived twice. Same goal as [winit#4478](https://github.com/rust-windowing/winit/pull/4478), whose changes don't reach this call order (macOS 14); to be proposed upstream there; (10) on macOS, when the first key in a fresh app reaches Apple Korean's `insertText` as a raw compatibility jamo instead of starting a composition, that key is interpreted once more, synchronously, before it is sent as `KeyboardInput` (g k s typed ㅎㅏㄴ instead of 한); only an unmodified, non-repeated first key whose one callback inserts that same jamo, with IME enabled and nothing composed, focus and input source unchanged, is retried (`ime_startup.rs`). Port of [winit#4744](https://github.com/rust-windowing/winit/pull/4744), with its policy tests.; (11) on X11 without `Xft.dpi`, the scale factor comes from the X screen's configured DPI (its size in pixels and millimetres) before the output's physical density from RandR: Xorg can deliberately run a dense panel at 96 DPI, and only winit applications then looked zoomed; `Xft.dpi` still wins, `WINIT_X11_SCALE_FACTOR` still overrides, and a screen reporting no size keeps the RandR fallback. PdfCraft patch (#1150, replacing the launcher pin proposed in #908). (12) on macOS, `WinitView` answers `mouseDownCanMoveWindow` with the window's `movable_by_window_background` attribute (NO unless asked): NSView answers YES for a view that isn't opaque, so with the transparent title bar and full-size content view PdfCraft uses, AppKit moved the window for any drag that began in the 28 pt title-bar band before the app saw it, and document tabs (which live in that band) could not be dragged to reorder them. The empty strip still moves the window through `ViewportCommand::StartDrag`. PdfCraft patch. The published crate's asset files (`docs/res/*.svg`, `examples/data/*.png`) are left out, so its `window` example doesn't build; (13) on Wayland, pixel-only wheel, wheel-tilt and continuous-source scroll events report `Moved` rather than starting an unterminated finger gesture, so egui follows modifier release instead of keeping Ctrl zoom latched (#991). Finger gestures and unknown sources retain their existing phase policy | (1) `pdfcraft` `tests::winit_carries_the_windows_11_monitor_scale_fix` keeps the patch in the build; the drag itself needs two monitors with different scale factors on Windows 11; (2) `pdfcraft` `tests/winit_wayland_text_input.rs`; the capability change itself needs a Wayland session and an input method; (3) `pdfcraft` `tests/winit_windows_ime_cursor.rs`; composing itself needs the Microsoft Korean IME; (4) `pdfcraft` `tests/winit_windows_hidden_maximized.rs`; the start-up itself needs Windows and a window that was closed maximized; (5) `pdfcraft` `tests/winit_x11_ime_focus.rs`; switching fields itself needs an X11 session and an input method (IBus, fcitx5); (6) `pdfcraft` `tests/winit_macos_ime_commit.rs`; composing itself needs macOS and an input method; (7) `pdfcraft` `tests/winit_macos_ime_discard.rs`; composing itself needs macOS and an input method; (8) `pdfcraft` `tests/winit_macos_emoji_picker.rs`; the picker itself needs macOS; (9) `pdfcraft` `tests/winit_macos_korean_space.rs`; composing itself needs macOS and Apple Korean; (10) `pdfcraft` `tests/winit_macos_korean_first_key.rs` and `ime_startup.rs`'s own tests (run them with `rustc --test`); typing itself needs macOS and Apple Korean; (11) `pdfcraft` `tests/winit_x11_dpi.rs` keeps the patch in the build, and `util::randr` tests the policy (run them in the vendored crate); the scale itself needs an X11 session; (12) `pdfcraft` `tests/winit_macos_title_bar_drag.rs` keeps the patch in the build; the drag itself needs macOS and a pointer; (13) `pdfcraft` `tests/winit_wayland_scroll.rs` compiles the production phase policy and exercises modifier release through egui; live COSMIC confirmation remains open | | egui-wgpu | 0.36.2 | MIT OR Apache-2.0 | (1) `winit::surface_fit`: a window's surface is configured within the device's `max_texture_dimension_2d`, both sides shrunk by one factor that egui then draws at (`pixels_per_point × render_scale`), so the whole window is drawn into the surface (on DX12 it is stretched over the window and lines up with the pointer; wgpu's GL backend copies it unscaled into a corner); the longer side becomes exactly the limit; the depth and MSAA textures follow, and a recreated surface starts again from the window's own size. egui-wgpu configured the window's size as is, and `Surface::configure` panics beyond the limit: a window restored at another monitor's scale (opening an Outlook attachment on a mixed-DPI laptop asked an 8192-pixel device for 8600 × 3423, #577) or stretched across monitors closed PdfCraft, before its window appeared when it happened at start-up ([emilk/egui#8361](https://github.com/emilk/egui/issues/8361)). The published crate leaves out egui's licence files; `LICENSE-APACHE` and `LICENSE-MIT` are from the 0.36.2 tag; (2) `winit::catch_errors`: a new window's surface is configured inside wgpu error scopes (validation, out of memory, internal), and a failure is returned as `WgpuError::ConfigureSurface` (for the root window at start-up eframe returns it as `Error::Wgpu`, which PdfCraft retries with OpenGL); nothing is kept for the window. wgpu's default handler panicked instead, and eframe configures the root surface before the app is created, so a driver that can't configure one closed PdfCraft at launch (a 2015 Intel GPU through wgpu's GL backend timed out waiting for the GPU to go idle, `GpuWaitTimeout`, #519). Unchanged, so still a panic: an error in a later reconfigure (resize, a lost surface), a device lost during the first configure (wgpu reports it only to a callback), and a window shown at zero size (first configured on resize) | (1) `pdfcraft` `tests::a_window_larger_than_the_gpu_limit_gets_a_surface_within_it`, `tests::surface_fit_never_empties_or_overflows_a_surface`, and `tests::egui_wgpu_carries_the_surface_size_fit`, which keeps the patch in the build and in the paths that size a surface; (2) `pdfcraft` `tests::a_gpu_error_while_a_window_is_set_up_is_returned_not_a_panic` (a real device: an error inside `catch_errors` is returned, not a panic) and `tests::egui_wgpu_returns_a_surface_it_cannot_configure_as_an_error`, which keeps it around `add_surface`'s configure | | vello_common | 0.0.8 | Apache-2.0 OR MIT | (1) `clip::intersect` (the clip step every clipped path passes through) starts its row walk at the first row both paths have strips in, not the earlier of the two first rows. The walk visited every row from the top of the clip for each path (on the A1 sample's full-page canvas, about half its strip rows per stroke), and rows where one path has no strips produce no output, so the strips are unchanged (vello_common 0.1 starts at the later row too). Its cost grew with the page, and a full-page render of the A1 sample was about 30% faster; the output is byte-identical | (1) `clip::tests::intersect_walks_only_rows_both_paths_have_strips` counts the rows the walk visits (`cargo test --manifest-path vendor/vello_common/Cargo.toml`) and fails without the patch; the render crate's pixel tests run through the patched crate | | hayro-cmap | 0.1.0 | Apache-2.0 OR MIT | (1) malformed parts of a CMap (bad bfchar/bfrange entries, unresolvable `usecmap`, unparseable tail) are skipped instead of discarding the whole map, so search and redaction find text the edit layer can read (#3177); (2) a binary CMap's `usecmap` counts against the 16-level nesting limit as a text one does, where it passed its own depth and a self-referencing binary CMap recursed until the stack overflowed (from storytold/craft-libs#254) | (1) `hayro-cmap::tests::bad_bfchar_entry_is_skipped`, `unresolvable_usecmap_is_skipped`; (2) `tests::self_referencing_binary_usecmap_stops_at_the_nesting_limit` | +| hayro-postscript | 0.1.0 | Apache-2.0 OR MIT | (1) the token scanner's `object::read` skips `<<` and `>>` in a loop; it called itself once per delimiter, so a CMap stream holding a long run of `<` or `>` overflowed the stack and aborted the process (from storytold/craft-libs#263) | (1) `hayro-postscript::object::tests::long_open_dict_run_does_not_overflow`, `long_close_dict_run_does_not_overflow`, `dict_delimiters_are_skipped`, `nested_dict_delimiters_are_skipped` | Renderer startup errors: `egui-wgpu::RenderState::create` also wraps `Renderer::new` in `winit::catch_errors` when the winit feature is enabled. A rejected shader or render pipeline @@ -92,3 +93,12 @@ are unchanged. Renderer regressions `japanese_substitute_missing_spaces_draw_no_ and `japanese_substitute_spaces_keep_pdf_widths_and_unicode` use the synthetic MS-Mincho / UniJIS-UCS2-HW-H fixture with craft-fonts, including `/W` and `/DW` placement controls. + +Symbolic TrueType `cmap` order (#4156): in `hayro-interpret` `EmbeddedKind::map_code`, an +embedded TrueType font whose Nonsymbolic flag is not set (the branch `is_non_symbolic()` does not +take) looks a code up in its Windows (3, 0) or (3, 1) subtables before its Macintosh and Unicode +ones, as ISO 32000-2 §9.6.5.4 prescribes for symbolic fonts; the +(1, 0) subtable still serves codes the (3, x) ones leave out. The records were tried in the +order the font lists them, platform ID first, so a wrong (1, 0) table (SAS 9.4's AMT fonts) +drew "8" as "p" and "+" as "V". Covered by `raster::tests::symbolic_truetype_cmap`, whose font +is built in memory. diff --git a/vendor/hayro-interpret/src/color.rs b/vendor/hayro-interpret/src/color.rs index 2027396590..a8e7d84c16 100644 --- a/vendor/hayro-interpret/src/color.rs +++ b/vendor/hayro-interpret/src/color.rs @@ -28,6 +28,22 @@ pub type ColorComponents = SmallVec<[f32; 4]>; std::thread_local! { // PdfCraft patch (34): see `tint_transform_runs`. static TINT_TRANSFORM_RUNS: core::cell::Cell = const { core::cell::Cell::new(0) }; + // PdfCraft patch (55): see `note_color_fallback`. + static COLOR_FALLBACK: core::cell::Cell = const { core::cell::Cell::new(false) }; +} + +/// PdfCraft patch (55): record that a colour could not be computed and was painted in a +/// fallback colour, reported as `InterpreterWarning::ColorFallback` when the content stream +/// being interpreted ends. Per thread, like the page's other budgets: one page is interpreted +/// on one thread. +pub(crate) fn note_color_fallback() { + COLOR_FALLBACK.with(|fell_back| fell_back.set(true)); +} + +/// PdfCraft patch (55): whether a colour fell back since the last call (or since the page +/// started), clearing it, so each fallback is reported once. +pub(crate) fn take_color_fallback() -> bool { + COLOR_FALLBACK.with(|fell_back| fell_back.replace(false)) } /// PdfCraft patch (34): how many colours this thread has run through a Separation or DeviceN @@ -1030,8 +1046,30 @@ impl ToRgb for Indexed { #[derive(Debug, Clone)] pub(crate) struct Separation { alternate_space: ColorSpace, - tint_transform: Function, + /// PdfCraft patch (55): `None` when the tint transform cannot be read (see + /// `eval_tint_transform`). + tint_transform: Option, is_none_separation: bool, + /// PdfCraft patch (66): the colourant is the special name `All`. + is_all: bool, +} + +/// PdfCraft patch (55): `tint_transform` run on the tints of one colour, or, when it cannot be +/// read or evaluated, the initial colour of `alternate_space` (black in every device space), with +/// the fallback recorded. hayro already fell back this way when evaluation failed, silently; a +/// transform it could not read made the whole colour space unreadable, and the space's +/// paints were drawn in DeviceGray from their first tint instead (white for a full tint). +fn eval_tint_transform( + tint_transform: Option<&Function>, + alternate_space: &ColorSpace, + tints: &[f32], +) -> ColorComponents { + tint_transform + .and_then(|f| f.eval(tints.to_smallvec())) + .unwrap_or_else(|| { + note_color_fallback(); + alternate_space.initial_color() + }) } impl Separation { @@ -1041,28 +1079,46 @@ impl Separation { let _ = iter.next::>()?; let name = iter.next::>()?; let alternate_space = ColorSpace::new(iter.next::>()?, cache)?; - let tint_transform = Function::new(&iter.next::>()?)?; - // Either I did something wrong, or no other viewers properly handles - // `All`, so let's just ignore it as well. + // PdfCraft patch (55): an unreadable tint transform keeps the space (see + // `eval_tint_transform`). + let tint_transform = iter.next::>().and_then(|f| Function::new(&f)); let is_none_separation = name.as_str() == "None"; + // PdfCraft patch (66): `All` names every colourant of the output device at once + // (ISO 32000-2 §8.6.6.4), so its tints never go through the alternate space. + let is_all = name.as_str() == "All"; Some(Self { alternate_space, tint_transform, is_none_separation, + is_all, }) } } impl ToRgb for Separation { fn convert_f32(&self, input: &[f32], output: &mut [u8], _: bool) -> Option<()> { + if self.is_all { + // PdfCraft patch (66): §8.6.6.4: the tint applies to every colourant, and on an + // additive device it is complemented first, so tint t is the gray 1 − t on each of + // R, G and B whatever the alternate space and tint transform say. + for (tint, rgb) in input.iter().zip(output.chunks_exact_mut(3)) { + // `clamp` keeps NaN, and `f32_to_u8`'s cast saturates it to 0. + let gray = f32_to_u8(1.0 - tint.clamp(0.0, 1.0)); + rgb.copy_from_slice(&[gray, gray, gray]); + } + return Some(()); + } count_tint_transform_runs(input.len()); + // PdfCraft patch (55): see `eval_tint_transform`. let evaluated = input .iter() .flat_map(|n| { - self.tint_transform - .eval(smallvec![*n]) - .unwrap_or(self.alternate_space.initial_color()) + eval_tint_transform( + self.tint_transform.as_ref(), + &self.alternate_space, + &[*n], + ) }) .collect::>(); self.alternate_space.convert_f32(&evaluated, output, false) @@ -1077,7 +1133,9 @@ impl ToRgb for Separation { pub(crate) struct DeviceN { alternate_space: ColorSpace, num_components: u8, - tint_transform: Function, + /// PdfCraft patch (55): `None` when the tint transform cannot be read (see + /// `eval_tint_transform`). + tint_transform: Option, is_none: bool, } @@ -1094,7 +1152,9 @@ impl DeviceN { let num_components = u8::try_from(names.len()).ok()?; let all_none = names.iter().all(|n| n.as_str() == "None"); let alternate_space = ColorSpace::new(iter.next::>()?, cache)?; - let tint_transform = Function::new(&iter.next::>()?)?; + // PdfCraft patch (55): an unreadable tint transform keeps the space (see + // `eval_tint_transform`). + let tint_transform = iter.next::>().and_then(|f| Function::new(&f)); if num_components == 0 { return None; @@ -1113,12 +1173,11 @@ impl ToRgb for DeviceN { fn convert_f32(&self, input: &[f32], output: &mut [u8], _: bool) -> Option<()> { let colours = input.len().checked_div(usize::from(self.num_components)); count_tint_transform_runs(colours.unwrap_or(0)); + // PdfCraft patch (55): see `eval_tint_transform`. let evaluated = input .chunks_exact(self.num_components as usize) .flat_map(|n| { - self.tint_transform - .eval(n.to_smallvec()) - .unwrap_or(self.alternate_space.initial_color()) + eval_tint_transform(self.tint_transform.as_ref(), &self.alternate_space, n) }) .collect::>(); self.alternate_space.convert_f32(&evaluated, output, false) diff --git a/vendor/hayro-interpret/src/context.rs b/vendor/hayro-interpret/src/context.rs index 3670f46fec..1b1655dcd4 100644 --- a/vendor/hayro-interpret/src/context.rs +++ b/vendor/hayro-interpret/src/context.rs @@ -209,6 +209,8 @@ impl<'a> Context<'a> { NESTED_PAINTS.with(|n| n.set(0)); CONTENT_LEFT.with(|c| c.set(MAX_PAGE_CONTENT)); CONTENT_TRUNCATED.with(|truncated| truncated.set(false)); + // PdfCraft patch (55): a fallback left over from another page is not this page's. + crate::color::take_color_fallback(); crate::encode::reset_mesh_budget(); Self::new_with(initial_transform, bbox, cache, xref, settings, state, 0) diff --git a/vendor/hayro-interpret/src/font/cid.rs b/vendor/hayro-interpret/src/font/cid.rs index ee4cc2412f..44e076b4b1 100644 --- a/vendor/hayro-interpret/src/font/cid.rs +++ b/vendor/hayro-interpret/src/font/cid.rs @@ -86,7 +86,9 @@ impl Type0Font { if let Some((standard, _)) = select_standard_font(dict, &font_descriptor) { (FontQuery::Standard(standard), true) } else { - let mut query = FallbackFontQuery::new(dict); + // PdfCraft patch: the style comes from the descendant's descriptor. + let mut query = + FallbackFontQuery::with_descriptor(dict, Some(font_descriptor.clone())); query.character_collection = character_collection.clone(); warn!( diff --git a/vendor/hayro-interpret/src/font/mod.rs b/vendor/hayro-interpret/src/font/mod.rs index 103ee39610..e0fc75981b 100644 --- a/vendor/hayro-interpret/src/font/mod.rs +++ b/vendor/hayro-interpret/src/font/mod.rs @@ -564,6 +564,13 @@ pub struct FallbackFontQuery { impl FallbackFontQuery { pub(crate) fn new(dict: &Dict<'_>) -> Self { + Self::with_descriptor(dict, dict.get::>(FONT_DESC)) + } + + /// PdfCraft patch: the query for the font `dict` names, with its style read from + /// `descriptor`. A Type 0 font has no font descriptor of its own; its descendant CIDFont + /// has it (ISO 32000-2 §9.7.4.1, §9.7.6.1). + pub(crate) fn with_descriptor(dict: &Dict<'_>, descriptor: Option>) -> Self { let post_script_name = dict .get::>(BASE_FONT) .map(|n| strip_subset_prefix(n.as_str()).to_string()); @@ -573,7 +580,7 @@ impl FallbackFontQuery { ..Default::default() }; - if let Some(descriptor) = dict.get::>(FONT_DESC) { + if let Some(descriptor) = descriptor { data.font_name = dict .get::>(FONT_NAME) .map(|n| strip_subset_prefix(n.as_str()).to_string()); diff --git a/vendor/hayro-interpret/src/font/standard_font.rs b/vendor/hayro-interpret/src/font/standard_font.rs index 5941160f77..7eb25ff2c7 100644 --- a/vendor/hayro-interpret/src/font/standard_font.rs +++ b/vendor/hayro-interpret/src/font/standard_font.rs @@ -544,6 +544,15 @@ impl StandardKind { return path; }; + // PdfCraft patch (57): a stand-in for a font the document names is narrowed to a + // narrower /Widths advance, so neighbours don't overlap, but never widened: the + // advance already places the next glyph, and widening drew `i` and `l` of a 600-unit + // `Arial,Bold` as solid blocks twice their width. + let should_width = if self.fallback { + should_width.min(actual_width) + } else { + should_width + }; return stretch_glyph(path, should_width, actual_width); } diff --git a/vendor/hayro-interpret/src/font/true_type.rs b/vendor/hayro-interpret/src/font/true_type.rs index c650439961..25b3ebc4e7 100644 --- a/vendor/hayro-interpret/src/font/true_type.rs +++ b/vendor/hayro-interpret/src/font/true_type.rs @@ -360,26 +360,36 @@ impl EmbeddedKind { } } } else if let Ok(cmap) = self.base_font.font_ref().cmap() { - for record in cmap.encoding_records() { - if record.platform_id() == PlatformId::Windows - && matches!(record.encoding_id(), 0 | 1) - { - if let Ok(subtable) = record.subtable(cmap.offset_data()) { - for offset in [0x0000_u32, 0xF000, 0xF100, 0xF200] { - glyph = glyph - .or_else(|| subtable.map_codepoint(code as u32 + offset)) - .filter(|g| *g != GlyphId::NOTDEF); + // PdfCraft patch (#4156): the Windows (3, 0) subtable comes first, and the (1, 0) one + // only for codes it doesn't map (ISO 32000-2 §9.6.5.4: "If the font contains a (3, 0) + // subtable" it is used; "Otherwise, if the font contains a (1, 0) subtable"). The + // records are listed by platform ID, so Macintosh came before Windows and a wrong + // (1, 0) table, as SAS writes for its AMT fonts, drew "8" as "p" and "+" as "V". + for windows_pass in [true, false] { + for record in cmap.encoding_records() { + let windows = record.platform_id() == PlatformId::Windows + && matches!(record.encoding_id(), 0 | 1); + if windows != windows_pass { + continue; + } + if windows { + if let Ok(subtable) = record.subtable(cmap.offset_data()) { + for offset in [0x0000_u32, 0xF000, 0xF100, 0xF200] { + glyph = glyph + .or_else(|| subtable.map_codepoint(code as u32 + offset)) + .filter(|g| *g != GlyphId::NOTDEF); + } } + } else if matches!( + record.platform_id(), + PlatformId::Macintosh | PlatformId::Unicode + ) && record.encoding_id() == 0 + && let Ok(subtable) = record.subtable(cmap.offset_data()) + { + glyph = glyph + .or_else(|| subtable.map_codepoint(code)) + .filter(|g| *g != GlyphId::NOTDEF); } - } else if matches!( - record.platform_id(), - PlatformId::Macintosh | PlatformId::Unicode - ) && record.encoding_id() == 0 - && let Ok(subtable) = record.subtable(cmap.offset_data()) - { - glyph = glyph - .or_else(|| subtable.map_codepoint(code)) - .filter(|g| *g != GlyphId::NOTDEF); } } } diff --git a/vendor/hayro-interpret/src/interpret/mod.rs b/vendor/hayro-interpret/src/interpret/mod.rs index 1ac17fa52b..cf71eb92b5 100644 --- a/vendor/hayro-interpret/src/interpret/mod.rs +++ b/vendor/hayro-interpret/src/interpret/mod.rs @@ -21,7 +21,7 @@ use hayro_syntax::object::dict::keys::{ ACTUAL_TEXT, ANNOTS, AP, AS, C, CA, F, MCID, N, OC, QUADPOINTS, RECT, SUBTYPE, }; use hayro_syntax::object::{Array, Dict, Object, Rect, Stream, dict_or_stream}; -use hayro_syntax::page::{ContentReader, Page, Resources}; +use hayro_syntax::page::{ContentReader, Page, Resources, Rotation}; use hayro_syntax::MAX_DECODED_STREAM; use kurbo::{Affine, BezPath, Point, Shape}; use smallvec::smallvec; @@ -157,6 +157,10 @@ pub enum InterpreterWarning { ImageDecodeFailure, /// PdfCraft patch (18): a page content stream was skipped after exhausting its safety budget. ContentTruncated, + /// PdfCraft patch (55): a colour could not be computed (a colour space that cannot be + /// read, or a Separation or DeviceN tint transform that cannot be evaluated) and was painted + /// in a fallback colour. Reported at most once per content stream. + ColorFallback, } /// interpret the contents of the page and render them into the device. @@ -186,6 +190,15 @@ pub fn interpret_page<'a>( if flags & (2 | 32) != 0 { continue; } + // PdfCraft patch (56): Invisible (bit 1) hides an annotation of a type that isn't one + // of the standard ones (ISO 32000-2 Table 167), and a popup has no appearance of its + // own (§12.5.6.14): the viewer shows it as a window for its parent, not on the page. + let subtype = annot.get::>(SUBTYPE); + if subtype.as_deref() == Some(b"Popup") + || (flags & 1 != 0 && !subtype.as_deref().is_some_and(is_standard_annotation_type)) + { + continue; + } // PdfCraft patch: "Hide all comments" keeps widgets and links. if context.settings.hide_comments && !matches!(annot.get::>(SUBTYPE).as_deref(), Some(b"Widget" | b"Link")) @@ -249,6 +262,13 @@ pub fn interpret_page<'a>( annot_rect.x0 - sx * transformed_rect.x0, annot_rect.y0 - sy * transformed_rect.y0, ]); + // PdfCraft patch (59): NoRotate (bit 5) keeps the appearance upright as the page is + // displayed (ISO 32000-2 §12.5.3). + let affine = if flags & 16 != 0 && no_rotate_applies(&annot) { + no_rotate_turn(page.rotation(), &annot_rect) * affine + } else { + affine + }; // PdfCraft patch: a /BBox or /Matrix that collapses the appearance box to a line // or a point leaves nothing to map onto /Rect: the scales above divide by zero, @@ -282,6 +302,67 @@ pub fn interpret_page<'a>( } } +/// PdfCraft patch (56): whether `subtype` is one of the standard annotation types of +/// ISO 32000-2 Table 171. +fn is_standard_annotation_type(subtype: &[u8]) -> bool { + matches!( + subtype, + b"Text" + | b"Link" + | b"FreeText" + | b"Line" + | b"Square" + | b"Circle" + | b"Polygon" + | b"PolyLine" + | b"Highlight" + | b"Underline" + | b"Squiggly" + | b"StrikeOut" + | b"Caret" + | b"Stamp" + | b"Ink" + | b"Popup" + | b"FileAttachment" + | b"Sound" + | b"Movie" + | b"Screen" + | b"Widget" + | b"PrinterMark" + | b"TrapNet" + | b"Watermark" + | b"3D" + | b"Redact" + | b"Projection" + | b"RichMedia" + ) +} + +/// PdfCraft patch (59): whether the NoRotate flag turns this annotation's appearance. Form fields +/// and links are left in `/Rect`: PdfCraft takes their click targets from it. +fn no_rotate_applies(annot: &Dict<'_>) -> bool { + !matches!( + annot.get::>(SUBTYPE).as_deref(), + Some(b"Widget" | b"Link") + ) +} + +/// PdfCraft patch (59): the user-space turn that keeps a NoRotate annotation upright on a page +/// with `/Rotate`. The page is shown turned clockwise, so the appearance (already fitted to +/// `/Rect`) is turned as far counterclockwise about the rectangle's upper-left corner, which stays +/// where it is on the page (ISO 32000-2 §12.5.3). Its width then runs across the displayed page +/// and its height down from that corner. +fn no_rotate_turn(rotation: Rotation, rect: &kurbo::Rect) -> Affine { + let (cos, sin) = match rotation { + Rotation::None => return Affine::IDENTITY, + Rotation::Horizontal => (0.0, 1.0), + Rotation::Flipped => (-1.0, 0.0), + Rotation::FlippedHorizontal => (0.0, -1.0), + }; + let (x, y) = (rect.x0.min(rect.x1), rect.y0.max(rect.y1)); + Affine::new([cos, sin, -sin, cos, x - (cos * x - sin * y), y - (sin * x + cos * y)]) +} + /// PdfCraft patch: the normal appearance of a Highlight annotation that has none (ISO 32000-2 /// §12.5.6.10), drawn as PdfCraft draws its own highlights (pdfcraft-annot): each /QuadPoints /// quadrilateral filled with /C at /CA opacity, blended with Multiply so the marked-up content @@ -562,8 +643,13 @@ fn interpret_window<'a>( } TypedInstruction::ColorSpaceStroke(c) => { // PdfCraft patch (38): see `named_color_space`. + // PdfCraft patch (55): a space that cannot be read is drawn in DeviceGray, as + // before, and the fallback is reported. let cs = named_color_space(c.0, resources, &defaults, context) - .unwrap_or(ColorSpace::device_gray()); + .unwrap_or_else(|| { + crate::color::note_color_fallback(); + ColorSpace::device_gray() + }); if !cs.is_pattern() { context.get_mut().graphics_state.stroke_pattern = None; @@ -573,8 +659,13 @@ fn interpret_window<'a>( } TypedInstruction::ColorSpaceNonStroke(c) => { // PdfCraft patch (38): see `named_color_space`. + // PdfCraft patch (55): a space that cannot be read is drawn in DeviceGray, as + // before, and the fallback is reported. let cs = named_color_space(c.0, resources, &defaults, context) - .unwrap_or(ColorSpace::device_gray()); + .unwrap_or_else(|| { + crate::color::note_color_fallback(); + ColorSpace::device_gray() + }); if !cs.is_pattern() { context.get_mut().graphics_state.non_stroke_pattern = None; @@ -932,6 +1023,10 @@ pub fn interpret<'a>( if crate::context::content_was_truncated() { (context.settings.warning_sink)(InterpreterWarning::ContentTruncated); } + // PdfCraft patch (55): and that a colour was painted in a fallback colour. + if crate::color::take_color_fallback() { + (context.settings.warning_sink)(InterpreterWarning::ColorFallback); + } } /// PdfCraft patch: the bytes of a window of page content. A window grows only while an instruction @@ -990,4 +1085,8 @@ fn interpret_content<'a>( if cut || content.truncated() || crate::context::content_was_truncated() { (context.settings.warning_sink)(InterpreterWarning::ContentTruncated); } + // PdfCraft patch (55): and that a colour was painted in a fallback colour. + if crate::color::take_color_fallback() { + (context.settings.warning_sink)(InterpreterWarning::ColorFallback); + } } diff --git a/vendor/hayro-interpret/src/interpret/text.rs b/vendor/hayro-interpret/src/interpret/text.rs index cd5e147f9b..b7b1b2a2b7 100644 --- a/vendor/hayro-interpret/src/interpret/text.rs +++ b/vendor/hayro-interpret/src/interpret/text.rs @@ -69,11 +69,15 @@ pub(crate) fn show_glyph<'a>( device.set_blend_mode(ctx.get().graphics_state.blend_mode); let stroke_props = ctx.stroke_props(); - // PdfCraft patch: only mode 3 has any effect on text in a Type 3 font (ISO 32000-1 - // 9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts - // look bold, and the clip modes have no outline to clip with. + // PdfCraft patch: text in a Type 3 font is rendered from its glyph descriptions in every mode + // but 3 and 7 (ISO 32000-2 §9.3.6). Stroking ran the glyph procedure a second time, so + // `2 Tr` made bitmap fonts look bold. + // PdfCraft patch (64): in modes 3 and 7 the text "shall not be rendered" (§9.3.6); `7 Tr` + // painted the glyphs in the fill colour. It is still reported as clipping text, but in + // modes 4 to 7 "nothing shall be added to the clipping path" (`clip_glyph`). let render_mode = match (glyph, ctx.get().text_state.render_mode) { (Glyph::Type3(_), TextRenderingMode::Invisible) => TextRenderingMode::Invisible, + (Glyph::Type3(_), TextRenderingMode::Clip) => TextRenderingMode::Clip, (Glyph::Type3(_), _) => TextRenderingMode::Fill, (_, mode) => mode, }; @@ -187,9 +191,9 @@ pub(crate) fn clip_glyph(context: &mut Context<'_>, glyph: &Glyph<'_>, transform context.get_mut().text_state.clip_paths.extend(outline); } } - Glyph::Type3(_) => { - warn!("text rendering mode clip not implemented for shape glyphs"); - } + // PdfCraft patch (64): a Type 3 glyph adds nothing to the clipping path in modes 4 to 7 + // (ISO 32000-2 §9.3.6), so text shown only in a Type 3 font leaves no clip at `ET`. + Glyph::Type3(_) => {} } } diff --git a/vendor/hayro-postscript/Cargo.lock b/vendor/hayro-postscript/Cargo.lock new file mode 100644 index 0000000000..b95d341526 --- /dev/null +++ b/vendor/hayro-postscript/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "hayro-postscript" +version = "0.1.0" diff --git a/vendor/hayro-postscript/Cargo.toml b/vendor/hayro-postscript/Cargo.toml new file mode 100644 index 0000000000..b1d53c3f3f --- /dev/null +++ b/vendor/hayro-postscript/Cargo.toml @@ -0,0 +1,70 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2024" +name = "hayro-postscript" +version = "0.1.0" +authors = ["Laurenz Stampfl "] +build = false +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "A lightweight PostScript scanner." +readme = "README.md" +license = "Apache-2.0 OR MIT" +repository = "https://github.com/LaurenzV/hayro" +resolver = "2" + +[lib] +name = "hayro_postscript" +path = "src/lib.rs" + +[[example]] +name = "tokenize" +path = "examples/tokenize.rs" + +[lints.clippy] +collection_is_never_read = "warn" +dbg_macro = "warn" +debug_assert_with_mut_call = "warn" +default_trait_access = "warn" +doc_markdown = "warn" +fn_to_numeric_cast_any = "warn" +infinite_loop = "warn" +large_stack_arrays = "warn" +mismatching_type_param_order = "warn" +negative_feature_names = "warn" +redundant_feature_names = "warn" +same_functions_in_if_condition = "warn" +semicolon_if_nothing_returned = "warn" +todo = "warn" +too_many_arguments = "allow" +unseparated_literal_suffix = "warn" +use_self = "warn" +wildcard_dependencies = "warn" + +[lints.rust] +elided_lifetimes_in_paths = "warn" +keyword_idents_2024 = "forbid" +missing_docs = "warn" +non_ascii_idents = "forbid" +non_local_definitions = "forbid" +trivial_numeric_casts = "warn" +unnameable_types = "warn" +unreachable_pub = "warn" +unsafe_op_in_unsafe_fn = "forbid" +unused_import_braces = "warn" +unused_lifetimes = "warn" +unused_macro_rules = "warn" +unused_qualifications = "warn" diff --git a/vendor/hayro-postscript/Cargo.toml.orig b/vendor/hayro-postscript/Cargo.toml.orig new file mode 100644 index 0000000000..6ba1eed22f --- /dev/null +++ b/vendor/hayro-postscript/Cargo.toml.orig @@ -0,0 +1,11 @@ +[package] +name = "hayro-postscript" +version = "0.1.0" +description = "A lightweight PostScript scanner." +authors = { workspace = true } +edition = { workspace = true } +repository = { workspace = true } +license = { workspace = true } + +[lints] +workspace = true diff --git a/vendor/hayro-postscript/LICENSE-APACHE b/vendor/hayro-postscript/LICENSE-APACHE new file mode 100644 index 0000000000..4947287f7b --- /dev/null +++ b/vendor/hayro-postscript/LICENSE-APACHE @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/vendor/hayro-postscript/LICENSE-MIT b/vendor/hayro-postscript/LICENSE-MIT new file mode 100644 index 0000000000..ae3742de4e --- /dev/null +++ b/vendor/hayro-postscript/LICENSE-MIT @@ -0,0 +1,7 @@ +Copyright (c) The Hayro Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/vendor/hayro-postscript/README.md b/vendor/hayro-postscript/README.md new file mode 100644 index 0000000000..3c2282716b --- /dev/null +++ b/vendor/hayro-postscript/README.md @@ -0,0 +1,30 @@ +# hayro-postscript + +[![Crates.io](https://img.shields.io/crates/v/hayro-postscript.svg)](https://crates.io/crates/hayro-postscript) +[![Documentation](https://docs.rs/hayro-postscript/badge.svg)](https://docs.rs/hayro-postscript) + + + +A lightweight PostScript scanner. + +This crate provides a scanner for tokenizing PostScript programs into typed objects. +It currently only implements a very small subset of the PostScript language, +with the main goal of being enough to parse CMAP files, but the scope _might_ +be expanded upon in the future. + +The supported types include integers and real numbers, name objects, strings and arrays. +Unsupported is anything else, including dictionaries, procedures, etc. An error +will be returned in case any of these is encountered. + +### Safety +This crate forbids unsafe code via a crate-level attribute. + + + +## License +Licensed under either of + +- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or ) +- MIT license ([LICENSE-MIT](LICENSE-MIT) or ) + +at your option. diff --git a/vendor/hayro-postscript/examples/tokenize.rs b/vendor/hayro-postscript/examples/tokenize.rs new file mode 100644 index 0000000000..c8cd84d038 --- /dev/null +++ b/vendor/hayro-postscript/examples/tokenize.rs @@ -0,0 +1,74 @@ +//! A small example that shows how to tokenize a PostScript program. + +#![allow(missing_docs)] + +use hayro_postscript::{Number, Object, Scanner}; +use std::env; +use std::fs; +use std::process; + +fn main() { + let path = match env::args().nth(1) { + Some(p) => p, + None => { + eprintln!("Usage: tokenize "); + process::exit(1); + } + }; + + let data = match fs::read(&path) { + Ok(d) => d, + Err(e) => { + eprintln!("Error reading {path}: {e}"); + process::exit(1); + } + }; + + let mut scanner = Scanner::new(&data); + + while !scanner.at_end() { + match scanner.parse_object() { + Ok(object) => { + print_object(&object); + println!(); + } + Err(e) => eprintln!("Error: {e}"), + } + } +} + +fn print_object(object: &Object<'_>) { + match object { + Object::Number(Number::Integer(n)) => print!("Integer({n})"), + Object::Number(Number::Real(n)) => print!("Real({n})"), + Object::Name(name) => { + let kind = if name.is_literal() { + "literal" + } else { + "executable" + }; + let text = name.as_str().unwrap_or(""); + print!("Name({text}, {kind})"); + } + Object::String(s) => { + let decoded = s.decode().unwrap_or_else(|_| Vec::new()); + print!("String({})", String::from_utf8_lossy(&decoded)); + } + Object::Array(arr) => { + print!("["); + let mut inner = arr.objects(); + let mut first = true; + while !inner.at_end() { + if !first { + print!(" "); + } + first = false; + match inner.parse_object() { + Ok(obj) => print_object(&obj), + Err(e) => print!("Error({e})"), + } + } + print!("]"); + } + } +} diff --git a/vendor/hayro-postscript/src/array.rs b/vendor/hayro-postscript/src/array.rs new file mode 100644 index 0000000000..b96b3601a5 --- /dev/null +++ b/vendor/hayro-postscript/src/array.rs @@ -0,0 +1,127 @@ +use crate::error::{Error, Result}; +use crate::object; +use crate::reader::Reader; +use crate::string; + +/// A PostScript array object. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Array<'a> { + data: &'a [u8], +} + +impl<'a> Array<'a> { + pub(crate) fn new(data: &'a [u8]) -> Self { + Self { data } + } + + /// Return a [`Scanner`](crate::Scanner) that iterates over the objects inside + /// this array. + pub fn objects(&self) -> crate::Scanner<'a> { + crate::Scanner::new(self.data) + } +} + +pub(crate) fn parse<'a>(r: &mut Reader<'a>) -> Result<&'a [u8]> { + r.forward_tag(b"[").ok_or(Error::SyntaxError)?; + + let start = r.offset(); + skip_array(r)?; + let end = r.offset() - 1; + + r.range(start..end).ok_or(Error::SyntaxError) +} + +fn skip_array(r: &mut Reader<'_>) -> Result<()> { + let mut depth = 1_u32; + + while depth > 0 { + match r.peek_byte().ok_or(Error::SyntaxError)? { + b'[' => { + r.forward(); + depth += 1; + } + b']' => { + r.forward(); + depth -= 1; + } + b'(' => { + let _ = string::parse_literal(r).ok_or(Error::SyntaxError)?; + } + b'<' => { + if r.peek_bytes(2) == Some(b"<~") { + let _ = string::parse_ascii85(r).ok_or(Error::SyntaxError)?; + } else if r.peek_bytes(2) == Some(b"<<") { + r.forward(); + r.forward(); + } else { + let _ = string::parse_hex(r).ok_or(Error::SyntaxError)?; + } + } + b'%' => object::skip_whitespace_and_comments(r), + _ => { + r.forward(); + } + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_array(input: &[u8]) -> Result<&[u8]> { + let mut r = Reader::new(input); + parse(&mut r) + } + + #[test] + fn empty() { + assert_eq!(parse_array(b"[]").unwrap(), b""); + } + + #[test] + fn simple() { + assert_eq!(parse_array(b"[1 2 3]").unwrap(), b"1 2 3"); + } + + #[test] + fn nested() { + assert_eq!(parse_array(b"[1 [2 3] 4]").unwrap(), b"1 [2 3] 4"); + } + + #[test] + fn with_string() { + // The ']' inside the string should not close the array. + assert_eq!(parse_array(b"[1 (str]) 2]").unwrap(), b"1 (str]) 2"); + } + + #[test] + fn with_hex_string() { + assert_eq!(parse_array(b"[<48> /name]").unwrap(), b"<48> /name"); + } + + #[test] + fn with_ascii85_string() { + assert_eq!(parse_array(b"[<~87cURDZ~> 1]").unwrap(), b"<~87cURDZ~> 1"); + } + + #[test] + fn with_comment() { + assert_eq!( + parse_array(b"[1 % comment with ]\n2]").unwrap(), + b"1 % comment with ]\n2" + ); + } + + #[test] + fn unterminated() { + assert_eq!(parse_array(b"[1 2"), Err(Error::SyntaxError)); + } + + #[test] + fn not_an_array() { + assert_eq!(parse_array(b"1 2]"), Err(Error::SyntaxError)); + } +} diff --git a/vendor/hayro-postscript/src/error.rs b/vendor/hayro-postscript/src/error.rs new file mode 100644 index 0000000000..3a2627a1a6 --- /dev/null +++ b/vendor/hayro-postscript/src/error.rs @@ -0,0 +1,30 @@ +//! Error types for the PostScript scanner. + +use core::fmt; + +/// A specialized [`Result`] type for PostScript scanner operations. +pub type Result = core::result::Result; + +/// An error encountered while scanning a PostScript token stream. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Error { + /// A syntax error in the input. + SyntaxError, + /// A numeric value exceeded implementation limits. + LimitCheck, + /// An unsupported PostScript type was encountered (like dictionaries or + /// procedures, which will be added in the future). + UnsupportedType, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::SyntaxError => f.write_str("syntaxerror"), + Self::LimitCheck => f.write_str("limitcheck"), + Self::UnsupportedType => f.write_str("unsupported type"), + } + } +} + +impl core::error::Error for Error {} diff --git a/vendor/hayro-postscript/src/lib.rs b/vendor/hayro-postscript/src/lib.rs new file mode 100644 index 0000000000..e16d55562d --- /dev/null +++ b/vendor/hayro-postscript/src/lib.rs @@ -0,0 +1,179 @@ +/*! +A lightweight PostScript scanner. + +This crate provides a scanner for tokenizing PostScript programs into typed objects. +It currently only implements a very small subset of the PostScript language, +with the main goal of being enough to parse CMAP files, but the scope _might_ +be expanded upon in the future. + +The supported types include integers and real numbers, name objects, strings and arrays. +Unsupported is anything else, including dictionaries, procedures, etc. An error +will be returned in case any of these is encountered. + +## Safety +This crate forbids unsafe code via a crate-level attribute. +*/ + +#![no_std] +#![forbid(unsafe_code)] +#![allow(missing_docs)] + +extern crate alloc; + +mod array; +mod error; +mod name; +mod number; +mod object; +mod reader; +mod string; + +pub use array::Array; +pub use error::{Error, Result}; +pub use name::Name; +pub use number::Number; +pub use object::Object; +pub use string::String; + +use reader::Reader; + +/// A PostScript scanner that parses [`Object`]s from a byte stream. +pub struct Scanner<'a> { + reader: Reader<'a>, +} + +impl<'a> Scanner<'a> { + /// Create a new scanner over the given bytes of a PostScript program. + pub fn new(data: &'a [u8]) -> Self { + Self { + reader: Reader::new(data), + } + } + + /// Returns `true` if there are no more objects to parse. + pub fn at_end(&mut self) -> bool { + object::at_end(&mut self.reader) + } + + /// Parse the next object. + pub fn parse_object(&mut self) -> Result> { + object::read(&mut self.reader) + } + + /// Parse the next object as a [`Number`]. + pub fn parse_number(&mut self) -> Result { + match self.parse_object()? { + Object::Number(n) => Ok(n), + _ => Err(Error::SyntaxError), + } + } + + /// Parse the next object as a [`Name`]. + pub fn parse_name(&mut self) -> Result> { + match self.parse_object()? { + Object::Name(n) => Ok(n), + _ => Err(Error::SyntaxError), + } + } + + /// Parse the next object as a [`String`]. + pub fn parse_string(&mut self) -> Result> { + match self.parse_object()? { + Object::String(s) => Ok(s), + _ => Err(Error::SyntaxError), + } + } + + /// Parse the next object as an [`Array`]. + pub fn parse_array(&mut self) -> Result> { + match self.parse_object()? { + Object::Array(a) => Ok(a), + _ => Err(Error::SyntaxError), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cmap_snippet() { + let input = br#"/CIDInit /ProcSet findresource begin +12 dict begin +begincmap +/CMapName /Test-H def +1 begincodespacerange +<00> +endcodespacerange +2 beginbfchar +<03> <0041> +<04> <0042> +endbfchar +endcmap"#; + + let mut s = Scanner::new(input); + + assert_eq!(s.parse_name().unwrap(), Name::new(b"CIDInit", true)); + assert_eq!(s.parse_name().unwrap(), Name::new(b"ProcSet", true)); + assert_eq!(s.parse_name().unwrap(), Name::new(b"findresource", false)); + assert_eq!(s.parse_name().unwrap(), Name::new(b"begin", false)); + assert_eq!(s.parse_number().unwrap(), Number::Integer(12)); + assert_eq!(s.parse_name().unwrap(), Name::new(b"dict", false)); + assert_eq!(s.parse_name().unwrap(), Name::new(b"begin", false)); + assert_eq!(s.parse_name().unwrap(), Name::new(b"begincmap", false)); + assert_eq!(s.parse_name().unwrap(), Name::new(b"CMapName", true)); + assert_eq!(s.parse_name().unwrap(), Name::new(b"Test-H", true)); + assert_eq!(s.parse_name().unwrap(), Name::new(b"def", false)); + assert_eq!(s.parse_number().unwrap(), Number::Integer(1)); + assert_eq!( + s.parse_name().unwrap(), + Name::new(b"begincodespacerange", false) + ); + assert_eq!(s.parse_string().unwrap(), String::from_hex(b"00")); + assert_eq!(s.parse_string().unwrap(), String::from_hex(b"FF")); + assert_eq!( + s.parse_name().unwrap(), + Name::new(b"endcodespacerange", false) + ); + assert_eq!(s.parse_number().unwrap(), Number::Integer(2)); + assert_eq!(s.parse_name().unwrap(), Name::new(b"beginbfchar", false)); + assert_eq!(s.parse_string().unwrap(), String::from_hex(b"03")); + assert_eq!(s.parse_string().unwrap(), String::from_hex(b"0041")); + assert_eq!(s.parse_string().unwrap(), String::from_hex(b"04")); + assert_eq!(s.parse_string().unwrap(), String::from_hex(b"0042")); + assert_eq!(s.parse_name().unwrap(), Name::new(b"endbfchar", false)); + assert_eq!(s.parse_name().unwrap(), Name::new(b"endcmap", false)); + assert!(s.at_end()); + } + + #[test] + fn array_round_trip() { + let input = b"[123 /abc (xyz)]"; + let mut scanner = Scanner::new(input); + let arr = scanner.parse_array().unwrap(); + assert!(scanner.at_end()); + + let mut inner = arr.objects(); + assert_eq!(inner.parse_number().unwrap(), Number::Integer(123)); + assert_eq!(inner.parse_name().unwrap(), Name::new(b"abc", true)); + assert_eq!(inner.parse_string().unwrap(), String::from_literal(b"xyz")); + assert!(inner.at_end()); + } + + #[test] + fn comments_skipped() { + let input = b"% comment\n42 % another\n/Name"; + let mut scanner = Scanner::new(input); + + assert_eq!(scanner.parse_number().unwrap(), Number::Integer(42)); + assert_eq!(scanner.parse_name().unwrap(), Name::new(b"Name", true)); + assert!(scanner.at_end()); + } + + #[test] + fn wrong_type_is_error() { + let mut scanner = Scanner::new(b"42 "); + assert!(scanner.parse_name().is_err()); + } +} diff --git a/vendor/hayro-postscript/src/name.rs b/vendor/hayro-postscript/src/name.rs new file mode 100644 index 0000000000..ab78e7ba67 --- /dev/null +++ b/vendor/hayro-postscript/src/name.rs @@ -0,0 +1,191 @@ +use alloc::vec::Vec; + +use crate::error::{Error, Result}; +use crate::reader::{Reader, is_regular}; +use crate::string::ascii_hex::decode_hex_digit; + +/// A PostScript name object. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Name<'a> { + data: &'a [u8], + literal: bool, +} + +impl<'a> Name<'a> { + pub(crate) fn new(data: &'a [u8], literal: bool) -> Self { + Self { data, literal } + } + + /// Returns `true` if this is a literal name. + pub fn is_literal(&self) -> bool { + self.literal + } + + /// Returns the name as a string if it is valid UTF-8, or + /// `None` if it contains non-ASCII bytes. + pub fn as_str(&self) -> Option<&'a str> { + core::str::from_utf8(self.data).ok() + } + + /// Decode the name into `out`, replacing any previous contents. + pub fn decode_into(&self, out: &mut Vec) -> Result<()> { + out.clear(); + + // Fast path: no escape sequences. + if !self.data.contains(&b'#') { + out.extend_from_slice(self.data); + return Ok(()); + } + + // Slow path: Process escape sequences. + let mut inner = Reader::new(self.data); + + while let Some(b) = inner.read_byte() { + if b == b'#' { + let hex = inner.read_bytes(2).ok_or(Error::SyntaxError)?; + let hi = decode_hex_digit(hex[0]).ok_or(Error::SyntaxError)?; + let lo = decode_hex_digit(hex[1]).ok_or(Error::SyntaxError)?; + out.push(hi << 4 | lo); + } else { + out.push(b); + } + } + + Ok(()) + } + + /// Decode the name. + pub fn decode(&self) -> Result> { + let mut out = Vec::new(); + self.decode_into(&mut out)?; + Ok(out) + } +} + +pub(crate) fn parse_literal<'a>(r: &mut Reader<'a>) -> Option<&'a [u8]> { + r.forward_tag(b"/")?; + let start = r.offset(); + while r.eat(is_regular).is_some() {} + r.range(start..r.offset()) +} + +pub(crate) fn parse_executable<'a>(r: &mut Reader<'a>) -> Option<&'a [u8]> { + let start = r.offset(); + r.forward_while(is_regular); + if r.offset() == start { + return None; + } + r.range(start..r.offset()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn read_literal(input: &[u8]) -> Option> { + let mut r = Reader::new(input); + parse_literal(&mut r).map(|d| Name::new(d, true)) + } + + fn read_executable(input: &[u8]) -> Option> { + let mut r = Reader::new(input); + parse_executable(&mut r).map(|d| Name::new(d, false)) + } + + #[test] + fn literal_simple() { + let n = read_literal(b"/Name1").unwrap(); + assert_eq!(n.as_str().unwrap(), "Name1"); + assert!(n.is_literal()); + } + + #[test] + fn literal_empty_name() { + let n = read_literal(b"/").unwrap(); + assert_eq!(n.as_str().unwrap(), ""); + assert!(n.is_literal()); + } + + #[test] + fn literal_with_hex_escape() { + let n = read_literal(b"/lime#20Green").unwrap(); + assert_eq!(n.as_str().unwrap(), "lime#20Green"); + assert_eq!(n.decode().unwrap(), b"lime Green"); + } + + #[test] + fn literal_multiple_hex_escapes() { + let n = read_literal(b"/paired#28#29parentheses").unwrap(); + assert_eq!(n.decode().unwrap(), b"paired()parentheses"); + } + + #[test] + fn literal_special_chars() { + let n = read_literal(b"/A;Name_With-Various***Characters?").unwrap(); + assert_eq!(n.as_str().unwrap(), "A;Name_With-Various***Characters?"); + } + + #[test] + fn literal_stops_at_delimiter() { + let mut r = Reader::new(b"/Name(rest"); + let data = parse_literal(&mut r).unwrap(); + assert_eq!(data, b"Name"); + assert_eq!(r.peek_byte(), Some(b'(')); + } + + #[test] + fn literal_stops_at_whitespace() { + let mut r = Reader::new(b"/Name rest"); + let data = parse_literal(&mut r).unwrap(); + assert_eq!(data, b"Name"); + assert_eq!(r.peek_byte(), Some(b' ')); + } + + #[test] + fn literal_not_a_name() { + assert!(read_literal(b"Name").is_none()); + } + + #[test] + fn executable_simple() { + let n = read_executable(b"beginbfchar ").unwrap(); + assert_eq!(n.as_str().unwrap(), "beginbfchar"); + assert!(!n.is_literal()); + } + + #[test] + fn executable_stops_at_delimiter() { + let mut r = Reader::new(b"def/name"); + let data = parse_executable(&mut r).unwrap(); + assert_eq!(data, b"def"); + assert_eq!(r.peek_byte(), Some(b'/')); + } + + #[test] + fn executable_at_eof() { + let n = read_executable(b"endcmap").unwrap(); + assert_eq!(n.as_str().unwrap(), "endcmap"); + } + + #[test] + fn executable_empty() { + assert!(read_executable(b"").is_none()); + } + + #[test] + fn executable_starts_at_delimiter() { + assert!(read_executable(b"(foo)").is_none()); + } + + #[test] + fn decode_no_escapes() { + let n = Name::new(b"simple", true); + assert_eq!(n.decode().unwrap(), b"simple"); + } + + #[test] + fn decode_with_escapes() { + let n = Name::new(b"lime#20Green", true); + assert_eq!(n.decode().unwrap(), b"lime Green"); + } +} diff --git a/vendor/hayro-postscript/src/number.rs b/vendor/hayro-postscript/src/number.rs new file mode 100644 index 0000000000..5c6d3ba10f --- /dev/null +++ b/vendor/hayro-postscript/src/number.rs @@ -0,0 +1,177 @@ +use crate::error::{Error, Result}; +use crate::reader::{Reader, is_delimiter, is_whitespace}; + +/// A PostScript number object (integer or real). +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Number { + Integer(i32), + Real(f32), +} + +impl Number { + /// Return the value as an `i32`. Reals are truncated. + pub fn as_i32(self) -> i32 { + match self { + Self::Integer(v) => v, + Self::Real(v) => v as i32, + } + } + + /// Return the value as an `f32`. + pub fn as_f32(self) -> f32 { + match self { + Self::Integer(v) => v as f32, + Self::Real(v) => v, + } + } + + /// Return the value as an `f64`. + pub fn as_f64(self) -> f64 { + match self { + Self::Integer(v) => v as f64, + Self::Real(v) => v as f64, + } + } +} + +fn is_terminated(r: &Reader<'_>) -> bool { + match r.peek_byte() { + None => true, + Some(b) => is_whitespace(b) || is_delimiter(b), + } +} + +pub(crate) fn read(r: &mut Reader<'_>) -> Result { + let saved = r.offset(); + + // Optional sign. + let first = r.peek_byte().ok_or(Error::SyntaxError)?; + let has_sign = first == b'+' || first == b'-'; + + if has_sign { + r.forward(); + } + + // Consume leading digits. + let digit_start = r.offset(); + r.forward_while(|b| b.is_ascii_digit()); + let has_digits = r.offset() > digit_start; + + // Check if number is a radix number. + if !has_sign && has_digits && r.peek_byte() == Some(b'#') { + let base_bytes = r.range(digit_start..r.offset()).ok_or(Error::SyntaxError)?; + let base_str = core::str::from_utf8(base_bytes).map_err(|_| Error::SyntaxError)?; + let base = base_str.parse::().map_err(|_| Error::SyntaxError)?; + + if !(2..=36).contains(&base) { + return Err(Error::SyntaxError); + } + + // Skip `#`. + r.forward(); + + let num_start = r.offset(); + r.forward_while(|b| b.is_ascii_alphanumeric()); + + if r.offset() == num_start || !is_terminated(r) { + return Err(Error::SyntaxError); + } + + let num_bytes = r.range(num_start..r.offset()).ok_or(Error::SyntaxError)?; + let num_str = core::str::from_utf8(num_bytes).map_err(|_| Error::SyntaxError)?; + let value = i32::from_str_radix(num_str, base).map_err(|_| Error::SyntaxError)?; + + return Ok(Number::Integer(value)); + } + + // Check for real number indicators: `.` or `e`/`E`. + let has_dot = r.peek_byte() == Some(b'.'); + + if has_dot { + r.forward(); // skip '.' + r.forward_while(|b| b.is_ascii_digit()); + } + + // At this point we need at least some digits (before or after the dot). + if !has_digits && !has_dot { + return Err(Error::SyntaxError); + } + + let has_exponent = matches!(r.peek_byte(), Some(b'e' | b'E')); + if has_exponent { + r.forward(); + + // Optional exponent sign. + if matches!(r.peek_byte(), Some(b'+' | b'-')) { + r.forward(); + } + + r.forward_while(|b| b.is_ascii_digit()); + } + + if !is_terminated(r) { + return Err(Error::SyntaxError); + } + + let token = r.range(saved..r.offset()).ok_or(Error::SyntaxError)?; + let str = core::str::from_utf8(token).map_err(|_| Error::SyntaxError)?; + + if has_dot || has_exponent { + let value = str.parse::().map_err(|_| Error::SyntaxError)?; + + Ok(Number::Real(value)) + } else { + if !has_digits { + return Err(Error::SyntaxError); + } + + let value = str.parse::().map_err(|_| Error::SyntaxError)?; + + Ok(Number::Integer(value)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn read_num(input: &[u8]) -> Result { + let mut r = Reader::new(input); + read(&mut r) + } + + #[test] + fn signed_integers() { + assert_eq!(read_num(b"123 ").unwrap(), Number::Integer(123)); + assert_eq!(read_num(b"-98 ").unwrap(), Number::Integer(-98)); + assert_eq!(read_num(b"43445 ").unwrap(), Number::Integer(43445)); + assert_eq!(read_num(b"0 ").unwrap(), Number::Integer(0)); + assert_eq!(read_num(b"+17 ").unwrap(), Number::Integer(17)); + } + + #[test] + fn real_numbers() { + assert_eq!(read_num(b"-.002 ").unwrap(), Number::Real(-0.002)); + assert_eq!(read_num(b"34.5 ").unwrap(), Number::Real(34.5)); + assert_eq!(read_num(b"-3.62 ").unwrap(), Number::Real(-3.62)); + assert_eq!(read_num(b"123.6e10 ").unwrap(), Number::Real(123.6e10)); + assert_eq!(read_num(b"1.0E-5 ").unwrap(), Number::Real(1.0E-5)); + assert_eq!(read_num(b"1E6 ").unwrap(), Number::Real(1E6)); + assert_eq!(read_num(b"-1. ").unwrap(), Number::Real(-1.0)); + assert_eq!(read_num(b"0.0 ").unwrap(), Number::Real(0.0)); + } + + #[test] + fn radix_numbers() { + assert_eq!(read_num(b"8#1777 ").unwrap(), Number::Integer(0o1777)); + assert_eq!(read_num(b"16#FFFE ").unwrap(), Number::Integer(0xFFFE)); + assert_eq!(read_num(b"2#1000 ").unwrap(), Number::Integer(0b1000)); + } + + #[test] + fn invalid() { + assert!(read_num(b"abc").is_err()); + assert!(read_num(b"+abc").is_err()); + assert!(read_num(b"1a").is_err()); + } +} diff --git a/vendor/hayro-postscript/src/object.rs b/vendor/hayro-postscript/src/object.rs new file mode 100644 index 0000000000..7c20075860 --- /dev/null +++ b/vendor/hayro-postscript/src/object.rs @@ -0,0 +1,226 @@ +use crate::array::{self, Array}; +use crate::error::{Error, Result}; +use crate::name::{self, Name}; +use crate::number::{self, Number}; +use crate::reader::Reader; +use crate::string::{self, String}; + +/// A PostScript object. +#[derive(Debug, Clone, PartialEq)] +pub enum Object<'a> { + /// A number object. + Number(Number), + /// A name object. + Name(Name<'a>), + /// A string object. + String(String<'a>), + /// An array object. + Array(Array<'a>), +} + +pub(crate) fn read<'a>(r: &mut Reader<'a>) -> Result> { + // PdfCraft patch: `<<` and `>>` are skipped by looping instead of + // recursing once per delimiter, so a long run cannot overflow the stack. + loop { + skip_whitespace_and_comments(r); + + let b = r.peek_byte().ok_or(Error::SyntaxError)?; + + return match b { + b'(' => string::parse_literal(r) + .map(|s| Object::String(String::from_literal(s))) + .ok_or(Error::SyntaxError), + b'<' => { + if r.peek_bytes(2) == Some(b"<~") { + string::parse_ascii85(r) + .map(|s| Object::String(String::from_ascii85(s))) + .ok_or(Error::SyntaxError) + } else if r.peek_bytes(2) == Some(b"<<") { + // TODO: Proper dict support. For now, skip `<<` and return + // the next object so callers see the inner tokens. + r.forward(); + r.forward(); + continue; + } else { + string::parse_hex(r) + .map(|s| Object::String(String::from_hex(s))) + .ok_or(Error::SyntaxError) + } + } + b'/' => name::parse_literal(r) + .map(|s| Object::Name(Name::new(s, true))) + .ok_or(Error::SyntaxError), + b'[' => array::parse(r).map(|d| Object::Array(Array::new(d))), + b'>' => { + if r.peek_bytes(2) == Some(b">>") { + // TODO: Proper dict support. Skip `>>` closing delimiter. + r.forward(); + r.forward(); + continue; + } else { + Err(Error::SyntaxError) + } + } + b'{' => { + r.forward(); + Err(Error::UnsupportedType) + } + b'.' | b'+' | b'-' | b'0'..=b'9' => number::read(r).map(Object::Number), + _ => name::parse_executable(r) + .map(|s| Object::Name(Name::new(s, false))) + .ok_or(Error::SyntaxError), + }; + } +} + +pub(crate) fn at_end(r: &mut Reader<'_>) -> bool { + skip_whitespace_and_comments(r); + r.peek_byte().is_none() +} + +pub(crate) fn skip_whitespace_and_comments(r: &mut Reader<'_>) { + loop { + match r.peek_byte() { + Some(b) if crate::reader::is_whitespace(b) => { + r.forward(); + } + Some(b'%') => { + r.forward(); + r.forward_while(|b| !crate::reader::is_eol(b)); + } + _ => return, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn read_one(input: &[u8]) -> Result> { + let mut r = Reader::new(input); + read(&mut r) + } + + fn read_ok(input: &[u8]) -> Object<'_> { + read_one(input).unwrap() + } + + fn read_err(input: &[u8]) -> Error { + read_one(input).unwrap_err() + } + + #[test] + fn integer() { + assert_eq!(read_ok(b"42 "), Object::Number(Number::Integer(42))); + } + + #[test] + fn negative_integer() { + assert_eq!(read_ok(b"-7 "), Object::Number(Number::Integer(-7))); + } + + #[test] + fn literal_name() { + assert_eq!( + read_ok(b"/CMapName "), + Object::Name(Name::new(b"CMapName", true)) + ); + } + + #[test] + fn executable_name() { + let obj = read_ok(b"beginbfchar "); + assert_eq!(obj, Object::Name(Name::new(b"beginbfchar", false))); + } + + #[test] + fn literal_string() { + assert_eq!( + read_ok(b"(Hello)"), + Object::String(String::from_literal(b"Hello")) + ); + } + + #[test] + fn hex_string() { + assert_eq!( + read_ok(b"<48656C6C6F>"), + Object::String(String::from_hex(b"48656C6C6F")) + ); + } + + #[test] + fn ascii85_string() { + assert_eq!( + read_ok(b"<~87cURDZ~>"), + Object::String(String::from_ascii85(b"87cURDZ")) + ); + } + + #[test] + fn array_simple() { + let obj = read_ok(b"[1 2 3]"); + assert_eq!(obj, Object::Array(Array::new(b"1 2 3"))); + } + + #[test] + fn stray_close_bracket() { + assert_eq!(read_err(b"]"), Error::SyntaxError); + } + + #[test] + fn stray_gt() { + assert_eq!(read_err(b">x"), Error::SyntaxError); + } + + #[test] + fn skips_whitespace() { + assert_eq!(read_ok(b" \t\n 42 "), Object::Number(Number::Integer(42))); + } + + #[test] + fn skips_comments() { + assert_eq!( + read_ok(b"% this is a comment\n42 "), + Object::Number(Number::Integer(42)) + ); + } + + #[test] + fn eof() { + assert!(read_one(b"").is_err()); + assert!(read_one(b" ").is_err()); + assert!(read_one(b"% comment only\n").is_err()); + } + + // PdfCraft patch: `<<` and `>>` runs are skipped without recursion. + #[test] + fn long_open_dict_run_does_not_overflow() { + let input = alloc::vec![b'<'; 1_000_000]; + assert_eq!(read_err(&input), Error::SyntaxError); + } + + #[test] + fn long_close_dict_run_does_not_overflow() { + let input = b">>".repeat(500_000); + assert_eq!(read_err(&input), Error::SyntaxError); + } + + #[test] + fn dict_delimiters_are_skipped() { + let mut r = Reader::new(b"<< /A 1 >>"); + assert_eq!(read(&mut r), Ok(Object::Name(Name::new(b"A", true)))); + assert_eq!(read(&mut r), Ok(Object::Number(Number::Integer(1)))); + assert_eq!(read(&mut r), Err(Error::SyntaxError)); + } + + #[test] + fn nested_dict_delimiters_are_skipped() { + let mut r = Reader::new(b"<<<< /X >>>> 7 <<\n% c\n<< >> 8"); + assert_eq!(read(&mut r), Ok(Object::Name(Name::new(b"X", true)))); + assert_eq!(read(&mut r), Ok(Object::Number(Number::Integer(7)))); + assert_eq!(read(&mut r), Ok(Object::Number(Number::Integer(8)))); + assert!(at_end(&mut r)); + } +} diff --git a/vendor/hayro-postscript/src/reader.rs b/vendor/hayro-postscript/src/reader.rs new file mode 100644 index 0000000000..f8a4967462 --- /dev/null +++ b/vendor/hayro-postscript/src/reader.rs @@ -0,0 +1,136 @@ +use core::ops::Range; + +#[derive(Clone, Debug)] +pub(crate) struct Reader<'a> { + pub(crate) data: &'a [u8], + pub(crate) offset: usize, +} + +impl<'a> Reader<'a> { + #[inline] + pub(crate) fn new(data: &'a [u8]) -> Self { + Self { data, offset: 0 } + } + + #[inline] + pub(crate) fn jump(&mut self, offset: usize) { + self.offset = offset; + } + + #[inline] + pub(crate) fn range(&self, range: Range) -> Option<&'a [u8]> { + self.data.get(range) + } + + #[inline] + pub(crate) fn offset(&self) -> usize { + self.offset + } + + #[inline] + pub(crate) fn read_bytes(&mut self, len: usize) -> Option<&'a [u8]> { + let v = self.peek_bytes(len)?; + self.offset += len; + Some(v) + } + + #[inline] + pub(crate) fn read_byte(&mut self) -> Option { + let v = self.peek_byte()?; + self.offset += 1; + Some(v) + } + + #[inline] + pub(crate) fn peek_bytes(&self, len: usize) -> Option<&'a [u8]> { + self.data.get(self.offset..self.offset + len) + } + + #[inline] + pub(crate) fn peek_byte(&self) -> Option { + self.data.get(self.offset).copied() + } + + #[inline] + pub(crate) fn eat(&mut self, f: impl Fn(u8) -> bool) -> Option { + let val = self.peek_byte()?; + if f(val) { + self.forward(); + Some(val) + } else { + None + } + } + + #[inline] + pub(crate) fn forward(&mut self) { + self.offset += 1; + } + + #[inline] + pub(crate) fn forward_while(&mut self, f: impl Fn(u8) -> bool) { + while let Some(b) = self.peek_byte() { + if f(b) { + self.forward(); + } else { + break; + } + } + } + + #[inline] + pub(crate) fn forward_tag(&mut self, tag: &[u8]) -> Option<()> { + self.peek_tag(tag)?; + self.offset += tag.len(); + Some(()) + } + + #[inline] + pub(crate) fn peek_tag(&self, tag: &[u8]) -> Option<()> { + let mut cloned = self.clone(); + + for b in tag.iter().copied() { + if cloned.peek_byte() == Some(b) { + cloned.forward(); + } else { + return None; + } + } + + Some(()) + } + + #[inline] + pub(crate) fn skip_eol(&mut self) { + while let Some(b) = self.peek_byte() { + if is_eol(b) { + self.forward(); + } else { + return; + } + } + } +} + +#[inline(always)] +pub(crate) fn is_whitespace(b: u8) -> bool { + matches!(b, 0x00 | 0x09 | 0x0a | 0x0c | 0x0d | 0x20) +} + +#[inline(always)] +pub(crate) fn is_delimiter(b: u8) -> bool { + matches!( + b, + b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%' + ) +} + +#[inline(always)] +pub(crate) fn is_regular(b: u8) -> bool { + !is_whitespace(b) && !is_delimiter(b) +} + +#[inline(always)] +pub(crate) fn is_eol(b: u8) -> bool { + matches!(b, 0x0a | 0x0d) +} diff --git a/vendor/hayro-postscript/src/string.rs b/vendor/hayro-postscript/src/string.rs new file mode 100644 index 0000000000..ed39d57c73 --- /dev/null +++ b/vendor/hayro-postscript/src/string.rs @@ -0,0 +1,288 @@ +mod ascii_85; +pub(crate) mod ascii_hex; +mod literal; + +use alloc::vec::Vec; + +use crate::error::{Error, Result}; +use crate::reader::Reader; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum StringInner<'a> { + Literal(&'a [u8]), + Hex(&'a [u8]), + Ascii85(&'a [u8]), +} + +/// A PostScript string object. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct String<'a> { + inner: StringInner<'a>, +} + +impl<'a> String<'a> { + pub(crate) const fn from_literal(data: &'a [u8]) -> Self { + Self { + inner: StringInner::Literal(data), + } + } + + pub(crate) const fn from_hex(data: &'a [u8]) -> Self { + Self { + inner: StringInner::Hex(data), + } + } + + pub(crate) const fn from_ascii85(data: &'a [u8]) -> Self { + Self { + inner: StringInner::Ascii85(data), + } + } + + /// Decode the string content into `out`, replacing any previous contents. + pub fn decode_into(&self, out: &mut Vec) -> Result<()> { + out.clear(); + match self.inner { + StringInner::Literal(data) => literal::decode_into(data, out), + StringInner::Hex(data) => ascii_hex::decode_into(data, out), + StringInner::Ascii85(data) => ascii_85::decode_into(data, out), + } + .ok_or(Error::SyntaxError) + } + + /// Decode the string content. + pub fn decode(&self) -> Result> { + let mut out = Vec::new(); + self.decode_into(&mut out)?; + Ok(out) + } +} + +pub(crate) fn parse_literal<'a>(r: &mut Reader<'a>) -> Option<&'a [u8]> { + let start = r.offset(); + skip_literal(r)?; + let end = r.offset(); + // Exclude outer parentheses. + r.range(start + 1..end - 1) +} + +pub(crate) fn parse_hex<'a>(r: &mut Reader<'a>) -> Option<&'a [u8]> { + r.forward_tag(b"<")?; + let start = r.offset(); + while let Some(b) = r.read_byte() { + if b == b'>' { + return r.range(start..r.offset() - 1); + } + } + None +} + +pub(crate) fn parse_ascii85<'a>(r: &mut Reader<'a>) -> Option<&'a [u8]> { + r.forward_tag(b"<~")?; + let start = r.offset(); + loop { + let b = r.read_byte()?; + if b == b'~' { + let end = r.offset() - 1; + r.forward_tag(b">")?; + return r.range(start..end); + } + } +} + +fn skip_literal(r: &mut Reader<'_>) -> Option<()> { + r.forward_tag(b"(")?; + let mut depth = 1_u32; + + while depth > 0 { + let byte = r.read_byte()?; + match byte { + b'\\' => { + let _ = r.read_byte()?; + } + b'(' => depth += 1, + b')' => depth -= 1, + _ => {} + } + } + + Some(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn decode_literal(input: &[u8]) -> Result> { + let mut r = Reader::new(input); + let data = parse_literal(&mut r).ok_or(Error::SyntaxError)?; + String::from_literal(data).decode() + } + + #[test] + fn literal_empty() { + assert_eq!(decode_literal(b"()").unwrap(), b""); + } + + #[test] + fn literal_simple() { + assert_eq!(decode_literal(b"(Hello)").unwrap(), b"Hello"); + } + + #[test] + fn literal_nested_parens() { + assert_eq!( + decode_literal(b"(Hi (()) there)").unwrap(), + b"Hi (()) there" + ); + } + + #[test] + fn literal_escape_n() { + assert_eq!(decode_literal(b"(a\\nb)").unwrap(), b"a\nb"); + } + + #[test] + fn literal_escape_r() { + assert_eq!(decode_literal(b"(a\\rb)").unwrap(), b"a\rb"); + } + + #[test] + fn literal_escape_t() { + assert_eq!(decode_literal(b"(a\\tb)").unwrap(), b"a\tb"); + } + + #[test] + fn literal_escape_b() { + assert_eq!(decode_literal(b"(a\\bb)").unwrap(), &[b'a', 0x08, b'b']); + } + + #[test] + fn literal_escape_f() { + assert_eq!(decode_literal(b"(a\\fb)").unwrap(), &[b'a', 0x0C, b'b']); + } + + #[test] + fn literal_escape_backslash() { + assert_eq!(decode_literal(b"(a\\\\b)").unwrap(), b"a\\b"); + } + + #[test] + fn literal_escape_parens() { + assert_eq!(decode_literal(b"(Hi \\()").unwrap(), b"Hi ("); + } + + #[test] + fn literal_octal_three_digits() { + assert_eq!(decode_literal(b"(\\053)").unwrap(), b"+"); + } + + #[test] + fn literal_octal_two_digits() { + assert_eq!(decode_literal(b"(\\36)").unwrap(), b"\x1e"); + } + + #[test] + fn literal_octal_one_digit() { + assert_eq!(decode_literal(b"(\\3)").unwrap(), b"\x03"); + } + + #[test] + fn literal_line_continuation_lf() { + assert_eq!(decode_literal(b"(Hi \\\nthere)").unwrap(), b"Hi there"); + } + + #[test] + fn literal_line_continuation_cr() { + assert_eq!(decode_literal(b"(Hi \\\rthere)").unwrap(), b"Hi there"); + } + + #[test] + fn literal_line_continuation_crlf() { + assert_eq!(decode_literal(b"(Hi \\\r\nthere)").unwrap(), b"Hi there"); + } + + #[test] + fn literal_bare_eol_lf() { + assert_eq!(decode_literal(b"(a\nb)").unwrap(), b"a\nb"); + } + + #[test] + fn literal_bare_eol_cr() { + assert_eq!(decode_literal(b"(a\rb)").unwrap(), b"a\nb"); + } + + #[test] + fn literal_bare_eol_crlf() { + assert_eq!(decode_literal(b"(a\r\nb)").unwrap(), b"a\nb"); + } + + fn decode_hex(input: &[u8]) -> Result> { + let mut r = Reader::new(input); + let data = parse_hex(&mut r).ok_or(Error::SyntaxError)?; + String::from_hex(data).decode() + } + + #[test] + fn hex_simple() { + assert_eq!(decode_hex(b"<48656C6C6F>").unwrap(), b"Hello"); + } + + #[test] + fn hex_with_whitespace() { + assert_eq!(decode_hex(b"<48 65 6C 6C 6F>").unwrap(), b"Hello"); + } + + #[test] + fn hex_odd_nibble() { + assert_eq!(decode_hex(b"").unwrap(), &[0xAB, 0xC0]); + } + + #[test] + fn hex_empty() { + assert_eq!(decode_hex(b"<>").unwrap(), b""); + } + + #[test] + fn hex_lowercase() { + assert_eq!(decode_hex(b"").unwrap(), &[0xAB, 0xCD]); + } + + #[test] + fn hex_mixed_case() { + assert_eq!(decode_hex(b"").unwrap(), &[0xAB, 0x3E]); + } + + fn decode_a85(input: &[u8]) -> Result> { + let mut r = Reader::new(input); + let data = parse_ascii85(&mut r).ok_or(Error::SyntaxError)?; + String::from_ascii85(data).decode() + } + + #[test] + fn ascii85_simple() { + // "Hello" in ASCII85 is "87cURDZ" + assert_eq!(decode_a85(b"<~87cURDZ~>").unwrap(), b"Hello"); + } + + #[test] + fn ascii85_empty() { + assert_eq!(decode_a85(b"<~~>").unwrap(), b""); + } + + #[test] + fn ascii85_z_shorthand() { + assert_eq!(decode_a85(b"<~z~>").unwrap(), &[0, 0, 0, 0]); + } + + #[test] + fn ascii85_partial_group() { + let result = decode_a85(b"<~87~>").unwrap(); + assert_eq!(result.len(), 1); + } + + #[test] + fn ascii85_with_whitespace() { + assert_eq!(decode_a85(b"<~87cU RDZ~>").unwrap(), b"Hello"); + } +} diff --git a/vendor/hayro-postscript/src/string/ascii_85.rs b/vendor/hayro-postscript/src/string/ascii_85.rs new file mode 100644 index 0000000000..93f7f50274 --- /dev/null +++ b/vendor/hayro-postscript/src/string/ascii_85.rs @@ -0,0 +1,90 @@ +// Keep in sync with `hayro-syntax/src/filter/ascii_85.rs`. + +use crate::reader::{Reader, is_whitespace}; +use alloc::vec::Vec; + +pub(crate) fn decode_into(data: &[u8], out: &mut Vec) -> Option<()> { + const POW_85: [u32; 5] = [52200625, 614125, 7225, 85, 1]; + + let mut reader = Reader::new(data); + + let mut read_byte = || -> Option { + loop { + let b = reader.read_byte()?; + + // White space characters should be ignored. + if !is_whitespace(b) { + return Some(b); + } + } + }; + + let flush_group = |group: &mut Vec, decoded: &mut Vec| -> Option<()> { + let (digits, output_len): ([u32; 5], usize) = match group.len() { + 0 => return Some(()), + 1 => return None, // A single character is not valid. + 2 => ( + [group[0], group[1], b'u', b'u', b'u'].map(|b| (b - b'!') as u32), + 1, + ), + 3 => ( + [group[0], group[1], group[2], b'u', b'u'].map(|b| (b - b'!') as u32), + 2, + ), + 4 => ( + [group[0], group[1], group[2], group[3], b'u'].map(|b| (b - b'!') as u32), + 3, + ), + 5 => ( + [group[0], group[1], group[2], group[3], group[4]].map(|b| (b - b'!') as u32), + 4, + ), + _ => unreachable!(), + }; + + let value = digits[0] + .checked_mul(POW_85[0])? + .checked_add(digits[1].checked_mul(POW_85[1])?)? + .checked_add(digits[2].checked_mul(POW_85[2])?)? + .checked_add(digits[3].checked_mul(POW_85[3])?)? + .checked_add(digits[4])?; + + decoded.extend_from_slice(&value.to_be_bytes()[..output_len]); + group.clear(); + Some(()) + }; + + out.reserve(data.len() * 4 / 5); + let mut group = Vec::with_capacity(5); + + loop { + let Some(b) = read_byte() else { + // Be lenient and accept what we have (see PDFBOX-5910). + flush_group(&mut group, out)?; + + return Some(()); + }; + + match b { + b'!'..=b'u' => { + group.push(b); + + if group.len() == 5 { + flush_group(&mut group, out)?; + } + } + b'z' => { + flush_group(&mut group, out)?; + out.extend_from_slice(&[0, 0, 0, 0]); + } + b'~' => { + // Technically requires a '>', but there is a PDF where it isn't + // appended and decodes fine in other viewers. + flush_group(&mut group, out)?; + + return Some(()); + } + _ => return None, // Invalid character. + } + } +} diff --git a/vendor/hayro-postscript/src/string/ascii_hex.rs b/vendor/hayro-postscript/src/string/ascii_hex.rs new file mode 100644 index 0000000000..bdf74389f4 --- /dev/null +++ b/vendor/hayro-postscript/src/string/ascii_hex.rs @@ -0,0 +1,59 @@ +// Keep in sync with `hayro-syntax/src/filter/ascii_hex.rs`. + +use crate::reader::is_whitespace; +use alloc::vec::Vec; + +pub(crate) fn decode_into(data: &[u8], out: &mut Vec) -> Option<()> { + let has_whitespace = data.iter().any(|&b| is_whitespace(b)); + + out.reserve(data.len().div_ceil(2)); + + if !has_whitespace { + // Fast path, don't need to worry about white spaces. + let mut i = 0; + while i + 1 < data.len() { + out.push(decode_hex_digit(data[i])? << 4 | decode_hex_digit(data[i + 1])?); + i += 2; + } + if i < data.len() { + out.push(decode_hex_digit(data[i])? << 4); + } + } else { + // Slow path, need to strip white spaces. + let mut iter = data.iter().copied(); + let mut read_byte = || -> Option { + loop { + let b = iter.next()?; + if !is_whitespace(b) { + return Some(b); + } + } + }; + + loop { + match (read_byte(), read_byte()) { + (Some(hi), Some(lo)) => { + out.push(decode_hex_digit(hi)? << 4 | decode_hex_digit(lo)?); + } + (Some(hi), None) => { + out.push(decode_hex_digit(hi)? << 4); + + break; + } + (None, _) => break, + } + } + } + + Some(()) +} + +#[inline(always)] +pub(crate) fn decode_hex_digit(c: u8) -> Option { + match c { + b'0'..=b'9' => Some(c - b'0'), + b'A'..=b'F' => Some(c - b'A' + 10), + b'a'..=b'f' => Some(c - b'a' + 10), + _ => None, + } +} diff --git a/vendor/hayro-postscript/src/string/literal.rs b/vendor/hayro-postscript/src/string/literal.rs new file mode 100644 index 0000000000..37a15934ff --- /dev/null +++ b/vendor/hayro-postscript/src/string/literal.rs @@ -0,0 +1,85 @@ +// Keep in sync with `hayro-syntax/src/object/string.rs` (`read_literal`). + +use alloc::vec::Vec; + +use crate::reader::Reader; + +pub(crate) fn decode_into(data: &[u8], out: &mut Vec) -> Option<()> { + let mut r = Reader::new(data); + + while let Some(byte) = r.read_byte() { + match byte { + b'\\' => { + let next = r.read_byte()?; + + if is_octal_digit(next) { + let second = r.read_byte(); + let third = r.read_byte(); + + let bytes = match (second, third) { + (Some(n1), Some(n2)) => match (is_octal_digit(n1), is_octal_digit(n2)) { + (true, true) => [next, n1, n2], + (true, _) => { + r.jump(r.offset() - 1); + [b'0', next, n1] + } + _ => { + r.jump(r.offset() - 2); + [b'0', b'0', next] + } + }, + (Some(n1), None) => { + if is_octal_digit(n1) { + [b'0', next, n1] + } else { + r.jump(r.offset() - 1); + [b'0', b'0', next] + } + } + _ => [b'0', b'0', next], + }; + + let str = core::str::from_utf8(&bytes).unwrap(); + + if let Ok(num) = u8::from_str_radix(str, 8) { + out.push(num); + } + } else { + match next { + b'n' => out.push(0xA), + b'r' => out.push(0xD), + b't' => out.push(0x9), + b'b' => out.push(0x8), + b'f' => out.push(0xC), + b'(' => out.push(b'('), + b')' => out.push(b')'), + b'\\' => out.push(b'\\'), + b'\n' | b'\r' => { + // "If the \ is followed immediately by a newline + // (CR, LF, or CR-LF pair), the scanner ignores + // both the initial \ and the newline; this breaks + // a string into multiple lines without including + // the newline character as part of the string." + r.skip_eol(); + } + _ => out.push(next), + } + } + } + b'(' | b')' => out.push(byte), + // "But if a newline appears without a preceding \, the result is + // equivalent to \n." + b'\n' | b'\r' => { + out.push(b'\n'); + r.skip_eol(); + } + other => out.push(other), + } + } + + Some(()) +} + +fn is_octal_digit(byte: u8) -> bool { + matches!(byte, b'0'..=b'7') +} diff --git a/vendor/hayro-syntax/src/content/mod.rs b/vendor/hayro-syntax/src/content/mod.rs index 886383ede7..e37044c5d7 100644 --- a/vendor/hayro-syntax/src/content/mod.rs +++ b/vendor/hayro-syntax/src/content/mod.rs @@ -37,12 +37,11 @@ use crate::content::ops::TypedInstruction; use crate::object; use crate::object::dict::{Dict, InlineImageDict}; use crate::object::name::{Name, skip_name_like}; -use crate::object::{Array, Null, Number, Object, Stream}; +use crate::object::{Array, Number, Object, Stream}; use crate::reader::Reader; use crate::reader::{Readable, ReaderContext, ReaderExt, Skippable}; use crate::trivia::{Comment, is_regular_character, is_white_space_character}; use crate::util::find_needle; -use core::array; use core::fmt::{Debug, Formatter}; use core::ops::Deref; use core::marker::PhantomData; @@ -51,8 +50,18 @@ use smallvec::SmallVec; // 6 operands are used for example for ctm or cubic curves, // but anything above should be pretty rare (only for example for // DeviceN color spaces, or invalid PDF files). So we settle on 10. +// PdfCraft patch (28): this is now only how many operands are held inline; see `MAX_OPERANDS`. const OPERANDS_THRESHOLD: usize = 10; +/// PdfCraft patch (28): the most operands one instruction keeps. The stack held ten, and the +/// eleventh operand of an instruction ended the whole content stream, so a DeviceN colour of +/// eleven or more inks (ISO 32000-2 §8.6.6.5 allows "an arbitrary number of colour +/// components") blanked the rest of the page (#4260). An `SCN`/`scn` for the largest DeviceN +/// space the interpreter reads (255 inks) takes 255 numbers and a pattern name. Past this, the +/// oldest operands are dropped: operators read the operands nearest to them (see `shift` in the +/// operator macros), so this is how stray operands below an instruction are already treated. +const MAX_OPERANDS: usize = 256; + impl Debug for Operator<'_> { fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result { write!(f, "{}", self.0.as_str()) @@ -436,6 +445,8 @@ impl<'a> UntypedIter<'a> { self.operator = Some(operator); self.committed = self.reader.offset(); + // PdfCraft patch (28): operands in reading order, if some were dropped. + self.stack.settle(); return Some(Instruction { operands: &self.stack, operator: self.operator.as_ref().unwrap(), @@ -594,8 +605,13 @@ impl<'b, 'a> Instruction<'b, 'a> { pub struct Stack<'a> { // TODO: Explore using an object pool to avoid repeatedly // allocating/deallocating objects. - data: [Object<'a>; OPERANDS_THRESHOLD], - len: usize, + // PdfCraft patch (28): up to `MAX_OPERANDS` operands, the first `OPERANDS_THRESHOLD` inline. + data: SmallVec<[Object<'a>; OPERANDS_THRESHOLD]>, + /// PdfCraft patch (28): once `data` is full, the slot of its oldest operand, which the next + /// operand replaces. Always 0 while the stack is not full, and after `settle`. + oldest: usize, + /// PdfCraft patch (28): whether operands were dropped since the stack was last settled. + dropped: bool, } impl<'a> Default for Stack<'a> { @@ -608,31 +624,53 @@ impl<'a> Stack<'a> { /// Create a new, empty stack. pub fn new() -> Self { Self { - data: array::from_fn(|_| Object::Null(Null)), - len: 0, + data: SmallVec::new(), + oldest: 0, + dropped: false, } } + /// PdfCraft patch (28): push an operand, dropping the oldest once `MAX_OPERANDS` are held + /// (see `MAX_OPERANDS`). Never fails: it used to, and the failure ended the content stream. fn push(&mut self, operand: Object<'a>) -> Option<()> { - if self.len >= OPERANDS_THRESHOLD { - return None; + if self.data.len() < MAX_OPERANDS { + self.data.push(operand); + return Some(()); } - self.data[self.len] = operand; - self.len += 1; + self.dropped = true; + if let Some(slot) = self.data.get_mut(self.oldest) { + *slot = operand; + } + self.oldest = (self.oldest + 1) % MAX_OPERANDS; Some(()) } + /// PdfCraft patch (28): put the operands back in the order they were read, oldest first, + /// after `push` dropped some. Called before the instruction is handed out. + fn settle(&mut self) { + if self.dropped { + warn!("more than {MAX_OPERANDS} operands before an operator; the oldest were dropped"); + // Cannot panic: operands are dropped only once `data` holds `MAX_OPERANDS`, and + // `oldest` stays below that. + self.data.rotate_left(self.oldest); + self.oldest = 0; + self.dropped = false; + } + } + fn clear(&mut self) { - self.len = 0; + self.data.clear(); + self.oldest = 0; + self.dropped = false; } fn len(&self) -> usize { - self.len + self.data.len() } fn as_slice(&self) -> &[Object<'a>] { - &self.data[..self.len] + &self.data } fn get<'b, T>(&'b self, index: usize) -> Option @@ -665,11 +703,12 @@ impl Debug for Stack<'_> { impl Clone for Stack<'_> { fn clone(&self) -> Self { - let mut stack = Self::new(); - for item in self.as_slice() { - stack.push(item.clone()).unwrap(); + // PdfCraft patch (28): a plain copy (pushing each operand again could no longer fail). + Self { + data: self.data.clone(), + oldest: self.oldest, + dropped: self.dropped, } - stack } } diff --git a/vendor/hayro-syntax/src/content/ops.rs b/vendor/hayro-syntax/src/content/ops.rs index b066165841..bf582c36a8 100644 --- a/vendor/hayro-syntax/src/content/ops.rs +++ b/vendor/hayro-syntax/src/content/ops.rs @@ -152,6 +152,47 @@ f assert!(iter.next().is_none()); } + /// PdfCraft patch (28): a twelve-ink DeviceN colour's `scn` keeps all twelve operands, and + /// the instructions after it are still read (#4260). The eleventh operand ended the stream. + #[test] + fn scn_with_twelve_components_keeps_them_all() { + let input = b"1 2 3 4 5 6 7 8 9 10 11 12 scn 0 0 10 10 re f"; + let mut iter = TypedIter::new(input); + + match iter.next() { + Some(TypedInstruction::NonStrokeColorNamed(NonStrokeColorNamed(nums, None))) => { + let expected = (1..=12).map(Number::from_i32).collect::>(); + assert_eq!(nums.as_slice(), expected.as_slice()); + } + other => panic!("unexpected instruction: {other:?}"), + } + assert!(matches!(iter.next(), Some(TypedInstruction::RectPath(_)))); + assert!(matches!(iter.next(), Some(TypedInstruction::FillPathNonZero(_)))); + assert!(iter.next().is_none()); + } + + /// PdfCraft patch (28): past the operand cap the oldest operands are dropped, in order, and + /// the operator reads the ones nearest to it. + #[test] + fn operands_past_the_cap_keep_the_latest_in_order() { + let mut input = String::new(); + for i in 0..600 { + input.push_str(&format!("{i} ")); + } + input.push_str("scn 0 1 0 rg"); + let mut iter = TypedIter::new(input.as_bytes()); + + match iter.next() { + Some(TypedInstruction::NonStrokeColorNamed(NonStrokeColorNamed(nums, None))) => { + let expected = (344..600).map(Number::from_i32).collect::>(); + assert_eq!(nums.as_slice(), expected.as_slice()); + } + other => panic!("unexpected instruction: {other:?}"), + } + assert!(matches!(iter.next(), Some(TypedInstruction::NonStrokeColorDeviceRgb(_)))); + assert!(iter.next().is_none()); + } + #[test] fn scn() { let input = b" diff --git a/vendor/hayro/src/lib.rs b/vendor/hayro/src/lib.rs index a5ce6c2a94..37d1a5b078 100644 --- a/vendor/hayro/src/lib.rs +++ b/vendor/hayro/src/lib.rs @@ -61,7 +61,7 @@ use vello_cpu::{Level, Pixmap, RenderMode}; mod renderer; /// PdfCraft patch: exported for its regression test. -pub use renderer::{image_resampling_size, tiling_cell_scale, usable_cell_scale}; +pub use renderer::{image_resampling_size, tiling_cell_pixels, tiling_cell_scale, usable_cell_scale}; /// PdfCraft patch: exported for its regression test. #[doc(hidden)] pub use renderer::set_skip_offscreen_paths; diff --git a/vendor/hayro/src/renderer.rs b/vendor/hayro/src/renderer.rs index 8909cb62a4..242c54ec2b 100644 --- a/vendor/hayro/src/renderer.rs +++ b/vendor/hayro/src/renderer.rs @@ -1302,13 +1302,18 @@ impl Renderer { xs = tiling_cell_scale(xs, t.x_step, MAX_PIXMAP_SIZE); ys = tiling_cell_scale(ys, t.y_step, MAX_PIXMAP_SIZE); - let x_step = xs * t.x_step; - let y_step = ys * t.y_step; + // PdfCraft patch (25): the cell pixmap repeats every `pix_width` pixels, + // so one step must be exactly that many pixels. The cell is drawn at the + // scale that makes it so, and the paint transform below maps the pixmap + // back onto pattern space: cells sit at whole multiples of the step at + // any device scale. Drawing at the unrounded scale repeated the cell a + // fraction of a pixel too early or late, and the error grew with every + // step away from the pattern's origin. + let (pix_width, xs) = tiling_cell_pixels(xs, t.x_step, MAX_PIXMAP_SIZE); + let (pix_height, ys) = tiling_cell_pixels(ys, t.y_step, MAX_PIXMAP_SIZE); - let scaled_width = bbox.width() as f32 * xs; - let scaled_height = bbox.height() as f32 * ys; - let pix_width = x_step.abs().round() as u16; - let pix_height = y_step.abs().round() as u16; + let scaled_width = bbox.width() * xs; + let scaled_height = bbox.height() * ys; let mut renderer = Self { ctx: RenderContext::new_with( @@ -1332,7 +1337,7 @@ impl Renderer { preview: self.preview, image_transparency_stack: Vec::new(), }; - let mut initial_transform = Affine::scale_non_uniform(xs as f64, ys as f64) + let mut initial_transform = Affine::scale_non_uniform(xs, ys) * Affine::translate((-bbox.x0, -bbox.y0)); t.interpret(&mut renderer, initial_transform, is_stroke); let mut pix = Pixmap::new(pix_width, pix_height); @@ -1341,14 +1346,14 @@ impl Renderer { renderer.ctx.render_to_pixmap(&mut resources, &mut pix); // TODO: Fix these - if x_step < 0.0 { + if t.x_step < 0.0 { initial_transform *= - Affine::new([-1.0, 0.0, 0.0, 1.0, scaled_width as f64, 0.0]); + Affine::new([-1.0, 0.0, 0.0, 1.0, scaled_width, 0.0]); } - if y_step < 0.0 { + if t.y_step < 0.0 { initial_transform *= - Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, scaled_height as f64]); + Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, scaled_height]); } paint_transform = @@ -2186,3 +2191,26 @@ pub fn tiling_cell_scale(scale: f32, step: f32, max_pixels: f32) -> f32 { scale } } + +/// PdfCraft patch (25): the side of a tiling cell pixmap in pixels, and the scale to draw the +/// cell at so that one `step` spans exactly that many pixels. The pixmap repeats every whole +/// number of pixels; drawing the cell at `scale` itself would repeat it at `round(scale × step)` +/// instead of `scale × step`, a fraction of a pixel off at every step (ISO 32000-2 8.7.3.1: cells +/// are displaced by multiples of `/XStep` and `/YStep`). The side is kept within 1 and +/// `max_pixels` (itself at most `u16::MAX`). When `scale × step` isn't finite and positive, +/// `scale` is kept and the side is `max_pixels` for an infinite product, 1 otherwise. +pub fn tiling_cell_pixels(scale: f32, step: f32, max_pixels: f32) -> (u16, f64) { + // A whole number of pixels; `max` and `min` (not `clamp`, which panics on NaN) make a NaN cap 1. + let max = f64::from(max_pixels).floor().max(1.0).min(f64::from(u16::MAX)); + let (scale, step) = (f64::from(scale), f64::from(step.abs())); + let exact = scale * step; + if !(exact.is_finite() && exact > 0.0) { + let pixels = if exact == f64::INFINITY { max } else { 1.0 }; + // In 1..=u16::MAX, so the cast is exact. + return (pixels as u16, scale); + } + let pixels = exact.round().max(1.0).min(max); + // `pixels` is in 1..=u16::MAX, so the cast is exact; `step` is positive and finite here + // (`exact` is), so the division is too. + (pixels as u16, pixels / step) +} diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 8ad15733a2..b961ebef66 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -182,6 +182,34 @@ impl std::fmt::Display for Violation { } } +/// Layer and testkit rules for one dependency of `c` that is itself a workspace crate. +fn check_workspace_dep(c: &Crate, class: Class, layer: u8, d: &Dep, out: &mut Vec) { + let dep_class = classify(&d.name); + // Test support is a dev-dependency only (`Class::Testkit`) and so never reaches + // the product graph a standalone crate publishes. Checking it against the + // standalone allow-list would stop `cos` using the shared oracles in its own + // tests, which is the one thing those crates exist for. + let testkit_dev = dep_class == Some(Class::Testkit) && d.kind == DepKind::Dev; + if matches!(class, Class::Standalone(_)) && !testkit_dev && !standalone_allowed(&c.name, &d.name) { + out.push(Violation::StandaloneHasWorkspaceDep { krate: c.name.clone(), dep: d.name.clone() }); + return; + } + match dep_class { + None => {} + Some(Class::Testkit) if d.kind != DepKind::Dev && class != Class::Testkit => { + out.push(Violation::TestkitAsNormalDep { krate: c.name.clone(), dep: d.name.clone() }); + } + Some(Class::Testkit) => {} + Some(dc) => { + let to = dc.layer().unwrap_or(u8::MAX); + let ok = to < layer || (to == layer && sideways_allowed(&c.name, &d.name)); + if !ok { + out.push(Violation::Upward { krate: c.name.clone(), dep: d.name.clone(), from: layer, to: to.min(8), kind: d.kind }); + } + } + } +} + pub fn check(crates: &[Crate]) -> Vec { let mut out = Vec::new(); for c in crates { @@ -198,30 +226,7 @@ pub fn check(crates: &[Crate]) -> Vec { continue; } if d.workspace { - let dep_class = classify(&d.name); - // Test support is a dev-dependency only (`Class::Testkit`) and so never reaches - // the product graph a standalone crate publishes. Checking it against the - // standalone allow-list would stop `cos` using the shared oracles in its own - // tests, which is the one thing those crates exist for. - let testkit_dev = dep_class == Some(Class::Testkit) && d.kind == DepKind::Dev; - if matches!(class, Class::Standalone(_)) && !testkit_dev && !standalone_allowed(&c.name, &d.name) { - out.push(Violation::StandaloneHasWorkspaceDep { krate: c.name.clone(), dep: d.name.clone() }); - continue; - } - match dep_class { - None => {} - Some(Class::Testkit) if d.kind != DepKind::Dev && class != Class::Testkit => { - out.push(Violation::TestkitAsNormalDep { krate: c.name.clone(), dep: d.name.clone() }); - } - Some(Class::Testkit) => {} - Some(dc) => { - let to = dc.layer().unwrap_or(u8::MAX); - let ok = to < layer || (to == layer && sideways_allowed(&c.name, &d.name)); - if !ok { - out.push(Violation::Upward { krate: c.name.clone(), dep: d.name.clone(), from: layer, to: to.min(8), kind: d.kind }); - } - } - } + check_workspace_dep(c, class, layer, d, &mut out); } else if layer < UI_MIN_LAYER && is_ui_crate(&d.name) && !(class == Class::Testkit || d.kind == DepKind::Dev && d.name == "egui_kittest") { out.push(Violation::UiBelowL7 { krate: c.name.clone(), dep: d.name.clone(), layer }); diff --git a/xtask/src/parity.rs b/xtask/src/parity.rs index 5466776102..ac32f32e85 100644 --- a/xtask/src/parity.rs +++ b/xtask/src/parity.rs @@ -104,6 +104,18 @@ fn valid_milestone(m: &str) -> bool { m == "none" || m.strip_prefix('M').and_then(|n| n.parse::().ok()).is_some_and(|n| n <= 14) } +/// Why an `evidence` entry (`path.rs::test_fn`) does not resolve to a test in the source tree, if it doesn't. +fn evidence_error(ev: &str, repo: &Path) -> Option { + let Some((path, func)) = ev.split_once("::") else { + return Some(format!("evidence {ev:?} must be `path.rs::test_fn`")); + }; + match std::fs::read_to_string(repo.join(path)) { + Ok(src) if src.contains(&format!("fn {func}(")) => None, + Ok(_) => Some(format!("evidence: no `fn {func}` in {path}")), + Err(_) => Some(format!("evidence: {path} does not exist")), + } +} + /// Validate features against the registry, tool table and source tree; returns the errors. pub fn validate(features: &[Feature], commands: &HashSet, tools: &HashSet, repo: &Path) -> Vec { let mut errors = Vec::new(); @@ -143,14 +155,8 @@ pub fn validate(features: &[Feature], commands: &HashSet, tools: &HashSe errors.push(e("shipped features must cite at least one test in `evidence`".into())); } for ev in &f.evidence { - let Some((path, func)) = ev.split_once("::") else { - errors.push(e(format!("evidence {ev:?} must be `path.rs::test_fn`"))); - continue; - }; - match std::fs::read_to_string(repo.join(path)) { - Ok(src) if src.contains(&format!("fn {func}(")) => {} - Ok(_) => errors.push(e(format!("evidence: no `fn {func}` in {path}"))), - Err(_) => errors.push(e(format!("evidence: {path} does not exist"))), + if let Some(m) = evidence_error(ev, repo) { + errors.push(e(m)); } } }
, pub value: Value, /// Check buttons: the on value then the off value; choice lists: the items as shown. @@ -581,7 +604,7 @@ pub enum Value { slope_up: bool, edge: Edge, }, - Rectangle(Border), + Rectangle(Box), } impl Value { @@ -633,6 +656,9 @@ pub struct Run { pub h_scale: Option, /// A floating field embedded in the text (`xfa:embed="#id"`), shown by its value. pub embed: Option, + /// Where the text links to (``), as the template writes it: shared by every run + /// of the link's text, not copied. + pub link: Option>, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs index fc9ad1a245..838d1730ea 100644 --- a/crates/xfa/src/parse.rs +++ b/crates/xfa/src/parse.rs @@ -5,7 +5,11 @@ use base64::Engine; use roxmltree::Node as XmlNode; use crate::XfaError; -use crate::model::*; +use crate::model::{ + Access, Anchor, Area, Border, Caption, ChoiceList, Color, Common, Corner, Draw, Edge, ExclGroup, Field, FontSpec, HAlign, Layout, Margin, Node, + Occur, PageArea, PageRole, PageSet, ParaSpec, Paragraph, Placement, Presence, Rect, RichText, Run, Script, Stroke, Subform, Template, Ui, VAlign, + Value, +}; /// Deepest element nesting followed (templates are shallow; hostile ones are not). const MAX_DEPTH: usize = 64; @@ -146,7 +150,23 @@ fn parse_border(n: Option) -> Option { 3 => [e(0), e(1), e(2), e(1)], _ => [e(0), e(1), e(2), e(3)], }; - Some(Border { presence: Presence::parse(n.attribute("presence")), edges: four, fill: parse_fill(child(n, "fill")) }) + Some(Border { presence: Presence::parse(n.attribute("presence")), edges: four, corners: parse_corners(n), fill: parse_fill(child(n, "fill")) }) +} + +/// The `` children of a border or rectangle, top-left, top-right, bottom-right then +/// bottom-left: one applies to all four, and a corner not given is the last one given. Without +/// any, the corners are square. +fn parse_corners(n: XmlNode) -> [Corner; 4] { + let given: Vec = children(n, "corner") + .take(4) + .map(|c| Corner { + presence: Presence::parse(c.attribute("presence")), + radius: attr_measure(c, "radius").filter(|r| r.is_finite()).unwrap_or(0.0).max(0.0), + inverted: c.attribute("inverted") == Some("1"), + }) + .collect(); + let Some(last) = given.last().copied() else { return [Corner::default(); 4] }; + std::array::from_fn(|i| given.get(i).copied().unwrap_or(last)) } /// ``: typeface, size, weight, posture, underline, fill color and horizontal scale. @@ -201,7 +221,7 @@ fn parse_common(n: XmlNode) -> Common { } /// Rich text: XHTML `body > p > span…`. `br` starts a new paragraph; `xfa:embed` spans become -/// embedded fields. +/// embedded fields; the text of an `` keeps where it links to. fn parse_rich(body: XmlNode) -> RichText { #[derive(Clone, Default)] struct Style { @@ -210,6 +230,8 @@ fn parse_rich(body: XmlNode) -> RichText { underline: Option, size: Option, h_scale: Option, + /// The `href` of the `` the text is in. + link: Option>, } /// `base` with the CSS declarations in `css` applied: weight, style, underline, size and /// `xfa-font-horizontal-scale`. Anything else is ignored. @@ -254,6 +276,7 @@ fn parse_rich(body: XmlNode) -> RichText { size: style.size, h_scale: style.h_scale, embed: None, + link: style.link.clone(), }, ); } @@ -285,6 +308,13 @@ fn parse_rich(body: XmlNode) -> RichText { "b" => st.bold = Some(true), "i" => st.italic = Some(true), "u" => st.underline = Some(true), + // A link's text keeps where it points; an `` without one (a named + // anchor) leaves the text as it was. + "a" => { + if let Some(href) = c.attribute("href").map(str::trim).filter(|h| !h.is_empty()) { + st.link = Some(std::sync::Arc::from(href)); + } + } _ => {} } let embed = c.attributes().find(|a| a.name() == "embed").map(|a| a.value().trim_start_matches('#').to_string()); @@ -300,6 +330,7 @@ fn parse_rich(body: XmlNode) -> RichText { size: st.size, h_scale: st.h_scale, embed: Some(e), + link: st.link.clone(), }, ); } @@ -378,7 +409,7 @@ fn parse_value(n: Option, warnings: &mut Vec) -> Value { "rectangle" => { let mut b = parse_border(Some(c)).unwrap_or_default(); b.fill = parse_fill(child(c, "fill")); - return Value::Rectangle(b); + return Value::Rectangle(Box::new(b)); } "date" | "time" | "dateTime" | "integer" | "decimal" | "float" | "boolean" => return Value::Text(text_of(c)), _ => {} @@ -397,7 +428,9 @@ fn parse_caption(n: Option, warnings: &mut Vec) -> Option Placement::Inline, _ => Placement::Left, }, - reserve: attr_measure(n, "reserve").filter(|r| *r >= 0.0), + // A reserve of zero or less sizes the caption to its content, as none does (XFA 3.3, + // the caption's reserve). + reserve: attr_measure(n, "reserve").filter(|r| *r > 0.0), presence: Presence::parse(n.attribute("presence")), value: parse_value(child(n, "value"), warnings), font: parse_font(child(n, "font")), @@ -478,6 +511,7 @@ fn parse_field(n: XmlNode, warnings: &mut Vec) -> Field { } _ => ChoiceList::default(), }; + let multiline = ui_el.is_some_and(|e| e.attribute("multiLine").is_some_and(|m| m == "1" || m == "true")); let validate_el = child(n, "validate"); let validate_message = validate_el .and_then(|v| child(v, "message")) @@ -492,7 +526,8 @@ fn parse_field(n: XmlNode, warnings: &mut Vec) -> Field { ui_margin: parse_margin(ui_el.and_then(|e| child(e, "margin"))), check_size: ui_el.and_then(|e| attr_measure(e, "size")).filter(|s| *s > 0.0), check_round: ui_el.is_some_and(|e| e.attribute("shape") == Some("round")), - multiline: ui_el.is_some_and(|e| e.attribute("multiLine").is_some_and(|m| m == "1" || m == "true")), + multiline, + no_scroll: ui_el.is_some_and(|e| e.attribute(if multiline { "vScrollPolicy" } else { "hScrollPolicy" }) == Some("off")), caption: parse_caption(child(n, "caption"), warnings), value: parse_value(value_el, warnings), items, diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index 8e0c47d888..8e930c8f46 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -8,9 +8,9 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; use pdfcraft_fonts::{literal, win_ansi}; use crate::XfaError; -use crate::layout::{Action, BorderShape, Form, Item, Page, Widget, WidgetKind}; +use crate::layout::{Action, BorderShape, Form, Item, Page, Rounded, Widget, WidgetKind}; use crate::model::Rect; -use crate::text::{Face, Family}; +use crate::text::{Face, Family, Span}; /// Private key on generated fields: the template's SOM path, so data can be put back. pub const SOM_KEY: &[u8] = b"PCSom"; @@ -129,6 +129,77 @@ fn all_faces() -> Vec<(&'static str, &'static str)> { out } +/// The content that draws rounded box `r` on a page `page_h` tall: its fill along its curves, +/// then its edge inside it. A side a page break cut has no edge. +pub(crate) fn rounded(r: &Rounded, page_h: f64) -> String { + let mut c = String::new(); + if let Some(color) = r.fill { + c.push_str(&format!("{} rg {}f\n", rgb(color), outline(r.rect, r.radii, r.inverted, (false, false), page_h))); + } + if let Some((w, color, dashed)) = r.edge { + // The edge's centre line, half its width inside the box, so the edge stays in: a corner + // curving around the box tightens by as much, and one curving into it widens. + let inset = Rect::new(r.rect.x + w / 2.0, r.rect.y + w / 2.0, (r.rect.w - w).max(0.0), (r.rect.h - w).max(0.0)); + let most = inset.w.min(inset.h) / 2.0; + let radii: [f64; 4] = std::array::from_fn(|i| match (r.radii[i], r.inverted[i]) { + (v, _) if v <= 0.0 => 0.0, + (v, true) => (v + w / 2.0).min(most), + (v, false) => (v - w / 2.0).clamp(0.0, most), + }); + let path = outline(inset, radii, r.inverted, (r.open_top, r.open_bottom), page_h); + c.push_str(&format!("q {} RG {} w {}{path}S Q\n", rgb(color), n(w), if dashed { "[2 2] 0 d " } else { "" })); + } + c +} + +/// The path around `rect` (top-left based, on a page `page_h` tall) with the corners `radii` +/// (top-left, top-right, bottom-right, bottom-left), each curving around the box or, when +/// `inverted`, into it. `open` leaves out the top or bottom side (and doesn't close the path): +/// those are where a page break cut the box. Ends with a space, for the paint operator. +fn outline(rect: Rect, radii: [f64; 4], inverted: [bool; 4], open: (bool, bool), page_h: f64) -> String { + let (x0, y0, x1, y1) = (rect.x, rect.y, rect.right(), rect.bottom()); + let pt = |q: (f64, f64)| format!("{} {}", n(q.0), n(page_h - q.1)); + // Corner `i`: its point, then where its curve starts and ends, going clockwise. + let corner = |i: usize| { + let r = radii.get(i).copied().unwrap_or(0.0); + match i { + 0 => ((x0, y0), (x0, y0 + r), (x0 + r, y0)), + 1 => ((x1, y0), (x1 - r, y0), (x1, y0 + r)), + 2 => ((x1, y1), (x1, y1 - r), (x1 - r, y1)), + _ => ((x0, y1), (x0 + r, y1), (x0, y1 - r)), + } + }; + // Corner `i`'s curve, from its start: a quarter circle as one Bézier curve. + let curve = |i: usize| { + if radii.get(i).copied().unwrap_or(0.0) <= 0.0 { + return String::new(); + } + let (p, a, b) = corner(i); + const K: f64 = 0.5523; + let (c1, c2) = if inverted.get(i).copied().unwrap_or(false) { + // Centred on the corner itself. + ((a.0 + (b.0 - p.0) * K, a.1 + (b.1 - p.1) * K), (b.0 + (a.0 - p.0) * K, b.1 + (a.1 - p.1) * K)) + } else { + ((a.0 + (p.0 - a.0) * K, a.1 + (p.1 - a.1) * K), (b.0 + (p.0 - b.0) * K, b.1 + (p.1 - b.1) * K)) + }; + format!("{} {} {} c ", pt(c1), pt(c2), pt(b)) + }; + // Along corners `from`, joining them with straight sides. + let along = |from: &[usize]| { + let mut s = String::new(); + for &i in from { + s.push_str(&format!("{} l {}", pt(corner(i).1), curve(i))); + } + s + }; + match open { + (false, false) => format!("{} m {}{}{}{}h ", pt(corner(0).1), curve(0), along(&[1]), along(&[2]), along(&[3])), + (true, false) => format!("{} m {}{} l ", pt((x1, y0)), along(&[2, 3]), pt((x0, y0))), + (false, true) => format!("{} m {}{} l ", pt((x0, y1)), along(&[0, 1]), pt((x1, y1))), + (true, true) => format!("{} m {} l {} m {} l ", pt((x0, y0)), pt((x0, y1)), pt((x1, y0)), pt((x1, y1))), + } +} + /// A PDF appearance-state name for a check button's on value. fn state_name(on: &str) -> String { let s: String = on.trim().chars().map(|c| if c.is_whitespace() || "()<>[]{}/%#".contains(c) || c.is_control() { '_' } else { c }).collect(); @@ -150,16 +221,27 @@ fn js_action(js: &str) -> Object { Object::Dict(a) } +/// What laying a form out says when it took the usage rights off (see [`drop_usage_rights`]). +pub(crate) const USAGE_RIGHTS_REMOVED: &str = + "the form's usage rights were removed: laying it out voids them, and a form with void usage rights can open with every field locked"; + pub struct Written { pub pages: usize, pub fields: usize, pub warnings: Vec, + /// Usage rights were taken off the catalog (laying the form out voided them). + pub usage_rights: bool, } /// On a push button generated for an XFA image field: clicking it picks a picture (the forms /// layer reads this key). pub const IMAGE_FIELD_KEY: &[u8] = b"PCXfaImage"; +/// On a check box or radio button generated for a round XFA check button (`shape="round"`): its +/// background and border are drawn as a circle, not the widget's rectangle (the forms layer +/// reads this key). +pub const ROUND_KEY: &[u8] = b"PCXfaRound"; + /// On an image XObject written for a picture: the SHA-256 (hex) of the picture's bytes. A /// re-layout (after every script event) finds the XObjects on the pages it replaces by this, /// and reuses them instead of decoding and embedding the same picture again. Nothing is kept @@ -244,6 +326,8 @@ impl Emitter<'_> { format!("/{} {} Tf {} rg", face.resource_name(), n(face.size), rgb(face.color)).into_bytes() } + /// Write laid-out widget `w` on `page` as an AcroForm widget annotation (and its field), with + /// its border, background and kind, and return the annotation's object. fn widget(&mut self, w: &Widget, page: &Page, page_ref: ObjRef) -> Result, XfaError> { let r = w.rect; let (x0, y0, x1, y1) = (r.x, page.height - r.bottom(), r.right(), page.height - r.y); @@ -271,6 +355,9 @@ impl Emitter<'_> { bs.set(b"W".to_vec(), Object::Real(if w.border.shape == BorderShape::None { 0.0 } else { w.border.width.clamp(0.1, 12.0) })); bs.set(b"S".to_vec(), Object::name(if w.border.shape == BorderShape::Underline { "U" } else { "S" })); d.set(b"BS".to_vec(), Object::Dict(bs)); + if matches!(w.kind, WidgetKind::CheckBox { round: true, .. } | WidgetKind::Radio { round: true, .. }) { + d.set(ROUND_KEY.to_vec(), Object::Bool(true)); + } let mut ff: i64 = 0; if w.read_only { ff |= 1; @@ -285,6 +372,10 @@ impl Emitter<'_> { if w.kind == WidgetKind::Password { ff |= 1 << 13; } + // DoNotScroll (ISO 32000-2 Table 228): the field takes no more than it shows. + if w.no_scroll { + ff |= 1 << 23; + } if let Some(m) = w.max_chars { d.set(b"MaxLen".to_vec(), Object::Int(m.min(100_000) as i64)); } @@ -574,8 +665,8 @@ impl Emitter<'_> { } /// Write one laid-out page under `pages_ref`: its fills, lines, text and pictures in a - /// content stream, the fonts and images it uses as resources, and its widgets as - /// annotations. Returns the page object. + /// content stream, the fonts and images it uses as resources, and its widgets and the links + /// in its text as annotations. Returns the page object. fn page(&mut self, page: &Page, pages_ref: ObjRef) -> Result { let page_ref = self.doc.add(Object::Dict(Dict::new())); let mut content: Vec = Vec::new(); @@ -583,11 +674,27 @@ impl Emitter<'_> { let mut xobjects = Dict::new(); let mut annots = Vec::new(); let h = page.height; + // The link the text before ended in, which a next piece of it on the line widens. + let mut last_link: Option = None; for (i, item) in page.items.iter().enumerate() { + // A link's pieces join only across text and the underline drawn just before it: a fill, + // picture, field or any other line (a rule) between them ends the link there. + let keeps_link = match item { + Item::Text(_) => true, + Item::Line { from, to, .. } => matches!(page.items.get(i + 1), Some(Item::Text(s)) + if (from.1 - to.1).abs() < 0.01 + && (from.0 - s.x).abs() < 0.01 + && (from.1 - (s.baseline + s.face.size * crate::layout::UNDERLINE_DROP)).abs() < 0.01), + _ => false, + }; + if !keeps_link { + last_link = None; + } match item { Item::Fill { rect, color } => { content.extend(format!("{} rg {} {} {} {} re f\n", rgb(*color), n(rect.x), n(h - rect.bottom()), n(rect.w), n(rect.h)).bytes()); } + Item::Rounded(r) => content.extend(rounded(r, h).bytes()), Item::Line { from, to, width, color, dashed } => { content.extend( format!( @@ -629,6 +736,9 @@ impl Emitter<'_> { ); content.extend(literal(&win_ansi(&s.text))); content.extend_from_slice(format!(" Tj{close}\n").as_bytes()); + if let Some(r) = self.text_link(s, h, page_ref, &mut last_link)? { + annots.push(Object::Ref(r)); + } } Item::Image { rect, data, content_type } => { let Some(r) = self.image_xobject(data, content_type) else { continue }; @@ -668,6 +778,108 @@ impl Emitter<'_> { self.doc.set(page_ref, Object::Dict(pd)); Ok(page_ref) } + + /// A link annotation over span `s` (on a page `page_height` tall), when its text links + /// somewhere: over the text it draws, without the spaces around it, and opening its address. + /// A span that goes on the link `last` ended in, on the same line, widens that annotation + /// instead (a link whose text changes style partway is one link). An address PdfCraft + /// wouldn't open from a document leaves the text as plain text, with one warning for the form. + fn text_link(&mut self, s: &Span, page_height: f64, page_ref: ObjRef, last: &mut Option) -> Result, XfaError> { + let piece = self.link_piece(s, page_height); + let Some((target, rect)) = piece else { + *last = None; + return Ok(None); + }; + if let Some(prev) = last.as_mut() + && prev.uri == target + && (prev.baseline - s.baseline).abs() < 0.01 + && (-0.01..=s.face.size).contains(&(rect[0] - prev.rect[2])) + { + prev.rect = [prev.rect[0].min(rect[0]), prev.rect[1].min(rect[1]), prev.rect[2].max(rect[2]), prev.rect[3].max(rect[3])]; + let r = prev.rect; + self.doc.update_dict(prev.annot, |d| d.set(b"Rect".to_vec(), Object::Array(r.iter().map(|v| Object::Real(*v)).collect())))?; + return Ok(None); + } + let mut action = Dict::new(); + action.set(b"S".to_vec(), Object::name("URI")); + action.set(b"URI".to_vec(), Object::String(PdfString::literal(target.as_bytes().to_vec()))); + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Annot")); + d.set(b"Subtype".to_vec(), Object::name("Link")); + d.set(b"Rect".to_vec(), Object::Array(rect.iter().map(|v| Object::Real(*v)).collect())); + d.set(b"P".to_vec(), Object::Ref(page_ref)); + d.set(b"F".to_vec(), Object::Int(4)); + d.set(b"Border".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), Object::Int(0)])); + d.set(b"A".to_vec(), Object::Dict(action)); + let annot = self.doc.add(Object::Dict(d)); + *last = Some(LinkPiece { annot, uri: target, baseline: s.baseline, rect }); + Ok(Some(annot)) + } + + /// Where span `s` links to and the rectangle (PDF user space, on a page `page_height` tall) + /// over the text it draws, without the spaces around it. `None` for text that links nowhere, + /// draws nothing, or links to an address PdfCraft wouldn't open from a document, which is + /// warned about once for the form. + fn link_piece(&mut self, s: &Span, page_height: f64) -> Option<(String, [f64; 4])> { + let href = s.link.as_deref()?; + let shown = s.text.trim(); + if shown.is_empty() { + return None; + } + let Some(target) = link_target(href) else { + if !self.warnings.iter().any(|w| w.starts_with(LINK_LEFT_OUT)) { + self.warnings.push(format!("{LINK_LEFT_OUT}: PdfCraft makes links only of web (http, https) and email (mailto) addresses")); + } + return None; + }; + let lead = s.text.len() - s.text.trim_start().len(); + let x = s.x + s.face.width(s.text.get(..lead).unwrap_or_default()); + let w = s.face.width(shown); + // The text's ascent and descent, as the layout measures a span's extent. + let rect = pdf_rect(Rect::new(x, s.baseline - s.face.size * 0.8, w, s.face.size), page_height); + (rect.iter().all(|v| v.is_finite()) && w > 0.0).then(|| (target.to_string(), rect)) + } +} + +/// The link annotation a page's text last ended in: the next piece of the same link on the same +/// line widens it. +struct LinkPiece { + annot: ObjRef, + uri: String, + baseline: f64, + rect: [f64; 4], +} + +/// How the layout warning about a link left out starts. +const LINK_LEFT_OUT: &str = "a link in the form's text was left as plain text"; + +/// The longest link address kept, in characters: as long as PdfCraft opens from a document. +const MAX_LINK: usize = 2048; + +/// `href` as the address of a link PdfCraft makes from a form's text: a web (http, https) or +/// email (mailto) address, trimmed, of at most [`MAX_LINK`] characters and without control +/// characters. `None` for anything else (scripts, files, data, relative paths). Opening it still +/// goes through the app's own checks and prompt. +fn link_target(href: &str) -> Option<&str> { + let href = href.trim(); + if href.is_empty() || href.chars().count() > MAX_LINK || href.chars().any(char::is_control) { + return None; + } + let (scheme, rest) = href.split_once(':')?; + let web = scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https"); + let ok = if web { rest.strip_prefix("//").is_some_and(has_host) } else { scheme.eq_ignore_ascii_case("mailto") && !rest.is_empty() }; + ok.then_some(href) +} + +/// Whether `after` (a web address past its `//`) names a host: what comes before the path (which a +/// backslash starts too, as web addresses read it), query or fragment, past any user name and +/// before any port, isn't empty. +fn has_host(after: &str) -> bool { + let authority = after.split(['/', '\\', '?', '#']).next().unwrap_or_default(); + let host = authority.rsplit('@').next().unwrap_or_default(); + // An IPv6 host is bracketed and holds colons of its own. + let host = if host.starts_with('[') { host } else { host.split(':').next().unwrap_or_default() }; + !host.is_empty() } /// Replace the document's pages with the laid-out form and add its fields to the AcroForm. The @@ -769,6 +981,13 @@ pub fn write_form(doc: &mut Document, form: &Form) -> Result z.set(b"BaseFont".to_vec(), Object::name("ZapfDingbats")); dr_fonts.set(b"ZaDb".to_vec(), Object::Dict(z)); } + // Each font its own object: Acrobat shows nothing in a text field it redraws after typing + // when the field's /DR font is a dictionary written inline (#3441). + let inline: Vec<(Vec, Dict)> = dr_fonts.iter().filter_map(|(k, v)| Some((k.clone(), v.as_dict()?.clone()))).collect(); + for (name, font) in inline { + let r = doc.add(Object::Dict(font)); + dr_fonts.set(name, Object::Ref(r)); + } dr.set(b"Font".to_vec(), Object::Dict(dr_fonts)); acro.set(b"DR".to_vec(), Object::Dict(dr)); if !acro.contains(b"DA") { @@ -786,10 +1005,12 @@ pub fn write_form(doc: &mut Document, form: &Form) -> Result catalog_changed = true; } } - if catalog_changed { + // The pages and fields are new: usage rights signed over the form as it was are void now. + let usage_rights = drop_usage_rights(doc, &mut catalog); + if catalog_changed || usage_rights { doc.set(catalog_ref, Object::Dict(catalog)); } - Ok(Written { pages: count, fields: field_count, warnings }) + Ok(Written { pages: count, fields: field_count, warnings, usage_rights }) } /// What [`strip`] took out, for the user to be told (the packets held more than the fields). @@ -975,21 +1196,7 @@ pub fn strip(doc: &mut Document) -> Result { None => catalog.set(b"AcroForm".to_vec(), Object::Dict(acro)), } catalog.remove(b"NeedsRendering"); - // Reader usage rights are void once the form changed: Reader says so on opening the file - // and drops them, so they go now, with a note. - if let Some(perms) = catalog.get(b"Perms").map(|p| doc.resolve(p)) - && let Some(mut perms) = perms.as_dict().cloned() - && (perms.contains(b"UR3") || perms.contains(b"UR")) - { - perms.remove(b"UR3"); - perms.remove(b"UR"); - out.usage_rights = true; - if perms.is_empty() { - catalog.remove(b"Perms"); - } else { - catalog.set(b"Perms".to_vec(), Object::Dict(perms)); - } - } + out.usage_rights = drop_usage_rights(doc, &mut catalog); doc.set(root, Object::Dict(catalog)); // The packet streams are referenced from nowhere else: freed, so an incremental save // doesn't carry an unreachable copy of the data. @@ -1036,6 +1243,28 @@ fn unbound_data(doc: &Document) -> Option { Some(count) } +/// Take the usage rights (`/Perms /UR3`, or the older `/UR`) off a catalog, keeping the rest of +/// `/Perms` (a certification's `/DocMDP`). They are signed over the form as it was: once its +/// pages or fields change they are void, and a viewer may lock every field of a form whose +/// usage rights are void, so they go, and the caller says so. Whether there were any. +fn drop_usage_rights(doc: &mut Document, catalog: &mut Dict) -> bool { + let Some(entry) = catalog.get(b"Perms").cloned() else { return false }; + let Some(mut perms) = doc.resolve(&entry).as_dict().cloned() else { return false }; + if !perms.contains(b"UR3") && !perms.contains(b"UR") { + return false; + } + perms.remove(b"UR3"); + perms.remove(b"UR"); + match entry.as_ref() { + _ if perms.is_empty() => { + catalog.remove(b"Perms"); + } + Some(r) => doc.set(r, Object::Dict(perms)), + None => catalog.set(b"Perms".to_vec(), Object::Dict(perms)), + } + true +} + /// The layout a document already carries from an earlier pass, if any. pub fn existing_layout(doc: &Document) -> Option { let root = doc.root()?; diff --git a/crates/xfa/src/picture.rs b/crates/xfa/src/picture.rs new file mode 100644 index 0000000000..3ddb392114 --- /dev/null +++ b/crates/xfa/src/picture.rs @@ -0,0 +1,65 @@ +//! Picture clauses that list alternatives (`date{YYYYMMDD}|date{YYMMDD}`): a date may be typed +//! by any of them, and is shown, and kept in the field, by the first. + +use crate::data::{date_letters, iso_to_pattern, pattern_to_iso, template_field}; +use crate::layout::date_pattern; +use crate::model::{Template, Ui}; + +/// The alternatives of a picture clause: its parts between `|`s that aren't inside braces or +/// quotes. +pub(crate) fn alternatives(picture: &str) -> Vec<&str> { + let mut out = Vec::new(); + let (mut depth, mut quoted, mut start) = (0usize, false, 0); + for (i, c) in picture.char_indices() { + match c { + '\'' => quoted = !quoted, + '{' if !quoted => depth = depth.saturating_add(1), + '}' if !quoted => depth = depth.saturating_sub(1), + '|' if !quoted && depth == 0 => { + out.extend(picture.get(start..i)); + start = i + 1; + } + _ => {} + } + } + out.extend(picture.get(start..)); + out +} + +/// `text` typed into the date field at `som` by one of its edit picture's alternatives after the +/// first, written in the first, which the field shows and checks typed dates against (`261018` +/// becomes `20261018` for `date{YYYYMMDD}|date{YYMMDD}`). A pattern reads the text only when the +/// date it gives, written in that pattern, is the text again, and only date clauses count. `None` +/// when the field isn't a date field with alternatives, the first pattern reads the text, no +/// other does, or the first pattern can't hold the date (a two-digit year for 1901). +pub fn typed_date(tpl: &Template, som: &str, text: &str) -> Option { + let f = template_field(tpl, som)?; + if f.ui != Ui::DateTimeEdit { + return None; + } + let picture = f.picture.as_deref()?; + let reads = |pattern: &str| pattern_to_iso(text, pattern).filter(|iso| iso_to_pattern(iso, pattern).eq_ignore_ascii_case(text.trim())); + let first = date_pattern(Some(picture)); + if reads(&first).is_some() { + return None; + } + // The first later date pattern that reads the text and gives a date the first pattern keeps: + // written in the first, it must read back as the same date (a two-digit year can't hold 1901 + // or 1203), else the next pattern is tried. + alternatives(picture).into_iter().skip(1).filter(|a| is_date_clause(a)).filter_map(date_letters).find_map(|p| { + let iso = reads(&p)?; + let shown = iso_to_pattern(&iso, &first); + (pattern_to_iso(&shown, &first).as_deref() == Some(iso.as_str())).then_some(shown) + }) +} + +/// Whether picture clause `clause` is a date pattern: a `date{…}` clause (with or without a +/// locale, `date(en_US){…}`), or a bare pattern, which a date field reads as a date. A clause of +/// another category (`text{…}`, `num{…}`, `time{…}`) is not, whatever its letters. +fn is_date_clause(clause: &str) -> bool { + let clause = clause.trim(); + match clause.find(['{', '(']) { + Some(i) => clause.get(..i).is_some_and(|category| category.trim().eq_ignore_ascii_case("date")), + None => true, + } +} diff --git a/crates/xfa/src/script.rs b/crates/xfa/src/script.rs index cb555dc81d..d330c2a647 100644 --- a/crates/xfa/src/script.rs +++ b/crates/xfa/src/script.rs @@ -7,7 +7,7 @@ use std::collections::{BTreeMap, HashMap}; use pdfcraft_cos::{Dict, Document, Object, PdfString}; use crate::data::{DataNode, data_path, parse_datasets}; -use crate::model::*; +use crate::model::{Access, Field, Node, Presence, Script, SomIndices, Template}; use crate::{Report, XfaError}; /// Private key on the AcroForm: presence scripts set, by SOM path. @@ -601,6 +601,9 @@ pub fn rerender(doc: &mut Document, tpl: &Template) -> Result let written = crate::pdf::write_form(doc, &form)?; let mut warnings = form.warnings; warnings.extend(written.warnings); + if written.usage_rights { + warnings.push(crate::pdf::USAGE_RIGHTS_REMOVED.into()); + } warnings.dedup(); Ok(Report { pages: written.pages, fields: written.fields, template_bytes: packets.xdp.len(), warnings }) } diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 411529b177..3175882f89 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use pdfcraft_cos::{Document, Object, SaveOptions, write_incremental}; +use pdfcraft_cos::{Dict, Document, Object, SaveOptions, write_incremental}; use super::*; use crate::data::{ @@ -324,6 +324,12 @@ fn rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields() { let dr = doc.resolve(acro.get(b"DR").unwrap()); let fonts = doc.resolve(dr.as_dict().unwrap().get(b"Font").unwrap()); assert!(fonts.as_dict().unwrap().contains(b"Cour") && fonts.as_dict().unwrap().contains(b"ZaDb")); + // Each one its own object: Acrobat shows nothing in a text field it redraws after typing + // when the /DR font is written inline (#3441). + for (name, font) in fonts.as_dict().unwrap().iter() { + let r = font.as_ref().unwrap_or_else(|| panic!("{} is an object", String::from_utf8_lossy(name))); + assert_eq!(doc.get(r).as_dict().and_then(|f| f.name(b"Type")), Some(&b"Font"[..])); + } // A field carries its SOM path for data round trips. let first = doc.resolve(&doc.resolve(acro.get(b"Fields").unwrap()).as_array().unwrap()[0]); assert!(first.as_dict().unwrap().contains(SOM_KEY)); @@ -350,6 +356,262 @@ fn form_text_the_standard_fonts_cannot_draw_is_reported() { assert!(!render_into(&mut doc).unwrap().warnings.iter().any(|w| w.contains("standard fonts"))); } +/// A field whose ui doesn't scroll in the direction its text runs out (`hScrollPolicy="off"` for +/// one line, `vScrollPolicy="off"` for several) is written with DoNotScroll (`/Ff` bit 24), so it +/// takes no more text than it shows; the other direction's policy, or the default, doesn't. +#[test] +fn fields_that_dont_scroll_are_written_with_do_not_scroll() { + let field = |name: &str, ui: &str| format!(r#"{ui}"#); + let xdp = format!( + r##" +"##, + field("line", r#""#), + field("lines", r#""#), + field("number", r#""#), + field("when", r#""#), + field("linesAcross", r#""#), + field("lineDown", r#""#), + field("free", r#""#), + ); + let mut doc = Document::open(Arc::new(shell(&xdp))).unwrap(); + render_into(&mut doc).unwrap(); + let catalog = doc.get(doc.root().unwrap()); + 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 mut no_scroll = std::collections::BTreeMap::new(); + for r in fields.iter().filter_map(Object::as_ref) { + let f = doc.get(r); + let d = f.as_dict().unwrap(); + let name = d.get(b"T").and_then(Object::as_string).map(|t| t.to_text()).unwrap_or_default(); + no_scroll.insert(name, d.int(b"Ff").unwrap_or(0) & (1 << 23) != 0); + } + let expected: std::collections::BTreeMap = + [("line", true), ("lines", true), ("number", true), ("when", true), ("linesAcross", false), ("lineDown", false), ("free", false)] + .into_iter() + .map(|(n, v)| (n.to_string(), v)) + .collect(); + assert_eq!(no_scroll, expected); +} + +/// A check box or radio button laid out from a round check button (`shape="round"`) carries +/// `ROUND_KEY`, so the forms layer draws its background and border round; a square one (the +/// default) doesn't. +#[test] +fn round_check_buttons_are_marked_for_a_round_appearance() { + let check = |name: &str, shape: &str, items: &str| { + format!(r#"{items}"#) + }; + let on_off = "10"; + let xdp = format!( + r##" +"##, + check("roundBox", r#" shape="round""#, on_off), + check("squareBox", "", on_off), + check("roundRadio", r#" shape="round""#, "a"), + check("squareRadio", r#" shape="square""#, "b"), + ); + let mut doc = Document::open(Arc::new(shell(&xdp))).unwrap(); + render_into(&mut doc).unwrap(); + // Every widget among the form's fields and their kids (a radio group's buttons), by the + // last step of its SOM path. + let catalog = doc.get(doc.root().unwrap()); + let acro = doc.resolve(catalog.as_dict().unwrap().get(b"AcroForm").unwrap()); + let mut todo: Vec<_> = + doc.resolve(acro.as_dict().unwrap().get(b"Fields").unwrap()).as_array().unwrap().iter().filter_map(Object::as_ref).collect(); + let mut round = std::collections::BTreeMap::new(); + while let Some(r) = todo.pop() { + let node = doc.get(r); + let d = node.as_dict().unwrap(); + if let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)) { + todo.extend(kids.as_array().unwrap().iter().filter_map(Object::as_ref)); + continue; + } + let som = d.get(SOM_KEY).and_then(Object::as_string).map(|s| s.to_text()).unwrap_or_default(); + let name = som.rsplit('.').next().unwrap_or_default().split('[').next().unwrap_or_default().to_string(); + round.insert(name, matches!(d.get(crate::pdf::ROUND_KEY), Some(Object::Bool(true)))); + } + let expected: std::collections::BTreeMap = [("roundBox", true), ("squareBox", false), ("roundRadio", true), ("squareRadio", false)] + .into_iter() + .map(|(n, r)| (n.to_string(), r)) + .collect(); + assert_eq!(round, expected); +} + +/// A template whose one draw holds `body` as rich text, in a box `w` wide. +fn rich_draw_form(w: &str, body: &str) -> String { + format!( + r##" +"## + ) +} + +/// The link annotations on the pages of `doc`: each one's URI and rectangle. +fn text_links(doc: &Document) -> Vec<(String, [f64; 4])> { + let catalog = doc.get(doc.root().unwrap()); + let pages = doc.resolve(catalog.as_dict().unwrap().get(b"Pages").unwrap()); + let kids = doc.resolve(pages.as_dict().unwrap().get(b"Kids").unwrap()).as_array().unwrap().clone(); + let mut out = Vec::new(); + for page in &kids { + let page = doc.resolve(page); + let Some(annots) = page.as_dict().unwrap().get(b"Annots").map(|a| doc.resolve(a).as_array().unwrap().clone()) else { continue }; + for a in &annots { + let a = doc.resolve(a); + let d = a.as_dict().unwrap(); + if d.name(b"Subtype") != Some(&b"Link"[..]) { + continue; + } + let action = doc.resolve(d.get(b"A").unwrap()); + let uri = + action.as_dict().unwrap().get(b"URI").and_then(Object::as_string).map(|s| String::from_utf8_lossy(&s.bytes).into_owned()).unwrap(); + let r: Vec = doc.resolve(d.get(b"Rect").unwrap()).as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); + out.push((uri, [r[0], r[1], r[2], r[3]])); + } + } + out +} + +/// An `` in rich text keeps where it links to on every run of its text, styled or not; +/// the text around it and a named anchor without an address link nowhere. +#[test] +fn rich_text_links_keep_their_address() { + let (tpl, _) = crate::parse(&rich_draw_form( + "4in", + r#"Write to forms@example.com or see the top."#, + )) + .unwrap(); + let Node::Draw(d) = &tpl.root.children[0] else { panic!("the draw") }; + let Value::Rich(rich) = &d.value else { panic!("rich text") }; + let runs: Vec<(&str, Option<&str>)> = rich.paragraphs.iter().flat_map(|p| &p.runs).map(|r| (r.text.as_str(), r.link.as_deref())).collect(); + assert_eq!( + runs, + [ + ("Write to ", None), + ("forms@", Some("mailto:forms@example.com")), + ("example", Some("mailto:forms@example.com")), + (".com", Some("mailto:forms@example.com")), + (" or see ", None), + ("the top", None), + (".", None), + ] + ); +} + +/// A link's address is held once, however many runs, words and lines its text takes: every +/// piece shares it rather than copying it, so a long address over many words stays one string. +#[test] +fn a_links_pieces_share_one_address() { + let words = "one two three four five six seven eight nine ten ".repeat(8); + let href = format!("https://example.com/{}", "x".repeat(1500)); + let xdp = rich_draw_form("2in", &format!(r#"{words}bold {words}"#)); + let form = layout_xml(&xdp).unwrap(); + let links: Vec> = + form.pages.iter().flat_map(|p| &p.items).filter_map(|i| if let Item::Text(s) = i { s.link.clone() } else { None }).collect(); + assert!(links.len() > 10, "the link's text takes many lines: {}", links.len()); + assert!(links.iter().all(|l| std::sync::Arc::ptr_eq(l, &links[0]) && **l == *href), "every piece shares one address"); +} + +/// A link's text becomes a link annotation on the page, one over each line it takes when it +/// wraps, each over its own text; a second layout replaces them rather than adding more. +#[test] +fn links_in_rich_text_become_link_annotations_one_per_line() { + let body = r#"See the help pages for this form, which explain every block first."#; + let mut doc = Document::open(Arc::new(shell(&rich_draw_form("2in", body)))).unwrap(); + let report = render_into(&mut doc).unwrap(); + assert!(report.warnings.iter().all(|w| !w.contains("link")), "{:?}", report.warnings); + let links = text_links(&doc); + assert!(links.len() >= 2, "the link wraps onto at least two lines: {links:?}"); + assert!(links.iter().all(|(uri, _)| uri == "https://example.com/forms/help")); + // One per line: each below the one before, all inside the 2-inch box (from x = 36pt). + for pair in links.windows(2) { + assert!(pair[1].1[3] <= pair[0].1[1] + 0.01, "a line below the one before: {pair:?}"); + } + for (_, r) in &links { + assert!(r[0] >= 36.0 - 0.01 && r[2] <= 36.0 + 144.0 + 0.01 && r[2] > r[0] && r[3] > r[1], "{r:?}"); + } + // The first line's link starts after "See ", not at the box's edge. + assert!(links[0].1[0] > 36.0 + 5.0, "{:?}", links[0].1); + // A second layout replaces the pages, and their links with them. + let tpl = crate::script::template_of(&doc).unwrap(); + rerender(&mut doc, &tpl).unwrap(); + assert_eq!(text_links(&doc), links, "laid out again: the same links, not twice as many"); + // Saved and opened again, they are still there. + let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert_eq!(text_links(&Document::open(Arc::new(bytes)).unwrap()), links); + + // A link whose text changes style partway is one link on its line, over all its text. + let mut doc = + Document::open(Arc::new(shell(&rich_draw_form("4in", r#"Write to forms@example.com today."#)))) + .unwrap(); + render_into(&mut doc).unwrap(); + let links = text_links(&doc); + assert_eq!(links.len(), 1, "{links:?}"); + let arial = Font { typeface: "Arial".into(), size: 10.0, ..Default::default() }; + let plain = crate::text::Face::of(&arial); + let bold = crate::text::Face::of(&Font { bold: true, ..arial.clone() }); + let width = plain.width("forms@") + bold.width("example") + plain.width(".com"); + let r = links[0].1; + assert!((r[2] - r[0] - width).abs() < 0.01, "over the whole address: {} against {width}", r[2] - r[0]); +} + +/// Two pieces of one link side by side on a line join into one link, but a rule drawn between +/// them keeps them apart, so the link never covers what lies between its pieces; an underline +/// (a level line drawn with its text) doesn't. +#[test] +fn a_rule_between_two_pieces_of_a_link_keeps_them_apart() { + let piece = |name: &str, x: &str, underline: bool| { + let u = if underline { ";text-decoration:underline" } else { "" }; + format!( + r#"

Go

"# + ) + }; + let rule = r#""#; + let level_rule = r#""#; + let form = |between: &str, underline: bool| { + format!( + r##" +"##, + piece("first", "0in", underline), + piece("second", "0.25in", underline) + ) + }; + let links = |xdp: &str| { + let mut doc = Document::open(Arc::new(shell(xdp))).unwrap(); + render_into(&mut doc).unwrap(); + text_links(&doc).len() + }; + assert_eq!(links(&form("", false)), 1, "side by side, the pieces join"); + assert_eq!(links(&form("", true)), 1, "an underline doesn't part them"); + assert_eq!(links(&form(rule, false)), 2, "a rule between them does"); + assert_eq!(links(&form(level_rule, false)), 2, "and so does a level one, which isn't an underline"); +} + +/// Only web and email addresses become links. Scripts, files, data, relative paths, web +/// addresses without a host, and empty or overlong addresses stay plain text (still drawn), with +/// one warning for the form. +#[test] +fn links_to_anything_but_web_and_email_addresses_stay_plain_text() { + let long = format!("https://example.com/{}", "a".repeat(2100)); + let body = format!( + r#"one two three four five six seven eight nine nine and a half ten"# + ); + let mut doc = Document::open(Arc::new(shell(&rich_draw_form("7in", &body)))).unwrap(); + let report = render_into(&mut doc).unwrap(); + assert_eq!(text_links(&doc).into_iter().map(|(u, _)| u).collect::>(), ["MailTo:a@example.com", "https://user@example.com:8443/x"]); + let warned: Vec<&String> = report.warnings.iter().filter(|w| w.contains("link")).collect(); + assert_eq!(warned.len(), 1, "one warning for the form: {:?}", report.warnings); + assert!(warned[0].contains("http, https") && warned[0].contains("mailto"), "{warned:?}"); +} + #[test] fn documents_without_xfa_are_not_touched() { let mut doc = Document::new_empty(); @@ -2021,6 +2283,8 @@ fn repeating_rows_in_a_wrapped_container_get_the_instances_the_data_holds() { assert_eq!(form.pages.iter().flat_map(widgets).filter(|w| w.name.starts_with("what")).count(), 1); } +/// A multi-line field with a minimum height and no fixed one grows to hold its text, moving +/// what follows down; one with a fixed height keeps it. #[test] fn a_multi_line_field_without_a_height_grows_to_hold_its_text() { let place = |data: &str| { @@ -2048,6 +2312,297 @@ fn a_multi_line_field_without_a_height_grows_to_hold_its_text() { assert!((notes.rect.h - 108.0).abs() < 0.01, "{:?}", notes.rect); } +/// The rounded boxes laid out on `page`, in order. +fn rounded(page: &Page) -> Vec<&crate::layout::Rounded> { + page.items.iter().filter_map(|i| if let Item::Rounded(r) = i { Some(r.as_ref()) } else { None }).collect() +} + +/// A border or rectangle with rounded corners is one shape, filled and edged along its curves: +/// one corner applies to all four, a corner not given repeats the last one given, a radius is at +/// most half the box's shorter side, and inverted corners are kept. Hidden corners, and edges +/// that differ from each other, keep the square box. +#[test] +fn rounded_corners_make_a_border_one_rounded_shape() { + let draw = |name: &str, y: f64, border: &str| { + format!(r#"{name}{border}"#) + }; + let body = [ + draw("round", 0.0, r#""#), + draw("mixed", 1.2, r#""#), + draw("huge", 2.4, r#""#), + draw("hiddenCorner", 3.6, r#""#), + draw("unlikeEdges", 4.8, r#""#), + draw("fillOnly", 6.0, r#""#), + r#""#.to_string(), + ] + .concat(); + let form = layout_xml(&one_area_form(r#"layout="tb""#, &format!(r#"{body}"#))) + .unwrap(); + let shapes = rounded(&form.pages[0]); + let at = |y: f64| shapes.iter().copied().find(|r| (r.rect.y - 36.0 - y * 72.0).abs() < 0.01); + let round = at(0.0).expect("the rounded border is one shape"); + assert_eq!((round.rect.w, round.rect.h, round.radii), (144.0, 72.0, [18.0; 4])); + assert_eq!((round.fill, round.edge), (Some([230.0 / 255.0; 3]), Some((1.0, [1.0, 0.0, 0.0], false)))); + let mixed = at(1.2).expect("the box with two corners given"); + assert_eq!((mixed.radii, mixed.inverted), ([7.2, 14.4, 14.4, 14.4], [false, true, true, true])); + assert_eq!(at(2.4).expect("the huge radius").radii, [36.0; 4], "at most half the shorter side"); + assert!(at(3.6).is_none() && at(4.8).is_none(), "hidden corners and unlike edges keep a square box"); + let fill_only = at(6.0).expect("a fill with hidden edges is rounded too"); + assert_eq!((fill_only.fill.is_some(), fill_only.edge), (true, None)); + let shape = at(7.2).expect("a rectangle drawn as a value"); + assert!((shape.radii[0] - 5.08 * 72.0 / 25.4).abs() < 1e-9 && shape.edge.is_some()); + // The square boxes are drawn as before: edges as lines. + let lines_at = |y: f64| { + form.pages[0] + .items + .iter() + .filter(|i| matches!(i, Item::Line { from, .. } if from.1 >= 36.0 + y * 72.0 - 1.0 && from.1 <= 36.0 + (y + 1.0) * 72.0 + 1.0)) + .count() + }; + assert_eq!((lines_at(3.6), lines_at(4.8)), (4, 2)); +} + +/// A rounded box a page break cuts keeps its rounded corners on each page, square where it was +/// cut, and its edge stops there instead of closing across the cut. +#[test] +fn a_rounded_box_cut_by_a_page_break_keeps_its_corners_on_each_page() { + // A 12-inch box in a 10-inch content area: it is cut at the area's bottom. + let body = r#"Note"#; + let form = layout_xml(&one_area_form(r#"layout="tb""#, body)).unwrap(); + assert_eq!(form.pages.len(), 2); + let (first, last) = (rounded(&form.pages[0]), rounded(&form.pages[1])); + assert_eq!((first.len(), last.len()), (1, 1), "{first:?} {last:?}"); + assert_eq!((first[0].radii, first[0].open_top, first[0].open_bottom), ([18.0, 18.0, 0.0, 0.0], false, true)); + assert_eq!((last[0].radii, last[0].open_top, last[0].open_bottom), ([0.0, 0.0, 18.0, 18.0], true, false)); + assert!((first[0].rect.h + last[0].rect.h - 12.0 * 72.0).abs() < 0.01, "the two parts make the whole box"); +} + +/// A rounded box is written as one path: filled along its curves, then edged half its width +/// inside, the curves as quarter circles. An inverted corner curves into the box, and the edge +/// of a box a page break cut doesn't close across the cut. +#[test] +fn rounded_boxes_are_written_as_curved_paths() { + use crate::layout::Rounded; + // A 2-by-1-inch box at the page's top left, on a page 792pt tall. + let shape = |inverted: [bool; 4], edge: bool, open: (bool, bool)| Rounded { + rect: Rect::new(36.0, 36.0, 144.0, 72.0), + radii: [18.0; 4], + inverted, + fill: Some([0.9, 0.9, 0.9]), + edge: edge.then_some((2.0, [1.0, 0.0, 0.0], false)), + open_top: open.0, + open_bottom: open.1, + }; + let content = |r: Rounded| crate::pdf::rounded(&r, 792.0); + let ops = content(shape([false; 4], true, (false, false))); + // The fill: from the top-left corner's start, a quarter circle to (54, 36), closed. + assert!(ops.contains("0.9 0.9 0.9 rg 36 738 m 36 747.941 44.059 756 54 756 c "), "{ops}"); + assert!(ops.contains(" h f\n"), "{ops}"); + // The edge: 1pt inside, the corners 1pt tighter. + assert!(ops.contains("q 1 0 0 RG 2 w 37 738 m 37 747.389 44.611 755 54 755 c "), "{ops}"); + assert!(ops.contains(" h S Q\n"), "{ops}"); + // Inverted: the curve bends towards the box's inside. + let inv = content(shape([true; 4], false, (false, false))); + assert!(inv.contains("36 738 m 45.941 738 54 746.059 54 756 c "), "{inv}"); + // Cut at the bottom: the edge runs from the bottom left round the top and back down, open. + let cut = content(Rounded { radii: [18.0, 18.0, 0.0, 0.0], ..shape([false; 4], true, (false, true)) }); + let edge = cut.lines().find(|l| l.contains(" RG ")).unwrap_or_default(); + assert!(edge.starts_with("q 1 0 0 RG 2 w 37 685 m 37 738 l ") && edge.ends_with(" 179 685 l S Q") && !edge.contains(" h "), "{edge}"); +} + +/// A flowed subform without a fixed height gets its border and fill round all it takes, margins +/// included, drawn under its content; a flowed table too. One a page break cuts between its +/// children gets a closed border round its part on each page, its bottom margin ending the first +/// part and its top margin starting the next, as another viewer draws it on a synthetic form. One +/// holding an item that doesn't fit on its page keeps no border, and the layout says so. +#[test] +fn a_flowed_subform_is_drawn_with_its_border_under_its_content() { + let field = |name: &str| format!(r#""#); + let boxed = |inner: &str| { + format!( + r#"{inner}"# + ) + }; + let lay = |body: &str| { + layout_xml(&one_area_form(r#"layout="tb""#, &format!(r#"{body}"#))).unwrap() + }; + // Two half-inch fields between a tenth and a fifth of an inch of margin: 1.3in from the top. + let form = lay(&boxed(&(field("a") + &field("b")))); + let items = &form.pages[0].items; + let fill = items.iter().position(|i| matches!(i, Item::Fill { .. })).expect("the subform's fill"); + let Some(Item::Fill { rect, .. }) = items.get(fill) else { panic!("item {fill} is the fill") }; + assert!((rect.y - 36.0).abs() < 0.01 && (rect.h - 1.3 * 72.0).abs() < 0.01 && (rect.w - 540.0).abs() < 0.01, "{rect:?}"); + assert_eq!(items.iter().filter(|i| matches!(i, Item::Line { .. })).count(), 4, "its four edges"); + let first_widget = items.iter().position(|i| matches!(i, Item::Widget(_))).unwrap(); + assert!(fill < first_widget, "the fill is under the fields"); + assert!(form.warnings.is_empty(), "{:?}", form.warnings); + // A flowed table's border too. + let table = r#"OneTwo"#; + let form = lay(table); + assert_eq!(form.pages[0].items.iter().filter(|i| matches!(i, Item::Line { .. })).count(), 4, "{:?}", form.pages[0].items); + // Thirty fields: 19 fit the first page after the top margin, 11 go to the second. + let many: String = (0..30).map(|i| field(&format!("f{i}"))).collect(); + let form = lay(&boxed(&many)); + assert_eq!(form.pages.len(), 2); + let fills = |page: usize| -> Vec { + form.pages[page].items.iter().filter_map(|i| if let Item::Fill { rect, .. } = i { Some(*rect) } else { None }).collect() + }; + let (first, second) = (fills(0), fills(1)); + assert_eq!((first.len(), second.len()), (1, 1), "a part on each page"); + // Page 1: from the top (36) past 0.1in of margin and 19 fields, then 0.2in of margin. + assert!((first[0].y - 36.0).abs() < 0.01 && (first[0].bottom() - (36.0 + 7.2 + 19.0 * 36.0 + 14.4)).abs() < 0.01, "{:?}", first[0]); + // Page 2: the top margin again, 11 fields, the bottom margin. + assert!((second[0].y - 36.0).abs() < 0.01 && (second[0].bottom() - (36.0 + 7.2 + 11.0 * 36.0 + 14.4)).abs() < 0.01, "{:?}", second[0]); + let lines = |page: usize| form.pages[page].items.iter().filter(|i| matches!(i, Item::Line { .. })).count(); + assert_eq!((lines(0), lines(1)), (4, 4), "each part's border is closed"); + let f19 = form.pages[1].items.iter().find_map(|i| if let Item::Widget(w) = i { (w.name == "f19").then_some(w.rect) } else { None }).unwrap(); + assert!((f19.y - 43.2).abs() < 0.01, "the second part starts after the top margin: {}", f19.y); + assert!(form.warnings.is_empty(), "{:?}", form.warnings); + // A bottom margin that would run past the content area stops at its edge: 39 quarter-inch + // fields end at 738, and 0.4in more would reach 766.8, past 756. + let quarter: String = (0..39).map(|i| format!(r#""#)).collect(); + let low = format!(r#"{quarter}"#); + let form = lay(&low); + let edge = form.pages[0].items.iter().find_map(|i| if let Item::Fill { rect, .. } = i { Some(rect.bottom()) } else { None }).unwrap(); + assert!((edge - 756.0).abs() < 0.01, "the box ends at the content area's bottom: {edge}"); + // The margins come back without a border too. + let bare = format!(r#"{many}"#); + let form = lay(&bare); + let f19 = form.pages[1].items.iter().find_map(|i| if let Item::Widget(w) = i { (w.name == "f19").then_some(w.rect) } else { None }).unwrap(); + assert!((f19.y - 43.2).abs() < 0.01, "{}", f19.y); + // A text of its own longer than a page, cut across pages: no border, one warning. + let long = format!( + r#"{}"#, + "A line of the long text.\n".repeat(80) + ); + let form = lay(&boxed(&(field("a") + &long))); + assert!(form.pages.len() >= 2, "the long text runs onto another page"); + assert!(form.pages.iter().all(|p| !p.items.iter().any(|i| matches!(i, Item::Fill { .. }))), "no fill drawn"); + assert_eq!(form.warnings.iter().filter(|w| w.contains("bordered section")).count(), 1, "{:?}", form.warnings); + // A field that fits a fresh page (720pt) but not after the 7.2pt top margin: the margin + // gives way, so the field ends at the content area's bottom, inside the second part. + let tall = r#""#; + let form = lay(&boxed(&(field("a") + tall))); + assert_eq!(form.pages.len(), 2); + let tall = form.pages[1].items.iter().find_map(|i| if let Item::Widget(w) = i { (w.name == "tall").then_some(w.rect) } else { None }).unwrap(); + assert!(tall.bottom() <= 756.01, "the field stays in the content area: {tall:?}"); + let part = form.pages[1].items.iter().find_map(|i| if let Item::Fill { rect, .. } = i { Some(*rect) } else { None }).expect("the second part"); + assert!(part.y <= tall.y + 0.01 && part.bottom() >= tall.bottom() - 0.01, "the border holds the field: {part:?} {tall:?}"); + assert!(form.warnings.is_empty(), "{:?}", form.warnings); + // A section that starts near a page's bottom, where its first field doesn't fit: the field + // moves to the next page whole, and the border starts there with it, none left behind. + let spacer = r#""#; + let form = lay(&format!("{spacer}{}", boxed(&(field("a") + &field("b"))))); + assert_eq!(form.pages.len(), 2); + let fills = |page: usize| -> Vec { + form.pages[page].items.iter().filter_map(|i| if let Item::Fill { rect, .. } = i { Some(*rect) } else { None }).collect() + }; + assert!(fills(0).is_empty(), "no border left behind on page 1: {:?}", fills(0)); + let moved = fills(1); + assert!(moved.len() == 1 && (moved[0].y - 36.0).abs() < 0.01 && (moved[0].h - (7.2 + 2.0 * 36.0 + 14.4)).abs() < 0.01, "{moved:?}"); + assert!(form.warnings.is_empty(), "{:?}", form.warnings); + // The same field first in the section, at the top of the page: after the section's own top + // margin it runs past the bottom, so the section has no border round it, and says so. + let form = lay(&boxed(r#""#)); + assert!(form.pages.iter().all(|p| !p.items.iter().any(|i| matches!(i, Item::Fill { .. }))), "no fill drawn"); + assert_eq!(form.warnings.iter().filter(|w| w.contains("bordered section")).count(), 1, "{:?}", form.warnings); +} + +/// A flowed table a page break cuts between its rows gets a closed border round its part on +/// each page, as a flowed subform does: its margins repeat, and its overflow leader starts the +/// second part after the top margin. +#[test] +fn a_flowed_table_cut_between_its_rows_keeps_its_border_on_each_page() { + let row = |name: &str, text: &str| { + format!( + r#"{text}-"# + ) + }; + let rows: String = (0..40).map(|i| row(&format!("r{i}"), &format!("Row {i}"))).collect(); + let table = format!( + r#"{}{rows}"#, + row("head", "Head") + ); + let body = format!(r#"{table}"#); + let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap(); + assert_eq!(form.pages.len(), 2); + let fills = |page: usize| -> Vec { + form.pages[page].items.iter().filter_map(|i| if let Item::Fill { rect, .. } = i { Some(*rect) } else { None }).collect() + }; + let lines = |page: usize| form.pages[page].items.iter().filter(|i| matches!(i, Item::Line { .. })).count(); + let (first, second) = (fills(0), fills(1)); + assert_eq!((first.len(), second.len()), (1, 1), "a part on each page"); + assert_eq!((lines(0), lines(1)), (4, 4), "each part's border is closed"); + // Page 1: 0.1in of margin, the leader and 32 rows of 0.3in fill the area to its bottom. + assert!((first[0].y - 36.0).abs() < 0.01 && (first[0].bottom() - 756.0).abs() < 0.01, "{:?}", first[0]); + // Page 2: the top margin again, the leader, the other 8 rows, the bottom margin. + assert!((second[0].y - 36.0).abs() < 0.01 && (second[0].bottom() - (36.0 + 7.2 + 9.0 * 21.6 + 14.4)).abs() < 0.01, "{:?}", second[0]); + let head = form.pages[1].items.iter().find_map(|i| if let Item::Text(t) = i { (t.text == "Head").then_some(t.baseline) } else { None }); + assert!(head.is_some_and(|b| b > 43.2 && b < 64.8), "the leader starts the second part after its top margin: {head:?}"); + assert!(form.warnings.is_empty(), "{:?}", form.warnings); +} + +/// A flowed table's overflow leader and the row that moved are given room together on the new +/// page; a leader and row too tall for any page, and a first row taller than its page, cost the +/// table its border, with the warning, rather than a border round what runs off the page. +#[test] +fn a_tables_leader_and_oversized_rows_fit_its_border_or_lose_it() { + let row = |name: &str, h: &str| { + format!(r#"{name}"#) + }; + let table = |rows: &str| { + format!( + r#"{}{rows}"#, + row("head", "0.3in") + ) + }; + let lay = |rows: &str| layout_xml(&one_area_form(r#"layout="tb""#, &table(rows))).unwrap(); + let fills = |form: &Form, page: usize| -> Vec { + form.pages[page].items.iter().filter_map(|i| if let Item::Fill { rect, .. } = i { Some(*rect) } else { None }).collect() + }; + let small: String = (0..10).map(|i| row(&format!("r{i}"), "0.3in")).collect(); + // A 695pt row after ten small ones moves; it and the 21.6pt leader leave 3.4pt of the 720pt + // page for the top margin, so the row ends at the content area's bottom, in the border. + let form = lay(&(small.clone() + &row("big", "695pt"))); + assert_eq!(form.pages.len(), 2); + let part = fills(&form, 1); + assert!(part.len() == 1 && (part[0].y - 36.0).abs() < 0.01 && (part[0].bottom() - 756.0).abs() < 0.01, "{part:?}"); + assert!(form.warnings.is_empty(), "{:?}", form.warnings); + // 710pt and the leader don't fit any page: no border, one warning. + let form = lay(&(small + &row("big", "710pt"))); + assert!(form.pages.iter().enumerate().all(|(p, _)| fills(&form, p).is_empty()), "no fill drawn"); + assert_eq!(form.warnings.iter().filter(|w| w.contains("bordered section")).count(), 1, "{:?}", form.warnings); + // A first row taller than the page: no border round it, one warning. + let form = lay(&row("tall", "800pt")); + assert!(fills(&form, 0).is_empty(), "{:?}", fills(&form, 0)); + assert_eq!(form.warnings.iter().filter(|w| w.contains("bordered section")).count(), 1, "{:?}", form.warnings); +} + +/// A child's own page break (breakBefore, breakAfter) breaks the flowed subform around it as a +/// break between children does: a closed part on each page, no warning. +#[test] +fn a_childs_page_break_keeps_the_border_around_it_on_each_page() { + let field = |name: &str| format!(r#""#); + let body = format!( + r#"{}{}{}{}"#, + field("a"), + field("b"), + field("c"), + field("d") + ); + let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap(); + assert_eq!(form.pages.len(), 3, "a, then b and c, then d"); + for (page, fields) in [(0, 1.0), (1, 2.0), (2, 1.0)] { + let fills: Vec = + form.pages[page].items.iter().filter_map(|i| if let Item::Fill { rect, .. } = i { Some(*rect) } else { None }).collect(); + let lines = form.pages[page].items.iter().filter(|i| matches!(i, Item::Line { .. })).count(); + assert_eq!((fills.len(), lines), (1, 4), "page {}: one closed part", page + 1); + // The top margin, its fields, the bottom margin. + assert!((fills[0].y - 36.0).abs() < 0.01 && (fills[0].h - (7.2 + fields * 36.0 + 14.4)).abs() < 0.01, "page {}: {:?}", page + 1, fills[0]); + } + assert!(form.warnings.is_empty(), "{:?}", form.warnings); +} + #[test] fn a_page_break_keeps_a_fields_border_with_the_field() { // 6in of heading, then a bordered 5in field: it doesn't fit the 10in area and moves to @@ -2202,6 +2757,258 @@ fn a_text_field_without_a_border_of_its_own_has_no_box() { assert_eq!(shapes, [BorderShape::None, BorderShape::Full, BorderShape::Full]); } +/// One measured check box: its name, width, box size, check box children, field children, and +/// where its box was drawn (x and y in inches from the field's top left). +type CheckCase<'a> = (&'a str, &'a str, Option<&'a str>, String, String, f64, f64); + +/// A check box field for the placement tests: `extra` holds its margin, paragraph and caption, +/// `ui` the check box's own children, and `size` its box size (none for the default 10pt). +fn placed_check(name: &str, x: f64, y: f64, w: &str, size: Option<&str>, ui: &str, extra: &str) -> String { + let size = size.map(|s| format!(r#" size="{s}""#)).unwrap_or_default(); + format!( + r#"{ui}{extra}10"# + ) +} + +/// Where a check box is drawn inside its field, measured in inches from the field's top left in +/// another viewer on synthetic probes: with no caption the box sits at the right of the content +/// area whatever the paragraph's alignment, beside a reserved caption the alignment places it, +/// a caption without a reserve takes all the box leaves, and the box's own margin doesn't move it. +#[test] +fn a_check_box_sits_where_its_margins_caption_and_alignment_put_it() { + let m = |a: &str| format!(""); + let p = |a: &str| format!(""); + let cap = |a: &str, text: &str| format!(r#"
{text}"#.to_string(), 0.5, 0.0), + ("cap_left_auto", "1.5in", Some("0.25in"), String::new(), cap(r#"placement="left""#, "Cap"), 1.25, 0.0), + ("cap_left_auto_center", "1.5in", Some("0.25in"), String::new(), cap(r#"placement="left""#, "Cap") + &p(r#"hAlign="center""#), 1.25, 0.0), + ("cap_right_auto", "1.5in", Some("0.25in"), String::new(), cap(r#"placement="right""#, "Cap"), 0.0, 0.0), + ("cap_right_auto_middle", "1.5in", Some("0.25in"), String::new(), cap(r#"placement="right""#, "Cap") + &p(r#"vAlign="middle""#), 0.0, 0.175), + ("cap_top_auto", "1.5in", Some("0.25in"), String::new(), cap(r#"placement="top""#, "Cap"), 0.0, 0.35), + ("cap_top_auto_center", "1.5in", Some("0.25in"), String::new(), cap(r#"placement="top""#, "Cap") + &p(r#"hAlign="center""#), 0.625, 0.35), + ("cap_bottom_auto", "1.5in", Some("0.25in"), String::new(), cap(r#"placement="bottom""#, "Cap"), 0.0, 0.0), + ("cap_inline", "1.5in", Some("0.25in"), String::new(), cap(r#"placement="inline""#, "Cap"), 1.25, 0.0), + ("cap_hidden_auto", "1.5in", Some("0.25in"), String::new(), cap(r#"placement="left" presence="hidden""#, "Cap"), 1.25, 0.0), + ("box_margin_left", "1.5in", Some("0.25in"), m(r#"leftInset="0.1in""#), String::new(), 1.25, 0.0), + ( + "box_margin_all", + "1.5in", + Some("0.25in"), + m(r#"leftInset="0.1in" rightInset="0.1in" topInset="0.1in" bottomInset="0.1in""#), + String::new(), + 1.25, + 0.0, + ), + ("default_size", "1.2in", None, String::new(), String::new(), 1.2 - 10.0 / 72.0, 0.0), + ("default_size_center", "1.2in", None, String::new(), p(r#"hAlign="center""#), 1.2 - 10.0 / 72.0, 0.0), + ]; + let fields: String = cases + .iter() + .enumerate() + .map(|(i, (name, w, size, ui, extra, _, _))| placed_check(name, (i % 4) as f64 * 1.8, (i / 4) as f64 * 0.8, w, *size, ui, extra)) + .collect(); + // The form in the measured case the probes started from (DD Form 2946's checklist boxes). + let cell = r#"10"#; + let body = format!(r#"{fields}{cell}"#); + let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap(); + let laid = widgets(&form.pages[0]); + let at = |name: &str| laid.iter().find(|w| w.name == name).map(|w| w.rect).unwrap_or_else(|| panic!("no widget {name}")); + // The content area starts half an inch in. + for (i, (name, _, size, _, _, x, y)) in cases.iter().enumerate() { + let r = at(name); + let (fx, fy) = (36.0 + (i % 4) as f64 * 1.8 * 72.0, 36.0 + (i / 4) as f64 * 0.8 * 72.0); + let s = if size.is_some() { 18.0 } else { 10.0 }; + assert!((r.x - fx - x * 72.0).abs() < 0.01, "{name}: box x {} in, expected {x}", (r.x - fx) / 72.0); + assert!((r.y - fy - y * 72.0).abs() < 0.01, "{name}: box y {} in, expected {y}", (r.y - fy) / 72.0); + assert!((r.w - s).abs() < 0.01 && (r.h - s).abs() < 0.01, "{name}: box size {}x{}", r.w, r.h); + } + // Its box sits 4.2mm in, against its 3.556mm right margin, and in the middle of its height. + let r = at("cell"); + let mm = 72.0 / 25.4; + assert!((r.x - 36.0 - (12.7 - 3.556 - 4.9389) * mm).abs() < 0.01, "cell box x {}", r.x - 36.0); + assert!((r.y - 36.0 - 8.0 * 72.0 - (0.6 * 72.0 - 4.9389 * mm) / 2.0).abs() < 0.01, "cell box y {}", r.y - 36.0 - 8.0 * 72.0); +} + +/// Radio buttons in an exclusion group sit as check boxes do: at the right without a caption, +/// moved in by a right margin, and placed by the alignment or the caption beside a caption. +#[test] +fn a_radio_button_sits_where_a_check_box_would() { + let radio = |name: &str, x: f64, extra: &str| { + format!( + r#"{extra}{name}"# + ) + }; + let cap = |a: &str| format!(r#"CapCaption text running on one line{text}RightName