diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index deb37ceb9..7ca1124ce 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 | Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/zh-hans.tsv` | Simplified Chinese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/zh-hant.tsv` | Traditional Chinese (Taiwan) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/cs.tsv` | Czech interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed | Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese interface translation catalog | André Cardoso (andrecards), PdfCraft contributors | MIT OR Apache-2.0 | Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed | Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/es.tsv` | Spanish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed | Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/fr.tsv` | French interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/de.tsv` | German interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/lo.tsv` | Lao interface translation catalog | @bounkirdni-2025 | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Lao UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/te.tsv` | Telugu interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/it.tsv` | Italian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/pl.tsv` | Polish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed | Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/ru.tsv` | Russian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/kk.tsv` | Kazakh interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/ja.tsv` | Japanese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/ckb.tsv` | Central Kurdish (Sorani) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/hi.tsv` | Hindi interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Hindi UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/id.tsv` | Indonesian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Indonesian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/ko.tsv` | Korean interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Korean UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/ro.tsv` | Romanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Romanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/fi.tsv` | Finnish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Finnish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/el.tsv` | Greek interface translation catalog | Camthalion77 and contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/zh-hans.tsv` | Simplified Chinese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/zh-hant.tsv` | Traditional Chinese (Taiwan) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/cs.tsv` | Czech interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese interface translation catalog | André Cardoso (andrecards), PdfCraft contributors | MIT OR Apache-2.0 | Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/es.tsv` | Spanish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/fr.tsv` | French interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/de.tsv` | German interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/lo.tsv` | Lao interface translation catalog | @bounkirdni-2025 | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Lao UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/te.tsv` | Telugu interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/it.tsv` | Italian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/pl.tsv` | Polish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/ru.tsv` | Russian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/kk.tsv` | Kazakh interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/ja.tsv` | Japanese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/ckb.tsv` | Central Kurdish (Sorani) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/hi.tsv` | Hindi interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Hindi UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/id.tsv` | Indonesian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Indonesian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/ko.tsv` | Korean interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Korean UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/ro.tsv` | Romanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; AI-assisted and not yet reviewed line by line by a native Romanian speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Romanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/fi.tsv` | Finnish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: AI-assisted (Claude Code) clean-room translations of PdfCraft's English UI labels and user-facing messages; not yet proofread by a native Finnish speaker; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 calculation-order, text-format, print, thumbnail, layer, comment-reshape and signature labels (previously English placeholders) are contributor-original AI-assisted translations from English, not separately native-reviewed | Finnish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `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 | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `vendor/vello_common/assets/probe.png` | vello_common probe reference image (rendered from probe.rgba) | Linebender vello contributors | MIT OR Apache-2.0 | vello_common 0.0.8 (crates.io), vendored in vendor/vello_common; regenerated by its vello_sparse_tests regenerate_probe_reference binary | Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file | -| `crates/ui-egui/src/i18n/nb.tsv` | Norwegian Bokmål interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/nb.tsv` | Norwegian Bokmål interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `assets/icons/superscript.svg` | Lucide icon "superscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/subscript.svg` | Lucide icon "subscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/pilcrow.svg` | Lucide icon "pilcrow" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/move-horizontal.svg` | Lucide icon "move-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | -| `crates/ui-egui/src/i18n/sq.tsv` | Albanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Albanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/th.tsv` | Thai interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Thai UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | -| `crates/ui-egui/src/i18n/ca.tsv` | Catalan interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Catalan UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/sq.tsv` | Albanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-appearance labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Albanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/th.tsv` | Thai interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Thai UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/ca.tsv` | Catalan interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Catalan UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | ## Compiled in through dependencies (5) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index c0bfada2d..9ca337e39 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" +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" 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" +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" 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" +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" 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" +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" 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" +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" 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" +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" 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" +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" 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" +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" 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" +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" 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" +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" 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" +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" 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" +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" 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" +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" 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" +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" 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" +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" 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" +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" 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" +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" 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" +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" 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" +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" 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" +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" 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" +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" 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" +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" 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" +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" 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" +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" 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" +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" 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" +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" 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" +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" 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" +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" 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" +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" 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" +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" 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" +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" 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" +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" 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" +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" licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" diff --git a/ROADMAP.md b/ROADMAP.md index 3b3a53159..79db56600 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,6 +8,7 @@ > **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) @@ -209,8 +210,12 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md 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. @@ -249,6 +254,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. @@ -296,6 +303,7 @@ 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-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. @@ -369,6 +377,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. @@ -666,6 +677,7 @@ Newest first: the date, what moved, and the new overall estimate. | 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. | diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs index 3a46280c6..9ac3b2158 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; @@ -700,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)?; @@ -806,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()); } @@ -989,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); @@ -1057,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 9576d59a8..7955157e2 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/crates/annot/README.md b/crates/annot/README.md index 8ba3ca8c4..55df6d1a7 100644 --- a/crates/annot/README.md +++ b/crates/annot/README.md @@ -51,6 +51,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 66a86aa30..436c49b35 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(" ")) } @@ -299,6 +318,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`). @@ -512,7 +580,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 +601,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 +750,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 +854,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())); @@ -803,22 +877,101 @@ 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)) +} + /// 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 +980,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 +1046,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 09a081c43..c00f44285 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(); @@ -2262,6 +2280,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]>, @@ -2344,6 +2364,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 20cc89b10..c3602d8b5 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,112 @@ 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()); +} diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 499683063..ced65f002 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -612,9 +612,9 @@ impl Automation { 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 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); @@ -714,7 +714,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)?; @@ -859,7 +859,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) => { diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 3d1b8d82d..a29354f0b 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -3,9 +3,7 @@ //! Geometry follows the automation convention: points from the top-left of the displayed page, //! y down. It is converted to PDF user space (crop box, `/Rotate`) here. -use pdfcraft_engine::{ - Edit, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, SummarySort, -}; +use pdfcraft_engine::{Edit, LineEnding, Markup, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, SummarySort}; use pdfcraft_render::{Annotation, PageInfo}; use serde_json::{Value, json}; @@ -81,13 +79,11 @@ fn rect_to_user(p: &PageInfo, r: [f64; 4]) -> [f64; 4] { } /// The engine's `at` for a note or attachment icon whose top-left corner *as displayed* is the -/// view point (x, y): the top-left (`[x0, y1]`) of the icon's square in user space. Converting -/// the point alone is only right on unrotated pages — under `/Rotate` that corner of the -/// displayed square is another corner of the user-space one, and the icon lands one icon-width -/// away from where it was asked for. +/// view point (x, y): the upper-left corner (`[x0, y1]`) of its `/Rect`. Notes and attachments +/// are NoRotate, so on a turned page the icon is drawn upright with its top-left at that corner +/// (ISO 32000-2 §12.5.3), and `comment_list` reports that square (`Annotation::shown_rect`). fn icon_anchor(p: &PageInfo, x: f64, y: f64) -> [f64; 2] { - let r = rect_to_user(p, [x, y, x + NOTE_SIZE, y + NOTE_SIZE]); - [r[0], r[3]] + to_user(p, x, y) } /// A user-space rectangle as the displayed-page rectangle every tool reports and accepts: @@ -183,8 +179,7 @@ impl Automation { "author": c.author, "contents": c.contents, "modified": c.modified, - // Unchanged on a page the viewer could not read (#2660), as `doc_info` reports it. - "rect": crate::view_rect(&doc.info, c.page, c.rect), + "rect": crate::view_rect(&doc.info, c.page, c.shown_rect(doc.info.pages.get(c.page).map_or(0, |p| p.rotation))), "color": c.color.map(hex), }); // A line's ends, or the corners of connected lines, a polygon or a cloud, as @@ -387,7 +382,7 @@ impl Automation { "attachment" => { let [x, y] = a.need::<2>("at", "an attachment (its icon's top-left)")?; let path = self.resolve(a.str("path")?, false)?; - let data = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let data = crate::read_input(&path)?; let file = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let icon = match a.opt_str("icon")? { Some(n) => pdfcraft_engine::AttachIcon::from_name(n) @@ -640,7 +635,12 @@ impl Automation { edits.push(Edit::FontSizeAnnotation { page, index, size }); } if let Some(r) = a.nums::<4>("rect")? { - edits.push(Edit::ResizeAnnotation { page, index, rect: rect_to_user(&info, r) }); + // The rectangle as comment_list reports it: where the comment is drawn. + let doc = self.doc(a)?; + let rect = rect_to_user(&info, r); + let rect = + doc.info.annotations.iter().find(|c| c.page == page && c.index == index).map_or(rect, |c| c.rect_for_shown(rect, info.rotation)); + edits.push(Edit::ResizeAnnotation { page, index, rect }); } if let Some([dx, dy]) = a.nums::<2>("move")? { let (o, d) = (to_user(&info, 0.0, 0.0), to_user(&info, dx, dy)); @@ -698,7 +698,7 @@ impl Automation { } }; let path = self.resolve(a.str("path")?, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = crate::read_input(&path)?; let file = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let name = a.opt_str("name")?.map(str::to_owned).unwrap_or_else(|| file.rsplit_once('.').map_or(file.clone(), |(s, _)| s.to_owned())); let edit = Edit::AddCustomStamp { diff --git a/crates/automation/src/content.rs b/crates/automation/src/content.rs index e135b4e87..d0b0b69fd 100644 --- a/crates/automation/src/content.rs +++ b/crates/automation/src/content.rs @@ -125,7 +125,7 @@ impl Automation { let page = self.page(a)?; let ph = self.doc(a)?.info.pages[page].height as f64; let path = self.resolve(a.str("path")?, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = crate::read_input(&path)?; let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let rect = rect_arg(a, "rect")?.map(|r| to_display(r, ph)); let mut out = self.apply(a, Edit::AddImage { page, rect, name, bytes: std::sync::Arc::new(bytes) })?; @@ -195,7 +195,7 @@ impl Automation { } if let Some(p) = a.opt_str("image")? { let path = self.resolve(p, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = crate::read_input(&path)?; let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); // Box and transforms first, then the new picture. self.apply(a, Edit::UpdateContent { page, index, content: content.clone() })?; diff --git a/crates/automation/src/file_access.rs b/crates/automation/src/file_access.rs index 55b10662f..4820743e2 100644 --- a/crates/automation/src/file_access.rs +++ b/crates/automation/src/file_access.rs @@ -345,7 +345,8 @@ mod tests { // Helpers that are only called with a resolved path. `doc_batch` (batch.rs) resolves its // inputs and out_dir, walks only inside them (skipping links) and resolves every // discovered file again before `process` reads it or `publish` writes it. - const RESOLVED_BY_CALLER: &[&str] = &["write_atomic", "write_atomic_with", "read_review_sidecar", "process", "linked", "walk", "publish"]; + const RESOLVED_BY_CALLER: &[&str] = + &["write_atomic", "write_atomic_with", "read_input", "read_input_bytes", "read_review_sidecar", "process", "linked", "walk", "publish"]; let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); let mut offenders = vec![]; for entry in std::fs::read_dir(&dir).unwrap() { @@ -365,7 +366,8 @@ mod tests { || !(trimmed.contains("std::fs") || trimmed.contains("File::open") || trimmed.contains("File::create") - || trimmed.contains("OpenOptions")) + || trimmed.contains("OpenOptions") + || trimmed.contains("read_input")) { continue; } diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index 763fd0efd..71fbc67b3 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -701,7 +701,7 @@ impl Automation { pub(crate) fn doc_import_data(&mut self, a: &Args) -> Result { let path = self.resolve(a.str("path")?, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = crate::read_input(&path)?; let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let before = (self.doc(a)?.info.annotations.len(), self.doc(a)?.form.iter().filter(|f| !f.value.is_empty()).count()); let mut out = self.apply(a, Edit::ImportData { name, bytes: std::sync::Arc::new(bytes) })?; diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 208370f15..47a325210 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -424,7 +424,7 @@ impl Automation { "page_replace" => { let pages = self.pages(&a, "pages")?; let path = self.resolve(a.str("path")?, false)?; - let bytes = Arc::new(std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?); + let bytes = Arc::new(read_input(&path)?); let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let src_pages = match a.opt_ints("from_pages")? { Some(p) => one_based(&p)?, @@ -573,7 +573,7 @@ impl Automation { "delete" => ImageEdit::Delete, "replace" => { let path = self.resolve(a.str("path")?, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = read_input(&path)?; ImageEdit::Replace { name: path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), bytes: Arc::new(bytes), @@ -803,7 +803,7 @@ impl Automation { "form_fill" => self.form_fill(&a)?, "form_set_image" => { let path = self.resolve(a.str("path")?, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = read_input(&path)?; self.apply(&a, Edit::SetFieldImage { name: a.str("field")?.to_owned(), image: Arc::new(bytes) })? } "form_reset" => self.form_reset(&a)?, @@ -1038,7 +1038,7 @@ impl Automation { fn doc_open(&mut self, a: &Args) -> Result { let path = self.resolve(a.str("path")?, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = read_input(&path)?; let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let id = self.session.open(name, Some(path.to_string_lossy().into_owned()), Arc::new(bytes), a.opt_str("password")?).map_err(failed)?; let doc = self.session.get(id).ok_or_else(|| failed("the document vanished"))?; @@ -1330,7 +1330,7 @@ impl Automation { fn mark_file(&self, a: &Args) -> Result> { let Some(p) = a.opt_str("file")? else { return Ok(None) }; let path = self.resolve(p, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = read_input(&path)?; let page = a.opt_int("file_page")?.unwrap_or(1).max(1) as usize - 1; Ok(Some(pdfcraft_engine::MarkFile { name: path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), @@ -1504,7 +1504,7 @@ impl Automation { let mut images = Vec::new(); for p in a.strs("paths")? { let path = self.resolve(p, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = read_input(&path)?; images.push((path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), bytes)); } let name = if images.len() == 1 { @@ -1519,7 +1519,7 @@ impl Automation { (Some(t), _) => ("Text".to_string(), t.to_string()), (None, Some(p)) => { let path = self.resolve(p, false)?; - let t = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let t = read_input(&path)?; (path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(), pdfcraft_engine::decode_text(&t)) } (None, None) => return Err(ToolError::InvalidArgs("text needs `text` or `path`".into())), @@ -1694,7 +1694,7 @@ impl Automation { fn insert_file(&mut self, a: &Args) -> Result { let at = self.position(a, "at")?; let path = self.resolve(a.str("path")?, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = read_input(&path)?; let pages = a.opt_ints("pages")?.map(|p| one_based(&p)).transpose()?; let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); self.apply(a, Edit::InsertPagesFrom { name, bytes: Arc::new(bytes), pages, at, field_conflicts: pdfcraft_organize::FieldConflicts::Rename }) @@ -1737,7 +1737,7 @@ impl Automation { // Each file is read, converted and copied in turn, so a long list holds one file at a time. let sources = paths.into_iter().zip(ranges).map(|(p, range)| -> std::result::Result { let path = self.resolve(p, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = read_input(&path)?; let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let (_, pdf) = self.session.convert_to_pdf(&name, &Arc::new(bytes)).map_err(failed)?; let title = path.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); @@ -1754,7 +1754,7 @@ impl Automation { let mut out = Vec::new(); for &p in paths { let converted = self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| { - let bytes = std::fs::read(&src).map_err(|e| format!("{}: {e}", src.display()))?; + let bytes = read_input_bytes(&src).map_err(|e| format!("{}: {e}", src.display()))?; let name = src.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); let stem = src.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "document".into()); let (kind, pdf) = self.session.convert_to_pdf(&name, &Arc::new(bytes)).map_err(|e| e.to_string())?; @@ -1858,7 +1858,7 @@ impl Automation { if let Some(g) = group { firsts.push((g, path.clone(), sources.len())); } - let bytes = Arc::new(std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?); + let bytes = Arc::new(read_input(&path)?); // Images and text files are made into PDFs first (#569), as in the Combine files tab. let file_name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); self.session.as_pdf(&file_name, &bytes).map_err(failed)? @@ -2639,7 +2639,8 @@ fn info(d: &Document) -> Value { // geometry-taking tools (#129). "annotations": i.annotations.iter().map(|a| json!({ "page": page1(a.page), "type": a.subtype, "author": a.author, "contents": a.contents, - "modified": a.modified, "name": a.name, "in_reply_to": a.in_reply_to, "rect": view_rect(i, a.page, a.rect), + "modified": a.modified, "name": a.name, "in_reply_to": a.in_reply_to, + "rect": view_rect(i, a.page, a.shown_rect(i.pages.get(a.page).map_or(0, |p| p.rotation))), })).collect::>(), "fields": i.fields.iter().map(|f| json!({ "name": f.name, "kind": format!("{:?}", f.kind), "value": f.value, "page": f.page.map(page1), @@ -2771,6 +2772,28 @@ fn unused(dir: &Path, stem: &str) -> PathBuf { (2..10_000u32).map(|n| child(dir, &format!("{stem} ({n}).pdf"))).find(|p| !p.exists()).unwrap_or(first) } +/// Largest input file a tool reads into memory (512 MiB, matching `doc_batch`). +pub(crate) const MAX_INPUT: u64 = 512 * 1024 * 1024; + +/// Read a resolved input file of at most [`MAX_INPUT`] bytes without buffering past the cap. +pub(crate) fn read_input_bytes(path: &Path) -> std::io::Result> { + use std::io::Read as _; + let file = std::fs::File::open(path)?; + if file.metadata().is_ok_and(|m| m.is_file() && m.len() > MAX_INPUT) { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "input exceeds 512 MiB")); + } + let mut bytes = Vec::new(); + file.take(MAX_INPUT + 1).read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_INPUT { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "input exceeds 512 MiB")); + } + Ok(bytes) +} + +pub(crate) fn read_input(path: &Path) -> Result> { + read_input_bytes(path).map_err(|e| failed(format!("{}: {e}", path.display()))) +} + /// Write and sync a temporary file in the same directory, then rename over the target. /// This flushes staged contents; it does not promise parent-directory durability after a /// power loss. Filesystems that refuse sync cause the save to fail before publication. @@ -3328,4 +3351,35 @@ mod tests { assert!(a.session.get(id).is_none()); } } + + #[test] + fn tool_file_reads_refuse_inputs_exceeding_512_mib() { + let dir = staging_dir("capped-read"); + let huge = dir.join("huge.bin"); + let file = std::fs::File::create(&huge).unwrap(); + file.set_len(MAX_INPUT + 1).unwrap(); + drop(file); + let huge_str = huge.to_string_lossy().into_owned(); + + let mut a = Automation::new(); + let id = a.session.open("a.pdf", None, Arc::new(numbered_pages(2)), None).unwrap().0; + for (tool, args) in [ + ("doc_open", json!({ "path": huge_str })), + ("page_insert_file", json!({ "doc": id, "at": 1, "path": huge_str })), + ("page_replace", json!({ "doc": id, "pages": [1], "path": huge_str })), + ("page_add_image", json!({ "doc": id, "page": 1, "path": huge_str })), + ("doc_import_data", json!({ "doc": id, "path": huge_str })), + ("doc_create", json!({ "from": "text", "path": huge_str })), + ("doc_create", json!({ "from": "images", "paths": [huge_str] })), + ("doc_combine", json!({ "paths": [huge_str, huge_str] })), + ("form_merge_data", json!({ "paths": [huge_str], "path": dir.join("out.csv").to_string_lossy() })), + ("comment_add", json!({ "doc": id, "page": 1, "type": "attachment", "at": [72.0, 72.0], "path": huge_str })), + ("stamp_custom", json!({ "doc": id, "page": 1, "at": [72.0, 72.0], "path": huge_str })), + ("sign_trust", json!({ "paths": [huge_str] })), + ] { + let err = a.call(tool, &args).unwrap_err().to_string(); + assert!(err.contains("input exceeds 512 MiB"), "{tool}: {err}"); + } + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/automation/src/mcp.rs b/crates/automation/src/mcp.rs index f5fda5be9..84cc21b25 100644 --- a/crates/automation/src/mcp.rs +++ b/crates/automation/src/mcp.rs @@ -25,6 +25,10 @@ const PARSE_ERROR: i64 = -32700; const INVALID_REQUEST: i64 = -32600; const METHOD_NOT_FOUND: i64 = -32601; const INVALID_PARAMS: i64 = -32602; +const INTERNAL_ERROR: i64 = -32603; + +/// Longest JSON-RPC request line [`McpServer::serve`] buffers into memory (16 MiB). +pub const MAX_LINE_BYTES: usize = 16 * 1024 * 1024; const INSTRUCTIONS: &str = "PdfCraft edits PDFs. Open a file with doc_open to get a document id, then inspect \ (doc_info, text_extract, text_find, page_render) or edit it (page_*, doc_set_info). Edits are undoable \ @@ -83,18 +87,36 @@ impl McpServer { } /// Serve until `input` reaches end of file. - pub fn serve(&mut self, input: impl BufRead, mut output: impl Write) -> std::io::Result<()> { - for line in input.lines() { - let line = line?; - if line.trim().is_empty() { - continue; + pub fn serve(&mut self, mut input: impl BufRead, mut output: impl Write) -> std::io::Result<()> { + let mut buf = Vec::new(); + loop { + match read_line_limited(&mut input, &mut buf, MAX_LINE_BYTES)? { + LineRead::End => return Ok(()), + LineRead::TooLong => { + drain_line(&mut input)?; + buf.clear(); + buf.shrink_to(64 * 1024); + let reply = error(Value::Null, INVALID_REQUEST, &format!("request line exceeds {MAX_LINE_BYTES} bytes")); + writeln!(output, "{reply}")?; + output.flush()?; + continue; + } + LineRead::Line => {} } - if let Some(reply) = self.handle_line(&line) { + let reply = match std::str::from_utf8(&buf) { + Ok(line) if line.trim().is_empty() => None, + Ok(line) => self.handle_line(line), + Err(e) => Some(error(Value::Null, PARSE_ERROR, &format!("parse error: {e}")).to_string()), + }; + if buf.capacity() > 64 * 1024 { + buf.clear(); + buf.shrink_to(64 * 1024); + } + if let Some(reply) = reply { writeln!(output, "{reply}")?; output.flush()?; } } - Ok(()) } /// Handle one JSON-RPC message; returns the serialized reply, if one is due. @@ -116,7 +138,7 @@ impl McpServer { }; let params = obj.get("params").cloned().unwrap_or(Value::Null); let id = id?; // notifications/initialized, notifications/cancelled, …: nothing to answer - let result = if method == "tools/call" { guarded_tool(|| self.dispatch(method, ¶ms)) } else { self.dispatch(method, ¶ms) }; + let result = guarded_dispatch(method, || self.dispatch(method, ¶ms)); Some(match result { Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }), Err((code, message)) => error(id, code, &message), @@ -323,11 +345,67 @@ fn unwrap_tool_call(args: &Value) -> Result<(String, Value), String> { Ok((name.to_owned(), inner)) } +/// What [`read_line_limited`] found. +#[derive(Debug, PartialEq, Eq)] +enum LineRead { + /// A line, now in the buffer (newline and a trailing `\r` stripped). + Line, + /// End of stream. + End, + /// The line is longer than the limit; at most `max + 1` bytes of it were read. + TooLong, +} + +/// Read one `\n`-terminated line of at most `max` bytes into `buf`. +fn read_line_limited(reader: &mut impl BufRead, buf: &mut Vec, max: usize) -> std::io::Result { + use std::io::Read as _; + buf.clear(); + // `take` bounds what is buffered for one line: never more than `max + 1` bytes. + let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1); + if reader.take(limit).read_until(b'\n', buf)? == 0 { + return Ok(LineRead::End); + } + if buf.last() == Some(&b'\n') { + buf.pop(); + if buf.last() == Some(&b'\r') { + buf.pop(); + } + return Ok(LineRead::Line); + } + // No newline: either the limit was passed, or this is a last line before end of stream. + Ok(if buf.len() > max { LineRead::TooLong } else { LineRead::Line }) +} + +/// Discard the rest of an overlong line up to `\n` or end of stream without buffering it. +fn drain_line(reader: &mut impl BufRead) -> std::io::Result<()> { + loop { + let chunk = reader.fill_buf()?; + if chunk.is_empty() { + return Ok(()); + } + if let Some(pos) = chunk.iter().position(|&b| b == b'\n') { + reader.consume(pos + 1); + return Ok(()); + } + let len = chunk.len(); + reader.consume(len); + } +} + /// Last-resort boundary shared by every tools/call route, including compact dispatch. fn guarded_tool(f: impl FnOnce() -> Result) -> Result { pdfcraft_engine::guard(f).unwrap_or_else(|message| Ok(tool_error(&format!("internal error: {message}")))) } +/// Last-resort boundary for every JSON-RPC method: tool calls report panics in the tool result +/// (`isError: true`), while resource and protocol methods report a JSON-RPC internal error. +fn guarded_dispatch(method: &str, f: impl FnOnce() -> Result) -> Result { + if method == "tools/call" { + return guarded_tool(f); + } + pdfcraft_engine::guard(f).unwrap_or_else(|message| Err((INTERNAL_ERROR, format!("internal error: {message}")))) +} + fn tool_content(name: &str, content: Vec) -> Value { let failed = matches!(name, "command_batch" | "doc_batch") && content.iter().any(|c| match c { @@ -509,7 +587,12 @@ fn call_result(content: Vec) -> Value { #[cfg(test)] mod tests { - use super::{Resource, first_sentence, parse_uri}; + use std::io::{BufReader, Read as _}; + + use serde_json::{Value, json}; + + use super::{McpServer, Resource, first_sentence, parse_uri}; + use crate::Automation; #[test] fn conventions_panic_is_a_tool_error() { @@ -520,6 +603,59 @@ mod tests { assert_eq!(next["ok"], true); } + #[test] + fn guarded_dispatch_catches_resource_and_protocol_panics() { + for method in ["resources/read", "resources/list", "resources/templates/list", "initialize"] { + let (code, msg) = super::guarded_dispatch(method, || panic!("synthetic {method} failure")).unwrap_err(); + assert_eq!(code, -32603, "{method}"); + assert!(msg.contains(&format!("synthetic {method} failure")), "{msg}"); + } + let ok = super::guarded_dispatch("resources/list", || Ok(json!({"resources": []}))).unwrap(); + assert_eq!(ok["resources"], json!([])); + } + + #[test] + fn read_line_limited_and_drain_line_respect_boundaries() { + use super::{LineRead, drain_line, read_line_limited}; + let mut buf = Vec::new(); + let mut input: &[u8] = b"aaaaaaaaaa\nbb\r\ncc"; + assert_eq!(read_line_limited(&mut input, &mut buf, 10).unwrap(), LineRead::Line); + assert_eq!(buf, b"aaaaaaaaaa", "a line exactly at the limit is accepted"); + assert_eq!(read_line_limited(&mut input, &mut buf, 10).unwrap(), LineRead::Line); + assert_eq!(buf, b"bb", "CRLF is stripped"); + assert_eq!(read_line_limited(&mut input, &mut buf, 10).unwrap(), LineRead::Line); + assert_eq!(buf, b"cc", "a last line without a newline is still read"); + assert_eq!(read_line_limited(&mut input, &mut buf, 10).unwrap(), LineRead::End); + + let mut overlong: &[u8] = b"0123456789abcdef\nafter\n"; + assert_eq!(read_line_limited(&mut overlong, &mut buf, 10).unwrap(), LineRead::TooLong); + assert_eq!(buf.len(), 11, "at most max + 1 bytes are buffered"); + drain_line(&mut overlong).unwrap(); + assert_eq!(read_line_limited(&mut overlong, &mut buf, 10).unwrap(), LineRead::Line); + assert_eq!(buf, b"after", "the next line after an overlong line is read cleanly"); + } + + #[test] + fn serve_answers_non_utf8_and_overlong_lines_and_keeps_serving() { + let init = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": { "name": "test", "version": "0" } } + }) + .to_string(); + let tail = format!("\n{init}\n"); + let stream = (&b"\xff\n"[..]).chain(std::io::repeat(b'a').take(64 * 1024 * 1024)).chain(tail.as_bytes()); + let mut out = Vec::new(); + McpServer::new(Automation::new()).serve(BufReader::new(stream), &mut out).unwrap(); + let replies: Vec = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + assert_eq!(replies.len(), 3, "{replies:?}"); + assert_eq!(replies[0]["error"]["code"], -32700); + assert_eq!(replies[1]["error"]["code"], -32600); + assert_eq!(replies[2]["id"], 1); + assert_eq!(replies[2]["result"]["serverInfo"]["name"], "pdfcraft"); + } + #[test] fn first_sentence_skips_abbreviations() { assert_eq!(first_sentence("Rotate pages. Undoable."), "Rotate pages."); diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index 39cd45760..9a1c1a6b7 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -22,11 +22,72 @@ fn duplex(value: &str, cut_stack: bool) -> Result> { }) } +/// [`duplex`] for a job laid out as `layout`: the name is checked either way, and a booklet +/// decides two-sided itself (#2707), as the Print dialog does (`print::booklet_duplex`). +fn job_duplex(value: &str, layout: &Layout) -> Result> { + let chosen = duplex(value, matches!(layout, Layout::Multiple { order: PageOrder::CutStack, .. }))?; + Ok(match layout { + Layout::Booklet { subset, .. } => Some(print::booklet_duplex(*subset)), + _ => chosen, + }) +} + +/// An invalid-arguments error saying `m`. fn bad(m: impl Into) -> ToolError { ToolError::InvalidArgs(m.into()) } +/// `doc_print`'s `paper`, by its name or a short form ("Letter", "ARCH D"), as its `PAPERS` +/// index and size; US Letter when none is given. +fn paper_named(name: Option<&str>) -> Result<(usize, (f64, f64))> { + let mut papers = PAPERS.iter().enumerate(); + let found = match name { + None => papers.next(), + Some(p) => papers.find(|(_, (n, _))| { + n.eq_ignore_ascii_case(p) + || n.replace("US ", "").eq_ignore_ascii_case(p) + || n.split(" (").next().is_some_and(|name| name.eq_ignore_ascii_case(p)) + }), + }; + found.map(|(index, (_, size))| (index, *size)).ok_or_else(|| { + bad(format!( + "unknown paper {:?}; choose one of {}", + name.unwrap_or_default(), + PAPERS.iter().map(|(name, _)| *name).collect::>().join(", ") + )) + }) +} + +/// The spooler job for `doc_print` to `printer`. It feeds the paper the PDF was laid out on +/// (`PAPERS[paper]`, as `media` after the driver's `options`) rather than the queue's default +/// paper, which a job naming none got (A4 under Letter sheets on the queue in #3752). +fn spool_job(a: &Args, printer: &str, title: String, duplex: Option, paper: usize) -> Result { + let options = match a.get("options") { + None => Vec::new(), + Some(Value::Object(m)) => m + .iter() + .map(|(k, v)| match v.as_str() { + Some(v) => Ok((k.clone(), v.to_string())), + None => Err(bad(format!("options.{k} must be a string (a choice from printer_options)"))), + }) + .collect::>>()?, + Some(_) => return Err(bad("options must be an object of option keys and choices from printer_options")), + }; + Ok(spool::Job { + printer: if printer == "default" { None } else { Some(printer.to_string()) }, + copies: a.opt_int("copies")?.unwrap_or(1).clamp(1, 999) as u32, + collate: a.opt_bool("collate")?.unwrap_or(true), + duplex, + scaling: spool::Scaling::Exact, + grayscale: a.opt_bool("grayscale")?.unwrap_or(false), + title, + options: options.into_iter().chain(print::media_option(paper)).collect(), + }) +} + impl Automation { + /// `printers`: the system's printers, the default marked, and each one's two-sided support + /// where the spooler reports it. pub(crate) fn printers(&self) -> Result { let list: Vec = spool::printers() .into_iter() @@ -53,6 +114,8 @@ impl Automation { Ok(json!({ "printer": printer, "count": list.len(), "options": list })) } + /// `doc_print`: lay the document out with the Print dialog's options, then save the + /// print-ready PDF to `path` or send it to `printer` with the chosen paper. pub(crate) fn doc_print(&mut self, a: &Args) -> Result { let doc = self.doc(a)?; let (id, count) = (doc.id, doc.info.pages.len()); @@ -108,7 +171,7 @@ impl Automation { }, l => return Err(bad(format!("unknown layout {l:?}"))), }; - let duplex = duplex(a.opt_str("duplex")?.unwrap_or("off"), matches!(layout, Layout::Multiple { order: PageOrder::CutStack, .. }))?; + let duplex = job_duplex(a.opt_str("duplex")?.unwrap_or("off"), &layout)?; if a.opt_str("order")? == Some("cut-stack") && !matches!(layout, Layout::Multiple { .. }) { return Err(bad("cut-stack order needs layout multiple")); } @@ -125,20 +188,7 @@ impl Automation { "form-fields-only" => Content::FormFieldsOnly, c => return Err(bad(format!("unknown comments_forms {c:?}"))), }; - let paper = match a.opt_str("paper")? { - None => PAPERS[0].1, - Some(p) => PAPERS - .iter() - .find(|(n, _)| { - n.eq_ignore_ascii_case(p) - || n.replace("US ", "").eq_ignore_ascii_case(p) - || n.split(" (").next().is_some_and(|name| name.eq_ignore_ascii_case(p)) - }) - .map(|x| x.1) - .ok_or_else(|| { - bad(format!("unknown paper {p:?}; choose one of {}", PAPERS.iter().map(|(name, _)| *name).collect::>().join(", "))) - })?, - }; + let (paper_index, paper) = paper_named(a.opt_str("paper")?)?; // Print as image (Acrobat's Advanced Print Setup): pictures at `dpi`, grey when // `grayscale` is also asked for. let raster = if a.opt_bool("as_image")?.unwrap_or(false) { @@ -162,27 +212,7 @@ impl Automation { out["path"] = json!(path.to_string_lossy()); } (None, Some(printer)) => { - let name = self.doc(a)?.name.clone(); - let job = spool::Job { - printer: if printer == "default" { None } else { Some(printer.to_string()) }, - copies: a.opt_int("copies")?.unwrap_or(1).clamp(1, 999) as u32, - collate: a.opt_bool("collate")?.unwrap_or(true), - duplex, - scaling: spool::Scaling::Exact, - grayscale: a.opt_bool("grayscale")?.unwrap_or(false), - title: name, - options: match a.get("options") { - None => Vec::new(), - Some(Value::Object(m)) => m - .iter() - .map(|(k, v)| match v.as_str() { - Some(v) => Ok((k.clone(), v.to_string())), - None => Err(bad(format!("options.{k} must be a string (a choice from printer_options)"))), - }) - .collect::>>()?, - Some(_) => return Err(bad("options must be an object of option keys and choices from printer_options")), - }, - }; + let job = spool_job(a, printer, self.doc(a)?.name.clone(), duplex, paper_index)?; out["job"] = json!(spool::submit(&bytes, &job).map_err(|e| failed(e.to_string()))?); } _ => return Err(bad("pass either path (save the print-ready PDF) or printer (a name from printers, or \"default\")")), @@ -195,6 +225,19 @@ impl Automation { mod tests { use super::*; + /// #2707: a booklet sets two-sided itself whatever `duplex` asks; a bad name is still an error. + #[test] + fn a_booklet_decides_two_sided_itself() { + let booklet = |subset| Layout::Booklet { subset, binding: Binding::Left }; + for asked in ["printer", "off", "long-edge", "short-edge"] { + assert_eq!(job_duplex(asked, &booklet(BookletSubset::BothSides)).ok(), Some(Some(spool::Duplex::ShortEdge)), "{asked}"); + assert_eq!(job_duplex(asked, &booklet(BookletSubset::FrontOnly)).ok(), Some(Some(spool::Duplex::Off)), "{asked}"); + assert_eq!(job_duplex(asked, &booklet(BookletSubset::BackOnly)).ok(), Some(Some(spool::Duplex::Off)), "{asked}"); + } + assert!(job_duplex("sideways", &booklet(BookletSubset::BothSides)).is_err()); + assert_eq!(job_duplex("long-edge", &Layout::Size(SizeMode::Fit)).ok(), Some(Some(spool::Duplex::LongEdge)), "other layouts keep it"); + } + /// `printer` leaves two-sided to the printer's own default (#2422); cut and stack still asks /// for single-sided sheets, and refuses an explicit edge. #[test] @@ -207,4 +250,30 @@ mod tests { assert!(duplex("long-edge", true).is_err()); assert!(duplex("sideways", false).is_err()); } + + /// #3752: `doc_print` to a printer sent no paper, so the queue's default paper stood in for + /// the one the sheets were laid out on (A4 under Letter sheets on the report's queue). + #[test] + fn printing_to_a_printer_feeds_the_laid_out_paper() { + let job = |args: Value, paper_name: Option<&str>| { + let (index, _) = paper_named(paper_name).unwrap(); + spool_job(&Args(&args), "Office", "a.pdf".into(), None, index).unwrap() + }; + let media = |m: &str| ("media".to_string(), m.to_string()); + // US Letter when no paper is asked for, after the driver's own options. + let letter = job(json!({ "options": { "InputSlot": "Tray3" } }), None); + assert_eq!(letter.options, [("InputSlot".to_string(), "Tray3".to_string()), media("Letter")]); + assert_eq!((letter.printer.as_deref(), letter.title.as_str()), (Some("Office"), "a.pdf")); + // Every paper, by its full name, goes out as its own media name. + for (i, (name, size)) in PAPERS.iter().enumerate() { + assert_eq!(paper_named(Some(name)).ok(), Some((i, *size)), "{name}"); + assert_eq!(job(json!({}), Some(name)).options, print::media_option(i).into_iter().collect::>(), "{name}"); + } + // Short forms find the same paper. + assert_eq!(job(json!({}), Some("letter")).options, [media("Letter")]); + assert_eq!(job(json!({}), Some("ARCH D")).options, [media("na_arch-d_24x36in")]); + assert!(matches!(paper_named(Some("Quarto")), Err(ToolError::InvalidArgs(m)) if m.contains("US Letter"))); + assert_eq!(spool_job(&Args(&json!({})), "default", String::new(), None, 0).ok().map(|j| j.printer), Some(None)); + assert!(spool_job(&Args(&json!({ "options": { "InputSlot": 3 } })), "Office", String::new(), None, 0).is_err()); + } } diff --git a/crates/automation/src/signing.rs b/crates/automation/src/signing.rs index c7a043c89..743a36ff5 100644 --- a/crates/automation/src/signing.rs +++ b/crates/automation/src/signing.rs @@ -189,7 +189,7 @@ impl Automation { return Err(failed(format!("windows:{r}: Windows certificate store identities are only available on Windows"))); } let path = self.resolve(a.str("id")?, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = crate::read_input(&path)?; sign::pkcs12::open(&bytes, a.opt_str("password")?.unwrap_or("")).map_err(|e| match e { sign::SignError::WrongPassword => bad("the digital ID password is incorrect"), e => failed(e), @@ -320,7 +320,7 @@ impl Automation { }; for p in paths { let path = self.resolve(&p, false)?; - let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; + let bytes = crate::read_input(&path)?; let lower = p.to_ascii_lowercase(); let found = if lower.ends_with(".p12") || lower.ends_with(".pfx") { let id = sign::pkcs12::open(&bytes, a.opt_str("password")?.unwrap_or("")).map_err(failed)?; diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index ebc5796dc..48e9c45c6 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -543,7 +543,7 @@ pub fn tools() -> Vec { t( "doc_print", "Print", - "Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed; default all); subset odd/even (by page number, as Acrobat counts them: pages 2, 7-10 with even prints 2, 8 and 10, not every second page of the range); reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, A0/A1/A2/A3/A4/A5/A6, or US #10 Envelope (short names or full preset labels); use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).", + "Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed; default all); subset odd/even (by page number, as Acrobat counts them: pages 2, 7-10 with even prints 2, 8 and 10, not every second page of the range); reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right; a booklet sets two-sided itself, as Acrobat does: both flips on the short edge, front or back prints one-sided), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, A0/A1/A2/A3/A4/A5/A6, or US #10 Envelope (short names or full preset labels); use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).", ) .with(schema( json!({ diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 7e073b757..d1f1b80db 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -8753,3 +8753,53 @@ fn bookmark_list_reports_colour_and_style() { let re = ok(&mut b, "doc_open", json!({ "path": "restyled.pdf" }))["doc"].as_u64().unwrap(); assert_eq!(style(&ok(&mut b, "bookmark_list", json!({ "doc": re }))["bookmarks"]), style(&set), "saved"); } + +/// `page_render`'s pixels show a rubber stamp written without an appearance as the stamp its +/// `/Name` names (ISO 32000-2 Table 184), and one with an unknown name as a grey placeholder. +#[test] +fn page_render_draws_stamps_without_an_appearance() { + let dir = workdir("stamps-without-appearance"); + let objects: Vec<&[u8]> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 100] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + b"<< /Type /Annot /Subtype /Stamp /Rect [10 20 130 80] /Name /Approved >>", + b"<< /Type /Annot /Subtype /Stamp /Rect [170 20 290 80] /Name /MyCustomStamp >>", + ]; + 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 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", objects.len() + 1).as_bytes()); + std::fs::write(dir.join("stamps.pdf"), bytes).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "stamps.pdf" }))["doc"].as_u64().unwrap(); + let (width, _, pixels) = a.render_pixels(doc, 1, 1.0).unwrap(); + let pixels = pixels.as_ref(); + // Ink in columns `x0..x1` of rows 20..80, which are the stamps' rows whether counted from the top or the bottom. + let ink = |x0: u32, x1: u32| -> Vec<[u8; 3]> { + (20..80u32) + .flat_map(|y| (x0..x1).map(move |x| (y, x))) + .map(|(y, x)| { + let i = ((y * width + x) * 4) as usize; + [pixels[i], pixels[i + 1], pixels[i + 2]] + }) + .filter(|p| p.iter().any(|v| *v < 200)) + .collect() + }; + let approved = ink(10, 130); + let green = approved.iter().filter(|p| p[1] > p[0] && p[1] > p[2]).count(); + assert!(green > 300 && green * 10 > approved.len() * 9, "Approved is drawn green: {green} of {}", approved.len()); + let custom = ink(170, 290); + let grey = custom.iter().filter(|p| p[0].abs_diff(p[1]) < 24 && p[1].abs_diff(p[2]) < 24).count(); + assert!(grey > 100 && grey == custom.len(), "an unknown name is a grey placeholder: {grey} of {}", custom.len()); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9f169171a..ebb53d339 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -2979,6 +2979,7 @@ fn comment_list(doc: &pdfcraft_cos::Document) -> Vec Vec<[u8; 4]> { + (80..120).flat_map(|y| (200..240).map(move |x| (x, y))).map(|(x, y)| stamp_pixel(rendered, x, y)).collect() + }; + let mut upright = None; + for degrees in [0, 90, 180, 270] { + let (mut session, id) = rotated_blank(degrees); + let info = session.get(id).unwrap().info.pages[0].clone(); + let [x, y] = info.view_to_user(100.0, 40.0); + let shape = Shape::Note { at: [f64::from(x), f64::from(y)], icon: NoteIcon::Comment }; + let style = Style::default_for(&shape); + session.apply(id, Edit::AddAnnotation(NewAnnotation { page: 0, style, shape, contents: "Turned".into(), author: "Test".into() })).unwrap(); + let mut check = |session: &Session, id: DocId| { + let doc = session.get(id).unwrap(); + let info = &doc.info.pages[0]; + let note = &doc.info.annotations[0]; + assert!(note.no_rotate, "rotation {degrees}: notes are saved NoRotate"); + let shown = note.shown_rect(info.rotation); + let (a, b) = (info.user_to_view(shown[0], shown[1]), info.user_to_view(shown[2], shown[3])); + let shown = [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]; + assert!(shown.iter().zip([100.0, 40.0, 120.0, 60.0]).all(|(v, w)| (v - w).abs() < 0.01), "rotation {degrees}: shown at {shown:?}"); + let icon = crop(&render_page(session, id)); + let reference = upright.get_or_insert_with(|| icon.clone()); + assert!(reference.iter().filter(|c| c.iter().any(|v| *v < 200)).count() > 100, "the note's icon is drawn"); + let off = icon.iter().zip(reference.iter()).filter(|(a, b)| a.iter().zip(b.iter()).any(|(a, b)| a.abs_diff(*b) > 48)).count(); + assert!(off <= 8, "rotation {degrees}: {off} of the icon's {} pixels differ from the unturned page's", icon.len()); + }; + check(&session, id); + let saved = session.save_bytes(id).unwrap(); + let mut reopened = Session::new().with_clock(|| 1_700_000_000); + let again = reopened.open("drawing.pdf", None, saved, None).unwrap(); + check(&reopened, again); + } +} + /// #2392: Fill & Sign marks are drawn as displayed on every page rotation, including after save: /// a checkmark's long stroke rises to the right, and a line runs across. #[test] @@ -6268,6 +6309,113 @@ fn comments_without_appearances_are_drawn_but_not_saved() { } } +/// A stamp's `/Rect` and the colour family expected inside it. +type StampRect = ([f64; 4], char); + +/// Rubber stamps without an appearance (ISO 32000-2 Table 184): the fourteen standard names, one +/// without `/Name`, one custom name, and last a Draft stamp with artwork of its own (magenta). +/// Each sits in a 120 × 60 `/Rect` on a 612 × 792 page; the name and the expected colour family +/// (`'r'`, `'g'`, `'b'`, or `'n'` for the neutral placeholder) of each, in page order. +fn stamps_without_appearances() -> (Arc>, Vec) { + let stamps: [(Option<&str>, char); 16] = [ + (Some("Approved"), 'g'), + (Some("Experimental"), 'b'), + (Some("NotApproved"), 'r'), + (Some("AsIs"), 'b'), + (Some("Expired"), 'r'), + (Some("NotForPublicRelease"), 'r'), + (Some("Confidential"), 'r'), + (Some("Final"), 'g'), + (Some("Sold"), 'g'), + (Some("Departmental"), 'b'), + (Some("ForComment"), 'b'), + (Some("TopSecret"), 'r'), + (Some("Draft"), 'b'), + (Some("ForPublicRelease"), 'b'), + (None, 'b'), + (Some("MyCustomStamp"), 'n'), + ]; + let mut objs: Vec = + vec!["<< /Type /Catalog /Pages 2 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(), String::new()]; + let mut expected = Vec::new(); + for (i, (name, family)) in stamps.iter().enumerate() { + let (x, y) = (30 + (i % 4) * 145, 660 - (i / 4) * 150); + let rect = [x as f64, y as f64, x as f64 + 120.0, y as f64 + 60.0]; + let name = name.map(|n| format!("/Name /{n}")).unwrap_or_default(); + objs.push(format!("<< /Type /Annot /Subtype /Stamp /Rect [{} {} {} {}] {name} /F 4 >>", rect[0], rect[1], rect[2], rect[3])); + expected.push((rect, *family)); + } + let own = objs.len() + 2; + objs.push(format!("<< /Type /Annot /Subtype /Stamp /Rect [30 60 150 120] /Name /Draft /F 4 /AP << /N {own} 0 R >> >>")); + expected.push(([30.0, 60.0, 150.0, 120.0], 'm')); + objs.push("<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] /Length 23 >>\nstream\n1 0 1 rg 0 0 10 10 re f\nendstream".into()); + let annots: Vec = (4..own).map(|n| format!("{n} 0 R")).collect(); + objs[2] = format!("<< /Type /Page /Parent 2 0 R /Annots [{}] >>", annots.join(" ")); + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + (Arc::new(bytes), expected) +} + +/// The colour family of an RGBA pixel that isn't white: `'r'`, `'g'` or `'b'` for the dominant +/// channel, `'n'` for a grey, `'m'` for magenta. +fn colour_family(p: &[u8]) -> Option { + let (r, g, b) = (i32::from(p[0]), i32::from(p[1]), i32::from(p[2])); + if r.min(g).min(b) > 200 { + return None; + } + Some(if (r - g).abs() < 24 && (g - b).abs() < 24 { + 'n' + } else if r > 200 && b > 200 && g < 80 { + 'm' + } else if r > g && r > b { + 'r' + } else if g > r && g > b { + 'g' + } else { + 'b' + }) +} + +/// Rubber stamps without an appearance are drawn from their `/Name` (ISO 32000-2 Table 184), in +/// the colour of the stamp they name; an unknown name gets a grey placeholder; a stamp's own +/// artwork is drawn as it is. Display only: Save writes the file without new appearances. +#[test] +fn stamps_without_appearances_draw_the_stamp_they_name() { + let (original, expected) = stamps_without_appearances(); + let mut s = Session::new(); + let id = s.open("stamps.pdf", None, original.clone(), None).expect("opens"); + let p = shown(&s, id, 0); + for (i, (rect, family)) in expected.iter().enumerate() { + let mut counts = std::collections::HashMap::new(); + for y in (792.0 - rect[3]) as u32..(792.0 - rect[1]) as u32 { + for x in rect[0] as u32..rect[2] as u32 { + let px = &p.rgba[((y * p.width + x) * 4) as usize..][..4]; + if let Some(f) = colour_family(px) { + *counts.entry(f).or_insert(0) += 1; + } + } + } + let total: u32 = counts.values().sum(); + let main = counts.iter().max_by_key(|(_, n)| **n).map(|(f, _)| *f); + assert!(total > 300, "stamp {i} draws ink inside its /Rect: {counts:?}"); + assert_eq!(main, Some(*family), "stamp {i} is drawn in its colour: {counts:?}"); + } + let saved = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap(); + for n in 4..20 { + assert!(saved.get(pdfcraft_cos::ObjRef::new(n, 0)).as_dict().is_some_and(|d| !d.contains(b"AP")), "{n} is saved without /AP"); + } +} + /// A page with a FreeText comment that has no `/AP` stream: the smallest case behind #2371. fn free_text_without_appearance() -> Arc> { let objs: Vec<&[u8]> = vec![ @@ -7986,3 +8134,53 @@ fn buttons_and_combs_left_to_the_viewer_are_displayed() { 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/organize/src/import.rs b/crates/organize/src/import.rs index 1162a810c..3e56c0d53 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)); } @@ -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() { diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index b5c448882..85c319915 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -931,9 +931,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 +1051,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 +1076,10 @@ 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)); } #[test] @@ -2850,8 +2951,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 +2970,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 diff --git a/crates/print/README.md b/crates/print/README.md index ae60e1a2c..1379be2b4 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 7605f51c9..b731aff00 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 @@ -839,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) { @@ -871,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 d03f31a3c..f0d8939da 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 e813f4820..bc0454c99 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)); @@ -989,3 +1051,11 @@ fn invisible_annotations_of_unknown_types_do_not_print() { 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 acf8ece6f..0d3107d1d 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/render/src/inspect.rs b/crates/render/src/inspect.rs index 81fad6b0b..26969396a 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -436,6 +436,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]>, @@ -446,6 +450,58 @@ impl Annotation { pub fn is_mark(&self) -> bool { matches!(self.state.as_deref(), Some("Marked" | "Unmarked")) } + + /// 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] } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -1356,6 +1412,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)); @@ -1864,6 +1921,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 => { @@ -2319,6 +2387,54 @@ 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, + 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] diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index a04d01720..90b81f30a 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -3181,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 diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index d9669f08e..b522a855e 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 55aa732d7..d71e616ce 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/canvas.rs b/crates/ui-egui/src/canvas.rs index 54f426582..62918fe0a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -665,6 +665,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, @@ -1126,6 +1128,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()) @@ -1328,10 +1359,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 +1385,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 => { @@ -1514,6 +1552,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() @@ -2658,9 +2707,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 +2732,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. @@ -4032,7 +4086,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) }); @@ -4756,6 +4810,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 @@ -4833,6 +4889,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; @@ -4918,6 +4975,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; } @@ -4931,6 +4992,9 @@ fn notices( if unlock { return Some(Notice::Unlock); } + if hide_secured { + return Some(Notice::HideSecured); + } open_security.then_some(Notice::Security).or(toggled) } @@ -5494,6 +5558,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(); @@ -5750,6 +5818,7 @@ mod tests { locked: false, intent: None, fill_sign, + no_rotate: false, points: Vec::new(), } } @@ -6842,6 +6911,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 5e8de9375..2723d16c2 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 92bc0b360..f908cd423 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -669,6 +669,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 c832c2c1a..2d2571863 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,15 @@ 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) +} + 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 +1571,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 +1592,15 @@ 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). + if !viewport.contains(anchor) { + c.focus = true; + return None; + } 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 +1846,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 +1913,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.in_reply_to.is_none())); match selected { @@ -1851,7 +1931,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 +2006,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 } @@ -2406,6 +2496,7 @@ mod tests { intent: None, fill_sign: false, points: Vec::new(), + no_rotate: false, }], ..Default::default() }; @@ -2415,7 +2506,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 ff8570456..6c13d78d3 100644 --- a/crates/ui-egui/src/comments_panel.rs +++ b/crates/ui-egui/src/comments_panel.rs @@ -264,7 +264,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); } } @@ -276,6 +277,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, @@ -442,7 +445,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()); @@ -585,6 +588,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/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6f6bf41cf..1099030ea 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -594,15 +594,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 +629,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; } diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 701a82d9d..7ed6e0795 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -265,6 +265,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), diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index aa2aad02d..0b031c8a8 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/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index dcfd48c14..51a9909e1 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -2041,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 لقياسه. @@ -2194,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} من الأخيرة @@ -2341,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 إعادة تحميل diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index bcb5ae3a3..8ceb68323 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1903,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 файл, за да го измервате. @@ -2179,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} от последно отваряните @@ -2283,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 Презареди diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index b7e169348..2675eb826 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2001,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. @@ -2138,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} @@ -2778,3 +2784,6 @@ theme Appearance Aspecte # 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 diff --git a/crates/ui-egui/src/i18n/ckb.tsv b/crates/ui-egui/src/i18n/ckb.tsv index f050fdf23..b851185cc 100644 --- a/crates/ui-egui/src/i18n/ckb.tsv +++ b/crates/ui-egui/src/i18n/ckb.tsv @@ -2004,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ێک بکەرەوە بۆ پێوانەکردنی. @@ -2304,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} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 7a8394719..ca7415b55 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 @@ -2088,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. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 0e42d2cc3..43b03a217 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1977,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. @@ -2187,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 @@ -2325,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 diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 5cbb1bc06..02100a0b5 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 Κλειστή αιχμή βέλους @@ -2329,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 Επαναφόρτωση diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4100bc4e0..a5ccea617 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2057,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. @@ -2161,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 @@ -2194,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 diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index ff0e0a47e..dcf2ecad2 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -1979,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ä. @@ -2269,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} @@ -2497,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. @@ -2517,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 ({}). @@ -2697,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 @@ -2787,3 +2793,6 @@ theme Appearance Teema # 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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 1304a42ca..81e838759 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1972,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. @@ -2042,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 diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 399aa56cd..4740cbac3 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -1962,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. मापने के लिए एक पीडीएफ़ खोलें। @@ -2251,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} @@ -2754,3 +2760,6 @@ 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 दस्तावेज़ फिर से खोलने पर पिछला दृश्य (पृष्ठ, ज़ूम और पृष्ठ प्रदर्शन) पुनर्स्थापित करें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 5259cfe1a..4e74554c9 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1962,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. @@ -2171,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 @@ -2319,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 diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 329ef63fb..2610c125a 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 @@ -1391,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 @@ -2755,3 +2761,6 @@ theme Appearance Tampilan # 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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 06751c50a..7730d91b4 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2048,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. @@ -2209,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 @@ -2329,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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 5ce6439c5..8254d4592 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2055,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を開いてください。 @@ -2107,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 ビューアーではこの値からフィールドが再描画されます。 @@ -2320,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 再読み込み diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index f9d91fc8d..67e72b2ca 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -1963,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 құжатын ашыңыз. @@ -2114,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} дегенді соңғылардан алып тастау @@ -2319,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 Қайта жүктеу diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index cf7619e33..e9ec77c8b 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1962,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를 여세요. @@ -2251,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} @@ -2754,3 +2760,6 @@ 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 문서를 다시 열 때 마지막 보기(페이지, 확대/축소, 페이지 표시) 복원 diff --git a/crates/ui-egui/src/i18n/lo.tsv b/crates/ui-egui/src/i18n/lo.tsv index 64d7adee1..0084cdc2f 100644 --- a/crates/ui-egui/src/i18n/lo.tsv +++ b/crates/ui-egui/src/i18n/lo.tsv @@ -2015,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 ເພື່ອວັດແທກ. @@ -2280,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} diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index ec4b259cb..cd4f6ac35 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į… @@ -1450,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 diff --git a/crates/ui-egui/src/i18n/nb.tsv b/crates/ui-egui/src/i18n/nb.tsv index 3c3f84112..5930aff85 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 @@ -1402,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 @@ -2698,3 +2704,6 @@ key Space Mellomrom # 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 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 7501c59fc..a6d7e98f2 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2035,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. @@ -2086,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. @@ -2300,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 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index bc5df4c75..3882788ce 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 @@ -1775,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. @@ -2322,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 diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index b9a757a2d..50d05a134 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ă @@ -1400,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 @@ -2778,3 +2784,6 @@ theme Appearance Aspect # 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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 2d4771fe1..09e5e9ecf 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2042,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 для измерения. @@ -2109,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 Перезагрузить diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 1b42065d6..39071fa9c 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 @@ -2326,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 diff --git a/crates/ui-egui/src/i18n/sq.tsv b/crates/ui-egui/src/i18n/sq.tsv index 85e6ea858..c2a792cd7 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 @@ -2264,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} @@ -2504,3 +2510,6 @@ animation effects Off Joaktiv # 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 diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 5b876332c..1f90d6a04 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -1961,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ను తెరవండి. @@ -2028,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 మళ్లీ లోడ్ చేయి diff --git a/crates/ui-egui/src/i18n/th.tsv b/crates/ui-egui/src/i18n/th.tsv index 00fef19fd..9aa0375d2 100644 --- a/crates/ui-egui/src/i18n/th.tsv +++ b/crates/ui-egui/src/i18n/th.tsv @@ -1985,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 เพื่อวัดระยะ @@ -2227,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} @@ -2685,3 +2691,6 @@ 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 คืนค่ามุมมองล่าสุด (หน้า การซูม และการแสดงหน้า) เมื่อเปิดเอกสารอีกครั้ง diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 1f5fbecad..c413a309b 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1935,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. @@ -2140,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 @@ -2317,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 diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 262a12392..549f7bc56 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2039,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 для вимірювання. @@ -2192,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} з нещодавніх @@ -2342,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 Перезавантажити diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 9e83da383..57ef46222 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ệ @@ -1440,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 @@ -2784,3 +2790,6 @@ key Space Space # 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 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index fcae06b8b..0c3c5feab 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1911,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 以进行测量。 @@ -2143,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 重新加载 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 5fa52c033..8ad1c0ed3 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2042,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 以進行測量。 @@ -2094,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 閱讀器會依據該值重新繪製此欄位。 @@ -2194,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 重新載入 diff --git a/crates/ui-egui/src/js_ui.rs b/crates/ui-egui/src/js_ui.rs index ecc87736d..0079423f6 100644 --- a/crates/ui-egui/src/js_ui.rs +++ b/crates/ui-egui/src/js_ui.rs @@ -408,9 +408,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 000000000..13012b1d6 --- /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 3d0773b1d..21aa10d55 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; @@ -584,6 +585,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 +597,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, @@ -965,7 +974,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, @@ -991,9 +1001,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(), @@ -1412,7 +1425,9 @@ impl PdfCraftApp { 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() { @@ -2015,6 +2030,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()) { @@ -2378,6 +2394,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, @@ -2438,6 +2456,7 @@ 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["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)); @@ -2476,6 +2495,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; @@ -2483,6 +2506,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); @@ -2911,6 +2937,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, @@ -3036,6 +3070,8 @@ impl PdfCraftApp { { 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. @@ -3134,6 +3170,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() { diff --git a/crates/ui-egui/src/measure_ui.rs b/crates/ui-egui/src/measure_ui.rs index f91e4bf3f..658457499 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(); @@ -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/panels.rs b/crates/ui-egui/src/panels.rs index 7d16bc340..e5dd30a8c 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -1410,6 +1410,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/print_ui.rs b/crates/ui-egui/src/print_ui.rs index cab6bfc23..fcc8d89d9 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 68d82922c..128e57604 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 ea76a6991..442cd6d8e 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 eacf854c2..d02ea5dc8 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/comments.rs b/crates/ui-egui/tests/it/comments.rs index 68b41ac98..9f142c8f7 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -665,6 +665,88 @@ 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")); +} + +/// 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 +932,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. @@ -1255,6 +1381,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/control.rs b/crates/ui-egui/tests/it/control.rs index 5a6869b06..f64485293 100644 --- a/crates/ui-egui/tests/it/control.rs +++ b/crates/ui-egui/tests/it/control.rs @@ -1425,6 +1425,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; diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 9fc62ea51..34260ef02 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -2285,6 +2285,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 { @@ -2392,6 +2474,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] diff --git a/crates/ui-egui/tests/it/pageboxes.rs b/crates/ui-egui/tests/it/pageboxes.rs index 865f12770..2e2829e05 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 bbab087d9..6aa551552 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/print.rs b/crates/ui-egui/tests/it/print.rs index ae325eaaa..21aeca128 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 6e86c5099..1559838dd 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 14f34086c..758aa1a27 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 275cdab76..c15a5c869 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 3520f0a98..b825968ff 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 8052e6a31..c655f4d04 100644 --- a/crates/ui-egui/tests/it/viewer.rs +++ b/crates/ui-egui/tests/it/viewer.rs @@ -1387,7 +1387,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/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 8421c6554..46c243531 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -3489,7 +3489,6 @@ fn rendering_keeps_the_forms_own_font_objects() { 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] diff --git a/docs/localization.md b/docs/localization.md index 880036237..90c669892 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/roadmap.md b/docs/roadmap.md index da524af9d..6b079d12f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -7,6 +7,7 @@ > **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) @@ -161,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. | @@ -192,7 +193,7 @@ total matches the parity estimate. | 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. 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`. 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. | +| 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. | @@ -255,6 +256,8 @@ 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. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c12461793..703f1e9d8 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/parity/acrobat-features.toml b/parity/acrobat-features.toml index a64c414d4..fb02d29a6 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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" @@ -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", "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"] -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); check boxes and radio buttons with no appearance for their /AS are drawn display-only from /MK (background, border, and when on the /MK /CA ZapfDingbats symbol in the /DA colour, a check or a dot by default), and push buttons with none from /MK and /BS with their caption; Link borders without /AP and printing are not covered yet; an annotation of a non-standard type with the Invisible flag (ISO 32000-2 Table 167) is neither drawn nor printed, and a popup's own /AP is never painted on the page (its parent's text shows in the popup window, §12.5.6.14)" +evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/render/src/raster.rs::highlights_without_an_appearance_are_drawn", "crates/engine/src/tests.rs::comments_without_appearances_are_drawn_but_not_saved", "crates/engine/src/tests.rs::cli_display_adds_missing_appearances_without_changing_the_source", "crates/engine/src/tests.rs::fields_left_to_the_viewer_are_displayed_from_their_values", "crates/forms/src/tests.rs::need_appearances_draws_every_text_and_choice_field_in_the_display_copy", "crates/forms/src/tests.rs::the_field_appearance_cache_sees_every_input", "crates/engine/src/tests.rs::buttons_and_combs_left_to_the_viewer_are_displayed", "crates/forms/src/tests.rs::check_boxes_without_an_appearance_show_their_caption_symbol", "crates/forms/src/tests.rs::push_buttons_without_an_appearance_show_their_caption_and_frame", "crates/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" @@ -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" @@ -2799,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" @@ -2931,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]] @@ -3583,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" @@ -3595,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" @@ -3982,6 +3994,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" @@ -4243,8 +4267,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" @@ -4267,7 +4291,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]] @@ -7976,8 +8000,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" @@ -8084,8 +8108,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" @@ -8120,8 +8144,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 29964d444..77bb7773d 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -17,7 +17,7 @@ Other resource categories retain their existing parent-chain lookup. | Crate | Version | Licence | Patches | Test | |---|---|---|---|---| -| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.notdef` for a CID of an Adobe character collection (Japan1, GB1, CNS1, Korea1) that it can't find by Unicode: hayro took the CID for a glyph index of the substitute, which numbers its glyphs its own way, so the character was drawn as an unrelated one (𠮷 as "Q" in BIZ UDPGothic); (22) a Type 0 font drawn with a substitute finds a character given with a variation selector (`/ToUnicode`, or Adobe-Japan1-UCS2, which maps the JIS X 0208 forms of the kanji JIS X 0213:2004 redrew that way: CID 1247 is 噂 U+E0100) through the substitute's variation sequences, else as the character alone: such a CID found no glyph and was drawn as an unrelated one; (23) backport of hayro 0.8's `gradient` module: `EncodedShadingPattern::as_svg_gradient` turns an axial or radial shading into stops of an sRGB gradient (within 0.01 per channel, a stop at every stitching-function bound via `Function::stitching_bounds`) when a native gradient draws it exactly (extended past the box drawn in; a radial one with no background colour); (24) `DecodedImageCache`: decoded image pixels shared across renders through the interpreter cache within a 64 MiB budget (an image-heavy page decoded its thousands of images again per tile and per scale), keyed by every decode input (`DecodedImageKey`: the stream's place and dictionary bytes, its object, mask/stencil/raster output, interpolation, decode target size, preview or final); images under a transfer function and inline images are not cached, an image over the budget is never kept, and a poisoned lock decodes uncached; (25) `Device::actual_text` hands the device a marked content sequence's `/ActualText` (inline or named in `/Properties`), so text extraction keeps the Thai, Indic and Arabic clusters Chrome marks this way (their shaped tone marks and vowels have no Unicode of their own); (26) non-embedded simple fonts drawn with a standard-font stand-in build the Latin Extended-A letters the stand-in lacks (`/Gbreve`, `/Idotaccent`, `/Scedilla`, `/aogonek`, `/ccaron`…) from the base letter and accent it has (`StandardKind::composite_glyph`, `ACCENTED_LETTERS`): PDFium's Foxit stand-ins hold only the Standard/WinAnsi/Mac Roman glyphs, so iText letters with Times New Roman unembedded showed blanks for Ğ, İ, Ş, ğ and ş; the comma-below letters (`/scommaaccent`, `/Tcommaaccent`, `/kcommaaccent`…, found by character, so `/uni0136` and the glyph list's `/Kcedilla` too) are the letter and the font's comma, smaller and under the baseline, since the stand-ins have no `commaaccent` (Romanian ș drew nothing and ț a cedilla in forms laid out from XFA; ģ, whose comma is turned and above, is not built); (27) an embedded TrueType font without a base encoding reads its codes as WinAnsi for text extraction (`EmbeddedKind::code_to_unicode_name`), matching pdftotext's output, instead of Standard: OCR text layers of scans write WinAnsi codes with no `/Encoding`, and Standard has no ü, ö or ß and reads é as Ø (glyph selection unchanged); (28) an image whose pixels hold at most 16 bits of samples (an 8-bit Separation or DeviceN image, a 1-, 2- or 4-bit gray or indexed one) is converted once per possible pixel value through a lookup table instead of once per pixel (`rgb_via_lookup`): a spot-colour tint transform is often a PostScript calculator program, run for every pixel, so a fillable character sheet's 3304 × 4105 DeviceN background took four seconds and 430 MB to draw a page; each entry is converted on its own (`convert_each`), so a tint transform giving the wrong number of components for a value the image never uses cannot shift the colours of the values it does; (29) `Device::begin_annotations`/`end_annotations` bracket the page's annotations, so a device can tell comment paints from document paints (reading colours recolour the document, never a comment, #1548); (30) the Annex D.2 alias codes read as the ink they draw: WinAnsiEncoding 0xA0 and 0xAD, and MacRomanEncoding 0xCA, carry the glyph names `space` and `hyphen` -- the names those tables also give 0x20 and 0x2D -- not `nbspace` and `sfthyphen`, whose glyph list values U+00A0 and U+00AD are invisible formatting characters. Extraction hid drawn ink behind them, so search and redaction matched neither (`XiaoBiaoSong.pdf` in the pdf.js corpus drew 104 soft hyphens; across the 989 files the soft hyphens we extract fall from 117 to 13 and the non-breaking spaces from 30 to 26, what remains coming from `/ToUnicode` maps, which say so explicitly). The base table only, in the text direction only: a `/Differences` name keeps its glyph list value and glyph lookup is untouched, both as pdftotext does; (31) a Type 0 sampled function keeps its samples in one flat array indexed by strides (`Type0::entry`) instead of a hash map keyed by each grid point: the map was built again, one hashed insert per sample, every time a shading using the function was painted (a 2,097,152-sample ramp painted 30 times took 38 s; `bug1721218` in the pdf.js corpus spent most of a 1.5 s page on it); the interpolation is unchanged, so results are bit for bit the same, a sample missing from a short stream still gives no value, and the maximum sample value is computed without overflowing at 32 bits per sample; (32) the decoded-image cache (24) keys a decode by the size the image is drawn at only when a JPX stream decodes it or its soft mask or mask (`decode_reads_target`): JPX is the only filter that decodes a reduced resolution for a size, so a JPEG or Flate scan was decoded again at every zoom step and kept once per zoom level; one decode now serves every size; `InterpreterCache::decoded_image_decodes` counts the cache's decodes; (33) Coons and tensor-product patches copy their vertices' colours less: each point of a patch's 20 × 20 triangle grid is interpolated once (`generate_patch_triangles`; each cell interpolated its four corners itself, 1,444 interpolations a patch for 400 points), a cell's second triangle takes the vertices its first copied, and the mesh sampler moves a patch's triangles into device space instead of copying each vertex (`TriangleSampler::sample_owned`); the same values, 10–16% faster on mesh-gradient pages; (34) `color::tint_transform_runs`: how many colours the calling thread has run through a Separation or DeviceN tint transform (counted once per batch in `convert_f32`), so a test counts patch (28)'s once-per-sample-value runs instead of timing them (its 10 s deadline failed on a busy FreeBSD VM in CI); (35) `EncodedShadingPattern::extent`: where a mesh shading (types 4–7) can paint at all, the pixels its triangles touched when it was encoded (`None` for the types that paint everywhere, and when the shading has a `/Background`), so the renderer samples a texture there alone and skips a mesh with nothing on the canvas; (36) a mesh shading's pixel whose corner no triangle holds strictly (on an edge two triangles share, or on the mesh's side) takes the triangle that covers its centre, edges included (`Triangle::covers`): testing only the corner left a white seam along every shared edge, and the colour is still taken at the corner; (38) the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource dictionary (ISO 32000-2 §8.6.5.6) replace the device space content selects: with `g`/`G`, `rg`/`RG`, `k`/`K` and `cs`/`CS` (by name, or through a `/ColorSpace` resource that is a device space), and as the `/ColorSpace` of an image XObject, an inline image or a shading painted by `sh` or as a shading pattern (`DefaultColorSpaces`, looked up once per content window in the resources and those they inherit, so a form XObject without a default of its own draws in its page's, as Poppler and MuPDF draw it); a default that is a Lab, Indexed, Pattern or device space, has another number of components or is no colour space is ignored and device colour is drawn as before; device spaces inside other spaces (an Indexed base, a Separation or DeviceN alternate, a Pattern's underlying space), shading dictionaries, masks and transparency group spaces are unchanged, and the decoded-image cache (24) keys a decode by the default it was drawn in. hayro drew device colour as it was, so pages that calibrate it this way (common in PDF/A) came out in the wrong colours; (39) knockout groups (#2971): `Device::set_knockout` right after a `/K true` group is pushed; `Device::knockout_image` for an unmasked image before its group is pushed, when `Device::in_knockout_group`: the device clears its placement rectangle only if it will draw the image (on the canvas, not a preview's tiny image, and it decodes), decoded at its draw size so the draw reuses that decode; and, when `Device::knockout_erase_wanted`, a nested group's content drawn once between `begin_knockout_erase` and `end_knockout_erase` before the group itself, which then always gets a layer of its own; (40) an optional content membership dictionary's visibility expression (`/VE`: `/And`, `/Or`, `/Not`, nested up to 32 deep) decides its visibility ahead of `/OCGs` and `/P` (ISO 32000-2 §8.11.2.2); it was ignored, so content under it stayed visible with its layers hidden; a malformed expression, including one naming an object the document doesn't list in `/OCProperties /OCGs`, falls back to `/OCGs` and `/P` (#2986); (41) a transparency group that is neither isolated nor knockout, entered at full opacity with no soft mask and Normal blending, is drawn straight onto the page instead of into a layer of its own (§11.4.7: its elements composite with the backdrop; the layer made a `/BM /Multiply` highlight appearance an opaque block, #1968); (42) text in a Type 3 font is drawn filled whatever the text rendering mode, except mode 3 (invisible): only that mode has any effect on Type 3 fonts (ISO 32000-1 §9.3.6). Stroking ran the glyph procedure a second time, so `2 Tr` made bitmap fonts look bold, `1 Tr` painted an uncoloured glyph in the stroking colour, and `7 Tr` hid it; (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` | (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); (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) | +| 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); (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 | (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); (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) | | 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-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer); (4) a segment header's long-form referred-to segment count is capped at `u16::MAX` and a larger count is refused before its retention or referred-to bytes are read: the 29-bit count went straight to `Vec::with_capacity`, a reservation of about 2 GiB whose failure aborts the process (from storytold/craft-libs#176) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate`; (4) `segment::tests::test_referred_to_count_is_capped` (`cargo test --manifest-path vendor/hayro-jbig2/Cargo.toml`) | | lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order; (6) `find_latest_trailer`: a rebuild takes a `/Type /XRef` stream dictionary as the trailer when the file has no `trailer` keyword (the latest in the file wins, as a later `trailer` would), so a concatenated or truncated file keeps its /Info and /ID instead of failing to load; a candidate whose /Root is the catalog beats one whose /Root is another dictionary, and an in-file /Root must match its generation (#2365) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace`; (6) `inspect::tests::rebuilt_file_keeps_the_info_of_its_cross_reference_stream`, `inspect::tests::rebuilt_file_prefers_a_trailer_whose_root_is_the_catalog` | diff --git a/vendor/hayro-interpret/src/interpret/mod.rs b/vendor/hayro-interpret/src/interpret/mod.rs index 20506b052..cf71eb92b 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; @@ -262,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, @@ -331,6 +338,31 @@ fn is_standard_annotation_type(subtype: &[u8]) -> bool { ) } +/// 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