Merge integration batch B (tracks r3-t05, r3-t06)

This commit is contained in:
aiongg
2026-10-24 07:12:16 +00:00
101 changed files with 4008 additions and 447 deletions
+33 -33
View File
@@ -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)
+33 -33
View File
@@ -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"
+13 -1
View File
@@ -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 `<occur>` 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. |
+88 -28
View File
@@ -84,7 +84,24 @@ help and feedback: https://discord.gg/artcraft"
);
fn main() -> ExitCode {
let args: Vec<String> = 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<String> = 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;
}
}
}
// `<command> --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<Arc<Vec<u8>>, 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<f32, String> {
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();
+86
View File
@@ -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}");
}
}
+5
View File
@@ -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
+189 -37
View File
@@ -180,23 +180,42 @@ fn interior_color(d: &Dict, fallback: Option<Rgb>) -> Option<Rgb> {
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<Vec<f64>> {
let a = o.as_array().filter(|a| !a.is_empty() && a.len() <= MAX_DASH)?;
let v: Vec<f64> = a.iter().map(Object::as_f64).collect::<Option<_>>()?;
(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::<Vec<_>>().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<Stream> {
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<Stream> {
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<f64> = s.as_array()?.iter().map(|o| o.as_f64()).collect::<Option<_>>()?;
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<Stream> {
}
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<Stream> {
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<Stream> {
// 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<Stream> {
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(
+22 -1
View File
@@ -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<String>,
/// 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<Summary> {
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,
});
}
+194 -2
View File
@@ -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<f64> = 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());
}
+5 -5
View File
@@ -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) => {
+14 -14
View File
@@ -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 {
+2 -2
View File
@@ -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() })?;
+4 -2
View File
@@ -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;
}
+1 -1
View File
@@ -701,7 +701,7 @@ impl Automation {
pub(crate) fn doc_import_data(&mut self, a: &Args) -> Result<Value> {
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) })?;
+66 -12
View File
@@ -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<Value> {
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<Option<pdfcraft_engine::MarkFile>> {
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<Value> {
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<pdfcraft_engine::CombineSource, ToolError> {
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::<Vec<_>>(),
"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<Vec<u8>> {
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<Vec<u8>> {
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);
}
}
+145 -9
View File
@@ -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, &params)) } else { self.dispatch(method, &params) };
let result = guarded_dispatch(method, || self.dispatch(method, &params));
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<u8>, max: usize) -> std::io::Result<LineRead> {
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<Value, (i64, String)>) -> Result<Value, (i64, String)> {
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<Value, (i64, String)>) -> Result<Value, (i64, String)> {
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<Content>) -> Value {
let failed = matches!(name, "command_batch" | "doc_batch")
&& content.iter().any(|c| match c {
@@ -509,7 +587,12 @@ fn call_result(content: Vec<Content>) -> 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<Value> = 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.");
+105 -36
View File
@@ -22,11 +22,72 @@ fn duplex(value: &str, cut_stack: bool) -> Result<Option<spool::Duplex>> {
})
}
/// [`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<Option<spool::Duplex>> {
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<String>) -> 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::<Vec<_>>().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<spool::Duplex>, paper: usize) -> Result<spool::Job> {
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::<Result<Vec<_>>>()?,
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<Value> {
let list: Vec<Value> = 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<Value> {
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::<Vec<_>>().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::<Result<Vec<_>>>()?,
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::<Vec<_>>(), "{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());
}
}
+2 -2
View File
@@ -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)?;
+1 -1
View File
@@ -543,7 +543,7 @@ pub fn tools() -> Vec<ToolDef> {
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!({
+50
View File
@@ -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());
}
+1
View File
@@ -2979,6 +2979,7 @@ fn comment_list(doc: &pdfcraft_cos::Document) -> Vec<pdfcraft_render::Annotation
locked: s.locked,
intent: s.intent,
fill_sign: s.fill_sign,
no_rotate: s.no_rotate,
points: s.points,
})
.collect()
+198
View File
@@ -196,6 +196,47 @@ fn rubber_stamps_point_left_as_displayed_on_rotated_pages() {
}
}
/// #2583, #1869: a sticky note placed on a turned page is drawn upright, with its top-left as
/// displayed where it was placed (the corner its `/Rect` keeps, as the NoRotate flag it is saved
/// with asks), exactly as on an unturned page, including after save. The comment list's
/// `shown_rect`, which the UI hit-tests, is that square.
#[test]
fn sticky_notes_stay_upright_where_they_are_placed_on_rotated_pages() {
// The displayed 20 × 20 pt square whose top-left is (100, 40), in device pixels at scale 2.
let crop = |rendered: &pdfcraft_render::RenderedPage| -> 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<u8>>, Vec<StampRect>) {
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<String> =
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<String> = (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<char> {
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<Vec<u8>> {
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<Vec<u8>> = (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<u8> {
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
}
+123 -16
View File
@@ -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() {
+119 -13
View File
@@ -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<ObjRef> =
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<ObjRef> = 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
+1
View File
@@ -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`;
+61 -16
View File
@@ -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));
+16 -1
View File
@@ -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<usize>, subset: Subset, reverse: bool) -> Result<Vec<usize>
}
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]);
}
}
+71 -1
View File
@@ -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::<f64>().unwrap() * points_per_unit, ph.parse::<f64>().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);
}
+62
View File
@@ -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);
+116
View File
@@ -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<OutlineIt
}
}
/// An annotation's `/F` as the renderer reads it: a real is truncated, and a value that isn't a
/// `u32` counts as no flags.
fn draw_flags(o: &Object) -> 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]
+85
View File
@@ -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
+40 -1
View File
@@ -1357,6 +1357,11 @@ fn classify_changes(doc: &Document, old: Option<Document>, p: Option<u8>) -> 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<ObjRef> = 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<ObjRef> =
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<Document>, p: Option<u8>) -> 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<ObjRef> =
before.as_deref().and_then(Object::as_array).map(|a| a.iter().filter_map(Object::as_ref).collect()).unwrap_or_default();
let kept: HashSet<ObjRef> = now.iter().filter_map(Object::as_ref).collect();
let removed: Vec<ObjRef> = was.iter().filter(|r| !kept.contains(r)).copied().collect();
if removed.is_empty() {
let added: Vec<ObjRef> = 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.
+94
View File
@@ -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(&ltv);
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};
+117 -7
View File
@@ -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<f32>; 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<egui::TextureId> {
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<f32>, 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 {
+1 -1
View File
@@ -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));
+3
View File
@@ -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" => {
+105 -14
View File
@@ -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<Edit> {
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<CanvasAction> {
/// `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<CanvasAction> {
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<CanvasAction>)| {
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);
}
});
+11 -2
View File
@@ -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| {
+23 -7
View File
@@ -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;
}
+4
View File
@@ -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),
+40 -6
View File
@@ -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 `<name> (page N).pdf`, and a single page the user renamed is `<name>.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<usize>, count: usize) -> Result<Vec<usize>, 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 });
}
}
+7
View File
@@ -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 إعادة تحميل
Can't render this file because it contains an unexpected character in line 6 and column 47.
+7
View File
@@ -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 Презареди
Can't render this file because it contains an unexpected character in line 8 and column 16.
+9
View File
@@ -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
Can't render this file because it contains an unexpected character in line 7 and column 3.
+6
View File
@@ -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}
Can't render this file because it contains an unexpected character in line 6 and column 47.
+7
View File
@@ -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.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 51.
+7
View File
@@ -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 Επαναφόρτωση
Can't render this file because it contains an unexpected character in line 482 and column 9.
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 60.
+74 -65
View File
@@ -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
Can't render this file because it contains an unexpected character in line 5 and column 47.
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 39.
+9
View File
@@ -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 दस्तावेज़ फिर से खोलने पर पिछला दृश्य (पृष्ठ, ज़ूम और पृष्ठ प्रदर्शन) पुनर्स्थापित करें
Can't render this file because it contains an unexpected character in line 855 and column 12.
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 7 and column 44.
+9
View File
@@ -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
Can't render this file because it contains an unexpected character in line 483 and column 12.
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+7
View File
@@ -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 再読み込み
Can't render this file because it contains an unexpected character in line 6 and column 47.
+7
View File
@@ -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 Қайта жүктеу
Can't render this file because it contains an unexpected character in line 860 and column 9.
+9
View File
@@ -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 문서를 다시 열 때 마지막 보기(페이지, 확대/축소, 페이지 표시) 복원
Can't render this file because it contains an unexpected character in line 855 and column 12.
+6
View File
@@ -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}
Can't render this file because it contains an unexpected character in line 6 and column 32.
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 406 and column 9.
+9
View File
@@ -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
Can't render this file because it contains an unexpected character in line 8 and column 16.
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 9 and column 16.
+9
View File
@@ -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
Can't render this file because it contains an unexpected character in line 3 and column 110.
+7
View File
@@ -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 Перезагрузить
Can't render this file because it contains an unexpected character in line 9 and column 16.
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 9 and column 16.
+9
View File
@@ -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
Can't render this file because it contains an unexpected character in line 9 and column 16.
+7
View File
@@ -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 మళ్లీ లోడ్ చేయి
Can't render this file because it contains an unexpected character in line 13 and column 16.
+9
View File
@@ -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 คืนค่ามุมมองล่าสุด (หน้า การซูม และการแสดงหน้า) เมื่อเปิดเอกสารอีกครั้ง
Can't render this file because it contains an unexpected character in line 866 and column 12.
+7
View File
@@ -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
Can't render this file because it contains an unexpected character in line 859 and column 9.
+7
View File
@@ -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 Перезавантажити
Can't render this file because it contains an unexpected character in line 6 and column 47.
+9
View File
@@ -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
Can't render this file because it contains an unexpected character in line 493 and column 12.
+7
View File
@@ -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 重新加载
Can't render this file because it contains an unexpected character in line 9 and column 16.
+7
View File
@@ -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 重新載入
Can't render this file because it contains an unexpected character in line 9 and column 16.
+5
View File
@@ -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 {
+129
View File
@@ -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<LastView> {
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<LastView> {
let mut out: Vec<LastView> = (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);
}
}
}
+40 -2
View File
@@ -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<RecentFile>,
/// 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<last_view::LastView>,
/// 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<last_session::LastSession>,
/// 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::<ThemePreference>(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() {
+218 -2
View File
@@ -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<PageUnit>,
/// 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<PageUnit>, 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<f64> {
let text = text.trim().replace(',', ".");
let n = match text.split_once([':', '/']) {
Some((one, n)) => n.trim().parse::<f64>().ok()? / one.trim().parse::<f64>().ok()?,
None => text.parse::<f64>().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<f64> = 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<f64> {
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<PageUnit>) {
(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);
}
}
+7
View File
@@ -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();
+32 -19
View File
@@ -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;
+29 -1
View File
@@ -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<PathBuf> = std::thread::scope(|s| {
let threads: Vec<_> = (0..8)
.map(|_| {
s.spawn(|| {
start.wait();
(0..25).map(|_| TestDir::new()).collect::<Vec<_>>()
})
})
.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);
+3 -2
View File
@@ -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,
}
+105 -1
View File
@@ -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::<Vec<_>>()
};
let ctx = egui::Context::default();
let frame = |events: Vec<egui::Event>, grab: Option<egui::ViewportCommand>| {
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]);
}
}
+167
View File
@@ -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<egui::Rect> {
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| {
+52
View File
@@ -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;
+137
View File
@@ -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<String> = 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<u8> {
@@ -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<String>| {
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]
+29
View File
@@ -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));
}
+54 -2
View File
@@ -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::<Vec<_>>()
};
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() {
+28
View File
@@ -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");
}
+26 -4
View File
@@ -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.
+5 -5
View File
@@ -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);
+138
View File
@@ -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<u8> {
let mut objs: Vec<Vec<u8>> = vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()];
let kids: Vec<String> = (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");
}
+48 -5
View File
@@ -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
+1 -1
View File
@@ -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();
-1
View File
@@ -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]
+1 -1
View File
@@ -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.
+5 -2
View File
@@ -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<br>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<br>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<br>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<br>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<br>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.<br>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.<br>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).<br>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).<br>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.<br>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<br>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<br>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. |
+1 -1
View File
@@ -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 |
+51 -27
View File
@@ -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"
+1 -1
View File
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More